@gntik-ai/templates 0.7.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gntik-ai/templates",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.7.0",
38
+ "@gntik-ai/blocks": "0.9.0",
39
39
  "@gntik-ai/charts": "0.5.0",
40
- "@gntik-ai/chat": "0.7.0",
40
+ "@gntik-ai/chat": "0.9.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.9.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) ?? ''));
@@ -1,3 +1,4 @@
1
+ import { useI18n } 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,10 @@ 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();
17
19
  const [query, setQuery] = useState('');
18
20
  const countId = useId();
19
- const visible = filterShortcuts(groups, query);
21
+ const visible = filterShortcuts(groups, query, locale);
20
22
  const total = visible.reduce((n, g) => n + g.shortcuts.length, 0);
21
23
  return (
22
24
  <Stack gap={5}>
@@ -1,3 +1,4 @@
1
+ import { matchText } from '@gntik-ai/ui';
1
2
  import type { BreadcrumbItem } from '@gntik-ai/ui';
2
3
 
3
4
  export interface Shortcut {
@@ -67,7 +68,7 @@ export const shortcutGroups: ShortcutGroup[] = [
67
68
  ];
68
69
 
69
70
  /** Groups with only the shortcuts whose description or keys match the query. */
70
- export function filterShortcuts(groups: readonly ShortcutGroup[], query: string): ShortcutGroup[] {
71
+ export function filterShortcuts(groups: readonly ShortcutGroup[], query: string, locale?: string): ShortcutGroup[] {
71
72
  const q = query.trim().toLowerCase();
72
73
  if (!q) return [...groups];
73
74
  return groups
@@ -75,8 +76,8 @@ export function filterShortcuts(groups: readonly ShortcutGroup[], query: string)
75
76
  ...g,
76
77
  shortcuts: g.shortcuts.filter(
77
78
  (s) =>
78
- s.description.toLowerCase().includes(q) ||
79
- g.title.toLowerCase().includes(q) ||
79
+ matchText(s.description, query, { locale }) ||
80
+ matchText(g.title, query, { locale }) ||
80
81
  [...s.keys, ...(s.then ?? [])].join(' ').toLowerCase() === q,
81
82
  ),
82
83
  }))
@@ -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
  });
@@ -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);
@@ -1,3 +1,4 @@
1
+ import { matchText } from '@gntik-ai/ui';
1
2
  import { RESOURCE_CARDS, type DataTableColumn, type PageToolbarView, type ResourceCard } from '@gntik-ai/blocks';
2
3
  import { LayoutGrid, Table2 } from '@gntik-ai/icons';
3
4
  import { StatusTag, type BreadcrumbItem } from '@gntik-ai/ui';
@@ -37,8 +38,6 @@ export const galleryColumns: DataTableColumn<ResourceCard>[] = [
37
38
  ];
38
39
 
39
40
  /** Case-insensitive search over name and description. */
