@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/dist/index.js +47 -40
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/audit-log/Page.test.tsx +53 -0
- package/src/audit-log/Page.tsx +13 -5
- package/src/audit-log/template.meta.ts +1 -1
- package/src/help-home/Page.tsx +3 -1
- package/src/help-home/data.ts +3 -4
- package/src/incident-board/Page.tsx +6 -1
- package/src/incident-board/data.ts +9 -3
- package/src/keyboard-shortcuts/ShortcutSheet.tsx +3 -1
- package/src/keyboard-shortcuts/data.ts +4 -3
- package/src/live-event-stream/Page.test.tsx +8 -4
- package/src/resource-gallery/Page.tsx +3 -1
- package/src/resource-gallery/data.ts +3 -4
- package/src/resource-index/Page.tsx +3 -1
- package/src/resource-index/data.ts +7 -4
- package/src/search-results/Page.tsx +5 -5
- package/src/test/match-text.test.ts +51 -0
- package/src/usage-analytics/Page.tsx +3 -1
- package/src/usage-analytics/data.ts +9 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gntik-ai/templates",
|
|
3
|
-
"version": "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.
|
|
38
|
+
"@gntik-ai/blocks": "0.9.0",
|
|
39
39
|
"@gntik-ai/charts": "0.5.0",
|
|
40
|
-
"@gntik-ai/chat": "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.
|
|
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 />);
|
package/src/audit-log/Page.tsx
CHANGED
|
@@ -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()
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
};
|
package/src/help-home/Page.tsx
CHANGED
|
@@ -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
|
|
package/src/help-home/data.ts
CHANGED
|
@@ -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
|
-
|
|
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) => ({
|
|
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(
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
79
|
-
g.title
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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) =>
|
|
76
|
-
if (!haystack.some((v) => v
|
|
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
|
-
|
|
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(
|
|
72
|
-
|
|
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
|
|
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;
|