Skip to content

Commit 1f74a3b

Browse files
arbrandesclaude
andcommitted
feat: use features for dynamic shell header/footer visibility
Apps can now hide the shell header or footer on specific routes by declaring role strings under hideHeaderFeatureId or hideFooterFeatureId in their features config. The shell checks these against active roles at render time. BREAKING CHANGE: App.provides is now App.features. getProvidedData() is now getFeatureData(). getProvidesAsRoles() is replaced by getFeatureDataAsStrings() and moved from the shell into the runtime. All identifier constants use the FeatureId suffix and the frontend.feature.* namespace. The ADR has been renamed to 0013-app-features-for-inter-app-configuration. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3b90095 commit 1f74a3b

14 files changed

Lines changed: 144 additions & 134 deletions

File tree

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

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
App ``provides`` for Inter-App Data
2-
####################################
1+
App ``features`` 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,14 +36,14 @@ 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
4444
========
4545

46-
Add an optional ``provides`` field to the ``App`` interface::
46+
Add an optional ``features`` field to the ``App`` interface::
4747

4848
export interface App {
4949
appId: string,
@@ -52,40 +52,39 @@ Add an optional ``provides`` field to the ``App`` interface::
5252
providers?: AppProvider[],
5353
slots?: SlotOperation[],
5454
config?: AppConfig,
55-
provides?: Record<string, unknown>,
55+
features?: 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+
``features`` is a flat key-value map where each key is a feature identifier
59+
agreed upon by the providing and consuming apps, and the value takes whatever
60+
shape the app that provides the feature expects. The runtime stores this data
61+
and exposes it through a runtime function, but does not interpret it. Any
62+
namespaced identifier can serve as a key.
6363

6464
A runtime helper would look something like::
6565

66-
// Returns all `provides` entries matching the given key.
67-
function getProvidedData(key: string): unknown[]
66+
// Returns all `features` entries matching the given feature identifier.
67+
function getFeatureData(id: string): unknown[]
6868

6969

7070
Guidelines
7171
==========
7272

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.
73+
1. ``features`` is for inter-app configuration that the runtime does not need
74+
to interpret. If it must consume the data to function (as it does with
75+
routes and slots), a dedicated typed field on ``App`` is the right choice.
7776

78-
2. Keys in ``provides`` should be their own namespaced identifiers, not
77+
2. Keys in ``features`` should be their own namespaced feature identifiers, not
7978
duplicates of existing app, slot, or widget IDs. This allows different
80-
widgets or other entities to consume the same provided data independently,
79+
widgets or other entities to consume the same feature data independently,
8180
without coupling the data's identity to a single consumer.
8281

8382
3. The shape of the value under each key is a contract between the providing and
8483
consuming apps. It is not enforced by frontend-base. Consuming apps should
8584
validate or type-guard the data they receive.
8685

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.
86+
4. ``features`` should not be used as a back door to modify the runtime's
87+
behavior. It is not a configuration mechanism for the runtime itself.
8988

9089

9190
Consequences
@@ -96,7 +95,7 @@ frontend-base's ``App`` type or runtime for each new use case. The ``App``
9695
interface grows by one optional field and remains stable as new inter-app
9796
patterns emerge.
9897

99-
The trade-off is that ``provides`` data is untyped from frontend-base's
98+
The trade-off is that ``features`` data is untyped from frontend-base's
10099
perspective. Consuming apps bear the responsibility of defining, documenting,
101100
and validating the shape of the data they expect. This is acceptable because
102101
the data is, by definition, outside frontend-base's domain.
@@ -108,27 +107,28 @@ As a concrete illustration, the Instructor Dashboard app could declare::
108107

109108
const config: App = {
110109
appId: 'org.openedx.frontend.app.instructor',
111-
provides: {
112-
'org.openedx.frontend.provides.courseNavigationRoles.v1': {
113-
courseNavigationRoles: ['org.openedx.frontend.role.instructor'],
114-
},
110+
features: {
111+
'org.openedx.frontend.feature.showCourseNavigationBar.v1': [
112+
'org.openedx.frontend.role.instructor',
113+
],
115114
},
116115
routes: [...],
117116
slots: [...],
118117
};
119118

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

126126

127127
Rejected alternatives
128128
=====================
129129

130-
Slot operations
131-
---------------
130+
Widget operations
131+
-----------------
132132

133133
Each app could register its own widget into the course navigation bar slot
134134
with an ``active`` condition on its role. The ``OPTIONS`` operation can even
@@ -155,5 +155,5 @@ static data. Each app would need a context, a provider component, and a
155155
consumer hook, and the header would need to aggregate across multiple contexts
156156
with no standard way to discover them. Providers are the right tool when data
157157
changes over time and consumers need to re-render. The course navigation roles
158-
are fixed at registration time and never change, making ``provides`` a more
158+
are fixed at registration time and never change, making ``features`` a more
159159
natural fit.

runtime/config/index.test.ts

Lines changed: 18 additions & 18 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+
getFeatureData,
77
getSiteConfig,
88
mergeSiteConfig,
99
setSiteConfig,
@@ -352,10 +352,10 @@ describe('mergeSiteConfig', () => {
352352
});
353353
});
354354

