Skip to content

Commit ff577ed

Browse files
arbrandesclaude
andcommitted
feat!: header parity with frontend-component-header
Bring the shell header closer to visual parity with the old frontend-component-header used by legacy MFEs. The logo now links to whichever route claims the homeRole. Active nav links get highlighted with the same dark navy background the old header used, matching location.pathname against the link URL with trailing-slash normalization. The user dropdown replaces the generic Person icon and DropdownButton with Paragon's AvatarButton inside a Dropdown, styled as outline-primary. The user's profile image is shown when available, with Paragon's default avatar fallback otherwise. Header and page content now use Container fluid size="xl" instead of fixed padding, so margins align at all viewport widths. The root / redirect is now conditional: it only applies when an app claims the home role, and is appended last so an app with its own / route takes priority. BREAKING CHANGE: route handles use a roles array instead of a single role string. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent babdc85 commit ff577ed

25 files changed

Lines changed: 142 additions & 62 deletions

docs/how_tos/migrate-frontend-app.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,7 @@ const app: App = {
725725
id: 'example.page',
726726
Component: ExamplePage,
727727
handle: {
728-
role: 'example'
728+
roles: ['example']
729729
}
730730
}],
731731
};
@@ -835,7 +835,7 @@ const siteConfig: SiteConfig = {
835835
<div>Test App 1</div>
836836
),
837837
handle: {
838-
role: 'test-app-1'
838+
roles: ['test-app-1']
839839
}
840840
}]
841841
}],

runtime/react/hooks/useActiveRouteRoleWatcher.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ const useActiveRouteRoleWatcher = () => {
1414
// Route roles
1515
for (const match of matches) {
1616
if (isRoleRouteObject(match)) {
17-
if (!roles.includes(match.handle.role)) {
18-
roles.push(match.handle.role);
17+
for (const role of match.handle.roles) {
18+
if (!roles.includes(role)) {
19+
roles.push(role);
20+
}
1921
}
2022
}
2123
}

runtime/routing/utils.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ describe('getUrlByRouteRole', () => {
1212
appId: 'test-app',
1313
routes: [{
1414
path: '/app1',
15-
handle: { role: 'test-app-1' },
15+
handle: { roles: ['test-app-1'] },
1616
}],
1717
}],
1818
} as any);
@@ -29,7 +29,7 @@ describe('getUrlByRouteRole', () => {
2929
children: [
3030
{
3131
path: 'login',
32-
handle: { role: 'org.openedx.frontend.role.login' },
32+
handle: { roles: ['org.openedx.frontend.role.login'] },
3333
},
3434
],
3535
}],

runtime/routing/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ function findRoleInRoutes(routes: RouteObject[], role: string, prefix = ''): str
88
const segment = route.path ?? '';
99
const fullPath = segment.startsWith('/') ? segment : `${prefix}/${segment}`.replace(/\/+/g, '/');
1010

