Skip to content

Commit e50de2b

Browse files
arbrandesclaude
andcommitted
feat: use provides for shell header/footer visibility
Replace hardcoded inactive role list with `provides`-based callbacks, letting apps declare when header/footer should be hidden. Rename `getProvidedData` to `getProvides` and add shared `getProvidesAsRoles` helper to deduplicate role ingestion across consumers. BREAKING CHANGE: `getProvidedData` renamed to `getProvides`. Course navigation bar `provides` data shape changed from `{ courseNavigationRoles: string[] }` to `string[]`. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3b90095 commit e50de2b

11 files changed

Lines changed: 62 additions & 67 deletions

File tree

docs/decisions/0013-app-provides-for-inter-app-data.rst

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ serve as a key.
6464
A runtime helper would look something like::
6565

6666
// Returns all `provides` entries matching the given key.
67-
function getProvidedData(key: string): unknown[]
67+
function getProvides(key: string): unknown[]
6868

6969

7070
Guidelines
@@ -109,9 +109,9 @@ As a concrete illustration, the Instructor Dashboard app could declare::
109109
const config: App = {
110110
appId: 'org.openedx.frontend.app.instructor',
111111
provides: {
112-
'org.openedx.frontend.provides.courseNavigationRoles.v1': {
113-
courseNavigationRoles: ['org.openedx.frontend.role.instructor'],
114-
},
112+
'org.openedx.frontend.provides.courseNavigationRoles.v1': [
113+
'org.openedx.frontend.role.instructor',
114+
],
115115
},
116116
routes: [...],
117117
slots: [...],

runtime/config/index.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as subscriptions from '../subscriptions';
33
import {
44
addAppConfigs,
55
getAppConfig,
6-
getProvidedData,
6+
getProvides,
77
getSiteConfig,
88
mergeSiteConfig,
99
setSiteConfig,
@@ -352,10 +352,10 @@ describe('mergeSiteConfig', () => {
352352
});
353353
});
354354

355-
describe('getProvidedData', () => {
355+
describe('getProvides', () => {
356356
it('should return empty array when no apps exist', () => {
357357
setSiteConfig({ ...defaultSiteConfig, apps: [] });
358-
expect(getProvidedData('org.openedx.frontend.provides.testKey.v1')).toEqual([]);
358+
expect(getProvides('org.openedx.frontend.provides.testKey.v1')).toEqual([]);
359359
});
360360

361361
it('should return empty array when no apps provide data for the consumer', () => {
@@ -366,7 +366,7 @@ describe('mergeSiteConfig', () => {
366366
{ appId: 'app-two' },
367367
],
368368
});
369-
expect(getProvidedData('org.openedx.frontend.provides.testKey.v1')).toEqual([]);
369+
expect(getProvides('org.openedx.frontend.provides.testKey.v1')).toEqual([]);
370370
});
371371

372372
it('should collect provided data from apps that declare it', () => {
@@ -388,7 +388,7 @@ describe('mergeSiteConfig', () => {
388388
],
389389
});
390390

391-
const result = getProvidedData('org.openedx.frontend.provides.testKey.v1');
391+
const result = getProvides('org.openedx.frontend.provides.testKey.v1');
392392
expect(result).toEqual([
393393
{ urlPattern: '/one/' },
394394
{ urlPattern: '/two/' },
@@ -409,10 +409,10 @@ describe('mergeSiteConfig', () => {
409409
],
410410
});
411411

412-
const headerData = getProvidedData('org.openedx.frontend.provides.testKey.v1');
412+
const headerData = getProvides('org.openedx.frontend.provides.testKey.v1');
413413
expect(headerData).toEqual([{ urlPattern: '/one/' }]);
414414

415-
const footerData = getProvidedData('org.openedx.frontend.provides.otherKey.v1');
415+
const footerData = getProvides('org.openedx.frontend.provides.otherKey.v1');
416416
expect(footerData).toEqual([{ showBranding: true }]);
417417
});
418418

@@ -431,7 +431,7 @@ describe('mergeSiteConfig', () => {
431431
],
432432
});
433433

434-
const result = getProvidedData('org.openedx.frontend.provides.testKey.v1');
434+
const result = getProvides('org.openedx.frontend.provides.testKey.v1');
435435
expect(result).toEqual([{ urlPattern: '/two/' }]);
436436
});
437437
});

