Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* Copyright 2026 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { expect, test } from '@playwright/test';
import { getApiContext, redirectToHomePage } from '../../../utils/common';

// The generic app-mode registry only ever carries runtime string keys — OSS's
// own AppRouter registers 'default' (see DEFAULT_APP_MODE), and a plugin
// (e.g. Collate's AI mode) would add its own key at runtime. The wire
// schema's `defaultAppMode` enum ('ai' | 'classic' | null) is a separate,
// fixed contract, so this test asserts on whatever value the page actually
// round-trips through the API rather than assuming a specific literal.
const NO_DEFAULT_OPTION_TEST_ID = 'default-app-mode-option-__no_default__';
const DEFAULT_MODE_OPTION_TEST_ID = 'default-app-mode-option-default';
const APP_CONFIGURATION_SETTING_PATH =
'/api/v1/system/settings/appConfiguration';
const SYSTEM_SETTINGS_PATH = '/api/v1/system/settings';
const APP_CONFIGURATION_CONFIG_TYPE = 'appConfiguration';

interface AppConfigurationSetting {
config_value?: { defaultAppMode?: string | null } | null;
}

test.use({ storageState: 'playwright/.auth/admin.json' });

test.describe('Settings > Preferences > App Mode (tenant default)', () => {
test('admin round-trip: load, change, save reflects on GET', async ({
page,
}) => {
const { apiContext, afterAction } = await getApiContext(page);

const initialResponse = await apiContext.get(
APP_CONFIGURATION_SETTING_PATH
);
const initialSetting: AppConfigurationSetting =
await initialResponse.json();
const initialDefaultAppMode =
initialSetting?.config_value?.defaultAppMode ?? null;

// Toggle to whichever option differs from the current tenant default so
// the round-trip is observable regardless of pre-existing state.
const targetOptionTestId =
initialDefaultAppMode === null
? DEFAULT_MODE_OPTION_TEST_ID
: NO_DEFAULT_OPTION_TEST_ID;
const expectedDefaultAppMode =
targetOptionTestId === DEFAULT_MODE_OPTION_TEST_ID ? 'default' : null;

try {
await redirectToHomePage(page);
await page.goto('/settings/preferences/appMode');

await expect(page.getByTestId('default-app-mode-page')).toBeVisible();

await page
.getByTestId('default-app-mode-radio-group')
.getByTestId(targetOptionTestId)
.click();

await page.getByTestId('save-default-app-mode').click();

await expect(async () => {
const response = await apiContext.get(APP_CONFIGURATION_SETTING_PATH);
const setting: AppConfigurationSetting = await response.json();

expect(setting?.config_value?.defaultAppMode ?? null).toBe(
expectedDefaultAppMode
);
}).toPass({ timeout: 5000 });
} finally {
// Restore the tenant default to its pre-test value so other specs
// that read appConfiguration don't observe this test's mutation.
await apiContext.put(SYSTEM_SETTINGS_PATH, {
data: {
config_type: APP_CONFIGURATION_CONFIG_TYPE,
config_value: { defaultAppMode: initialDefaultAppMode },
},
});
await afterAction();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,40 @@ describe('AppRouter — App Mode routing integration', () => {
screen.queryByTestId('default-authenticated-routes')
).not.toBeInTheDocument();
});

it('registers DEFAULT_APP_MODE with the default authenticated routes and metadata on mount', async () => {
setAuthState({ isAuthenticated: true });

renderRouter();

await screen.findByTestId('default-authenticated-routes');

const { routes, metadata } = useAppRoutesRegistry.getState();

expect(routes[DEFAULT_APP_MODE]).toBeDefined();
expect(metadata[DEFAULT_APP_MODE]).toEqual(
expect.objectContaining({ labelKey: 'label.default' })
);
});

it('unregisters DEFAULT_APP_MODE on unmount', async () => {
setAuthState({ isAuthenticated: true });

const { unmount } = renderRouter();

await screen.findByTestId('default-authenticated-routes');

expect(
useAppRoutesRegistry.getState().routes[DEFAULT_APP_MODE]
).toBeDefined();

unmount();

expect(
useAppRoutesRegistry.getState().routes[DEFAULT_APP_MODE]
).toBeUndefined();
expect(
useAppRoutesRegistry.getState().metadata[DEFAULT_APP_MODE]
).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import { isEmpty } from 'lodash';
import { lazy } from 'react';
import { lazy, useEffect } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { useShallow } from 'zustand/react/shallow';
import { DEFAULT_APP_MODE } from '../../constants/appMode.constants';
Expand All @@ -29,7 +29,11 @@ const AuthenticatedApp = withPageSuspenseFallback(
lazy(() => import('./AuthenticatedApp'))
);

const AuthenticatedRoutes = withPageSuspenseFallback(
// Exported so it can be registered with `useAppRoutesRegistry` under
// `DEFAULT_APP_MODE` below — this is the actual "authenticated router" for
// the default/Classic mode, symmetric with how a downstream plugin (e.g.
// Collate's AI mode) registers its own routes component under its mode key.
export const AuthenticatedRoutes = withPageSuspenseFallback(
lazy(() =>
import('./AuthenticatedRoutes').then((m) => ({
default: m.AuthenticatedRoutes,
Expand Down Expand Up @@ -83,6 +87,27 @@ const AppRouter = () => {
const appMode = useAppMode();
const ModeRoutes = useAppRoutesRegistry((state) => state.routes[appMode]);

// Register the default mode's own routes/metadata so it shows up
// alongside any plugin-registered modes (e.g. Collate's AI mode) in
// registry-driven UIs — notably the tenant admin's default-app-mode
// picker, which lists its options from `useAppRoutesRegistry.metadata`.
// `DEFAULT_APP_MODE` is otherwise never routed through the registry
// (`isModeRoutesPending` and `useResolvedAppMode`'s `isModeRegistered`
// both special-case it as always-valid), so this registration is purely
// additive: `ModeRoutes` below resolves to the same `AuthenticatedRoutes`
// component whether or not this effect has run yet.
useEffect(() => {
useAppRoutesRegistry
.getState()
.registerRoutes(DEFAULT_APP_MODE, AuthenticatedRoutes, {
labelKey: 'label.default',
});

return () => {
useAppRoutesRegistry.getState().unregisterRoutes(DEFAULT_APP_MODE);
};
}, []);

const isRegistrySettled = useResolvedAppMode();

// A non-default mode's routes are registered by the plugin that owns the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
default: jest.fn().mockReturnValue(<div>BotsPageV1</div>),
}));

jest.mock('../../pages/Settings/DefaultAppModePage/DefaultAppModePage', () => ({
__esModule: true,
default: jest.fn().mockReturnValue(<div>DefaultAppModePage</div>),
}));

jest.mock(
'../../pages/Configuration/EditLoginConfiguration/EditLoginConfigurationPage',
() => ({
Expand Down Expand Up @@ -189,7 +194,7 @@
default: jest.fn().mockImplementation(({ children }) => children),
}));

describe.skip('SettingsRouter', () => {

Check warning on line 197 in openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.test.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

Tests should not be skipped
it('should render GlobalSettingPage component for exact settings route', async () => {
render(
<MemoryRouter initialEntries={['/settings']}>
Expand Down Expand Up @@ -335,7 +340,7 @@
).toBeInTheDocument();
});

it.skip('should render CustomPageSettings component for custom page settings route', async () => {

Check warning on line 343 in openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.test.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

Tests should not be skipped
render(
<MemoryRouter
initialEntries={[`/settings/preferences/customizeLandingPage`]}>
Expand Down Expand Up @@ -540,4 +545,10 @@
expect(await screen.findByText('ServicesPage')).toBeInTheDocument();
expect(screen.queryByText('NotFound')).not.toBeInTheDocument();
});

it('renders DefaultAppModePage for the preferences app mode route', async () => {
renderAt('/settings/preferences/appMode');

expect(await screen.findByText('DefaultAppModePage')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@
)
);

const DefaultAppModePage = withPageSuspenseFallback(
React.lazy(
() => import('../../pages/Settings/DefaultAppModePage/DefaultAppModePage')
)
);

const EditLoginConfiguration = withPageSuspenseFallback(
React.lazy(
() =>
Expand Down Expand Up @@ -404,7 +410,7 @@
element={
<AdminProtectedRoute hasPermission={false}>
<EditEmailConfigPage
pageTitle={t('label.edit-entity', {

Check warning on line 413 in openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.tsx

View workflow job for this annotation

GitHub Actions / checkstyle

Define a constant instead of duplicating this literal 4 times
entity: t('label.entity-configuration', {
entity: t('label.email'),
}),
Expand Down Expand Up @@ -931,6 +937,17 @@
GlobalSettingOptions.LEARNING_RESOURCES
)}
/>
<Route
element={
<AdminProtectedRoute>
<DefaultAppModePage />
</AdminProtectedRoute>
}
path={getSettingPathRelative(
GlobalSettingsMenuCategory.PREFERENCES,
GlobalSettingOptions.APP_MODE
)}
/>
<Route
element={<SettingsSso />}
path={getSettingPathRelative(GlobalSettingsMenuCategory.SSO)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,5 @@ export enum GlobalSettingOptions {
WORKFLOW_DEFINITIONS = 'workflow-definitions',
LEARNING_RESOURCES = 'learning-resources',
COLUMN = 'column',
APP_MODE = 'appMode',
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import { act, ComponentType } from 'react';
import { DEFAULT_APP_MODE } from '../constants/appMode.constants';
import { useAppRoutesRegistry } from './useAppRoutesRegistry';

const FakeRoutes: ComponentType = () => null;
Expand Down Expand Up @@ -87,3 +88,47 @@ describe('useAppRoutesRegistry', () => {
expect(useAppRoutesRegistry.getState().routes).toBe(before);
});
});

describe('metadata', () => {
const Component = () => null;

beforeEach(() => {
useAppRoutesRegistry.setState({ routes: {}, metadata: {} });
});

it('registerRoutes stores explicit metadata', () => {
useAppRoutesRegistry.getState().registerRoutes('ai', Component, {
labelKey: 'label.ai',
});

expect(useAppRoutesRegistry.getState().metadata.ai).toEqual({
labelKey: 'label.ai',
});
});

it('registerRoutes falls back to the mode-keyed default when metadata omitted', () => {
useAppRoutesRegistry.getState().registerRoutes(DEFAULT_APP_MODE, Component);

expect(useAppRoutesRegistry.getState().metadata[DEFAULT_APP_MODE]).toEqual({
labelKey: 'label.default',
});
});

it('registerRoutes falls back to a generic metadata for unknown modes when metadata omitted', () => {
useAppRoutesRegistry.getState().registerRoutes('custom-mode', Component);

expect(useAppRoutesRegistry.getState().metadata['custom-mode']).toEqual({
labelKey: 'label.app-mode',
});
});

it('unregisterRoutes removes both routes and metadata', () => {
useAppRoutesRegistry.getState().registerRoutes('ai', Component, {
labelKey: 'label.ai',
});
useAppRoutesRegistry.getState().unregisterRoutes('ai');

expect(useAppRoutesRegistry.getState().routes.ai).toBeUndefined();
expect(useAppRoutesRegistry.getState().metadata.ai).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import { ComponentType } from 'react';
import { create } from 'zustand';
import { AI_APP_MODE, DEFAULT_APP_MODE } from '../constants/appMode.constants';

/**
* Runtime registry of authenticated-routes components keyed by AppMode.
Expand All @@ -31,9 +32,21 @@ import { create } from 'zustand';
* fallback that runs whenever no other mode is active or registered.
*/

export interface AppModeMetadata {
/** i18n key for the human-readable label. */
labelKey: string;
/** Optional SVG component for pickers. */
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}

interface AppRoutesRegistryStore {
routes: Record<string, ComponentType>;
registerRoutes: (mode: string, component: ComponentType) => void;
metadata: Record<string, AppModeMetadata>;
registerRoutes: (
mode: string,
component: ComponentType,
metadata?: AppModeMetadata
) => void;
/**
* Remove a previously-registered mode. Use when the source plugin
* becomes unavailable mid-session (e.g., admin uninstalls the app)
Expand All @@ -44,18 +57,36 @@ interface AppRoutesRegistryStore {
unregisterRoutes: (mode: string) => void;
}

// Fallback so callers that predate the metadata arg still yield a
// sensible label. Explicit metadata always wins.
const FALLBACK_METADATA: Record<string, AppModeMetadata> = {
[DEFAULT_APP_MODE]: { labelKey: 'label.default' },
[AI_APP_MODE]: { labelKey: 'label.ai' },
};

const GENERIC_METADATA: AppModeMetadata = { labelKey: 'label.app-mode' };

export const useAppRoutesRegistry = create<AppRoutesRegistryStore>((set) => ({
routes: {},
registerRoutes: (mode, component) =>
set((state) => ({ routes: { ...state.routes, [mode]: component } })),
metadata: {},
registerRoutes: (mode, component, metadata) =>
set((state) => ({
routes: { ...state.routes, [mode]: component },
metadata: {
...state.metadata,
[mode]: metadata ?? FALLBACK_METADATA[mode] ?? GENERIC_METADATA,
},
})),
unregisterRoutes: (mode) =>
set((state) => {
if (!(mode in state.routes)) {
if (!(mode in state.routes) && !(mode in state.metadata)) {
return state;
}
const next = { ...state.routes };
delete next[mode];
const nextRoutes = { ...state.routes };
delete nextRoutes[mode];
const nextMetadata = { ...state.metadata };
delete nextMetadata[mode];

return { routes: next };
return { routes: nextRoutes, metadata: nextMetadata };
}),
}));
Loading
Loading