11-
if (route.handle?.role === role) {
11+
if (route.handle?.roles?.includes(role)) {
1212
return fullPath || null;
1313
}
1414

@@ -48,5 +48,5 @@ export function getUrlByRouteRole(role: string) {
4848
}
4949

5050
export function isRoleRouteObject(match: RouteObject): match is RoleRouteObject {
51-
return match.handle !== undefined && 'role' in match.handle;
51+
return match.handle !== undefined && 'roles' in match.handle;
5252
}

shell/Logo.test.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,31 +16,31 @@ describe('Logo component', () => {
1616
setSiteConfig(originalConfig);
1717
});
1818

19-
it('renders the image with default URL when no imageUrl prop is provided and headerLogoImageUrl is not set in site config', async () => {
20-
const { getByRole, queryByRole } = render(<Logo />);
19+
it('renders the image with default URL and links to / when no props are provided', async () => {
20+
const { getByRole } = render(<Logo />);
2121
const image = getByRole('img');
2222
expect(image).toHaveAttribute('src', 'https://edx-cdn.org/v3/default/logo.svg');
23-
const link = queryByRole('link');
24-
expect(link).toBeNull();
23+
const link = getByRole('link');
24+
expect(link).toHaveAttribute('href', '/');
2525
});
2626

27-
it('renders the image with provided imageUrl', async () => {
27+
it('renders the image with provided imageUrl and links to / by default', async () => {
2828
const testUrl = 'https://example.com/test-logo.svg';
29-
const { getByRole, queryByRole } = render(<Logo imageUrl={testUrl} />);
29+
const { getByRole } = render(<Logo imageUrl={testUrl} />);
3030
const image = getByRole('img');
3131
expect(image).toHaveAttribute('src', testUrl);
32-
const link = queryByRole('link');
33-
expect(link).toBeNull();
32+
const link = getByRole('link');
33+
expect(link).toHaveAttribute('href', '/');
3434
});
3535

36-
it('renders the image with headerLogoImageUrl when set in site config and no imageUrl prop is provided', async () => {
36+
it('renders the image with headerLogoImageUrl when set in site config', async () => {
3737
const configLogoUrl = 'https://example.com/config-logo.svg';
3838
mergeSiteConfig({ headerLogoImageUrl: configLogoUrl });
39-
const { getByRole, queryByRole } = render(<Logo />);
39+
const { getByRole } = render(<Logo />);
4040
const image = getByRole('img');
4141
expect(image).toHaveAttribute('src', configLogoUrl);
42-
const link = queryByRole('link');
43-
expect(link).toBeNull();
42+
const link = getByRole('link');
43+
expect(link).toHaveAttribute('href', '/');
4444
});
4545

4646
it('renders the image wrapped in a Hyperlink when destinationUrl is provided', async () => {

shell/Logo.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { IntlProvider } from 'react-intl';
22
import { Hyperlink, Image } from '@openedx/paragon';
33
import { getSiteConfig } from '../runtime/config';
4+
import { getUrlByRouteRole } from '../runtime/routing';
5+
import { homeRole } from './constants';
46

57
interface LogoProps {
68
imageUrl?: string,
@@ -9,7 +11,7 @@ interface LogoProps {
911

1012
export default function Logo({
1113
imageUrl = getSiteConfig().headerLogoImageUrl ?? 'https://edx-cdn.org/v3/default/logo.svg',
12-
destinationUrl
14+
destinationUrl = getUrlByRouteRole(homeRole) || '/'
1315
}: LogoProps) {
1416
const image = (
1517
<Image src={imageUrl} style={{ maxHeight: '2rem' }} />

shell/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export const homeRole = 'org.openedx.frontend.role.home';
12
export const providesChromelessRolesId = 'org.openedx.frontend.provides.chromelessRoles.v1';

shell/dev/devHome/HomePage.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Container } from '@openedx/paragon';
12
import { Link } from 'react-router-dom';
23
import { useIntl } from '../../../runtime';
34
import { getUrlByRouteRole } from '../../../runtime/routing';
@@ -10,7 +11,7 @@ export default function HomePage() {
1011
const intl = useIntl();
1112

1213
return (
13-
<div className="p-3">
14+
<Container fluid size="xl">
1415
<p>{intl.formatMessage(messages.homeContent)}</p>
1516
<ul>
1617
{coursewareUrl !== null && (
@@ -23,6 +24,6 @@ export default function HomePage() {
2324
<li><Link to={slotShowcaseUrl}>Go to slot showcase page</Link></li>
2425
)}
2526
</ul>
26-
</div>
27+
</Container>
2728
);
2829
}

shell/dev/devHome/app.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { App } from '../../../types';
2+
import { homeRole } from '../../constants';
23
import HomePage from './HomePage';
34

45
const app: App = {
56
appId: 'org.openedx.frontend.app.dev.home',
67
routes: [{
7-
path: '/',
8+
path: '/dev',
89
id: 'org.openedx.frontend.route.dev.home',
910
Component: HomePage,
1011
handle: {
11-
role: 'org.openedx.frontend.role.devHome'
12+
roles: [homeRole],
1213
}
1314
}],
1415
};

shell/dev/slotShowcase/SlotShowcasePage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function Section({ title, children }: { title: string, children: ReactNode }) {
2727

2828
export default function SlotShowcasePage() {
2929
return (
30-
<Container size="xl" className="showcase-page py-4">
30+
<Container fluid size="xl" className="showcase-page py-4">
3131
<div className="showcase-full-width">
3232
<h1>Slot Showcase</h1>
3333
<p>As a best practice, widgets should pass additional props (<code>...props</code>) to their rendered HTMLElement. This allows custom layouts to add <code>className</code> and <code>style</code> props as necessary for the layout.</p>

0 commit comments

Comments
 (0)