Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
App ``provides`` for Inter-App Data
####################################
App ``provides`` for Inter-App Configuration
############################################

Status
======
Expand All @@ -11,15 +11,15 @@ Context
=======

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

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

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

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

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


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

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

A runtime helper would look something like::

// Returns all `provides` entries matching the given key.
function getProvidedData(key: string): unknown[]
// Returns all `provides` entries matching the given identifier.
function getProvides(id: string): unknown[]


Guidelines
==========

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

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

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


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

const config: App = {
appId: 'org.openedx.frontend.app.instructor',
appId: 'org.openedx.frontend.app.instructorDashboard',
provides: {
'org.openedx.frontend.provides.courseNavigationRoles.v1': {
courseNavigationRoles: ['org.openedx.frontend.role.instructor'],
},
'org.openedx.frontend.provides.courseNavigationRoles.v1': [
'org.openedx.frontend.role.instructorDashboard',
],
},
routes: [...],
slots: [...],
};

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


Rejected alternatives
=====================

Slot operations
---------------
Widget operations
-----------------

Each app could register its own widget into the course navigation bar slot
with an ``active`` condition on its role. The ``OPTIONS`` operation can even
Expand Down Expand Up @@ -157,3 +156,15 @@ with no standard way to discover them. Providers are the right tool when data
changes over time and consumers need to re-render. The course navigation roles
are fixed at registration time and never change, making ``provides`` a more
natural fit.

Reusing ``App.config``
----------------------

The existing ``App.config`` field has the same type (``Record<string, unknown>``)
and could theoretically hold provided data. However, ``config`` is per-app: it
is retrieved by ``appId`` via ``getAppConfig()`` and is meant to hold settings
*for* that app. ``provides`` has a cross-app access pattern:
``getProvides()`` collects entries from all apps that declared data under a
given identifier. Merging the two would require scanning every app's config
for a specific key, blurring the distinction between settings an app consumes
and data it exposes for others.
26 changes: 13 additions & 13 deletions runtime/config/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as subscriptions from '../subscriptions';
import {
addAppConfigs,
getAppConfig,
getProvidedData,
getProvides,
getSiteConfig,
mergeSiteConfig,
setSiteConfig,
Expand Down Expand Up @@ -352,10 +352,10 @@ describe('mergeSiteConfig', () => {
});
});

describe('getProvidedData', () => {
describe('getProvides', () => {
it('should return empty array when no apps exist', () => {
setSiteConfig({ ...defaultSiteConfig, apps: [] });
expect(getProvidedData('org.openedx.frontend.provides.testKey.v1')).toEqual([]);
expect(getProvides('org.openedx.frontend.provides.testProvidesId.v1')).toEqual([]);
});

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

it('should collect provided data from apps that declare it', () => {
Expand All @@ -376,19 +376,19 @@ describe('mergeSiteConfig', () => {
{
appId: 'app-one',
provides: {
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/one/' },
'org.openedx.frontend.provides.testProvidesId.v1': { urlPattern: '/one/' },
},
},
{
appId: 'app-two',
provides: {
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/two/' },
'org.openedx.frontend.provides.testProvidesId.v1': { urlPattern: '/two/' },
},
},
],
});

const result = getProvidedData('org.openedx.frontend.provides.testKey.v1');
const result = getProvides('org.openedx.frontend.provides.testProvidesId.v1');
expect(result).toEqual([
{ urlPattern: '/one/' },
{ urlPattern: '/two/' },
Expand All @@ -402,17 +402,17 @@ describe('mergeSiteConfig', () => {
{
appId: 'app-one',
provides: {
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/one/' },
'org.openedx.frontend.provides.otherKey.v1': { showBranding: true },
'org.openedx.frontend.provides.testProvidesId.v1': { urlPattern: '/one/' },
'org.openedx.frontend.provides.otherProvidesId.v1': { showBranding: true },
},
},
],
});

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

const footerData = getProvidedData('org.openedx.frontend.provides.otherKey.v1');
const footerData = getProvides('org.openedx.frontend.provides.otherProvidesId.v1');
expect(footerData).toEqual([{ showBranding: true }]);
});

Expand All @@ -424,14 +424,14 @@ describe('mergeSiteConfig', () => {
{
appId: 'app-two',
provides: {
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/two/' },
'org.openedx.frontend.provides.testProvidesId.v1': { urlPattern: '/two/' },
},
},
{ appId: 'app-three', config: { VALUE: 'test' } },
],
});

const result = getProvidedData('org.openedx.frontend.provides.testKey.v1');
const result = getProvides('org.openedx.frontend.provides.testProvidesId.v1');
expect(result).toEqual([{ urlPattern: '/two/' }]);
});
});
Expand Down
26 changes: 20 additions & 6 deletions runtime/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,25 +325,39 @@ export function getActiveRoles() {
}

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

const results: unknown[] = [];
for (const app of apps) {
if (app.provides && app.provides[key] !== undefined) {
results.push(app.provides[key]);
if (app.provides && app.provides[id] !== undefined) {
results.push(app.provides[id]);
}
}
return results;
}

