@open-mercato/ui 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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 +4 -2
- package/agentic/standalone-guide.md +1 -1
- package/build.mjs +2 -54
- package/dist/ai/AiChat.js +26 -7
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/LoopDisabledBanner.js +1 -1
- package/dist/ai/LoopDisabledBanner.js.map +2 -2
- package/dist/ai/parts/ConfirmationCard.js +4 -4
- package/dist/ai/parts/ConfirmationCard.js.map +2 -2
- package/dist/ai/parts/FieldDiffCard.js +2 -2
- package/dist/ai/parts/FieldDiffCard.js.map +2 -2
- package/dist/ai/parts/MutationResultCard.js +3 -3
- package/dist/ai/parts/MutationResultCard.js.map +2 -2
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +1 -1
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
- package/dist/backend/AppShell.js +43 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/AuthSessionGuard.js +14 -0
- package/dist/backend/AuthSessionGuard.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +6 -1
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/CrudForm.js +6 -4
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +20 -18
- 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/PerspectiveSidebar.js +1 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +2 -1
- 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 +13 -1
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/crud/useGroupOrder.js +14 -21
- package/dist/backend/crud/useGroupOrder.js.map +2 -2
- package/dist/backend/custom-fields/FieldDefinitionsManager.js +1 -1
- package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +3 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/detail/AddressTiles.js +4 -5
- 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/filters/AdvancedFilterPanel.js +1 -1
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/backend/forms/FormHeader.js +1 -2
- package/dist/backend/forms/FormHeader.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +1 -25
- 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/icons/lucideRegistryRuntime.js +28 -0
- package/dist/backend/icons/lucideRegistryRuntime.js.map +7 -0
- package/dist/backend/injection/spotIds.js +23 -18
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js.map +1 -1
- package/dist/backend/messages/EmailThreadsPanel.js +1 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/perspectiveState.js +34 -0
- package/dist/backend/perspectiveState.js.map +7 -0
- 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 +87 -70
- package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/sidebar/customization-helpers.js +30 -0
- package/dist/backend/sidebar/customization-helpers.js.map +2 -2
- package/dist/backend/utils/apiCall.js +21 -1
- package/dist/backend/utils/apiCall.js.map +2 -2
- package/dist/backend/utils/customFieldDefs.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +10 -9
- package/dist/backend/utils/customFieldForms.js.map +2 -2
- package/dist/backend/utils/fetchAllPages.js +57 -0
- package/dist/backend/utils/fetchAllPages.js.map +7 -0
- package/dist/backend/utils/nav.js +5 -2
- package/dist/backend/utils/nav.js.map +2 -2
- package/dist/backend/version-history/VersionHistoryPanel.js +1 -1
- package/dist/backend/version-history/VersionHistoryPanel.js.map +2 -2
- package/dist/frontend/LanguageSwitcher.js +2 -1
- 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/portalBridgeStatus.js +25 -0
- package/dist/portal/hooks/portalBridgeStatus.js.map +7 -0
- package/dist/portal/hooks/usePortalEventBridge.js +6 -0
- package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
- package/dist/portal/hooks/usePortalNotifications.js +42 -4
- package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
- package/dist/portal/utils/nav.js +2 -3
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/ErrorNotice.js +1 -1
- package/dist/primitives/ErrorNotice.js.map +2 -2
- package/dist/primitives/Notice.js +1 -1
- package/dist/primitives/Notice.js.map +1 -1
- package/dist/primitives/button.js +9 -4
- package/dist/primitives/button.js.map +2 -2
- package/dist/theme/ThemeProvider.js +1 -1
- package/dist/theme/ThemeProvider.js.map +2 -2
- package/dist/theme/index.js +3 -0
- package/dist/theme/index.js.map +2 -2
- package/dist/theme/theme-init-script.js +17 -0
- package/dist/theme/theme-init-script.js.map +7 -0
- package/figma/alert.figma.tsx +35 -0
- package/figma/badge.figma.tsx +33 -0
- package/figma/button.figma.tsx +33 -0
- package/figma/checkbox.figma.tsx +28 -0
- package/figma/drawer.figma.tsx +40 -0
- package/figma/input-variants.figma.tsx +58 -0
- package/figma/radio.figma.tsx +16 -0
- package/figma/select.figma.tsx +24 -0
- package/figma/status-badge.figma.tsx +24 -0
- package/figma/switch.figma.tsx +28 -0
- package/figma/tabs.figma.tsx +22 -0
- package/figma/tag.figma.tsx +27 -0
- package/figma.config.json +6 -0
- package/jest.config.cjs +2 -1
- package/package.json +21 -18
- package/scripts/lucideRegistrySource.cjs +133 -0
- package/src/ai/AiChat.tsx +36 -7
- package/src/ai/LoopDisabledBanner.tsx +1 -1
- package/src/ai/__tests__/AiChat.test.tsx +31 -0
- package/src/ai/parts/ConfirmationCard.tsx +4 -4
- package/src/ai/parts/FieldDiffCard.tsx +2 -2
- package/src/ai/parts/MutationResultCard.tsx +3 -3
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +2 -2
- package/src/backend/AppShell.tsx +55 -4
- package/src/backend/AuthSessionGuard.tsx +29 -0
- package/src/backend/BackendChromeProvider.tsx +19 -1
- package/src/backend/CrudForm.tsx +18 -4
- package/src/backend/DataTable.tsx +36 -19
- package/src/backend/JsonBuilder.tsx +3 -3
- package/src/backend/NextStepCallout.tsx +4 -4
- package/src/backend/PerspectiveSidebar.tsx +1 -1
- package/src/backend/ProfileDropdown.tsx +1 -0
- package/src/backend/RowActions.tsx +2 -2
- package/src/backend/__tests__/AppShell.test.tsx +65 -7
- package/src/backend/__tests__/AuthSessionGuard.test.tsx +52 -0
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +98 -2
- package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +158 -0
- package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +98 -0
- package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +105 -0
- package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +62 -1
- package/src/backend/__tests__/nav-utils.test.ts +139 -0
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +16 -1
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +68 -1
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +39 -1
- package/src/backend/crud/useGroupOrder.ts +30 -25
- package/src/backend/custom-fields/FieldDefinitionsManager.tsx +1 -1
- package/src/backend/dashboard/DashboardScreen.tsx +3 -3
- package/src/backend/detail/AddressTiles.tsx +3 -6
- 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 +21 -0
- package/src/backend/filters/AdvancedFilterPanel.tsx +1 -1
- package/src/backend/forms/FormHeader.tsx +1 -2
- package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +177 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +3 -37
- package/src/backend/icons/lucideRegistry.ts +2 -2
- package/src/backend/icons/lucideRegistryRuntime.tsx +36 -0
- package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +38 -0
- package/src/backend/injection/spotIds.ts +23 -18
- package/src/backend/inputs/ComboboxInput.tsx +1 -1
- package/src/backend/messages/EmailThreadsPanel.tsx +1 -1
- package/src/backend/perspectiveState.ts +44 -0
- package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
- package/src/backend/schedule/ScheduleGrid.tsx +3 -3
- package/src/backend/schedule/ScheduleToolbar.tsx +89 -68
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +42 -3
- package/src/backend/sidebar/__tests__/group-visibility.test.ts +131 -0
- package/src/backend/sidebar/customization-helpers.ts +47 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +61 -0
- package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +95 -0
- package/src/backend/utils/__tests__/fetchAllPages.test.ts +103 -0
- package/src/backend/utils/apiCall.ts +29 -1
- package/src/backend/utils/customFieldDefs.ts +1 -0
- package/src/backend/utils/customFieldForms.ts +15 -11
- package/src/backend/utils/fetchAllPages.ts +93 -0
- package/src/backend/utils/nav.ts +22 -2
- package/src/backend/version-history/VersionHistoryPanel.tsx +1 -1
- package/src/frontend/LanguageSwitcher.tsx +1 -0
- package/src/portal/PortalContext.tsx +1 -1
- package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +66 -0
- package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +155 -0
- package/src/portal/hooks/portalBridgeStatus.ts +29 -0
- package/src/portal/hooks/usePortalEventBridge.ts +6 -0
- package/src/portal/hooks/usePortalNotifications.ts +55 -12
- package/src/portal/utils/__tests__/nav.test.ts +2 -2
- package/src/portal/utils/nav.ts +3 -4
- package/src/primitives/ErrorNotice.tsx +1 -1
- package/src/primitives/Notice.tsx +5 -5
- package/src/primitives/__tests__/button.test.tsx +70 -0
- package/src/primitives/button.tsx +10 -4
- package/src/theme/ThemeProvider.tsx +1 -2
- package/src/theme/__tests__/theme-init-script.test.ts +70 -0
- package/src/theme/index.ts +1 -0
- package/src/theme/theme-init-script.ts +23 -0
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { DateRangePicker } from '../../primitives/date-range-picker'
|
|
5
|
+
import { IconButton } from '../../primitives/icon-button'
|
|
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'
|
|
6
17
|
import type { ScheduleRange, ScheduleViewMode } from './types'
|
|
7
18
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
8
19
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -27,17 +38,19 @@ const VIEW_OPTIONS: Array<{ id: ScheduleViewMode; labelKey: string; fallback: st
|
|
|
27
38
|
{ id: 'agenda', labelKey: 'schedule.view.agenda', fallback: 'Agenda' },
|
|
28
39
|
]
|
|
29
40
|
|
|
30
|
-
function
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
function getTimezoneOptions(current?: string): string[] {
|
|
42
|
+
const options = new Set<string>()
|
|
43
|
+
if (current) options.add(current)
|
|
44
|
+
options.add('UTC')
|
|
45
|
+
const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
46
|
+
if (resolved) options.add(resolved)
|
|
47
|
+
const intlWithSupportedValues = Intl as typeof Intl & { supportedValuesOf?: (input: 'timeZone') => string[] }
|
|
48
|
+
if (typeof intlWithSupportedValues.supportedValuesOf === 'function') {
|
|
49
|
+
intlWithSupportedValues.supportedValuesOf('timeZone').forEach((timezone) => {
|
|
50
|
+
if (timezone) options.add(timezone)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
return Array.from(options).sort((a, b) => a.localeCompare(b))
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
export type ScheduleToolbarProps = {
|
|
@@ -120,68 +133,76 @@ export function ScheduleToolbar({
|
|
|
120
133
|
onRangeChange({ start: nextStart, end: endOfDay(addDays(nextStart, rangeLength - 1)) })
|
|
121
134
|
}, [onRangeChange, range.start, rangeLength, view])
|
|
122
135
|
|
|
136
|
+
const timezoneOptions = React.useMemo(() => getTimezoneOptions(timezone), [timezone])
|
|
137
|
+
|
|
123
138
|
return (
|
|
124
|
-
<div className={cn('flex flex-
|
|
125
|
-
<
|
|
139
|
+
<div className={cn('flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border bg-card p-3', className)}>
|
|
140
|
+
<SegmentedControl
|
|
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
|
+
>
|
|
126
151
|
{VIEW_OPTIONS.map((option) => (
|
|
127
|
-
<
|
|
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
|
-
>
|
|
152
|
+
<SegmentedControlItem key={option.id} value={option.id}>
|
|
137
153
|
{t(option.labelKey, option.fallback)}
|
|
138
|
-
</
|
|
154
|
+
</SegmentedControlItem>
|
|
139
155
|
))}
|
|
140
|
-
</
|
|
141
|
-
<div className="flex
|
|
142
|
-
<
|
|
156
|
+
</SegmentedControl>
|
|
157
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
158
|
+
<IconButton
|
|
159
|
+
type="button"
|
|
160
|
+
variant="outline"
|
|
161
|
+
onClick={() => shiftRange('prev')}
|
|
162
|
+
aria-label={t('schedule.range.prev', 'Previous')}
|
|
163
|
+
>
|
|
143
164
|
<ChevronLeft className="size-4" aria-hidden />
|
|
144
|
-
</
|
|
145
|
-
<div className="text-sm font-medium text-foreground">{rangeLabel}</div>
|
|
146
|
-
<
|
|
165
|
+
</IconButton>
|
|
166
|
+
<div className="min-w-0 whitespace-nowrap px-1 text-sm font-medium text-foreground">{rangeLabel}</div>
|
|
167
|
+
<IconButton
|
|
168
|
+
type="button"
|
|
169
|
+
variant="outline"
|
|
170
|
+
onClick={() => shiftRange('next')}
|
|
171
|
+
aria-label={t('schedule.range.next', 'Next')}
|
|
172
|
+
>
|
|
147
173
|
<ChevronRight className="size-4" aria-hidden />
|
|
148
|
-
</
|
|
174
|
+
</IconButton>
|
|
149
175
|
</div>
|
|
150
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
151
|
-
<
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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>
|
|
176
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 md:ml-auto">
|
|
177
|
+
<DateRangePicker
|
|
178
|
+
value={range}
|
|
179
|
+
onChange={(next) => {
|
|
180
|
+
if (!next) return
|
|
181
|
+
onRangeChange({ start: startOfDay(next.start), end: endOfDay(next.end) })
|
|
182
|
+
}}
|
|
183
|
+
size="sm"
|
|
184
|
+
showPresets={false}
|
|
185
|
+
numberOfMonths={2}
|
|
186
|
+
aria-label={t('schedule.range.label', 'Date range')}
|
|
187
|
+
/>
|
|
188
|
+
{onTimezoneChange ? (
|
|
189
|
+
<Select value={timezone ?? undefined} onValueChange={onTimezoneChange}>
|
|
190
|
+
<SelectTrigger
|
|
191
|
+
size="sm"
|
|
192
|
+
className="w-auto min-w-[10rem]"
|
|
193
|
+
aria-label={t('schedule.range.timezone', 'Timezone')}
|
|
194
|
+
>
|
|
195
|
+
<SelectValue placeholder={t('schedule.range.timezone.placeholder', 'UTC')} />
|
|
196
|
+
</SelectTrigger>
|
|
197
|
+
<SelectContent>
|
|
198
|
+
{timezoneOptions.map((option) => (
|
|
199
|
+
<SelectItem key={option} value={option}>
|
|
200
|
+
{option}
|
|
201
|
+
</SelectItem>
|
|
202
|
+
))}
|
|
203
|
+
</SelectContent>
|
|
204
|
+
</Select>
|
|
205
|
+
) : null}
|
|
185
206
|
</div>
|
|
186
207
|
</div>
|
|
187
208
|
)
|
|
@@ -14,6 +14,7 @@ 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'
|
|
17
18
|
import {
|
|
18
19
|
Select,
|
|
19
20
|
SelectContent,
|
|
@@ -31,12 +32,14 @@ import { useConfirmDialog } from '../confirm-dialog'
|
|
|
31
32
|
import { useGuardedMutation } from '../injection/useGuardedMutation'
|
|
32
33
|
import {
|
|
33
34
|
applyCustomizationDraft,
|
|
35
|
+
applyGroupHidden,
|
|
34
36
|
applyItemOrder,
|
|
35
37
|
cloneSidebarGroups,
|
|
36
38
|
collectSidebarDefaults,
|
|
37
39
|
filterMainSidebarGroups,
|
|
38
40
|
mergeGroupOrder,
|
|
39
41
|
resolveGroupKey,
|
|
42
|
+
resolveGroupVisibility,
|
|
40
43
|
resolveItemKey,
|
|
41
44
|
type SidebarCustomizationDraft,
|
|
42
45
|
type SidebarGroup,
|
|
@@ -548,6 +551,19 @@ export function SidebarCustomizationEditor({
|
|
|
548
551
|
})
|
|
549
552
|
}, [updateDraft])
|
|
550
553
|
|
|
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
|
+
|
|
551
567
|
const reset = React.useCallback(() => {
|
|
552
568
|
if (!baseSnapshotRef.current) return
|
|
553
569
|
if (selectedVariant) {
|
|
@@ -1222,7 +1238,30 @@ export function SidebarCustomizationEditor({
|
|
|
1222
1238
|
</p>
|
|
1223
1239
|
) : null}
|
|
1224
1240
|
</div>
|
|
1225
|
-
<div className="flex shrink-0 items-center gap-
|
|
1241
|
+
<div className="flex shrink-0 items-center gap-2 mt-6">
|
|
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
|
+
})()}
|
|
1226
1265
|
<IconButton
|
|
1227
1266
|
type="button"
|
|
1228
1267
|
variant="outline"
|
|
@@ -1349,9 +1388,9 @@ function ItemRow({ item, draft, saving, onLabelChange, onHiddenChange, t, depth,
|
|
|
1349
1388
|
</div>
|
|
1350
1389
|
<div className="flex shrink-0 items-center gap-2 pt-1.5">
|
|
1351
1390
|
{hidden ? (
|
|
1352
|
-
<
|
|
1391
|
+
<Badge variant="muted" size="sm" className="uppercase tracking-wide">
|
|
1353
1392
|
{t('appShell.sidebarCustomizationHiddenBadge', 'Hidden')}
|
|
1354
|
-
</
|
|
1393
|
+
</Badge>
|
|
1355
1394
|
) : null}
|
|
1356
1395
|
<Switch
|
|
1357
1396
|
checked={!hidden}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyGroupHidden,
|
|
3
|
+
collectGroupItemKeys,
|
|
4
|
+
resolveGroupVisibility,
|
|
5
|
+
resolveItemKey,
|
|
6
|
+
type SidebarGroup,
|
|
7
|
+
} from '../customization-helpers'
|
|
8
|
+
|
|
9
|
+
// Hiding a whole group used to require toggling every item in it individually — `AppShell` drops a
|
|
10
|
+
// group only once *all* of its items are hidden. These cases cover the bulk toggle that does it in one
|
|
11
|
+
// action, and pin the invariant that matters for compatibility: it writes the same `hiddenItemIds`
|
|
12
|
+
// keys the per-item switches write, and nothing else.
|
|
13
|
+
|
|
14
|
+
const group: SidebarGroup = {
|
|
15
|
+
id: 'catalog.nav.group',
|
|
16
|
+
name: 'Catalog',
|
|
17
|
+
items: [
|
|
18
|
+
{ href: '/backend/catalog/products', title: 'Products' },
|
|
19
|
+
{
|
|
20
|
+
href: '/backend/catalog/categories',
|
|
21
|
+
title: 'Categories',
|
|
22
|
+
children: [
|
|
23
|
+
{ href: '/backend/catalog/categories/tree', title: 'Tree' },
|
|
24
|
+
{ href: '/backend/catalog/categories/import', title: 'Import' },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ALL_KEYS = [
|
|
31
|
+
'/backend/catalog/products',
|
|
32
|
+
'/backend/catalog/categories',
|
|
33
|
+
'/backend/catalog/categories/tree',
|
|
34
|
+
'/backend/catalog/categories/import',
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
describe('collectGroupItemKeys', () => {
|
|
38
|
+
it('includes top-level items and every nested descendant', () => {
|
|
39
|
+
expect(collectGroupItemKeys(group).sort()).toEqual([...ALL_KEYS].sort())
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns nothing for an empty group', () => {
|
|
43
|
+
expect(collectGroupItemKeys({ id: 'empty', name: 'Empty', items: [] })).toEqual([])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('prefers an explicit item id over the href, matching resolveItemKey', () => {
|
|
47
|
+
const withIds: SidebarGroup = {
|
|
48
|
+
id: 'g',
|
|
49
|
+
name: 'G',
|
|
50
|
+
items: [{ id: 'custom-id', href: '/backend/thing', title: 'Thing' }],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
expect(collectGroupItemKeys(withIds)).toEqual([resolveItemKey(withIds.items[0])])
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('resolveGroupVisibility', () => {
|
|
58
|
+
it('is visible when nothing is hidden', () => {
|
|
59
|
+
expect(resolveGroupVisibility(group, {})).toBe('visible')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('is hidden only when every item including descendants is hidden', () => {
|
|
63
|
+
const allHidden = Object.fromEntries(ALL_KEYS.map((key) => [key, true]))
|
|
64
|
+
|
|
65
|
+
expect(resolveGroupVisibility(group, allHidden)).toBe('hidden')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('is partial when some but not all items are hidden', () => {
|
|
69
|
+
expect(resolveGroupVisibility(group, { '/backend/catalog/products': true })).toBe('partial')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('is partial when only a nested descendant is left visible', () => {
|
|
73
|
+
const allButOne = Object.fromEntries(
|
|
74
|
+
ALL_KEYS.filter((key) => key !== '/backend/catalog/categories/import').map((key) => [key, true]),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
expect(resolveGroupVisibility(group, allButOne)).toBe('partial')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('treats an empty group as visible rather than hidden', () => {
|
|
81
|
+
expect(resolveGroupVisibility({ id: 'empty', name: 'Empty', items: [] }, {})).toBe('visible')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('applyGroupHidden', () => {
|
|
86
|
+
it('hides every item in the group, descendants included', () => {
|
|
87
|
+
const next = applyGroupHidden({}, group, true)
|
|
88
|
+
|
|
89
|
+
expect(Object.keys(next).sort()).toEqual([...ALL_KEYS].sort())
|
|
90
|
+
expect(resolveGroupVisibility(group, next)).toBe('hidden')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('clears every item in the group when shown', () => {
|
|
94
|
+
const hidden = applyGroupHidden({}, group, true)
|
|
95
|
+
const next = applyGroupHidden(hidden, group, false)
|
|
96
|
+
|
|
97
|
+
expect(next).toEqual({})
|
|
98
|
+
expect(resolveGroupVisibility(group, next)).toBe('visible')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('leaves keys belonging to other groups untouched', () => {
|
|
102
|
+
const other = { '/backend/sales/orders': true }
|
|
103
|
+
|
|
104
|
+
expect(applyGroupHidden(other, group, true)['/backend/sales/orders']).toBe(true)
|
|
105
|
+
expect(applyGroupHidden(other, group, false)).toEqual(other)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('does not mutate the input map', () => {
|
|
109
|
+
const input = { '/backend/sales/orders': true }
|
|
110
|
+
const snapshot = { ...input }
|
|
111
|
+
|
|
112
|
+
applyGroupHidden(input, group, true)
|
|
113
|
+
|
|
114
|
+
expect(input).toEqual(snapshot)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('reaches the same state as toggling each item by hand', () => {
|
|
118
|
+
const byHand = ALL_KEYS.reduce<Record<string, boolean>>((acc, key) => {
|
|
119
|
+
acc[key] = true
|
|
120
|
+
return acc
|
|
121
|
+
}, {})
|
|
122
|
+
|
|
123
|
+
expect(applyGroupHidden({}, group, true)).toEqual(byHand)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('is idempotent', () => {
|
|
127
|
+
const once = applyGroupHidden({}, group, true)
|
|
128
|
+
|
|
129
|
+
expect(applyGroupHidden(once, group, true)).toEqual(once)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -156,6 +156,53 @@ 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
|
+
|
|
159
206
|
/**
|
|
160
207
|
* Filters groups to include only main sidebar items.
|
|
161
208
|
* Excludes items with pageContext 'settings' or 'profile' from customization.
|
|
@@ -107,3 +107,64 @@ 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
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
const apiCall = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.mock('../apiCall', () => ({
|
|
6
|
+
apiCall: (...args: unknown[]) => apiCall(...args),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
jest.mock('../../fields/registry', () => ({
|
|
10
|
+
FieldRegistry: { getInput: () => null },
|
|
11
|
+
loadGeneratedFieldRegistrations: async () => {},
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
import { buildFormFieldFromCustomFieldDef } from '../customFieldForms'
|
|
15
|
+
|
|
16
|
+
const okPage = (payload: Record<string, unknown>) => ({ ok: true, result: payload })
|
|
17
|
+
|
|
18
|
+
const requestedUrls = () => apiCall.mock.calls.map(([url]) => url as string)
|
|
19
|
+
|
|
20
|
+
const loadOptionsFor = (def: Parameters<typeof buildFormFieldFromCustomFieldDef>[0]) => {
|
|
21
|
+
const field = buildFormFieldFromCustomFieldDef(def)
|
|
22
|
+
const loadOptions = (field as { loadOptions?: (query?: string) => Promise<Array<{ value: string; label: string }>> })
|
|
23
|
+
.loadOptions
|
|
24
|
+
if (!loadOptions) throw new Error('[internal] expected the field to expose loadOptions')
|
|
25
|
+
return loadOptions
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Guards the review finding on #4175: the entries route caps rows per response,
|
|
30
|
+
* so the generic `optionsUrl` loader behind every select/currency/relation and
|
|
31
|
+
* multi-dictionary custom field must page instead of dropping everything past
|
|
32
|
+
* the first page.
|
|
33
|
+
*/
|
|
34
|
+
describe('custom field remote options paging', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
apiCall.mockReset()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('walks every page of a dictionary-backed multi-select', async () => {
|
|
40
|
+
apiCall
|
|
41
|
+
.mockResolvedValueOnce(
|
|
42
|
+
okPage({ items: [{ value: 'a', label: 'Alpha' }], total: 2, hasMore: true }),
|
|
43
|
+
)
|
|
44
|
+
.mockResolvedValueOnce(
|
|
45
|
+
okPage({ items: [{ value: 'b', label: 'Beta' }], total: 2, hasMore: false }),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const options = await loadOptionsFor({
|
|
49
|
+
key: 'tags',
|
|
50
|
+
kind: 'dictionary',
|
|
51
|
+
multi: true,
|
|
52
|
+
dictionaryId: 'dict-1',
|
|
53
|
+
})()
|
|
54
|
+
|
|
55
|
+
expect(requestedUrls()).toEqual([
|
|
56
|
+
'/api/dictionaries/dict-1/entries',
|
|
57
|
+
'/api/dictionaries/dict-1/entries?offset=1',
|
|
58
|
+
])
|
|
59
|
+
expect(options).toEqual([
|
|
60
|
+
{ value: 'a', label: 'Alpha' },
|
|
61
|
+
{ value: 'b', label: 'Beta' },
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('keeps the typeahead query on every page it requests', async () => {
|
|
66
|
+
apiCall
|
|
67
|
+
.mockResolvedValueOnce(okPage({ items: [{ value: 'a', label: 'Alpha' }], hasMore: true }))
|
|
68
|
+
.mockResolvedValueOnce(okPage({ items: [{ value: 'ab', label: 'Able' }], hasMore: false }))
|
|
69
|
+
|
|
70
|
+
await loadOptionsFor({ key: 'ref', kind: 'select', optionsUrl: '/api/refs' })('al')
|
|
71
|
+
|
|
72
|
+
expect(requestedUrls()[0]).toContain('query=al')
|
|
73
|
+
expect(requestedUrls()[1]).toContain('query=al')
|
|
74
|
+
expect(requestedUrls()[1]).toContain('offset=1')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('issues one request for a route that does not report hasMore', async () => {
|
|
78
|
+
apiCall.mockResolvedValueOnce(okPage({ items: [{ value: 'usd', label: 'USD' }] }))
|
|
79
|
+
|
|
80
|
+
const options = await loadOptionsFor({ key: 'ref', kind: 'select', optionsUrl: '/api/refs' })()
|
|
81
|
+
|
|
82
|
+
expect(apiCall).toHaveBeenCalledTimes(1)
|
|
83
|
+
expect(options).toEqual([{ value: 'usd', label: 'USD' }])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('returns no options when a page fails rather than a partial list', async () => {
|
|
87
|
+
apiCall
|
|
88
|
+
.mockResolvedValueOnce(okPage({ items: [{ value: 'a', label: 'Alpha' }], hasMore: true }))
|
|
89
|
+
.mockResolvedValueOnce({ ok: false, result: { error: 'Forbidden' } })
|
|
90
|
+
|
|
91
|
+
const options = await loadOptionsFor({ key: 'ref', kind: 'select', optionsUrl: '/api/refs' })()
|
|
92
|
+
|
|
93
|
+
expect(options).toEqual([])
|
|
94
|
+
})
|
|
95
|
+
})
|