40
- export function searchResources(items: readonly ResourceCard[], query: string): ResourceCard[] {
41
- const q = query.trim().toLowerCase();
42
- if (!q) return [...items];
43
- return items.filter((i) => i.name.toLowerCase().includes(q) || (i.description ?? '').toLowerCase().includes(q));
41
+ export function searchResources(items: readonly ResourceCard[], query: string, locale?: string): ResourceCard[] {
42
+ return items.filter((i) => matchText(i.name, query, { locale }) || matchText(i.description ?? '', query, { locale }));
44
43
  }
@@ -1,3 +1,4 @@
1
+ import { useI18n } from '@gntik-ai/ui';
1
2
  import {
2
3
  BulkActionBar,
3
4
  DataTable,
@@ -92,11 +93,12 @@ export default function ResourceIndexPage<T extends object = ResourceRow>({
92
93
  loading = false,
93
94
  shell,
94
95
  }: Partial<ResourceIndexProps<T>>) {
96
+ const { locale } = useI18n();
95
97
  const [rows, setRows] = useState<readonly T[]>(initialRows);
96
98
  const [query, setQuery] = useState('');
97
99
  const [filters, setFilters] = useState<ActiveFilter[]>([]);
98
100
  const [selected, setSelected] = useState<string[]>([]);
99
- const visible = filterResources(rows, query, filters, columns);
101
+ const visible = filterResources(rows, query, filters, columns, locale);
100
102
  const visibleSelected = selected.filter((id) => visible.some((r) => getRowId(r) === id));
101
103
  const tableColumns = useMemo(
102
104
  () => linkColumns(columns, linkColumnId ?? columns[0]?.id, getRowHref, onRowClick),
@@ -1,3 +1,4 @@
1
+ import { matchText } from '@gntik-ai/ui';
1
2
  import {
2
3
  DEPLOYMENT_COLUMNS,
3
4
  DEPLOYMENT_ROWS,
@@ -62,18 +63,20 @@ export function filterResources<T extends object = ResourceRow>(
62
63
  query: string,
63
64
  filters: readonly ActiveFilter[],
64
65
  columns?: ReadonlyArray<Column<T>>,
66
+ locale?: string,
65
67
  ): T[] {
66
- const q = query.trim().toLowerCase();
68
+ const q = query.trim();
67
69
  const byField = new Map<string, string[]>();
68
70
  for (const f of filters) byField.set(f.field, [...(byField.get(f.field) ?? []), f.value.toLowerCase()]);
69
71
  return rows.filter((row) => {
70
72
  if (q) {
71
73
  const r = row as Record<string, unknown>;
72
- let haystack = [r.name, r.id].map(text).filter((v) => v !== undefined);
74
+ const searchText = (v: unknown) => (typeof v === 'string' || typeof v === 'number' ? String(v) : undefined);
75
+ let haystack = [r.name, r.id].map(searchText).filter((v) => v !== undefined);
73
76
  // Rows without a name/id: search every column value instead.
74
77
  if (haystack.length === 0 && columns)
75
- haystack = columns.map((c) => text(c.accessor?.(row))).filter((v) => v !== undefined);
76
- if (!haystack.some((v) => v.includes(q))) return false;
78
+ haystack = columns.map((c) => searchText(c.accessor?.(row))).filter((v) => v !== undefined);
79
+ if (!haystack.some((v) => matchText(v, q, { locale }))) return false;
77
80
  }
78
81
  for (const [field, values] of byField) {
79
82
  const value = text(fieldValue(row, field, columns));
@@ -1,6 +1,7 @@
1
1
  import { NoResultsEmpty, PageHeader, SectionHeader } from '@gntik-ai/blocks';
2
2
  import { Search } from '@gntik-ai/icons';
3
3
  import {
4
+ matchText,
4
5
  Checkbox,
5
6
  CheckboxGroup,
6
7
  Grid,
@@ -46,14 +47,13 @@ const EMPTY_SELECTION: SearchSelection = { type: [], owner: [] };
46
47
  const facetValue = (hit: SearchHit, facet: SearchFacet['id']) => (facet === 'type' ? hit.type : hit.owner);
47
48
 
48
49
  /** Hits whose title or snippet contain the query (case-insensitive). */
49
- export function matchQuery(hits: readonly SearchHit[], query: string) {
50
- const q = query.trim().toLowerCase();
51
- return q ? hits.filter((h) => `${h.title} ${h.snippet}`.toLowerCase().includes(q)) : [...hits];
50
+ export function matchQuery(hits: readonly SearchHit[], query: string, locale?: string) {
51
+ return hits.filter((h) => matchText(`${h.title} ${h.snippet}`, query, { locale }));
52
52
  }
53
53
 
54
54
  /** Search results: ConsoleShell + Page with Checkbox-group facets and results grouped by type. */
55
55
  export default function SearchResultsPage(props: Partial<SearchResultsProps>) {
56
- const { t } = useI18n();
56
+ const { t, locale } = useI18n();
57
57
  const {
58
58
  hits = searchHits,
59
59
  facets = searchFacets,
@@ -69,7 +69,7 @@ export default function SearchResultsPage(props: Partial<SearchResultsProps>) {
69
69
  const [query, setQuery] = useState(defaultQuery);
70
70
  const [selection, setSelection] = useState<SearchSelection>(EMPTY_SELECTION);
71
71
 
72
- const matched = matchQuery(hits, query);
72
+ const matched = matchQuery(hits, query, locale);
73
73
  const results = matched.filter((h) =>
74
74
  (Object.keys(selection) as Array<SearchFacet['id']>).every(
75
75
  (f) => selection[f].length === 0 || selection[f].includes(facetValue(h, f)),
@@ -0,0 +1,51 @@
1
+ import { matchesTriage, triageColumns } from '../incident-board/data';
2
+ import { filterUsage, usageRows } from '../usage-analytics/data';
3
+ import { filterShortcuts } from '../keyboard-shortcuts/data';
4
+ import { filterAuditEvents } from '../audit-log/Page';
5
+ import { auditEvents } from '../audit-log/data';
6
+ import { filterResources } from '../resource-index/data';
7
+ import { searchResources, galleryItems } from '../resource-gallery/data';
8
+ import { searchArticles, helpArticles } from '../help-home/data';
9
+ import { matchQuery } from '../search-results/Page';
10
+ import { searchHits } from '../search-results/data';
11
+
12
+ it('search-results finds an accented title', () => {
13
+ const row = { ...searchHits[0]!, title: 'Función' };
14
+ expect(matchQuery([row], 'funcion')).toEqual([row]);
15
+ });
16
+
17
+ it('help-home finds an accented title', () => {
18
+ const row = { ...helpArticles[0]!, title: 'Función' };
19
+ expect(searchArticles([row], 'funcion')).toEqual([row]);
20
+ });
21
+
22
+ it('resource-gallery finds an accented name', () => {
23
+ const row = { ...galleryItems[0]!, name: 'Función' };
24
+ expect(searchResources([row], 'funcion')).toEqual([row]);
25
+ });
26
+
27
+ it('resource-index folds free text and keeps facets accent-sensitive', () => {
28
+ const row = { id: 'function', name: 'Función', status: 'Función' };
29
+ expect(filterResources([row], 'funcion', [])).toEqual([row]);
30
+ expect(filterResources([row], '', [{ field: 'status', value: 'funcion' }])).toEqual([]);
31
+ });
32
+
33
+ it('audit-log finds an accented target', () => {
34
+ const row = { ...auditEvents[0]!, target: 'Función' };
35
+ expect(filterAuditEvents([row], 'funcion', [], { start: null, end: null })).toEqual([row]);
36
+ });
37
+
38
+ it('keyboard-shortcuts finds an accented description', () => {
39
+ const row = { id: 'actions', title: 'Actions', shortcuts: [{ id: 'function', description: 'Función', keys: ['F'] }] };
40
+ expect(filterShortcuts([row], 'funcion')).toEqual([row]);
41
+ });
42
+
43
+ it('usage-analytics finds an accented project', () => {
44
+ const row = { ...usageRows[0]!, project: 'Función' };
45
+ expect(filterUsage([row], 'funcion', [])).toEqual([row]);
46
+ });
47
+
48
+ it('incident-board finds an accented title', () => {
49
+ const row = { ...triageColumns[0]!.cards[0]!, title: 'Función' };
50
+ expect(matchesTriage(row, 'funcion', [])).toBe(true);
51
+ });
@@ -1,3 +1,4 @@
1
+ import { useI18n } from '@gntik-ai/ui';
1
2
  import {
2
3
  ChartCard,
3
4
  DataTable,
@@ -100,11 +101,12 @@ export default function UsageAnalyticsPage<P extends object = CostPoint, T exten
100
101
  onExport,
101
102
  shell,
102
103
  }: Partial<UsageAnalyticsProps<P, T>>) {
104
+ const { locale } = useI18n();
103
105
  const secondary = { ...(TRAFFIC_CHART as unknown as UsageSecondaryChart<T>), ...secondaryChart };
104
106
  const [range, setRange] = useState<DateRange | null>(null);
105
107
  const [query, setQuery] = useState('');
106
108
  const [filters, setFilters] = useState<ActiveFilter[]>([]);
107
- const visible = filterUsage(rows, query, filters);
109
+ const visible = filterUsage(rows, query, filters, locale);
108
110
 
109
111
  const clearFilters = () => setFilters([]);
110
112
  const exportAction = {
@@ -1,3 +1,4 @@
1
+ import { matchText } from '@gntik-ai/ui';
1
2
  import type { ActiveFilter, ChartCardRange, DataTableColumn, FilterField, SavedView } from '@gntik-ai/blocks';
2
3
  import type { BreadcrumbItem } from '@gntik-ai/ui';
3
4
 
@@ -68,12 +69,17 @@ export const usageSavedViews: SavedView[] = [
68
69
  * Search by project name; filters match the row field named by the filter id (same field OR,
69
70
  * across fields AND). Filters on fields the row does not have are ignored.
70
71
  */
71
- export function filterUsage(rows: readonly UsageRow[], query: string, filters: readonly ActiveFilter[]): UsageRow[] {
72
- const q = query.trim().toLowerCase();
72
+ export function filterUsage(
73
+ rows: readonly UsageRow[],
74
+ query: string,
75
+ filters: readonly ActiveFilter[],
76
+ locale?: string,
77
+ ): UsageRow[] {
78
+ const q = query.trim();
73
79
  const byField = new Map<string, string[]>();
74
80
  for (const f of filters) byField.set(f.field, [...(byField.get(f.field) ?? []), f.value]);
75
81
  return rows.filter((row) => {
76
- if (q && !row.project.toLowerCase().includes(q)) return false;
82
+ if (q && !matchText(row.project, q, { locale })) return false;
77
83
  for (const [field, values] of byField) {
78
84
  const value: unknown = (row as unknown as Record<string, unknown>)[field];
79
85
  if (value !== undefined && value !== null && !values.includes(String(value))) return false;