/**
* Collects and flattens all `provides` entries for the given identifier
* as strings. Each entry can be a single string or a string array; entries
* of other types are silently skipped.
*
* @param id - The namespaced provides identifier.
* @returns A flat array of strings from all apps that declared data for this identifier.
*/
export function getProvidesAsStrings(id: string): string[] {
return getProvides(id)
.filter((data): data is string | string[] => typeof data === 'string' || Array.isArray(data))
.flat();
}

/**
* Get an external link URL based on the URL provided. If the passed in URL is overridden in the
* `externalLinkUrlOverrides` object, it will return the overridden URL. Otherwise, it will return
Expand Down
3 changes: 2 additions & 1 deletion runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export {
getActiveWidgetRoles,
getActiveRoles,
getExternalLinkUrl,
getProvidedData
getProvides,
getProvidesAsStrings
} from './config';

export * from './constants';
Expand Down
28 changes: 18 additions & 10 deletions shell/app.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { WidgetOperationTypes } from '../runtime';
import { getActiveRoles, getProvidesAsStrings, WidgetOperationTypes } from '../runtime';
import { App } from '../types';
import { Footer } from './footer';
import { Header } from './header';
import { providesChromelessRolesId } from './constants';

const inactive = [
'org.openedx.frontend.role.login',
'org.openedx.frontend.role.register',
'org.openedx.frontend.role.resetPassword',
'org.openedx.frontend.role.confirmPassword',
'org.openedx.frontend.role.welcome'
];
/*
* Returns false when the current route should be chromeless (no header or
* footer). Apps request chromeless mode by listing their route roles under
* providesChromelessRolesId in their `provides` entry, e.g.:
*
* provides: { [providesChromelessRolesId]: ['org.openedx.frontend.role.authn'] }
*
* The widget is disabled when any of those roles is currently active.
*/
function isChromeVisible(): boolean {
const activeRoles = getActiveRoles();
const chromelessRoles = getProvidesAsStrings(providesChromelessRolesId);
return !chromelessRoles.some(role => activeRoles.includes(role));
}

const app: App = {
appId: 'org.openedx.frontend.app.shell',
Expand All @@ -20,7 +28,7 @@ const app: App = {
op: WidgetOperationTypes.APPEND,
component: Header,
condition: {
inactive,
callback: isChromeVisible,
}
},
{
Expand All @@ -29,7 +37,7 @@ const app: App = {
op: WidgetOperationTypes.APPEND,
component: Footer,
condition: {
inactive,
callback: isChromeVisible,
}
},
]
Expand Down
1 change: 1 addition & 0 deletions shell/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const providesChromelessRolesId = 'org.openedx.frontend.provides.chromelessRoles.v1';
6 changes: 3 additions & 3 deletions shell/header/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import MobileNavLinks from './mobile/MobileNavLinks';
import messages from '../Shell.messages';
import CourseTabsNavigation from './course-navigation-bar/CourseTabsNavigation';
import { isCourseNavigationRoute } from './course-navigation-bar/utils';
import { appId, courseNavigationBarSlotId, courseTabsNavigationWidgetId } from './constants';
import { appId } from './constants';

const config: App = {
appId,
Expand Down Expand Up @@ -139,8 +139,8 @@ const config: App = {
}
},
{
slotId: courseNavigationBarSlotId,
id: courseTabsNavigationWidgetId,
slotId: 'org.openedx.frontend.slot.header.courseNavigationBar.v1',
id: 'org.openedx.frontend.widget.header.courseNavigationBar.v1',
op: WidgetOperationTypes.APPEND,
component: CourseTabsNavigation,
condition: {
Expand Down
4 changes: 1 addition & 3 deletions shell/header/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
export const appId = 'org.openedx.frontend.app.header';
export const courseNavigationBarSlotId = 'org.openedx.frontend.slot.header.courseNavigationBar.v1';
export const courseTabsNavigationWidgetId = 'org.openedx.frontend.widget.header.courseTabsNavigation.v1';
export const courseNavigationRolesProvidesKey = 'org.openedx.frontend.provides.courseNavigationRoles.v1';
export const providesCourseNavigationRolesId = 'org.openedx.frontend.provides.courseNavigationRoles.v1';
19 changes: 12 additions & 7 deletions shell/header/course-navigation-bar/CourseTabsNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@ interface ResolvedTab extends CourseTab {
clientPath: string | null,
}

// Returns the tabId of the tab whose pathname is the longest prefix match
// against the current path. Uses react-router's matchPath for segment-aware
// matching. For example, given tabs with paths /course/ (tabId: "outline")
// and /course/dates/ (tabId: "dates"):
// /course/dates/foo -> "dates" (longest prefix match)
// /course/outline -> "outline"
// /courseware -> null (not a segment boundary)
/*
* Returns the tabId of the tab whose pathname is the longest prefix match
* against the current path. Uses react-router's matchPath for segment-aware
* matching.
*
* For example, given tabs with paths /course/ (tabId: "outline")
* and /course/dates/ (tabId: "dates"):
*
* /course/dates/foo -> "dates" (longest prefix match)
* /course/outline -> "outline"
* /courseware -> null (not a segment boundary)
*/
const getActiveTabId = (currentPath: string, tabs: ResolvedTab[]): string | null => {
let best: ResolvedTab | null = null;
for (const tab of tabs) {
Expand Down
Loading
Loading