Skip to content

Commit

Permalink
feat: Apps, routes, and slots (openedx#2)
Browse files Browse the repository at this point in the history
This PR significantly re-imagines the module, header, footer, and plugin configuration in frontend-base.

The system is now focused around routes and slots. Slots are in the style of frontend-plugin-framework, tweaked to work with the lessons learned by rebuilding the header and footer, along with the needs of loading 'apps' representing the functionality of today's MFEs.

Site Config
The SiteConfig has a few new data structures:

apps: This section includes 'app' configurations with routes, slots operations, and i18n messages.
federatedApps: This section contains configurations for module federation, including "hints", a new concept that allows the shell to delay loading a federated module until it's needed by the site. A hint can be a route prefix or a slot identifier.
remotes: The configuration for the remotes needed by our federatedApps.
externalRoutes: A list of external URLs associated with semantically meaningful 'roles', allowing the system to seamlessly navigate to external pages without needing to know they're not part of the site, meaning they could be added in later without any of the apps needing to know about it.
Apps
Each App in apps can include a slots data structure that describes operations that App wants to perform on slots anywhere in the site. An app can add widgets to a slot, change the slot's layout, or supply 'options' to the layout to change it's configuration.

Notably, layouts do not pass any props to widgets. A widget is a component without props. To use a React component that takes props as a widget, it can be wrapped in another component that supplies the props. The slot can provide a React Context to share contextual data with its widget children. The slot operations also allow overriding the layout, supplying it options (layout-specific config), or modifying an existing widget. This is a test. The goal is for widget overrides, layout options, and layout overrides to supply all the configuration we need, rather than having to reason through complicated props merging between slots and their children. It may not work, in which case we'll try to carefully layer in prop overrides for layouts/widgets as well.

Routes
After thinking it through for quite a while, I reached the conclusion that routes and slots are effectively two separate things. React-router's configuration gives us everything we need to have a comprehensive route rendering system, trying to insert our slots into the middle of it only makes it more complicated for very little benefit. A slot's layout or widgets can use a React Router Outlet component to indicate that it's a place where a given route should be rendered. A layout may also use its widgets to display visual metadata about the routes it's responsible for rendering. For instance, a tabbed container component could use its widgets to display the tabs, and an Outlet for the main content of the container. The tabs would be links to the proper routes, and react-router will just do the right thing with the Outlet.

Module Federation
Module federation can occur at two levels. First, at the "FederatedApp" level. FederatedApps are included in the federatedApps data structure, and the site uses their hints to know when to load their App config (their routes, slots, and messages). Once a remote App has been loaded, it's added to the apps array and acts like any other app.

The other way to use module federation is in a slot. This is useful for Apps that want to load resources from other remotes. There's a special slot operation for loading a widget via module federation, where a remoteID and moduleID are supplied instead of a React element or component. The slot will use Suspense to load the module on demand when the slot is rendered.

Commits:

* feat: reimagining of shell config as slots

This commit is one of several significantly refactoring the shell, header, footer, menu helpers, and site config into a system with frontend-plugin-framework-style “Slots” as a central concept.

An “App” consists of a set of routes, slot configurations, and i18n messages.  Routes are react-router routes, and are generally unrelated to slots.  Slots are configured via operations.  The only type of slot right now is a “widget” slot, which renders UI components into a ‘layout’ container.  There are operations to work with widgets and the parent slot layout.

Subsequent commits will add new versions of the header/footer and menu helpers. It will then use them all in the dev-project and test-project.

* feat: simplifies shell ‘menu’ helpers

This commit clears out a bunch of helpers for creating menus in the header and footer, simplifying them into a reusable component that’s aware of widget and route roles and can render itself as various link-like components.  Subsequent commits to the header/footer sub-folders will use this (and the previous ‘slot’ commit) extensively.

* feat: re-working Header to use slots

This commit reworks the default header component to use slots for all its children.  It supplies a default header app which makes use of all the slots in a way that should be a good starting point for most operators.

* feat: refactor the footer to use slots

Like the previous commit did for the header, this commit refactors the footer to use slots for configuration.

* test: Refactor the ‘dev-project’ to use the new app/slot system

This refactors site.config.dev.shell and the sample pages in dev-project to use the new system.  The ‘home’ and ‘user’ apps in dev-project export App configurations with routes and slots to test out the various features of the new system.

* fix: removing unused slot operation types

We’ll add these back in once they’re actually usable.
  • Loading branch information
davidjoy committed Jan 15, 2025
1 parent 7fb3cc7 commit aa185b7
Show file tree
Hide file tree
Showing 81 changed files with 1,035 additions and 1,294 deletions.
15 changes: 1 addition & 14 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,7 @@ export {
configureI18n,
configureLogging,
convertKeyNames,
createAppMenuItem,
createComponentMenuItem,
createDropdownMenuItem,
createExternalAppConfig,
createFederatedAppConfig,
createInternalAppConfig,
createIntl,
createLabeledMenu,
createUrlMenuItem,
defineMessages,
ensureAuthenticatedUser,
fetchAuthenticatedUser,
Expand Down Expand Up @@ -88,10 +80,10 @@ export {
logError,
logInfo,
mergeConfig,
mergeMessages,
mockMessages,
modifyObjectKeys,
parseURL,
patchMessages,
publish,
redirectToLogin,
redirectToLogout,
Expand All @@ -113,16 +105,11 @@ export {
} from './runtime';

export type {
ApplicationModuleConfig,
ExternalAppConfig,
FederatedAppConfig,
InternalAppConfig,
ProjectModuleConfig,
ProjectSiteConfig
} from './types';

export {
AppConfigTypes,
EnvironmentTypes,
PluginOperationTypes,
PluginTypes
Expand Down
31 changes: 0 additions & 31 deletions runtime/config/appConfigHelpers.ts

This file was deleted.

37 changes: 12 additions & 25 deletions runtime/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,26 +102,14 @@

import merge from 'lodash.merge';
import {
AppConfigTypes, ApplicationModuleConfig, ConfigurableAppConfig, EnvironmentTypes,
App,
EnvironmentTypes,
RequiredSiteConfig,
SiteConfig
} from '../../types';
import { CONFIG_CHANGED } from '../constants';
import { APPS_CHANGED, CONFIG_CHANGED } from '../constants';
import { publish } from '../subscriptions';

export {
createExternalAppConfig,
createFederatedAppConfig,
createInternalAppConfig
} from './appConfigHelpers';

export {
createAppMenuItem,
createComponentMenuItem,
createDropdownMenuItem,
createLabeledMenu,
createUrlMenuItem
} from './menuConfigHelpers';

let config: SiteConfig = {
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
Expand Down Expand Up @@ -150,6 +138,10 @@ let config: SiteConfig = {
PUBLISHER_BASE_URL: null,

apps: [],
remotes: [],
federatedApps: [],
externalRoutes: [],

pluginSlots: {},
custom: {},

Expand Down Expand Up @@ -233,19 +225,14 @@ export function setConfig(newConfig: SiteConfig) {
*
* @param {Object} newConfig
*/
export function mergeConfig(newConfig: Partial<SiteConfig>) {
export function mergeConfig(newConfig: RequiredSiteConfig) {
config = merge(config, newConfig);
publish(CONFIG_CHANGED);
}

export function patchAppModuleConfig(appId: string, appModuleConfig: ApplicationModuleConfig) {
if (config.apps[appId] !== undefined) {
const app = config.apps[appId];
if (app.type === AppConfigTypes.INTERNAL || app.type === AppConfigTypes.FEDERATED) {
const configurableApp = app as ConfigurableAppConfig;
configurableApp.config = appModuleConfig;
}
}
export function patchApp(app: App) {
config.apps.push(app);
publish(APPS_CHANGED);
}

/**
Expand Down
43 changes: 0 additions & 43 deletions runtime/config/menuConfigHelpers.ts

This file was deleted.

4 changes: 4 additions & 0 deletions runtime/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,7 @@ export const APP_INIT_ERROR = `${APP_TOPIC}.INIT_ERROR`;
export const CONFIG_TOPIC = 'CONFIG';

export const CONFIG_CHANGED = `${CONFIG_TOPIC}.CHANGED`;

export const APPS_TOPIC = 'APPS';

export const APPS_CHANGED = `${APPS_TOPIC}.CHANGED`;
4 changes: 2 additions & 2 deletions runtime/i18n/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export {
IntlProvider,
createIntl,
defineMessages,
useIntl,
useIntl
} from 'react-intl';

export {
Expand All @@ -108,7 +108,7 @@ export {
handleRtl,
intlShape,
isRtl,
mergeMessages,
patchMessages,
updateLocale
} from './lib';

Expand Down
26 changes: 13 additions & 13 deletions runtime/i18n/lib.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
getPrimaryLanguageSubtag,
handleRtl,
isRtl,
mergeMessages,
patchMessages,
} from './lib';

jest.mock('universal-cookie');
Expand Down Expand Up @@ -144,14 +144,14 @@ describe('lib', () => {
});
});

describe('mergeMessages', () => {
describe('patchMessages', () => {
it('should merge objects', () => {
configure({
messages: {
ar: { message: 'ar-hah' },
},
});
const result = mergeMessages({ en: { foo: 'bar' }, de: { buh: 'baz' }, jp: { gah: 'wut' } });
const result = patchMessages({ en: { foo: 'bar' }, de: { buh: 'baz' }, jp: { gah: 'wut' } });
expect(result).toEqual({
ar: { message: 'ar-hah' },
en: { foo: 'bar' },
Expand All @@ -166,7 +166,7 @@ describe('mergeMessages', () => {
ar: { message: 'ar-hah' },
},
});
const result = mergeMessages([{ foo: 'bar' }, { buh: 'baz' }, { gah: 'wut' }]);
const result = patchMessages([{ foo: 'bar' }, { buh: 'baz' }, { gah: 'wut' }]);
expect(result).toEqual({
ar: { message: 'ar-hah' },
foo: 'bar',
Expand All @@ -193,7 +193,7 @@ describe('mergeMessages', () => {
},
];

const result = mergeMessages(messages);
const result = patchMessages(messages);
expect(result).toEqual({
en: {
init: 'initial',
Expand All @@ -212,19 +212,19 @@ describe('mergeMessages', () => {
configure({
messages: {},
});
expect(mergeMessages(undefined)).toEqual({});
expect(mergeMessages(null)).toEqual({});
expect(mergeMessages([])).toEqual({});
expect(mergeMessages({})).toEqual({});
expect(patchMessages(undefined)).toEqual({});
expect(patchMessages(null)).toEqual({});
expect(patchMessages([])).toEqual({});
expect(patchMessages({})).toEqual({});
});

it('should return the original object if no messages', () => {
configure({
messages: { en: { hello: 'world ' } },
});
expect(mergeMessages(undefined)).toEqual({ en: { hello: 'world ' } });
expect(mergeMessages(null)).toEqual({ en: { hello: 'world ' } });
expect(mergeMessages([])).toEqual({ en: { hello: 'world ' } });
expect(mergeMessages({})).toEqual({ en: { hello: 'world ' } });
expect(patchMessages(undefined)).toEqual({ en: { hello: 'world ' } });
expect(patchMessages(null)).toEqual({ en: { hello: 'world ' } });
expect(patchMessages([])).toEqual({ en: { hello: 'world ' } });
expect(patchMessages({})).toEqual({ en: { hello: 'world ' } });
});
});
2 changes: 1 addition & 1 deletion runtime/i18n/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export function handleRtl() {
* @returns {Object}
* @memberof module:Internationalization
*/
export function mergeMessages(newMessages = {}) {
export function patchMessages(newMessages = {}) {
const msgs = Array.isArray(newMessages) ? merge({}, ...newMessages) : newMessages;
messages = merge(messages, msgs);

Expand Down
10 changes: 1 addition & 9 deletions runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,6 @@ export {
} from './auth';

export {
createAppMenuItem,
createComponentMenuItem,
createDropdownMenuItem,
createExternalAppConfig,
createFederatedAppConfig,
createInternalAppConfig,
createLabeledMenu,
createUrlMenuItem,
getConfig,
mergeConfig,
setConfig
Expand Down Expand Up @@ -81,7 +73,7 @@ export {
injectIntl,
intlShape,
isRtl,
mergeMessages,
patchMessages,
updateLocale,
useIntl
} from './i18n';
Expand Down
12 changes: 0 additions & 12 deletions runtime/routing/getApp.ts

This file was deleted.

19 changes: 0 additions & 19 deletions runtime/routing/getAppUrl.test.ts

This file was deleted.

23 changes: 0 additions & 23 deletions runtime/routing/getAppUrl.ts

This file was deleted.

11 changes: 11 additions & 0 deletions runtime/routing/getUrlByRole.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import getUrlByRole from './getUrlByRole';

describe('getUrlByRole', () => {
it('returns the default path for an internal module', () => {
expect(getUrlByRole('test-app-1')).toBe('/app1');
});

it('returns the path for a federated module', () => {
expect(getUrlByRole('test-app-2')).toBe('/app2');
});
});
Loading

0 comments on commit aa185b7

Please sign in to comment.