@gntik-ai/templates 0.8.0 → 0.10.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.
Files changed (36) hide show
  1. package/dist/index.d.ts +13 -5
  2. package/dist/index.js +136 -103
  3. package/dist/index.js.map +1 -1
  4. package/package.json +5 -5
  5. package/src/audit-log/Page.test.tsx +53 -0
  6. package/src/audit-log/Page.tsx +13 -5
  7. package/src/audit-log/template.meta.ts +1 -1
  8. package/src/help-home/Page.tsx +3 -1
  9. package/src/help-home/data.ts +3 -4
  10. package/src/incident-board/Page.tsx +6 -1
  11. package/src/incident-board/data.ts +9 -3
  12. package/src/index.ts +1 -1
  13. package/src/keyboard-shortcuts/Page.test.tsx +39 -1
  14. package/src/keyboard-shortcuts/ShortcutSheet.tsx +4 -1
  15. package/src/keyboard-shortcuts/data.ts +29 -14
  16. package/src/live-event-stream/Page.test.tsx +8 -4
  17. package/src/resource-detail/Page.labels.test.tsx +67 -0
  18. package/src/resource-detail/Page.tsx +18 -7
  19. package/src/resource-detail/examples/Labels.tsx +18 -0
  20. package/src/resource-detail/examples/Spanish.tsx +11 -0
  21. package/src/resource-detail/template.meta.ts +1 -1
  22. package/src/resource-gallery/Page.tsx +3 -1
  23. package/src/resource-gallery/data.ts +3 -4
  24. package/src/resource-index/Page.tsx +3 -1
  25. package/src/resource-index/data.ts +7 -4
  26. package/src/search-results/Page.tsx +5 -5
  27. package/src/settings-profile/fixtures/editable.snap +1 -1
  28. package/src/shared/ConsoleShell.test.tsx +13 -0
  29. package/src/shared/ConsoleShell.tsx +35 -30
  30. package/src/test/match-text.test.ts +51 -0
  31. package/src/usage-analytics/Page.tsx +3 -1
  32. package/src/usage-analytics/data.ts +9 -3
  33. package/src/workflow-builder/Page.test.tsx +103 -2
  34. package/src/workflow-builder/Page.tsx +34 -8
  35. package/src/workflow-builder/examples/SaveGraph.tsx +17 -0
  36. 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.8.0",
3
+ "version": "0.10.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.8.0",
39
- "@gntik-ai/charts": "0.5.0",
40
- "@gntik-ai/chat": "0.7.0",
38
+ "@gntik-ai/blocks": "0.10.0",
39
+ "@gntik-ai/charts": "0.10.0",
40
+ "@gntik-ai/chat": "0.10.0",
41
41
  "@gntik-ai/editor": "0.5.0",
42
42
  "@gntik-ai/flow": "0.5.0",
43
43
  "@gntik-ai/icons": "0.5.0",
44
44
  "@gntik-ai/tokens": "0.2.1",
45
- "@gntik-ai/ui": "0.7.0",
45
+ "@gntik-ai/ui": "0.10.0",
46
46
  "lucide-react": "^1.48.0"
47
47
  },
