@spree/dashboard-core 0.13.0 → 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 (126) 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 +97 -36
  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 +425 -131
  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 +63 -22
  36. package/src/components/table-toolbar.tsx +1373 -442
  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 +261 -14
  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 +21 -0
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/storefront.ts +19 -0
  77. package/src/lib/table-registry.ts +56 -0
  78. package/src/lib/type-labels.test.ts +68 -0
  79. package/src/lib/type-labels.ts +78 -0
  80. package/src/locales/ar.json +1889 -516
  81. package/src/locales/de.json +1883 -515
  82. package/src/locales/en.json +1886 -516
  83. package/src/locales/fr.json +1884 -516
  84. package/src/locales/pl.json +1913 -516
  85. package/src/locales/zh-CN.json +1883 -516
  86. package/src/pages/package-types.tsx +484 -0
  87. package/src/pages/settings-index.tsx +112 -0
  88. package/src/pages/stock-locations.tsx +674 -0
  89. package/src/plugin.ts +29 -0
  90. package/src/products/add-video-dialog.tsx +100 -0
  91. package/src/products/bulk-variants-dialog.tsx +301 -0
  92. package/src/products/carton-size.test.ts +51 -0
  93. package/src/products/carton-size.ts +20 -0
  94. package/src/products/currency-parts.ts +21 -0
  95. package/src/products/inventory-section.tsx +259 -0
  96. package/src/products/media-edit-sheet.tsx +336 -0
  97. package/src/products/normalize-customs.test.ts +39 -0
  98. package/src/products/normalize-customs.ts +27 -0
  99. package/src/products/normalize-money.test.ts +58 -0
  100. package/src/products/normalize-money.ts +73 -0
  101. package/src/products/normalize-quantity.test.ts +32 -0
  102. package/src/products/normalize-quantity.ts +31 -0
  103. package/src/products/product-bulk-price-editor.tsx +165 -0
  104. package/src/products/product-form-cards.tsx +1167 -0
  105. package/src/products/product-form-mapping.ts +212 -0
  106. package/src/products/product-schema.ts +318 -0
  107. package/src/products/product-types.ts +130 -0
  108. package/src/products/use-product-form-data.ts +273 -0
  109. package/src/products/variant-edit-sheet.tsx +725 -0
  110. package/src/products/variants-matrix.test.ts +154 -0
  111. package/src/products/variants-matrix.ts +244 -0
  112. package/src/products/variants-options-builder.tsx +571 -0
  113. package/src/products/variants-section.tsx +535 -0
  114. package/src/products/video-url.test.ts +71 -0
  115. package/src/products/video-url.ts +83 -0
  116. package/src/providers/auth-provider.tsx +70 -12
  117. package/src/providers/permission-provider.tsx +68 -28
  118. package/src/providers/sticky-header-provider.tsx +134 -0
  119. package/src/providers/store-provider.tsx +36 -3
  120. package/src/providers/tenant-provider.tsx +33 -0
  121. package/src/schemas/package-type.ts +95 -0
  122. package/src/schemas/stock-location.ts +126 -0
  123. package/src/tables/package-types.tsx +74 -0
  124. package/src/tables/stock-locations.tsx +124 -0
  125. package/src/vite/index.ts +40 -7
  126. package/src/components/top-bar.tsx +0 -210
