@vendure/dashboard 3.5.0-minor-202510031341 → 3.5.0-minor-202510071456

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 (26) hide show
  1. package/dist/plugin/default-page.html +1 -1
  2. package/dist/vite/utils/tsconfig-utils.js +2 -1
  3. package/package.json +5 -4
  4. package/src/app/routes/_authenticated/_global-settings/global-settings.tsx +4 -8
  5. package/src/app/routes/_authenticated/_global-settings/utils/global-languages.ts +268 -0
  6. package/src/app/routes/_authenticated/_orders/components/order-address.tsx +15 -15
  7. package/src/app/routes/_authenticated/_orders/components/order-detail-shared.tsx +4 -4
  8. package/src/app/routes/_authenticated/_product-variants/components/add-currency-dropdown.tsx +49 -0
  9. package/src/app/routes/_authenticated/_product-variants/components/add-stock-location-dropdown.tsx +56 -0
  10. package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +12 -0
  11. package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +178 -50
  12. package/src/app/routes/_authenticated/_products/components/product-variants-table.tsx +0 -11
  13. package/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx +3 -14
  14. package/src/lib/components/data-input/customer-group-input.tsx +0 -1
  15. package/src/lib/components/data-input/money-input.tsx +7 -11
  16. package/src/lib/components/data-input/number-input.tsx +6 -1
  17. package/src/lib/components/data-table/data-table-filter-badge.tsx +15 -8
  18. package/src/lib/components/data-table/data-table.tsx +2 -2
  19. package/src/lib/components/layout/generated-breadcrumbs.tsx +4 -12
  20. package/src/lib/components/shared/configurable-operation-input.tsx +1 -1
  21. package/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx +0 -2
  22. package/src/lib/framework/extension-api/types/layout.ts +41 -1
  23. package/src/lib/framework/form-engine/value-transformers.ts +8 -1
  24. package/src/lib/framework/layout-engine/page-layout.tsx +58 -48
  25. package/src/lib/framework/page/detail-page.tsx +12 -15
  26. package/src/lib/providers/channel-provider.tsx +1 -0
@@ -1,4 +1,5 @@
1
1
  import { MoneyInput } from '@/vdb/components/data-input/money-input.js';
2
+ import { NumberInput } from '@/vdb/components/data-input/number-input.js';
2
3
  import { AssignedFacetValues } from '@/vdb/components/shared/assigned-facet-values.js';
3
4
  import { EntityAssets } from '@/vdb/components/shared/entity-assets.js';
4
5
  import { ErrorPage } from '@/vdb/components/shared/error-page.js';
@@ -24,15 +25,21 @@ import {
24
25
  } from '@/vdb/framework/layout-engine/page-layout.js';
25
26
  import { detailPageRouteLoader } from '@/vdb/framework/page/detail-page-route-loader.js';
26
27
  import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
28
+ import { api } from '@/vdb/graphql/api.js';
27
29
  import { useChannel } from '@/vdb/hooks/use-channel.js';
28
30
  import { Trans, useLingui } from '@lingui/react/macro';
31
+ import { useQuery } from '@tanstack/react-query';
29
32
  import { createFileRoute, useNavigate } from '@tanstack/react-router';
30
- import { Fragment } from 'react/jsx-runtime';
33
+ import { VariablesOf } from 'gql.tada';
34
+ import { Trash } from 'lucide-react';
31
35
  import { toast } from 'sonner';
36
+ import { AddCurrencyDropdown } from './components/add-currency-dropdown.js';
37
+ import { AddStockLocationDropdown } from './components/add-stock-location-dropdown.js';
32
38
  import { VariantPriceDetail } from './components/variant-price-detail.js';
33
39
  import {
34
40
  createProductVariantDocument,
35
41
  productVariantDetailDocument,
42
+ stockLocationsQueryDocument,
36
43
  updateProductVariantDocument,
37
44
  } from './product-variants.graphql.js';
38
45
 
@@ -57,6 +64,8 @@ export const Route = createFileRoute('/_authenticated/_product-variants/product-
57
64
  errorComponent: ({ error }) => <ErrorPage message={error.message} />,
58
65
  });
