Skip to content

Commit 9292ea8

Browse files
arbrandesclaude
andcommitted
feat!: use provides for chromeless shell mode
Apps can now request chromeless mode (no header or footer) by listing their route roles under providesChromelessRolesId in their `provides` config. The shell checks these against active roles at render time. A new getProvidesAsStrings() runtime helper is added beside getProvides(). The shell now exports providesChromelessRolesId and providesCourseNavigationRolesId for use by consuming apps. BREAKING CHANGE: The course navigation bar's provides data shape is simplified from { courseNavigationRoles: string[] } to a plain string[]. getProvidesAsRoles() is replaced by getProvidesAsStrings() and moved from the shell into the runtime. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3b90095 commit 9292ea8

14 files changed

Lines changed: 144 additions & 124 deletions

File tree

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

Lines changed: 45 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
App ``provides`` for Inter-App Data
2-
####################################
1+
App ``provides`` for Inter-App Configuration
2+
############################################
33

44
Status
55
======
@@ -11,15 +11,15 @@ Context
1111
=======
1212

1313
frontend-base applications currently communicate through two structured
14-
mechanisms: ``routes`` and ``slots``. Both are defined in the ``App`` interface
15-
and consumed directly by frontend-base's runtime.
14+
mechanisms: ``routes``, ``slots``, and ``providers``. All are defined in the
15+
``App`` interface and consumed directly by frontend-base's runtime.
1616

17-
As the platform evolves, however, situations arise where apps need to share data
18-
with each other that frontend-base itself has no reason to understand. A
19-
concrete example is the course navigation bar introduced in the header app.
20-
The header needs to know two things from other apps:
17+
As the platform evolves, however, situations arise where apps need to share
18+
configuration data with each other that frontend-base itself has no reason to
19+
understand. A concrete example is the course navigation bar introduced in the
20+
header app. The header needs to know two things from other apps:
2121

22-
1. Which apps want the course navigation bar to appear (currently a hardcoded
22+
1. Which apps want the course navigation bar to appear (previously a hardcoded
2323
list of roles in ``constants.ts``).
2424

2525
2. Which URL patterns each app handles client-side, so the navigation bar can
@@ -36,8 +36,8 @@ runtime needs to interpret them directly. It builds a router from ``routes``
3636
and renders widgets from ``slots``. Any new field that frontend-base itself
3737
must consume deserves the same treatment: a dedicated, typed field.
3838

39-
But for data that flows between apps - where frontend-base is just the conduit -
40-
a generic mechanism is more appropriate.
39+
But for generic configuration between apps - where frontend-base is just the
40+
conduit - a generic mechanism is more appropriate.
4141

4242

4343
Decision
@@ -55,25 +55,23 @@ Add an optional ``provides`` field to the ``App`` interface::
5555
provides?: Record<string, unknown>,
5656
}
5757

58-
``provides`` is a flat key-value map where each key is an identifier agreed
59-
upon by the providing and consuming apps, and the value is whatever the
60-
consumer expects. frontend-base stores this data and exposes it through a
61-
runtime function, but does not interpret it. Any namespaced identifier can
62-
serve as a key.
58+
``provides`` is a flat key-value map where each key is a namespaced identifier
59+
agreed upon by the providing and consuming apps, and the value takes whatever
60+
shape the consuming app expects. The runtime stores this data and exposes it
61+
through a runtime function, but does not interpret it.
6362

6463
A runtime helper would look something like::
6564

66-
// Returns all `provides` entries matching the given key.
67-
function getProvidedData(key: string): unknown[]
65+
// Returns all `provides` entries matching the given identifier.
66+
function getProvides(id: string): unknown[]
6867

6968

7069
Guidelines
7170
==========
7271

73-
1. ``provides`` is for inter-app data that frontend-base does not need to
74-
interpret. If frontend-base's runtime must consume the data to function
75-
(as it does with routes and slots), a dedicated typed field on ``App`` is
76-
the right choice.
72+
1. ``provides`` is for inter-app configuration that the runtime does not need
73+
to interpret. If it must consume the data to function (as it does with
74+
routes and slots), a dedicated typed field on ``App`` is the right choice.
7775

