Skip to content

feat(ui): App Mode — port admin logic + tenant page to OSS - #31939

Closed
chirag-madlani wants to merge 7 commits into
mainfrom
feature/app-mode-full-port
Closed

feat(ui): App Mode — port admin logic + tenant page to OSS#31939
chirag-madlani wants to merge 7 commits into
mainfrom
feature/app-mode-full-port

Conversation

@chirag-madlani

Copy link
Copy Markdown
Collaborator

Summary

Ports the App Mode admin logic and tenant-default settings UI from Collate into OSS so downstream plugins can register modes symmetrically:

  • Extends useAppRoutesRegistry with an optional { labelKey, icon } metadata field per registration, plus a runtime fallback map so pre-metadata Collate builds keep compiling.
  • Adds tenant-default admin page DefaultAppModePage at /settings/preferences/appMode (admin-gated in the Preferences category). Options come from the registry metadata + a "No default" sentinel; save writes appConfiguration.defaultAppMode via the existing /system/settings/appConfiguration endpoint.
  • Registers DEFAULT_APP_MODE in AppRouter.tsx with metadata (the registry-driven dispatch already lived there — this just makes the default show up in the picker).
  • Adds locale keys across all 20 locale files with real translations.
  • Playwright admin round-trip spec at playwright/e2e/Features/AppMode/DefaultAppModePage.spec.ts.

Non-breaking guarantee

  • No changes to schemas, DB migrations, REST clients, useAppMode/useResolvedAppMode/resolveEffectiveAppMode, or SystemResource.java.
  • useAppRoutesRegistry.registerRoutes metadata arg is optional at the type level; runtime fallback map keys off DEFAULT_APP_MODE/AI_APP_MODE. Collate builds on the pre-merge OSS pointer continue to compile.
  • Diff on openmetadata-spec/, SystemResource.java, settingConfigAPI.ts, useAppMode.ts, useResolvedAppMode.ts: empty.

