@open-mercato/ui 0.6.7-develop.6860.1.f796b22e80 → 0.6.7
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 +2 -4
- package/agentic/standalone-guide.md +1 -1
- package/build.mjs +54 -2
- package/dist/ai/AiChat.js +2 -21
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/backend/AppShell.js +4 -43
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/AuthSessionGuard.js +0 -14
- package/dist/backend/AuthSessionGuard.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +1 -6
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/CrudForm.js +4 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +18 -20
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/JsonBuilder.js +3 -3
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/NextStepCallout.js +4 -4
- package/dist/backend/NextStepCallout.js.map +1 -1
- package/dist/backend/ProfileDropdown.js +1 -2
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/RowActions.js +2 -2
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -13
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/crud/useGroupOrder.js +21 -14
- package/dist/backend/crud/useGroupOrder.js.map +2 -2
- package/dist/backend/detail/AddressTiles.js +5 -4
- package/dist/backend/detail/AddressTiles.js.map +2 -2
- package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
- package/dist/backend/detail/AttachmentsSection.js +1 -1
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +1 -1
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/detail/TagsSection.js +1 -1
- package/dist/backend/detail/TagsSection.js.map +2 -2
- package/dist/backend/forms/FormHeader.js +2 -1
- package/dist/backend/forms/FormHeader.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +25 -1
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.js +3 -3
- package/dist/backend/icons/lucideRegistry.js.map +2 -2
- package/dist/backend/injection/spotIds.js +18 -23
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/schedule/ScheduleAgenda.js +3 -3
- package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
- package/dist/backend/schedule/ScheduleGrid.js +3 -3
- package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
- package/dist/backend/schedule/ScheduleToolbar.js +70 -87
- package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +2 -30
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/sidebar/customization-helpers.js +0 -30
- package/dist/backend/sidebar/customization-helpers.js.map +2 -2
- package/dist/backend/utils/api.js +13 -3
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/apiCall.js +1 -21
- package/dist/backend/utils/apiCall.js.map +2 -2
- package/dist/backend/utils/customFieldDefs.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +9 -10
- package/dist/backend/utils/customFieldForms.js.map +2 -2
- package/dist/backend/utils/nav.js +2 -5
- package/dist/backend/utils/nav.js.map +2 -2
- package/dist/frontend/LanguageSwitcher.js +1 -2
- package/dist/frontend/LanguageSwitcher.js.map +2 -2
- package/dist/portal/PortalContext.js +1 -1
- package/dist/portal/PortalContext.js.map +2 -2
- package/dist/portal/hooks/usePortalEventBridge.js +0 -6
- package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
- package/dist/portal/hooks/usePortalNotifications.js +4 -42
- package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
- package/dist/portal/utils/nav.js +3 -2
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/button.js +4 -9
- package/dist/primitives/button.js.map +2 -2
- package/jest.config.cjs +1 -2
- package/package.json +18 -21
- package/src/ai/AiChat.tsx +1 -30
- package/src/ai/__tests__/AiChat.test.tsx +0 -31
- package/src/backend/AppShell.tsx +4 -55
- package/src/backend/AuthSessionGuard.tsx +0 -29
- package/src/backend/BackendChromeProvider.tsx +1 -19
- package/src/backend/CrudForm.tsx +4 -18
- package/src/backend/DataTable.tsx +19 -36
- package/src/backend/JsonBuilder.tsx +3 -3
- package/src/backend/NextStepCallout.tsx +4 -4
- package/src/backend/ProfileDropdown.tsx +0 -1
- package/src/backend/RowActions.tsx +2 -2
- package/src/backend/__tests__/AppShell.test.tsx +7 -65
- package/src/backend/__tests__/AuthSessionGuard.test.tsx +0 -52
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +2 -98
- package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +1 -62
- package/src/backend/__tests__/nav-utils.test.ts +0 -139
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -16
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +1 -68
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +1 -39
- package/src/backend/crud/useGroupOrder.ts +25 -30
- package/src/backend/detail/AddressTiles.tsx +6 -3
- package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/backend/detail/AttachmentsSection.tsx +1 -1
- package/src/backend/detail/NotesSection.tsx +1 -1
- package/src/backend/detail/TagsSection.tsx +1 -1
- package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +0 -21
- package/src/backend/forms/FormHeader.tsx +2 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +37 -3
- package/src/backend/icons/lucideRegistry.ts +2 -2
- package/src/backend/injection/spotIds.ts +18 -23
- package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
- package/src/backend/schedule/ScheduleGrid.tsx +3 -3
- package/src/backend/schedule/ScheduleToolbar.tsx +68 -89
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +3 -42
- package/src/backend/sidebar/customization-helpers.ts +0 -47
- package/src/backend/utils/__tests__/api.test.ts +27 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +0 -61
- package/src/backend/utils/api.ts +20 -3
- package/src/backend/utils/apiCall.ts +1 -29
- package/src/backend/utils/customFieldDefs.ts +0 -1
- package/src/backend/utils/customFieldForms.ts +11 -15
- package/src/backend/utils/nav.ts +2 -22
- package/src/frontend/LanguageSwitcher.tsx +0 -1
- package/src/portal/PortalContext.tsx +1 -1
- package/src/portal/hooks/usePortalEventBridge.ts +0 -6
- package/src/portal/hooks/usePortalNotifications.ts +12 -55
- package/src/portal/utils/__tests__/nav.test.ts +2 -2
- package/src/portal/utils/nav.ts +4 -3
- package/src/primitives/button.tsx +4 -10
- package/dist/backend/icons/lucideRegistryRuntime.js +0 -28
- package/dist/backend/icons/lucideRegistryRuntime.js.map +0 -7
- package/dist/backend/perspectiveState.js +0 -34
- package/dist/backend/perspectiveState.js.map +0 -7
- package/dist/backend/utils/fetchAllPages.js +0 -57
- package/dist/backend/utils/fetchAllPages.js.map +0 -7
- package/dist/portal/hooks/portalBridgeStatus.js +0 -25
- package/dist/portal/hooks/portalBridgeStatus.js.map +0 -7
- package/figma/alert.figma.tsx +0 -35
- package/figma/badge.figma.tsx +0 -33
- package/figma/button.figma.tsx +0 -33
- package/figma/checkbox.figma.tsx +0 -28
- package/figma/drawer.figma.tsx +0 -40
- package/figma/input-variants.figma.tsx +0 -58
- package/figma/radio.figma.tsx +0 -16
- package/figma/select.figma.tsx +0 -24
- package/figma/status-badge.figma.tsx +0 -24
- package/figma/switch.figma.tsx +0 -28
- package/figma/tabs.figma.tsx +0 -22
- package/figma/tag.figma.tsx +0 -27
- package/figma.config.json +0 -6
- package/scripts/lucideRegistrySource.cjs +0 -133
- package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +0 -158
- package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +0 -98
- package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +0 -105
- package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +0 -177
- package/src/backend/icons/lucideRegistryRuntime.tsx +0 -36
- package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +0 -38
- package/src/backend/perspectiveState.ts +0 -44
- package/src/backend/sidebar/__tests__/group-visibility.test.ts +0 -131
- package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +0 -95
- package/src/backend/utils/__tests__/fetchAllPages.test.ts +0 -103
- package/src/backend/utils/fetchAllPages.ts +0 -93
- package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +0 -66
- package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +0 -155
- package/src/portal/hooks/portalBridgeStatus.ts +0 -29
- package/src/primitives/__tests__/button.test.tsx +0 -70
|
@@ -75,25 +75,4 @@ describe('AttachmentAssignmentsEditor (metadata dialog)', () => {
|
|
|
75
75
|
fireEvent.click(screen.getByRole('button', { name: 'Remove assignment' }))
|
|
76
76
|
expect(screen.queryByPlaceholderText('catalog.product')).toBeNull()
|
|
77
77
|
})
|
|
78
|
-
|
|
79
|
-
it('keeps the surviving assignment values after removing a middle row', () => {
|
|
80
|
-
render(
|
|
81
|
-
<EditorHarness
|
|
82
|
-
initial={[
|
|
83
|
-
{ type: 'catalog.product', id: 'p-1', href: '', label: '' },
|
|
84
|
-
{ type: 'sales.order', id: 'o-2', href: '', label: '' },
|
|
85
|
-
{ type: 'customers.person', id: 'c-3', href: '', label: '' },
|
|
86
|
-
]}
|
|
87
|
-
/>,
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
fireEvent.click(screen.getAllByRole('button', { name: labels.remove })[1])
|
|
91
|
-
|
|
92
|
-
const typeInputs = screen.getAllByPlaceholderText('catalog.product') as HTMLInputElement[]
|
|
93
|
-
const idInputs = screen.getAllByPlaceholderText('Record ID') as HTMLInputElement[]
|
|
94
|
-
expect(typeInputs[0]).toHaveValue('catalog.product')
|
|
95
|
-
expect(idInputs[0]).toHaveValue('p-1')
|
|
96
|
-
expect(typeInputs[1]).toHaveValue('customers.person')
|
|
97
|
-
expect(idInputs[1]).toHaveValue('c-3')
|
|
98
|
-
})
|
|
99
78
|
})
|
|
@@ -181,10 +181,11 @@ function DetailHeader({
|
|
|
181
181
|
{onDelete ? (
|
|
182
182
|
<Button
|
|
183
183
|
type="button"
|
|
184
|
-
variant="
|
|
184
|
+
variant="outline"
|
|
185
185
|
size="sm"
|
|
186
186
|
onClick={onDelete}
|
|
187
187
|
disabled={isDeleting}
|
|
188
|
+
className="text-red-600 border-red-200 hover:bg-red-50 rounded"
|
|
188
189
|
>
|
|
189
190
|
{isDeleting ? (
|
|
190
191
|
<Loader2 className="size-4 mr-2 animate-spin" />
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// AUTO-GENERATED by @open-mercato/ui build. Do not edit by hand.
|
|
3
3
|
// Generated from discovered icon string usages across the repo.
|
|
4
4
|
|
|
5
|
-
import type
|
|
5
|
+
import type * as React from 'react'
|
|
6
|
+
import type { LucideIcon } from 'lucide-react'
|
|
6
7
|
import {
|
|
7
8
|
Activity,
|
|
8
9
|
AlertCircle,
|
|
@@ -149,7 +150,7 @@ import {
|
|
|
149
150
|
Wrench,
|
|
150
151
|
X,
|
|
151
152
|
XCircle,
|
|
152
|
-
} from 'lucide-react'
|
|
153
|
+
} from 'lucide-react'
|
|
153
154
|
|
|
154
155
|
export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
155
156
|
'activity': Activity,
|
|
@@ -297,4 +298,37 @@ export const LUCIDE_ICON_REGISTRY: Record<string, LucideIcon> = {
|
|
|
297
298
|
'wrench': Wrench,
|
|
298
299
|
'x': X,
|
|
299
300
|
'x-circle': XCircle,
|
|
300
|
-
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeKebabIconName(input: string): string {
|
|
304
|
+
const trimmed = input.trim()
|
|
305
|
+
if (!trimmed) return ''
|
|
306
|
+
const withoutPrefix = trimmed.startsWith('lucide:') ? trimmed.slice('lucide:'.length) : trimmed
|
|
307
|
+
if (!withoutPrefix) return ''
|
|
308
|
+
if (!withoutPrefix.includes('-') && !withoutPrefix.includes('_') && !withoutPrefix.includes(' ') && /[A-Z]/.test(withoutPrefix)) {
|
|
309
|
+
return withoutPrefix
|
|
310
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
311
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
312
|
+
.toLowerCase()
|
|
313
|
+
}
|
|
314
|
+
return withoutPrefix
|
|
315
|
+
.replace(/[_\s]+/g, '-')
|
|
316
|
+
.replace(/-+/g, '-')
|
|
317
|
+
.toLowerCase()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function resolveRegisteredLucideIcon(name: string | undefined): LucideIcon | null {
|
|
321
|
+
if (!name) return null
|
|
322
|
+
const normalized = normalizeKebabIconName(name)
|
|
323
|
+
if (!normalized) return null
|
|
324
|
+
return (LUCIDE_ICON_REGISTRY as Record<string, LucideIcon | undefined>)[normalized] ?? null
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function resolveRegisteredLucideIconNode(
|
|
328
|
+
name: string | undefined,
|
|
329
|
+
className: string
|
|
330
|
+
): React.ReactNode | null {
|
|
331
|
+
const Icon = resolveRegisteredLucideIcon(name)
|
|
332
|
+
if (!Icon) return null
|
|
333
|
+
return <Icon className={className} />
|
|
334
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export {
|
|
2
2
|
resolveRegisteredLucideIcon,
|
|
3
3
|
resolveRegisteredLucideIconNode,
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
LUCIDE_ICON_REGISTRY,
|
|
5
|
+
} from './lucideRegistry.generated'
|
|
6
6
|
|
|
7
7
|
import type { LucideIcon } from 'lucide-react'
|
|
8
8
|
import { LUCIDE_ICON_REGISTRY } from './lucideRegistry.generated'
|
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'
|
|
2
|
-
import {
|
|
3
|
-
crudFormExtensionSpotId,
|
|
4
|
-
dataTableExtensionSpotId,
|
|
5
|
-
} from '@open-mercato/shared/modules/widgets/extension-points'
|
|
6
2
|
|
|
7
3
|
export const BACKEND_RECORD_CURRENT_INJECTION_SPOT_ID: InjectionSpotId = 'backend:record:current'
|
|
8
4
|
export const BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID: InjectionSpotId = 'backend:layout:top'
|
|
@@ -22,33 +18,32 @@ export const GLOBAL_HEADER_STATUS_INDICATORS_INJECTION_SPOT_ID: InjectionSpotId
|
|
|
22
18
|
|
|
23
19
|
// Standardized pattern helpers
|
|
24
20
|
export const CrudFormInjectionSpots = {
|
|
25
|
-
base: (entityId: string): InjectionSpotId =>
|
|
26
|
-
beforeFields: (entityId: string): InjectionSpotId =>
|
|
27
|
-
afterFields: (entityId: string): InjectionSpotId =>
|
|
28
|
-
header: (entityId: string): InjectionSpotId =>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
fieldAfter: (entityId: string, fieldId: string): InjectionSpotId => crudFormExtensionSpotId(entityId, `field:${fieldId}:after`),
|
|
21
|
+
base: (entityId: string): InjectionSpotId => `crud-form:${entityId}`,
|
|
22
|
+
beforeFields: (entityId: string): InjectionSpotId => `crud-form:${entityId}:before-fields`,
|
|
23
|
+
afterFields: (entityId: string): InjectionSpotId => `crud-form:${entityId}:after-fields`,
|
|
24
|
+
header: (entityId: string): InjectionSpotId => `crud-form:${entityId}:header`,
|
|
25
|
+
footer: (entityId: string): InjectionSpotId => `crud-form:${entityId}:footer`,
|
|
26
|
+
sidebar: (entityId: string): InjectionSpotId => `crud-form:${entityId}:sidebar`,
|
|
27
|
+
group: (entityId: string, groupId: string): InjectionSpotId => `crud-form:${entityId}:group:${groupId}`,
|
|
28
|
+
fieldBefore: (entityId: string, fieldId: string): InjectionSpotId => `crud-form:${entityId}:field:${fieldId}:before`,
|
|
29
|
+
fieldAfter: (entityId: string, fieldId: string): InjectionSpotId => `crud-form:${entityId}:field:${fieldId}:after`,
|
|
35
30
|
} as const
|
|
36
31
|
|
|
37
32
|
export const DataTableInjectionSpots = {
|
|
38
|
-
header: (tableId: string): InjectionSpotId =>
|
|
39
|
-
footer: (tableId: string): InjectionSpotId =>
|
|
40
|
-
toolbar: (tableId: string): InjectionSpotId =>
|
|
33
|
+
header: (tableId: string): InjectionSpotId => `data-table:${tableId}:header`,
|
|
34
|
+
footer: (tableId: string): InjectionSpotId => `data-table:${tableId}:footer`,
|
|
35
|
+
toolbar: (tableId: string): InjectionSpotId => `data-table:${tableId}:toolbar`,
|
|
41
36
|
// Slot rendered immediately after the search input on the same row as the
|
|
42
37
|
// FilterBar — intended for compact, icon-sized triggers (AI assistants,
|
|
43
38
|
// saved view shortcuts, etc.). Hosts pass the resolved spot ID through to
|
|
44
39
|
// FilterBar's `searchTrailing` prop. Stays empty when the table has no
|
|
45
40
|
// search input.
|
|
46
|
-
searchTrailing: (tableId: string): InjectionSpotId =>
|
|
47
|
-
emptyState: (tableId: string): InjectionSpotId =>
|
|
48
|
-
columns: (tableId: string): InjectionSpotId =>
|
|
49
|
-
rowActions: (tableId: string): InjectionSpotId =>
|
|
50
|
-
bulkActions: (tableId: string): InjectionSpotId =>
|
|
51
|
-
filters: (tableId: string): InjectionSpotId =>
|
|
41
|
+
searchTrailing: (tableId: string): InjectionSpotId => `data-table:${tableId}:search-trailing`,
|
|
42
|
+
emptyState: (tableId: string): InjectionSpotId => `data-table:${tableId}:empty-state`,
|
|
43
|
+
columns: (tableId: string): InjectionSpotId => `data-table:${tableId}:columns`,
|
|
44
|
+
rowActions: (tableId: string): InjectionSpotId => `data-table:${tableId}:row-actions`,
|
|
45
|
+
bulkActions: (tableId: string): InjectionSpotId => `data-table:${tableId}:bulk-actions`,
|
|
46
|
+
filters: (tableId: string): InjectionSpotId => `data-table:${tableId}:filters`,
|
|
52
47
|
} as const
|
|
53
48
|
|
|
54
49
|
// Portal chrome spot IDs (FROZEN once shipped)
|
|
@@ -57,9 +57,9 @@ function getStatusLabel(status: ScheduleItem['status'], t: (key: string, fallbac
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function getKindStyles(kind: ScheduleItem['kind']): string {
|
|
60
|
-
if (kind === 'event') return 'border-
|
|
61
|
-
if (kind === 'exception') return 'border-
|
|
62
|
-
return 'border-
|
|
60
|
+
if (kind === 'event') return 'border-blue-500/40 bg-blue-500/10 text-blue-950'
|
|
61
|
+
if (kind === 'exception') return 'border-amber-500/40 bg-amber-500/10 text-amber-950'
|
|
62
|
+
return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-950'
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export type ScheduleAgendaProps = {
|
|
@@ -57,9 +57,9 @@ function getStatusLabel(status: ScheduleItem['status'], t: (key: string, fallbac
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function getKindStyles(kind: ScheduleItem['kind']): string {
|
|
60
|
-
if (kind === 'event') return 'border-
|
|
61
|
-
if (kind === 'exception') return 'border-
|
|
62
|
-
return 'border-
|
|
60
|
+
if (kind === 'event') return 'border-blue-500/40 bg-blue-500/10 text-blue-950'
|
|
61
|
+
if (kind === 'exception') return 'border-amber-500/40 bg-amber-500/10 text-amber-950'
|
|
62
|
+
return 'border-emerald-500/40 bg-emerald-500/10 text-emerald-950'
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export type ScheduleGridProps = {
|
|
@@ -1,19 +1,8 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
SegmentedControl,
|
|
8
|
-
SegmentedControlItem,
|
|
9
|
-
} from '../../primitives/segmented-control'
|
|
10
|
-
import {
|
|
11
|
-
Select,
|
|
12
|
-
SelectContent,
|
|
13
|
-
SelectItem,
|
|
14
|
-
SelectTrigger,
|
|
15
|
-
SelectValue,
|
|
16
|
-
} from '../../primitives/select'
|
|
4
|
+
import { Button } from '../../primitives/button'
|
|
5
|
+
import { Input } from '../../primitives/input'
|
|
17
6
|
import type { ScheduleRange, ScheduleViewMode } from './types'
|
|
18
7
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
19
8
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -38,19 +27,17 @@ const VIEW_OPTIONS: Array<{ id: ScheduleViewMode; labelKey: string; fallback: st
|
|
|
38
27
|
{ id: 'agenda', labelKey: 'schedule.view.agenda', fallback: 'Agenda' },
|
|
39
28
|
]
|
|
40
29
|
|
|
41
|
-
function
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
return Array.from(options).sort((a, b) => a.localeCompare(b))
|
|
30
|
+
function formatDateInputValue(value: Date): string {
|
|
31
|
+
const year = value.getFullYear()
|
|
32
|
+
const month = String(value.getMonth() + 1).padStart(2, '0')
|
|
33
|
+
const day = String(value.getDate()).padStart(2, '0')
|
|
34
|
+
return `${year}-${month}-${day}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseDateInputValue(value: string, fallback: Date): Date {
|
|
38
|
+
if (!value) return fallback
|
|
39
|
+
const next = new Date(`${value}T00:00:00`)
|
|
40
|
+
return Number.isNaN(next.getTime()) ? fallback : next
|
|
54
41
|
}
|
|
55
42
|
|
|
56
43
|
export type ScheduleToolbarProps = {
|
|
@@ -133,76 +120,68 @@ export function ScheduleToolbar({
|
|
|
133
120
|
onRangeChange({ start: nextStart, end: endOfDay(addDays(nextStart, rangeLength - 1)) })
|
|
134
121
|
}, [onRangeChange, range.start, rangeLength, view])
|
|
135
122
|
|
|
136
|
-
const timezoneOptions = React.useMemo(() => getTimezoneOptions(timezone), [timezone])
|
|
137
|
-
|
|
138
123
|
return (
|
|
139
|
-
<div className={cn('flex flex-
|
|
140
|
-
<
|
|
141
|
-
value={view}
|
|
142
|
-
onValueChange={(value) => {
|
|
143
|
-
const nextView = value as ScheduleViewMode
|
|
144
|
-
if (nextView === view) return
|
|
145
|
-
onViewChange(nextView)
|
|
146
|
-
onRangeChange(deriveRangeForView(new Date(), nextView))
|
|
147
|
-
}}
|
|
148
|
-
aria-label={t('schedule.view.label', 'Schedule view')}
|
|
149
|
-
className="shrink-0"
|
|
150
|
-
>
|
|
124
|
+
<div className={cn('flex flex-col gap-3 rounded-xl border bg-card p-4 md:flex-row md:items-center md:justify-between', className)}>
|
|
125
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
151
126
|
{VIEW_OPTIONS.map((option) => (
|
|
152
|
-
<
|
|
127
|
+
<Button
|
|
128
|
+
key={option.id}
|
|
129
|
+
variant={view === option.id ? 'default' : 'outline'}
|
|
130
|
+
size="sm"
|
|
131
|
+
onClick={() => {
|
|
132
|
+
if (option.id === view) return
|
|
133
|
+
onViewChange(option.id)
|
|
134
|
+
onRangeChange(deriveRangeForView(new Date(), option.id))
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
153
137
|
{t(option.labelKey, option.fallback)}
|
|
154
|
-
</
|
|
138
|
+
</Button>
|
|
155
139
|
))}
|
|
156
|
-
</
|
|
157
|
-
<div className="flex
|
|
158
|
-
<
|
|
159
|
-
type="button"
|
|
160
|
-
variant="outline"
|
|
161
|
-
onClick={() => shiftRange('prev')}
|
|
162
|
-
aria-label={t('schedule.range.prev', 'Previous')}
|
|
163
|
-
>
|
|
140
|
+
</div>
|
|
141
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
142
|
+
<Button type="button" variant="outline" size="sm" onClick={() => shiftRange('prev')} aria-label={t('schedule.range.prev', 'Previous')}>
|
|
164
143
|
<ChevronLeft className="size-4" aria-hidden />
|
|
165
|
-
</
|
|
166
|
-
<div className="
|
|
167
|
-
<
|
|
168
|
-
type="button"
|
|
169
|
-
variant="outline"
|
|
170
|
-
onClick={() => shiftRange('next')}
|
|
171
|
-
aria-label={t('schedule.range.next', 'Next')}
|
|
172
|
-
>
|
|
144
|
+
</Button>
|
|
145
|
+
<div className="text-sm font-medium text-foreground">{rangeLabel}</div>
|
|
146
|
+
<Button type="button" variant="outline" size="sm" onClick={() => shiftRange('next')} aria-label={t('schedule.range.next', 'Next')}>
|
|
173
147
|
<ChevronRight className="size-4" aria-hidden />
|
|
174
|
-
</
|
|
148
|
+
</Button>
|
|
175
149
|
</div>
|
|
176
|
-
<div className="flex
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
150
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
151
|
+
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
152
|
+
<span>{t('schedule.range.start', 'Start')}</span>
|
|
153
|
+
<Input
|
|
154
|
+
type="date"
|
|
155
|
+
value={formatDateInputValue(range.start)}
|
|
156
|
+
onChange={(event) => {
|
|
157
|
+
const nextStart = parseDateInputValue(event.target.value, range.start)
|
|
158
|
+
onRangeChange({ start: nextStart, end: range.end })
|
|
159
|
+
}}
|
|
160
|
+
className="h-8 w-full sm:w-[140px]"
|
|
161
|
+
/>
|
|
162
|
+
</label>
|
|
163
|
+
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
164
|
+
<span>{t('schedule.range.end', 'End')}</span>
|
|
165
|
+
<Input
|
|
166
|
+
type="date"
|
|
167
|
+
value={formatDateInputValue(range.end)}
|
|
168
|
+
onChange={(event) => {
|
|
169
|
+
const nextEnd = parseDateInputValue(event.target.value, range.end)
|
|
170
|
+
onRangeChange({ start: range.start, end: nextEnd })
|
|
171
|
+
}}
|
|
172
|
+
className="h-8 w-full sm:w-[140px]"
|
|
173
|
+
/>
|
|
174
|
+
</label>
|
|
175
|
+
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
176
|
+
<span>{t('schedule.range.timezone', 'Timezone')}</span>
|
|
177
|
+
<Input
|
|
178
|
+
type="text"
|
|
179
|
+
value={timezone ?? ''}
|
|
180
|
+
onChange={(event) => onTimezoneChange?.(event.target.value)}
|
|
181
|
+
className="h-8 w-full sm:w-[180px]"
|
|
182
|
+
placeholder={t('schedule.range.timezone.placeholder', 'UTC')}
|
|
183
|
+
/>
|
|
184
|
+
</label>
|
|
206
185
|
</div>
|
|
207
186
|
</div>
|
|
208
187
|
)
|
|
@@ -14,7 +14,6 @@ import { Switch } from '../../primitives/switch'
|
|
|
14
14
|
import { Card, CardContent, CardHeader, CardTitle } from '../../primitives/card'
|
|
15
15
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../../primitives/dialog'
|
|
16
16
|
import { Tag } from '../../primitives/tag'
|
|
17
|
-
import { Badge } from '../../primitives/badge'
|
|
18
17
|
import {
|
|
19
18
|
Select,
|
|
20
19
|
SelectContent,
|
|
@@ -32,14 +31,12 @@ import { useConfirmDialog } from '../confirm-dialog'
|
|
|
32
31
|
import { useGuardedMutation } from '../injection/useGuardedMutation'
|
|
33
32
|
import {
|
|
34
33
|
applyCustomizationDraft,
|
|
35
|
-
applyGroupHidden,
|
|
36
34
|
applyItemOrder,
|
|
37
35
|
cloneSidebarGroups,
|
|
38
36
|
collectSidebarDefaults,
|
|
39
37
|
filterMainSidebarGroups,
|
|
40
38
|
mergeGroupOrder,
|
|
41
39
|
resolveGroupKey,
|
|
42
|
-
resolveGroupVisibility,
|
|
43
40
|
resolveItemKey,
|
|
44
41
|
type SidebarCustomizationDraft,
|
|
45
42
|
type SidebarGroup,
|
|
@@ -551,19 +548,6 @@ export function SidebarCustomizationEditor({
|
|
|
551
548
|
})
|
|
552
549
|
}, [updateDraft])
|
|
553
550
|
|
|
554
|
-
/**
|
|
555
|
-
* Hiding a whole group previously meant toggling every item in it one at a time — a dozen-plus
|
|
556
|
-
* clicks for what reads as a single decision. Writes the same `hiddenItemIds` keys the per-item
|
|
557
|
-
* switches do, so the persisted settings are indistinguishable from reaching this state by hand.
|
|
558
|
-
*/
|
|
559
|
-
const setGroupHidden = React.useCallback((groupKey: string, hidden: boolean) => {
|
|
560
|
-
updateDraft((draft) => {
|
|
561
|
-
const group = baseSnapshotRef.current?.find((candidate) => resolveGroupKey(candidate) === groupKey)
|
|
562
|
-
if (!group) return draft
|
|
563
|
-
return { ...draft, hiddenItemIds: applyGroupHidden(draft.hiddenItemIds, group, hidden) }
|
|
564
|
-
})
|
|
565
|
-
}, [updateDraft])
|
|
566
|
-
|
|
567
551
|
const reset = React.useCallback(() => {
|
|
568
552
|
if (!baseSnapshotRef.current) return
|
|
569
553
|
if (selectedVariant) {
|
|
@@ -1238,30 +1222,7 @@ export function SidebarCustomizationEditor({
|
|
|
1238
1222
|
</p>
|
|
1239
1223
|
) : null}
|
|
1240
1224
|
</div>
|
|
1241
|
-
<div className="flex shrink-0 items-center gap-
|
|
1242
|
-
{(() => {
|
|
1243
|
-
const visibility = resolveGroupVisibility(baseGroup, draft.hiddenItemIds)
|
|
1244
|
-
const groupName = trimmedValue.length > 0 ? trimmedValue : placeholder
|
|
1245
|
-
const label = visibility === 'partial'
|
|
1246
|
-
? t('appShell.sidebarCustomizationHideGroupPartial', 'Some items in {group} are hidden — turn off to hide the whole group', { group: groupName })
|
|
1247
|
-
: t('appShell.sidebarCustomizationShowGroup', 'Show {group}', { group: groupName })
|
|
1248
|
-
return (
|
|
1249
|
-
<>
|
|
1250
|
-
{visibility === 'hidden' ? (
|
|
1251
|
-
<Badge variant="muted" size="sm" className="uppercase tracking-wide">
|
|
1252
|
-
{t('appShell.sidebarCustomizationHiddenBadge', 'Hidden')}
|
|
1253
|
-
</Badge>
|
|
1254
|
-
) : null}
|
|
1255
|
-
<Switch
|
|
1256
|
-
checked={visibility !== 'hidden'}
|
|
1257
|
-
onCheckedChange={(next) => setGroupHidden(groupId, next !== true)}
|
|
1258
|
-
disabled={isBusy}
|
|
1259
|
-
aria-label={label}
|
|
1260
|
-
title={label}
|
|
1261
|
-
/>
|
|
1262
|
-
</>
|
|
1263
|
-
)
|
|
1264
|
-
})()}
|
|
1225
|
+
<div className="flex shrink-0 items-center gap-1 mt-[26px]">
|
|
1265
1226
|
<IconButton
|
|
1266
1227
|
type="button"
|
|
1267
1228
|
variant="outline"
|
|
@@ -1388,9 +1349,9 @@ function ItemRow({ item, draft, saving, onLabelChange, onHiddenChange, t, depth,
|
|
|
1388
1349
|
</div>
|
|
1389
1350
|
<div className="flex shrink-0 items-center gap-2 pt-1.5">
|
|
1390
1351
|
{hidden ? (
|
|
1391
|
-
<
|
|
1352
|
+
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
1392
1353
|
{t('appShell.sidebarCustomizationHiddenBadge', 'Hidden')}
|
|
1393
|
-
</
|
|
1354
|
+
</span>
|
|
1394
1355
|
) : null}
|
|
1395
1356
|
<Switch
|
|
1396
1357
|
checked={!hidden}
|
|
@@ -156,53 +156,6 @@ export function applyCustomizationDraft(
|
|
|
156
156
|
return result
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
/** Every item key a group owns, including nested children. */
|
|
160
|
-
export function collectGroupItemKeys(group: SidebarGroup): string[] {
|
|
161
|
-
const keys: string[] = []
|
|
162
|
-
const walk = (items: SidebarItem[]) => {
|
|
163
|
-
for (const item of items) {
|
|
164
|
-
keys.push(resolveItemKey(item))
|
|
165
|
-
if (item.children?.length) walk(item.children)
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
walk(group.items)
|
|
169
|
-
return keys
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Whether a group is fully visible, fully hidden, or in between. `AppShell` drops a group once every
|
|
174
|
-
* one of its items is hidden, so `'hidden'` is the state that removes the group from the sidebar.
|
|
175
|
-
*/
|
|
176
|
-
export function resolveGroupVisibility(
|
|
177
|
-
group: SidebarGroup,
|
|
178
|
-
hiddenItemIds: Record<string, boolean>,
|
|
179
|
-
): 'visible' | 'hidden' | 'partial' {
|
|
180
|
-
const keys = collectGroupItemKeys(group)
|
|
181
|
-
if (keys.length === 0) return 'visible'
|
|
182
|
-
const hiddenCount = keys.filter((key) => hiddenItemIds[key]).length
|
|
183
|
-
if (hiddenCount === 0) return 'visible'
|
|
184
|
-
if (hiddenCount === keys.length) return 'hidden'
|
|
185
|
-
return 'partial'
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Hides or shows every item in a group at once. Writes the same `hiddenItemIds` keys the per-item
|
|
190
|
-
* switches use, so the resulting settings are indistinguishable from reaching this state by toggling
|
|
191
|
-
* each item by hand — no new persisted shape, no migration.
|
|
192
|
-
*/
|
|
193
|
-
export function applyGroupHidden(
|
|
194
|
-
hiddenItemIds: Record<string, boolean>,
|
|
195
|
-
group: SidebarGroup,
|
|
196
|
-
hidden: boolean,
|
|
197
|
-
): Record<string, boolean> {
|
|
198
|
-
const next = { ...hiddenItemIds }
|
|
199
|
-
for (const key of collectGroupItemKeys(group)) {
|
|
200
|
-
if (hidden) next[key] = true
|
|
201
|
-
else delete next[key]
|
|
202
|
-
}
|
|
203
|
-
return next
|
|
204
|
-
}
|
|
205
|
-
|
|
206
159
|
/**
|
|
207
160
|
* Filters groups to include only main sidebar items.
|
|
208
161
|
* Excludes items with pageContext 'settings' or 'profile' from customization.
|
|
@@ -149,6 +149,33 @@ describe('apiFetch', () => {
|
|
|
149
149
|
expect(flash).not.toHaveBeenCalled()
|
|
150
150
|
})
|
|
151
151
|
|
|
152
|
+
it('stays silent when a login-page 401 lands after the post-login navigation', async () => {
|
|
153
|
+
window.history.pushState({}, '', '/login')
|
|
154
|
+
;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () => {
|
|
155
|
+
// The sign-in completes while the pre-auth probe is still in flight, so the
|
|
156
|
+
// app has already client-navigated to /backend when the 401 arrives.
|
|
157
|
+
window.history.pushState({}, '', '/backend')
|
|
158
|
+
return createMockResponse(401, { error: 'Unauthorized' })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const result = await apiFetch('/api/auth/feature-check', { method: 'POST' })
|
|
162
|
+
|
|
163
|
+
expect(result.status).toBe(401)
|
|
164
|
+
expect(flash).not.toHaveBeenCalled()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('stays silent when a 401 lands after navigating to the login page', async () => {
|
|
168
|
+
;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () => {
|
|
169
|
+
window.history.pushState({}, '', '/login')
|
|
170
|
+
return createMockResponse(401, { error: 'Unauthorized' })
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const result = await apiFetch('/api/private')
|
|
174
|
+
|
|
175
|
+
expect(result.status).toBe(401)
|
|
176
|
+
expect(flash).not.toHaveBeenCalled()
|
|
177
|
+
})
|
|
178
|
+
|
|
152
179
|
it('throws UnauthorizedError for 401 responses by default', async () => {
|
|
153
180
|
;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () =>
|
|
154
181
|
createMockResponse(401, { error: 'Unauthorized' }),
|
|
@@ -107,64 +107,3 @@ describe('readApiResultOrThrow', () => {
|
|
|
107
107
|
expect(result).toBeNull()
|
|
108
108
|
})
|
|
109
109
|
})
|
|
110
|
-
|
|
111
|
-
describe('aborted requests', () => {
|
|
112
|
-
beforeEach(() => {
|
|
113
|
-
jest.resetAllMocks()
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
function createAbortedBodyResponse(): Response {
|
|
117
|
-
const abortError = new Error('The operation was aborted.')
|
|
118
|
-
abortError.name = 'AbortError'
|
|
119
|
-
const response = {
|
|
120
|
-
ok: true,
|
|
121
|
-
status: 200,
|
|
122
|
-
headers: new Map<string, string>(),
|
|
123
|
-
text: jest.fn(async () => { throw abortError }),
|
|
124
|
-
clone: () => response,
|
|
125
|
-
} as unknown as Response
|
|
126
|
-
return response
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
it('rejects with AbortError instead of reporting an empty payload', async () => {
|
|
130
|
-
;(apiFetch as jest.Mock).mockResolvedValue(createAbortedBodyResponse())
|
|
131
|
-
const controller = new AbortController()
|
|
132
|
-
controller.abort()
|
|
133
|
-
|
|
134
|
-
await expect(
|
|
135
|
-
readApiResultOrThrow('/api/interactions', { signal: controller.signal }),
|
|
136
|
-
).rejects.toMatchObject({ name: 'AbortError' })
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
it('does not mask a genuinely empty payload when the request was not aborted', async () => {
|
|
140
|
-
;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse('', { status: 200 }))
|
|
141
|
-
const controller = new AbortController()
|
|
142
|
-
|
|
143
|
-
await expect(
|
|
144
|
-
readApiResultOrThrow('/api/interactions', { signal: controller.signal }),
|
|
145
|
-
).rejects.toThrow('Missing response payload (200)')
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
it('keeps the caller-provided fallback when one is configured', async () => {
|
|
149
|
-
;(apiFetch as jest.Mock).mockResolvedValue(createAbortedBodyResponse())
|
|
150
|
-
const controller = new AbortController()
|
|
151
|
-
controller.abort()
|
|
152
|
-
|
|
153
|
-
const call = await apiCall<{ items: string[] }>(
|
|
154
|
-
'/api/interactions',
|
|
155
|
-
{ signal: controller.signal },
|
|
156
|
-
{ fallback: { items: [] } },
|
|
157
|
-
)
|
|
158
|
-
expect(call.result).toEqual({ items: [] })
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
it('propagates AbortError raised by a custom parser', async () => {
|
|
162
|
-
;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse('{}', { status: 200 }))
|
|
163
|
-
const abortError = new Error('The operation was aborted.')
|
|
164
|
-
abortError.name = 'AbortError'
|
|
165
|
-
|
|
166
|
-
await expect(
|
|
167
|
-
apiCall('/api/interactions', undefined, { parse: async () => { throw abortError } }),
|
|
168
|
-
).rejects.toMatchObject({ name: 'AbortError' })
|
|
169
|
-
})
|
|
170
|
-
})
|