7876
2. Keys in ``provides`` should be their own namespaced identifiers, not
7977
duplicates of existing app, slot, or widget IDs. This allows different
@@ -84,8 +82,8 @@ Guidelines
8482
consuming apps. It is not enforced by frontend-base. Consuming apps should
8583
validate or type-guard the data they receive.
8684

87-
4. ``provides`` should not be used as a back door to modify frontend-base's
88-
behavior. It is not a configuration mechanism for the runtime.
85+
4. ``provides`` should not be used as a back door to modify the runtime's
86+
behavior. It is not a configuration mechanism for the runtime itself.
8987

9088

9189
Consequences
@@ -107,28 +105,29 @@ Course navigation bar example
107105
As a concrete illustration, the Instructor Dashboard app could declare::
108106

109107
const config: App = {
110-
appId: 'org.openedx.frontend.app.instructor',
108+
appId: 'org.openedx.frontend.app.instructorDashboard',
111109
provides: {
112-
'org.openedx.frontend.provides.courseNavigationRoles.v1': {
113-
courseNavigationRoles: ['org.openedx.frontend.role.instructor'],
114-
},
110+
'org.openedx.frontend.provides.courseNavigationRoles.v1': [
111+
'org.openedx.frontend.role.instructorDashboard',
112+
],
115113
},
116114
routes: [...],
117115
slots: [...],
118116
};
119117

120118
The header's course navigation bar widget collects ``provides`` entries keyed
121-
to its provides identifier from all registered apps. From the provided roles
122-
it determines both when to render the navigation bar (by checking
123-
``getActiveRoles()``) and which tab URLs can be navigated client-side (by
124-
resolving roles to route paths via ``getUrlByRouteRole()``).
119+
to the course navigation roles identifier from all registered apps. It expects
120+
the provided values to be role identifiers, from which it determines both when
121+
to render the navigation bar (by checking ``getActiveRoles()``) and which tab
122+
URLs can be navigated client-side (by resolving roles to route paths via
123+
``getUrlByRouteRole()``).
125124

126125

127126
Rejected alternatives
128127
=====================
129128

130-
Slot operations
131-
---------------
129+
Widget operations
130+
-----------------
132131

133132
Each app could register its own widget into the course navigation bar slot
134133
with an ``active`` condition on its role. The ``OPTIONS`` operation can even
@@ -157,3 +156,15 @@ with no standard way to discover them. Providers are the right tool when data
157156
changes over time and consumers need to re-render. The course navigation roles
158157
are fixed at registration time and never change, making ``provides`` a more
159158
natural fit.
159+
160+
Reusing ``App.config``
161+
----------------------
162+
163+
The existing ``App.config`` field has the same type (``Record<string, unknown>``)
164+
and could theoretically hold provided data. However, ``config`` is per-app: it
165+
is retrieved by ``appId`` via ``getAppConfig()`` and is meant to hold settings
166+
*for* that app. ``provides`` has a cross-app access pattern:
167+
``getProvides()`` collects entries from all apps that declared data under a
168+
given identifier. Merging the two would require scanning every app's config
169+
for a specific key, blurring the distinction between settings an app consumes
170+
and data it exposes for others.

runtime/config/index.test.ts

Lines changed: 13 additions & 13 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.testProvidesId.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.testProvidesId.v1')).toEqual([]);
370370
});
371371

