Skip to content

Commit f17e129

Browse files
authored
feature(menu-bar): external links to dev (#505)
* feat: add support for external links configuration via environment variable * docs: update configuration settings to improve feature flag descriptions * feat: integrate external links into environment variables configuration * feat: add external links configuration description in main menu * test: update DynamicIcon.spec.tsx to check attributes of base64 image * feat: enhance DynamicIcon component with alt text for accessibility; update MainMenu to use new label parsing; validate external link properties in environment configuration; remove external links description from locale files; update configuration documentation * feat: validate external link URLs in parseExternalLinks function * feat: enhance parseExternalLinks function with detailed validation and logging for external link items
1 parent 644f4fd commit f17e129

File tree

11 files changed

+551
-12
lines changed

11 files changed

+551
-12
lines changed

.env.local

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ NEXTAUTH_URL= "http://localhost:3000"
3333
BASYX_RBAC_SEC_SM_API_URL= "http://localhost:8089"
3434

3535
IMPRINT_URL= ""
36-
DATA_PRIVACY_URL= ""
36+
DATA_PRIVACY_URL= ""
37+
EXTERNAL_LINKS= ""
3738

3839
EXPERIMENTAL_PRODUCT_VIEW_FEATURE_FLAG= "false"
3940
EXPERIMENTAL_HIGHLIGHT_DATA_FLAG= "false"
41+

src/app/EnvProvider.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const initialEnvValues: EnvironmentalVariables = {
3939
KEYCLOAK_CLIENT_ID: undefined,
4040
SERIALIZATION_API_URL: undefined,
4141
EXPERIMENTAL_HIGHLIGHT_DATA_FLAG: false,
42+
EXTERNAL_LINKS: [],
4243
};
4344

4445
const EnvContext = createContext(initialEnvValues);

src/app/[locale]/settings/_components/mnestix-infrastructure/DefaultInfrastructure.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,18 @@ export function DefaultInfrastructure() {
8787
{t('form.endpoints')}
8888
</Typography>
8989
<Box>
90-
{CONNECTION_TYPES.map((type) => (
91-
<Box key={type.id} display="flex" gap={2} mb={1}>
92-
<Typography sx={{ minWidth: 320 }} variant="h5">
93-
{type.label}
94-
</Typography>
95-
<Typography>{env[ENV_KEY_BY_CONNECTION_ID[type.id]] || '-'}</Typography>
96-
</Box>
97-
))}
90+
{CONNECTION_TYPES.map((type) => {
91+
const value = env[ENV_KEY_BY_CONNECTION_ID[type.id]];
92+
const displayValue = typeof value === 'string' ? value : '-';
93+
return (
94+
<Box key={type.id} display="flex" gap={2} mb={1}>
95+
<Typography sx={{ minWidth: 320 }} variant="h5">
96+
{type.label}
97+
</Typography>
98+
<Typography>{displayValue}</Typography>
99+
</Box>
100+
);
101+
})}
98102
</Box>
99103
</Box>
100104
</Collapse>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React from 'react';
2+
import { screen } from '@testing-library/react';
3+
import { DynamicIcon } from './DynamicIcon';
4+
import { Link } from '@mui/icons-material';
5+
import { expect } from '@jest/globals';
6+
import { CustomRender } from 'test-utils/CustomRender';
7+
8+
describe('DynamicIcon', () => {
9+
it('should render Material-UI icon by name', () => {
10+
const { container } = CustomRender(<DynamicIcon iconName="OpenInNew" />);
11+
const svgElement = container.querySelector('svg');
12+
expect(svgElement).toBeInTheDocument();
13+
});
14+
15+
it('should render fallback icon when icon name is not found', () => {
16+
const fallback = <Link data-testid="fallback-icon" />;
17+
CustomRender(<DynamicIcon iconName="NonExistentIcon" fallback={fallback} />);
18+
expect(screen.getByTestId('fallback-icon')).toBeInTheDocument();
19+
});
20+
21+
it('should render base64 image when provided', () => {
22+
const base64Image =
23+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
24+
CustomRender(<DynamicIcon iconName={base64Image} />);
25+
const imgElement = screen.getByRole('img');
26+
expect(imgElement).toBeInTheDocument();
27+
expect(imgElement).toHaveAttribute('src', base64Image);
28+
});
29+
30+
it('should render nothing when no icon name is provided and no fallback', () => {
31+
const { container } = CustomRender(<DynamicIcon />);
32+
expect(container.firstChild).toBeNull();
33+
});
34+
35+
it('should render fallback when no icon name is provided', () => {
36+
const fallback = <Link data-testid="fallback-icon" />;
37+
CustomRender(<DynamicIcon fallback={fallback} />);
38+
expect(screen.getByTestId('fallback-icon')).toBeInTheDocument();
39+
});
40+
41+
it('should apply correct styling to base64 image', () => {
42+
const base64Image =
43+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
44+
CustomRender(<DynamicIcon iconName={base64Image} />);
45+
const imgElement = screen.getByRole('img');
46+
expect(imgElement).toHaveAttribute('src', base64Image);
47+
expect(imgElement).toHaveAttribute('width', '24');
48+
expect(imgElement).toHaveAttribute('height', '24');
49+
expect(imgElement).toHaveStyle({ objectFit: 'contain' });
50+
});
51+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from 'react';
2+
import * as MuiIcons from '@mui/icons-material';
3+
import Image from 'next/image';
4+
5+
/**
6+
* Props for the DynamicIcon component
7+
*/
8+
interface DynamicIconProps {
9+
/**
10+
* Name of the Material-UI icon or base64 image data
11+
*/
12+
iconName?: string;
13+
/**
14+
* Fallback icon if the specified icon is not found
15+
*/
16+
fallback?: React.ReactElement;
17+
/**
18+
* Alt text for the image (for accessibility)
19+
*/
20+
altText?: string;
21+
}
22+
23+
/**
24+
* Component that dynamically renders a Material-UI icon based on its name
25+
* or displays an image if a base64 string is provided
26+
*/
27+
export function DynamicIcon({ iconName, fallback, altText = 'icon' }: DynamicIconProps) {
28+
if (!iconName) {
29+
return fallback || null;
30+
}
31+
32+
// Check if it's a base64 image
33+
if (iconName.startsWith('data:image/')) {
34+
return (
35+
<Image src={iconName} alt={altText} width={24} height={24} style={{ objectFit: 'contain' }} unoptimized />
36+
);
37+
}
38+
39+
// Try to find the icon in Material-UI icons
40+
const IconComponent = (MuiIcons as Record<string, React.ComponentType>)[iconName];
41+
42+
if (IconComponent) {
43+
return <IconComponent />;
44+
}
45+
46+
// Return fallback if icon not found
47+
return fallback || null;
48+
}

src/layout/menu/MainMenu.spec.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,55 @@ const mockEnvVariables = jest.fn(() => {
1515
AAS_LIST_FEATURE_FLAG: true,
1616
MNESTIX_AAS_GENERATOR_API_URL: 'http://localhost:5064/backend',
1717
AUTHENTICATION_FEATURE_FLAG: true,
18+
EXTERNAL_LINKS: [],
19+
};
20+
});
21+
22+
const mockEnvVariablesWithExternalLinks = jest.fn(() => {
23+
return {
24+
AAS_LIST_FEATURE_FLAG: true,
25+
MNESTIX_BACKEND_API_URL: 'http://localhost:5064/backend',
26+
AUTHENTICATION_FEATURE_FLAG: true,
27+
EXTERNAL_LINKS: [
28+
{
29+
label: 'Test External Link',
30+
url: 'https://example.com',
31+
icon: 'OpenInNew',
32+
target: '_blank',
33+
},
34+
{
35+
label: 'Another Link',
36+
url: 'https://another-example.com',
37+
icon: 'Link',
38+
},
39+
],
40+
};
41+
});
42+
43+
const mockEnvVariablesWithI18nExternalLinks = jest.fn(() => {
44+
return {
45+
AAS_LIST_FEATURE_FLAG: true,
46+
MNESTIX_BACKEND_API_URL: 'http://localhost:5064/backend',
47+
AUTHENTICATION_FEATURE_FLAG: true,
48+
EXTERNAL_LINKS: [
49+
{
50+
label: {
51+
en: 'Documentation',
52+
de: 'Dokumentation',
53+
},
54+
url: 'https://docs.example.com',
55+
icon: 'MenuBook',
56+
target: '_blank',
57+
},
58+
{
59+
label: {
60+
en: 'Support',
61+
de: 'Unterstützung',
62+
},
63+
url: 'https://support.example.com',
64+
icon: 'Help',
65+
},
66+
],
1867
};
1968
});
2069

@@ -107,4 +156,43 @@ describe('MainMenu', () => {
107156
expect(list).toBeInTheDocument();
108157
});
109158
});
159+
160+
describe('external links', () => {
161+
it('should render external links from environment configuration', () => {
162+
(useEnv as jest.Mock).mockImplementation(mockEnvVariablesWithExternalLinks);
163+
(useAuth as jest.Mock).mockImplementation(mockUseAuthAdmin);
164+
renderAndOpenMenu();
165+
166+
const externalLink1 = screen.getByTestId('https://example.com');
167+
expect(externalLink1).toBeInTheDocument();
168+
expect(externalLink1).toHaveTextContent('Test External Link');
169+
170+
const externalLink2 = screen.getByTestId('https://another-example.com');
171+
expect(externalLink2).toBeInTheDocument();
172+
expect(externalLink2).toHaveTextContent('Another Link');
173+
});
174+
175+
it('should render i18n external links with correct language', () => {
176+
(useEnv as jest.Mock).mockImplementation(mockEnvVariablesWithI18nExternalLinks);
177+
(useAuth as jest.Mock).mockImplementation(mockUseAuthAdmin);
178+
renderAndOpenMenu();
179+
180+
const docLink = screen.getByTestId('https://docs.example.com');
181+
expect(docLink).toBeInTheDocument();
182+
expect(docLink).toHaveTextContent('Documentation'); // English is default in tests
183+
184+
const supportLink = screen.getByTestId('https://support.example.com');
185+
expect(supportLink).toBeInTheDocument();
186+
expect(supportLink).toHaveTextContent('Support');
187+
});
188+
189+
it('should not render external links when EXTERNAL_LINKS is empty', () => {
190+
(useEnv as jest.Mock).mockImplementation(mockEnvVariables);
191+
(useAuth as jest.Mock).mockImplementation(mockUseAuthAdmin);
192+
renderAndOpenMenu();
193+
194+
const externalLink = screen.queryByTestId('https://example.com');
195+
expect(externalLink).not.toBeInTheDocument();
196+
});
197+
});
110198
});