355-
describe('getProvidedData', () => {
355+
describe('getFeatureData', () => {
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(getFeatureData('org.openedx.frontend.feature.testFeatureId.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(getFeatureData('org.openedx.frontend.feature.testFeatureId.v1')).toEqual([]);
370370
});
371371

372372
it('should collect provided data from apps that declare it', () => {
@@ -375,20 +375,20 @@ describe('mergeSiteConfig', () => {
375375
apps: [
376376
{
377377
appId: 'app-one',
378-
provides: {
379-
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/one/' },
378+
features: {
379+
'org.openedx.frontend.feature.testFeatureId.v1': { urlPattern: '/one/' },
380380
},
381381
},
382382
{
383383
appId: 'app-two',
384-
provides: {
385-
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/two/' },
384+
features: {
385+
'org.openedx.frontend.feature.testFeatureId.v1': { urlPattern: '/two/' },
386386
},
387387
},
388388
],
389389
});
390390

391-
const result = getProvidedData('org.openedx.frontend.provides.testKey.v1');
391+
const result = getFeatureData('org.openedx.frontend.feature.testFeatureId.v1');
392392
expect(result).toEqual([
393393
{ urlPattern: '/one/' },
394394
{ urlPattern: '/two/' },
@@ -401,37 +401,37 @@ describe('mergeSiteConfig', () => {
401401
apps: [
402402
{
403403
appId: 'app-one',
404-
provides: {
405-
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/one/' },
406-
'org.openedx.frontend.provides.otherKey.v1': { showBranding: true },
404+
features: {
405+
'org.openedx.frontend.feature.testFeatureId.v1': { urlPattern: '/one/' },
406+
'org.openedx.frontend.feature.otherFeatureId.v1': { showBranding: true },
407407
},
408408
},
409409
],
410410
});
411411

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

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

419-
it('should skip apps without provides', () => {
419+
it('should skip apps without features', () => {
420420
setSiteConfig({
421421
...defaultSiteConfig,
422422
apps: [
423423
{ appId: 'app-one' },
424424
{
425425
appId: 'app-two',
426-
provides: {
427-
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/two/' },
426+
features: {
427+
'org.openedx.frontend.feature.testFeatureId.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 = getFeatureData('org.openedx.frontend.feature.testFeatureId.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 `features` entries from registered apps that match the given feature 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 feature identifier.
332+
* @returns An array of feature data from all apps that declared data for this identifier.
333333
*/
334-
export function getProvidedData(key: string): unknown[] {
334+
export function getFeatureData(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.features && app.features[id] !== undefined) {
341+
results.push(app.features[id]);
342342
}
343343
}
344344
return results;
345345
}
346346

347+
/**
348+
* Collects and flattens all `features` entries for the given feature 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 feature identifier.
353+
* @returns A flat array of strings from all apps that declared data for this identifier.
354+
*/
355+
export function getFeatureDataAsStrings(id: string): string[] {
356+
return getFeatureData(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+
getFeatureData,
49+
getFeatureDataAsStrings
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, getFeatureDataAsStrings, WidgetOperationTypes } from '../runtime';
22
import { App } from '../types';
33
import { Footer } from './footer';
44
import { Header } from './header';
5+
import { hideFooterFeatureId, hideHeaderFeatureId } 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+
* Checks whether a widget should render by comparing active roles against
9+
* roles declared under the given feature. Apps opt into hiding a widget by
10+
* listing role strings in their `features` entry, e.g.:
11+
*
12+
* features: { [hideHeaderFeatureId]: ['org.openedx.frontend.role.authn'] }
13+
*
14+
* The widget is disabled when any of those roles is currently active.
15+
*/
16+
function isWidgetEnabled(featureId: string): boolean {
17+
const activeRoles = getActiveRoles();
18+
const hideRoles = getFeatureDataAsStrings(featureId);
19+
return !hideRoles.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: () => isWidgetEnabled(hideHeaderFeatureId),
2432
}
2533
},
2634
{
@@ -29,7 +37,7 @@ const app: App = {
2937
op: WidgetOperationTypes.APPEND,
3038
component: Footer,
3139
condition: {
32-
inactive,
40+
callback: () => isWidgetEnabled(hideFooterFeatureId),
3341
}
3442
},
3543
]

shell/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const hideHeaderFeatureId = 'org.openedx.frontend.feature.hideHeader.v1';
2+
export const hideFooterFeatureId = 'org.openedx.frontend.feature.hideFooter.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 showCourseNavigationBarFeatureId = 'org.openedx.frontend.feature.showCourseNavigationBar.v1';

0 commit comments

Comments
 (0)