372372
it('should collect provided data from apps that declare it', () => {
@@ -376,19 +376,19 @@ describe('mergeSiteConfig', () => {
376376
{
377377
appId: 'app-one',
378378
provides: {
379-
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/one/' },
379+
'org.openedx.frontend.provides.testProvidesId.v1': { urlPattern: '/one/' },
380380
},
381381
},
382382
{
383383
appId: 'app-two',
384384
provides: {
385-
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/two/' },
385+
'org.openedx.frontend.provides.testProvidesId.v1': { urlPattern: '/two/' },
386386
},
387387
},
388388
],
389389
});
390390

391-
const result = getProvidedData('org.openedx.frontend.provides.testKey.v1');
391+
const result = getProvides('org.openedx.frontend.provides.testProvidesId.v1');
392392
expect(result).toEqual([
393393
{ urlPattern: '/one/' },
394394
{ urlPattern: '/two/' },
@@ -402,17 +402,17 @@ describe('mergeSiteConfig', () => {
402402
{
403403
appId: 'app-one',
404404
provides: {
405-
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/one/' },
406-
'org.openedx.frontend.provides.otherKey.v1': { showBranding: true },
405+
'org.openedx.frontend.provides.testProvidesId.v1': { urlPattern: '/one/' },
406+
'org.openedx.frontend.provides.otherProvidesId.v1': { showBranding: true },
407407
},
408408
},
409409
],
410410
});
411411

412-
const headerData = getProvidedData('org.openedx.frontend.provides.testKey.v1');
412+
const headerData = getProvides('org.openedx.frontend.provides.testProvidesId.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.otherProvidesId.v1');
416416
expect(footerData).toEqual([{ showBranding: true }]);
417417
});
418418

@@ -424,14 +424,14 @@ describe('mergeSiteConfig', () => {
424424
{
425425
appId: 'app-two',
426426
provides: {
427-
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/two/' },
427+
'org.openedx.frontend.provides.testProvidesId.v1': { urlPattern: '/two/' },
428428
},
429429
},
430430
{ appId: 'app-three', config: { VALUE: 'test' } },
431431
],
432432
});
433433

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

runtime/config/index.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -325,25 +325,39 @@ export function getActiveRoles() {
325325
}
326326