src/layout/menu/MainMenu.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import MenuIcon from '@mui/icons-material/Menu';
22
import { alpha, Box, Divider, Drawer, IconButton, List, styled, Typography } from '@mui/material';
3-
import { Dashboard, OpenInNew, Settings } from '@mui/icons-material';
3+
import { Dashboard, OpenInNew, Settings, Link } from '@mui/icons-material';
44
import React, { useState } from 'react';
55
import { useAuth } from 'lib/hooks/UseAuth';
66
import { TemplateIcon } from 'components/custom-icons/TemplateIcon';
@@ -10,8 +10,9 @@ import ListIcon from '@mui/icons-material/List';
1010
import packageJson from '../../../package.json';
1111
import { useEnv } from 'app/EnvProvider';
1212
import BottomMenu from 'layout/menu/BottomMenu';
13-
import { useTranslations } from 'next-intl';
13+
import { useTranslations, useLocale } from 'next-intl';
1414
import { MnestixRole } from 'components/authentication/AllowedRoutes';
15+
import { DynamicIcon } from 'components/basics/DynamicIcon';
1516

1617
const StyledDrawer = styled(Drawer)(({ theme }) => ({
1718
'.MuiDrawer-paper': {
@@ -63,6 +64,7 @@ export default function MainMenu() {
6364
const mnestixRole = auth.getAccount()?.user.mnestixRole ?? MnestixRole.MnestixGuest;
6465
const allowedRoutes = auth.getAccount()?.user.allowedRoutes ?? [];
6566
const t = useTranslations('navigation.mainMenu');
67+
const locale = useLocale();
6668

6769
const getAuthName = () => {
6870
const user = auth?.getAccount()?.user;
@@ -121,6 +123,23 @@ export default function MainMenu() {
121123
basicMenu.push(settingsMenu);
122124
}
123125

126+
if (env.EXTERNAL_LINKS && env.EXTERNAL_LINKS.length > 0) {
127+
env.EXTERNAL_LINKS.forEach((link) => {
128+
const label =
129+
typeof link.label === 'string'
130+
? link.label
131+
: (link.label[locale] ?? link.label['en'] ?? Object.values(link.label)[0]);
132+
const externalLink: MenuListItemProps = {
133+
label,
134+
to: link.url,
135+
icon: <DynamicIcon iconName={link.icon} fallback={<Link />} />,
136+
external: true,
137+
target: link.target || '_blank',
138+
};
139+
basicMenu.push(externalLink);
140+
});
141+
}
142+
124143
const guestMoreMenu: MenuListItemProps[] = [
125144
{
126145
label: 'mnestix.io',

0 commit comments

Comments
 (0)