@@ -7,39 +7,84 @@
7
7
  export const Subject = {
8
8
  All: 'all',
9
9
  Product: 'Spree::Product',
10
+ ProductType: 'Spree::ProductType',
11
+ // The media library. Authorized as product imagery — a merchant who can edit
12
+ // products can manage the files that illustrate them.
13
+ Media: 'Spree::Media',
10
14
  Variant: 'Spree::Variant',
11
15
  Order: 'Spree::Order',
16
+ SavedReport: 'Spree::SavedReport',
17
+ ReportExport: 'Spree::Exports::Report',
12
18
  Customer: 'Spree::User',
13
19
  CustomerGroup: 'Spree::CustomerGroup',
14
20
  AdminUser: 'Spree::AdminUser',
15
21
  ApiKey: 'Spree::ApiKey',
16
22
  AllowedOrigin: 'Spree::AllowedOrigin',
17
23
  Store: 'Spree::Store',
24
+ // The store's packaging vocabulary: the box it ships parcels in, and the
25
+ // cartons, pallets and containers a wholesale order leaves on.
26
+ PackageType: 'Spree::PackageType',
18
27
  Channel: 'Spree::Channel',
19
28
  OrderRoutingRule: 'Spree::OrderRoutingRule',
20
29
  // Categories are Spree::Category < Spree::Taxon; abilities are published under
21
30
  // the Spree::Taxon subject, so the value stays 'Spree::Taxon' while the key
22
31
  // reads as the user-facing resource name.
23
32
  Category: 'Spree::Taxon',
33
+ Collection: 'Spree::Collection',
24
34
  OptionType: 'Spree::OptionType',
25
35
  OptionValue: 'Spree::OptionValue',
36
+ Policy: 'Spree::Policy',
26
37
  TaxCategory: 'Spree::TaxCategory',
27
- CustomFieldDefinition: 'Spree::MetafieldDefinition',
38
+ TaxRate: 'Spree::TaxRate',
39
+ TaxIdentifier: 'Spree::TaxIdentifier',
40
+ TaxExemptionCertificate: 'Spree::TaxExemptionCertificate',
41
+ Company: 'Spree::Company',
42
+ CompanyAddress: 'Spree::CompanyAddress',
43
+ CompanyMembership: 'Spree::CompanyMembership',
44
+ CompanyInvitation: 'Spree::CompanyInvitation',
45
+ Catalog: 'Spree::Catalog',
46
+ ReturnReason: 'Spree::ReturnReason',
47
+ ClaimReason: 'Spree::ClaimReason',
48
+ RefundReason: 'Spree::RefundReason',
49
+ OrderCancellationReason: 'Spree::OrderCancellationReason',
50
+ CustomFieldDefinition: 'Spree::CustomFieldDefinition',
51
+ Integration: 'Spree::Integration',
28
52
  PaymentMethod: 'Spree::PaymentMethod',
53
+ DeliveryMethod: 'Spree::DeliveryMethod',
54
+ DeliveryZone: 'Spree::DeliveryZone',
55
+ DeliveryProfile: 'Spree::DeliveryProfile',
56
+ /** @deprecated Use Subject.DeliveryMethod — removed in Spree 6.1. */
29
57
  ShippingMethod: 'Spree::ShippingMethod',
30
58
  StockLocation: 'Spree::StockLocation',
59
+ StockLevel: 'Spree::StockLevel',
60
+ /** @deprecated Use Subject.StockLevel — removed in Spree 6.1. */
31
61
  StockItem: 'Spree::StockItem',
32
62
  StockTransfer: 'Spree::StockTransfer',
63
+ StockTransferItem: 'Spree::StockTransferItem',
64
+ Supplier: 'Spree::Supplier',
65
+ PurchaseOrder: 'Spree::PurchaseOrder',
66
+ PurchaseOrderItem: 'Spree::PurchaseOrderItem',
33
67
  PriceList: 'Spree::PriceList',
34
68
  PriceRule: 'Spree::PriceRule',
35
69
  Promotion: 'Spree::Promotion',
36
70
  PromotionAction: 'Spree::PromotionAction',
37
71
  PromotionRule: 'Spree::PromotionRule',
38
72
  GiftCard: 'Spree::GiftCard',
73
+ GiftCardBatch: 'Spree::GiftCardBatch',
74
+ StoreCredit: 'Spree::StoreCredit',
75
+ StoreCreditEvent: 'Spree::StoreCreditEvent',
76
+ Role: 'Spree::Role',
77
+ Invitation: 'Spree::Invitation',
39
78
  Market: 'Spree::Market',
40
79
  WebhookEndpoint: 'Spree::WebhookEndpoint',
41
80
  WebhookDelivery: 'Spree::WebhookDelivery',
42
81
  Wishlist: 'Spree::Wishlist',
82
+ Seller: 'Spree::Seller',
83
+ CommissionRate: 'Spree::CommissionRate',
84
+ CommissionRule: 'Spree::CommissionRule',
85
+ CommissionLine: 'Spree::CommissionLine',
86
+ SellerPayout: 'Spree::SellerPayout',
87
+ SellerTransfer: 'Spree::SellerTransfer',
43
88
  } as const
