@gntik-ai/templates 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +47 -13
- package/dist/index.js +257 -189
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/index.ts +1 -1
- package/src/keyboard-shortcuts/Page.test.tsx +39 -1
- package/src/keyboard-shortcuts/ShortcutSheet.tsx +3 -2
- package/src/keyboard-shortcuts/data.ts +28 -14
- package/src/resource-detail/CustomTabs.tsx +41 -0
- package/src/resource-detail/Page.default.test.tsx +28 -0
- package/src/resource-detail/Page.labels.test.tsx +67 -0
- package/src/resource-detail/Page.shell-less.test.tsx +36 -0
- package/src/resource-detail/Page.tabs.test.tsx +116 -0
- package/src/resource-detail/Page.tsx +80 -34
- package/src/resource-detail/__snapshots__/Page.default.test.tsx.snap +7 -0
- package/src/resource-detail/examples/Labels.tsx +18 -0
- package/src/resource-detail/examples/Spanish.tsx +11 -0
- package/src/resource-detail/examples/embedded-custom-tabs.tsx +44 -0
- package/src/resource-detail/template.meta.ts +1 -1
- package/src/settings-profile/fixtures/editable.snap +1 -1
- package/src/shared/ConsoleShell.test.tsx +13 -0
- package/src/shared/ConsoleShell.tsx +35 -30
- package/src/workflow-builder/Page.test.tsx +103 -2
- package/src/workflow-builder/Page.tsx +34 -8
- package/src/workflow-builder/examples/SaveGraph.tsx +17 -0
- package/src/workflow-builder/template.meta.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gntik-ai/templates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "gntik-ui page templates: complete product pages (auth, dashboard, resources, settings, system) on the kit layouts and blocks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,14 +35,14 @@
|
|
|
35
35
|
"react-dom": "^19.0.0"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@gntik-ai/blocks": "0.
|
|
39
|
-
"@gntik-ai/charts": "0.
|
|
40
|
-
"@gntik-ai/chat": "0.
|
|
41
|
-
"@gntik-ai/editor": "0.
|
|
42
|
-
"@gntik-ai/flow": "0.
|
|
38
|
+
"@gntik-ai/blocks": "0.11.0",
|
|
39
|
+
"@gntik-ai/charts": "0.11.0",
|
|
40
|
+
"@gntik-ai/chat": "0.11.0",
|
|
41
|
+
"@gntik-ai/editor": "0.11.0",
|
|
42
|
+
"@gntik-ai/flow": "0.11.0",
|
|
43
43
|
"@gntik-ai/icons": "0.5.0",
|
|
44
|
-
"@gntik-ai/tokens": "0.
|
|
45
|
-
"@gntik-ai/ui": "0.
|
|
44
|
+
"@gntik-ai/tokens": "0.3.0",
|
|
45
|
+
"@gntik-ai/ui": "0.11.0",
|
|
46
46
|
"lucide-react": "^1.48.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -57,7 +57,7 @@ export { meta as resetPasswordTemplateMeta } from './reset-password/template.met
|
|
|
57
57
|
export type { ResetPasswordPageProps } from './reset-password/Page';
|
|
58
58
|
export { default as ResourceDetailPage } from './resource-detail/Page';
|
|
59
59
|
export { meta as resourceDetailTemplateMeta } from './resource-detail/template.meta';
|
|
60
|
-
export type { ResourceDetailProps, ResourceDetailTab } from './resource-detail/Page';
|
|
60
|
+
export type { ResourceDetailProps, ResourceDetailTab, ResourceDetailLabels } from './resource-detail/Page';
|
|
61
61
|
export type { ResourceSettings } from './resource-detail/data';
|
|
62
62
|
export { default as ResourceFormPage } from './resource-form/Page';
|
|
63
63
|
export { meta as resourceFormTemplateMeta } from './resource-form/template.meta';
|
|
@@ -2,7 +2,45 @@ import { render, screen, within } from '@testing-library/react';
|
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { expectNoAxeViolations } from '../test/a11y';
|
|
4
4
|
import KeyboardShortcutsPage, { KeyboardShortcutsDialog } from './Page';
|
|
5
|
-
import { filterShortcuts, shortcutGroups } from './data';
|
|
5
|
+
import { filterShortcuts, shortcutGroups, type ShortcutGroup } from './data';
|
|
6
|
+
import { ShortcutSheet } from './ShortcutSheet';
|
|
7
|
+
|
|
8
|
+
describe('platform shortcut hints', () => {
|
|
9
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
10
|
+
|
|
11
|
+
it('shows Ctrl on Linux and finds the command palette by ctrl k', async () => {
|
|
12
|
+
vi.stubGlobal('navigator', { platform: 'Linux x86_64' });
|
|
13
|
+
render(<ShortcutSheet groups={shortcutGroups} />);
|
|
14
|
+
const row = screen.getByText('Open the command palette').closest('li');
|
|
15
|
+
expect(row).not.toBeNull();
|
|
16
|
+
expect(within(row!).getByText('Ctrl')).toBeInTheDocument();
|
|
17
|
+
await userEvent.type(screen.getByRole('searchbox'), 'ctrl k');
|
|
18
|
+
expect(screen.getByText('Open the command palette')).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByText('1 shortcut')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('matches platform modifier spellings without changing legacy callers', () => {
|
|
23
|
+
const ids = (groups: ShortcutGroup[]) => groups.flatMap((group) => group.shortcuts.map((shortcut) => shortcut.id));
|
|
24
|
+
expect(ids(filterShortcuts(shortcutGroups, 'CtRl k', 'en', 'linux'))).toEqual(['palette']);
|
|
25
|
+
expect(ids(filterShortcuts(shortcutGroups, '⌘ k', 'en', 'macos'))).toEqual(['palette']);
|
|
26
|
+
expect(ids(filterShortcuts(shortcutGroups, '⌘ k', 'en'))).toEqual(['palette']);
|
|
27
|
+
const legacy: ShortcutGroup[] = [
|
|
28
|
+
{
|
|
29
|
+
id: 'legacy',
|
|
30
|
+
title: 'Legacy',
|
|
31
|
+
shortcuts: [
|
|
32
|
+
{ id: 'literal', description: 'Explicit shortcut', keys: ['⌘', 'K'] },
|
|
33
|
+
{ id: 'sequence', description: 'Sequence', keys: ['G'], then: ['P'] },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
expect(ids(filterShortcuts(legacy, '⌘ k', 'es'))).toEqual(['literal']);
|
|
38
|
+
expect(filterShortcuts(legacy, 'ctrl k', 'es')).toEqual([]);
|
|
39
|
+
expect(ids(filterShortcuts(legacy, 'g p'))).toEqual(['sequence']);
|
|
40
|
+
expect(filterShortcuts(legacy, 'legacy')).toEqual(legacy);
|
|
41
|
+
expect(filterShortcuts(legacy, ' ')).toEqual(legacy);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
6
44
|
|
|
7
45
|
describe('KeyboardShortcutsPage', () => {
|
|
8
46
|
it('renders the console page with every group', { timeout: 15000 }, async () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useI18n } from '@gntik-ai/ui';
|
|
1
|
+
import { useI18n, useOs } from '@gntik-ai/ui';
|
|
2
2
|
import { Keyboard, Search } from '@gntik-ai/icons';
|
|
3
3
|
import { EmptyState, Grid, Heading, Input, KbdCombo, Stack, Text } from '@gntik-ai/ui';
|
|
4
4
|
import { useId, useState } from 'react';
|
|
@@ -16,9 +16,10 @@ export interface ShortcutSheetProps {
|
|
|
16
16
|
/** Searchable shortcut sheet shared by the page and the dialog. */
|
|
17
17
|
export function ShortcutSheet({ groups, headingLevel = 2, columns = 2, autoFocus }: ShortcutSheetProps) {
|
|
18
18
|
const { locale } = useI18n();
|
|
19
|
+
const os = useOs();
|
|
19
20
|
const [query, setQuery] = useState('');
|
|
20
21
|
const countId = useId();
|
|
21
|
-
const visible = filterShortcuts(groups, query, locale);
|
|
22
|
+
const visible = filterShortcuts(groups, query, locale, os);
|
|
22
23
|
const total = visible.reduce((n, g) => n + g.shortcuts.length, 0);
|
|
23
24
|
return (
|
|
24
25
|
<Stack gap={5}>
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { matchText } from '@gntik-ai/ui';
|
|
2
|
-
import type { BreadcrumbItem } from '@gntik-ai/ui';
|
|
2
|
+
import type { BreadcrumbItem, Os } from '@gntik-ai/ui';
|
|
3
3
|
|
|
4
4
|
export interface Shortcut {
|
|
5
5
|
id: string;
|
|
6
6
|
/** What the shortcut does. */
|
|
7
7
|
description: string;
|
|
8
|
-
/** Keys pressed together, e.g. ['
|
|
8
|
+
/** Keys pressed together, e.g. ['mod', 'K']. */
|
|
9
9
|
keys: string[];
|
|
10
10
|
/** A second combination that does the same (e.g. a sequence "G then P"). */
|
|
11
11
|
then?: string[];
|
|
@@ -28,10 +28,10 @@ export const shortcutGroups: ShortcutGroup[] = [
|
|
|
28
28
|
id: 'general',
|
|
29
29
|
title: 'General',
|
|
30
30
|
shortcuts: [
|
|
31
|
-
{ id: 'palette', description: 'Open the command palette', keys: ['
|
|
31
|
+
{ id: 'palette', description: 'Open the command palette', keys: ['mod', 'K'] },
|
|
32
32
|
{ id: 'help', description: 'Show keyboard shortcuts', keys: ['?'] },
|
|
33
|
-
{ id: 'sidebar', description: 'Collapse or expand the sidebar', keys: ['
|
|
34
|
-
{ id: 'theme', description: 'Switch theme', keys: ['
|
|
33
|
+
{ id: 'sidebar', description: 'Collapse or expand the sidebar', keys: ['mod', 'B'] },
|
|
34
|
+
{ id: 'theme', description: 'Switch theme', keys: ['mod', '⇧', 'L'] },
|
|
35
35
|
],
|
|
36
36
|
},
|
|
37
37
|
{
|
|
@@ -59,26 +59,40 @@ export const shortcutGroups: ShortcutGroup[] = [
|
|
|
59
59
|
id: 'editing',
|
|
60
60
|
title: 'Editing',
|
|
61
61
|
shortcuts: [
|
|
62
|
-
{ id: 'save', description: 'Save changes', keys: ['
|
|
63
|
-
{ id: 'submit', description: 'Submit a form or comment', keys: ['
|
|
62
|
+
{ id: 'save', description: 'Save changes', keys: ['mod', 'S'] },
|
|
63
|
+
{ id: 'submit', description: 'Submit a form or comment', keys: ['mod', '↵'] },
|
|
64
64
|
{ id: 'cancel', description: 'Cancel and close', keys: ['Esc'] },
|
|
65
|
-
{ id: 'undo', description: 'Undo', keys: ['
|
|
65
|
+
{ id: 'undo', description: 'Undo', keys: ['mod', 'Z'] },
|
|
66
66
|
],
|
|
67
67
|
},
|
|
68
68
|
];
|
|
69
69
|
|
|
70
|
-
/** Groups
|
|
71
|
-
export function filterShortcuts(
|
|
70
|
+
/** Groups matching descriptions, titles or keys; mod accepts both Command and Ctrl spellings. */
|
|
71
|
+
export function filterShortcuts(
|
|
72
|
+
groups: readonly ShortcutGroup[],
|
|
73
|
+
query: string,
|
|
74
|
+
locale?: string,
|
|
75
|
+
os: Os = 'undetermined',
|
|
76
|
+
): ShortcutGroup[] {
|
|
72
77
|
const q = query.trim().toLowerCase();
|
|
73
78
|
if (!q) return [...groups];
|
|
79
|
+
const modifier = os === 'macos' || os === 'ios' ? '⌘' : 'Ctrl';
|
|
80
|
+
const alias = modifier === '⌘' ? 'Ctrl' : '⌘';
|
|
81
|
+
const matchesKeys = (shortcut: Shortcut) => {
|
|
82
|
+
const keys = [...shortcut.keys, ...(shortcut.then ?? [])];
|
|
83
|
+
return ['mod', modifier, alias].some(
|
|
84
|
+
(mod) =>
|
|
85
|
+
keys
|
|
86
|
+
.map((key) => (key === 'mod' ? mod : key))
|
|
87
|
+
.join(' ')
|
|
88
|
+
.toLowerCase() === q,
|
|
89
|
+
);
|
|
90
|
+
};
|
|
74
91
|
return groups
|
|
75
92
|
.map((g) => ({
|
|
76
93
|
...g,
|
|
77
94
|
shortcuts: g.shortcuts.filter(
|
|
78
|
-
(s) =>
|
|
79
|
-
matchText(s.description, query, { locale }) ||
|
|
80
|
-
matchText(g.title, query, { locale }) ||
|
|
81
|
-
[...s.keys, ...(s.then ?? [])].join(' ').toLowerCase() === q,
|
|
95
|
+
(s) => matchText(s.description, query, { locale }) || matchText(g.title, query, { locale }) || matchesKeys(s),
|
|
82
96
|
),
|
|
83
97
|
}))
|
|
84
98
|
.filter((g) => g.shortcuts.length > 0);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Tabs, TabsList, TabsPanel, TabsTab } from '@gntik-ai/ui';
|
|
2
|
+
import { useState, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
export interface CustomTabsProps {
|
|
5
|
+
tabs: { value: string; label: string; content: ReactNode }[];
|
|
6
|
+
defaultTab?: string;
|
|
7
|
+
onTabChange?: (tab: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function CustomTabs({ tabs, defaultTab, onTabChange, label }: CustomTabsProps & { label: string }) {
|
|
11
|
+
const [selected, setSelected] = useState(defaultTab);
|
|
12
|
+
const value = tabs.find((tab) => tab.value === selected)?.value ?? tabs[0]?.value ?? null;
|
|
13
|
+
|
|
14
|
+
if (tabs.length === 0) return null;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<Tabs
|
|
18
|
+
value={value}
|
|
19
|
+
onValueChange={(next) => {
|
|
20
|
+
const tab = tabs.find((tab) => tab.value === next);
|
|
21
|
+
if (tab) {
|
|
22
|
+
setSelected(tab.value);
|
|
23
|
+
onTabChange?.(tab.value);
|
|
24
|
+
}
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
<TabsList aria-label={label}>
|
|
28
|
+
{tabs.map((tab) => (
|
|
29
|
+
<TabsTab key={tab.value} value={tab.value}>
|
|
30
|
+
{tab.label}
|
|
31
|
+
</TabsTab>
|
|
32
|
+
))}
|
|
33
|
+
</TabsList>
|
|
34
|
+
{tabs.map((tab) => (
|
|
35
|
+
<TabsPanel key={tab.value} value={tab.value}>
|
|
36
|
+
{tab.content}
|
|
37
|
+
</TabsPanel>
|
|
38
|
+
))}
|
|
39
|
+
</Tabs>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import ResourceDetailPage from './Page';
|
|
3
|
+
import { shell } from './examples/shell';
|
|
4
|
+
|
|
5
|
+
vi.hoisted(() => {
|
|
6
|
+
vi.useFakeTimers({ now: new Date('2026-10-23T12:00:00Z'), toFake: ['Date'] });
|
|
7
|
+
});
|
|
8
|
+
afterAll(() => vi.useRealTimers());
|
|
9
|
+
|
|
10
|
+
// React's client IDs advance across roots. Preserve their relationships while
|
|
11
|
+
// canonicalizing only the generated IDs so the original markup is comparable.
|
|
12
|
+
function stableIds(html: string) {
|
|
13
|
+
const ids = new Map<string, string>();
|
|
14
|
+
return html.replace(/_r_[\da-z]+_/g, (id) => {
|
|
15
|
+
if (!ids.has(id)) ids.set(id, `_stable_${ids.size}_`);
|
|
16
|
+
return ids.get(id)!;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
it('preserves the original default shell markup', () => {
|
|
21
|
+
const { container } = render(<ResourceDetailPage />);
|
|
22
|
+
expect(stableIds(container.innerHTML)).toMatchSnapshot();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('preserves the original explicit shell markup', () => {
|
|
26
|
+
const { container } = render(<ResourceDetailPage shell={shell} />);
|
|
27
|
+
expect(stableIds(container.innerHTML)).toMatchSnapshot();
|
|
28
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { I18nProvider, es } from '@gntik-ai/ui';
|
|
4
|
+
import ResourceDetailPage, { type ResourceDetailLabels } from './Page';
|
|
5
|
+
import { detailResource, detailMeta, detailItems } from './data';
|
|
6
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
7
|
+
|
|
8
|
+
it(
|
|
9
|
+
'localizes only resource tabs and their tablist, preserves fixtures, and remains keyboard accessible',
|
|
10
|
+
{ timeout: 15000 },
|
|
11
|
+
async () => {
|
|
12
|
+
const user = userEvent.setup();
|
|
13
|
+
const { container } = render(
|
|
14
|
+
<I18nProvider locale="es">
|
|
15
|
+
<ResourceDetailPage />
|
|
16
|
+
</I18nProvider>,
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByRole('tablist', { name: es['resourceDetail.sections'] })).toBeVisible();
|
|
19
|
+
for (const key of ['overview', 'activity', 'settings'] as const)
|
|
20
|
+
expect(screen.getByRole('tab', { name: es[`resourceDetail.${key}`] })).toBeVisible();
|
|
21
|
+
const overview = screen.getByRole('tab', { name: 'Resumen' });
|
|
22
|
+
expect(overview).toHaveAttribute('aria-selected', 'true');
|
|
23
|
+
act(() => overview.focus());
|
|
24
|
+
await user.keyboard('{ArrowRight}{Enter}');
|
|
25
|
+
expect(screen.getByRole('tab', { name: 'Actividad' })).toHaveAttribute('aria-selected', 'true');
|
|
26
|
+
await user.click(overview);
|
|
27
|
+
expect(screen.getByRole('heading', { name: detailResource.name })).toBeVisible();
|
|
28
|
+
expect(screen.getByText(detailResource.description)).toBeVisible();
|
|
29
|
+
for (const item of detailMeta) expect(screen.getAllByText(String(item.value))[0]).toBeVisible();
|
|
30
|
+
for (const item of detailItems) expect(screen.getAllByText(item.label)[0]).toBeVisible();
|
|
31
|
+
expect(screen.getByRole('heading', { name: 'Configuration' })).toBeVisible();
|
|
32
|
+
expect(screen.getAllByRole('button', { name: 'View logs' })[0]).toBeVisible();
|
|
33
|
+
expect(screen.getAllByRole('link', { name: 'Deployments' })[0]).toHaveAttribute('href', '/deployments');
|
|
34
|
+
expect(container.textContent).not.toContain('{');
|
|
35
|
+
await expectNoAxeViolations(container);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
it('keeps the English defaults and renders every tab override with catalog fallback in readOnly mode', () => {
|
|
40
|
+
const { rerender } = render(<ResourceDetailPage />);
|
|
41
|
+
expect(screen.getByRole('tablist', { name: 'Resource sections' })).toBeVisible();
|
|
42
|
+
for (const name of ['Overview', 'Activity', 'Settings']) expect(screen.getByRole('tab', { name })).toBeVisible();
|
|
43
|
+
const labels: ResourceDetailLabels = {
|
|
44
|
+
overview: 'Overview override',
|
|
45
|
+
activity: 'Activity override',
|
|
46
|
+
settings: 'Settings override',
|
|
47
|
+
sections: 'Sections override',
|
|
48
|
+
};
|
|
49
|
+
const view = (overrides: Partial<ResourceDetailLabels>, readOnly = false) => (
|
|
50
|
+
<I18nProvider locale="es">
|
|
51
|
+
<ResourceDetailPage labels={overrides} readOnly={readOnly} />
|
|
52
|
+
</I18nProvider>
|
|
53
|
+
);
|
|
54
|
+
rerender(view(labels));
|
|
55
|
+
expect(screen.getByRole('tablist', { name: labels.sections })).toBeVisible();
|
|
56
|
+
for (const name of [labels.overview, labels.activity, labels.settings])
|
|
57
|
+
expect(screen.getByRole('tab', { name })).toBeVisible();
|
|
58
|
+
rerender(view({ overview: 'Only overview' }));
|
|
59
|
+
expect(screen.getByRole('tablist', { name: es['resourceDetail.sections'] })).toBeVisible();
|
|
60
|
+
expect(screen.getByRole('tab', { name: 'Only overview' })).toBeVisible();
|
|
61
|
+
expect(screen.getByRole('tab', { name: es['resourceDetail.activity'] })).toBeVisible();
|
|
62
|
+
expect(screen.getByRole('tab', { name: es['resourceDetail.settings'] })).toBeVisible();
|
|
63
|
+
rerender(view({}, true));
|
|
64
|
+
expect(screen.getAllByRole('tab')).toHaveLength(1);
|
|
65
|
+
expect(screen.getByRole('tab', { name: es['resourceDetail.overview'] })).toHaveAttribute('aria-selected', 'true');
|
|
66
|
+
expect(screen.getByRole('tablist', { name: es['resourceDetail.sections'] })).toBeVisible();
|
|
67
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { render, screen, within } from '@testing-library/react';
|
|
2
|
+
import ResourceDetailPage from './Page';
|
|
3
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
4
|
+
|
|
5
|
+
it('renders only page content and the default breadcrumb navigation without a shell', () => {
|
|
6
|
+
render(<ResourceDetailPage shell={null} />);
|
|
7
|
+
for (const role of ['complementary', 'banner', 'main']) {
|
|
8
|
+
expect(screen.queryByRole(role)).not.toBeInTheDocument();
|
|
9
|
+
}
|
|
10
|
+
expect(screen.getAllByRole('navigation')).toHaveLength(1);
|
|
11
|
+
expect(screen.getByRole('heading', { level: 1 })).toBeVisible();
|
|
12
|
+
expect(screen.getByRole('tablist', { name: 'Resource sections' })).toBeVisible();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('shows explicit breadcrumbs and omits navigation for an empty trail', () => {
|
|
16
|
+
const { rerender } = render(
|
|
17
|
+
<ResourceDetailPage shell={null} breadcrumbs={[{ label: 'Ops', href: '/ops' }, { label: 'Op 1' }]} />,
|
|
18
|
+
);
|
|
19
|
+
const nav = screen.getByRole('navigation');
|
|
20
|
+
expect(within(nav).getByRole('link', { name: 'Ops' })).toHaveAttribute('href', '/ops');
|
|
21
|
+
expect(within(nav).getByText('Op 1')).toBeVisible();
|
|
22
|
+
rerender(<ResourceDetailPage shell={null} breadcrumbs={[]} />);
|
|
23
|
+
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
|
24
|
+
rerender(<ResourceDetailPage shell={null} readOnly name="Resource record" />);
|
|
25
|
+
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('fits accessibly inside the host main landmark', { timeout: 15000 }, async () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<main>
|
|
31
|
+
<ResourceDetailPage shell={null} />
|
|
32
|
+
</main>,
|
|
33
|
+
);
|
|
34
|
+
expect(screen.getAllByRole('main')).toHaveLength(1);
|
|
35
|
+
await expectNoAxeViolations(container);
|
|
36
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { expectTypeOf } from 'vitest';
|
|
4
|
+
import type { ComponentProps } from 'react';
|
|
5
|
+
import { expectNoAxeViolations } from '../test/a11y';
|
|
6
|
+
import ResourceDetailPage, { type ResourceDetailTab } from './Page';
|
|
7
|
+
|
|
8
|
+
const tabs = [
|
|
9
|
+
{ value: 'A', label: 'First section', content: <p>Content A</p> },
|
|
10
|
+
{ value: 'B', label: 'Second section', content: <p>Content B</p> },
|
|
11
|
+
{ value: 'C', label: 'Third section', content: <p>Content C</p> },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
it('replaces built-in tabs with caller labels, order and content', async () => {
|
|
15
|
+
const user = userEvent.setup();
|
|
16
|
+
render(<ResourceDetailPage tabs={tabs} />);
|
|
17
|
+
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
|
|
18
|
+
'First section',
|
|
19
|
+
'Second section',
|
|
20
|
+
'Third section',
|
|
21
|
+
]);
|
|
22
|
+
for (const name of ['Overview', 'Activity', 'Settings']) {
|
|
23
|
+
expect(screen.queryByRole('tab', { name })).not.toBeInTheDocument();
|
|
24
|
+
}
|
|
25
|
+
expect(screen.getByText('Content A')).toBeVisible();
|
|
26
|
+
await user.click(screen.getByRole('tab', { name: 'Second section' }));
|
|
27
|
+
await waitFor(() => expect(screen.getAllByRole('tabpanel')).toHaveLength(1));
|
|
28
|
+
expect(within(screen.getByRole('tabpanel')).getByText('Content B')).toBeVisible();
|
|
29
|
+
expect(screen.queryByText('Content A')).not.toBeInTheDocument();
|
|
30
|
+
expect(screen.queryByText('Content C')).not.toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('uses caller defaults, panel associations and manual keyboard activation', async () => {
|
|
34
|
+
const user = userEvent.setup();
|
|
35
|
+
const onTabChange = vi.fn();
|
|
36
|
+
render(<ResourceDetailPage tabs={tabs} defaultTab="B" onTabChange={onTabChange} />);
|
|
37
|
+
const first = screen.getByRole('tab', { name: 'First section' });
|
|
38
|
+
const second = screen.getByRole('tab', { name: 'Second section' });
|
|
39
|
+
const third = screen.getByRole('tab', { name: 'Third section' });
|
|
40
|
+
expect(second).toHaveAttribute('aria-selected', 'true');
|
|
41
|
+
act(() => second.focus());
|
|
42
|
+
await user.keyboard('{ArrowRight}');
|
|
43
|
+
expect(third).toHaveFocus();
|
|
44
|
+
expect(second).toHaveAttribute('aria-selected', 'true');
|
|
45
|
+
expect(onTabChange).not.toHaveBeenCalled();
|
|
46
|
+
await user.keyboard('{Enter}');
|
|
47
|
+
expect(third).toHaveAttribute('aria-selected', 'true');
|
|
48
|
+
expect(onTabChange).toHaveBeenCalledWith('C');
|
|
49
|
+
await user.keyboard('{Home}');
|
|
50
|
+
expect(first).toHaveFocus();
|
|
51
|
+
await user.keyboard('{End}');
|
|
52
|
+
expect(third).toHaveFocus();
|
|
53
|
+
for (const tab of [first, second, third]) {
|
|
54
|
+
await user.click(tab);
|
|
55
|
+
const panel = await screen.findByRole('tabpanel');
|
|
56
|
+
expect(tab).toHaveAttribute('aria-controls', panel.id);
|
|
57
|
+
expect(panel).toHaveAttribute('aria-labelledby', tab.id);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('falls back to the first caller tab for an unknown default', () => {
|
|
62
|
+
render(<ResourceDetailPage tabs={tabs} defaultTab="unknown" />);
|
|
63
|
+
expect(screen.getByRole('tab', { name: 'First section' })).toHaveAttribute('aria-selected', 'true');
|
|
64
|
+
expect(screen.getByText('Content A')).toBeVisible();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('renders only the heading and header when caller tabs are empty', () => {
|
|
68
|
+
render(<ResourceDetailPage tabs={[]} />);
|
|
69
|
+
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
|
|
70
|
+
expect(screen.queryByRole('tabpanel')).not.toBeInTheDocument();
|
|
71
|
+
expect(screen.getByRole('heading', { level: 1 })).toBeVisible();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('selects the first caller tab when no default is supplied, including built-in value names', () => {
|
|
75
|
+
render(<ResourceDetailPage tabs={[tabs[0]!, { ...tabs[1]!, value: 'overview' }]} />);
|
|
76
|
+
expect(screen.getByRole('tab', { name: 'First section' })).toHaveAttribute('aria-selected', 'true');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not filter custom tabs or their selection in read-only mode', async () => {
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
render(<ResourceDetailPage readOnly name="Resource record" tabs={tabs.slice(0, 2)} />);
|
|
82
|
+
expect(screen.getAllByRole('tab')).toHaveLength(2);
|
|
83
|
+
await user.click(screen.getByRole('tab', { name: 'Second section' }));
|
|
84
|
+
expect(screen.getByText('Content B')).toBeVisible();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it.each([undefined, null])('keeps custom tabs accessible with shell=%s', { timeout: 15000 }, async (shell) => {
|
|
88
|
+
const { container } = render(
|
|
89
|
+
<ResourceDetailPage shell={shell} tabs={tabs} labels={{ sections: 'Record sections' }} />,
|
|
90
|
+
);
|
|
91
|
+
expect(screen.getByRole('tablist', { name: 'Record sections' })).toBeVisible();
|
|
92
|
+
await expectNoAxeViolations(container);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('keeps built-in callback values narrow and custom callback values as strings', () => {
|
|
96
|
+
type Props = ComponentProps<typeof ResourceDetailPage>;
|
|
97
|
+
type BuiltIn = Extract<Props, { tabs?: undefined }>;
|
|
98
|
+
type Custom = Extract<Props, { tabs: unknown[] }>;
|
|
99
|
+
expectTypeOf<BuiltIn['defaultTab']>().toEqualTypeOf<ResourceDetailTab | undefined>();
|
|
100
|
+
expectTypeOf<NonNullable<BuiltIn['onTabChange']>>().parameter(0).toEqualTypeOf<ResourceDetailTab>();
|
|
101
|
+
expectTypeOf<Custom['defaultTab']>().toEqualTypeOf<string | undefined>();
|
|
102
|
+
expectTypeOf<NonNullable<Custom['onTabChange']>>().parameter(0).toEqualTypeOf<string>();
|
|
103
|
+
expectTypeOf<{ defaultTab: 'B' }>().not.toExtend<Props>();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('smoke-loads the embedded custom tabs preview inside one accessible main', { timeout: 15000 }, async () => {
|
|
107
|
+
const { default: EmbeddedCustomTabs } = await import('./examples/embedded-custom-tabs');
|
|
108
|
+
const { container } = render(<EmbeddedCustomTabs />);
|
|
109
|
+
expect(screen.getAllByRole('main')).toHaveLength(1);
|
|
110
|
+
expect(screen.queryByRole('banner')).not.toBeInTheDocument();
|
|
111
|
+
expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
|
|
112
|
+
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['Summary', 'Output']);
|
|
113
|
+
await userEvent.click(screen.getByRole('tab', { name: 'Output' }));
|
|
114
|
+
expect(screen.getByText('The resource completed successfully.')).toBeVisible();
|
|
115
|
+
await expectNoAxeViolations(container);
|
|
116
|
+
});
|
|
@@ -28,10 +28,12 @@ import {
|
|
|
28
28
|
TabsPanel,
|
|
29
29
|
TabsTab,
|
|
30
30
|
Textarea,
|
|
31
|
+
useI18n,
|
|
31
32
|
type BreadcrumbItem,
|
|
32
33
|
} from '@gntik-ai/ui';
|
|
33
34
|
import { useState } from 'react';
|
|
34
35
|
import { ConsoleShell, type ConsoleShellProps } from '../shared/ConsoleShell';
|
|
36
|
+
import { CustomTabs, type CustomTabsProps } from './CustomTabs';
|
|
35
37
|
import {
|
|
36
38
|
detailActivity,
|
|
37
39
|
detailBreadcrumbs,
|
|
@@ -45,9 +47,18 @@ import {
|
|
|
45
47
|
|
|
46
48
|
export type ResourceDetailTab = 'overview' | 'activity' | 'settings';
|
|
47
49
|
|
|
48
|
-
export interface
|
|
50
|
+
export interface ResourceDetailLabels {
|
|
51
|
+
overview: string;
|
|
52
|
+
activity: string;
|
|
53
|
+
settings: string;
|
|
54
|
+
sections: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ResourceDetailBaseProps {
|
|
58
|
+
/** Per-key copy overrides; explicit props take precedence, then labels, then the active catalog. */
|
|
59
|
+
labels?: Partial<ResourceDetailLabels>;
|
|
49
60
|
/**
|
|
50
|
-
* Optional boolean, default false.
|
|
61
|
+
* Optional boolean, default false. With built-in tabs, shows only the keyboard-accessible Overview tab in
|
|
51
62
|
* the "Resource sections" tablist, using explicit resource props without sample defaults.
|
|
52
63
|
* Ignores activity, events, dangerActions, defaultTab, onSaveSettings and onDangerAction.
|
|
53
64
|
* Explicit actions and onEditDetails are still honored. ConsoleShell defaults are unchanged.
|
|
@@ -76,23 +87,39 @@ export interface ResourceDetailProps {
|
|
|
76
87
|
events: readonly StatusEvent[];
|
|
77
88
|
activity: readonly ActivityItem[];
|
|
78
89
|
dangerActions: DangerZoneAction[];
|
|
79
|
-
defaultTab: ResourceDetailTab;
|
|
80
|
-
onTabChange: (tab: ResourceDetailTab) => void;
|
|
81
90
|
onEditDetails: () => void;
|
|
82
91
|
onSaveSettings: (settings: ResourceSettings) => void;
|
|
83
92
|
/** Confirmed Danger zone action id (e.g. `pause`, `delete`). */
|
|
84
93
|
onDangerAction: (actionId: string) => void | Promise<void>;
|
|
85
|
-
|
|
94
|
+
/** null omits ConsoleShell; undefined or an object preserves the default frame. */
|
|
95
|
+
shell: Omit<ConsoleShellProps, 'children'> | null;
|
|
86
96
|
}
|
|
87
97
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
{
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
// Custom tabs require an explicit list; omitted tabs retain the built-in value types.
|
|
99
|
+
type ResourceDetailTabsProps =
|
|
100
|
+
| {
|
|
101
|
+
tabs?: undefined;
|
|
102
|
+
defaultTab?: ResourceDetailTab;
|
|
103
|
+
onTabChange?: (tab: ResourceDetailTab) => void;
|
|
104
|
+
}
|
|
105
|
+
/** Replaces the built-in tabs, including in read-only mode; [] hides the tab set. */
|
|
106
|
+
| CustomTabsProps;
|
|
107
|
+
|
|
108
|
+
export type ResourceDetailProps = ResourceDetailBaseProps & ResourceDetailTabsProps;
|
|
109
|
+
|
|
110
|
+
const TABS: ResourceDetailTab[] = ['overview', 'activity', 'settings'];
|
|
93
111
|
|
|
94
|
-
/**
|
|
95
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Resource detail: editable tabs by default, or explicit-only Overview when readOnly is true.
|
|
114
|
+
* Pass tabs to replace the built-in sections with caller labels, values and content; readOnly
|
|
115
|
+
* does not filter custom tabs. An omitted or unknown defaultTab selects the first custom tab.
|
|
116
|
+
* shell={null} renders only Page and its header. The host owns the main landmark and must
|
|
117
|
+
* provide ChartLocaleProvider when its custom chart content needs a locale. Breadcrumbs
|
|
118
|
+
* render in the header in this mode: pass a trail or [] to avoid the editable sample trail.
|
|
119
|
+
* Omitted or object shells and omitted tabs preserve the existing default output.
|
|
120
|
+
*/
|
|
121
|
+
export default function ResourceDetailPage(props: Partial<ResourceDetailBaseProps> & ResourceDetailTabsProps) {
|
|
122
|
+
const { t } = useI18n();
|
|
96
123
|
const readOnly = props.readOnly ?? false;
|
|
97
124
|
const {
|
|
98
125
|
name = readOnly ? '' : detailResource.name,
|
|
@@ -109,11 +136,11 @@ export default function ResourceDetailPage(props: Partial<ResourceDetailProps>)
|
|
|
109
136
|
activity = readOnly ? [] : detailActivity,
|
|
110
137
|
dangerActions = readOnly ? [] : detailDangerActions,
|
|
111
138
|
defaultTab = 'overview',
|
|
112
|
-
onTabChange,
|
|
113
139
|
onEditDetails,
|
|
114
140
|
onSaveSettings,
|
|
115
141
|
onDangerAction,
|
|
116
142
|
shell,
|
|
143
|
+
labels,
|
|
117
144
|
} = props;
|
|
118
145
|
const meta = metaProp ?? (readOnly ? [] : detailMeta);
|
|
119
146
|
const actions: PageChromeAction[] =
|
|
@@ -126,37 +153,48 @@ export default function ResourceDetailPage(props: Partial<ResourceDetailProps>)
|
|
|
126
153
|
]);
|
|
127
154
|
const details = detailsProp ?? (readOnly ? [] : detailItems);
|
|
128
155
|
const historyEvents = readOnly ? props.history : events;
|
|
129
|
-
const [tab, setTab] = useState
|
|
156
|
+
const [tab, setTab] = useState(defaultTab);
|
|
130
157
|
const [settings, setSettings] = useState<ResourceSettings>({ name, description });
|
|
131
158
|
const [saved, setSaved] = useState<ResourceSettings>({ name, description });
|
|
132
159
|
const dirty = settings.name !== saved.name || settings.description !== saved.description;
|
|
133
|
-
const tabs = readOnly ? TABS.filter((
|
|
160
|
+
const tabs = (readOnly ? TABS.filter((value) => value === 'overview') : TABS).map((value) => ({
|
|
161
|
+
value,
|
|
162
|
+
label: labels?.[value] ?? t(`resourceDetail.${value}`),
|
|
163
|
+
}));
|
|
134
164
|
|
|
135
|
-
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
165
|
+
const content = (
|
|
166
|
+
<Page
|
|
167
|
+
width="wide"
|
|
168
|
+
header={
|
|
169
|
+
<PageHeader
|
|
170
|
+
as={shell === null ? 'div' : undefined}
|
|
171
|
+
breadcrumbs={shell === null ? breadcrumbs : null}
|
|
172
|
+
title={name}
|
|
173
|
+
description={description}
|
|
174
|
+
status={status}
|
|
175
|
+
meta={meta}
|
|
176
|
+
actions={actions}
|
|
177
|
+
tabs={null}
|
|
178
|
+
/>
|
|
179
|
+
}
|
|
180
|
+
>
|
|
181
|
+
{props.tabs !== undefined ? (
|
|
182
|
+
<CustomTabs
|
|
183
|
+
tabs={props.tabs}
|
|
184
|
+
defaultTab={props.defaultTab}
|
|
185
|
+
onTabChange={props.onTabChange}
|
|
186
|
+
label={labels?.sections ?? t('resourceDetail.sections')}
|
|
187
|
+
/>
|
|
188
|
+
) : (
|
|
151
189
|
<Tabs
|
|
152
190
|
value={readOnly ? 'overview' : tab}
|
|
153
191
|
onValueChange={(v) => {
|
|
154
192
|
const next = tabs.find((t) => t.value === v)?.value ?? 'overview';
|
|
155
193
|
setTab(next);
|
|
156
|
-
onTabChange?.(next);
|
|
194
|
+
if (props.tabs === undefined) props.onTabChange?.(next);
|
|
157
195
|
}}
|
|
158
196
|
>
|
|
159
|
-
<TabsList aria-label=
|
|
197
|
+
<TabsList aria-label={labels?.sections ?? t('resourceDetail.sections')}>
|
|
160
198
|
{tabs.map((t) => (
|
|
161
199
|
<TabsTab key={t.value} value={t.value}>
|
|
162
200
|
{t.label}
|
|
@@ -256,7 +294,15 @@ export default function ResourceDetailPage(props: Partial<ResourceDetailProps>)
|
|
|
256
294
|
</TabsPanel>
|
|
257
295
|
)}
|
|
258
296
|
</Tabs>
|
|
259
|
-
|
|
297
|
+
)}
|
|
298
|
+
</Page>
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return shell === null ? (
|
|
302
|
+
content
|
|
303
|
+
) : (
|
|
304
|
+
<ConsoleShell currentHref="/deployments" breadcrumbs={breadcrumbs} {...shell}>
|
|
305
|
+
{content}
|
|
260
306
|
</ConsoleShell>
|
|
261
307
|
);
|
|
262
308
|
}
|