runtime/config/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ export function getActiveRoles() {
331331
* @param key - The namespaced identifier for the provided data.
332332
* @returns An array of provided data objects from all apps that declared data for this key.
333333
*/
334-
export function getProvidedData(key: string): unknown[] {
334+
export function getProvides(key: string): unknown[] {
335335
const { apps } = getSiteConfig();
336336
if (!apps) return [];
337337

runtime/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export {
4545
getActiveWidgetRoles,
4646
getActiveRoles,
4747
getExternalLinkUrl,
48-
getProvidedData
48+
getProvides
4949
} from './config';
5050

5151
export * from './constants';

shell/app.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import { WidgetOperationTypes } from '../runtime';
1+
import { getActiveRoles, WidgetOperationTypes } from '../runtime';
22
import { App } from '../types';
33
import { Footer } from './footer';
44
import { Header } from './header';
5+
import { hideFooterRolesProvidesKey, hideHeaderRolesProvidesKey } from './constants';
6+
import { getProvidesAsRoles } from './provides';
57

6-
const inactive = [
7-
'org.openedx.frontend.role.login',
8-
'org.openedx.frontend.role.register',
9-
'org.openedx.frontend.role.resetPassword',
10-
'org.openedx.frontend.role.confirmPassword',
11-
'org.openedx.frontend.role.welcome'
12-
];
8+
function isActive(providesKey: string): boolean {
9+
const activeRoles = getActiveRoles();
10+
const inactiveRoles = getProvidesAsRoles(providesKey);
11+
return !inactiveRoles.some(role => activeRoles.includes(role));
12+
}
1313

1414
const app: App = {
1515
appId: 'org.openedx.frontend.app.shell',
@@ -20,7 +20,7 @@ const app: App = {
2020
op: WidgetOperationTypes.APPEND,
2121
component: Header,
2222
condition: {
23-
inactive,
23+
callback: () => isActive(hideHeaderRolesProvidesKey),
2424
}
2525
},
2626
{
@@ -29,7 +29,7 @@ const app: App = {
2929
op: WidgetOperationTypes.APPEND,
3030
component: Footer,
3131
condition: {
32-
inactive,
32+
callback: () => isActive(hideFooterRolesProvidesKey),
3333
}
3434
},
3535
]

shell/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const hideHeaderRolesProvidesKey = 'org.openedx.frontend.provides.hideHeaderRoles.v1';
2+
export const hideFooterRolesProvidesKey = 'org.openedx.frontend.provides.hideFooterRoles.v1';

shell/header/course-navigation-bar/utils.test.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,31 @@
11
import { isClientRoute, isCourseNavigationRoute } from './utils';
22
import * as runtime from '../../../runtime';
3+
import * as provides from '../../provides';
34

45
jest.mock('../../../runtime');
6+
jest.mock('../../provides');
57

68
const mockGetActiveRoles = runtime.getActiveRoles as jest.MockedFunction<typeof runtime.getActiveRoles>;
7-
const mockGetProvidedData = runtime.getProvidedData as jest.MockedFunction<typeof runtime.getProvidedData>;
9+
const mockGetProvidedRoles = provides.getProvidesAsRoles as jest.MockedFunction<typeof provides.getProvidesAsRoles>;
810
const mockGetUrlByRouteRole = runtime.getUrlByRouteRole as jest.MockedFunction<typeof runtime.getUrlByRouteRole>;
911

1012
describe('isCourseNavigationRoute', () => {
1113
it('returns true when a provided role is active', () => {
12-
mockGetProvidedData.mockReturnValue([
13-
{ courseNavigationRoles: ['org.openedx.frontend.role.instructor'] },
14-
]);
14+
mockGetProvidedRoles.mockReturnValue(['org.openedx.frontend.role.instructor']);
1515
mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.instructor']);
1616

1717
expect(isCourseNavigationRoute()).toBe(true);
1818
});
1919

2020
it('returns false when no provided roles are active', () => {
21-
mockGetProvidedData.mockReturnValue([
22-
{ courseNavigationRoles: ['org.openedx.frontend.role.instructor'] },
23-
]);
21+
mockGetProvidedRoles.mockReturnValue(['org.openedx.frontend.role.instructor']);
2422
mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.learning']);
2523

2624
expect(isCourseNavigationRoute()).toBe(false);
2725
});
2826

2927
it('returns false when no providers exist', () => {
30-
mockGetProvidedData.mockReturnValue([]);
28+
mockGetProvidedRoles.mockReturnValue([]);
3129
mockGetActiveRoles.mockReturnValue(['org.openedx.frontend.role.instructor']);
3230

3331
expect(isCourseNavigationRoute()).toBe(false);
@@ -36,45 +34,35 @@ describe('isCourseNavigationRoute', () => {
3634

3735
describe('isClientRoute', () => {
3836
it('matches a pathname under a static route path', () => {
39-
mockGetProvidedData.mockReturnValue([
40-
{ courseNavigationRoles: ['org.openedx.frontend.role.learning'] },
41-
]);
37+
mockGetProvidedRoles.mockReturnValue(['org.openedx.frontend.role.learning']);
4238
mockGetUrlByRouteRole.mockReturnValue('/course');
4339

4440
expect(isClientRoute('/course/outline')).toBe(true);
4541
});
4642

4743
it('matches a pathname under a parameterized route path', () => {
48-
mockGetProvidedData.mockReturnValue([
49-
{ courseNavigationRoles: ['org.openedx.frontend.role.instructor'] },
50-
]);
44+
mockGetProvidedRoles.mockReturnValue(['org.openedx.frontend.role.instructor']);
5145
mockGetUrlByRouteRole.mockReturnValue('/instructor-dashboard/:courseId');
5246

5347
expect(isClientRoute('/instructor-dashboard/course-v1:edX+DemoX+Demo')).toBe(true);
5448
});
5549

5650
it('does not match a pathname outside the route prefix', () => {
57-
mockGetProvidedData.mockReturnValue([
58-
{ courseNavigationRoles: ['org.openedx.frontend.role.instructor'] },
59-
]);
51+
mockGetProvidedRoles.mockReturnValue(['org.openedx.frontend.role.instructor']);
6052
mockGetUrlByRouteRole.mockReturnValue('/instructor-dashboard/:courseId');
6153

6254
expect(isClientRoute('/courses/some-course/instructor')).toBe(false);
6355
});
6456

6557
it('returns false for external routes', () => {
66-
mockGetProvidedData.mockReturnValue([
67-
{ courseNavigationRoles: ['org.openedx.frontend.role.learning'] },
68-
]);
58+
mockGetProvidedRoles.mockReturnValue(['org.openedx.frontend.role.learning']);
6959
mockGetUrlByRouteRole.mockReturnValue('https://external.example.com/course');
7060

7161
expect(isClientRoute('/course/outline')).toBe(false);
7262
});
7363

7464
it('returns false when role has no matching route', () => {
75-
mockGetProvidedData.mockReturnValue([
76-
{ courseNavigationRoles: ['org.openedx.frontend.role.learning'] },
77-
]);
65+
mockGetProvidedRoles.mockReturnValue(['org.openedx.frontend.role.learning']);
7866
mockGetUrlByRouteRole.mockReturnValue(null);
7967

8068
expect(isClientRoute('/course/outline')).toBe(false);

shell/header/course-navigation-bar/utils.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,19 @@
11
import { matchPath } from 'react-router-dom';
2-
import { getActiveRoles, getProvidedData, getUrlByRouteRole } from '../../../runtime';
2+
import { getActiveRoles, getUrlByRouteRole } from '../../../runtime';
3+
import { getProvidesAsRoles } from '../../provides';
34
import { courseNavigationRolesProvidesKey } from '../constants';
45

5-
interface CourseNavigationProviderData {
6-
courseNavigationRoles: string[],
7-
}
8-
9-
function getProviders(): CourseNavigationProviderData[] {
10-
return getProvidedData(courseNavigationRolesProvidesKey).filter(
11-
(data): data is CourseNavigationProviderData =>
12-
data !== null
13-
&& typeof data === 'object'
14-
&& 'courseNavigationRoles' in data
15-
&& Array.isArray((data as CourseNavigationProviderData).courseNavigationRoles)
16-
);
17-
}
18-
19-
function getProvidedRoles(): string[] {
20-
return getProviders().flatMap(data => data.courseNavigationRoles);
6+
function getRoles(): string[] {
7+
return getProvidesAsRoles(courseNavigationRolesProvidesKey);
218
}
229

2310
export function isCourseNavigationRoute(): boolean {
2411
const activeRoles = getActiveRoles();
25-
return getProvidedRoles().some(role => activeRoles.includes(role));
12+
return getRoles().some(role => activeRoles.includes(role));
2613
}
2714

2815
export function isClientRoute(pathname: string): boolean {
29-
return getProvidedRoles().some(role => {
16+
return getRoles().some(role => {
3017
const routePath = getUrlByRouteRole(role);
3118
return routePath !== null
3219
&& routePath.startsWith('/')

shell/header/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { default as headerApp } from './app';
2+
export { courseNavigationRolesProvidesKey } from './constants';
23
export { default as Header } from './Header';

shell/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export { default as DefaultLayout } from './DefaultLayout';
22
export { default as DefaultMain } from './DefaultMain';
33
export { default as shellApp } from './app';
44
export { Footer, footerApp } from './footer';
5-
export { Header, headerApp } from './header';
5+
export { courseNavigationRolesProvidesKey, Header, headerApp } from './header';
6+
export { hideFooterRolesProvidesKey, hideHeaderRolesProvidesKey } from './constants';
7+
export { getProvidesAsRoles } from './provides';
68
export { default as LinkMenuItem } from './menus/LinkMenuItem';
79
export { default as NavDropdownMenuSlot } from './menus/NavDropdownMenuSlot';

0 commit comments

Comments
 (0)