44
89
 
45
90
  export type SubjectName = (typeof Subject)[keyof typeof Subject] | string
@@ -0,0 +1,18 @@
1
+ /** A count of finished items out of a total — an onboarding checklist, say. */
2
+ export interface ProgressCounts {
3
+ done: number
4
+ total: number
5
+ }
6
+
7
+ /**
8
+ * How far through a checklist something is, as a percentage for a progress bar.
9
+ *
10
+ * An empty checklist counts as complete rather than as nothing done: a
11
+ * marketplace that asks nothing of its sellers has no outstanding work, and
12
+ * `done / total` would be NaN.
13
+ */
14
+ export function progressPercentage({ done, total }: ProgressCounts): number {
15
+ if (total <= 0) return 100
16
+
17
+ return Math.round((done / total) * 100)
18
+ }
@@ -1,26 +1,26 @@
1
1
  import type { QueryKey } from '@tanstack/react-query'
2
2
  import { useCallback } from 'react'
3
- import { useStore } from '../providers/store-provider'
3
+ import { useTenantId } from '../providers/tenant-provider'
4
4
 
5
5
  /**
6
6
  * Canonical TanStack query-key shape for every dashboard resource:
7
7
  *
8
- * [resource, storeId, ...rest]
8
+ * [resource, tenantId, ...rest]
9
9
  *
10
10
  * Use `useResourceKey` (the hook) from inside React components to build
11
- * store-scoped keys — it reads `storeId` from `<StoreProvider>` so callers
12
- * never spell it themselves:
11
+ * tenant-scoped keys — it reads the id from `<TenantProvider>` (falling back
12
+ * to `<StoreProvider>`) so callers never spell it themselves:
13
13
  *
14
14
  * useQuery({ queryKey: useResourceKey('channels', id), ... })
15
15
  *
16
- * The bare `resourceKey()` helper is the same builder without the storeId
16
+ * The bare `resourceKey()` helper is the same builder without the tenant id
17
17
  * dependency — keep it for non-hook contexts (tests, build-time defaults).
18
18
  *
19
19
  * Lists: `useResourceKey('channels')`
20
20
  * Singletons: `useResourceKey('channels', id)`
21
21
  * Nested: `useResourceKey('products', productId, 'variants')`
22
22
  *
23
- * For invalidation inside `useResourceMutation`, the storeId is auto-injected
23
+ * For invalidation inside `useResourceMutation`, the tenant id is auto-injected
24
24
  * — pass the logical key without it:
25
25
  *
26
26
  * invalidate: [['channels'], ['channels', id]]
@@ -30,12 +30,12 @@ export function resourceKey(resource: string, ...rest: ReadonlyArray<unknown>):
30
30
  }
31
31
 
32
32
  /**
33
- * Hook variant of `resourceKey` that auto-injects the current `storeId` from
34
- * `<StoreProvider>`. The default way to build query keys in dashboard hooks.
33
+ * Hook variant of `resourceKey` that auto-injects the current tenant id from
34
+ * `<TenantProvider>`. The default way to build query keys in dashboard hooks.
35
35
  */
36
36
  export function useResourceKey(resource: string, ...rest: ReadonlyArray<unknown>): QueryKey {
37
- const { storeId } = useStore()
38
- return [resource, storeId, ...rest]
37
+ const tenantId = useTenantId()
38
+ return [resource, tenantId, ...rest]
39
39
  }
40
40
 
41
41
  /**
@@ -50,10 +50,10 @@ export function useResourceKey(resource: string, ...rest: ReadonlyArray<unknown>
50
50
  * })
51
51
  */
52
52
  export function useResourceKeyBuilder() {
53
- const { storeId } = useStore()
53
+ const tenantId = useTenantId()
54
54
  return useCallback(
55
- (resource: string, ...rest: ReadonlyArray<unknown>): QueryKey => [resource, storeId, ...rest],
56
- [storeId],
55
+ (resource: string, ...rest: ReadonlyArray<unknown>): QueryKey => [resource, tenantId, ...rest],
56
+ [tenantId],
57
57
  )
58
58
  }
