feat: Add extensibility for tmtv and segmentation modes (#6128)

* feat: Add extensibility for tmtv and segmentation modes

* Fixes for ordering issues on laod

* Remove unnecessary reference lookup

* Chane side panel timing to fix tests

* PR comments - change how mode definitions get created

* Improvements to mode customizations

* Start organizing customizations

* Misc fixes for a customization demo page

* Security fixes

* PR requested changes to naming
This commit is contained in:
Bill Wallace authored and GitHub committed 2026-07-10 12:43:17 -04:00
1 parent f79055f98e
commit b266c0a86a
49 files changed
+2653 -1215

No files matched your search

+23 -11
View File
@@ -93,13 +93,31 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]);
const { customizationService } = servicesManager.services;
if (!appConfig.modes) {
throw new Error('No modes are defined! Check your app-config.js');
}
// Load the mode modules and register the customizations they carry (plain
// `customizationId -> value` maps on the mode definition) at Default scope
// BEFORE the bootstrap phase applies, so bootstrap / `?customization=`
// modules can modify a mode's registered values before anything reads them.
// The mode *instances* are only created after the global phase (below), so
// they too see any modifications.
const loadedModes = await loadModules([...(appConfig.modes || []), ...defaultModes]);
for (const mode of loadedModes) {
if (mode?.customizations) {
customizationService.addReferences(mode.customizations, customizationService.Scope.Default);
}
}
// Resolve every customization module up front — from
// `appConfig.customizationService.requires` and the `?customization=` URL
// parameter — long before any mode loads, then apply the `bootstrap` phase
// BEFORE extensions register so it is in place while they initialize. Modules
// are only loaded when `appConfig.customizationUrlPrefixes` allows their
// prefix; the feature is off by default, and a value with an unconfigured
// prefix throws here (aborting startup) rather than being silently ignored.
// parameter — then apply the `bootstrap` phase BEFORE extensions register so
// it is in place while they initialize. Modules are only loaded when
// `appConfig.customizationUrlPrefixes` allows their prefix; the feature is
// off by default, and a value with an unconfigured prefix throws here
// (aborting startup) rather than being silently ignored.
await customizationService.loadAndApplyBootstrapCustomizations(extensionManager);
await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources);
@@ -114,12 +132,6 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
// TODO: We no longer init webWorkers at app level
// TODO: We no longer init the user Manager
if (!appConfig.modes) {
throw new Error('No modes are defined! Check your app-config.js');
}
const loadedModes = await loadModules([...(appConfig.modes || []), ...defaultModes]);
// This is the name for the loaded instance object
appConfig.loadedModes = [];
const modesById = new Set();
+65 -3
View File
@@ -97,6 +97,15 @@ export default function ModeRoute({
}
}
// Mode dependency extensions register their customization modules here,
// but `registerExtension` does not merge them into the customization
// service — that otherwise only happens later in
// `extensionManager.onModeEnter`. Merge them now so anything that runs
// before setupRouteInit (e.g. a mode's layoutTemplate) can already read
// the defaults these extensions provide.
// `init` is idempotent — each extension module is merged at most once.
customizationService.init(extensionManager);
if (isMounted.current) {
setExtensionDependenciesLoaded(true);
}
@@ -180,10 +189,19 @@ export default function ModeRoute({
if (isMounted.current) {
const { leftPanels = [], rightPanels = [], ...layoutProps } = layoutData.props;
// Register panels immediately so ViewerLayout's first render sees them.
// ResizablePanelsHook only auto-expands side panels on the initial mount;
// if panels are added later, the viewport grid keeps the wrong width.
// setupRouteInit (below) resets and re-applies them after customizations
// are layered on, so URL/config modules can still modify the lists.
panelService.reset();
panelService.addPanels(panelService.PanelPosition.Left, leftPanels);
panelService.addPanels(panelService.PanelPosition.Right, rightPanels);
// Stash the layout lists for setupRouteInit to seed into the
// `leftPanels` / `rightPanels` customizations.
layoutData.panels = { leftPanels, rightPanels };
// layoutProps contains all props but leftPanels and rightPanels
layoutData.props = layoutProps;
@@ -222,11 +240,55 @@ export default function ModeRoute({
});
// `extensionManager.onModeEnter` resets the customization mode scope via
// `customizationService.onModeEnter`; now layer on the `mode` phase blocks
// for this mode — the general (`*`) block first, then any block keyed by
// this mode's id / routeName so a single mode can override the general one.
// `customizationService.onModeEnter`; the mode scope is then layered
// bottom-up so the final value of every key is decided by scope
// precedence (global > mode > default) and application order alone:
// 1. the mode's own values — its layout panel lists, seeded as the
// standard `leftPanels` / `rightPanels` customizations;
// its toolbar/tool-group composition, seeded as the plain
// `toolbarButtons` / `toolbarSections` / `toolGroupAdditions`
// customizations (resolved to concrete definitions later, when the
// mode's `onModeEnter` registers the toolbar); and its
// `modeCustomizations` block (declared as data on the mode
// instance, usually as a customization name registered at default
// scope when the mode loaded, so bootstrap/global customizations
// can modify the block itself before it is applied);
// 2. the app config / URL `mode` phase blocks — the general (`*`)
// block first, then any block keyed by this mode's id / routeName.
const { leftPanels = [], rightPanels = [] } = layoutTemplateData.current.panels ?? {};
customizationService.setCustomizations({
leftPanels,
rightPanels,
toolbarButtons: mode.toolbarButtons ?? [],
toolbarSections: mode.toolbarSections ?? [],
toolGroupAdditions: mode.toolGroupAdditions ?? {},
});
const modeCustomizations =
typeof mode.modeCustomizations === 'string'
? customizationService.getCustomization(mode.modeCustomizations)
: mode.modeCustomizations;
if (modeCustomizations) {
customizationService.setCustomizations(modeCustomizations);
}
customizationService.applyModeCustomizations([mode.id, mode.routeName]);
// Re-apply panels only when customizations changed the lists. When they
// match the layout, the panels registered in retrieveLayoutData are left
// in place so ViewerLayout's ResizablePanelsHook keeps the correct sizes
// from its one-time initial expand.
const resolvedLeftPanels = customizationService.getValue('leftPanels') ?? [];
const resolvedRightPanels = customizationService.getValue('rightPanels') ?? [];
const panelsChanged =
JSON.stringify(resolvedLeftPanels) !== JSON.stringify(leftPanels) ||
JSON.stringify(resolvedRightPanels) !== JSON.stringify(rightPanels);
if (panelsChanged) {
panelService.reset();
panelService.addPanels(panelService.PanelPosition.Left, resolvedLeftPanels);
panelService.addPanels(panelService.PanelPosition.Right, resolvedRightPanels);
}
// use the URL hangingProtocolId if it exists, otherwise use the one
// defined in the mode configuration
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(
@@ -13,6 +13,14 @@ describe('preserveQueryParameters', () => {
expect(out.getAll('configUrl')).toEqual(['foo.js']);
});
it('preserves the theme key across mode navigation', () => {
const current = new URLSearchParams();
current.append('theme', 'arctic');
const out = new URLSearchParams();
preserveQueryParameters(out, undefined, current);
expect(out.get('theme')).toBe('arctic');
});
it('preserves all repeated values for the customization key', () => {
const current = new URLSearchParams();
current.append('customization', 'a');
@@ -12,6 +12,7 @@ export const preserveKeys = [
'screenNumber',
'hangingProtocolId',
'customization',
'theme',
];
function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) {