@spree/dashboard-core 0.13.1 → 1.0.0-beta.1

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -1,7 +1,9 @@
1
1
  import {
2
+ cn,
2
3
  DropdownMenu,
3
4
  DropdownMenuContent,
4
5
  DropdownMenuItem,
6
+ DropdownMenuSeparator,
5
7
  DropdownMenuTrigger,
6
8
  SidebarGroup,
7
9
  SidebarMenu,
@@ -14,8 +16,9 @@ import {
14
16
  } from '@spree/dashboard-ui'
15
17
  import { Link, useRouterState } from '@tanstack/react-router'
16
18
  import type { LucideIcon } from 'lucide-react'
17
- import { type ComponentType, useState } from 'react'
18
- import type { SubjectName } from '../lib/permissions'
19
+ import { type ComponentType, useCallback, useEffect, useRef, useState } from 'react'
20
+ import { isPathWithin } from '../lib/nav-registry'
21
+ import type { ActionName, SubjectName } from '../lib/permissions'
19
22
 
20
23
  export type NavItem = {
21
24
  title: string
@@ -23,9 +26,18 @@ export type NavItem = {
23
26
  icon: LucideIcon
24
27
  /** CanCanCan subject required to see this item. If omitted, item is always visible. */
25
28
  subject?: SubjectName
29
+ /** Action checked against `subject`. Defaults to `'read'`. */
30
+ action?: ActionName
26
31
  /** Component rendered after the label (e.g. a count badge). May return null. */
27
32
  badge?: ComponentType
28
- items?: { title: string; url: string; subject?: SubjectName }[]
33
+ items?: {
34
+ title: string
35
+ url: string
36
+ subject?: SubjectName
37
+ action?: ActionName
38
+ /** Component rendered after the label, same contract as the parent's. */
39
+ badge?: ComponentType
40
+ }[]
29
41
  }
30
42
 
31
43
  export function NavIcon({ icon: Icon, isActive }: { icon: NavItem['icon']; isActive?: boolean }) {
@@ -41,28 +53,133 @@ export function NavIcon({ icon: Icon, isActive }: { icon: NavItem['icon']; isAct
41
53
  )
42
54
  }
43
55
 
44
- function CollapsedDropdown({ item, children }: { item: NavItem; children: React.ReactNode }) {
45
- const [open, setOpen] = useState(false)
56
+ /** Grace period between leaving the trigger/menu and the menu closing. */
57
+ const HOVER_CLOSE_DELAY_MS = 150
58
+
59
+ /**
60
+ * Which collapsed-nav menu is open, shared across the whole rail.
61
+ *
62
+ * One value rather than per-item state, so the menus are mutually exclusive by
63
+ * construction: hovering a neighbour replaces the open key instantly instead of
64
+ * leaving the previous menu on screen for the close delay, which read as two
65
+ * panels flickering over each other.
66
+ */
67
+ interface HoverMenuController {
68
+ openKey: string | null
69
+ /** Opens `key` immediately, replacing whatever was open. */
70
+ open: (key: string) => void
71
+ /** Schedules a close, but only if `key` is still the open one. */
72
+ closeSoon: (key: string) => void
73
+ /** Closes right away — for explicit dismissals (Escape, selecting an item). */
74
+ closeNow: () => void
75
+ /** Cancels a pending close (pointer came back). */
76
+ cancelClose: () => void
77
+ }
78
+
79
+ function useHoverMenuController(): HoverMenuController {
80
+ const [openKey, setOpenKey] = useState<string | null>(null)
81
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
82
+
83
+ const cancelClose = useCallback(() => {
84
+ if (closeTimer.current) {
85
+ clearTimeout(closeTimer.current)
86
+ closeTimer.current = null
87
+ }
88
+ }, [])
89
+
90
+ const open = useCallback(
91
+ (key: string) => {
92
+ cancelClose()
93
+ setOpenKey(key)
94
+ },
95
+ [cancelClose],
96
+ )
97
+
98
+ const closeSoon = useCallback(
99
+ (key: string) => {
100
+ cancelClose()
101
+ closeTimer.current = setTimeout(() => {
102
+ // Guarded so a late timer from the menu the pointer just left can't
103
+ // close the one it has since moved to.
104
+ setOpenKey((current) => (current === key ? null : current))
105
+ }, HOVER_CLOSE_DELAY_MS)
106
+ },
107
+ [cancelClose],
108
+ )
109
+
110
+ const closeNow = useCallback(() => {
111
+ cancelClose()
112
+ setOpenKey(null)
113
+ }, [cancelClose])
114
+
115
+ useEffect(
116
+ () => () => {
117
+ if (closeTimer.current) clearTimeout(closeTimer.current)
118
+ },
119
+ [],
120
+ )
121
+
122
+ return { openKey, open, closeSoon, closeNow, cancelClose }
123
+ }
124
+
125
+ function CollapsedDropdown({
126
+ item,
127
+ controller,
128
+ children,
129
+ }: {
130
+ item: NavItem
131
+ controller: HoverMenuController
132
+ children: React.ReactNode
133
+ }) {
134
+ const key = item.title
135
+ const open = controller.openKey === key
136
+ const openNow = () => controller.open(key)
137
+ const closeSoon = () => controller.closeSoon(key)
46
138
 
47
139
  return (
48
- <DropdownMenu open={open} onOpenChange={setOpen}>
140
+ <DropdownMenu
141
+ open={open}
142
+ // Base UI's own open/close signals (Escape, outside click, selecting an
143
+ // item) are explicit dismissals, so they close immediately — the grace
144
+ // period exists only for the pointer leaving on its way somewhere.
145
+ onOpenChange={(next) => {
146
+ if (next) controller.open(key)
147
+ else controller.closeNow()
148
+ }}
149
+ >
49
150
  {/* `nativeButton={false}` because the trigger renders a `<div>` wrapping
50
151
  a `SidebarMenuButton` (whose inner element is a `<Link>` → `<a>`),
51
152
  not a `<button>`. The wrapper exists to attach `onMouseLeave` for
52
153
  the hover-out close, so we can't drop it; instead we tell Base UI
53
154
  we're rendering a non-button trigger and let it apply menu role +
54
155
  keyboard semantics. */}
55
- <DropdownMenuTrigger asChild nativeButton={false} onMouseEnter={() => setOpen(true)}>
156
+ <DropdownMenuTrigger asChild nativeButton={false} onMouseEnter={openNow}>
56
157
  {/* biome-ignore lint/a11y/noStaticElementInteractions: hover trigger for collapsed nav */}
57
- <div onMouseLeave={() => setOpen(false)}>{children}</div>
158
+ <div onMouseLeave={closeSoon}>{children}</div>
58
159
  </DropdownMenuTrigger>
160
+ {/* `sideOffset={0}` plus inline-start *padding* closes the dead zone the
161
+ pointer used to cross: an offset (or a margin) puts those pixels
162
+ outside both elements, so travelling to the menu reads as leaving
163
+ both. Padding keeps the gap inside the panel's own hover area, so the
164
+ visual inset costs nothing in reachability. */}
59
165
  <DropdownMenuContent
60
166
  side="right"
61
167
  align="start"
62
- sideOffset={4}
63
- onMouseLeave={() => setOpen(false)}
64
- onMouseEnter={() => setOpen(true)}
168
+ sideOffset={0}
169
+ className="ps-1"
170
+ onMouseLeave={closeSoon}
171
+ onMouseEnter={openNow}
65
172
  >
173
+ {/* The section itself leads the list. Collapsed, the icon is the only
174
+ route to it, and hovering opens this panel instead — so without an
175
+ entry here "Products" is reachable by a click most people won't
176
+ guess at, and not at all by keyboard. */}
177
+ <DropdownMenuItem asChild>
178
+ <Link to={item.url} className="no-underline font-medium">
179
+ {item.title}
180
+ </Link>
181
+ </DropdownMenuItem>
182
+ <DropdownMenuSeparator />
66
183
  {item.items!.map((subItem) => (
67
184
  <DropdownMenuItem key={subItem.title} asChild>
68
185
  <Link to={subItem.url} className="no-underline">
@@ -79,19 +196,24 @@ function NavItemContent({
79
196
  item,
80
197
  currentPath,
81
198
  isCollapsed,
199
+ isMobile,
200
+ hoverMenu,
82
201
  }: {
83
202
  item: NavItem
84
203
  currentPath: string
85
204
  isCollapsed: boolean
205
+ isMobile: boolean
206
+ hoverMenu: HoverMenuController
86
207
  }) {
87
208
  const isExactActive = currentPath === item.url || currentPath === `${item.url}/`
88
209
  // Only prefix-match for items with a sub-path after the storeId segment (e.g. /store_abc/orders)
89
210
  const hasSubPath = item.url.split('/').filter(Boolean).length > 1
90
- const isActive = isExactActive || (hasSubPath && currentPath.startsWith(item.url))
91
- const hasActiveChild = item.items?.some(
92
- (sub) => currentPath === sub.url || currentPath.startsWith(sub.url),
93
- )
94
- const showSubmenu = isActive || hasActiveChild
211
+ const isActive = isExactActive || (hasSubPath && isPathWithin(currentPath, item.url))
212
+ const hasActiveChild = item.items?.some((sub) => isPathWithin(currentPath, sub.url))
213
+ // Desktop reveals children on hover or when the section is active. Touch has
214
+ // no hover, and the drawer closes on navigation, so a collapsed child would
215
+ // take three taps to reach — show every child up front instead.
216
+ const showSubmenu = isMobile || isActive || hasActiveChild
95
217
  const itemIsActive = isActive || !!hasActiveChild
96
218
 
97
219
  const button = (
@@ -100,14 +222,19 @@ function NavItemContent({
100
222
  asChild
101
223
  isActive={itemIsActive}
102
224
  >
103
- <Link to={item.url}>
225
+ {/* `aria-label` because the visible label is hidden in collapsed icon
226
+ mode, which would otherwise leave the link with no accessible name —
227
+ an icon-only link is unusable to a screen reader. */}
228
+ <Link to={item.url} aria-label={isCollapsed ? item.title : undefined}>
104
229
  <NavIcon icon={item.icon} isActive={itemIsActive} />
105
230
  {/* Explicitly hide in collapsed icon mode: a trailing badge span would
106
231
  otherwise steal the `span:last-child` position the base button style
107
232
  relies on to hide the label, leaving the title visible. */}
108
233
  <span className="group-data-[collapsible=icon]:hidden">{item.title}</span>
234
+ {/* `shrink-0` so the badge keeps its shape while the sidebar width
235
+ animates — a squeezed badge deforms before the label clips. */}
109
236
  {item.badge && (
110
- <span className="ml-auto group-data-[collapsible=icon]:hidden">
237
+ <span className="ml-auto shrink-0 group-data-[collapsible=icon]:hidden">
111
238
  <item.badge />
112
239
  </span>
113
240
  )}
@@ -116,21 +243,31 @@ function NavItemContent({
116
243
  )
117
244
 
118
245
  return (
119
- <SidebarMenuItem>
246
+ // Hovering an item with no submenu dismisses whatever menu is open: the
247
+ // pointer has clearly moved on, and leaving a neighbour's panel hanging
248
+ // over the rail reads as a stuck popup.
249
+ <SidebarMenuItem onMouseEnter={isCollapsed && !item.items ? hoverMenu.closeNow : undefined}>
120
250
  {isCollapsed && item.items ? (
121
- <CollapsedDropdown item={item}>{button}</CollapsedDropdown>
251
+ <CollapsedDropdown item={item} controller={hoverMenu}>
252
+ {button}
253
+ </CollapsedDropdown>
122
254
  ) : (
123
255
  button
124
256
  )}
125
257
  {item.items && showSubmenu && (
126
258
  <SidebarMenuSub>
127
259
  {item.items.map((subItem) => {
128
- const subActive = currentPath === subItem.url || currentPath.startsWith(subItem.url)
260
+ const subActive = isPathWithin(currentPath, subItem.url)
129
261
  return (
130
262
  <SidebarMenuSubItem key={subItem.title}>
131
263
  <SidebarMenuSubButton asChild isActive={subActive}>
132
264
  <Link to={subItem.url}>
133
265
  <span>{subItem.title}</span>
266
+ {subItem.badge && (
267
+ <span className="ml-auto">
268
+ <subItem.badge />
269
+ </span>
270
+ )}
134
271
  </Link>
135
272
  </SidebarMenuSubButton>
136
273
  </SidebarMenuSubItem>
@@ -145,11 +282,35 @@ function NavItemContent({
145
282
  export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?: NavItem[] }) {
146
283
  const routerState = useRouterState()
147
284
  const currentPath = routerState.location.pathname
148
- const { state } = useSidebar()
285
+ const { state, isMobile, setOpenMobile } = useSidebar()
149
286
  const isCollapsed = state === 'collapsed'
287
+ // Close the drawer as the link is tapped. Done here rather than in an effect
288
+ // watching the path: this tree renders *inside* the Sheet, so it remounts on
289
+ // every open and an effect could not tell "just opened" from "just navigated".
290
+ const handleLinkTap = useCallback(
291
+ (event: React.MouseEvent) => {
292
+ if (isMobile && (event.target as HTMLElement).closest('a')) setOpenMobile(false)
293
+ },
294
+ [isMobile, setOpenMobile],
295
+ )
296
+ // One controller for the whole rail so only a single hover menu is ever open.
297
+ const hoverMenu = useHoverMenuController()
298
+
299
+ // Expanding the rail unmounts the dropdowns but leaves `openKey` set, so
300
+ // collapsing again would restore a menu the user never opened.
301
+ const { closeNow } = hoverMenu
302
+ useEffect(() => {
303
+ if (!isCollapsed) closeNow()
304
+ }, [isCollapsed, closeNow])
150
305
 
151
306
  return (
152
- <>
307
+ // The close handler sits on the list rather than each `<Link>`: those links
308
+ // are rendered through nested `asChild` Slots (Tooltip → SidebarMenuButton),
309
+ // which clone their own `onClick` over anything the child declares. A
310
+ // bubble-phase listener here sees the tap regardless.
311
+ // biome-ignore lint/a11y/noStaticElementInteractions: delegated link taps only
312
+ // biome-ignore lint/a11y/useKeyWithClickEvents: links keep their own keyboard behaviour
313
+ <div onClick={handleLinkTap} className="contents">
153
314
  <SidebarGroup>
154
315
  <SidebarMenu>
155
316
  {items.map((item) => (
@@ -158,22 +319,31 @@ export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?
158
319
  item={item}
159
320
  currentPath={currentPath}
160
321
  isCollapsed={isCollapsed}
322
+ isMobile={isMobile}
323
+ hoverMenu={hoverMenu}
161
324
  />
162
325
  ))}
163
326
  </SidebarMenu>
164
327
  </SidebarGroup>
165
328
 
166
329
  {bottomItems && bottomItems.length > 0 && (
167
- <SidebarGroup className="mt-auto mb-2">
330
+ // `mt-auto` pins Settings to the foot of the desktop rail. In a
331
+ // full-height drawer that strands it alone below a screen of empty
332
+ // space, so on mobile it simply follows the list.
333
+ <SidebarGroup className={cn('mb-2', !isMobile && 'mt-auto')}>
168
334
  <SidebarMenu>
169
335
  {bottomItems.map((item) => {
170
336
  const isActive =
171
- currentPath === item.url || (item.url !== '/' && currentPath.startsWith(item.url))
337
+ currentPath === item.url ||
338
+ (item.url !== '/' && isPathWithin(currentPath, item.url))
172
339
 
173
340
  return (
174
341
  <SidebarMenuItem key={item.title}>
175
342
  <SidebarMenuButton tooltip={item.title} asChild isActive={isActive}>
176
- <Link to={item.url}>
343
+ {/* `aria-label` for the same reason as the main nav above:
344
+ collapsed mode hides the label, and Settings lives here
345
+ — the one place the rail is collapsed by default. */}
346
+ <Link to={item.url} aria-label={isCollapsed ? item.title : undefined}>
177
347
  <NavIcon icon={item.icon} isActive={isActive} />
178
348
  <span>{item.title}</span>
179
349
  </Link>
@@ -184,6 +354,6 @@ export function NavMain({ items, bottomItems }: { items: NavItem[]; bottomItems?
184
354
  </SidebarMenu>
185
355
  </SidebarGroup>
186
356
  )}
187
- </>
357
+ </div>
188
358
  )
189
359
  }