@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.
Files changed (214) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -2
  3. package/agentic/standalone-guide.md +1 -1
  4. package/build.mjs +2 -54
  5. package/dist/ai/AiChat.js +26 -7
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/LoopDisabledBanner.js +1 -1
  8. package/dist/ai/LoopDisabledBanner.js.map +2 -2
  9. package/dist/ai/parts/ConfirmationCard.js +4 -4
  10. package/dist/ai/parts/ConfirmationCard.js.map +2 -2
  11. package/dist/ai/parts/FieldDiffCard.js +2 -2
  12. package/dist/ai/parts/FieldDiffCard.js.map +2 -2
  13. package/dist/ai/parts/MutationResultCard.js +3 -3
  14. package/dist/ai/parts/MutationResultCard.js.map +2 -2
  15. package/dist/ai/ui-parts/pending-phase3-placeholder.js +1 -1
  16. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +2 -2
  17. package/dist/backend/AppShell.js +43 -4
  18. package/dist/backend/AppShell.js.map +2 -2
  19. package/dist/backend/AuthSessionGuard.js +14 -0
  20. package/dist/backend/AuthSessionGuard.js.map +2 -2
  21. package/dist/backend/BackendChromeProvider.js +6 -1
  22. package/dist/backend/BackendChromeProvider.js.map +2 -2
  23. package/dist/backend/CrudForm.js +6 -4
  24. package/dist/backend/CrudForm.js.map +2 -2
  25. package/dist/backend/DataTable.js +20 -18
  26. package/dist/backend/DataTable.js.map +2 -2
  27. package/dist/backend/JsonBuilder.js +3 -3
  28. package/dist/backend/JsonBuilder.js.map +2 -2
  29. package/dist/backend/NextStepCallout.js +4 -4
  30. package/dist/backend/NextStepCallout.js.map +1 -1
  31. package/dist/backend/PerspectiveSidebar.js +1 -1
  32. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  33. package/dist/backend/ProfileDropdown.js +2 -1
  34. package/dist/backend/ProfileDropdown.js.map +2 -2
  35. package/dist/backend/RowActions.js +2 -2
  36. package/dist/backend/RowActions.js.map +2 -2
  37. package/dist/backend/confirm-dialog/ConfirmDialog.js +13 -1
  38. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  39. package/dist/backend/crud/useGroupOrder.js +14 -21
  40. package/dist/backend/crud/useGroupOrder.js.map +2 -2
  41. package/dist/backend/custom-fields/FieldDefinitionsManager.js +1 -1
  42. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +2 -2
  43. package/dist/backend/dashboard/DashboardScreen.js +3 -3
  44. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  45. package/dist/backend/detail/AddressTiles.js +4 -5
  46. package/dist/backend/detail/AddressTiles.js.map +2 -2
  47. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  48. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  49. package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
  50. package/dist/backend/detail/AttachmentsSection.js +1 -1
  51. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  52. package/dist/backend/detail/NotesSection.js +1 -1
  53. package/dist/backend/detail/NotesSection.js.map +2 -2
  54. package/dist/backend/detail/TagsSection.js +1 -1
  55. package/dist/backend/detail/TagsSection.js.map +2 -2
  56. package/dist/backend/filters/AdvancedFilterPanel.js +1 -1
  57. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  58. package/dist/backend/forms/FormHeader.js +1 -2
  59. package/dist/backend/forms/FormHeader.js.map +2 -2
  60. package/dist/backend/icons/lucideRegistry.generated.js +1 -25
  61. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  62. package/dist/backend/icons/lucideRegistry.js +3 -3
  63. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  64. package/dist/backend/icons/lucideRegistryRuntime.js +28 -0
  65. package/dist/backend/icons/lucideRegistryRuntime.js.map +7 -0
  66. package/dist/backend/injection/spotIds.js +23 -18
  67. package/dist/backend/injection/spotIds.js.map +2 -2
  68. package/dist/backend/inputs/ComboboxInput.js.map +1 -1
  69. package/dist/backend/messages/EmailThreadsPanel.js +1 -1
  70. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  71. package/dist/backend/perspectiveState.js +34 -0
  72. package/dist/backend/perspectiveState.js.map +7 -0
  73. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  74. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  75. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  76. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  77. package/dist/backend/schedule/ScheduleToolbar.js +87 -70
  78. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  79. package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -2
  80. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  81. package/dist/backend/sidebar/customization-helpers.js +30 -0
  82. package/dist/backend/sidebar/customization-helpers.js.map +2 -2
  83. package/dist/backend/utils/apiCall.js +21 -1
  84. package/dist/backend/utils/apiCall.js.map +2 -2
  85. package/dist/backend/utils/customFieldDefs.js.map +2 -2
  86. package/dist/backend/utils/customFieldForms.js +10 -9
  87. package/dist/backend/utils/customFieldForms.js.map +2 -2
  88. package/dist/backend/utils/fetchAllPages.js +57 -0
  89. package/dist/backend/utils/fetchAllPages.js.map +7 -0
  90. package/dist/backend/utils/nav.js +5 -2
  91. package/dist/backend/utils/nav.js.map +2 -2
  92. package/dist/backend/version-history/VersionHistoryPanel.js +1 -1
  93. package/dist/backend/version-history/VersionHistoryPanel.js.map +2 -2
  94. package/dist/frontend/LanguageSwitcher.js +2 -1
  95. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  96. package/dist/portal/PortalContext.js +1 -1
  97. package/dist/portal/PortalContext.js.map +2 -2
  98. package/dist/portal/hooks/portalBridgeStatus.js +25 -0
  99. package/dist/portal/hooks/portalBridgeStatus.js.map +7 -0
  100. package/dist/portal/hooks/usePortalEventBridge.js +6 -0
  101. package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
  102. package/dist/portal/hooks/usePortalNotifications.js +42 -4
  103. package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
  104. package/dist/portal/utils/nav.js +2 -3
  105. package/dist/portal/utils/nav.js.map +2 -2
  106. package/dist/primitives/ErrorNotice.js +1 -1
  107. package/dist/primitives/ErrorNotice.js.map +2 -2
  108. package/dist/primitives/Notice.js +1 -1
  109. package/dist/primitives/Notice.js.map +1 -1
  110. package/dist/primitives/button.js +9 -4
  111. package/dist/primitives/button.js.map +2 -2
  112. package/dist/theme/ThemeProvider.js +1 -1
  113. package/dist/theme/ThemeProvider.js.map +2 -2
  114. package/dist/theme/index.js +3 -0
  115. package/dist/theme/index.js.map +2 -2
  116. package/dist/theme/theme-init-script.js +17 -0
  117. package/dist/theme/theme-init-script.js.map +7 -0
  118. package/figma/alert.figma.tsx +35 -0
  119. package/figma/badge.figma.tsx +33 -0
  120. package/figma/button.figma.tsx +33 -0
  121. package/figma/checkbox.figma.tsx +28 -0
  122. package/figma/drawer.figma.tsx +40 -0
  123. package/figma/input-variants.figma.tsx +58 -0
  124. package/figma/radio.figma.tsx +16 -0
  125. package/figma/select.figma.tsx +24 -0
  126. package/figma/status-badge.figma.tsx +24 -0
  127. package/figma/switch.figma.tsx +28 -0
  128. package/figma/tabs.figma.tsx +22 -0
  129. package/figma/tag.figma.tsx +27 -0
  130. package/figma.config.json +6 -0
  131. package/jest.config.cjs +2 -1
  132. package/package.json +21 -18
  133. package/scripts/lucideRegistrySource.cjs +133 -0
  134. package/src/ai/AiChat.tsx +36 -7
  135. package/src/ai/LoopDisabledBanner.tsx +1 -1
  136. package/src/ai/__tests__/AiChat.test.tsx +31 -0
  137. package/src/ai/parts/ConfirmationCard.tsx +4 -4
  138. package/src/ai/parts/FieldDiffCard.tsx +2 -2
  139. package/src/ai/parts/MutationResultCard.tsx +3 -3
  140. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +2 -2
  141. package/src/backend/AppShell.tsx +55 -4
  142. package/src/backend/AuthSessionGuard.tsx +29 -0
  143. package/src/backend/BackendChromeProvider.tsx +19 -1
  144. package/src/backend/CrudForm.tsx +18 -4
  145. package/src/backend/DataTable.tsx +36 -19
  146. package/src/backend/JsonBuilder.tsx +3 -3
  147. package/src/backend/NextStepCallout.tsx +4 -4
  148. package/src/backend/PerspectiveSidebar.tsx +1 -1
  149. package/src/backend/ProfileDropdown.tsx +1 -0
  150. package/src/backend/RowActions.tsx +2 -2
  151. package/src/backend/__tests__/AppShell.test.tsx +65 -7
  152. package/src/backend/__tests__/AuthSessionGuard.test.tsx +52 -0
  153. package/src/backend/__tests__/BackendChromeProvider.test.tsx +98 -2
  154. package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +158 -0
  155. package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +98 -0
  156. package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +105 -0
  157. package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +62 -1
  158. package/src/backend/__tests__/nav-utils.test.ts +139 -0
  159. package/src/backend/confirm-dialog/ConfirmDialog.tsx +16 -1
  160. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +68 -1
  161. package/src/backend/crud/__tests__/useGroupOrder.test.ts +39 -1
  162. package/src/backend/crud/useGroupOrder.ts +30 -25
  163. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +1 -1
  164. package/src/backend/dashboard/DashboardScreen.tsx +3 -3
  165. package/src/backend/detail/AddressTiles.tsx +3 -6
  166. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  167. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  168. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  169. package/src/backend/detail/NotesSection.tsx +1 -1
  170. package/src/backend/detail/TagsSection.tsx +1 -1
  171. package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +21 -0
  172. package/src/backend/filters/AdvancedFilterPanel.tsx +1 -1
  173. package/src/backend/forms/FormHeader.tsx +1 -2
  174. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +177 -0
  175. package/src/backend/icons/lucideRegistry.generated.tsx +3 -37
  176. package/src/backend/icons/lucideRegistry.ts +2 -2
  177. package/src/backend/icons/lucideRegistryRuntime.tsx +36 -0
  178. package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +38 -0
  179. package/src/backend/injection/spotIds.ts +23 -18
  180. package/src/backend/inputs/ComboboxInput.tsx +1 -1
  181. package/src/backend/messages/EmailThreadsPanel.tsx +1 -1
  182. package/src/backend/perspectiveState.ts +44 -0
  183. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  184. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  185. package/src/backend/schedule/ScheduleToolbar.tsx +89 -68
  186. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +42 -3
  187. package/src/backend/sidebar/__tests__/group-visibility.test.ts +131 -0
  188. package/src/backend/sidebar/customization-helpers.ts +47 -0
  189. package/src/backend/utils/__tests__/apiCall.test.ts +61 -0
  190. package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +95 -0
  191. package/src/backend/utils/__tests__/fetchAllPages.test.ts +103 -0
  192. package/src/backend/utils/apiCall.ts +29 -1
  193. package/src/backend/utils/customFieldDefs.ts +1 -0
  194. package/src/backend/utils/customFieldForms.ts +15 -11
  195. package/src/backend/utils/fetchAllPages.ts +93 -0
  196. package/src/backend/utils/nav.ts +22 -2
  197. package/src/backend/version-history/VersionHistoryPanel.tsx +1 -1
  198. package/src/frontend/LanguageSwitcher.tsx +1 -0
  199. package/src/portal/PortalContext.tsx +1 -1
  200. package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +66 -0
  201. package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +155 -0
  202. package/src/portal/hooks/portalBridgeStatus.ts +29 -0
  203. package/src/portal/hooks/usePortalEventBridge.ts +6 -0
  204. package/src/portal/hooks/usePortalNotifications.ts +55 -12
  205. package/src/portal/utils/__tests__/nav.test.ts +2 -2
  206. package/src/portal/utils/nav.ts +3 -4
  207. package/src/primitives/ErrorNotice.tsx +1 -1
  208. package/src/primitives/Notice.tsx +5 -5
  209. package/src/primitives/__tests__/button.test.tsx +70 -0
  210. package/src/primitives/button.tsx +10 -4
  211. package/src/theme/ThemeProvider.tsx +1 -2
  212. package/src/theme/__tests__/theme-init-script.test.ts +70 -0
  213. package/src/theme/index.ts +1 -0
  214. 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 { Button } from '../../primitives/button'
5
- import { Input } from '../../primitives/input'
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 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
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-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">
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
- <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
- >
152
+ <SegmentedControlItem key={option.id} value={option.id}>
137
153
  {t(option.labelKey, option.fallback)}
138
- </Button>
154
+ </SegmentedControlItem>
139
155
  ))}
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')}>
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
- </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')}>
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
- </Button>
174
+ </IconButton>
149
175
  </div>
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>
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-1 mt-[26px]">
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
- <span className="rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
1391
+ <Badge variant="muted" size="sm" className="uppercase tracking-wide">
1353
1392
  {t('appShell.sidebarCustomizationHiddenBadge', 'Hidden')}
1354
- </span>
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
+ })