This commit is contained in:
Michael Telatynski
2025-05-14 11:56:58 +01:00
parent 1985a351d9
commit 3c7bb885fd
8 changed files with 174 additions and 83 deletions
+2
View File
@@ -23,6 +23,8 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@vector-im/compound-design-tokens": "^4.0.3",
"@vector-im/compound-web": "^7.11.0",
"framer-motion": "^12.4.10", "framer-motion": "^12.4.10",
"styled-components": "^6.1.18", "styled-components": "^6.1.18",
"zod": "^3.24.2" "zod": "^3.24.2"
+2 -2
View File
@@ -25,12 +25,12 @@ interface Props {
} }
const Logo: FC<Props> = ({ api, src, href }) => { const Logo: FC<Props> = ({ api, src, href }) => {
const img = <Image alt={api.i18n.translate("Portal logo")} src={src} />; const img = <Image alt={api.i18n.translate("logo_alt")} src={src} />;
if (!href) return img; if (!href) return img;
return ( return (
<Anchor aria-label={api.i18n.translate("Show portal")} href={href}> <Anchor aria-label={api.i18n.translate("logo_link_label")} href={href}>
{img} {img}
</Anchor> </Anchor>
); );
+62 -29
View File
@@ -5,10 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { type FC, useState } from "react"; import { type FC, type JSX, useState } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import * as Dialog from "@radix-ui/react-dialog"; import * as Dialog from "@radix-ui/react-dialog";
import styled from "styled-components"; import styled from "styled-components";
import { InlineSpinner } from "@vector-im/compound-web";
import { StaticConfig } from "./config"; import { StaticConfig } from "./config";
import { theme } from "./theme"; import { theme } from "./theme";
@@ -16,7 +17,7 @@ import type { Api } from "@element-hq/element-web-module-api";
import Logo from "./Logo.tsx"; import Logo from "./Logo.tsx";
const Sidebar = styled(motion.div)` const Sidebar = styled(motion.div)`
padding: 16px 12px; padding: 16px 12px 0;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
overflow: auto; overflow: auto;
position: fixed; position: fixed;
@@ -105,6 +106,19 @@ const LinkLogo = styled.img`
background-color: #ffffff; background-color: #ffffff;
`; `;
const CentredContainer = styled.div`
display: flex;
height: 100%;
width: 100%;
align-items: center;
text-align: center;
font-weight: 600;
svg {
margin: 0 auto;
}
`;
const Overlay = styled(motion.div)` const Overlay = styled(motion.div)`
background-color: rgba(238, 239, 242, 0.5); background-color: rgba(238, 239, 242, 0.5);
position: fixed; position: fixed;
@@ -116,7 +130,7 @@ const Overlay = styled(motion.div)`
interface Props { interface Props {
api: Api; api: Api;
config: StaticConfig; config: StaticConfig | Error | null; // null for loading
fallbackLogoUrl: string; fallbackLogoUrl: string;
} }
@@ -140,10 +154,29 @@ const Category: FC<{
const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => { const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
let content: JSX.Element;
if (config instanceof Error) {
content = <CentredContainer>{api.i18n.translate("univention_error")}</CentredContainer>;
} else if (config) {
content = (
<>
{config.categories.map((category) => (
<Category key={category.name} data={category} />
))}
</>
);
} else {
content = (
<CentredContainer>
<InlineSpinner size={32} />
</CentredContainer>
);
}
return ( return (
<Dialog.Root open={open} onOpenChange={setOpen}> <Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild> <Dialog.Trigger asChild>
<Launcher aria-haspopup={true} aria-expanded={open} aria-label={api.i18n.translate("Show menu")}> <Launcher aria-haspopup={true} aria-expanded={open} aria-label={api.i18n.translate("trigger_label")}>
<svg fill="currentColor" height="16" width="16"> <svg fill="currentColor" height="16" width="16">
<path d="M0 4h4V0H0v4Zm6 12h4v-4H6v4Zm-6 0h4v-4H0v4Zm0-6h4V6H0v4Zm6 0h4V6H6v4Zm6-10v4h4V0h-4ZM6 4h4V0H6v4Zm6 6h4V6h-4v4Zm0 6h4v-4h-4v4Z" /> <path d="M0 4h4V0H0v4Zm6 12h4v-4H6v4Zm-6 0h4v-4H0v4Zm0-6h4V6H0v4Zm6 0h4V6H6v4Zm6-10v4h4V0h-4ZM6 4h4V0H6v4Zm6 6h4V6h-4v4Zm0 6h4v-4h-4v4Z" />
</svg> </svg>
@@ -168,33 +201,33 @@ const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: -WIDTH }} exit={{ x: -WIDTH }}
transition={{ type: "tween", ease: "easeInOut", duration: 0.3 }} transition={{ type: "tween", ease: "easeInOut", duration: 0.3 }}
aria-label={api.i18n.translate("Menu")} aria-label={api.i18n.translate("menu_label")}
> >
<SidebarHeading> <Dialog.Title>
<Logo api={api} src={config.logo_url ?? fallbackLogoUrl} /> <SidebarHeading>
<Dialog.Close asChild> <Logo api={api} src={config?.logo_url ?? fallbackLogoUrl} />
<CloseButton <Dialog.Close asChild>
aria-label={api.i18n.translate("Close menu")} <CloseButton
onClick={() => setOpen(false)} aria-label={api.i18n.translate("close_label")}
> onClick={() => setOpen(false)}
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
> >
<path <svg
d="M7.04167 13.9999L6 12.9583L8.9375 9.99992L6 7.06242L7.04167 6.02075L10 8.95825L12.9375 6.02075L13.9792 7.06242L11.0417 9.99992L13.9792 12.9583L12.9375 13.9999L10 11.0624L7.04167 13.9999Z" width="20"
fill="currentColor" height="20"
/> viewBox="0 0 20 20"
</svg> fill="none"
</CloseButton> xmlns="http://www.w3.org/2000/svg"
</Dialog.Close> >
</SidebarHeading> <path
{config.categories.map((category) => ( d="M7.04167 13.9999L6 12.9583L8.9375 9.99992L6 7.06242L7.04167 6.02075L10 8.95825L12.9375 6.02075L13.9792 7.06242L11.0417 9.99992L13.9792 12.9583L12.9375 13.9999L10 11.0624L7.04167 13.9999Z"
<Category key={category.name} data={category} /> fill="currentColor"
))} />
</svg>
</CloseButton>
</Dialog.Close>
</SidebarHeading>
</Dialog.Title>
{content}
</Sidebar> </Sidebar>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
@@ -21,30 +21,34 @@ interface Props {
const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => { const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [data, setData] = useState<StaticConfig | null>(); const [data, setData] = useState<StaticConfig | Error>();
const language = api.i18n.language.toLowerCase().startsWith("de") ? "de-DE" : "en"; const language = api.i18n.language.toLowerCase().startsWith("de") ? "de-DE" : "en";
useEffect(() => { useEffect(() => {
let discard = false; let discard = false;
setData(null); setData(undefined);
fetchNavigation(config.ics_url, language).then((data) => { fetchNavigation(config.ics_url, language)
if (discard) return; .then((data) => {
setData(data); if (discard) return;
}); setData(data);
})
.catch((error) => {
if (discard) return;
setData(error);
});
return (): void => { return (): void => {
discard = true; discard = true;
}; };
}, [config, language, loggedIn]); }, [config, language, loggedIn]);
if (!loggedIn) { return (
return <SilentLogin onLoggedIn={setLoggedIn} icsUrl={config.ics_url} />; <>
} {!loggedIn && <SilentLogin onLoggedIn={setLoggedIn} icsUrl={config.ics_url} />}
if (data) { <StaticMenu api={api} config={data ?? null} fallbackLogoUrl={config.logo_url ?? fallbackLogoUrl} />
return <StaticMenu api={api} config={data} fallbackLogoUrl={fallbackLogoUrl} />; </>
} );
return <div />;
}; };
export default Menu; export default Menu;
@@ -1,22 +1,26 @@
{ {
"Portal logo": { "logo_alt": {
"en": "Portal logo", "en": "Portal logo",
"de": "Portal Logo" "de": "Portal Logo"
}, },
"Menu": { "menu_label": {
"en": "Menu", "en": "Menu",
"de": "Menü" "de": "Menü"
}, },
"Show menu": { "trigger_label": {
"en": "Show menu", "en": "Show menu",
"de": "Menü anzeigen" "de": "Menü anzeigen"
}, },
"Close menu": { "close_label": {
"en": "Close menu", "en": "Close menu",
"de": "Menü schließen" "de": "Menü schließen"
}, },
"Show portal": { "logo_link_label": {
"en": "Show portal", "en": "Show portal",
"de": "Portal anzeigen" "de": "Portal anzeigen"
},
"univention_error": {
"en": "Failed to load data from Univention",
"de": "Daten konnten nicht von Univention geladen werden"
} }
} }
+78 -30
View File
@@ -11,6 +11,9 @@ import { ModuleConfig } from "../src/config.ts";
test.describe("Banner", () => { test.describe("Banner", () => {
test.use({ test.use({
displayName: "Timmy", displayName: "Timmy",
navigationJsonResolver: async ({}, use) => {
await use(Promise.withResolvers<void>());
},
page: async ({ context, page, moduleDir }, use) => { page: async ({ context, page, moduleDir }, use) => {
for (const path of ["logo.svg", "app1.png", "app2.png", "opendesk/"]) { for (const path of ["logo.svg", "app1.png", "app2.png", "opendesk/"]) {
await context.route(`/${path}*`, async (route) => { await context.route(`/${path}*`, async (route) => {
@@ -18,15 +21,6 @@ test.describe("Banner", () => {
await route.fulfill({ path: `${moduleDir}/tests/fixture/${file}` }); await route.fulfill({ path: `${moduleDir}/tests/fixture/${file}` });
}); });
} }
await context.route("http://localhost:8080/ics/navigation.json*", async (route) => {
await route.fulfill({
path: `${moduleDir}/tests/fixture/navigation.json`,
contentType: "application/json",
});
});
await context.route("http://localhost:8080/ics/silent", async (route) => {
await route.fulfill({ path: `${moduleDir}/tests/fixture/silent/index.html`, contentType: "text/html" });
});
await page.goto("/"); await page.goto("/");
await use(page); await use(page);
@@ -35,6 +29,7 @@ test.describe("Banner", () => {
test("should error if config is missing", { tag: ["@screenshot"] }, async ({ page }) => { test("should error if config is missing", { tag: ["@screenshot"] }, async ({ page }) => {
await expect(page.getByText("Your Element is misconfigured")).toBeVisible(); await expect(page.getByText("Your Element is misconfigured")).toBeVisible();
await expect(page.getByText("Errors in module configuration")).toBeVisible();
// We don't take a screenshot as we don't want to assert Element's styling, only our own // We don't take a screenshot as we don't want to assert Element's styling, only our own
}); });
@@ -50,8 +45,8 @@ test.describe("Banner", () => {
links: [ links: [
{ {
icon_uri: "http://localhost:8080/app1.png", icon_uri: "http://localhost:8080/app1.png",
name: "App 1", name: "E-Mail",
link_url: "https://example.com/app1", link_url: "https://example.com/email",
target: "app1", target: "app1",
}, },
{ {
@@ -96,20 +91,27 @@ test.describe("Banner", () => {
}, },
}); });
test("should render", { tag: ["@screenshot"] }, async ({ page, axe }) => { test.beforeEach(async ({ context, moduleDir, navigationJsonResolver }) => {
await context.route("http://localhost:8080/ics/navigation.json*", async (route) => {
await navigationJsonResolver.promise;
await route.fulfill({
path: `${moduleDir}/tests/fixture/navigation.json`,
contentType: "application/json",
});
});
await context.route("http://localhost:8080/ics/silent", async (route) => {
await route.fulfill({
path: `${moduleDir}/tests/fixture/silent/index.html`,
contentType: "text/html",
});
});
});
test("should render", { tag: ["@screenshot"] }, async ({ page, axe, navigationJsonResolver }) => {
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
await expect(page.getByLabel("Show portal")).toHaveAttribute("href", "https://example.com/portal"); await expect(page.getByLabel("Show portal")).toHaveAttribute("href", "https://example.com/portal");
const nav = page.locator("nav"); const nav = page.locator("nav");
if (type === "univention") {
await expect(nav).toMatchScreenshot(`${type}_nav_loading.png`);
// The stub silent html doesn't seem to work in Playwright so send the postMessage manually
await page.evaluate(() => {
window.postMessage({
loggedIn: true,
});
});
}
const trigger = page.getByLabel("Show menu"); const trigger = page.getByLabel("Show menu");
await expect(trigger).toBeVisible(); await expect(trigger).toBeVisible();
@@ -124,21 +126,28 @@ test.describe("Banner", () => {
await test.step("open menu", async () => { await test.step("open menu", async () => {
await trigger.click(); await trigger.click();
const app1 = page.getByText("App 1"); const sidebar = page.getByRole("dialog");
await expect(app1).toHaveAttribute("href", "https://example.com/app1");
await app1.hover(); if (type === "univention") {
await expect(sidebar).toMatchScreenshot(`${type}_menu_loading.png`);
await navigationJsonResolver.resolve();
}
const emailApp = page.getByText("E-Mail");
await expect(emailApp).toHaveAttribute("href", "https://example.com/email");
await emailApp.hover();
// Assert the sidebar looks as we expect // Assert the sidebar looks as we expect
const sidebar = page.getByRole("dialog"); await expect(axe).toHaveNoViolations();
await expect(sidebar).toMatchAriaSnapshot(); await expect(sidebar).toMatchAriaSnapshot();
await expect(page).toMatchScreenshot(`${type}_menu.png`, { await expect(page).toMatchScreenshot(`${type}_menu.png`, {
// We exclude this as we don't want to assert Element's styling, only our own // We exclude this as we don't want to assert Element's styling, only our own
css: ` css: `
#matrixchat { #matrixchat {
opacity: 0; opacity: 0;
background: orchid; background: orchid;
} }
`, `,
}); });
}); });
@@ -152,4 +161,43 @@ test.describe("Banner", () => {
}); });
}); });
} }
test.describe("univention config", () => {
test.use({
config: {
"io.element.element-web-modules.banner": {
logo_url: "http://localhost:8080/opendesk/logomark.svg",
logo_link_url: "https://example.com/portal",
menu: {
type: "univention",
logo_url: "http://localhost:8080/opendesk/logofull.svg",
ics_url: "http://localhost:8080/ics/",
},
},
},
});
test.beforeEach(async ({ context }) => {
await context.route("http://localhost:8080/ics/silent", async (route) => {
await route.fulfill({ status: 500 });
});
await context.route("http://localhost:8080/ics/navigation.json*", async (route) => {
await route.fulfill({ status: 500 });
});
});
test("should render error", { tag: ["@screenshot"] }, async ({ page, axe }) => {
await expect(page.getByRole("heading", { name: "Welcome to Element!" })).toBeVisible();
await expect(page.getByLabel("Show portal")).toHaveAttribute("href", "https://example.com/portal");
const nav = page.locator("nav");
const trigger = page.getByLabel("Show menu");
await trigger.click();
const sidebar = page.getByRole("dialog");
await expect(sidebar.getByText("Failed to load")).toBeVisible();
await expect(sidebar).toMatchScreenshot("univention_error.png");
});
});
}); });
@@ -36,7 +36,7 @@
"identifier": "app5", "identifier": "app5",
"display_name": "E-Mail", "display_name": "E-Mail",
"icon_url": "http://localhost:8080/opendesk/app5.svg", "icon_url": "http://localhost:8080/opendesk/app5.svg",
"link": "https://example.com/app5", "link": "https://example.com/email",
"target": "_blank" "target": "_blank"
}, },
{ {
@@ -5,9 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Univention Silent Login</title> <title>Univention Silent Login</title>
<script type="text/javascript"> <script type="text/javascript">
window.postMessage({ // window.postMessage({
loggedIn: true, // loggedIn: true,
}); // });
</script> </script>
</head> </head>
<body></body> <body></body>