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..85ac88a46b --- /dev/null +++ b/modules/banner/element-web/package.json @@ -0,0 +1,29 @@ +{ + "name": "@element-hq/element-web-module-banner", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "lib/index.js", + "scripts": { + "prepare": "vite build", + "lint:ts": "tsc --noEmit", + "test": "echo no tests yet" + }, + "devDependencies": { + "@element-hq/element-web-module-api": "^0.2.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" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.6", + "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..4ad63827ab --- /dev/null +++ b/modules/banner/element-web/src/Banner.tsx @@ -0,0 +1,65 @@ +/* +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, { ThemeProvider } from "styled-components"; +import { type Api } from "@element-hq/element-web-module-api"; + +import { type ModuleConfig } from "./config"; +import UniventionMenu from "./Univention/Menu"; +import Menu from "./Menu"; +import { theme } from "./theme"; +import Logo from "./Logo.tsx"; + +const Root = styled.nav` + background-color: ${({ theme }) => theme.compound.color.bgCanvasDefault}; + border-bottom: ${({ theme }) => theme.navbar.border}; + height: ${({ theme }) => theme.navbar.height}; + display: grid; + grid-template-columns: ${({ theme }) => `${theme.navbar.triggerWidth} auto`}; + gap: 24px; +`; + +const Main = styled.div` + display: flex; + align-items: center; + grid-column: 2; +`; + +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} +
+ +
+
+
+ ); +}; + +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..48840486ca --- /dev/null +++ b/modules/banner/element-web/src/Logo.tsx @@ -0,0 +1,39 @@ +/* +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: ${({ theme }) => theme.navbar.logoHeight}; + align-self: center; +`; + +interface Props { + api: Api; + src: string; + href?: string; +} + +const Logo: FC = ({ api, src, href }) => { + const img = {api.i18n.translate("Portal; + + 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..9ca1763858 --- /dev/null +++ b/modules/banner/element-web/src/Menu.tsx @@ -0,0 +1,207 @@ +/* +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, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import * as Dialog from "@radix-ui/react-dialog"; +import styled from "styled-components"; + +import { StaticConfig } from "./config"; +import { theme } from "./theme"; +import type { Api } from "@element-hq/element-web-module-api"; +import Logo from "./Logo.tsx"; + +const Sidebar = styled(motion.div)` + padding: 16px 12px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); + overflow: auto; + position: fixed; + left: 0; + top: 0; + height: 100%; + width: ${({ theme }): string => theme.menu.width}; + background: white; + border-top-right-radius: 16px; + border-bottom-right-radius: 16px; +`; + +const SidebarHeading = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +`; + +const Launcher = styled.button` + align-items: center; + border: none; + color: ${({ theme }) => theme.compound.color.textPrimary}; + cursor: pointer; + display: flex; + + &:hover, + &:focus, + &[data-expanded="true"] { + background-color: ${({ theme }): string => theme.color.accent}; + color: #ffffff; + } + + 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: rgba(238, 239, 242, 1); + } +`; + +const CategoryHeading = styled.h2` + font-weight: 700; + font-size: 12px; + color: #203257; + margin-top: 16px; + margin-bottom: 8px; +`; + +const LinkButton = styled.a` + font-size: 14px; + color: #000000; + font-weight: 500; + display: flex; + border-radius: 8px; + padding: 8px; + align-items: center; + + &:hover { + background-color: #eeeff2; + } +`; + +const LinkLogo = styled.img` + height: 24px; + width: 24px; + border-radius: 3px; + border: 1px solid #eeeff2; + margin-right: 8px; + background-color: #ffffff; +`; + +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; + fallbackLogoUrl: string; +} + +const WIDTH = parseInt(theme.menu.width.slice(0, -2), 10); + +const Category: FC<{ + data: StaticConfig["categories"][number]; +}> = ({ data }) => { + return ( +
+ {data.name} + {data.links.map((link) => ( + + {link.name} + + ))} +
+ ); +}; + +const Menu: FC = ({ api, config, fallbackLogoUrl }) => { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + + + {open && ( + + + + + + + + + + setOpen(false)} + > + + + + + + + {config.categories.map((category) => ( + + ))} + + + + )} + + + ); +}; + +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..690b03e94c --- /dev/null +++ b/modules/banner/element-web/src/Univention/Menu.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 { 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(null); + fetchNavigation(config.ics_url, language).then((data) => { + if (discard) return; + setData(data); + }); + + return (): void => { + discard = true; + }; + }, [config, language, loggedIn]); + + if (!loggedIn) { + return ; + } + if (data) { + return ; + } + return
; +}; + +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..06814333c2 --- /dev/null +++ b/modules/banner/element-web/src/Univention/SilentLogin.tsx @@ -0,0 +1,41 @@ +/* +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]); + + // TODO title? + 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..8443d2951f --- /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 + */ +export 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..3e5966bbbc --- /dev/null +++ b/modules/banner/element-web/src/config.ts @@ -0,0 +1,93 @@ +/* +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"; + +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"), + + /** + * 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(), + + menu: z.discriminatedUnion("type", [StaticConfig, UniventionConfig]), +}); + +export type ModuleConfig = z.infer; + +export const CONFIG_KEY = "io.element.element-web-modules.banner"; + +declare module "@element-hq/element-web-module-api" { + export interface Config { + [CONFIG_KEY]: ModuleConfig; + } +} diff --git a/modules/banner/element-web/src/index.tsx b/modules/banner/element-web/src/index.tsx new file mode 100644 index 0000000000..9eeb972671 --- /dev/null +++ b/modules/banner/element-web/src/index.tsx @@ -0,0 +1,46 @@ +/* +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 { 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 TopBarModule implements Module { + public static readonly moduleApiVersion = "^0.2.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 TopBarModule 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..4eec071127 --- /dev/null +++ b/modules/banner/element-web/src/theme.ts @@ -0,0 +1,43 @@ +/* +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 { DefaultTheme } from "styled-components"; + +const bgCanvasDefault = "var(--cpd-color-bg-canvas-default)"; +const textActionAccent = "var(--cpd-color-text-action-accent)"; +const textPrimary = "var(--cpd-color-text-primary)"; +const iconOnSolidPrimary = "var(--cpd-color-icon-on-solid-primary)"; + +const bodyMdSemibold = "var(--cpd-font-body-md-semibold)"; + +export const theme: DefaultTheme = { + compound: { + color: { + bgCanvasDefault, + textActionAccent, + textPrimary, + iconOnSolidPrimary, + }, + font: { + bodyMdSemibold, + }, + }, + color: { + accent: "#571EFA", // primary/700 TODO + }, + navbar: { + border: "1px solid #D3D7DE", // TODO + boxShadow: "4px 4px 12px 0 rgba(118, 131, 156, 0.6)", + height: "60px", + triggerWidth: "68px", + logoHeight: "34px", + }, + menu: { + // TODO + width: "235px", + }, +}; diff --git a/modules/banner/element-web/src/translations.json b/modules/banner/element-web/src/translations.json new file mode 100644 index 0000000000..a177686956 --- /dev/null +++ b/modules/banner/element-web/src/translations.json @@ -0,0 +1,22 @@ +{ + "Portal logo": { + "en": "Portal logo", + "de": "Portal Logo" + }, + "Menu": { + "en": "Menu", + "de": "Menü" + }, + "Show menu": { + "en": "Show menu", + "de": "Menü anzeigen" + }, + "Close menu": { + "en": "Close menu", + "de": "Menü schließen" + }, + "Show portal": { + "en": "Show portal", + "de": "Portal anzeigen" + } +} 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..7637b688ad --- /dev/null +++ b/modules/banner/element-web/tests/banner.spec.ts @@ -0,0 +1,153 @@ +/* +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, expect } from "../../../../playwright/element-web-test.ts"; +import { ModuleConfig } from "../src/config.ts"; + +test.describe("Banner", () => { + test.use({ + displayName: "Timmy", + page: async ({ context, page, moduleDir }, use) => { + for (const path of ["logo.svg", "app1.png", "app2.png"]) { + await context.route(`/${path}*`, async (route) => { + await route.fulfill({ path: `${moduleDir}/tests/fixture/${path}` }); + }); + } + 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); + }, + }); + + test("should error if config is missing", { tag: ["@screenshot"] }, async ({ page }) => { + await expect(page.getByText("Your Element is misconfigured")).toBeVisible(); + // We don't take a screenshot as we don't want to assert Element's styling, only our own + }); + + const configs: ModuleConfig[] = [ + { + 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: "App 1", + link_url: "https://example.com/app1", + 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/logo.svg", + logo_link_url: "https://example.com/portal", + menu: { + type: "univention", + ics_url: "http://localhost:8080/ics/", + }, + }, + ]; + + 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("should render", { 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"); + 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(); + + // 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 app1 = page.getByText("App 1"); + await expect(app1).toHaveAttribute("href", "https://example.com/app1"); + await app1.hover(); + + // Assert the sidebar looks as we expect + const sidebar = page.getByRole("dialog"); + 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(); + }); + }); + }); + } +}); 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..79bac98d25 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/navigation.json @@ -0,0 +1,44 @@ +{ + "categories": [ + { + "identifier": "category1", + "display_name": "Category 1", + "entries": [ + { + "identifier": "app1", + "display_name": "App 1", + "icon_url": "http://localhost:8080/app1.png", + "link": "https://example.com/app1", + "target": "_blank" + }, + { + "identifier": "app2", + "display_name": "App 2", + "icon_url": "http://localhost:8080/app2.png", + "link": "https://example.com/app2", + "target": "_blank" + } + ] + }, + { + "identifier": "category2", + "display_name": "Category 2", + "entries": [ + { + "identifier": "app3", + "display_name": "App 3", + "icon_url": "http://localhost:8080/app1.png", + "link": "https://example.com/app3", + "target": "_blank" + }, + { + "identifier": "app4", + "display_name": "App 4", + "icon_url": "http://localhost:8080/app2.png", + "link": "https://example.com/app4", + "target": "_blank" + } + ] + } + ] +} 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..ae80633f60 --- /dev/null +++ b/modules/banner/element-web/tests/fixture/silent/index.html @@ -0,0 +1,14 @@ + + + + + + Univention Silent Login + + + + 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..48d989a42c --- /dev/null +++ b/modules/banner/element-web/vite.config.ts @@ -0,0 +1,45 @@ +/* +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"; + +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(), + nodePolyfills({ + include: ["events"], + }), + externalGlobals({ + // Reuse React from the host app + react: "window.React", + }), + ], + define: { + process: { env: {} }, + }, +}); diff --git a/modules/opendesk/element-web/package.json b/modules/opendesk/element-web/package.json index e74754c4ce..438d65dfa1 100644 --- a/modules/opendesk/element-web/package.json +++ b/modules/opendesk/element-web/package.json @@ -12,7 +12,7 @@ "test": "echo no tests yet" }, "devDependencies": { - "@element-hq/element-web-module-api": "^0.1.0", + "@element-hq/element-web-module-api": "^0.2.0", "@nordeck/element-web-guest-module": "^2.0.0", "@nordeck/element-web-opendesk-module": "^0.5.0", "@nordeck/element-web-widget-lifecycle-module": "^1.0.1",