327327
/**
328-
* Collects all `provides` entries from registered apps that match the given key.
328+
* Collects all `provides` entries from registered apps that match the given identifier.
329329
* This enables inter-app data sharing without frontend-base needing to understand the data shape.
330330
*
331-
* @param key - The namespaced identifier for the provided data.
332-
* @returns An array of provided data objects from all apps that declared data for this key.
331+
* @param id - The namespaced provides identifier.
332+
* @returns An array of provided data from all apps that declared data for this identifier.
333333
*/
334-
export function getProvidedData(key: string): unknown[] {
334+
export function getProvides(id: string): unknown[] {
335335
const { apps } = getSiteConfig();
336336
if (!apps) return [];
337337

338338
const results: unknown[] = [];
339339
for (const app of apps) {
340-
if (app.provides && app.provides[key] !== undefined) {
341-
results.push(app.provides[key]);
340+
if (app.provides && app.provides[id] !== undefined) {
341+
results.push(app.provides[id]);
342342
}
343343
}
344344
return results;
345345
}
346346

347+
/**
348+
* Collects and flattens all `provides` entries for the given identifier
349+
* as strings. Each entry can be a single string or a string array; entries
350+
* of other types are silently skipped.
351+
*
352+
* @param id - The namespaced provides identifier.
353+
* @returns A flat array of strings from all apps that declared data for this identifier.
354+
*/
355+
export function getProvidesAsStrings(id: string): string[] {
356+
return getProvides(id)
357+
.filter((data): data is string | string[] => typeof data === 'string' || Array.isArray(data))
358+
.flat();
359+
}
360+
347361
/**
348362
* Get an external link URL based on the URL provided. If the passed in URL is overridden in the
349363
* `externalLinkUrlOverrides` object, it will return the overridden URL. Otherwise, it will return

runtime/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export {
4545
getActiveWidgetRoles,
4646
getActiveRoles,
4747
getExternalLinkUrl,
48-
getProvidedData
48+
getProvides,
49+
getProvidesAsStrings
4950
} from './config';
5051

5152
export * from './constants';

shell/app.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
1-
import { WidgetOperationTypes } from '../runtime';
1+
import { getActiveRoles, getProvidesAsStrings, WidgetOperationTypes } from '../runtime';
22
import { App } from '../types';
33
import { Footer } from './footer';
44
import { Header } from './header';
5+
import { providesChromelessRolesId } from './constants';
56

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-
];
7+
/*
8+
* Returns false when the current route should be chromeless (no header or
9+
* footer). Apps request chromeless mode by listing their route roles under
10+
* providesChromelessRolesId in their `provides` entry, e.g.:
11+
*
12+
* provides: { [providesChromelessRolesId]: ['org.openedx.frontend.role.authn'] }
13+
*
14+
* The widget is disabled when any of those roles is currently active.
15+
*/
16+
function isChromeVisible(): boolean {
17+
const activeRoles = getActiveRoles();
18+
const chromelessRoles = getProvidesAsStrings(providesChromelessRolesId);
19+
return !chromelessRoles.some(role => activeRoles.includes(role));
20+
}
1321

1422
const app: App = {
1523
appId: 'org.openedx.frontend.app.shell',
@@ -20,7 +28,7 @@ const app: App = {
2028
op: WidgetOperationTypes.APPEND,
2129
component: Header,
2230
condition: {
23-
inactive,
31+
callback: isChromeVisible,
2432
}
2533
},
2634
{
@@ -29,7 +37,7 @@ const app: App = {
2937
op: WidgetOperationTypes.APPEND,
3038
component: Footer,
3139
condition: {
32-
inactive,
40+
callback: isChromeVisible,
3341
}
3442
},
3543
]

shell/constants.ts

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

shell/header/app.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import MobileNavLinks from './mobile/MobileNavLinks';
1414
import messages from '../Shell.messages';
1515
import CourseTabsNavigation from './course-navigation-bar/CourseTabsNavigation';
1616
import { isCourseNavigationRoute } from './course-navigation-bar/utils';
17-
import { appId, courseNavigationBarSlotId, courseTabsNavigationWidgetId } from './constants';
17+
import { appId } from './constants';
1818

1919
const config: App = {
2020
appId,
@@ -139,8 +139,8 @@ const config: App = {
139139
}
140140
},
141141
{
142-
slotId: courseNavigationBarSlotId,
143-
id: courseTabsNavigationWidgetId,
142+
slotId: 'org.openedx.frontend.slot.header.courseNavigationBar.v1',
143+
id: 'org.openedx.frontend.widget.header.courseNavigationBar.v1',
144144
op: WidgetOperationTypes.APPEND,
145145
component: CourseTabsNavigation,
146146
condition: {

shell/header/constants.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,2 @@
11
export const appId = 'org.openedx.frontend.app.header';
2-
export const courseNavigationBarSlotId = 'org.openedx.frontend.slot.header.courseNavigationBar.v1';
3-
export const courseTabsNavigationWidgetId = 'org.openedx.frontend.widget.header.courseTabsNavigation.v1';
4-
export const courseNavigationRolesProvidesKey = 'org.openedx.frontend.provides.courseNavigationRoles.v1';
2+
export const providesCourseNavigationRolesId = 'org.openedx.frontend.provides.courseNavigationRoles.v1';

shell/header/course-navigation-bar/CourseTabsNavigation.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@ interface ResolvedTab extends CourseTab {
1313
clientPath: string | null,
1414
}
1515

16-
// Returns the tabId of the tab whose pathname is the longest prefix match
17-
// against the current path. Uses react-router's matchPath for segment-aware
18-
// matching. For example, given tabs with paths /course/ (tabId: "outline")
19-
// and /course/dates/ (tabId: "dates"):
20-
// /course/dates/foo -> "dates" (longest prefix match)
21-
// /course/outline -> "outline"
22-
// /courseware -> null (not a segment boundary)
16+
/*
17+
* Returns the tabId of the tab whose pathname is the longest prefix match
18+
* against the current path. Uses react-router's matchPath for segment-aware
19+
* matching.
20+
*
21+
* For example, given tabs with paths /course/ (tabId: "outline")
22+
* and /course/dates/ (tabId: "dates"):
23+
*
24+
* /course/dates/foo -> "dates" (longest prefix match)
25+
* /course/outline -> "outline"
26+
* /courseware -> null (not a segment boundary)
27+
*/
2328
const getActiveTabId = (currentPath: string, tabs: ResolvedTab[]): string | null => {
2429
let best: ResolvedTab | null = null;
2530
for (const tab of tabs) {

0 commit comments

Comments
 (0)