Skip to content

Commit 58f9519

Browse files
authored
Create shared navigation components using module federation (#774)
### Summary & Motivation This introduces shared navigation components using module federation to enable consistent navigation across self-contained systems. The implementation provides federated side menu, top menu, and common UI components that can be shared between all self-contained systems. - Create federated side menu and mobile menu components that work across module boundaries. - Implement federated top menu with user avatar, theme selector, language switcher, and support options. - Enable cross-system navigation with SPA reload only when navigating between different systems. - Make toast notifications work across module federation boundaries. - Create shared support dialog using module federation to replace account deletion functionality. - Implement federated translation system to share translations between self-contained systems. - Fix various UI issues including mobile menu display, theme selector alignment, and Safari navigation bugs. - Improve mobile experience by showing theme, support, and language switcher on login/signup pages. - Centralize tooltip implementations and improve component accessibility. - Ensure federated module styles are properly loaded in host systems. - Ensure translations in federated modules are loaded from the host systems. - Add platform configuration to restrict BackOffice menu option to internal users. ### Downstream projects 1. Move your side menu configuration to the centralized location - Add your side menu items to `application/account-management/WebApp/federated-modules/sideMenu/NavigationMenuItems.tsx` - Remove side menu items from your local components This file now serves as the central configuration for all side menu items across systems. 3. Update your `bootstrap.tsx` to use federated translations: ```diff - import { Translation } from "@repo/infrastructure/translations/Translation"; + import { createFederatedTranslation } from "@repo/infrastructure/translations/createFederatedTranslation"; - const { TranslationProvider } = await Translation.create( + const { TranslationProvider } = await createFederatedTranslation( (locale) => import(`@/shared/translations/locale/${locale}.ts`) ); ``` 4. Replace your side menu component with the federated version in your main route: ```diff - import { SharedSideMenu } from "@/shared/components/SharedSideMenu"; + import FederatedSideMenu from "account-management/FederatedSideMenu"; - <SharedSideMenu ariaLabel={t`Toggle collapsed menu`} /> + <FederatedSideMenu currentSystem="your-self-contained-system" /> ``` 5. Update your top menu component to use the federated version: ```diff - import { LocaleSwitcher } from "@repo/infrastructure/translations/LocaleSwitcher"; - import { ThemeModeSelector } from "@repo/ui/theme/ThemeModeSelector"; - const AvatarButton = lazy(() => import("account-management/AvatarButton")); + const FederatedTopMenu = lazy(() => import("account-management/FederatedTopMenu")); // Wrap your existing breadcrumbs or top menu content with FederatedTopMenu: - <nav className="flex w-full items-center justify-between"> - {/* Your breadcrumbs and menu items */} - </nav> + <Suspense fallback={<div className="h-12 w-full" />}> + <FederatedTopMenu> + {/* Your breadcrumbs */} + </FederatedTopMenu> + </Suspense> ``` 6. Remove local implementations of SharedSideMenu and update imports throughout your system. 7. Change the `internalEmailDomain` in `application/shared-kernel/SharedKernel/Platform/platform-settings.jsonc` to your own to only show BackOffice to internal users. This change is part of a bigger redesign of the navigation, so please look at this and other changes holistically. ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents 2ff6d50 + c554bb1 commit 58f9519

File tree

48 files changed

+1384
-661
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+1384
-661
lines changed

application/account-management/Core/Features/Users/Domain/User.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using PlatformPlatform.SharedKernel.Domain;
2+
using PlatformPlatform.SharedKernel.Platform;
23

34
namespace PlatformPlatform.AccountManagement.Features.Users.Domain;
45

@@ -37,6 +38,8 @@ public string Email
3738

3839
public string Locale { get; private set; }
3940

41+
public bool IsInternalUser => Email.EndsWith(Settings.Current.Identity.InternalEmailDomain, StringComparison.OrdinalIgnoreCase);
42+
4043
public TenantId TenantId { get; }
4144

4245
public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale)

