@open-mercato/ui 0.6.1-develop.3276.1.1fcd145078 → 0.6.1-develop.3287.1.450f4ffb56
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +23 -14
- package/dist/backend/AppShell.js +8 -32
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +18 -0
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +11 -14
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -6
- package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
- package/dist/backend/filters/ActiveFilterChips.js +142 -0
- package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
- package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
- package/dist/backend/filters/FilterEmptyState.js +41 -0
- package/dist/backend/filters/FilterEmptyState.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +148 -0
- package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
- package/dist/backend/filters/FilteredEmptyResults.js +26 -0
- package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
- package/dist/backend/filters/QuickFilters.js +41 -0
- package/dist/backend/filters/QuickFilters.js.map +7 -0
- package/dist/backend/filters/filterValidation.js +47 -0
- package/dist/backend/filters/filterValidation.js.map +7 -0
- package/dist/backend/filters/treeReducer.js +159 -0
- package/dist/backend/filters/treeReducer.js.map +7 -0
- package/dist/backend/hooks/useAdvancedFilter.js +82 -1
- package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +11 -7
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/EventPatternInput.js +5 -2
- package/dist/backend/inputs/EventPatternInput.js.map +2 -2
- package/dist/backend/inputs/EventSelect.js +10 -4
- package/dist/backend/inputs/EventSelect.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +51 -47
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +196 -45
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +2 -2
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/utils/api.js +18 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/serverErrors.js +6 -2
- package/dist/backend/utils/serverErrors.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/backend/utils/useCurrentUserId.js +31 -0
- package/dist/backend/utils/useCurrentUserId.js.map +7 -0
- package/dist/portal/utils/nav.js +17 -0
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/amount-input.js +128 -0
- package/dist/primitives/amount-input.js.map +7 -0
- package/dist/primitives/button-input.js +47 -0
- package/dist/primitives/button-input.js.map +7 -0
- package/dist/primitives/card-input.js +133 -0
- package/dist/primitives/card-input.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/email-input.js +29 -0
- package/dist/primitives/email-input.js.map +7 -0
- package/dist/primitives/password-input.js +64 -0
- package/dist/primitives/password-input.js.map +7 -0
- package/dist/primitives/search-input.js +57 -0
- package/dist/primitives/search-input.js.map +7 -0
- package/dist/primitives/select.js +43 -5
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/website-input.js +56 -0
- package/dist/primitives/website-input.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +7 -30
- package/src/backend/CrudForm.tsx +17 -0
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/FilterBar.tsx +5 -6
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/__tests__/serverErrors.test.ts +79 -1
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -5
- package/src/backend/filters/ActiveFilterChips.tsx +179 -0
- package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
- package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
- package/src/backend/filters/FilterEmptyState.tsx +51 -0
- package/src/backend/filters/FilterFieldPicker.tsx +139 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
- package/src/backend/filters/QuickFilters.tsx +62 -0
- package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
- package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
- package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
- package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
- package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
- package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
- package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
- package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
- package/src/backend/filters/filterValidation.ts +61 -0
- package/src/backend/filters/treeReducer.ts +201 -0
- package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
- package/src/backend/hooks/useAdvancedFilter.ts +130 -0
- package/src/backend/inputs/ComboboxInput.tsx +32 -26
- package/src/backend/inputs/EventPatternInput.tsx +11 -2
- package/src/backend/inputs/EventSelect.tsx +13 -4
- package/src/backend/inputs/LookupSelect.tsx +75 -53
- package/src/backend/inputs/PhoneNumberField.tsx +237 -47
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +30 -0
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +4 -0
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -0
- package/src/backend/messages/message-compose-form-groups.tsx +2 -2
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/serverErrors.ts +15 -8
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/portal/utils/__tests__/nav.test.ts +80 -0
- package/src/portal/utils/nav.ts +23 -1
- package/src/primitives/__tests__/password-input.test.tsx +93 -0
- package/src/primitives/__tests__/search-input.test.tsx +95 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +26 -0
- package/src/primitives/amount-input.tsx +184 -0
- package/src/primitives/button-input.tsx +68 -0
- package/src/primitives/card-input.tsx +193 -0
- package/src/primitives/dialog.tsx +18 -7
- package/src/primitives/email-input.tsx +36 -0
- package/src/primitives/password-input.tsx +100 -0
- package/src/primitives/search-input.tsx +87 -0
- package/src/primitives/select.tsx +98 -11
- package/src/primitives/tag.tsx +3 -1
- package/src/primitives/website-input.tsx +72 -0
|
@@ -389,4 +389,174 @@ describe('DataTable extensions', () => {
|
|
|
389
389
|
rendered.cleanupQueryClient()
|
|
390
390
|
}
|
|
391
391
|
})
|
|
392
|
+
|
|
393
|
+
it('does not expose saved filters through the perspectives action row', async () => {
|
|
394
|
+
const originalFetch = globalThis.fetch
|
|
395
|
+
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
|
|
396
|
+
const url = String(input)
|
|
397
|
+
if (url.includes('/api/auth/feature-check')) {
|
|
398
|
+
return new Response(JSON.stringify({ granted: ['perspectives.use'] }), {
|
|
399
|
+
status: 200,
|
|
400
|
+
headers: { 'content-type': 'application/json' },
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
if (url.includes('/api/perspectives/customers.people.list')) {
|
|
404
|
+
return new Response(JSON.stringify({
|
|
405
|
+
tableId: 'customers.people.list',
|
|
406
|
+
perspectives: [],
|
|
407
|
+
rolePerspectives: [],
|
|
408
|
+
roles: [],
|
|
409
|
+
defaultPerspectiveId: null,
|
|
410
|
+
canApplyToRoles: false,
|
|
411
|
+
}), {
|
|
412
|
+
status: 200,
|
|
413
|
+
headers: { 'content-type': 'application/json' },
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
return new Response(JSON.stringify({}), {
|
|
417
|
+
status: 404,
|
|
418
|
+
headers: { 'content-type': 'application/json' },
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch
|
|
422
|
+
|
|
423
|
+
const rendered = renderTable({
|
|
424
|
+
columns: [{ accessorKey: 'name', header: 'Name' }],
|
|
425
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
426
|
+
searchValue: '',
|
|
427
|
+
onSearchChange: () => {},
|
|
428
|
+
perspective: { tableId: 'customers.people.list' },
|
|
429
|
+
advancedFilter: {
|
|
430
|
+
value: {
|
|
431
|
+
root: {
|
|
432
|
+
id: 'root',
|
|
433
|
+
type: 'group',
|
|
434
|
+
combinator: 'and',
|
|
435
|
+
children: [
|
|
436
|
+
{ id: 'rule-1', type: 'rule', field: 'name', operator: 'contains', value: 'Alice' },
|
|
437
|
+
],
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
fields: [{ key: 'name', label: 'Name', type: 'text' }],
|
|
441
|
+
onChange: () => {},
|
|
442
|
+
onApply: () => {},
|
|
443
|
+
onClear: () => {},
|
|
444
|
+
externalPopover: true,
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
await screen.findByRole('button', { name: /All views/ })
|
|
450
|
+
expect(screen.queryByTestId('advanced-filter-save-trigger')).not.toBeInTheDocument()
|
|
451
|
+
expect(screen.queryByRole('button', { name: 'Save filter' })).not.toBeInTheDocument()
|
|
452
|
+
expect(fetchMock.mock.calls.some(([input, init]) =>
|
|
453
|
+
String(input).includes('/api/perspectives/customers.people.list') && init?.method === 'POST'
|
|
454
|
+
)).toBe(false)
|
|
455
|
+
} finally {
|
|
456
|
+
if (originalFetch) {
|
|
457
|
+
globalThis.fetch = originalFetch
|
|
458
|
+
} else {
|
|
459
|
+
delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch
|
|
460
|
+
}
|
|
461
|
+
rendered.cleanupQueryClient()
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('clears the advanced filter tree when No view is selected', async () => {
|
|
466
|
+
const originalFetch = globalThis.fetch
|
|
467
|
+
const savedTree = {
|
|
468
|
+
v: 2,
|
|
469
|
+
root: {
|
|
470
|
+
id: 'saved-root',
|
|
471
|
+
type: 'group',
|
|
472
|
+
combinator: 'and',
|
|
473
|
+
children: [
|
|
474
|
+
{ id: 'saved-rule', type: 'rule', field: 'name', operator: 'contains', value: 'Alice' },
|
|
475
|
+
],
|
|
476
|
+
},
|
|
477
|
+
}
|
|
478
|
+
const perspectiveResponse = {
|
|
479
|
+
tableId: 'customers.people.list.clear-test',
|
|
480
|
+
perspectives: [
|
|
481
|
+
{
|
|
482
|
+
id: 'saved-filter-1',
|
|
483
|
+
name: 'Owned leads',
|
|
484
|
+
tableId: 'customers.people.list.clear-test',
|
|
485
|
+
isDefault: false,
|
|
486
|
+
settings: { filters: savedTree },
|
|
487
|
+
createdAt: new Date().toISOString(),
|
|
488
|
+
updatedAt: new Date().toISOString(),
|
|
489
|
+
},
|
|
490
|
+
],
|
|
491
|
+
rolePerspectives: [],
|
|
492
|
+
roles: [],
|
|
493
|
+
defaultPerspectiveId: null,
|
|
494
|
+
canApplyToRoles: false,
|
|
495
|
+
}
|
|
496
|
+
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
|
|
497
|
+
const url = String(input)
|
|
498
|
+
if (url.includes('/api/auth/feature-check')) {
|
|
499
|
+
return new Response(JSON.stringify({ granted: ['perspectives.use'] }), {
|
|
500
|
+
status: 200,
|
|
501
|
+
headers: { 'content-type': 'application/json' },
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
if (url.includes('/api/perspectives/customers.people.list.clear-test')) {
|
|
505
|
+
return new Response(JSON.stringify(perspectiveResponse), {
|
|
506
|
+
status: 200,
|
|
507
|
+
headers: { 'content-type': 'application/json' },
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
return new Response(JSON.stringify({}), {
|
|
511
|
+
status: 404,
|
|
512
|
+
headers: { 'content-type': 'application/json' },
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch
|
|
516
|
+
window.localStorage.clear()
|
|
517
|
+
|
|
518
|
+
const onApplyTree = jest.fn()
|
|
519
|
+
const rendered = renderTable({
|
|
520
|
+
columns: [{ accessorKey: 'name', header: 'Name' }],
|
|
521
|
+
data: [{ id: 'r1', name: 'Alice' }],
|
|
522
|
+
searchValue: '',
|
|
523
|
+
onSearchChange: () => {},
|
|
524
|
+
perspective: {
|
|
525
|
+
tableId: 'customers.people.list.clear-test',
|
|
526
|
+
initialState: { response: perspectiveResponse },
|
|
527
|
+
},
|
|
528
|
+
advancedFilter: {
|
|
529
|
+
value: savedTree,
|
|
530
|
+
fields: [{ key: 'name', label: 'Name', type: 'text' }],
|
|
531
|
+
onChange: () => {},
|
|
532
|
+
onApply: () => {},
|
|
533
|
+
onClear: () => {},
|
|
534
|
+
externalPopover: true,
|
|
535
|
+
onApplyTree,
|
|
536
|
+
},
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
await waitFor(() => expect(onApplyTree).toHaveBeenCalled())
|
|
541
|
+
const activeViewButton = await screen.findByRole('button', { name: /Owned leads/ })
|
|
542
|
+
fireEvent.click(activeViewButton)
|
|
543
|
+
fireEvent.click(screen.getByRole('button', { name: /No view/ }))
|
|
544
|
+
|
|
545
|
+
await waitFor(() => {
|
|
546
|
+
const calls = onApplyTree.mock.calls
|
|
547
|
+
const lastTree = calls[calls.length - 1]?.[0]
|
|
548
|
+
expect(lastTree?.root.children).toHaveLength(0)
|
|
549
|
+
})
|
|
550
|
+
await waitFor(() => expect(screen.getByRole('button', { name: /All views/ })).toBeInTheDocument())
|
|
551
|
+
const calls = onApplyTree.mock.calls
|
|
552
|
+
expect(calls[calls.length - 1]?.[0]?.root.children).toHaveLength(0)
|
|
553
|
+
} finally {
|
|
554
|
+
if (originalFetch) {
|
|
555
|
+
globalThis.fetch = originalFetch
|
|
556
|
+
} else {
|
|
557
|
+
delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch
|
|
558
|
+
}
|
|
559
|
+
rendered.cleanupQueryClient()
|
|
560
|
+
}
|
|
561
|
+
})
|
|
392
562
|
})
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mapCrudServerErrorToFormErrors,
|
|
3
|
+
normalizeCrudServerError,
|
|
4
|
+
raiseCrudError,
|
|
5
|
+
readJsonSafe,
|
|
6
|
+
} from '../utils/serverErrors'
|
|
2
7
|
|
|
3
8
|
describe('serverErrors helpers', () => {
|
|
4
9
|
it('maps details array into field errors and message', () => {
|
|
@@ -59,6 +64,79 @@ describe('serverErrors helpers', () => {
|
|
|
59
64
|
}
|
|
60
65
|
})
|
|
61
66
|
|
|
67
|
+
it('raiseCrudError extracts message from structured { error: { code, message } } envelope', async () => {
|
|
68
|
+
expect.assertions(4)
|
|
69
|
+
const response = new Response(
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
ok: false,
|
|
72
|
+
error: {
|
|
73
|
+
code: 'invite_cooldown_active',
|
|
74
|
+
message: 'A recent invite for this email is still pending — please wait before re-inviting.',
|
|
75
|
+
details: { retryAfterSeconds: 60 },
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
{ status: 429, headers: { 'content-type': 'application/json' } },
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await raiseCrudError(response, 'Fallback message')
|
|
83
|
+
} catch (err) {
|
|
84
|
+
expect(err).toMatchObject({
|
|
85
|
+
status: 429,
|
|
86
|
+
message:
|
|
87
|
+
'A recent invite for this email is still pending — please wait before re-inviting.',
|
|
88
|
+
})
|
|
89
|
+
const errorField = (err as { error?: unknown }).error as
|
|
90
|
+
| { code?: unknown; details?: unknown }
|
|
91
|
+
| undefined
|
|
92
|
+
expect(errorField?.code).toBe('invite_cooldown_active')
|
|
93
|
+
expect(errorField?.details).toEqual({ retryAfterSeconds: 60 })
|
|
94
|
+
expect((err as { ok?: unknown }).ok).toBe(false)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('normalizeCrudServerError reads message from nested { error: { message } } envelope', () => {
|
|
99
|
+
const normalized = normalizeCrudServerError({
|
|
100
|
+
ok: false,
|
|
101
|
+
error: {
|
|
102
|
+
code: 'invite_cooldown_active',
|
|
103
|
+
message: 'A recent invite for this email is still pending — please wait before re-inviting.',
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
expect(normalized.message).toBe(
|
|
107
|
+
'A recent invite for this email is still pending — please wait before re-inviting.',
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('raiseCrudError prefers top-level string error over nested error.message', async () => {
|
|
112
|
+
expect.assertions(1)
|
|
113
|
+
const response = new Response(
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
error: 'Top-level message',
|
|
116
|
+
message: 'Top-level message',
|
|
117
|
+
}),
|
|
118
|
+
{ status: 400, headers: { 'content-type': 'application/json' } },
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
await expect(raiseCrudError(response, 'Fallback message')).rejects.toMatchObject({
|
|
122
|
+
status: 400,
|
|
123
|
+
message: 'Top-level message',
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('raiseCrudError falls back when structured envelope has empty nested message', async () => {
|
|
128
|
+
expect.assertions(1)
|
|
129
|
+
const response = new Response(
|
|
130
|
+
JSON.stringify({ ok: false, error: { code: 'oops', message: ' ' } }),
|
|
131
|
+
{ status: 500, headers: { 'content-type': 'application/json' } },
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
await expect(raiseCrudError(response, 'Fallback message')).rejects.toMatchObject({
|
|
135
|
+
status: 500,
|
|
136
|
+
message: 'Fallback message',
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
62
140
|
it('raiseCrudError falls back to message when body is plain text', async () => {
|
|
63
141
|
expect.assertions(1)
|
|
64
142
|
const response = new Response('Something went wrong', { status: 500 })
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
|
-
import {
|
|
3
|
+
import { GripVertical, X, ChevronRight } from 'lucide-react'
|
|
4
4
|
import { Button } from '../../primitives/button'
|
|
5
5
|
import { IconButton } from '../../primitives/icon-button'
|
|
6
6
|
import { Switch } from '../../primitives/switch'
|
|
7
7
|
import { Input } from '../../primitives/input'
|
|
8
|
+
import { SearchInput } from '../../primitives/search-input'
|
|
8
9
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
9
10
|
import {
|
|
10
11
|
DndContext,
|
|
@@ -168,13 +169,11 @@ export function ColumnChooserSection({
|
|
|
168
169
|
return (
|
|
169
170
|
<div className="flex flex-col">
|
|
170
171
|
<div className="px-4 py-3 border-t">
|
|
171
|
-
<
|
|
172
|
-
type="text"
|
|
172
|
+
<SearchInput
|
|
173
173
|
size="sm"
|
|
174
|
-
leftIcon={<Search />}
|
|
175
174
|
placeholder={t('ui.columnChooser.search', 'Search columns...')}
|
|
176
175
|
value={searchQuery}
|
|
177
|
-
onChange={
|
|
176
|
+
onChange={setSearchQuery}
|
|
178
177
|
/>
|
|
179
178
|
</div>
|
|
180
179
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { X } from 'lucide-react'
|
|
4
|
+
import { Tag, type TagVariant } from '../../primitives/tag'
|
|
5
|
+
import { IconButton } from '../../primitives/icon-button'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import type { AdvancedFilterTree, FilterRule, FilterGroup } from '@open-mercato/shared/lib/query/advanced-filter-tree'
|
|
8
|
+
import type { FilterFieldDef, FilterOption } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
9
|
+
import { isValuelessOperator } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
10
|
+
import { validateTreeForApply } from './filterValidation'
|
|
11
|
+
|
|
12
|
+
export type ActiveFilterChipsProps = {
|
|
13
|
+
tree: AdvancedFilterTree
|
|
14
|
+
fields: FilterFieldDef[]
|
|
15
|
+
popoverOpen: boolean
|
|
16
|
+
onRemoveNode: (id: string) => void
|
|
17
|
+
onOpen: (focusNodeId?: string) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_LABEL = 24
|
|
21
|
+
|
|
22
|
+
function truncate(s: string): string {
|
|
23
|
+
if (s.length <= MAX_LABEL) return s
|
|
24
|
+
return s.slice(0, MAX_LABEL) + '…'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getOption(field: FilterFieldDef | undefined, value: string): FilterOption | undefined {
|
|
28
|
+
if (!field?.options) return undefined
|
|
29
|
+
return field.options.find((o) => o.value === value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function valueText(field: FilterFieldDef | undefined, value: unknown): { text: string; tone?: TagVariant } {
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
if (value.length === 0) return { text: '' }
|
|
35
|
+
const first = String(value[0])
|
|
36
|
+
const opt = getOption(field, first)
|
|
37
|
+
const display = opt?.label ?? first
|
|
38
|
+
const more = value.length - 1
|
|
39
|
+
return {
|
|
40
|
+
text: more > 0 ? `${truncate(display)} +${more}` : truncate(display),
|
|
41
|
+
tone: opt?.tone,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (value == null || value === '') return { text: '' }
|
|
45
|
+
const str = String(value)
|
|
46
|
+
const opt = getOption(field, str)
|
|
47
|
+
return { text: truncate(opt?.label ?? str), tone: opt?.tone }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Operator labels in chips read as natural English: "Email is empty" beats
|
|
51
|
+
// "Email: " (trailing colon, blank value). Keep the map narrow — only valueless
|
|
52
|
+
// operators need a phrase form here; value-bearing operators show the value.
|
|
53
|
+
const VALUELESS_OPERATOR_LABEL: Record<string, string> = {
|
|
54
|
+
is_empty: 'is empty',
|
|
55
|
+
is_not_empty: 'is not empty',
|
|
56
|
+
is_true: 'is true',
|
|
57
|
+
is_false: 'is false',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Translator = ReturnType<typeof useT>
|
|
61
|
+
|
|
62
|
+
function ruleChip(rule: FilterRule, fields: FilterFieldDef[], t: Translator): { label: string; tone?: TagVariant } {
|
|
63
|
+
const field = fields.find((f) => f.key === rule.field)
|
|
64
|
+
const fieldLabel = field?.label ?? rule.field
|
|
65
|
+
if (isValuelessOperator(rule.operator)) {
|
|
66
|
+
const fallback = VALUELESS_OPERATOR_LABEL[rule.operator] ?? rule.operator
|
|
67
|
+
const opLabel = t(`ui.advancedFilter.operator.${rule.operator}`, fallback)
|
|
68
|
+
return { label: `${fieldLabel} ${opLabel}` }
|
|
69
|
+
}
|
|
70
|
+
// `between` stores `[start, end]` — render it as a range, not as the
|
|
71
|
+
// multi-select "first +N" summary that `valueText` would produce.
|
|
72
|
+
if (rule.operator === 'between' && Array.isArray(rule.value)) {
|
|
73
|
+
const [start, end] = rule.value
|
|
74
|
+
const startStr = start == null ? '' : String(start)
|
|
75
|
+
const endStr = end == null ? '' : String(end)
|
|
76
|
+
return { label: `${fieldLabel}: ${truncate(startStr)} – ${truncate(endStr)}` }
|
|
77
|
+
}
|
|
78
|
+
const v = valueText(field, rule.value)
|
|
79
|
+
return { label: `${fieldLabel}: ${v.text}`, tone: v.tone }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function groupChip(group: FilterGroup, fields: FilterFieldDef[], invalidRuleIds: Set<string>, t: Translator): { label: string; tone?: TagVariant; remaining: number } {
|
|
83
|
+
const ruleChildren: FilterRule[] = []
|
|
84
|
+
for (const c of group.children) if (c.type === 'rule' && !invalidRuleIds.has(c.id)) ruleChildren.push(c)
|
|
85
|
+
if (!ruleChildren.length) return { label: '', remaining: 0 }
|
|
86
|
+
const firstChip = ruleChip(ruleChildren[0], fields, t)
|
|
87
|
+
const remaining = ruleChildren.length - 1
|
|
88
|
+
return {
|
|
89
|
+
label: remaining > 0 ? `${firstChip.label} +${remaining}` : firstChip.label,
|
|
90
|
+
tone: firstChip.tone,
|
|
91
|
+
remaining,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isNodeFullyInvalid(node: FilterRule | FilterGroup, invalidRuleIds: Set<string>): boolean {
|
|
96
|
+
if (node.type === 'rule') return invalidRuleIds.has(node.id)
|
|
97
|
+
if (!node.children.length) return true
|
|
98
|
+
return node.children.every((c) => isNodeFullyInvalid(c, invalidRuleIds))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function ActiveFilterChips({ tree, fields, popoverOpen, onRemoveNode, onOpen }: ActiveFilterChipsProps) {
|
|
102
|
+
const t = useT()
|
|
103
|
+
// Suppress chips for rules/groups whose values won't actually filter (matches
|
|
104
|
+
// the same gate the auto-apply hook uses). Stops the "chip is shown but data
|
|
105
|
+
// appears unfiltered" mismatch when a substring text rule has < 2 characters
|
|
106
|
+
// or a multi-value rule has no picks yet.
|
|
107
|
+
const invalidRuleIds = React.useMemo(() => {
|
|
108
|
+
const result = validateTreeForApply(tree, fields)
|
|
109
|
+
if (result.ok) return new Set<string>()
|
|
110
|
+
return new Set(result.errors.map((e) => e.ruleId))
|
|
111
|
+
}, [tree, fields])
|
|
112
|
+
const visibleChildren = React.useMemo(
|
|
113
|
+
() => tree.root.children.filter((child) => !isNodeFullyInvalid(child, invalidRuleIds)),
|
|
114
|
+
[tree.root.children, invalidRuleIds],
|
|
115
|
+
)
|
|
116
|
+
if (popoverOpen) return null
|
|
117
|
+
if (!visibleChildren.length) return null
|
|
118
|
+
return (
|
|
119
|
+
<div
|
|
120
|
+
className="flex items-center gap-2 px-4 py-2 overflow-x-auto bg-background border-b border-border"
|
|
121
|
+
data-testid="active-filter-chips"
|
|
122
|
+
>
|
|
123
|
+
{visibleChildren.map((child) => {
|
|
124
|
+
if (child.type === 'rule') {
|
|
125
|
+
const c = ruleChip(child, fields, t)
|
|
126
|
+
return (
|
|
127
|
+
<ChipWithRemove
|
|
128
|
+
key={child.id}
|
|
129
|
+
label={c.label}
|
|
130
|
+
tone={c.tone}
|
|
131
|
+
onClick={() => onOpen(child.id)}
|
|
132
|
+
onRemove={() => onRemoveNode(child.id)}
|
|
133
|
+
removeLabel={t('ui.advancedFilter.chips.remove', 'Remove filter')}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
const c = groupChip(child, fields, invalidRuleIds, t)
|
|
138
|
+
return (
|
|
139
|
+
<ChipWithRemove
|
|
140
|
+
key={child.id}
|
|
141
|
+
label={c.label}
|
|
142
|
+
tone={c.tone}
|
|
143
|
+
onClick={() => onOpen(child.id)}
|
|
144
|
+
onRemove={() => onRemoveNode(child.id)}
|
|
145
|
+
removeLabel={t('ui.advancedFilter.chips.removeGroup', 'Remove filter group')}
|
|
146
|
+
/>
|
|
147
|
+
)
|
|
148
|
+
})}
|
|
149
|
+
</div>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type ChipWithRemoveProps = {
|
|
154
|
+
label: string
|
|
155
|
+
tone?: TagVariant
|
|
156
|
+
onClick: () => void
|
|
157
|
+
onRemove: () => void
|
|
158
|
+
removeLabel: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ChipWithRemove({ label, tone, onClick, onRemove, removeLabel }: ChipWithRemoveProps) {
|
|
162
|
+
return (
|
|
163
|
+
<div className="inline-flex items-center gap-1" data-testid="active-filter-chip" aria-label={label}>
|
|
164
|
+
{/* Raw <button> required: <Tag> is a presentational pill primitive (not a Button), so wrapping
|
|
165
|
+
it in <Button> would inject Button's own styling around it. The native button keeps Tag's
|
|
166
|
+
visual contract intact while adding click + keyboard semantics; DS focus-visible ring is applied. */}
|
|
167
|
+
<button
|
|
168
|
+
type="button"
|
|
169
|
+
onClick={onClick}
|
|
170
|
+
className="inline-flex rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
171
|
+
>
|
|
172
|
+
<Tag variant={tone ?? 'default'} dot={tone != null}>{label}</Tag>
|
|
173
|
+
</button>
|
|
174
|
+
<IconButton type="button" variant="ghost" size="xs" aria-label={removeLabel} onClick={onRemove}>
|
|
175
|
+
<X className="size-3" />
|
|
176
|
+
</IconButton>
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|