59
59
 
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Import types that ship a populated example CSV in Spree's
3
+ * `core/db/sample_data`. Keyed by the API's type shorthand (`"products"`),
4
+ * which is also the CSV's filename stem.
5
+ *
6
+ * Only used to decide whether to render the download link — the file itself is
7
+ * served by each panel's own `imports/example` endpoint, which pins the URL to
8
+ * the installed Spree version. A type missing from this list simply hides the link;
9
+ * the endpoint is the authority and 404s for anything it has no example for.
10
+ */
11
+ const TYPES_WITH_SAMPLE_CSV = new Set([
12
+ 'products',
13
+ 'customers',
14
+ 'product_translations',
15
+ 'price_list_prices',
16
+ ])
17
+
18
+ /** Whether an import type has an example CSV to offer. */
19
+ export function hasSampleCsv(type: string): boolean {
20
+ return TYPES_WITH_SAMPLE_CSV.has(type)
21
+ }
@@ -48,8 +48,12 @@ export interface SearchEntry<T = unknown> {
48
48
  getKey: (item: T) => string
49
49
  /** Render one result row's inner content (icon/thumbnail + label + badges). */
50
50
  renderRow: (item: T) => ReactNode
51
- /** Resolve the destination for a clicked result, given the active store. */
52
- getRoute: (item: T, storeId: string) => { to: string }
51
+ /**
52
+ * Resolve the destination for a clicked result, given the active store.
53
+ * `search` carries route search params — needed by resources edited in a
54
+ * deep-linked sheet rather than on a page of their own.
55
+ */
56
+ getRoute: (item: T, storeId: string) => { to: string; search?: Record<string, unknown> }
53
57
  }
54
58
 
55
59
  interface SearchMutator {
@@ -1,6 +1,7 @@
1
1
  import type { LucideIcon } from 'lucide-react'
2
2
  import { useSyncExternalStore } from 'react'
3
- import type { SubjectName } from './permissions'
3
+ import { ensureLabelled, resolveNavDescription, resolveNavLabel } from './nav-registry'
4
+ import type { ActionName, SubjectName } from './permissions'
4
5
 
5
6
  /**
6
7
  * Registry for the settings sub-shell sidebar. Mirrors `nav-registry.ts` but
@@ -22,6 +23,19 @@ export interface SettingsNavEntry {
22
23
  label?: string
23
24
  /** i18n key passed to `t(...)` at render time. Takes precedence over `label`. */
24
25
  labelKey?: string
26
+ /**
27
+ * One-line summary of what the page is for. Shown on the settings landing
28
+ * page under the entry's label; the sidebar ignores it.
29
+ */
30
+ description?: string
31
+ /** i18n key for `description`, resolved at render. Takes precedence over it. */
32
+ descriptionKey?: string
33
+ /**
34
+ * Extra terms that match this entry in settings search, beyond its label.
35
+ * Merchants search for the word they know ("VAT", "SKU", "CORS") rather than
36
+ * the page's name, so list those synonyms here.
37
+ */
38
+ keywords?: string[]
25
39
  /**
26
40
  * Path template, prefixed with `/$storeId/settings` at render time. Pass paths
27
41
  * like `'/general'` or `'/staff'`, NOT `/store_abc/settings/general`. The
@@ -36,6 +50,13 @@ export interface SettingsNavEntry {
36
50
  position?: number
37
51
  /** CanCanCan subject required to see this item. Omit for always-visible. */
38
52
  subject?: SubjectName
53
+ /**
54
+ * Action checked against `subject`. Defaults to `'read'`. Settings pages
55
+ * that only exist to *change* something (store settings, emails) should
56
+ * declare `'update'` — every staff member can read the store record for
57
+ * shell data, so a read check would show them a page they cannot use.
58
+ */
59
+ action?: ActionName
39
60
  /** When true, the page is disabled in the sidebar with a "Soon" badge. */
40
61
  comingSoon?: boolean
41
62
  }
