Simplifies the tenant Visibility tab to match the tenant-only appearance
model established by the previous commits:
- primary_color: now required. Removed the "No brand color / Use system
default (no brand accent)" toggle — every tenant carries a concrete
primary color. Legacy NULL rows render the neutral app default (#2fa4a4)
in the color picker and are converted to that explicit hex on save.
- default_theme: the select no longer offers a "Use system default" empty
option. Legacy NULL rows resolve to 'light' at render time; saves always
persist a valid theme via SettingsAppGateway::normalizeTheme().
- allow_user_theme: replaced the tri-state "Use system default (allowed) /
Force allow / Force disallow" select with a single boolean switch
("Users may choose their own theme"). Legacy NULL rows load as checked.
Saves persist 0/1 explicitly.
TenantService: sanitize no longer reads primary_color_use_default or
allow_user_theme_mode; it validates primary_color as a required hex and
treats allow_user_theme as a plain boolean. Both create and update paths
write concrete values only — no more NULL writes for these three fields.
DirectorySettingsGateway gains a normalizeTheme() delegate so TenantService
can route through the same gateway it uses for isAllowedTheme().
Removed now-unused app-color-default-toggle JS component + its runtime
registration + its architecture-test entry. i18n cleanup: "No brand
color", "Use system default (no brand accent)", "When enabled the tenant
renders without a brand accent color.", "Use system default (light)",
"Use system default (allowed)", "Force allow user theme", "Force disallow
user theme", "User theme policy is invalid" all removed. New copy: "Users
may choose their own theme" + helper text, plus a tightened tab blockquote.
Tests: TenantServiceTest validInput() updated to send concrete values;
settingsGateway mock gets normalizeTheme() + isAllowedTheme() defaults.
All 1985 tests pass; PHPStan level 5 clean; QG-006 clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
9.8 KiB
JavaScript
286 lines
9.8 KiB
JavaScript
import './core/app-telemetry.js';
|
|
import { createComponentRuntime } from './core/app-component-runtime.js';
|
|
import { warnOnce } from './core/app-dom.js';
|
|
import { initFlashAutoDismiss } from './components/app-flash-auto-dismiss.js';
|
|
import { initPasswordHints } from './components/app-password-hints.js';
|
|
import { initBadgeCopy } from './components/app-copy-badge.js';
|
|
import { initCopyField } from './components/app-copy-field.js';
|
|
import { initMultiSelectComponent } from './components/app-multiselect-init.js';
|
|
import { initSidebarToggle } from './components/app-toggle-sidebar.js';
|
|
import { initAsidePanels } from './components/app-toggle-aside-panels.js';
|
|
import { initDetailsAsideToggle } from './components/app-toggle-details-aside.js';
|
|
import { initGlobalSearch } from './components/app-global-search.js';
|
|
import { initThemeControls } from './components/app-theme-toggle.js';
|
|
import { initContrastToggle } from './components/app-contrast-toggle.js';
|
|
import { initConfirmActions } from './components/app-confirm-actions.js';
|
|
import { initDetailsState } from './components/app-details-state.js';
|
|
import { initTenantSwitcher } from './components/app-tenant-switcher.js';
|
|
import { initCustomFieldOptionsToggle } from './components/app-custom-field-options-toggle.js';
|
|
import { initTenantSsoToggle } from './components/app-tenant-sso-toggle.js';
|
|
import { initTenantLdapToggle } from './components/app-tenant-ldap-toggle.js';
|
|
import { initTelemetrySettings } from './components/app-settings-telemetry.js';
|
|
import { initStandardDetailPages } from './components/app-standard-detail-page.js';
|
|
import { initAutoSubmit } from './components/app-auto-submit.js';
|
|
import { initTabs } from './components/app-tabs.js';
|
|
import { initSessionWarning } from './components/app-session-warning.js';
|
|
import { initFsLightboxRefresh } from './components/app-fslightbox-refresh.js';
|
|
import { initFileUpload } from './components/app-file-upload.js';
|
|
import { initLookupFields } from './components/app-lookup-field.js';
|
|
|
|
const runtime = createComponentRuntime({
|
|
root: document,
|
|
configId: 'app-components',
|
|
});
|
|
|
|
const phaseRank = {
|
|
early: 10,
|
|
default: 50,
|
|
late: 90,
|
|
};
|
|
|
|
const normalizePhase = (raw) => {
|
|
const phase = String(raw || '').trim().toLowerCase();
|
|
if (phase === 'early' || phase === 'default' || phase === 'late') {
|
|
return phase;
|
|
}
|
|
return 'late';
|
|
};
|
|
|
|
const normalizeModuleRuntimeComponents = (value) => {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.filter((item) => item && typeof item === 'object')
|
|
.map((item) => {
|
|
const key = String(item.key || '').trim();
|
|
const moduleId = String(item.moduleId || '').trim();
|
|
const componentName = String(item.componentName || '').trim() || (moduleId && key ? `${moduleId}.${key}` : '');
|
|
const script = String(item.script || '').trim();
|
|
const exportName = String(item.export || '').trim();
|
|
const selector = String(item.selector || '').trim();
|
|
const scope = String(item.scope || '').trim().toLowerCase();
|
|
const configPath = String(item.configPath || '').trim();
|
|
const phase = normalizePhase(item.phase);
|
|
const order = Number.isFinite(Number(item.order)) ? Number(item.order) : 100;
|
|
|
|
return {
|
|
key,
|
|
moduleId,
|
|
componentName,
|
|
script,
|
|
exportName,
|
|
selector,
|
|
scope,
|
|
configPath,
|
|
phase,
|
|
order,
|
|
};
|
|
})
|
|
.filter((item) => item.componentName !== '' && item.script !== '')
|
|
.sort((a, b) => {
|
|
const phaseCmp = (phaseRank[a.phase] || phaseRank.late) - (phaseRank[b.phase] || phaseRank.late);
|
|
if (phaseCmp !== 0) {
|
|
return phaseCmp;
|
|
}
|
|
if (a.order !== b.order) {
|
|
return a.order - b.order;
|
|
}
|
|
return a.componentName.localeCompare(b.componentName);
|
|
});
|
|
};
|
|
|
|
const registerRuntimeComponent = async (definition) => {
|
|
const moduleContext = {
|
|
module: 'app-init',
|
|
runtimeComponent: definition.componentName,
|
|
};
|
|
|
|
const scriptUrl = (() => {
|
|
try {
|
|
return new URL(definition.script, document.baseURI).href;
|
|
} catch {
|
|
return '';
|
|
}
|
|
})();
|
|
|
|
if (!scriptUrl) {
|
|
warnOnce('UI_COMPONENT_INVALID', `Invalid runtime.component script URL for "${definition.componentName}"`, moduleContext);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const loaded = await import(scriptUrl);
|
|
const exportName = definition.exportName || 'default';
|
|
const init = loaded?.[exportName];
|
|
if (typeof init !== 'function') {
|
|
warnOnce(
|
|
'UI_COMPONENT_INVALID',
|
|
`runtime.component "${definition.componentName}" export "${exportName}" is not a function`,
|
|
moduleContext
|
|
);
|
|
return;
|
|
}
|
|
|
|
const componentOptions = {};
|
|
if (definition.scope !== '') {
|
|
componentOptions.scope = definition.scope;
|
|
}
|
|
if (definition.selector !== '') {
|
|
componentOptions.selector = definition.selector;
|
|
}
|
|
if (definition.configPath !== '') {
|
|
componentOptions.configPath = definition.configPath;
|
|
}
|
|
|
|
runtime.register(definition.componentName, init, componentOptions);
|
|
} catch (error) {
|
|
warnOnce('UI_COMPONENT_IMPORT_FAILED', `Failed to import runtime.component "${definition.componentName}"`, {
|
|
...moduleContext,
|
|
error,
|
|
});
|
|
}
|
|
};
|
|
|
|
const mountModuleComponentsByPhase = async (phase) => {
|
|
const pageConfig = runtime.getPageConfig();
|
|
const runtimeComponents = normalizeModuleRuntimeComponents(pageConfig?.moduleRuntimeComponents);
|
|
const phaseComponents = runtimeComponents.filter((item) => item.phase === phase);
|
|
for (const definition of phaseComponents) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await registerRuntimeComponent(definition);
|
|
}
|
|
};
|
|
|
|
const bootRuntime = async () => {
|
|
// Phase A: low risk and global utilities.
|
|
runtime.register('confirm-actions', initConfirmActions, {
|
|
scope: 'global',
|
|
configPath: 'components.confirmActions',
|
|
});
|
|
runtime.register('flash-auto-dismiss', initFlashAutoDismiss, {
|
|
selector: '[data-app-component="flash-auto-dismiss"]',
|
|
configPath: 'components.flashAutoDismiss',
|
|
defaultConfig: {
|
|
selector: '.notice[data-flash-timeout]',
|
|
},
|
|
});
|
|
runtime.register('password-hints', initPasswordHints, {
|
|
scope: 'global',
|
|
configPath: 'components.passwordHints',
|
|
});
|
|
runtime.register('copy-badge', initBadgeCopy, {
|
|
scope: 'global',
|
|
configPath: 'components.copyBadge',
|
|
});
|
|
runtime.register('copy-field', initCopyField, {
|
|
scope: 'global',
|
|
configPath: 'components.copyField',
|
|
});
|
|
runtime.register('multiselect', initMultiSelectComponent, {
|
|
scope: 'global',
|
|
configPath: 'components.multiSelect',
|
|
});
|
|
runtime.register('details-state', initDetailsState, {
|
|
scope: 'global',
|
|
configPath: 'components.detailsState',
|
|
});
|
|
runtime.register('settings-telemetry', initTelemetrySettings, {
|
|
scope: 'global',
|
|
configPath: 'components.settingsTelemetry',
|
|
});
|
|
runtime.register('auto-submit', initAutoSubmit, {
|
|
scope: 'global',
|
|
configPath: 'components.autoSubmit',
|
|
});
|
|
runtime.register('file-upload', initFileUpload, {
|
|
selector: '[data-app-component="file-upload"]',
|
|
configPath: 'components.fileUpload',
|
|
});
|
|
runtime.register('lookup-field', initLookupFields, {
|
|
scope: 'global',
|
|
configPath: 'components.lookupField',
|
|
defaultConfig: {
|
|
selector: '[data-app-lookup]',
|
|
},
|
|
});
|
|
await mountModuleComponentsByPhase('early');
|
|
|
|
// Phase B: structure-defining components.
|
|
runtime.register('sidebar-toggle', initSidebarToggle, {
|
|
scope: 'global',
|
|
configPath: 'components.sidebarToggle',
|
|
});
|
|
runtime.register('aside-panels', initAsidePanels, {
|
|
scope: 'global',
|
|
configPath: 'components.asidePanels',
|
|
});
|
|
runtime.register('details-aside-toggle', initDetailsAsideToggle, {
|
|
scope: 'global',
|
|
configPath: 'components.detailsAsideToggle',
|
|
});
|
|
runtime.register('theme-controls', initThemeControls, {
|
|
selector: '[data-app-component="theme-controls"]',
|
|
configPath: 'components.themeControls',
|
|
});
|
|
runtime.register('contrast-toggle', initContrastToggle, {
|
|
selector: '[data-app-component="contrast-toggle"]',
|
|
configPath: 'components.contrastToggle',
|
|
});
|
|
runtime.register('tenant-switcher', initTenantSwitcher, {
|
|
selector: '[data-app-component="tenant-switcher"]',
|
|
configPath: 'components.tenantSwitcher',
|
|
});
|
|
runtime.register('custom-field-options-toggle', initCustomFieldOptionsToggle, {
|
|
scope: 'global',
|
|
configPath: 'components.customFieldOptionsToggle',
|
|
});
|
|
runtime.register('tenant-sso-toggle', initTenantSsoToggle, {
|
|
scope: 'global',
|
|
configPath: 'components.tenantSsoToggle',
|
|
});
|
|
runtime.register('tenant-ldap-toggle', initTenantLdapToggle, {
|
|
scope: 'global',
|
|
configPath: 'components.tenantLdapToggle',
|
|
});
|
|
runtime.register('standard-detail-page', initStandardDetailPages, {
|
|
scope: 'global',
|
|
configPath: 'components.standardDetailPage',
|
|
});
|
|
await mountModuleComponentsByPhase('default');
|
|
|
|
// Phase C: high-risk (search, side panel interactions).
|
|
runtime.register('global-search', initGlobalSearch, {
|
|
selector: '[data-app-search-dialog]',
|
|
configPath: 'components.globalSearch',
|
|
});
|
|
|
|
runtime.register('tabs', initTabs, {
|
|
selector: '[data-app-component="tabs"]',
|
|
configPath: 'components.tabs',
|
|
defaultConfig: {
|
|
selector: '[data-app-component="tabs"]',
|
|
storageNamespace: 'app-ui',
|
|
storageVersion: 'v1',
|
|
},
|
|
});
|
|
runtime.register('session-warning', initSessionWarning, {
|
|
selector: '[data-app-component="session-warning"]',
|
|
configPath: 'components.sessionWarning',
|
|
defaultConfig: {
|
|
selector: '[data-app-session-warning-dialog]',
|
|
enabled: true,
|
|
},
|
|
});
|
|
runtime.register('fslightbox-refresh', initFsLightboxRefresh, {
|
|
scope: 'global',
|
|
configPath: 'components.fslightboxRefresh',
|
|
});
|
|
await mountModuleComponentsByPhase('late');
|
|
|
|
runtime.mountAll();
|
|
};
|
|
|
|
void bootRuntime();
|