Not in this PR (deferred by design)

  • Persona picker registry migration: OSS already ships a per-persona app-mode picker (pages/SettingsAppModePage/SettingsAppModePage.tsx, reached via PersonaDetailsPage's "Customize UI" tab → app-mode category). Today it hardcodes [AppMode.Classic, AppMode.AI] rather than reading from the new registry metadata — refactor is a follow-up ticket.
  • Generic-key ↔ closed-enum seam: DefaultAppModePage.tsx:97-98 casts the registry's generic string key to the closed DefaultAppMode enum. In OSS-only deployments (only DEFAULT_APP_MODE = 'default' registered), the picker can only PUT null — no path to 'ai' or 'classic' without a plugin registering that mode. Pre-existing design tension; recommend a follow-up ticket to unify.
  • AppModeSwitcher popover UI: intentionally stays in Collate for now; arrives in OSS later together with any OSS-native AI routes.

Test plan

  • yarn test --testPathPattern=useAppRoutesRegistry — 9/9 pass
  • yarn test --testPathPattern=DefaultAppModePage — 4/4 pass
  • yarn test --testPathPattern=AppRouter — 9/9 pass (7 pre-existing + 2 new)
  • Broader sweep (152 passed / 26 skipped / 0 failed) across App/AppMode/AppRouter/PersonaDetailsPage/SettingsAppModePage
  • npx playwright test Features/AppMode/DefaultAppModePage --list — 1 spec discovered
  • npx tsc --project playwright/tsconfig.json --noEmit — 0 new errors
  • npx eslint — 0 errors on new files
  • Non-breaking-diff verification on protected paths — empty
  • Manual (dev server): Settings → Preferences → App Mode loads, save cycle works
  • Native-speaker review of new locale keys in 9 locales (ar-sa, gl-es, he-he, mr-in, nl-nl, pr-pr, sv-se, th-th, tr-tr) — authored without native review

Companion Collate cleanup will land after this merges — deletes the duplicate AppModeSettingsPage, AdminOpsServlet app-configuration handlers + tests, and updates Collate's three registerRoutes(...) call sites to pass explicit metadata.

chirag-madlani and others added 7 commits August 24, 2026 11:02
registerRoutes now accepts an optional { labelKey, icon } describing how
the mode should render in pickers. Fallback map covers DEFAULT/AI when a
caller omits it (kept for pre-metadata Collate builds). Explicit metadata
always wins; unregisterRoutes clears both routes and metadata.
default-app-mode, no-default, and two description keys — with real
translations in every non-English locale. label.default and
label.app-mode already existed in every locale from a prior port and
were left untouched.
Radio picker sourced from useAppRoutesRegistry metadata + a 'No default'
option. Reads/writes appConfiguration.defaultAppMode via existing
/system/settings/appConfiguration endpoints. Save disabled until the
value changes.
server.entity-updated-successfully does not exist in en-us.json; the real
key is server.entity-updated-success. Also drop the test's local
react-i18next mock (which had papered over the typo) in favor of the
repo's global key-echoing mock, and assert the success toast fires with
the exact correct key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New route at /settings/preferences/appMode with an admin-gated entry in
the Preferences category. Icon reuses assets/svg/app-mode.svg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AppRouter already dispatches the authenticated router through the
registry (routes[appMode] ?? AuthenticatedRoutes), but DEFAULT_APP_MODE
itself was never registered, so its metadata never appeared in
useAppRoutesRegistry.metadata. That left the tenant admin's default
app-mode picker (DefaultAppModePage) unable to list "Classic/Default"
as an option — only "No default" plus whatever plugin modes (e.g.
Collate's AI mode) happened to be registered.

Export AuthenticatedRoutes from AppRouter.tsx and register it under
DEFAULT_APP_MODE with { labelKey: 'label.default' } on mount,
unregistering on unmount — symmetric with how a downstream plugin
registers its own mode. Purely additive: DEFAULT_APP_MODE was already
special-cased as "always valid" by isModeRoutesPending and
useResolvedAppMode's isModeRegistered, and ModeRoutes resolves to the
same AuthenticatedRoutes component whether or not this effect has run.
@github-actions github-actions Bot added safe to test Add this label to run secure Github workflows on PRs UI UI specific issues labels Aug 24, 2026
Comment on lines +45 to +59
const metadata = useAppRoutesRegistry((state) => state.metadata);
const [initialValue, setInitialValue] = useState<string>(NO_DEFAULT_VALUE);
const [currentValue, setCurrentValue] = useState<string>(NO_DEFAULT_VALUE);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);

const options = useMemo<AppModeOption[]>(() => {
const modeOptions = Object.entries(metadata).map(([value, m]) => ({
value,
labelKey: m.labelKey,
}));

return [
{ value: NO_DEFAULT_VALUE, labelKey: 'label.no-default' },
...modeOptions,

@gitar-bot gitar-bot Bot Aug 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Saved mode outside registry leaves radio group with no selection

currentValue/initialValue are seeded from getAppConfiguration().defaultAppMode, but the radio options come only from useAppRoutesRegistry.metadata. If the stored default is a mode whose plugin isn't loaded in the current build (e.g. 'ai' or 'classic' in an OSS-only deploy), no RadioButton matches the value, so the group renders with nothing selected and the admin cannot see the currently-configured default. Consider surfacing the unmatched value as a disabled/labelled option, or falling back to a synthetic option for the loaded value.

Fix:

const options = useMemo<AppModeOption[]>(() => {
  const modeOptions = Object.entries(metadata).map(([value, m]) => ({ value, labelKey: m.labelKey }));
  const base = [{ value: NO_DEFAULT_VALUE, labelKey: 'label.no-default' }, ...modeOptions];
  // Surface a stored value that no loaded plugin registers so it stays visible/selected.
  if (initialValue !== NO_DEFAULT_VALUE && !base.some((o) => o.value === initialValue)) {
    base.push({ value: initialValue, labelKey: 'label.app-mode' });
  }
  return base;
}, [metadata, initialValue]);

Was this helpful? React with 👍 / 👎

currentValue === NO_DEFAULT_VALUE
? null
: (currentValue as unknown as DefaultAppMode);
await patchAppConfiguration({ defaultAppMode });

@gitar-bot gitar-bot Bot Aug 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: patchAppConfiguration replaces whole config despite Partial signature

patchAppConfiguration accepts Partial<AppConfiguration> and is named like a merge, but it PUTs config_value: patch which fully replaces the stored appConfiguration. Today AppConfiguration has only defaultAppMode, so there is no live data loss, but the moment another field is added to this config, saving the default app mode from this page will silently wipe it. Consider merging over the fetched config before PUT (or renaming to make the replace-semantics explicit).

Fix:

export const patchAppConfiguration = async (
  patch: Partial<AppConfiguration>
): Promise<AppConfiguration> => {
  const current = await getAppConfiguration();
  const response = await axiosClient.put<Settings>(`/system/settings`, {
    config_type: SettingType.AppConfiguration,
    config_value: { ...current, ...patch },
  });

  return (response.data.config_value as AppConfiguration) ?? {};
};

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 0 resolved / 2 findings

Ports the App Mode admin logic and tenant-default settings UI to OSS with comprehensive test coverage. Consider handling saved modes outside the registry and verifying the patchAppConfiguration payload replacement behavior.

💡 Edge Case: Saved mode outside registry leaves radio group with no selection

📄 openmetadata-ui/src/main/resources/ui/src/pages/Settings/DefaultAppModePage/DefaultAppModePage.tsx:45-59 📄 openmetadata-ui/src/main/resources/ui/src/pages/Settings/DefaultAppModePage/DefaultAppModePage.tsx:120-133

currentValue/initialValue are seeded from getAppConfiguration().defaultAppMode, but the radio options come only from useAppRoutesRegistry.metadata. If the stored default is a mode whose plugin isn't loaded in the current build (e.g. 'ai' or 'classic' in an OSS-only deploy), no RadioButton matches the value, so the group renders with nothing selected and the admin cannot see the currently-configured default. Consider surfacing the unmatched value as a disabled/labelled option, or falling back to a synthetic option for the loaded value.

Fix
const options = useMemo<AppModeOption[]>(() => {
  const modeOptions = Object.entries(metadata).map(([value, m]) => ({ value, labelKey: m.labelKey }));
  const base = [{ value: NO_DEFAULT_VALUE, labelKey: 'label.no-default' }, ...modeOptions];
  // Surface a stored value that no loaded plugin registers so it stays visible/selected.
  if (initialValue !== NO_DEFAULT_VALUE && !base.some((o) => o.value === initialValue)) {
    base.push({ value: initialValue, labelKey: 'label.app-mode' });
  }
  return base;
}, [metadata, initialValue]);
💡 Quality: patchAppConfiguration replaces whole config despite Partial signature

📄 openmetadata-ui/src/main/resources/ui/src/pages/Settings/DefaultAppModePage/DefaultAppModePage.tsx:100

patchAppConfiguration accepts Partial<AppConfiguration> and is named like a merge, but it PUTs config_value: patch which fully replaces the stored appConfiguration. Today AppConfiguration has only defaultAppMode, so there is no live data loss, but the moment another field is added to this config, saving the default app mode from this page will silently wipe it. Consider merging over the fetched config before PUT (or renaming to make the replace-semantics explicit).

Fix
export const patchAppConfiguration = async (
  patch: Partial<AppConfiguration>
): Promise<AppConfiguration> => {
  const current = await getAppConfiguration();
  const response = await axiosClient.put<Settings>(`/system/settings`, {
    config_type: SettingType.AppConfiguration,
    config_value: { ...current, ...patch },
  });

  return (response.data.config_value as AppConfiguration) ?? {};
};
🤖 Prompt for agents
Code Review: Ports the App Mode admin logic and tenant-default settings UI to OSS with comprehensive test coverage. Consider handling saved modes outside the registry and verifying the patchAppConfiguration payload replacement behavior.

1. 💡 Edge Case: Saved mode outside registry leaves radio group with no selection
   Files: openmetadata-ui/src/main/resources/ui/src/pages/Settings/DefaultAppModePage/DefaultAppModePage.tsx:45-59, openmetadata-ui/src/main/resources/ui/src/pages/Settings/DefaultAppModePage/DefaultAppModePage.tsx:120-133

   `currentValue`/`initialValue` are seeded from `getAppConfiguration().defaultAppMode`, but the radio options come only from `useAppRoutesRegistry.metadata`. If the stored default is a mode whose plugin isn't loaded in the current build (e.g. `'ai'` or `'classic'` in an OSS-only deploy), no RadioButton matches the value, so the group renders with nothing selected and the admin cannot see the currently-configured default. Consider surfacing the unmatched value as a disabled/labelled option, or falling back to a synthetic option for the loaded value.

   Fix:
   const options = useMemo<AppModeOption[]>(() => {
     const modeOptions = Object.entries(metadata).map(([value, m]) => ({ value, labelKey: m.labelKey }));
     const base = [{ value: NO_DEFAULT_VALUE, labelKey: 'label.no-default' }, ...modeOptions];
     // Surface a stored value that no loaded plugin registers so it stays visible/selected.
     if (initialValue !== NO_DEFAULT_VALUE && !base.some((o) => o.value === initialValue)) {
       base.push({ value: initialValue, labelKey: 'label.app-mode' });
     }
     return base;
   }, [metadata, initialValue]);

2. 💡 Quality: patchAppConfiguration replaces whole config despite Partial signature
   Files: openmetadata-ui/src/main/resources/ui/src/pages/Settings/DefaultAppModePage/DefaultAppModePage.tsx:100

   `patchAppConfiguration` accepts `Partial<AppConfiguration>` and is named like a merge, but it PUTs `config_value: patch` which fully replaces the stored `appConfiguration`. Today `AppConfiguration` has only `defaultAppMode`, so there is no live data loss, but the moment another field is added to this config, saving the default app mode from this page will silently wipe it. Consider merging over the fetched config before PUT (or renaming to make the replace-semantics explicit).

   Fix:
   export const patchAppConfiguration = async (
     patch: Partial<AppConfiguration>
   ): Promise<AppConfiguration> => {
     const current = await getAppConfiguration();
     const response = await axiosClient.put<Settings>(`/system/settings`, {
       config_type: SettingType.AppConfiguration,
       config_value: { ...current, ...patch },
     });
   
     return (response.data.config_value as AppConfiguration) ?? {};
   };

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source

@github-actions

Copy link
Copy Markdown
Contributor

❌ UI Checkstyle Failed

❌ ESLint + Prettier + Organise Imports (src)

One or more source files have linting or formatting issues.

Affected files
  • openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json
    • openmetadata-ui/src/main/resources/ui/src/pages/Settings/DefaultAppModePage/DefaultAppModePage.test.tsx

❌ I18n Sync

Translation locale files are out of sync with en-us.json.

Affected files
  • openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json
    • openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json

🔍 ESLint findings in this PR's files — 0 error(s), 25 warning(s)

Errors block the build. Warnings do not yet — they are rules whose backlog is still
being worked down, listed so this PR does not add to it. See docs/ui-code-quality-gate.md.

0 error(s), 25 warning(s) across 3 changed file(s).

Count Rule
22 sonarjs/no-duplicate-string
2 jest/no-disabled-tests
1 openmetadata-imports/no-circular-imports
All findings
Location Rule Message
🟡 src/components/AppRouter/SettingsRouter.test.tsx:197:1 jest/no-disabled-tests Tests should not be skipped
🟡 src/components/AppRouter/SettingsRouter.test.tsx:343:3 jest/no-disabled-tests Tests should not be skipped
🟡 src/components/AppRouter/SettingsRouter.tsx:413:28 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:70:1 openmetadata-imports/no-circular-imports This runtime import participates in a circular dependency. Extract the shared type/constant/utility or invert the dependency.
🟡 src/utils/GlobalSettingsClassBase.ts:109:15 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 3 times.
🟡 src/utils/GlobalSettingsClassBase.ts:181:22 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 3 times.
🟡 src/utils/GlobalSettingsClassBase.ts:201:22 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 5 times.
🟡 src/utils/GlobalSettingsClassBase.ts:211:22 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 5 times.
🟡 src/utils/GlobalSettingsClassBase.ts:221:22 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 5 times.
🟡 src/utils/GlobalSettingsClassBase.ts:445:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:446:32 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 23 times.
🟡 src/utils/GlobalSettingsClassBase.ts:472:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:481:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:490:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:499:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:508:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:517:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:571:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:598:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:607:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:616:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:625:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:634:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 4 times.
🟡 src/utils/GlobalSettingsClassBase.ts:643:26 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 3 times.
🟡 src/utils/GlobalSettingsClassBase.ts:701:28 sonarjs/no-duplicate-string Define a constant instead of duplicating this literal 22 times.

Fix locally (fast - only checks files changed in this branch):

make ui-checkstyle-changed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

safe to test Add this label to run secure Github workflows on PRs UI UI specific issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant