diff --git a/modules/banner/README.md b/modules/banner/README.md new file mode 100644 index 0000000000..c62890c417 --- /dev/null +++ b/modules/banner/README.md @@ -0,0 +1,45 @@ +# @element-hq/element-web-module-banner + +Banner module for Element Web. +Allows rendering a top bar with slide out left panel menu. + +Supports the following configuration options: + +| Key | Type | Description | +| ------------- | ------ | ------------------------------------------------------------ | +| logo_url | string | URL to the logo to render in the banner | +| logo_link_url | string | URL to send the user to when clicking the logo in the banner | +| menu | `Menu` | Data to render in the banner menu | + +The `Menu` type is fulfilled by the following discriminated union: + +### Univention menu + +| Key | Type | Description | +| ------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ | +| type | "univention" | The type for this menu config | +| ics_url | string | URL to the UCS Intercom Service, https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints | + +### Static menu + +| Key | Type | Description | +| ---------- | ---------------- | ------------------------------------------------------------------------- | +| type | "static" | The type for this menu config | +| logo_url | string, optional | URL to the logo to render in the menu, defaults to banner logo if omitted | +| categories | `[]Category` | Categories to render in the menu | + +The `Category` type is fulfilled by the following interface: + +| Key | Type | Description | +| ----- | -------- | -------------------------------- | +| name | string | The name of this category | +| links | `[]Link` | Links to render in this category | + +The `Link` type is fulfilled by the following interface: + +| Key | Type | Description | +| -------- | ---------------- | --------------------------------------- | +| icon_uri | string | URL to the icon to render for this link | +| name | string | The name to render for this link | +| link_url | string | The URL to link to | +| target | string, optional | The `target` to use for this link | diff --git a/modules/banner/element-web/package.json b/modules/banner/element-web/package.json new file mode 100644 index 0000000000..f852df5db3 --- /dev/null +++ b/modules/banner/element-web/package.json @@ -0,0 +1,33 @@ +{ + "name": "@element-hq/element-web-module-banner", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "scripts": { + "prepare": "vite build", + "lint:types": "tsc --noEmit", + "lint:codestyle": "echo 'handled by lint:eslint'", + "test": "echo no tests yet" + }, + "devDependencies": { + "@element-hq/element-web-module-api": "^1.0.0", + "@types/node": "^22.10.7", + "@types/react": "^19", + "@vitejs/plugin-react": "^4.3.4", + "react": "^19", + "rollup-plugin-external-globals": "^0.13.0", + "typescript": "^5.7.3", + "vite": "^6.0.11", + "vite-plugin-node-polyfills": "^0.23.0", + "vite-plugin-svgr": "^4.3.0" + }, + "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/Banner.tsx b/modules/banner/element-web/src/Banner.tsx new file mode 100644 index 0000000000..81193fbaa2 --- /dev/null +++ b/modules/banner/element-web/src/Banner.tsx @@ -0,0 +1,64 @@ +/* +Copyright 2025 New Vector Ltd. + +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 } from "react"; +import styled from "styled-components"; +import { type Api } from "@element-hq/element-web-module-api"; +import { Heading } from "@vector-im/compound-web"; + +import { type ModuleConfig } from "./config"; +import UniventionMenu from "./Univention/Menu"; +import Menu from "./Menu"; +import Logo from "./Logo.tsx"; + +const Root = styled.nav` + height: ${({ theme }): string => theme.bannerHeight}; + background-color: ${({ theme }): string => theme.bannerBackgroundColor}; + border-bottom: "var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary)"; + display: flex; + gap: var(--cpd-space-3x); +`; + +const LogoContainer = styled.div` + display: flex; + padding: var(--cpd-space-3x) 0; +`; + +interface Props { + api: Api; + logoUrl: string; + href: string; + menu: ModuleConfig["menu"]; +} + +const Banner: FC = ({ api, logoUrl, href, menu }) => { + let menuJsx; + switch (menu.type) { + case "static": { + menuJsx = ; + break; + } + case "univention": { + menuJsx = ; + break; + } + } + + return ( + + {menuJsx} + + + + + {api.config.get("brand")} + + + ); +}; + +export default Banner; diff --git a/modules/banner/element-web/src/Logo.tsx b/modules/banner/element-web/src/Logo.tsx new file mode 100644 index 0000000000..485f5afbbf --- /dev/null +++ b/modules/banner/element-web/src/Logo.tsx @@ -0,0 +1,42 @@ +/* +Copyright 2025 New Vector Ltd. + +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 } from "react"; +import styled from "styled-components"; +import { type Api } from "@element-hq/element-web-module-api"; + +const Anchor = styled.a` + display: flex; +`; + +const Image = styled.img<{ + height?: string; +}>` + align-self: center; + height: ${({ height }): string => height ?? "32px"}; +`; + +interface Props { + api: Api; + src: string; + height?: string; + href?: string; +} + +const Logo: FC = ({ api, src, href, height }) => { + const img = {api.i18n.translate("logo_alt")}; + + if (!href) return img; + + return ( + + {img} + + ); +}; + +export default Logo; diff --git a/modules/banner/element-web/src/Menu.tsx b/modules/banner/element-web/src/Menu.tsx new file mode 100644 index 0000000000..4649f64a77 --- /dev/null +++ b/modules/banner/element-web/src/Menu.tsx @@ -0,0 +1,261 @@ +/* +Copyright 2025 New Vector Ltd. + +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, type JSX, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import * as Dialog from "@radix-ui/react-dialog"; +import styled, { useTheme } from "styled-components"; +import { InlineSpinner } from "@vector-im/compound-web"; + +import { StaticConfig } from "./config"; +import type { Api } from "@element-hq/element-web-module-api"; +import Logo from "./Logo.tsx"; +import TriggerIcon from "./trigger.svg?react"; + +const Sidebar = styled(motion.div)` + padding: 0 var(--cpd-space-3x) var(--cpd-space-4x); + box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1); + overflow: auto; + position: fixed; + left: 0; + top: 0; + height: 100%; + width: ${({ theme }): string => theme.menuWidth}; + background: ${({ theme }): string => theme.menuBackgroundColor}; + border-radius: 0 16px 16px 0; +`; + +const SidebarHeading = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--cpd-space-5x); +`; + +const Trigger = styled.button` + align-items: center; + border: none; + cursor: pointer; + display: flex; + background-color: ${({ theme }): string => theme.triggerBackgroundColor}; + color: ${({ theme }): string => theme.triggerColor}; + width: ${({ theme }): string => theme.triggerWidth}; + + &:hover, + &:focus { + background-color: ${({ theme }): string => theme.triggerBackgroundColorHover}; + color: ${({ theme }): string => theme.triggerColorContrast}; + } + + &:active, + &[data-expanded="true"] { + background-color: ${({ theme }): string => theme.triggerBackgroundColorPressed}; + color: ${({ theme }): string => theme.triggerColorContrast}; + } + + svg { + margin: auto; + } +`; + +const CloseButton = styled.button` + /* Reset button styles */ + appearance: none; + background: none; + border: none; + padding: 0; + margin: 0; + + height: 32px; + width: 32px; + cursor: pointer; + border-radius: 8px; + + &:hover, + &:focus { + background-color: ${({ theme }): string => theme.menuButtonBackgroundColorHover}; + } + + &:active { + background-color: ${({ theme }): string => theme.menuButtonBackgroundColorPressed}; + } +`; + +const CategoryHeading = styled.h2` + font-weight: 700; + font-size: 12px; + color: ${({ theme }): string => theme.subheadingColor}; + margin-top: var(--cpd-space-4x); + margin-bottom: var(--cpd-space-2x); +`; + +const LinkButton = styled.a` + font-size: 14px; + color: var(--cpd-color-text-action-primary); + font-weight: var(--cpd-font-weight-medium); + display: flex; + border-radius: 8px; + padding: var(--cpd-space-2x); + align-items: center; + + &:link { + color: var(--cpd-color-text-action-primary); + } + + &:hover, + &:focus { + background-color: ${({ theme }): string => theme.menuButtonBackgroundColorHover}; + } + + &:active { + background-color: ${({ theme }): string => theme.menuButtonBackgroundColorPressed}; + } +`; + +const LinkLogo = styled.img` + height: 24px; + width: 24px; + border-radius: 4px; + border: ${({ theme }): string => `var(--cpd-border-width-1) solid ${theme.menuButtonBackgroundColorPressed}`}; + background-color: ${({ theme }): string => theme.menuBackgroundColor}; + margin-right: var(--cpd-space-2x); +`; + +const CentredContainer = styled.div` + display: flex; + height: 100%; + width: 100%; + align-items: center; + text-align: center; + font-weight: var(--cpd-font-weight-semibold); + + svg { + margin: 0 auto; + } +`; + +const Overlay = styled(motion.div)` + background-color: rgba(238, 239, 242, 0.5); + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; +`; + +interface Props { + api: Api; + config: StaticConfig | Error | null; // null for loading + fallbackLogoUrl: string; +} + +const Category: FC<{ + data: StaticConfig["categories"][number]; +}> = ({ data }) => { + return ( +
+ {data.name} + {data.links.map((link) => ( + + {link.name} + + ))} +
+ ); +}; + +const Menu: FC = ({ api, config, fallbackLogoUrl }) => { + const theme = useTheme(); + const width = parseInt(theme.menuWidth.slice(0, -2), 10); + const [open, setOpen] = useState(false); + + let content: JSX.Element; + let logoUrl = fallbackLogoUrl; + if (config instanceof Error) { + content = {api.i18n.translate("univention_error")}; + } else if (config) { + content = ( + <> + {config.categories.map((category) => ( + + ))} + + ); + if (config.logo_url) { + logoUrl = config.logo_url; + } + } else { + content = ( + + + + ); + } + + return ( + + + + + + + + + {open && ( + + + + + + + + + + + setOpen(false)} + > + + + + + + + + {content} + + + + )} + + + ); +}; + +export default Menu; diff --git a/modules/banner/element-web/src/Univention/Menu.tsx b/modules/banner/element-web/src/Univention/Menu.tsx new file mode 100644 index 0000000000..7416c99232 --- /dev/null +++ b/modules/banner/element-web/src/Univention/Menu.tsx @@ -0,0 +1,54 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { FC, useEffect, useState } from "react"; + +import type { Api } from "@element-hq/element-web-module-api"; +import { StaticConfig, UniventionConfig } from "../config"; +import StaticMenu from "../Menu"; +import { fetchNavigation } from "./navigation"; +import SilentLogin from "./SilentLogin"; + +interface Props { + api: Api; + config: UniventionConfig; + fallbackLogoUrl: string; +} + +const Menu: FC = ({ api, config, fallbackLogoUrl }) => { + const [loggedIn, setLoggedIn] = useState(false); + const [data, setData] = useState(); + const language = api.i18n.language.toLowerCase().startsWith("de") ? "de-DE" : "en"; + + useEffect(() => { + let discard = false; + + 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]); + + return ( + <> + {!loggedIn && } + + + ); +}; + +export default Menu; diff --git a/modules/banner/element-web/src/Univention/SilentLogin.tsx b/modules/banner/element-web/src/Univention/SilentLogin.tsx new file mode 100644 index 0000000000..46e0f39eb0 --- /dev/null +++ b/modules/banner/element-web/src/Univention/SilentLogin.tsx @@ -0,0 +1,40 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { FC, useEffect } from "react"; +import styled from "styled-components"; + +const HiddenIFrame = styled.iframe` + display: none; +`; + +interface Props { + icsUrl: string; + onLoggedIn(success: boolean): void; +} + +const SilentLogin: FC = ({ onLoggedIn, icsUrl }) => { + const url = new URL("silent", icsUrl); + + useEffect(() => { + const listener = (event: MessageEvent): void => { + if (event.origin === url.origin && typeof event.data === "object" && event.data["loggedIn"] === true) { + onLoggedIn(true); + } + }; + + window.addEventListener("message", listener); + + return (): void => { + window.removeEventListener("message", listener); + }; + }, [onLoggedIn, url.origin]); + + return ; +}; + +export default SilentLogin; diff --git a/modules/banner/element-web/src/Univention/navigation.ts b/modules/banner/element-web/src/Univention/navigation.ts new file mode 100644 index 0000000000..96b035d3ed --- /dev/null +++ b/modules/banner/element-web/src/Univention/navigation.ts @@ -0,0 +1,86 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { z } from "zod"; + +import { StaticConfig } from "../config"; + +/** + * Univention Central Navigation API + * https://docs.software-univention.de/nubus-kubernetes-customization/1.x/en/api/central-navigation.html + */ +const UniventionCentralNavigation = z.object({ + categories: z.array( + z.object({ + identifier: z.string(), + display_name: z.string(), + entries: z.array( + z.object({ + /** + * A unique identifier for the navigation item. + */ + identifier: z.string(), + /** + * The URL to the icon in SVG format. + */ + icon_url: z.string(), + /** + * The label of the link. + */ + display_name: z.string(), + /** + * The destination URL of the link. + */ + link: z.string(), + /** + * The browsing context in which the browser opens the link. + * Corresponds to the `target` property of `` tags in HTML. + */ + target: z.string(), + /** + * It’s usually empty. + */ + keywords: z.object({}).optional(), + }), + ), + }), + ), +}); + +type UniventionCentralNavigation = z.infer; + +function navigationToConfig(navigation: UniventionCentralNavigation): StaticConfig { + return { + type: "static", + categories: navigation.categories.map((category) => ({ + name: category.display_name, + links: category.entries.map((entry) => ({ + icon_uri: entry.icon_url, + name: entry.display_name, + link_url: entry.link, + target: entry.target, + })), + })), + }; +} + +export async function fetchNavigation(icsUrl: string, language: string): Promise { + const url = new URL("navigation.json", icsUrl); + url.search = `?language=${language}`; + + const response = await fetch(url, { + credentials: "include", + }); + + if (!response.ok) { + throw new Error(`Failed to fetch navigation: ${response.status}`); + } + + const data = await response.json(); + const config = await UniventionCentralNavigation.parseAsync(data); + return navigationToConfig(config); +} diff --git a/modules/banner/element-web/src/config.ts b/modules/banner/element-web/src/config.ts new file mode 100644 index 0000000000..1066a85bc2 --- /dev/null +++ b/modules/banner/element-web/src/config.ts @@ -0,0 +1,111 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { z, ZodSchema, ZodTypeDef } from "zod"; + +import { Theme } from "./theme.ts"; + +const StaticConfig = z.object({ + type: z.literal("static"), + + /** + * Alternative logo URL to display in the popover menu. + * Will use the main logo url if omitted. + */ + logo_url: z.string().url().optional(), + + /** + * Categories of links to display in the menu. + */ + categories: z.array( + z.object({ + /** + * The category display name + */ + name: z.string(), + /** + * List of links to display in the category + */ + links: z.array( + z.object({ + /** + * The URL to the icon. + */ + icon_uri: z.string().url(), + /** + * The label of the link. + */ + name: z.string(), + /** + * The destination URL of the link. + */ + link_url: z.string().url(), + /** + * The browsing context in which the browser opens the link. + */ + target: z.string().optional(), + }), + ), + }), + ), +}); + +export type StaticConfig = z.infer; + +const UniventionConfig = z.object({ + type: z.literal("univention"), + + /** + * Alternative logo URL to display in the popover menu. + * Will use the main logo url if omitted. + */ + logo_url: z.string().url().optional(), + + /** + * Base URL to an Intercom Service + * https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints + */ + ics_url: z.string().url(), +}); + +export type UniventionConfig = z.infer; + +export const ModuleConfig = z.object({ + /** + * The URL of the portal logo.svg file. + * @example `https://example.com/logo.svg` + */ + logo_url: z.string().url(), + + /** + * The URL of the portal. + * @example `https://example.com` + */ + logo_link_url: z.string().url(), + + /** + * Configuration for the menu. + */ + menu: z.discriminatedUnion("type", [StaticConfig, UniventionConfig]), + + /** + * Theme variable overrides, optional. + */ + theme: Theme.default({}), +}); + +export type ModuleConfig = z.infer; + +export type ConfigSchema = ZodSchema, ZodTypeDef, z.input>; + +export const CONFIG_KEY = "io.element.element-web-modules.banner"; + +declare module "@element-hq/element-web-module-api" { + export interface Config { + [CONFIG_KEY]: ConfigSchema["_input"]; + } +} diff --git a/modules/banner/element-web/src/index.tsx b/modules/banner/element-web/src/index.tsx new file mode 100644 index 0000000000..f6baa9c896 --- /dev/null +++ b/modules/banner/element-web/src/index.tsx @@ -0,0 +1,50 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { ThemeProvider } from "styled-components"; + +import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api"; +import Translations from "./translations.json"; +import { ModuleConfig, CONFIG_KEY } from "./config"; +import Banner from "./Banner"; +import { name as ModuleName } from "../package.json"; + +class BannerModule implements Module { + public static readonly moduleApiVersion = "^1.0.0"; + + private config?: ModuleConfig; + + public constructor(private api: Api) {} + + public async load(): Promise { + this.api.i18n.register(Translations); + + try { + this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY)); + } catch (e) { + console.error("Failed to init module", e); + throw new Error(`Errors in module configuration for "${ModuleName}"`); + } + + const div = document.createElement("div"); + this.api.rootNode.before(div); + + const root = this.api.createRoot(div); + root.render( + + + , + ); + } +} + +export default BannerModule satisfies ModuleFactory; diff --git a/modules/banner/element-web/src/theme.ts b/modules/banner/element-web/src/theme.ts new file mode 100644 index 0000000000..aadc572c3f --- /dev/null +++ b/modules/banner/element-web/src/theme.ts @@ -0,0 +1,32 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { z } from "zod"; + +export const Theme = z.object({ + textColor: z.string().default("var(--cpd-color-text-primary)"), + subheadingColor: z.string().default("var(--cpd-color-text-secondary)"), + bannerBackgroundColor: z.string().default("var(--cpd-color-bg-canvas-default)"), + bannerHeight: z.string().default("60px"), + triggerWidth: z.string().default("68px"), + triggerBackgroundColor: z.string().default("var(--cpd-color-bg-subtle-secondary)"), + triggerBackgroundColorHover: z.string().default("var(--cpd-color-bg-accent-hovered)"), + triggerBackgroundColorPressed: z.string().default("var(--cpd-color-bg-accent-pressed)"), + triggerColor: z.string().default("var(--cpd-color-icon-primary)"), + triggerColorContrast: z.string().default("var(--cpd-color-icon-on-solid-primary)"), + menuWidth: z.string().default("320px"), + menuBackgroundColor: z.string().default("var(--cpd-color-bg-canvas-default)"), + menuButtonBackgroundColorHover: z.string().default("var(--cpd-color-bg-action-secondary-hovered)"), + menuButtonBackgroundColorPressed: z.string().default("var(--cpd-color-bg-action-secondary-pressed)"), +}); + +export type Theme = z.infer; + +declare module "styled-components" { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + export interface DefaultTheme extends Theme {} +} diff --git a/modules/banner/element-web/src/translations.json b/modules/banner/element-web/src/translations.json new file mode 100644 index 0000000000..be2627e3aa --- /dev/null +++ b/modules/banner/element-web/src/translations.json @@ -0,0 +1,26 @@ +{ + "logo_alt": { + "en": "Portal logo", + "de": "Portal Logo" + }, + "menu_label": { + "en": "Menu", + "de": "Menü" + }, + "trigger_label": { + "en": "Show menu", + "de": "Menü anzeigen" + }, + "close_label": { + "en": "Close menu", + "de": "Menü schließen" + }, + "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/src/trigger.svg b/modules/banner/element-web/src/trigger.svg new file mode 100644 index 0000000000..72273ad287 --- /dev/null +++ b/modules/banner/element-web/src/trigger.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/modules/banner/element-web/src/vite-env.d.ts b/modules/banner/element-web/src/vite-env.d.ts new file mode 100644 index 0000000000..ef030aec3d --- /dev/null +++ b/modules/banner/element-web/src/vite-env.d.ts @@ -0,0 +1,8 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/// diff --git a/modules/banner/element-web/tests/banner.spec.ts b/modules/banner/element-web/tests/banner.spec.ts new file mode 100644 index 0000000000..fe60dfc3a4 --- /dev/null +++ b/modules/banner/element-web/tests/banner.spec.ts @@ -0,0 +1,213 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { test as base, expect } from "../../../../playwright/element-web-test.ts"; +import { type ConfigSchema } from "../src/config.ts"; + +const test = base.extend<{ + // Resolver for when to respond to the navigation.json request + navigationJsonResolver: PromiseWithResolvers; +}>({ + navigationJsonResolver: async ({}, use) => { + await use(Promise.withResolvers()); + }, +}); + +test.describe("Banner", () => { + test.use({ + displayName: "Timmy", + page: async ({ context, page, moduleDir }, use) => { + for (const path of ["logo.svg", "app1.png", "app2.png", "opendesk/"]) { + await context.route(`/${path}*`, async (route) => { + const file = new URL(route.request().url()).pathname; + await route.fulfill({ path: `${moduleDir}/tests/fixture/${file}` }); + }); + } + + await page.goto("/"); + await use(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("Errors in module configuration")).toBeVisible(); + // We don't take a screenshot as we don't want to assert Element's styling, only our own + }); + + const configs: ConfigSchema["_input"][] = [ + { + logo_url: "http://localhost:8080/logo.svg", + logo_link_url: "https://example.com/portal", + menu: { + type: "static", + categories: [ + { + name: "Applications", + links: [ + { + icon_uri: "http://localhost:8080/app1.png", + name: "E-Mail", + link_url: "https://example.com/email", + target: "app1", + }, + { + icon_uri: "http://localhost:8080/app2.png", + name: "Riot", + link_url: "https://riot.im/app", + target: "riot-im", + }, + ], + }, + { + name: "Links", + links: [ + { + icon_uri: "http://localhost:8080/app1.png", + name: "Link", + link_url: "https://example.com/link1", + }, + ], + }, + ], + }, + }, + { + 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/", + }, + theme: { + triggerBackgroundColorHover: "#571EFA", + triggerBackgroundColorPressed: "#4519C2", + }, + }, + ]; + + for (const config of configs) { + const type = config.menu.type; + + test.describe(`${type} config`, () => { + test.use({ + config: { + "io.element.element-web-modules.banner": config, + }, + }); + + 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"); + + const trigger = page.getByLabel("Show menu"); + await expect(trigger).toBeVisible(); + + // Assert the banner looks as we expect + await expect(nav).toMatchAriaSnapshot(); + await expect(nav).toMatchScreenshot(`${type}_nav.png`); + + // Check hover styles + await trigger.hover(); + await expect(nav).toMatchScreenshot(`${type}_nav_hover.png`); + + await test.step("open menu", async () => { + await trigger.click(); + const sidebar = page.getByRole("dialog"); + + if (type === "univention") { + await expect(sidebar).toMatchScreenshot(`${type}_menu_loading.png`); + navigationJsonResolver.resolve(); + } + await page.pause(); + + 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 + 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; + } + `, + }); + }); + + await test.step("close menu", async () => { + await expect(axe).toHaveNoViolations(); + + // Assert it closes by clicking the overlay + await page.getByTestId("dialog-overlay").click(); + await expect(page.getByRole("dialog")).not.toBeVisible(); + }); + }); + }); + } + + 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 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"); + await expect(axe).toHaveNoViolations(); + }); + }); +}); diff --git a/modules/banner/element-web/tests/fixture/app1.png b/modules/banner/element-web/tests/fixture/app1.png new file mode 100644 index 0000000000..2c34416b88 Binary files /dev/null and b/modules/banner/element-web/tests/fixture/app1.png differ diff --git a/modules/banner/element-web/tests/fixture/app2.png b/modules/banner/element-web/tests/fixture/app2.png new file mode 100644 index 0000000000..ee42954c78 Binary files /dev/null and b/modules/banner/element-web/tests/fixture/app2.png differ diff --git a/modules/banner/element-web/tests/fixture/logo.svg b/modules/banner/element-web/tests/fixture/logo.svg new file mode 100644 index 0000000000..90c502cacf --- /dev/null +++ b/modules/banner/element-web/tests/fixture/logo.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/banner/element-web/tests/fixture/navigation.json b/modules/banner/element-web/tests/fixture/navigation.json new file mode 100644 index 0000000000..7365ff1371 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/navigation.json @@ -0,0 +1,87 @@ +{ + "categories": [ + { + "identifier": "applications", + "display_name": "Anwendungen", + "entries": [ + { + "identifier": "app1", + "display_name": "Portal", + "icon_url": "http://localhost:8080/opendesk/app1.svg", + "link": "https://example.com/app1", + "target": "_blank" + }, + { + "identifier": "app2", + "display_name": "Aufgaben", + "icon_url": "http://localhost:8080/opendesk/app2.svg", + "link": "https://example.com/app2", + "target": "_blank" + }, + { + "identifier": "app3", + "display_name": "Chat", + "icon_url": "http://localhost:8080/opendesk/app3.svg", + "link": "https://example.com/app3", + "target": "_blank" + }, + { + "identifier": "app4", + "display_name": "Dateien", + "icon_url": "http://localhost:8080/opendesk/app4.svg", + "link": "https://example.com/ap4", + "target": "_blank" + }, + { + "identifier": "app5", + "display_name": "E-Mail", + "icon_url": "http://localhost:8080/opendesk/app5.svg", + "link": "https://example.com/email", + "target": "_blank" + }, + { + "identifier": "app6", + "display_name": "Kelender", + "icon_url": "http://localhost:8080/opendesk/app6.svg", + "link": "https://example.com/app6", + "target": "_blank" + }, + { + "identifier": "app7", + "display_name": "Kontakte", + "icon_url": "http://localhost:8080/opendesk/app7.svg", + "link": "https://example.com/app7", + "target": "_blank" + }, + { + "identifier": "app8", + "display_name": "Notizen", + "icon_url": "http://localhost:8080/opendesk/app8.svg", + "link": "https://example.com/app8", + "target": "_blank" + }, + { + "identifier": "app9", + "display_name": "Projekte", + "icon_url": "http://localhost:8080/opendesk/app9.svg", + "link": "https://example.com/app9", + "target": "_blank" + }, + { + "identifier": "app10", + "display_name": "Videokonferenz", + "icon_url": "http://localhost:8080/opendesk/app10.svg", + "link": "https://example.com/app10", + "target": "_blank" + }, + { + "identifier": "app11", + "display_name": "Wiki", + "icon_url": "http://localhost:8080/opendesk/app11.svg", + "link": "https://example.com/app11", + "target": "_blank" + } + ] + } + ] +} diff --git a/modules/banner/element-web/tests/fixture/opendesk/app1.svg b/modules/banner/element-web/tests/fixture/opendesk/app1.svg new file mode 100644 index 0000000000..758e28621f --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app1.svg @@ -0,0 +1,3 @@ + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/app10.svg b/modules/banner/element-web/tests/fixture/opendesk/app10.svg new file mode 100644 index 0000000000..147057229c --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app10.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/app11.svg b/modules/banner/element-web/tests/fixture/opendesk/app11.svg new file mode 100644 index 0000000000..d853d42473 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app11.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/app2.svg b/modules/banner/element-web/tests/fixture/opendesk/app2.svg new file mode 100644 index 0000000000..a537b52199 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/app3.svg b/modules/banner/element-web/tests/fixture/opendesk/app3.svg new file mode 100644 index 0000000000..90f21f0667 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/app4.svg b/modules/banner/element-web/tests/fixture/opendesk/app4.svg new file mode 100644 index 0000000000..6c78f1bf58 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app4.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/app5.svg b/modules/banner/element-web/tests/fixture/opendesk/app5.svg new file mode 100644 index 0000000000..90d15399f3 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app5.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/app6.svg b/modules/banner/element-web/tests/fixture/opendesk/app6.svg new file mode 100644 index 0000000000..dbdba6b547 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app6.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/app7.svg b/modules/banner/element-web/tests/fixture/opendesk/app7.svg new file mode 100644 index 0000000000..e88e54cbfe --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app7.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/app8.svg b/modules/banner/element-web/tests/fixture/opendesk/app8.svg new file mode 100644 index 0000000000..1658f680ec --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app8.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/app9.svg b/modules/banner/element-web/tests/fixture/opendesk/app9.svg new file mode 100644 index 0000000000..590bc7dc27 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/app9.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/logofull.svg b/modules/banner/element-web/tests/fixture/opendesk/logofull.svg new file mode 100644 index 0000000000..eaadffcfe3 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/logofull.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/modules/banner/element-web/tests/fixture/opendesk/logomark.svg b/modules/banner/element-web/tests/fixture/opendesk/logomark.svg new file mode 100644 index 0000000000..d23f0ec64c --- /dev/null +++ b/modules/banner/element-web/tests/fixture/opendesk/logomark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/modules/banner/element-web/tests/fixture/silent/index.html b/modules/banner/element-web/tests/fixture/silent/index.html new file mode 100644 index 0000000000..969c920d77 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/silent/index.html @@ -0,0 +1,9 @@ + + + + + + Univention Silent Login + + + diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-static-config-should-render-1-linux.aria.yml b/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-static-config-should-render-1-linux.aria.yml new file mode 100644 index 0000000000..602fa03a65 --- /dev/null +++ b/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-static-config-should-render-1-linux.aria.yml @@ -0,0 +1,6 @@ +- navigation: + - button "Show menu": + - img + - link "Show portal": + - /url: https://example.com/portal + - img "Portal logo" diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-static-config-should-render-2-linux.aria.yml b/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-static-config-should-render-2-linux.aria.yml new file mode 100644 index 0000000000..3ce946d830 --- /dev/null +++ b/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-static-config-should-render-2-linux.aria.yml @@ -0,0 +1,13 @@ +- dialog "Portal logo Close menu": + - heading "Portal logo Close menu" [level=2]: + - img "Portal logo" + - button "Close menu": + - img + - heading "Applications" [level=2] + - link "E-Mail": + - /url: https://example.com/email + - link "Riot": + - /url: https://riot.im/app + - heading "Links" [level=2] + - link "Link": + - /url: https://example.com/link1 diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-univention-config-should-render-1-linux.aria.yml b/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-univention-config-should-render-1-linux.aria.yml new file mode 100644 index 0000000000..602fa03a65 --- /dev/null +++ b/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-univention-config-should-render-1-linux.aria.yml @@ -0,0 +1,6 @@ +- navigation: + - button "Show menu": + - img + - link "Show portal": + - /url: https://example.com/portal + - img "Portal logo" diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-univention-config-should-render-2-linux.aria.yml b/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-univention-config-should-render-2-linux.aria.yml new file mode 100644 index 0000000000..47446acc17 --- /dev/null +++ b/modules/banner/element-web/tests/snapshots/banner.spec.ts/Banner-univention-config-should-render-2-linux.aria.yml @@ -0,0 +1,28 @@ +- dialog "Portal logo Close menu": + - heading "Portal logo Close menu" [level=2]: + - img "Portal logo" + - button "Close menu": + - img + - heading "Anwendungen" [level=2] + - link "Portal": + - /url: https://example.com/app1 + - link "Aufgaben": + - /url: https://example.com/app2 + - link "Chat": + - /url: https://example.com/app3 + - link "Dateien": + - /url: https://example.com/ap4 + - link "E-Mail": + - /url: https://example.com/email + - link "Kelender": + - /url: https://example.com/app6 + - link "Kontakte": + - /url: https://example.com/app7 + - link "Notizen": + - /url: https://example.com/app8 + - link "Projekte": + - /url: https://example.com/app9 + - link "Videokonferenz": + - /url: https://example.com/app10 + - link "Wiki": + - /url: https://example.com/app11 diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/static-menu-linux.png b/modules/banner/element-web/tests/snapshots/banner.spec.ts/static-menu-linux.png new file mode 100644 index 0000000000..9970b9ebde Binary files /dev/null and b/modules/banner/element-web/tests/snapshots/banner.spec.ts/static-menu-linux.png differ diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/static-nav-hover-linux.png b/modules/banner/element-web/tests/snapshots/banner.spec.ts/static-nav-hover-linux.png new file mode 100644 index 0000000000..06f9925da1 Binary files /dev/null and b/modules/banner/element-web/tests/snapshots/banner.spec.ts/static-nav-hover-linux.png differ diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/static-nav-linux.png b/modules/banner/element-web/tests/snapshots/banner.spec.ts/static-nav-linux.png new file mode 100644 index 0000000000..4c19ec5b69 Binary files /dev/null and b/modules/banner/element-web/tests/snapshots/banner.spec.ts/static-nav-linux.png differ diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-error-linux.png b/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-error-linux.png new file mode 100644 index 0000000000..48f4242c85 Binary files /dev/null and b/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-error-linux.png differ diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-menu-linux.png b/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-menu-linux.png new file mode 100644 index 0000000000..c28ec77d31 Binary files /dev/null and b/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-menu-linux.png differ diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-menu-loading-linux.png b/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-menu-loading-linux.png new file mode 100644 index 0000000000..c2b44b9f6b Binary files /dev/null and b/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-menu-loading-linux.png differ diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-nav-hover-linux.png b/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-nav-hover-linux.png new file mode 100644 index 0000000000..2c62995b7d Binary files /dev/null and b/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-nav-hover-linux.png differ diff --git a/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-nav-linux.png b/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-nav-linux.png new file mode 100644 index 0000000000..f0870325dd Binary files /dev/null and b/modules/banner/element-web/tests/snapshots/banner.spec.ts/univention-nav-linux.png differ diff --git a/modules/banner/element-web/tsconfig.json b/modules/banner/element-web/tsconfig.json new file mode 100644 index 0000000000..fbda6380e7 --- /dev/null +++ b/modules/banner/element-web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "lib", + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/modules/banner/element-web/vite.config.ts b/modules/banner/element-web/vite.config.ts new file mode 100644 index 0000000000..f57993abbd --- /dev/null +++ b/modules/banner/element-web/vite.config.ts @@ -0,0 +1,49 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +import externalGlobals from "rollup-plugin-external-globals"; +import svgr from "vite-plugin-svgr"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, "src/index.tsx"), + name: "element-web-module-banner", + fileName: "index", + formats: ["es"], + }, + outDir: "lib", + target: "esnext", + sourcemap: true, + rollupOptions: { + external: ["react"], + }, + }, + plugins: [ + react(), + svgr(), + nodePolyfills({ + include: ["events"], + }), + externalGlobals({ + // Reuse React from the host app + react: "window.React", + }), + ], + define: { + // Use production mode for the build as it is tested against production builds of Element Web, + // this is required for React JSX versions to be compatible. + process: { env: { NODE_ENV: "production" } }, + }, +});