application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ public async Task<UserInfo> CreateUserInfoAsync(User user, CancellationToken can
3232
Title = user.Title,
3333
AvatarUrl = user.Avatar.Url,
3434
TenantName = tenant?.Name,
35-
Locale = user.Locale
35+
Locale = user.Locale,
36+
IsInternalUser = user.IsInternalUser
3637
};
3738
}
3839
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using FluentAssertions;
2+
using PlatformPlatform.AccountManagement.Features.Users.Domain;
3+
using PlatformPlatform.SharedKernel.Domain;
4+
using PlatformPlatform.SharedKernel.Platform;
5+
using Xunit;
6+
7+
namespace PlatformPlatform.AccountManagement.Tests.Users.Domain;
8+
9+
public sealed class UserTests
10+
{
11+
private readonly TenantId _tenantId = TenantId.NewId();
12+
13+
[Fact]
14+
public void IsInternalUser_ShouldReturnTrueForInternalEmails()
15+
{
16+
// Arrange
17+
var internalEmails = new[]
18+
{
19+
$"user{Settings.Current.Identity.InternalEmailDomain}",
20+
$"admin{Settings.Current.Identity.InternalEmailDomain}",
21+
$"test.user{Settings.Current.Identity.InternalEmailDomain}",
22+
$"user+tag{Settings.Current.Identity.InternalEmailDomain}",
23+
$"USER{Settings.Current.Identity.InternalEmailDomain.ToUpperInvariant()}"
24+
};
25+
26+
foreach (var email in internalEmails)
27+
{
28+
// Arrange
29+
var user = User.Create(_tenantId, email, UserRole.Member, true, "en-US");
30+
31+
// Act
32+
var isInternal = user.IsInternalUser;
33+
34+
// Assert
35+
isInternal.Should().BeTrue($"Email {email} should be identified as internal");
36+
}
37+
}
38+
39+
[Fact]
40+
public void IsInternalUser_ShouldReturnFalseForExternalEmails()
41+
{
42+
// Arrange
43+
var externalEmails = new[]
44+
{
45+
"user@example.com",
46+
"user@company.net",
47+
$"{Settings.Current.Identity.InternalEmailDomain.Substring(1)}@example.com",
48+
$"user@subdomain.{Settings.Current.Identity.InternalEmailDomain.Substring(1)}"
49+
};
50+
51+
foreach (var email in externalEmails)
52+
{
53+
// Arrange
54+
var user = User.Create(_tenantId, email, UserRole.Member, true, "en-US");
55+
56+
// Act
57+
var isInternal = user.IsInternalUser;
58+
59+
// Assert
60+
isInternal.Should().BeFalse($"Email {email} should be identified as external");
61+
}
62+
}
63+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { t } from "@lingui/core/macro";
2+
import { Trans } from "@lingui/react/macro";
3+
import type { Key } from "@react-types/shared";
4+
import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks";
5+
import { enhancedFetch } from "@repo/infrastructure/http/httpClient";
6+
import type { Locale } from "@repo/infrastructure/translations/TranslationContext";
7+
import localeMap from "@repo/infrastructure/translations/i18n.config.json";
8+
import { Button } from "@repo/ui/components/Button";
9+
import { Menu, MenuItem, MenuTrigger } from "@repo/ui/components/Menu";
10+
import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip";
11+
import { CheckIcon, GlobeIcon } from "lucide-react";
12+
import { useEffect, useState } from "react";
13+
14+
const PREFERRED_LOCALE_KEY = "preferred-locale";
15+
16+
const locales = Object.entries(localeMap).map(([id, info]) => ({
17+
id: id as Locale,
18+
label: info.label
19+
}));
20+
21+
async function updateLocaleOnBackend(locale: Locale) {
22+
try {
23+
const response = await enhancedFetch("/api/account-management/users/me/change-locale", {
24+
method: "PUT",
25+
headers: { "Content-Type": "application/json" },
26+
body: JSON.stringify({ Locale: locale })
27+
});
28+
29+
return response.ok || response.status === 401;
30+
} catch {
31+
return true; // Continue even if API call fails
32+
}
33+
}
34+
35+
export default function LocaleSwitcher({
36+
variant = "icon",
37+
onAction
38+
}: {
39+
variant?: "icon" | "mobile-menu";
40+
onAction?: () => void;
41+
} = {}) {
42+
const [currentLocale, setCurrentLocale] = useState<Locale>("en-US");
43+
const isAuthenticated = useIsAuthenticated();
44+
45+
useEffect(() => {
46+
// Get current locale from document or localStorage
47+
const htmlLang = document.documentElement.lang as Locale;
48+
const savedLocale = localStorage.getItem(PREFERRED_LOCALE_KEY) as Locale;
49+
50+
if (savedLocale && locales.some((l) => l.id === savedLocale)) {
51+
setCurrentLocale(savedLocale);
52+
} else if (htmlLang && locales.some((l) => l.id === htmlLang)) {
53+
setCurrentLocale(htmlLang);
54+
}
55+
}, []);
56+
57+
const handleLocaleChange = async (key: Key) => {
58+
const locale = key.toString() as Locale;
59+
if (locale !== currentLocale) {
60+
// Call onAction if provided (for closing mobile menu)
61+
onAction?.();
62+
63+
// Save to localStorage
64+
localStorage.setItem(PREFERRED_LOCALE_KEY, locale);
65+
66+
// Only update backend if user is authenticated
67+
if (isAuthenticated) {
68+
await updateLocaleOnBackend(locale);
69+
}
70+
71+
// Reload page to apply new locale
72+
window.location.reload();
73+
}
74+
};
75+
76+
const currentLocaleLabel = locales.find((l) => l.id === currentLocale)?.label || currentLocale;
77+
78+
if (variant === "mobile-menu") {
79+
return (
80+
<MenuTrigger>
81+
<Button
82+
variant="ghost"
83+
className="flex h-11 w-full items-center justify-start gap-4 px-3 py-2 font-normal text-base text-muted-foreground hover:bg-hover-background hover:text-foreground"
84+
style={{ pointerEvents: "auto" }}
85+
>
86+
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
87+
<GlobeIcon className="h-5 w-5 stroke-current" />
88+
</div>
89+
<div className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-start">
90+
<Trans>Language</Trans>
91+
</div>
92+
<div className="shrink-0 text-base text-muted-foreground">{currentLocaleLabel}</div>
93+
</Button>
94+
<Menu onAction={handleLocaleChange} placement="bottom end">
95+
{locales.map((locale) => (
96+
<MenuItem key={locale.id} id={locale.id} textValue={locale.label}>
97+
<div className="flex items-center gap-2">
98+
<span>{locale.label}</span>
99+
{locale.id === currentLocale && <CheckIcon className="ml-auto h-4 w-4" />}
100+
</div>
101+
</MenuItem>
102+
))}
103+
</Menu>
104+
</MenuTrigger>
105+
);
106+
}
107+
108+
// Icon variant
109+
const menuContent = (
110+
<MenuTrigger>
111+
<Button variant="icon" aria-label={t`Change language`}>
112+
<GlobeIcon className="h-5 w-5" />
113+
</Button>
114+
<Menu onAction={handleLocaleChange} aria-label={t`Change language`}>
115+
{locales.map((locale) => (
116+
<MenuItem key={locale.id} id={locale.id} textValue={locale.label}>
117+
<div className="flex items-center gap-2">
118+
<span>{locale.label}</span>
119+
{locale.id === currentLocale && <CheckIcon className="ml-auto h-4 w-4" />}
120+
</div>
121+
</MenuItem>
122+
))}
123+
</Menu>
124+
</MenuTrigger>
125+
);
126+
127+
return (
128+
<TooltipTrigger>
129+
{menuContent}
130+
<Tooltip>{t`Change language`}</Tooltip>
131+
</TooltipTrigger>
132+
);
133+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { t } from "@lingui/core/macro";
2+
import { Button } from "@repo/ui/components/Button";
3+
import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip";
4+
import { MailQuestion } from "lucide-react";
5+
import { SupportDialog } from "./SupportDialog";
6+
import "@repo/ui/tailwind.css";
7+
8+
export default function SupportButton() {
9+
return (
10+
<SupportDialog>
11+
<TooltipTrigger>
12+
<Button variant="icon" aria-label={t`Contact support`}>
13+
<MailQuestion size={20} />
14+
</Button>
15+
<Tooltip>{t`Contact support`}</Tooltip>
16+
</TooltipTrigger>
17+
</SupportDialog>
18+
);
19+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { t } from "@lingui/core/macro";
2+
import { Button } from "@repo/ui/components/Button";
3+
import { Dialog, DialogTrigger } from "@repo/ui/components/Dialog";
4+
import { Heading } from "@repo/ui/components/Heading";
5+
import { Modal } from "@repo/ui/components/Modal";
6+
import { MailIcon, XIcon } from "lucide-react";
7+
import type { ReactNode } from "react";
8+
9+
interface SupportDialogProps {
10+
children: ReactNode;
11+
}
12+
13+
export function SupportDialog({ children }: Readonly<SupportDialogProps>) {
14+
return (
15+
<DialogTrigger>
16+
{children}
17+
<Modal isDismissable={true} zIndex="high">
18+
<Dialog className="max-w-lg">
19+
{({ close }) => (
20+
<>
21+
<XIcon onClick={close} className="absolute top-2 right-2 h-10 w-10 cursor-pointer p-2 hover:bg-muted" />
22+
<Heading slot="title" className="text-2xl">
23+
{t`Contact support`}
24+
</Heading>
25+
<p className="text-muted-foreground text-sm">{t`Need help? Our support team is here to assist you.`}</p>
26+
<div className="mt-4 flex flex-col gap-4">
27+
<div className="flex items-center gap-3 rounded-lg border border-input bg-input-background p-4 opacity-50">
28+
<MailIcon className="h-5 w-5 text-muted-foreground" />
29+
<a href="mailto:support@platformplatform.net" className="text-primary hover:underline">
30+
support@platformplatform.net
31+
</a>
32+
</div>
33+
<p className="text-muted-foreground text-sm">{t`Feel free to reach out with any questions or issues you may have.`}</p>
34+
<div className="mt-6 flex justify-end gap-4">
35+
<Button onPress={close}>{t`Close`}</Button>
36+
</div>
37+
</div>
38+
</>
39+
)}
40+
</Dialog>
41+
</Modal>
42+
</DialogTrigger>
43+
);
44+
}

0 commit comments

Comments
 (0)