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
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ clean:

build:
tsc --build ./tsconfig.build.json
cp ./shell/app.scss ./dist/shell/app.scss
find shell -type f -name '*.scss' -exec sh -c '\
for f in "$$@"; do \
d="dist/$${f}"; \
mkdir -p "$$(dirname "$$d")"; \
cp "$$f" "$$d"; \
done' sh {} +
# When the package is installed from the registry, NPM sets the executable
# bit on `bin` files automatically. It doesn't do the same in workspaces,
# though, so we handle it explicitly here.
Expand Down
159 changes: 159 additions & 0 deletions docs/decisions/0013-app-provides-for-inter-app-data.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
App ``provides`` for Inter-App Data
####################################

Status
======

Proposed


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.

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:

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

2. Which URL patterns each app handles client-side, so the navigation bar can
use ``navigate()`` instead of a full page load for same-origin tab URLs.

There is no place in the current ``App`` interface to express this. Extending
the interface with a dedicated field (e.g. ``courseNavigation``) would work for
this specific case, but the pattern would repeat: every new inter-app
coordination need would require another field, another type change, and another
release of frontend-base.

Meanwhile, ``routes`` and ``slots`` are structured because frontend-base's
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.


Decision
========

Add an optional ``provides`` field to the ``App`` interface::

export interface App {
appId: string,
messages?: LocalizedMessages,
routes?: RoleRouteObject[],
providers?: AppProvider[],
slots?: SlotOperation[],
config?: AppConfig,
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.

A runtime helper would look something like::

// Returns all `provides` entries matching the given key.
function getProvidedData(key: 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.

2. Keys in ``provides`` should be their own namespaced identifiers, not
duplicates of existing app, slot, or widget IDs. This allows different
widgets or other entities to consume the same provided data independently,
without coupling the data's identity to a single consumer.

3. The shape of the value under each key is a contract between the providing and
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.


Consequences
============

Apps gain a channel for coordination that does not require changes to
frontend-base's ``App`` type or runtime for each new use case. The ``App``
interface grows by one optional field and remains stable as new inter-app
patterns emerge.

The trade-off is that ``provides`` data is untyped from frontend-base's
perspective. Consuming apps bear the responsibility of defining, documenting,
and validating the shape of the data they expect. This is acceptable because
the data is, by definition, outside frontend-base's domain.

Course navigation bar example
-----------------------------

As a concrete illustration, the Instructor Dashboard app could declare::

const config: App = {
appId: 'org.openedx.frontend.app.instructor',
provides: {
'org.openedx.frontend.provides.courseNavigationRoles.v1': {
courseNavigationRoles: ['org.openedx.frontend.role.instructor'],
},
},
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()``).


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

Slot 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
carry arbitrary data to a widget via ``useWidgetOptions``. However, the
navigation bar needs to know which apps participate *before* it renders, in the
slot ``condition.callback`` that decides whether to render at all.
``useWidgetOptions`` is a React hook that only works inside a rendered
component, so the data arrives too late for the condition check.

The component could work around this by always mounting, reading options, and
returning ``null`` when no apps have registered. But this means the component
and its hooks (including the API call to fetch course metadata) would run on
every page, even where no app participates in the navigation bar.

Hoisted providers
-----------------

Each app could register a React context provider exposing its role list, and
the navigation bar could consume those contexts. All app providers are already
hoisted and combined into a single tree, so the data would be available.

This was rejected because it is a heavy mechanism for passing a small piece of
static data. Each app would need a context, a provider component, and a
consumer hook, and the header would need to aggregate across multiple contexts
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.
85 changes: 85 additions & 0 deletions runtime/config/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as subscriptions from '../subscriptions';
import {
addAppConfigs,
getAppConfig,
getProvidedData,
getSiteConfig,
mergeSiteConfig,
setSiteConfig,
Expand Down Expand Up @@ -350,4 +351,88 @@ describe('mergeSiteConfig', () => {
expect(config).toEqual({ NESTED: { a: 1, b: 3, c: 4 } });
});
});

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

it('should return empty array when no apps provide data for the consumer', () => {
setSiteConfig({
...defaultSiteConfig,
apps: [
{ appId: 'app-one', config: { VALUE: 'test' } },
{ appId: 'app-two' },
],
});
expect(getProvidedData('org.openedx.frontend.provides.testKey.v1')).toEqual([]);
});

it('should collect provided data from apps that declare it', () => {
setSiteConfig({
...defaultSiteConfig,
apps: [
{
appId: 'app-one',
provides: {
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/one/' },
},
},
{
appId: 'app-two',
provides: {
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/two/' },
},
},
],
});

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

it('should only return data for the requested consumer', () => {
setSiteConfig({
...defaultSiteConfig,
apps: [
{
appId: 'app-one',
provides: {
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/one/' },
'org.openedx.frontend.provides.otherKey.v1': { showBranding: true },
},
},
],
});

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

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

it('should skip apps without provides', () => {
setSiteConfig({
...defaultSiteConfig,
apps: [
{ appId: 'app-one' },
{
appId: 'app-two',
provides: {
'org.openedx.frontend.provides.testKey.v1': { urlPattern: '/two/' },
},
},
{ appId: 'app-three', config: { VALUE: 'test' } },
],
});

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

/**
* Collects all `provides` entries from registered apps that match the given key.
* 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.
*/
export function getProvidedData(key: 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]);
}
}
return results;
}

/**
* 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 @@ -44,7 +44,8 @@ export {
removeActiveWidgetRole,
getActiveWidgetRoles,
getActiveRoles,
getExternalLinkUrl
getExternalLinkUrl,
getProvidedData
} from './config';

export * from './constants';
Expand Down
17 changes: 10 additions & 7 deletions shell/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ export default function Header() {
const intl = useIntl();

return (
<header className="border-bottom py-2">
<nav className="py-2">
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
<Slot id="org.openedx.frontend.slot.header.desktop.v1" />
<Slot id="org.openedx.frontend.slot.header.mobile.v1" />
</nav>
</header>
<>
<header className="border-bottom py-2">
<nav className="py-2">
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
<Slot id="org.openedx.frontend.slot.header.desktop.v1" />
<Slot id="org.openedx.frontend.slot.header.mobile.v1" />
</nav>
</header>
<Slot id="org.openedx.frontend.slot.header.courseNavigationBar.v1" />
</>
);
}
15 changes: 13 additions & 2 deletions shell/header/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import MobileLayout from './mobile/MobileLayout';
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';

const config: App = {
appId: 'org.openedx.frontend.app.header',
appId,
slots: [

// Layouts
{
slotId: 'org.openedx.frontend.slot.header.desktop.v1',
Expand Down Expand Up @@ -136,6 +138,15 @@ const config: App = {
authenticated: false,
}
},
{
slotId: courseNavigationBarSlotId,
id: courseTabsNavigationWidgetId,
op: WidgetOperationTypes.APPEND,
component: CourseTabsNavigation,
condition: {
callback: () => isCourseNavigationRoute(),
}
}
]
};

Expand Down
4 changes: 4 additions & 0 deletions shell/header/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
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';
Loading
Loading