import { getLocalizedString } from "@olenbetong/appframe-core"; import { AppErrorBoundary } from "@olenbetong/appframe-ds"; import { type ReactNode, useCallback, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { AppMenu, type AppMenuArticle } from "../AppMenu/AppMenu.js"; import { AppMenuToggle } from "../AppMenu/AppMenuToggle.js"; import type { AppMenuUser } from "../AppMenu/AppMenuUserMenu.js"; import { createDeveloperCategories } from "../AppMenu/developerCategories.js"; import { useServiceWorkerUpdate } from "../AppMenu/useServiceWorkerUpdate.js"; const hostName = globalThis.af?.article?.hostName ?? globalThis.location?.hostname ?? ""; /* * `aviw_WebShared_Articles` stores every published version of an article as its * own row, and rows for all sites share the view. Without the host name filter * and `ArticleVersion IS NULL` (which keeps only the development row, i.e. the * canonical article) every app shows up once per published version. */ const menuArticlesWhereClause = [ `HostName = '${hostName.replace(/'/g, "''")}'`, "ArticleVersion IS NULL", "LastPublished IS NOT NULL", "Authenticated = 1", "ListOnFrontPage = 1", "SysApp = 0", "HideFromNav = 0", "Hidden = 0", "ISNULL(ParentArticleId, '') <> 'news'", ].join(" AND "); // Created once — shared across all AppShell instances on a page. const dsMenuArticles = af.data.generateApiDataObject({ resource: "aviw_WebShared_Articles", id: "dsMenuArticles", allowUpdate: false, allowInsert: false, allowDelete: false, dynamicLoading: false, fields: [ { name: "ArticleId", type: "string", nullable: false }, { name: "Title", type: "string", nullable: true }, { name: "MenuGroup", type: "string", nullable: true }, { name: "Path", type: "string", nullable: true }, { name: "IsFavorite", type: "boolean", nullable: true }, ], parameters: { maxRecords: -1, whereClause: menuArticlesWhereClause, sortOrder: [{ MenuGroup: af.data.SortOrder.Asc }, { Title: af.data.SortOrder.Asc }], }, }); // Toggles the current user's favorite state for an article. const procSetArticleFavorite = new af.ProcedureAPI<{ HostName: string; ArticleID: string; Favorite: boolean }, unknown>( { procedureId: "astp_WebShared_SetArticleFavorite", parameters: [ { name: "HostName", type: "string", required: true, hasDefault: false }, { name: "ArticleID", type: "string", required: true, hasDefault: false }, { name: "Favorite", type: "boolean", required: false, hasDefault: true }, ], timeout: 30000, }, ); // The current user's employee record, used for the photo and full name. const dsAppMenuUser = af.data.generateApiDataObject({ resource: "aviw_Personnel_Employees_Lookup", id: "dsAppMenuUser", allowUpdate: false, allowInsert: false, allowDelete: false, dynamicLoading: false, fields: [ { name: "PrimKey", type: "string", nullable: false }, { name: "PersonID", type: "string", nullable: false }, { name: "FirstName", type: "string", nullable: true }, { name: "LastName", type: "string", nullable: true }, { name: "Fullname", type: "string", nullable: true }, { name: "ImageHash", type: "number", nullable: true }, ], parameters: { maxRecords: 1, // Resolved server side so it doesn't depend on the session being hydrated. whereClause: "PersonID = SUSER_SNAME()", }, }); export type AppShellProps = { children: ReactNode; }; /** * Standard Synergi app chrome: error boundary, app menu and the toolbar toggle. * * This component is Designsystemet-only. Apps that still render MUI components * should additionally wrap it in `MuiThemeProvider` from * `@olenbetong/synergi-react/mui`, which bridges the Designsystemet colour * tokens into a MUI theme. */ export function AppShell({ children }: AppShellProps) { const [isAppMenuOpen, setIsAppMenuOpen] = useState(false); const { isUpdateAvailable, installUpdate } = useServiceWorkerUpdate(); // The developer tools are only reachable for developers, so hide them for // everyone else instead of showing links that all return 403. const categories = useMemo(() => (globalThis.af?.userSession?.isDeveloper ? createDeveloperCategories() : []), []); // The portal isn't listed with the apps, so link to it from the menu itself // unless that's the article we're already on. const homeLink = useMemo(() => { const articleId = (globalThis.af?.article?.id ?? "").replace(/\.\d+$/, ""); return articleId === "portal" ? undefined : { label: getLocalizedString("Front page"), href: "/portal" }; }, []); const toggleFavorite = useCallback(async (articleId: string, favorite: boolean) => { await procSetArticleFavorite.execute({ HostName: hostName, ArticleID: articleId, Favorite: favorite }); }, []); const appMenuSlot = document.getElementById("ob-toolbar-app-menu"); return ( {appMenuSlot && createPortal( setIsAppMenuOpen((open) => !open)} />, appMenuSlot, )} setIsAppMenuOpen(false)} /> {children} ); }