diff --git a/modules/banner/element-web/package.json b/modules/banner/element-web/package.json index 2c5076379d..6aa9b89e56 100644 --- a/modules/banner/element-web/package.json +++ b/modules/banner/element-web/package.json @@ -23,6 +23,8 @@ }, "dependencies": { "@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", "styled-components": "^6.1.18", "zod": "^3.24.2" diff --git a/modules/banner/element-web/src/Logo.tsx b/modules/banner/element-web/src/Logo.tsx index 0866bf802c..d515cfdb4e 100644 --- a/modules/banner/element-web/src/Logo.tsx +++ b/modules/banner/element-web/src/Logo.tsx @@ -25,12 +25,12 @@ interface Props { } const Logo: FC = ({ api, src, href }) => { - const img = {api.i18n.translate("Portal; + const img = {api.i18n.translate("logo_alt")}; if (!href) return img; return ( - + {img} ); diff --git a/modules/banner/element-web/src/Menu.tsx b/modules/banner/element-web/src/Menu.tsx index 033bc78f9d..df6771a56d 100644 --- a/modules/banner/element-web/src/Menu.tsx +++ b/modules/banner/element-web/src/Menu.tsx @@ -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. */ -import { type FC, useState } from "react"; +import { type FC, type JSX, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import * as Dialog from "@radix-ui/react-dialog"; import styled from "styled-components"; +import { InlineSpinner } from "@vector-im/compound-web"; import { StaticConfig } from "./config"; import { theme } from "./theme"; @@ -16,7 +17,7 @@ import type { Api } from "@element-hq/element-web-module-api"; import Logo from "./Logo.tsx"; const Sidebar = styled(motion.div)` - padding: 16px 12px; + padding: 16px 12px 0; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); overflow: auto; position: fixed; @@ -105,6 +106,19 @@ const LinkLogo = styled.img` 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)` background-color: rgba(238, 239, 242, 0.5); position: fixed; @@ -116,7 +130,7 @@ const Overlay = styled(motion.div)` interface Props { api: Api; - config: StaticConfig; + config: StaticConfig | Error | null; // null for loading fallbackLogoUrl: string; } @@ -140,10 +154,29 @@ const Category: FC<{ const Menu: FC = ({ api, config, fallbackLogoUrl }) => { const [open, setOpen] = useState(false); + let content: JSX.Element; + if (config instanceof Error) { + content = {api.i18n.translate("univention_error")}; + } else if (config) { + content = ( + <> + {config.categories.map((category) => ( + + ))} + + ); + } else { + content = ( + + + + ); + } + return ( - + @@ -168,33 +201,33 @@ const Menu: FC = ({ api, config, fallbackLogoUrl }) => { animate={{ x: 0 }} exit={{ x: -WIDTH }} transition={{ type: "tween", ease: "easeInOut", duration: 0.3 }} - aria-label={api.i18n.translate("Menu")} + aria-label={api.i18n.translate("menu_label")} > - - - - setOpen(false)} - > - + + + + setOpen(false)} > - - - - - - {config.categories.map((category) => ( - - ))} + + + + + + + + {content} diff --git a/modules/banner/element-web/src/Univention/Menu.tsx b/modules/banner/element-web/src/Univention/Menu.tsx index 690b03e94c..7416c99232 100644 --- a/modules/banner/element-web/src/Univention/Menu.tsx +++ b/modules/banner/element-web/src/Univention/Menu.tsx @@ -21,30 +21,34 @@ interface Props { const Menu: FC = ({ api, config, fallbackLogoUrl }) => { const [loggedIn, setLoggedIn] = useState(false); - const [data, setData] = useState(); + const [data, setData] = useState(); const language = api.i18n.language.toLowerCase().startsWith("de") ? "de-DE" : "en"; useEffect(() => { let discard = false; - setData(null); - fetchNavigation(config.ics_url, language).then((data) => { - if (discard) return; - setData(data); - }); + setData(undefined); + fetchNavigation(config.ics_url, language) + .then((data) => { + if (discard) return; + setData(data); + }) + .catch((error) => { + if (discard) return; + setData(error); + }); return (): void => { discard = true; }; }, [config, language, loggedIn]); - if (!loggedIn) { - return ; - } - if (data) { - return ; - } - return
; + return ( + <> + {!loggedIn && } + + + ); }; export default Menu; diff --git a/modules/banner/element-web/src/translations.json b/modules/banner/element-web/src/translations.json index a177686956..be2627e3aa 100644 --- a/modules/banner/element-web/src/translations.json +++ b/modules/banner/element-web/src/translations.json @@ -1,22 +1,26 @@ { - "Portal logo": { + "logo_alt": { "en": "Portal logo", "de": "Portal Logo" }, - "Menu": { + "menu_label": { "en": "Menu", "de": "Menü" }, - "Show menu": { + "trigger_label": { "en": "Show menu", "de": "Menü anzeigen" }, - "Close menu": { + "close_label": { "en": "Close menu", "de": "Menü schließen" }, - "Show portal": { + "logo_link_label": { "en": "Show portal", "de": "Portal anzeigen" + }, + "univention_error": { + "en": "Failed to load data from Univention", + "de": "Daten konnten nicht von Univention geladen werden" } } diff --git a/modules/banner/element-web/tests/banner.spec.ts b/modules/banner/element-web/tests/banner.spec.ts index 4795b67a6a..41216f91e5 100644 --- a/modules/banner/element-web/tests/banner.spec.ts +++ b/modules/banner/element-web/tests/banner.spec.ts @@ -11,6 +11,9 @@ import { ModuleConfig } from "../src/config.ts"; test.describe("Banner", () => { test.use({ displayName: "Timmy", + navigationJsonResolver: async ({}, use) => { + await use(Promise.withResolvers()); + }, page: async ({ context, page, moduleDir }, use) => { for (const path of ["logo.svg", "app1.png", "app2.png", "opendesk/"]) { await context.route(`/${path}*`, async (route) => { @@ -18,15 +21,6 @@ test.describe("Banner", () => { 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 use(page); @@ -35,6 +29,7 @@ test.describe("Banner", () => { test("should error if config is missing", { tag: ["@screenshot"] }, async ({ page }) => { 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 }); @@ -50,8 +45,8 @@ test.describe("Banner", () => { links: [ { icon_uri: "http://localhost:8080/app1.png", - name: "App 1", - link_url: "https://example.com/app1", + name: "E-Mail", + link_url: "https://example.com/email", 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.getByLabel("Show portal")).toHaveAttribute("href", "https://example.com/portal"); 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"); await expect(trigger).toBeVisible(); @@ -124,21 +126,28 @@ test.describe("Banner", () => { await test.step("open menu", async () => { await trigger.click(); - const app1 = page.getByText("App 1"); - await expect(app1).toHaveAttribute("href", "https://example.com/app1"); - await app1.hover(); + const sidebar = page.getByRole("dialog"); + + 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 - const sidebar = page.getByRole("dialog"); + await expect(axe).toHaveNoViolations(); await expect(sidebar).toMatchAriaSnapshot(); await expect(page).toMatchScreenshot(`${type}_menu.png`, { // We exclude this as we don't want to assert Element's styling, only our own css: ` - #matrixchat { - opacity: 0; - background: orchid; - } - `, + #matrixchat { + opacity: 0; + 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"); + }); + }); }); diff --git a/modules/banner/element-web/tests/fixture/navigation.json b/modules/banner/element-web/tests/fixture/navigation.json index 7c05eca422..7365ff1371 100644 --- a/modules/banner/element-web/tests/fixture/navigation.json +++ b/modules/banner/element-web/tests/fixture/navigation.json @@ -36,7 +36,7 @@ "identifier": "app5", "display_name": "E-Mail", "icon_url": "http://localhost:8080/opendesk/app5.svg", - "link": "https://example.com/app5", + "link": "https://example.com/email", "target": "_blank" }, { diff --git a/modules/banner/element-web/tests/fixture/silent/index.html b/modules/banner/element-web/tests/fixture/silent/index.html index ae80633f60..84072225b1 100644 --- a/modules/banner/element-web/tests/fixture/silent/index.html +++ b/modules/banner/element-web/tests/fixture/silent/index.html @@ -5,9 +5,9 @@ Univention Silent Login