@@ -81,9 +102,7 @@ function notify() {
81
102
 
82
103
  export const settingsNav: SettingsNavMutator = {
83
104
  add(entry) {
84
- if (!entry.label && !entry.labelKey) {
85
- throw new Error(`Settings nav entry "${entry.key}" must define either "label" or "labelKey".`)
86
- }
105
+ ensureLabelled(entry, 'Settings nav entry')
87
106
  if (entries.some((e) => e.key === entry.key)) {
88
107
  throw new Error(`Settings nav entry "${entry.key}" already registered.`)
89
108
  }
@@ -99,17 +118,14 @@ export const settingsNav: SettingsNavMutator = {
99
118
  update(key, patch) {
100
119
  const e = entries.find((x) => x.key === key)
101
120
  if (!e) throw new Error(`Settings nav entry "${key}" not found.`)
121
+ // Validate a copy before touching the stored entry: a patch that removes
122
+ // both label fields must leave the registry as it was, not half-applied.
123
+ ensureLabelled({ ...e, ...patch }, 'Settings nav entry')
102
124
  Object.assign(e, patch)
103
- // Re-validate the merged entry — the patch could remove both label fields.
104
- if (!e.label && !e.labelKey) {
105
- throw new Error(`Settings nav entry "${e.key}" must define either "label" or "labelKey".`)
106
- }
107
125
  notify()
108
126
  },
109
127
  addGroup(group) {
110
- if (!group.label && !group.labelKey) {
111
- throw new Error(`Settings nav group "${group.key}" must define either "label" or "labelKey".`)
112
- }
128
+ ensureLabelled(group, 'Settings nav group')
113
129
  if (groups.some((g) => g.key === group.key)) {
114
130
  throw new Error(`Settings nav group "${group.key}" already registered.`)
115
131
  }
@@ -150,6 +166,83 @@ export function useSettingsNav() {
150
166
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
151
167
  }
152
168
 
169
+ /**
170
+ * Whether a settings entry matches a search term. Label, description and the
171
+ * entry's own `keywords` all count, so the word a merchant knows ("VAT",
172
+ * "CORS") reaches the page named something else. Shared by the settings
173
+ * sidebar and the settings landing page so both filter identically.
174
+ *
175
+ * @param entry Entry to test.
176
+ * @param term Search text; matching is case-insensitive and ignores surrounding space.
177
+ * @param t Translation function used to resolve `labelKey`/`descriptionKey`.
178
+ * @returns True when the entry matches, or when the term is blank.
179
+ */
180
+ export function settingsEntryMatches(
181
+ entry: SettingsNavEntry,
182
+ term: string,
183
+ t: (key: string) => string,
184
+ ): boolean {
185
+ const needle = term.trim().toLowerCase()
186
+ if (!needle) return true
187
+
188
+ const haystack = [
189
+ resolveNavLabel(entry, t),
190
+ resolveNavDescription(entry, t),
191
+ ...(entry.keywords ?? []),
192
+ ]
193
+ return haystack.some((value) => value?.toLowerCase().includes(needle))
194
+ }
195
+
196
+ /**
197
+ * Drop entries the current staff member cannot reach. Exported because the
198
+ * sidebar and the settings landing page must agree on what a role can see —
199
+ * a private copy on either surface is how one starts showing a card the other
200
+ * hides.
201
+ */
202
+ export function filterSettingsByPermissions(
203
+ snapshot: SettingsNavSnapshot,
204
+ permissions: { can: (action: string, subject: string) => boolean },
205
+ ): SettingsNavSnapshot {
206
+ return regroup(snapshot, (entry) =>
207
+ !entry.subject ? true : permissions.can(entry.action ?? 'read', entry.subject),
208
+ )
209
+ }
210
+
211
+ /** Narrow the nav to entries matching the merchant's search. */
212
+ export function filterSettingsByQuery(
213
+ snapshot: SettingsNavSnapshot,
214
+ query: string,
215
+ t: (key: string) => string,
216
+ ): SettingsNavSnapshot {
217
+ return regroup(snapshot, (entry) => settingsEntryMatches(entry, query, t))
218
+ }
219
+
220
+ /** Rebuild a snapshot from the entries passing `keep`, dropping emptied groups. */
221
+ function regroup(
222
+ snapshot: SettingsNavSnapshot,
223
+ keep: (entry: SettingsNavEntry) => boolean,
224
+ ): SettingsNavSnapshot {
225
+ const groups = snapshot.groups
226
+ .map(({ group, entries }) => ({ group, entries: entries.filter(keep) }))
227
+ .filter((g) => g.entries.length > 0)
228
+ return { groups, all: groups.flatMap((g) => g.entries) }
229
+ }
230
+
231
+ /**
232
+ * Whether any settings entry is reachable for these permissions. The Settings
233
+ * launcher in the primary sidebar uses this so a role with no settings
234
+ * authority never opens an empty shell.
235
+ */
236
+ export function hasVisibleSettingsEntries(permissions: unknown): boolean {
237
+ const can = (permissions as { can?: (action: string, subject: string) => boolean } | undefined)
238
+ ?.can
239
+ if (typeof can !== 'function') return false
240
+
241
+ return getSnapshot().all.some(
242
+ (entry) => !entry.subject || can(entry.action ?? 'read', entry.subject),
243
+ )
244
+ }
245
+
153
246
  /** Test-only: clear the registry. */
154
247
  export function __resetSettingsNavRegistry(): void {
155
248
  entries.length = 0
@@ -0,0 +1,19 @@
1
+ import type { Store } from '@spree/admin-sdk'
2
+
3
+ /**
4
+ * Resolves the storefront URL to link to from admin "View store" affordances,
5
+ * or `null` when no storefront URL is configured.
6
+ *
7
+ * The serialized `store.url` always resolves — the backend falls back to the
8
+ * store's formatted URL — so gate on the explicit `preferred_storefront_url`
9
+ * preference: only return a link once a real storefront has been set.
10
+ *
11
+ * @param store the current store, or null/undefined while it loads
12
+ * @returns an absolute URL, or null when the store has no configured storefront
13
+ */
14
+ export function storefrontHref(store: Store | null | undefined): string | null {
15
+ if (!store?.preferred_storefront_url) return null
16
+
17
+ // Best-effort: prefix with https if the URL is just a hostname.
18
+ return /^https?:\/\//.test(store.url) ? store.url : `https://${store.url}`
19
+ }
@@ -9,17 +9,53 @@ import { ensureTimestampColumns } from './timestamp-columns'
9
9
  interface ColumnDefBase<T = any> {
10
10
  key: string
11
11
  label: string
12
+ /**
13
+ * One sentence explaining what the column means, shown from an info icon
14
+ * beside the header. For a figure whose name is not self-evident — what
15
+ * counts as "available", which units are "reserved" — where the alternative
16
+ * is a merchant guessing. Leave it off a column that explains itself.
17
+ */
18
+ labelHint?: string
12
19
  sortable?: boolean
13
20
  filterable?: boolean
14
21
  default?: boolean
22
+ /**
23
+ * What a `boolean` column's two states are called, where the domain has
24
+ * better words than Yes and No — "Active" and "Inactive" on a catalog. The
25
+ * filter control then says what the rows say, instead of asking a question
26
+ * the column never posed. Ignored by every other filter type.
27
+ */
28
+ booleanLabels?: { true: string; false: string }
15
29
  /** Ransack attribute name if different from key (e.g., 'master_sku' for 'sku') */
16
30
  ransackAttribute?: string
31
+ /** Send `ransackAttribute` as a Ransack scope: bare key, no operator suffix. */
32
+ ransackScope?: boolean
17
33
  /** Custom cell renderer. If omitted, renders `row[key]` as text. */
18
34
  render?: (row: T) => ReactNode
35
+ /**
36
+ * `expand` value the list request needs while this column is visible
37
+ * (e.g. `'custom_fields'`). ResourceTable unions the values across
38
+ * visible columns into the `expand` query param.
39
+ */
40
+ expand?: string
19
41
  /** Cell className */
20
42
  className?: string
21
43
  /** Whether this column is displayable in the table. Set to false for filter-only columns. */
22
44
  displayable?: boolean
45
+ /**
46
+ * Surface this column as a always-visible control in the toolbar, beside the
47
+ * search field, instead of only inside the filter popover.
48
+ *
49
+ * For the handful of filters an operator reaches for constantly — a status,
50
+ * a date range — the popover costs an open, an "Add filter", a field pick, a
51
+ * value pick and an Apply. A quick filter is the same `FilterRule` reached in
52
+ * one click, so it shares the URL state, the chips and the export with
53
+ * everything set the long way.
54
+ *
55
+ * Only `enum`/`boolean` (multi-select) and `date` (relative presets) render
56
+ * one; the flag is ignored on other filter types.
57
+ */
58
+ quickFilter?: boolean
23
59
  }
24
60
 
25
61
  /**
@@ -37,6 +73,14 @@ export interface ResourceFilterConfig<R extends { id: string } = { id: string }>
37
73
  getOptionLabel: (option: R) => string
38
74
  placeholder?: string
39
75
  emptyText?: string
76
+ /**
77
+ * Fetches the whole list, for a resource small enough to show in full —
78
+ * a store's warehouses, its channels. The control then opens as a plain
79
+ * list of options instead of an empty box waiting to be typed into, and
80
+ * still searches once the operator types. Leave it off for anything that
81
+ * could run to hundreds of rows.
82
+ */
83
+ listAll?: () => Promise<{ data: R[] }>
40
84
  }
41
85
 
42
86
  /**
@@ -110,6 +154,18 @@ export interface SortOption {
110
154
  export interface TableDef<T = any> {
111
155
  key: string
112
156
  title?: string
157
+ /**
158
+ * One line under the title saying what the list is for. Worth writing for
159
+ * anything a merchant meets for the first time — catalogs, price lists,
160
+ * transfers — and worth leaving off a list that explains itself.
161
+ */
162
+ description?: string
163
+ /**
164
+ * Documentation for the feature, appended to `description` as a "Learn more"
165
+ * link. A path is relative to the user guide (`'catalogs'`); a full URL is
166
+ * used as given, so a plugin can point at its own docs.
167
+ */
168
+ docsPath?: string
113
169
  columns: ColumnDef<T>[]
114
170
  searchParam?: string
115
171
  searchPlaceholder?: string
@@ -0,0 +1,68 @@
1
+ import i18n from 'i18next'
2
+ import { beforeAll, describe, expect, it } from 'vitest'
3
+ import { permissionGroupLabel, typeDescription, typeLabel } from './type-labels'
4
+
5
+ beforeAll(async () => {
6
+ await i18n.init({
7
+ lng: 'en',
8
+ resources: {
9
+ en: {
10
+ translation: {
11
+ admin: {
12
+ types: {
13
+ promotion_rule: {
14
+ item_total: { name: 'Item total', description: 'Order total meets these criteria' },
15
+ // A type whose translation names it but says nothing more.
16
+ first_order: { name: 'First order' },
17
+ },
18
+ permission_group: { catalog: { name: 'Catalog' } },
19
+ },
20
+ },
21
+ },
22
+ },
23
+ },
24
+ })
25
+ })
26
+
27
+ describe('typeLabel', () => {
28
+ it('prefers the translation over the API label', () => {
29
+ expect(typeLabel('promotion_rule', 'item_total', 'Item Total (from API)')).toBe('Item total')
30
+ })
31
+
32
+ it('falls back to the API label for a type it has no translation for', () => {
33
+ // The extension case: a gem ships a Ruby rule but no dashboard locales.
34
+ expect(typeLabel('promotion_rule', 'wishlist_count', 'Wishlist count')).toBe('Wishlist count')
35
+ })
36
+
37
+ it('falls back to the code when the API sends nothing usable', () => {
38
+ expect(typeLabel('promotion_rule', 'wishlist_count')).toBe('wishlist_count')
39
+ expect(typeLabel('promotion_rule', 'wishlist_count', ' ')).toBe('wishlist_count')
40
+ })
41
+ })
42
+
43
+ describe('typeDescription', () => {
44
+ it('prefers the translation over the API description', () => {
45
+ expect(typeDescription('promotion_rule', 'item_total', 'from API')).toBe(
46
+ 'Order total meets these criteria',
47
+ )
48
+ })
49
+
50
+ it('falls back to the API description when only the name is translated', () => {
51
+ expect(typeDescription('promotion_rule', 'first_order', 'Must be their first order')).toBe(
52
+ 'Must be their first order',
53
+ )
54
+ })
55
+
56
+ it('returns an empty string rather than the code when nothing describes the type', () => {
57
+ // Descriptions are optional helper copy — a bare code would read as noise.
58
+ expect(typeDescription('promotion_rule', 'wishlist_count')).toBe('')
59
+ })
60
+ })
61
+
62
+ describe('permissionGroupLabel', () => {
63
+ it('translates a known group and falls back for an unknown one', () => {
64
+ expect(permissionGroupLabel('catalog', 'Catalog (API)')).toBe('Catalog')
65
+ expect(permissionGroupLabel('logistics', 'Logistics')).toBe('Logistics')
66
+ expect(permissionGroupLabel('logistics')).toBe('logistics')
67
+ })
68
+ })
@@ -0,0 +1,78 @@
1
+ import i18n from 'i18next'
2
+
3
+ /**
4
+ * Type catalog families the Admin API exposes. Each entry on the wire carries
5
+ * a stable snake_case `type` code; the `label`/`description` beside it are a
6
+ * fallback for clients without their own translation, not the source of
7
+ * truth. The dashboard renders the code through its own locale files and
8
+ * reaches for the fallback only when it has no key — which is what an
9
+ * extension gem shipping a Ruby type but no dashboard locales gets.
10
+ */
11
+ export type TypeFamily =
12
+ | 'promotion_rule'
13
+ | 'promotion_action'
14
+ | 'calculator'
15
+ | 'order_routing_rule'
16
+ | 'collection_rule'
17
+ | 'price_rule'
18
+ | 'commission_rule'
19
+ | 'delivery_method_rule'
20
+ | 'seller_requirement'
21
+ | 'integration'
22
+ | 'permission'
23
+
24
+ type Facet = 'name' | 'description'
25
+
26
+ function i18nKey(family: TypeFamily, code: string, facet: Facet): string {
27
+ return `admin.types.${family}.${code}.${facet}`
28
+ }
29
+
30
+ /**
31
+ * Localized name for an API type code.
32
+ *
33
+ * @param family which type catalog the code belongs to
34
+ * @param code the stable snake_case type code (e.g. `item_total`, `flat_rate`)
35
+ * @param fallback the API-provided label, used when no translation exists
36
+ * @returns the localized name, the fallback, or the bare code
37
+ */
38
+ export function typeLabel(family: TypeFamily, code: string, fallback?: string | null): string {
39
+ const key = i18nKey(family, code, 'name')
40
+ if (i18n.exists(key)) return i18n.t(key)
41
+ return fallback?.trim() || code
42
+ }
43
+
44
+ /**
45
+ * Localized description for an API type code.
46
+ *
47
+ * Returns an empty string when neither a translation nor a fallback exists —
48
+ * descriptions are optional helper copy, so callers render nothing rather
49
+ * than a bare code.
50
+ *
51
+ * @param family which type catalog the code belongs to
52
+ * @param code the stable snake_case type code
53
+ * @param fallback the API-provided description, used when no translation exists
54
+ * @returns the localized description, the fallback, or an empty string
55
+ */
56
+ export function typeDescription(
57
+ family: TypeFamily,
58
+ code: string,
59
+ fallback?: string | null,
60
+ ): string {
61
+ const key = i18nKey(family, code, 'description')
62
+ if (i18n.exists(key)) return i18n.t(key)
63
+ return fallback?.trim() || ''
64
+ }
65
+
66
+ /**
67
+ * Localized label for a group of permission catalog entries, keyed by the
68
+ * group code the API returns (`catalog`, `orders`, …).
69
+ *
70
+ * @param code the group code
71
+ * @param fallback the API-provided group label
72
+ * @returns the localized group label, the fallback, or the bare code
73
+ */
74
+ export function permissionGroupLabel(code: string, fallback?: string | null): string {
75
+ const key = `admin.types.permission_group.${code}.name`
76
+ if (i18n.exists(key)) return i18n.t(key)
77
+ return fallback?.trim() || code
78
+ }