diff --git a/packages/backend/src/config/app.js b/packages/backend/src/config/app.js index a2c1eb26..5a5f1870 100644 --- a/packages/backend/src/config/app.js +++ b/packages/backend/src/config/app.js @@ -18,7 +18,9 @@ const port = process.env.PORT || '3000'; const serveWebAppSeparately = process.env.SERVE_WEB_APP_SEPARATELY === 'true' ? true : false; -let apiUrl = new URL(process.env.API_URL || `${protocol}://${host}:${port}`).toString(); +let apiUrl = new URL( + process.env.API_URL || `${protocol}://${host}:${port}` +).toString(); apiUrl = apiUrl.substring(0, apiUrl.length - 1); // use apiUrl by default, which has less priority over the following cases @@ -90,6 +92,8 @@ const appConfig = { CI: process.env.CI === 'true', disableNotificationsPage: process.env.DISABLE_NOTIFICATIONS_PAGE === 'true', disableFavicon: process.env.DISABLE_FAVICON === 'true', + additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK, + additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT, }; if (!appConfig.encryptionKey) { diff --git a/packages/backend/src/graphql/queries/get-config.ee.js b/packages/backend/src/graphql/queries/get-config.ee.js index b20216bc..89cdd258 100644 --- a/packages/backend/src/graphql/queries/get-config.ee.js +++ b/packages/backend/src/graphql/queries/get-config.ee.js @@ -8,6 +8,8 @@ const getConfig = async (_parent, params) => { const defaultConfig = { disableNotificationsPage: appConfig.disableNotificationsPage, disableFavicon: appConfig.disableFavicon, + additionalDrawerLink: appConfig.additionalDrawerLink, + additionalDrawerLinkText: appConfig.additionalDrawerLinkText, }; const configQuery = Config.query(); diff --git a/packages/backend/src/graphql/queries/get-config.ee.test.js b/packages/backend/src/graphql/queries/get-config.ee.test.js index 74a438e6..4f7e6f5e 100644 --- a/packages/backend/src/graphql/queries/get-config.ee.test.js +++ b/packages/backend/src/graphql/queries/get-config.ee.test.js @@ -59,6 +59,8 @@ describe('graphQL getConfig query', () => { [configThree.key]: configThree.value.data, disableNotificationsPage: false, disableFavicon: false, + additionalDrawerLink: undefined, + additionalDrawerLinkText: undefined, }, }, }; @@ -87,6 +89,8 @@ describe('graphQL getConfig query', () => { [configTwo.key]: configTwo.value.data, disableNotificationsPage: false, disableFavicon: false, + additionalDrawerLink: undefined, + additionalDrawerLinkText: undefined, }, }, }; @@ -101,6 +105,12 @@ describe('graphQL getConfig query', () => { true ); vi.spyOn(appConfig, 'disableFavicon', 'get').mockReturnValue(true); + vi.spyOn(appConfig, 'additionalDrawerLink', 'get').mockReturnValue( + 'https://automatisch.io' + ); + vi.spyOn(appConfig, 'additionalDrawerLinkText', 'get').mockReturnValue( + 'Automatisch' + ); }); it('should return custom config', async () => { @@ -117,6 +127,8 @@ describe('graphQL getConfig query', () => { [configThree.key]: configThree.value.data, disableNotificationsPage: true, disableFavicon: true, + additionalDrawerLink: 'https://automatisch.io', + additionalDrawerLinkText: 'Automatisch', }, }, }; diff --git a/packages/web/src/components/AdminSettingsLayout/index.tsx b/packages/web/src/components/AdminSettingsLayout/index.tsx index e24fe743..1b4b93af 100644 --- a/packages/web/src/components/AdminSettingsLayout/index.tsx +++ b/packages/web/src/components/AdminSettingsLayout/index.tsx @@ -15,6 +15,7 @@ import { SvgIconComponent } from '@mui/icons-material'; import AppBar from 'components/AppBar'; import Drawer from 'components/Drawer'; import * as URLS from 'config/urls'; +import useFormatMessage from 'hooks/useFormatMessage'; import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; type SettingsLayoutProps = { @@ -86,19 +87,11 @@ function createDrawerLinks({ return items; } -const drawerBottomLinks = [ - { - Icon: ArrowBackIosNewIcon, - primary: 'adminSettingsDrawer.goBack', - to: '/', - dataTest: 'go-back-drawer-link', - }, -]; - export default function SettingsLayout({ children, }: SettingsLayoutProps): React.ReactElement { const theme = useTheme(); + const formatMessage = useFormatMessage(); const currentUserAbility = useCurrentUserAbility(); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg')); const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); @@ -116,6 +109,15 @@ export default function SettingsLayout({ canUpdateApp: currentUserAbility.can('update', 'App'), }); + const drawerBottomLinks = [ + { + Icon: ArrowBackIosNewIcon, + primary: formatMessage('adminSettingsDrawer.goBack'), + to: '/', + dataTest: 'go-back-drawer-link', + }, + ]; + return ( <> {bottomLinks.map( - ({ Icon, badgeContent, primary, to, dataTest }, index) => ( + ({ Icon, badgeContent, primary, to, dataTest, target }, index) => ( } - primary={formatMessage(primary)} + primary={primary} to={to} onClick={closeOnClick} + target={target} data-test={dataTest} /> ) diff --git a/packages/web/src/components/Layout/index.tsx b/packages/web/src/components/Layout/index.tsx index 1ea91a15..4dc3f38f 100644 --- a/packages/web/src/components/Layout/index.tsx +++ b/packages/web/src/components/Layout/index.tsx @@ -7,8 +7,10 @@ import AppsIcon from '@mui/icons-material/Apps'; import SwapCallsIcon from '@mui/icons-material/SwapCalls'; import HistoryIcon from '@mui/icons-material/History'; import NotificationsIcon from '@mui/icons-material/Notifications'; +import ArrowBackIosNew from '@mui/icons-material/ArrowBackIosNew'; import * as URLS from 'config/urls'; +import useFormatMessage from 'hooks/useFormatMessage'; import useVersion from 'hooks/useVersion'; import AppBar from 'components/AppBar'; import Drawer from 'components/Drawer'; @@ -41,46 +43,93 @@ const drawerLinks = [ type GenerateDrawerBottomLinksOptions = { disableNotificationsPage: boolean; - loading: boolean; notificationBadgeContent: number; + additionalDrawerLink?: string; + additionalDrawerLinkText?: string; + additionalDrawerLinkIcon?: string; + formatMessage: ReturnType; }; -const generateDrawerBottomLinks = ({ +const generateDrawerBottomLinks = async ({ disableNotificationsPage, - loading, notificationBadgeContent = 0, + additionalDrawerLink, + additionalDrawerLinkText, + formatMessage, }: GenerateDrawerBottomLinksOptions) => { - if (loading || disableNotificationsPage) { - return []; + const notificationsPageLinkObject = { + Icon: NotificationsIcon, + primary: formatMessage('settingsDrawer.notifications'), + to: URLS.UPDATES, + badgeContent: notificationBadgeContent, + }; + + const hasAdditionalDrawerLink = + additionalDrawerLink && additionalDrawerLinkText; + + const additionalDrawerLinkObject = { + Icon: ArrowBackIosNew, + primary: additionalDrawerLinkText || '', + to: additionalDrawerLink || '', + target: '_blank' as const, + }; + + const links = []; + + if (!disableNotificationsPage) { + links.push(notificationsPageLinkObject); } - return [ - { - Icon: NotificationsIcon, - primary: 'settingsDrawer.notifications', - to: URLS.UPDATES, - badgeContent: notificationBadgeContent, - }, - ]; + if (hasAdditionalDrawerLink) { + links.push(additionalDrawerLinkObject); + } + + return links; +}; + +type Link = { + Icon: React.ElementType; + primary: string; + target?: '_blank'; + to: string; + badgeContent?: React.ReactNode; }; export default function PublicLayout({ children, }: PublicLayoutProps): React.ReactElement { const version = useVersion(); - const { config, loading } = useConfig(['disableNotificationsPage']); + const { config, loading } = useConfig([ + 'disableNotificationsPage', + 'additionalDrawerLink', + 'additionalDrawerLinkText', + ]); const theme = useTheme(); + const formatMessage = useFormatMessage(); + const [bottomLinks, setBottomLinks] = React.useState([]); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg')); const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); const openDrawer = () => setDrawerOpen(true); const closeDrawer = () => setDrawerOpen(false); - const drawerBottomLinks = generateDrawerBottomLinks({ - notificationBadgeContent: version.newVersionCount, - loading, - disableNotificationsPage: config?.disableNotificationsPage as boolean, - }); + React.useEffect(() => { + async function perform() { + const newBottomLinks = await generateDrawerBottomLinks({ + notificationBadgeContent: version.newVersionCount, + disableNotificationsPage: config?.disableNotificationsPage as boolean, + additionalDrawerLink: config?.additionalDrawerLink as string, + additionalDrawerLinkText: config?.additionalDrawerLinkText as string, + formatMessage, + }); + + setBottomLinks(newBottomLinks); + } + + if (loading) return; + + perform(); + }, [config, loading, version.newVersionCount]); return ( <> @@ -93,7 +142,7 @@ export default function PublicLayout({ void; 'data-test'?: string; }; @@ -16,14 +17,29 @@ type ListItemLinkProps = { export default function ListItemLink( props: ListItemLinkProps ): React.ReactElement { - const { icon, primary, to, onClick, 'data-test': dataTest } = props; + const { icon, primary, to, onClick, 'data-test': dataTest, target } = props; const selected = useMatch({ path: to, end: true }); const CustomLink = React.useMemo( () => React.forwardRef>( function InLineLink(linkProps, ref) { - return ; + try { + // challenge the link to check if it's absolute URL + new URL(to); // should throw an error if it's not an absolute URL + + return ( + + ); + } catch { + return ; + } } ), [to] @@ -37,6 +53,7 @@ export default function ListItemLink( selected={!!selected} onClick={onClick} data-test={dataTest} + target={target} > {icon} setDrawerOpen(false); const drawerLinks = createDrawerLinks({ isCloud }); + const drawerBottomLinks = [ + { + Icon: ArrowBackIosNewIcon, + primary: formatMessage('settingsDrawer.goBack'), + to: '/', + }, + ]; + return ( <>