48
48
  "devDependencies": {
@@ -6,6 +6,59 @@ import { auditEvents } from './data';
6
6
 
7
7
  const ALL = { start: null, end: null };
8
8
 
9
+ describe('auditEventsToCsv', () => {
10
+ it('neutralises an actor formula before applying CSV quoting', () => {
11
+ const event = {
12
+ ...auditEvents[0]!,
13
+ actor: { name: '=HYPERLINK("https://example.test","x")', email: 'ana@example.test' },
14
+ };
15
+
16
+ expect(auditEventsToCsv([event]).split('\n')[1]).toContain(
17
+ `,"'=HYPERLINK(""https://example.test"",""x"")",ana@example.test,`,
18
+ );
19
+ });
20
+
21
+ it.each([
22
+ ['equals', '=1+1', "'=1+1"],
23
+ ['plus', '+1', "'+1"],
24
+ ['minus', '-1', "'-1"],
25
+ ['at sign', '@SUM(A1)', "'@SUM(A1)"],
26
+ ['tab', '\tprojects/a', "'\tprojects/a"],
27
+ ['carriage return', '\rprojects/a', "'\rprojects/a"],
28
+ ])('neutralises a target starting with %s', (_label, target, expected) => {
29
+ const event = { ...auditEvents[0]!, target };
30
+
31
+ expect(auditEventsToCsv([event]).split('\n')[1]?.split(',')[5]).toBe(expected);
32
+ });
33
+
34
+ it.each([
35
+ ['plain', 'projects/a', 'projects/a'],
36
+ ['comma', 'projects/a,b', '"projects/a,b"'],
37
+ ['quote', 'projects/"a"', '"projects/""a"""'],
38
+ ['newline', 'projects/a\nb', '"projects/a\nb"'],
39
+ ['embedded equals', 'a=b', 'a=b'],
40
+ ['embedded minus', 'x-1', 'x-1'],
41
+ ['leading space', ' =1+1', ' =1+1'],
42
+ ['empty', '', ''],
43
+ ])('preserves safe cells with %s and the CSV format', (_label, target, expected) => {
44
+ const event = {
45
+ ...auditEvents[0]!,
46
+ at: new Date('2026-01-02T03:04:05.000Z'),
47
+ actor: { name: 'Ana López', email: 'ana@example.test' },
48
+ action: 'project.update',
49
+ resource: 'Project',
50
+ target,
51
+ ip: '10.0.0.4',
52
+ result: 'succeeded' as const,
53
+ };
54
+
55
+ expect(auditEventsToCsv([event])).toBe(
56
+ 'time,actor,email,action,resource,target,ip,result\n' +
57
+ `2026-01-02T03:04:05.000Z,Ana López,ana@example.test,project.update,Project,${expected},10.0.0.4,succeeded`,
58
+ );
59
+ });
60
+ });
61
+
9
62
  describe('AuditLogPage', () => {
10
63
  it('renders filters, date range and the events table', { timeout: 15000 }, async () => {
11
64
  render(<AuditLogPage />);
@@ -1,3 +1,5 @@
1
+ import { useI18n } from '@gntik-ai/ui';
2
+ import { matchText } from '@gntik-ai/ui';
1
3
  import {
2
4
  DataTable,
3
5
  DateRangeFilter,
@@ -60,13 +62,14 @@ export function filterAuditEvents(
60
62
  query: string,
61
63
  filters: readonly ActiveFilter[],
62
64
  range: DateRange,
65
+ locale?: string,
63
66
  ) {
64
- const q = query.trim().toLowerCase();
67
+ const q = query.trim();
65
68
  const fields = [...new Set(filters.map((f) => f.field))];
66
69
  const from = range.start ? startOfDay(range.start).getTime() : -Infinity;
67
70
  const to = range.end ? startOfDay(range.end).getTime() + DAY : Infinity;
68
71
  return events.filter((e) => {
69
- if (q && ![e.actor.name, e.actor.email, e.action, e.target].some((v) => v.toLowerCase().includes(q))) return false;
72
+ if (q && ![e.actor.name, e.actor.email, e.action, e.target].some((v) => matchText(v, q, { locale }))) return false;
70
73
  if (!fields.every((field) => filters.some((f) => f.field === field && f.value === fieldValue(e, field))))
71
74
  return false;
72
75
  const t = e.at.getTime();
@@ -74,7 +77,11 @@ export function filterAuditEvents(
74
77
  });
75
78
  }
76
79
 
77
- const csvCell = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
80
+ /** Neutralises leading spreadsheet formula triggers before applying CSV quoting. */
81
+ export function escapeCsvCell(value: string): string {
82
+ const safe = /^[=+@\t\r-]/.test(value) ? `'${value}` : value;
83
+ return /[",\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
84
+ }
78
85
 
79
86
  /** CSV of the events (time, actor, action, resource, target, ip, result). */
80
87
  export function auditEventsToCsv(events: readonly AuditEvent[]) {
@@ -89,7 +96,7 @@ export function auditEventsToCsv(events: readonly AuditEvent[]) {
89
96
  e.ip,
90
97
  e.result,
91
98
  ]);
92
- return [head, ...rows].map((r) => r.map(csvCell).join(',')).join('\n');
99
+ return [head, ...rows].map((r) => r.map(escapeCsvCell).join(',')).join('\n');
93
100
  }
94
101
 
95
102
  function downloadCsv(events: AuditEvent[]) {
@@ -104,6 +111,7 @@ function downloadCsv(events: AuditEvent[]) {
104
111
 
105
112
  /** Audit log: ConsoleShell + Page with FilterBar + DateRangeFilter, an events DataTable and a diff drawer. */
106
113
  export default function AuditLogPage(props: Partial<AuditLogProps>) {
114
+ const { locale } = useI18n();
107
115
  const {
108
116
  events = auditEvents,
109
117
  filterFields = auditFilterFields,
@@ -125,7 +133,7 @@ export default function AuditLogPage(props: Partial<AuditLogProps>) {
125
133
  });
126
134
  const [diffEvent, setDiffEvent] = useState<AuditEvent | null>(null);
127
135
 
128
- const rows = filterAuditEvents(events, query, filters, range);
136
+ const rows = filterAuditEvents(events, query, filters, range, locale);
129
137
 
130
138
  const columns: DataTableColumn<AuditEvent>[] = [
131
139
  {
@@ -6,7 +6,7 @@ export const meta: TemplateMeta = {
6
6
  priority: 'P2',
7
7
  status: 'beta',
8
8
  description:
9
- 'Workspace audit trail: FilterBar (search, actor / resource / result filters) and DateRangeFilter over a DataTable of events with actor, action, target and result; a drawer shows the change diff (CodeBlock); export to CSV.',
9
+ 'Workspace audit trail: FilterBar (search, actor / resource / result filters) and DateRangeFilter over a DataTable of events with actor, action, target and result; a drawer shows the change diff (CodeBlock); export to CSV. CSV export neutralises leading spreadsheet formula triggers.',
10
10
  layout: 'SidebarLayout · Page',
11
11
  blocks: ['app-sidebar', 'app-topbar', 'page-header', 'filter-bar', 'date-range-filter', 'data-table', 'empty-states'],
12
12
  };
@@ -1,3 +1,4 @@
1
+ import { useI18n } from '@gntik-ai/ui';
1
2
  import { Clock, Search } from '@gntik-ai/icons';
2
3
  import {
3
4
  Badge,
@@ -36,6 +37,7 @@ export interface HelpHomeProps {
36
37
 
37
38
  /** Help center home on DocsLayout: search, category cards and popular articles. */
38
39
  export default function HelpHomePage(props: Partial<HelpHomeProps>) {
40
+ const { locale } = useI18n();
39
41
  const {
40
42
  brand = <Logo size={22} wordmark />,
41
43
  title = 'How can we help?',
@@ -53,7 +55,7 @@ export default function HelpHomePage(props: Partial<HelpHomeProps>) {
53
55
  } = props;
54
56
  const [query, setQuery] = useState('');
55
57
  const ids = useId();
56
- const results = searchArticles(articles, query);
58
+ const results = searchArticles(articles, query, locale);
57
59
  const searching = query.trim() !== '';
58
60
  const categoryTitle = (id: string) => categories.find((c) => c.id === id)?.title ?? id;
59
61
 
@@ -1,3 +1,4 @@
1
+ import { matchText } from '@gntik-ai/ui';
1
2
  import { BookOpen, CreditCard, LifeBuoy, Plug, Rocket, Shield, type LucideIcon } from '@gntik-ai/icons';
2
3
  import type { NavGroup } from '@gntik-ai/ui';
3
4
 
@@ -144,8 +145,6 @@ export const helpArticles: HelpArticle[] = [
144
145
  ];
145
146
 
146
147
  /** Articles whose title or excerpt contains the query. */
147
- export function searchArticles(articles: readonly HelpArticle[], query: string): HelpArticle[] {
148
- const q = query.trim().toLowerCase();
149
- if (!q) return [...articles];
150
- return articles.filter((a) => a.title.toLowerCase().includes(q) || a.excerpt.toLowerCase().includes(q));
148
+ export function searchArticles(articles: readonly HelpArticle[], query: string, locale?: string): HelpArticle[] {
149
+ return articles.filter((a) => matchText(a.title, query, { locale }) || matchText(a.excerpt, query, { locale }));
151
150
  }
@@ -1,3 +1,4 @@
1
+ import { useI18n } from '@gntik-ai/ui';
1
2
  import { FilterBar, KpiRow, PageHeader, type ActiveFilter, type FilterField } from '@gntik-ai/blocks';
2
3
  import { Download, Plus } from '@gntik-ai/icons';
3
4
  import {
@@ -95,13 +96,17 @@ function IncidentBoard({
95
96
  onDeclare,
96
97
  onExport,
97
98
  }: BoardProps) {
99
+ const { locale } = useI18n();
98
100
  const announce = useAnnounce();
99
101
  const [columns, setColumns] = useState(defaultColumns);
100
102
  const [query, setQuery] = useState('');
101
103
  const [filters, setFilters] = useState<ActiveFilter[]>(defaultFilters);
102
104
  const [openId, setOpenId] = useState<string | null>(defaultOpenId);
103
105
 
104
- const visible = columns.map((c) => ({ ...c, cards: c.cards.filter((i) => matchesTriage(i, query, filters)) }));
106
+ const visible = columns.map((c) => ({
107
+ ...c,
108
+ cards: c.cards.filter((i) => matchesTriage(i, query, filters, locale)),
109
+ }));
105
110
  const shown = visible.reduce((n, c) => n + c.cards.length, 0);
106
111
  const openColumn = columns.find((c) => c.cards.some((i) => i.id === openId));
107
112
  const open = openColumn?.cards.find((i) => i.id === openId);
@@ -1,3 +1,4 @@
1
+ import { matchText } from '@gntik-ai/ui';
1
2
  import type { ActiveFilter, FilterField, KpiItem } from '@gntik-ai/blocks';
2
3
  import {
3
4
  moveKanbanCard,
@@ -169,9 +170,14 @@ export function triageFieldValue(i: TriageIncident, field: string): string | und
169
170
  }
170
171
 
171
172
  /** True when the incident matches the search text and every filtered field (OR within a field). */
172
- export function matchesTriage(i: TriageIncident, query: string, filters: readonly ActiveFilter[]): boolean {
173
- const q = query.trim().toLowerCase();
174
- if (q && ![i.id, i.title, i.service, i.owner].some((v) => v.toLowerCase().includes(q))) return false;
173
+ export function matchesTriage(
174
+ i: TriageIncident,
175
+ query: string,
176
+ filters: readonly ActiveFilter[],
177
+ locale?: string,
178
+ ): boolean {
179
+ const q = query.trim();
180
+ if (q && ![i.id, i.title, i.service, i.owner].some((v) => matchText(v, q, { locale }))) return false;
175
181
  const byField = new Map<string, string[]>();
176
182
  for (const f of filters) byField.set(f.field, [...(byField.get(f.field) ?? []), f.value]);
177
183
  return [...byField].every(([field, values]) => values.includes(triageFieldValue(i, field) ?? ''));
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,3 +1,4 @@
1
+ import { useI18n, useOs } from '@gntik-ai/ui';
1
2
  import { Keyboard, Search } from '@gntik-ai/icons';
2
3
  import { EmptyState, Grid, Heading, Input, KbdCombo, Stack, Text } from '@gntik-ai/ui';
3
4
  import { useId, useState } from 'react';
@@ -14,9 +15,11 @@ export interface ShortcutSheetProps {
14
15
 
15
16
  /** Searchable shortcut sheet shared by the page and the dialog. */
16
17
  export function ShortcutSheet({ groups, headingLevel = 2, columns = 2, autoFocus }: ShortcutSheetProps) {
18
+ const { locale } = useI18n();
19
+ const os = useOs();
17
20
  const [query, setQuery] = useState('');
18
21
  const countId = useId();
19
- const visible = filterShortcuts(groups, query);
22
+ const visible = filterShortcuts(groups, query, locale, os);
20
23
  const total = visible.reduce((n, g) => n + g.shortcuts.length, 0);
21
24
  return (
22
25
  <Stack gap={5}>
@@ -1,10 +1,11 @@
1
- import type { BreadcrumbItem } from '@gntik-ai/ui';
1
+ import { matchText } from '@gntik-ai/ui';
2
+ import type { BreadcrumbItem, Os } from '@gntik-ai/ui';
2
3
 
3
4
  export interface Shortcut {
4
5
  id: string;
5
6
  /** What the shortcut does. */
6
7
  description: string;
7
- /** Keys pressed together, e.g. ['⌘', 'K']. */
8
+ /** Keys pressed together, e.g. ['mod', 'K']. */
8
9
  keys: string[];
9
10
  /** A second combination that does the same (e.g. a sequence "G then P"). */
10
11
  then?: string[];
@@ -27,10 +28,10 @@ export const shortcutGroups: ShortcutGroup[] = [
27
28
  id: 'general',
28
29
  title: 'General',
29
30
  shortcuts: [
30
- { id: 'palette', description: 'Open the command palette', keys: ['⌘', 'K'] },
31
+ { id: 'palette', description: 'Open the command palette', keys: ['mod', 'K'] },
31
32
  { id: 'help', description: 'Show keyboard shortcuts', keys: ['?'] },
32
- { id: 'sidebar', description: 'Collapse or expand the sidebar', keys: ['⌘', 'B'] },
33
- { id: 'theme', description: 'Switch theme', keys: ['⌘', '⇧', 'L'] },
33
+ { id: 'sidebar', description: 'Collapse or expand the sidebar', keys: ['mod', 'B'] },
34
+ { id: 'theme', description: 'Switch theme', keys: ['mod', '⇧', 'L'] },
34
35
  ],
35
36
  },
36
37
  {
@@ -58,26 +59,40 @@ export const shortcutGroups: ShortcutGroup[] = [
58
59
  id: 'editing',
59
60
  title: 'Editing',
60
61
  shortcuts: [
61
- { id: 'save', description: 'Save changes', keys: ['⌘', 'S'] },
62
- { 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', '↵'] },
63
64
  { id: 'cancel', description: 'Cancel and close', keys: ['Esc'] },
64
- { id: 'undo', description: 'Undo', keys: ['⌘', 'Z'] },
65
+ { id: 'undo', description: 'Undo', keys: ['mod', 'Z'] },
65
66
  ],
66
67
  },
67
68
  ];
68
69
 
69
- /** Groups with only the shortcuts whose description or keys match the query. */
70
- export function filterShortcuts(groups: readonly ShortcutGroup[], query: string): ShortcutGroup[] {
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[] {
71
77
  const q = query.trim().toLowerCase();
72
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
+ };
73
91
  return groups
74
92
  .map((g) => ({
75
93
  ...g,
76
94
  shortcuts: g.shortcuts.filter(
77
- (s) =>
78
- s.description.toLowerCase().includes(q) ||
79
- g.title.toLowerCase().includes(q) ||
80
- [...s.keys, ...(s.then ?? [])].join(' ').toLowerCase() === q,
95
+ (s) => matchText(s.description, query, { locale }) || matchText(g.title, query, { locale }) || matchesKeys(s),
81
96
  ),
82
97
  }))
83
98
  .filter((g) => g.shortcuts.length > 0);
@@ -1,4 +1,4 @@
1
- import { render, screen, within } from '@testing-library/react';
1
+ import { render, screen, within, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { ThemeProvider } from '@gntik-ai/ui';
4
4
  import { expectNoAxeViolations } from '../test/a11y';
@@ -115,7 +115,9 @@ describe('LiveEventStreamPage', () => {
115
115
  const onSelectEvent = vi.fn();
116
116
  render(<LiveEventStreamPage status="live" events={events.slice(0, 2)} onSelectEvent={onSelectEvent} />);
117
117
  await user.click(screen.getByText('First record'));
118
- expect(screen.getByRole('button', { name: 'Inspect event 12:00:00' })).toHaveAttribute('aria-pressed', 'true');
118
+ await waitFor(() =>
119
+ expect(screen.getByRole('button', { name: 'Inspect event 12:00:00' })).toHaveAttribute('aria-pressed', 'true'),
120
+ );
119
121
  const panel = screen.getByRole('complementary', { name: 'Event inspector' });
120
122
  expect(within(panel).getByRole('tree', { name: 'Event payload' })).toHaveTextContent('alpha');
121
123
  expect(onSelectEvent).toHaveBeenLastCalledWith(events[0]);
@@ -123,8 +125,10 @@ describe('LiveEventStreamPage', () => {
123
125
  await user.tab();
124
126
  expect(screen.getByRole('button', { name: 'Inspect event 12:00:01' })).toHaveFocus();
125
127
  await user.keyboard('{Enter}');
126
- expect(screen.getByRole('button', { name: 'Inspect event 12:00:01' })).toHaveAttribute('aria-pressed', 'true');
127
- expect(within(panel).getByRole('tree', { name: 'Event payload' })).toHaveTextContent('beta');
128
+ await waitFor(() =>
129
+ expect(screen.getByRole('button', { name: 'Inspect event 12:00:01' })).toHaveAttribute('aria-pressed', 'true'),
130
+ );
131
+ await waitFor(() => expect(within(panel).getByRole('tree', { name: 'Event payload' })).toHaveTextContent('beta'));
128
132
  expect(onSelectEvent).toHaveBeenLastCalledWith(events[1]);
129
133
  await expectNoAxeViolations();
130
134
  });
@@ -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
+ });
@@ -28,6 +28,7 @@ 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';
@@ -45,7 +46,16 @@ import {
45
46
 
46
47
  export type ResourceDetailTab = 'overview' | 'activity' | 'settings';
47
48
 
49
+ export interface ResourceDetailLabels {
50
+ overview: string;
51
+ activity: string;
52
+ settings: string;
53
+ sections: string;
54
+ }
55
+
48
56
  export interface ResourceDetailProps {
57
+ /** Per-key copy overrides; explicit props take precedence, then labels, then the active catalog. */
58
+ labels?: Partial<ResourceDetailLabels>;
49
59
  /**
50
60
  * Optional boolean, default false. Shows only the keyboard-accessible Overview tab in
51
61
  * the "Resource sections" tablist, using explicit resource props without sample defaults.
@@ -85,14 +95,11 @@ export interface ResourceDetailProps {
85
95
  shell: Omit<ConsoleShellProps, 'children'>;
86
96
  }
87
97
 
88
- const TABS: Array<{ value: ResourceDetailTab; label: string }> = [
89
- { value: 'overview', label: 'Overview' },
90
- { value: 'activity', label: 'Activity' },
91
- { value: 'settings', label: 'Settings' },
92
- ];
98
+ const TABS: ResourceDetailTab[] = ['overview', 'activity', 'settings'];
93
99
 
94
100
  /** Resource detail: editable tabs by default, or explicit-only Overview when readOnly is true. */
95
101
  export default function ResourceDetailPage(props: Partial<ResourceDetailProps>) {
102
+ const { t } = useI18n();
96
103
  const readOnly = props.readOnly ?? false;
97
104
  const {
98
105
  name = readOnly ? '' : detailResource.name,
@@ -114,6 +121,7 @@ export default function ResourceDetailPage(props: Partial<ResourceDetailProps>)
114
121
  onSaveSettings,
115
122
  onDangerAction,
116
123
  shell,
124
+ labels,
117
125
  } = props;
118
126
  const meta = metaProp ?? (readOnly ? [] : detailMeta);
119
127
  const actions: PageChromeAction[] =
@@ -130,7 +138,10 @@ export default function ResourceDetailPage(props: Partial<ResourceDetailProps>)
130
138
  const [settings, setSettings] = useState<ResourceSettings>({ name, description });
131
139
  const [saved, setSaved] = useState<ResourceSettings>({ name, description });
132
140
  const dirty = settings.name !== saved.name || settings.description !== saved.description;
133
- const tabs = readOnly ? TABS.filter((t) => t.value === 'overview') : TABS;
141
+ const tabs = (readOnly ? TABS.filter((value) => value === 'overview') : TABS).map((value) => ({
142
+ value,
143
+ label: labels?.[value] ?? t(`resourceDetail.${value}`),
144
+ }));
134
145
 
135
146
  return (
136
147
  <ConsoleShell currentHref="/deployments" breadcrumbs={breadcrumbs} {...shell}>
@@ -156,7 +167,7 @@ export default function ResourceDetailPage(props: Partial<ResourceDetailProps>)
156
167
  onTabChange?.(next);
157
168
  }}
158
169
  >
159
- <TabsList aria-label="Resource sections">
170
+ <TabsList aria-label={labels?.sections ?? t('resourceDetail.sections')}>
160
171
  {tabs.map((t) => (
161
172
  <TabsTab key={t.value} value={t.value}>
162
173
  {t.label}
@@ -0,0 +1,18 @@
1
+ import { I18nProvider } from '@gntik-ai/ui';
2
+ import ResourceDetailPage from '../Page';
3
+
4
+ /** Each tab label and the tablist name can be overridden independently. */
5
+ export default function Labels() {
6
+ return (
7
+ <I18nProvider locale="es">
8
+ <ResourceDetailPage
9
+ labels={{
10
+ overview: 'Resource overview',
11
+ activity: 'Resource activity',
12
+ settings: 'Resource settings',
13
+ sections: 'Resource navigation',
14
+ }}
15
+ />
16
+ </I18nProvider>
17
+ );
18
+ }
@@ -0,0 +1,11 @@
1
+ import { I18nProvider } from '@gntik-ai/ui';
2
+ import ResourceDetailPage from '../Page';
3
+
4
+ /** Only tab chrome is translated; imported resource data and section content stay unchanged. */
5
+ export default function Spanish() {
6
+ return (
7
+ <I18nProvider locale="es">
8
+ <ResourceDetailPage />
9
+ </I18nProvider>
10
+ );
11
+ }
@@ -6,7 +6,7 @@ export const meta: TemplateMeta = {
6
6
  priority: 'P1',
7
7
  status: 'beta',
8
8
  description:
9
- 'Resource detail with editable Overview, Activity and Settings tabs by default; optional readOnly (boolean, default false) uses only explicit header and detail props and one Overview tab, with optional history (readonly StatusEvent[], default undefined): omitted hides the history card, [] shows the named empty state, and events render exactly as supplied without invented actors. Read-only ignores activity, events, dangerActions, defaultTab, onSaveSettings and onDangerAction; explicit actions and onEditDetails are honored. The Resource sections tablist retains Tab, arrow, Home/End and Enter/Space keyboard navigation; history is a named group containing the Status history list or empty-state region. Uses token styling in dark (default), light and high_contrast. Examples: runtime-with-transition maps state and lastTransitionAt to one actor-free event, runtime-without-transition maps null to [], and realtime-configuration and postgres-table-detail omit history. ConsoleShell retains sample frame defaults unless explicit shell props are supplied, as in the examples.',
9
+ 'Resource detail with editable Overview, Activity and Settings tabs by default; optional readOnly (boolean, default false) uses only explicit header and detail props and one Overview tab, with optional history (readonly StatusEvent[], default undefined): omitted hides the history card, [] shows the named empty state, and events render exactly as supplied without invented actors. Read-only ignores activity, events, dangerActions, defaultTab, onSaveSettings and onDangerAction; explicit actions and onEditDetails are honored. The Resource sections tablist retains Tab, arrow, Home/End and Enter/Space keyboard navigation; history is a named group containing the Status history list or empty-state region. Uses token styling in dark (default), light and high_contrast. Examples: runtime-with-transition maps state and lastTransitionAt to one actor-free event, runtime-without-transition maps null to [], and realtime-configuration and postgres-table-detail omit history. Optional labels?: Partial<ResourceDetailLabels> overrides overview, activity, settings and sections (all strings). Unset keys use I18nProvider (en/es); no provider keeps English. Only the tabs and tablist are localized by this template; fixtures and other section text are unchanged. readOnly keeps only the localized overview tab. See the Catalog labels live preview. ConsoleShell retains sample frame defaults unless explicit shell props are supplied, as in the examples.',
10
10
  layout: 'SidebarLayout + Page',
11
11
  blocks: [
12
12
  'app-sidebar',
@@ -1,3 +1,4 @@
1
+ import { useI18n } from '@gntik-ai/ui';
1
2
  import {
2
3
  DataTable,
3
4
  NoResultsEmpty,
@@ -43,10 +44,11 @@ export default function ResourceGalleryPage({
43
44
  onAction,
44
45
  shell,
45
46
  }: Partial<ResourceGalleryProps>) {
47
+ const { locale } = useI18n();
46
48
  const [items, setItems] = useState<readonly ResourceCard[]>(initialItems);
47
49
  const [view, setView] = useState<GalleryView>(defaultView);
48
50
  const [query, setQuery] = useState('');
49
- const visible = searchResources(items, query);
51
+ const visible = searchResources(items, query, locale);
50
52
 
51
53
  const handleAction = (action: ResourceAction, item: ResourceCard) => {
52
54
  if (onAction) onAction(action, item);