@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.
- package/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- package/src/components/top-bar.tsx +0 -209
package/src/lib/permissions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/lib/query-keys.ts
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
import type { QueryKey } from '@tanstack/react-query'
|
|
2
2
|
import { useCallback } from 'react'
|
|
3
|
-
import {
|
|
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,
|
|
8
|
+
* [resource, tenantId, ...rest]
|
|
9
9
|
*
|
|
10
10
|
* Use `useResourceKey` (the hook) from inside React components to build
|
|
11
|
-
*
|
|
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
|
|
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
|
|
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
|
|
34
|
-
* `<
|
|
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
|
|
38
|
-
return [resource,
|
|
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
|
|
53
|
+
const tenantId = useTenantId()
|
|
54
54
|
return useCallback(
|
|
55
|
-
(resource: string, ...rest: ReadonlyArray<unknown>): QueryKey => [resource,
|
|
56
|
-
[
|
|
55
|
+
(resource: string, ...rest: ReadonlyArray<unknown>): QueryKey => [resource, tenantId, ...rest],
|
|
56
|
+
[tenantId],
|
|
57
57
|
)
|
|
58
58
|
}
|
|
59
59
|
|
package/src/lib/sample-csv.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
import type { ImportType } from '@spree/admin-sdk'
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Import types that ship a populated example CSV in Spree's
|
|
5
3
|
* `core/db/sample_data`. Keyed by the API's type shorthand (`"products"`),
|
|
6
4
|
* which is also the CSV's filename stem.
|
|
7
5
|
*
|
|
8
6
|
* Only used to decide whether to render the download link — the file itself is
|
|
9
|
-
* served by `
|
|
10
|
-
* installed Spree version. A type missing from this list simply hides the link;
|
|
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;
|
|
11
9
|
* the endpoint is the authority and 404s for anything it has no example for.
|
|
12
10
|
*/
|
|
13
|
-
const TYPES_WITH_SAMPLE_CSV = new Set([
|
|
11
|
+
const TYPES_WITH_SAMPLE_CSV = new Set([
|
|
12
|
+
'products',
|
|
13
|
+
'customers',
|
|
14
|
+
'product_translations',
|
|
15
|
+
'price_list_prices',
|
|
16
|
+
])
|
|
14
17
|
|
|
15
18
|
/** Whether an import type has an example CSV to offer. */
|
|
16
|
-
export function hasSampleCsv(type:
|
|
19
|
+
export function hasSampleCsv(type: string): boolean {
|
|
17
20
|
return TYPES_WITH_SAMPLE_CSV.has(type)
|
|
18
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
|
-
/**
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -9,11 +9,27 @@ 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
|
|
19
35
|
/**
|
|
@@ -26,6 +42,20 @@ interface ColumnDefBase<T = any> {
|
|
|
26
42
|
className?: string
|
|
27
43
|
/** Whether this column is displayable in the table. Set to false for filter-only columns. */
|
|
28
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
|
|
29
59
|
}
|
|
30
60
|
|
|
31
61
|
/**
|
|
@@ -43,6 +73,14 @@ export interface ResourceFilterConfig<R extends { id: string } = { id: string }>
|
|
|
43
73
|
getOptionLabel: (option: R) => string
|
|
44
74
|
placeholder?: string
|
|
45
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[] }>
|
|
46
84
|
}
|
|
47
85
|
|
|
48
86
|
/**
|
|
@@ -116,6 +154,18 @@ export interface SortOption {
|
|
|
116
154
|
export interface TableDef<T = any> {
|
|
117
155
|
key: string
|
|
118
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
|
|
119
169
|
columns: ColumnDef<T>[]
|
|
120
170
|
searchParam?: string
|
|
121
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
|
+
}
|