59
66
 
67
+ type PriceInput = NonNullable<VariablesOf<typeof updateProductVariantDocument>['input']['prices']>[number];
68
+
60
69
  function ProductVariantDetailPage() {
61
70
  const params = Route.useParams();
62
71
  const navigate = useNavigate();
@@ -64,6 +73,11 @@ function ProductVariantDetailPage() {
64
73
  const { t } = useLingui();
65
74
  const { activeChannel } = useChannel();
66
75
 
76
+ const { data: stockLocationsData } = useQuery({
77
+ queryKey: ['stockLocations'],
78
+ queryFn: () => api.query(stockLocationsQueryDocument, {}),
79
+ });
80
+
67
81
  const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
68
82
  pageId,
69
83
  queryDocument: productVariantDetailDocument,
@@ -79,7 +93,7 @@ function ProductVariantDetailPage() {
79
93
  facetValueIds: entity.facetValues.map(facetValue => facetValue.id),
80
94
  taxCategoryId: entity.taxCategory.id,
81
95
  price: entity.price,
82
- prices: [],
96
+ prices: entity.prices,
83
97
  trackInventory: entity.trackInventory,
84
98
  outOfStockThreshold: entity.outOfStockThreshold,
85
99
  stockLevels: entity.stockLevels.map(stockLevel => ({
@@ -117,7 +131,74 @@ function ProductVariantDetailPage() {
117
131
  },
118
132
  });
119
133
 
120
- const [price, taxCategoryId] = form.watch(['price', 'taxCategoryId']);
134
+ const availableCurrencies = activeChannel?.availableCurrencyCodes ?? [];
135
+ const [prices, taxCategoryId, stockLevels] = form.watch(['prices', 'taxCategoryId', 'stockLevels']);
136
+
137
+ // Filter out deleted prices for display
138
+ const activePrices = prices?.filter(p => !p.delete) ?? [];
139
+
140
+ // Get currencies that are currently active (not deleted)
141
+ const usedCurrencies = activePrices.map(p => p.currencyCode);
142
+ const unusedCurrencies = availableCurrencies.filter(c => !usedCurrencies.includes(c));
143
+
144
+ // Get used stock location IDs
145
+ const usedStockLocationIds = stockLevels?.map(sl => sl.stockLocationId) ?? [];
146
+
147
+ const handleAddCurrency = (currencyCode: string) => {
148
+ const currentPrices = form.getValues('prices') || [];
149
+
150
+ // Check if this currency already exists (including deleted ones)
151
+ const existingPriceIndex = currentPrices.findIndex(p => p.currencyCode === currencyCode);
152
+
153
+ if (existingPriceIndex !== -1) {
154
+ // Currency exists, mark it as not deleted
155
+ const updatedPrices = [...currentPrices];
156
+ updatedPrices[existingPriceIndex] = {
157
+ ...updatedPrices[existingPriceIndex],
158
+ delete: false,
159
+ };
160
+ form.setValue('prices', updatedPrices, {
161
+ shouldDirty: true,
162
+ shouldValidate: true,
163
+ });
164
+ } else {
165
+ // Add new currency
166
+ const newPrice = {
167
+ currencyCode,
168
+ price: 0,
169
+ delete: false,
170
+ } as PriceInput;
171
+ form.setValue('prices', [...currentPrices, newPrice], {
172
+ shouldDirty: true,
173
+ shouldValidate: true,
174
+ });
175
+ }
176
+ };
177
+
178
+ const handleRemoveCurrency = (indexToRemove: number) => {
179
+ const currentPrices = form.getValues('prices') || [];
180
+ const updatedPrices = [...currentPrices];
181
+ updatedPrices[indexToRemove] = {
182
+ ...updatedPrices[indexToRemove],
183
+ delete: true,
184
+ };
185
+ form.setValue('prices', updatedPrices, {
186
+ shouldDirty: true,
187
+ shouldValidate: true,
188
+ });
189
+ };
190
+
191
+ const handleAddStockLocation = (stockLocationId: string, stockLocationName: string) => {
192
+ const currentStockLevels = form.getValues('stockLevels') || [];
193
+ const newStockLevel = {
194
+ stockLocationId,
195
+ stockOnHand: 0,
196
+ };
197
+ form.setValue('stockLevels', [...currentStockLevels, newStockLevel], {
198
+ shouldDirty: true,
199
+ shouldValidate: true,
200
+ });
201
+ };
121
202
 
122
203
  return (
123
204
  <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
@@ -168,7 +249,7 @@ function ProductVariantDetailPage() {
168
249
  <CustomFieldsPageBlock column="main" entityType="ProductVariant" control={form.control} />
169
250
 
170
251
  <PageBlock column="main" blockId="price-and-tax" title={<Trans>Price and tax</Trans>}>
171
- <div className="grid grid-cols-2 gap-4 items-start">
252
+ <DetailFormGrid>
172
253
  <FormFieldWrapper
173
254
  control={form.control}
174
255
  name="taxCategoryId"
@@ -177,56 +258,62 @@ function ProductVariantDetailPage() {
177
258
  <TaxCategorySelector value={field.value} onChange={field.onChange} />
178
259
  )}
179
260
  />
261
+ </DetailFormGrid>
262
+ {activePrices.map((price, displayIndex) => {
263
+ // Find the actual index in the full prices array
264
+ const actualIndex = prices?.findIndex(p => p === price) ?? displayIndex;
180
265
 
181
- <div>
182
- <FormFieldWrapper
183
- control={form.control}
184
- name="price"
185
- label={<Trans>Price</Trans>}
186
- render={({ field }) => (
187
- <MoneyInput {...field} currency={entity?.currencyCode} />
188
- )}
189
- />
190
- <VariantPriceDetail
191
- priceIncludesTax={activeChannel?.pricesIncludeTax ?? false}
192
- price={price}
193
- currencyCode={
194
- entity?.currencyCode ?? activeChannel?.defaultCurrencyCode ?? ''
195
- }
196
- taxCategoryId={taxCategoryId}
197
- />
198
- </div>
199
- </div>
266
+ const currencyCodeLabel = (
267
+ <div className="uppercase text-muted-foreground">{price.currencyCode}</div>
268
+ );
269
+ const priceLabel = (
270
+ <div className="flex gap-1 items-center justify-between">
271
+ <Trans>Price</Trans> {activePrices.length > 1 ? currencyCodeLabel : null}
272
+ </div>
273
+ );
274
+ return (
275
+ <DetailFormGrid key={price.currencyCode}>
276
+ <div className="flex gap-1 items-end">
277
+ <FormFieldWrapper
278
+ control={form.control}
279
+ name={`prices.${actualIndex}.price`}
280
+ label={priceLabel}
281
+ render={({ field }) => (
282
+ <MoneyInput {...field} currency={price.currencyCode} />
283
+ )}
284
+ />
285
+ {activePrices.length > 1 && (
286
+ <Button
287
+ type="button"
288
+ variant="ghost"
289
+ size="sm"
290
+ onClick={() => handleRemoveCurrency(actualIndex)}
291
+ className="h-6 w-6 p-0 mb-2 hover:text-destructive hover:bg-destructive-100"
292
+ >
293
+ <Trash className="size-4" />
294
+ </Button>
295
+ )}
296
+ </div>
297
+ <VariantPriceDetail
298
+ priceIncludesTax={activeChannel?.pricesIncludeTax ?? false}
299
+ price={price.price}
300
+ currencyCode={
301
+ price.currencyCode ?? activeChannel?.defaultCurrencyCode ?? ''
302
+ }
303
+ taxCategoryId={taxCategoryId}
304
+ />
305
+ </DetailFormGrid>
306
+ );
307
+ })}
308
+ {unusedCurrencies.length ? (
309
+ <AddCurrencyDropdown
310
+ onCurrencySelect={handleAddCurrency}
311
+ unusedCurrencies={unusedCurrencies}
312
+ />
313
+ ) : null}
200
314
  </PageBlock>
201
315
  <PageBlock column="main" blockId="stock" title={<Trans>Stock</Trans>}>
202
316
  <DetailFormGrid>
203
- {entity?.stockLevels.map((stockLevel, index) => (
204
- <Fragment key={stockLevel.id}>
205
- <FormFieldWrapper
206
- control={form.control}
207
- name={`stockLevels.${index}.stockOnHand`}
208
- label={<Trans>Stock level</Trans>}
209
- render={({ field }) => (
210
- <Input
211
- type="number"
212
- value={field.value}
213
- onChange={e => {
214
- field.onChange(e.target.valueAsNumber);
215
- }}
216
- />
217
- )}
218
- />
219
- <div>
220
- <FormItem>
221
- <FormLabel>
222
- <Trans>Allocated</Trans>
223
- </FormLabel>
224
- <div className="text-sm pt-1.5">{stockLevel.stockAllocated}</div>
225
- </FormItem>
226
- </div>
227
- </Fragment>
228
- ))}
229
-
230
317
  <FormFieldWrapper
231
318
  control={form.control}
232
319
  name="trackInventory"
@@ -292,6 +379,47 @@ function ProductVariantDetailPage() {
292
379
  )}
293
380
  />
294
381
  </DetailFormGrid>
382
+ {stockLevels?.map((stockLevel, index) => {
383
+ const stockAllocated =
384
+ entity?.stockLevels.find(sl => sl.stockLocation.id === stockLevel.stockLocationId)
385
+ ?.stockAllocated ?? 0;
386
+ const stockLocationName = stockLocationsData?.stockLocations.items?.find(
387
+ sl => sl.id === stockLevel.stockLocationId,
388
+ )?.name;
389
+ const stockLocationNameLabel =
390
+ stockLevels.length > 1 ? (
391
+ <div className="text-muted-foreground">{stockLocationName}</div>
392
+ ) : null;
393
+ const stockLabel = (
394
+ <>
395
+ <Trans>Stock level</Trans>
396
+ {stockLocationNameLabel}
397
+ </>
398
+ );
399
+ return (
400
+ <DetailFormGrid key={stockLevel.stockLocationId}>
401
+ <FormFieldWrapper
402
+ control={form.control}
403
+ name={`stockLevels.${index}.stockOnHand`}
404
+ label={stockLabel}
405
+ render={({ field }) => <NumberInput {...field} value={field.value} />}
406
+ />
407
+ <div>
408
+ <FormItem>
409
+ <FormLabel>
410
+ <Trans>Allocated</Trans>
411
+ </FormLabel>
412
+ <div className="text-sm pt-1.5">{stockAllocated}</div>
413
+ </FormItem>
414
+ </div>
415
+ </DetailFormGrid>
416
+ );
417
+ })}
418
+ <AddStockLocationDropdown
419
+ availableStockLocations={stockLocationsData?.stockLocations.items ?? []}
420
+ usedStockLocationIds={usedStockLocationIds}
421
+ onStockLocationSelect={handleAddStockLocation}
422
+ />
295
423
  </PageBlock>
296
424
 
297
425
  <PageBlock column="side" blockId="facet-values">
@@ -5,7 +5,6 @@ import {
5
5
  PaginatedListRefresherRegisterFn,
6
6
  } from '@/vdb/components/shared/paginated-list-data-table.js';
7
7
  import { StockLevelLabel } from '@/vdb/components/shared/stock-level-label.js';
8
- import { graphql } from '@/vdb/graphql/graphql.js';
9
8
  import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
10
9
  import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
11
10
  import { useState } from 'react';
@@ -17,15 +16,6 @@ import {
17
16
  } from '../../_product-variants/components/product-variant-bulk-actions.js';
18
17
  import { productVariantListDocument } from '../products.graphql.js';
19
18
 
20
- export const deleteProductVariantDocument = graphql(`
21
- mutation DeleteProductVariant($id: ID!) {
22
- deleteProductVariant(id: $id) {
23
- result
24
- message
25
- }
26
- }
27
- `);
28
-
29
19
  interface ProductVariantsTableProps {
30
20
  productId: string;
31
21
  registerRefresher?: PaginatedListRefresherRegisterFn;
@@ -47,7 +37,6 @@ export function ProductVariantsTable({
47
37
  <PaginatedListDataTable
48
38
  registerRefresher={registerRefresher}
49
39
  listQuery={productVariantListDocument}
50
- deleteMutation={deleteProductVariantDocument}
51
40
  transformVariables={variables => ({
52
41
  ...variables,
53
42
  productId,
@@ -1,4 +1,5 @@
1
1
  import { DateTimeInput } from '@/vdb/components/data-input/datetime-input.js';
2
+ import { NumberInput } from '@/vdb/components/data-input/number-input.js';
2
3
  import { RichTextInput } from '@/vdb/components/data-input/rich-text-input.js';
3
4
  import { ErrorPage } from '@/vdb/components/shared/error-page.js';
4
5
  import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
@@ -202,25 +203,13 @@ function PromotionDetailPage() {
202
203
  control={form.control}
203
204
  name="perCustomerUsageLimit"
204
205
  label={<Trans>Per customer usage limit</Trans>}
205
- render={({ field }) => (
206
- <Input
207
- type="number"
208
- value={field.value ?? ''}
209
- onChange={e => field.onChange(e.target.valueAsNumber)}
210
- />
211
- )}
206
+ render={({ field }) => <NumberInput {...field} value={field.value ?? ''} />}
212
207
  />
213
208
  <FormFieldWrapper
214
209
  control={form.control}
215
210
  name="usageLimit"
216
211
  label={<Trans>Usage limit</Trans>}
217
- render={({ field }) => (
218
- <Input
219
- type="number"
220
- value={field.value ?? ''}
221
- onChange={e => field.onChange(e.target.valueAsNumber)}
222
- />
223
- )}
212
+ render={({ field }) => <NumberInput {...field} value={field.value ?? ''} />}
224
213
  />
225
214
  </DetailFormGrid>
226
215
  </PageBlock>
@@ -26,7 +26,6 @@ export function CustomerGroupInput({
26
26
  disabled,
27
27
  fieldDef,
28
28
  }: Readonly<DashboardFormComponentProps>) {
29
- console.log(fieldDef);
30
29
  const { data } = useQuery({
31
30
  queryKey: ['customerGroups', value],
32
31
  queryFn: () =>
@@ -1,11 +1,11 @@
1
1
  import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
2
- import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
3
2
  import { useEffect, useMemo, useState } from 'react';
4
3
  import { AffixedInput } from './affixed-input.js';
5
4
 
6
5
  import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
7
6
  import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
8
7
  import { useChannel } from '@/vdb/hooks/use-channel.js';
8
+ import { useDisplayLocale } from '@/vdb/hooks/use-display-locale.js';
9
9
 
10
10
  export interface MoneyInputProps extends DashboardFormComponentProps {
11
11
  currency?: string;
@@ -24,9 +24,7 @@ export function MoneyInput(props: Readonly<MoneyInputProps>) {
24
24
  const { activeChannel } = useChannel();
25
25
  const activeCurrency = currency ?? activeChannel?.defaultCurrencyCode;
26
26
  const readOnly = isReadonlyField(props.fieldDef);
27
- const {
28
- settings: { displayLanguage, displayLocale },
29
- } = useUserSettings();
27
+ const { bcp47Tag } = useDisplayLocale();
30
28
  const { toMajorUnits, toMinorUnits } = useLocalFormat();
31
29
  const [displayValue, setDisplayValue] = useState(toMajorUnits(value).toFixed(2));
32
30
 
@@ -40,32 +38,30 @@ export function MoneyInput(props: Readonly<MoneyInputProps>) {
40
38
  if (!activeCurrency) {
41
39
  return false;
42
40
  }
43
- const locale = displayLocale || displayLanguage.replace(/_/g, '-');
44
- const parts = new Intl.NumberFormat(locale, {
41
+ const parts = new Intl.NumberFormat(bcp47Tag, {
45
42
  style: 'currency',
46
43
  currency: activeCurrency,
47
44
  currencyDisplay: 'symbol',
48
45
  }).formatToParts();
49
46
  const NaNString = parts.find(p => p.type === 'nan')?.value ?? 'NaN';
50
- const localised = new Intl.NumberFormat(locale, {
47
+ const localised = new Intl.NumberFormat(bcp47Tag, {
51
48
  style: 'currency',
52
49
  currency: activeCurrency,
53
50
  currencyDisplay: 'symbol',
54
51
  }).format(undefined as any);
55
52
  return localised.indexOf(NaNString) > 0;
56
- }, [activeCurrency, displayLocale, displayLanguage]);
53
+ }, [activeCurrency, bcp47Tag]);
57
54
 
58
55
  // Get the currency symbol
59
56
  const currencySymbol = useMemo(() => {
60
57
  if (!activeCurrency) return '';
61
- const locale = displayLocale || displayLanguage.replace(/_/g, '-');
62
- const parts = new Intl.NumberFormat(locale, {
58
+ const parts = new Intl.NumberFormat(bcp47Tag, {
63
59
  style: 'currency',
64
60
  currency: activeCurrency,
65
61
  currencyDisplay: 'symbol',
66
62
  }).formatToParts();
67
63
  return parts.find(p => p.type === 'currency')?.value ?? activeCurrency;
68
- }, [activeCurrency, displayLocale, displayLanguage]);
64
+ }, [activeCurrency, bcp47Tag]);
69
65
 
70
66
  return (
71
67
  <AffixedInput
@@ -22,7 +22,12 @@ export function NumberInput({ fieldDef, onChange, ...fieldProps }: Readonly<Dash
22
22
  const shouldUseAffixedInput = prefix || suffix;
23
23
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
24
24
  if (readOnly) return;
25
- onChange(e.target.valueAsNumber);
25
+ const numValue = e.target.valueAsNumber;
26
+ if (Number.isNaN(numValue)) {
27
+ onChange(null);
28
+ } else {
29
+ onChange(e.target.valueAsNumber);
30
+ }
26
31
  };
27
32
  if (shouldUseAffixedInput) {
28
33
  return (
@@ -19,18 +19,21 @@ export function DataTableFilterBadge({
19
19
  return (
20
20
  <Badge
21
21
  key={filter.id}
22
- className="flex gap-1 items-center font-mono cursor-pointer "
22
+ className="flex gap-1 flex-wrap items-center font-mono cursor-pointer"
23
23
  variant="outline"
24
24
  onClick={() => onRemove(filter)}
25
25
  >
26
- <Filter size="12" className="opacity-50" />
27
- <div>{filter.id}</div>
28
- <div className="text-muted-foreground">
26
+ <Filter size="12" className="opacity-50 flex-shrink-0" />
27
+ <div className="@xs:overflow-hidden @xs:text-ellipsis @xs:whitespace-nowrap" title={filter.id}>
28
+ {filter.id}
29
+ </div>
30
+ <div className="text-muted-foreground flex-shrink-0">
29
31
  <HumanReadableOperator operator={operator as Operator} mode="short" />
30
32
  </div>
31
- <FilterValue value={value} dataType={dataType} currencyCode={currencyCode} />
32
-
33
- <XIcon className="h-4" />
33
+ <div className="@xs:overflow-hidden @xs:text-ellipsis @xs:whitespace-nowrap flex flex-col @xl:flex-row @2xl:gap-1">
34
+ <FilterValue value={value} dataType={dataType} currencyCode={currencyCode} />
35
+ </div>
36
+ <XIcon className="h-4 flex-shrink-0" />
34
37
  </Badge>
35
38
  );
36
39
  }
@@ -65,7 +68,11 @@ function FilterValue({
65
68
  );
66
69
  }
67
70
  if (typeof value === 'string' && isDateIsoString(value)) {
68
- return <div>{formatDate(value, { dateStyle: 'short', timeStyle: 'short' })}</div>;
71
+ return (
72
+ <div title={formatDate(value, { dateStyle: 'short', timeStyle: 'long' })}>
73
+ {formatDate(value, { dateStyle: 'short' })}
74
+ </div>
75
+ );
69
76
  }
70
77
  if (typeof value === 'boolean') {
71
78
  return <div>{value ? 'true' : 'false'}</div>;
@@ -243,11 +243,11 @@ export function DataTable<TData>({
243
243
 
244
244
  {(pageId && onFilterChange && globalViews.length > 0) ||
245
245
  columnFilters.filter(f => !facetedFilters?.[f.id]).length > 0 ? (
246
- <div className="flex items-center justify-between bg-muted/40 rounded border border-border p-2">
246
+ <div className="flex items-center justify-between bg-muted/40 rounded border border-border p-2 @container">
247
247
  <div className="flex items-center">
248
248
  {pageId && onFilterChange && <GlobalViewsBar />}
249
249
  </div>
250
- <div className="flex gap-1 items-center">
250
+ <div className="flex gap-1 flex-wrap items-center">
251
251
  {columnFilters
252
252
  .filter(f => !facetedFilters?.[f.id])
253
253
  .map(f => {
@@ -7,9 +7,8 @@ import {
7
7
  } from '@/vdb/components/ui/breadcrumb.js';
8
8
  import type { NavMenuItem, NavMenuSection } from '@/vdb/framework/nav-menu/nav-menu-extensions.js';
9
9
  import { getNavMenuConfig } from '@/vdb/framework/nav-menu/nav-menu-extensions.js';
10
- import { useDisplayLocale } from '@/vdb/hooks/use-display-locale.js';
11
10
  import { useLingui } from '@lingui/react';
12
- import { Link, useRouter, useRouterState } from '@tanstack/react-router';
11
+ import { Link, useRouterState } from '@tanstack/react-router';
13
12
  import * as React from 'react';
14
13
  import { Fragment } from 'react';
15
14
 
@@ -25,11 +24,8 @@ export type PageBreadcrumb = BreadcrumbPair | BreadcrumbShorthand;
25
24
  export function GeneratedBreadcrumbs() {
26
25
  const matches = useRouterState({ select: s => s.matches });
27
26
  const currentPath = useRouterState({ select: s => s.location.pathname });
28
- const router = useRouter();
29
27
  const { i18n } = useLingui();
30
28
  const navMenuConfig = getNavMenuConfig();
31
- const { bcp47Tag } = useDisplayLocale();
32
- const basePath = router.basepath || '';
33
29
 
34
30
  const normalizeBreadcrumb = (breadcrumb: any, pathname: string): BreadcrumbPair[] => {
35
31
  if (typeof breadcrumb === 'string') {
@@ -58,12 +54,11 @@ export function GeneratedBreadcrumbs() {
58
54
  .flatMap(({ pathname, loaderData }) => normalizeBreadcrumb(loaderData.breadcrumb, pathname));
59
55
  }, [matches]);
60
56
 
61
- const isBaseRoute = (p: string) => p === basePath || p === `${basePath}/`;
57
+ const isBaseRoute = (p: string) => p === '' || p === `/`;
62
58
  const pageCrumbs: BreadcrumbPair[] = rawCrumbs.filter(c => !isBaseRoute(c.path));
63
59
 
64
60
  const normalizePath = (path: string): string => {
65
- const normalizedPath = basePath && path.startsWith(basePath) ? path.slice(basePath.length) : path;
66
- return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
61
+ return path.startsWith('/') ? path : `/${path}`;
67
62
  };
68
63
 
69
64
  const pathMatches = (cleanPath: string, rawUrl?: string): boolean => {
@@ -115,10 +110,7 @@ export function GeneratedBreadcrumbs() {
115
110
  return undefined;
116
111
  };
117
112
 
118
- const sectionCrumb = React.useMemo(
119
- () => findSectionCrumb(currentPath),
120
- [currentPath, basePath, navMenuConfig],
121
- );
113
+ const sectionCrumb = React.useMemo(() => findSectionCrumb(currentPath), [currentPath, navMenuConfig]);
122
114
  const breadcrumbs: BreadcrumbPair[] = React.useMemo(() => {
123
115
  const arr = sectionCrumb ? [sectionCrumb, ...pageCrumbs] : pageCrumbs;
124
116
  return arr.filter(
@@ -144,7 +144,7 @@ export function interpolateDescription(
144
144
  (substring: string, argName: string) => {
145
145
  const normalizedArgName = argName.toLowerCase();
146
146
  const value = values.find(v => v.name === normalizedArgName)?.value;
147
- if (value == null) {
147
+ if (value == null || value === '') {
148
148
  return '_';
149
149
  }
150
150
  let formatted = value;
@@ -5,7 +5,6 @@ import {
5
5
  OrderStateCell,
6
6
  } from '@/vdb/components/shared/table-cell/order-table-cell-components.js';
7
7
  import { Button } from '@/vdb/components/ui/button.js';
8
- import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
9
8
  import { useLingui } from '@lingui/react/macro';
10
9
  import { Link } from '@tanstack/react-router';
11
10
  import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
@@ -39,7 +38,6 @@ export function LatestOrdersWidget() {
39
38
  },
40
39
  },
41
40
  ]);
42
- const { formatCurrency } = useLocalFormat();
43
41
 
44
42
  // Update filters when date range changes
45
43
  useEffect(() => {
@@ -93,9 +93,49 @@ export type PageBlockLocation = {
93
93
  * @since 3.3.0
94
94
  */
95
95
  export interface DashboardPageBlockDefinition {
96
+ /**
97
+ * @description
98
+ * An ID for the page block. Should be unique at least
99
+ * to the page in which it appears.
100
+ */
96
101
  id: string;
102
+ /**
103
+ * @description
104
+ * An optional title for the page block
105
+ */
97
106
  title?: React.ReactNode;
107
+ /**
108
+ * @description
109
+ * The location of the page block. It specifies the pageId, and then the
110
+ * relative location compared to another existing block.
111
+ */
98
112
  location: PageBlockLocation;
99
- component: React.FunctionComponent<{ context: PageContextValue }>;
113
+ /**
114
+ * @description
115
+ * The component to be rendered inside the page block.
116
+ */
117
+ component?: React.FunctionComponent<{ context: PageContextValue }>;
118
+ /**
119
+ * @description
120
+ * Control whether to render the page block depending on your custom
121
+ * logic.
122
+ *
123
+ * This can also be used to disable any built-in blocks you
124
+ * do not need to display.
125
+ *
126
+ * If you need to query aspects about the current context not immediately
127
+ * provided in the `PageContextValue`, you can also use hooks such as
128
+ * `useChannel` in this function.
129
+ *
130
+ * @since 3.5.0
131
+ */
132
+ shouldRender?: (context: PageContextValue) => boolean;
133
+ /**
134
+ * @description
135
+ * If provided, the logged-in user must have one or more of the specified
136
+ * permissions in order for the block to render.
137
+ *
138
+ * For more advanced control over rendering, use the `shouldRender` function.
139
+ */
100
140
  requiresPermission?: string | string[];
101
141
  }
@@ -29,9 +29,16 @@ export const nativeValueTransformer: ValueTransformer = {
29
29
  */
30
30
  export const jsonStringValueTransformer: ValueTransformer = {
31
31
  parse: (value: string, fieldDef: ConfigurableFieldDef) => {
32
- if (!value) {
32
+ if (value === undefined) {
33
33
  return getDefaultValue(fieldDef);
34
34
  }
35
+ // This case arises often when the administrator is actively editing
36
+ // values and clears out the input. At that point, we don't want to suddenly
37
+ // switch to the default value otherwise it results in poor UX, e.g. pressing
38
+ // backspace to delete a number would result in `0` suddenly appearing as the value.
39
+ if (value === '') {
40
+ return value;
41
+ }
35
42
 
36
43
  try {
37
44
  // For JSON string mode, parse the string to get the native value