@vendure/dashboard 3.5.0-minor-202510012036 → 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 (38) hide show
  1. package/dist/plugin/dashboard.plugin.d.ts +25 -6
  2. package/dist/plugin/dashboard.plugin.js +184 -27
  3. package/dist/plugin/default-page.html +188 -0
  4. package/dist/vite/utils/tsconfig-utils.js +2 -1
  5. package/package.json +10 -9
  6. package/src/app/routes/_authenticated/_global-settings/global-settings.tsx +4 -8
  7. package/src/app/routes/_authenticated/_global-settings/utils/global-languages.ts +268 -0
  8. package/src/app/routes/_authenticated/_orders/components/order-address.tsx +15 -15
  9. package/src/app/routes/_authenticated/_orders/components/order-detail-shared.tsx +4 -4
  10. package/src/app/routes/_authenticated/_product-variants/components/add-currency-dropdown.tsx +49 -0
  11. package/src/app/routes/_authenticated/_product-variants/components/add-stock-location-dropdown.tsx +56 -0
  12. package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +12 -0
  13. package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +178 -50
  14. package/src/app/routes/_authenticated/_products/components/product-variants-table.tsx +0 -11
  15. package/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx +3 -14
  16. package/src/app/routes/_authenticated/_shipping-methods/components/test-address-form.tsx +13 -12
  17. package/src/app/routes/_authenticated/_shipping-methods/components/test-order-builder.tsx +3 -2
  18. package/src/lib/components/data-input/customer-group-input.tsx +0 -1
  19. package/src/lib/components/data-input/money-input.tsx +7 -11
  20. package/src/lib/components/data-input/number-input.tsx +6 -1
  21. package/src/lib/components/data-table/data-table-filter-badge.tsx +15 -8
  22. package/src/lib/components/data-table/data-table.tsx +2 -2
  23. package/src/lib/components/data-table/my-views-button.tsx +12 -12
  24. package/src/lib/components/data-table/save-view-button.tsx +5 -1
  25. package/src/lib/components/layout/generated-breadcrumbs.tsx +4 -12
  26. package/src/lib/components/shared/configurable-operation-input.tsx +1 -1
  27. package/src/lib/constants.ts +10 -0
  28. package/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx +0 -2
  29. package/src/lib/framework/extension-api/types/layout.ts +41 -1
  30. package/src/lib/framework/form-engine/value-transformers.ts +8 -1
  31. package/src/lib/framework/layout-engine/page-layout.tsx +58 -48
  32. package/src/lib/framework/page/detail-page.tsx +12 -15
  33. package/src/lib/graphql/api.ts +17 -4
  34. package/src/lib/graphql/graphql-env.d.ts +29 -50
  35. package/src/lib/hooks/use-saved-views.ts +7 -0
  36. package/src/lib/providers/auth.tsx +2 -2
  37. package/src/lib/providers/channel-provider.tsx +4 -2
  38. package/src/lib/providers/user-settings.tsx +46 -5
@@ -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>
@@ -3,6 +3,7 @@ import { AccordionContent, AccordionItem, AccordionTrigger } from '@/vdb/compone
3
3
  import { Form } from '@/vdb/components/ui/form.js';
4
4
  import { Input } from '@/vdb/components/ui/input.js';
5
5
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
6
+ import { LS_KEY_SHIPPING_TEST_ADDRESS } from '@/vdb/constants.js';
6
7
  import { api } from '@/vdb/graphql/api.js';
7
8
  import { graphql } from '@/vdb/graphql/graphql.js';
8
9
  import { Trans } from '@lingui/react/macro';
@@ -43,20 +44,20 @@ export function TestAddressForm({ onAddressChange }: Readonly<TestAddressFormPro
43
44
  const form = useForm<TestAddress>({
44
45
  defaultValues: (() => {
45
46
  try {
46
- const stored = localStorage.getItem('shippingTestAddress');
47
+ const stored = localStorage.getItem(LS_KEY_SHIPPING_TEST_ADDRESS);
47
48
  return stored
48
49
  ? JSON.parse(stored)
49
50
  : {
50
- fullName: '',
51
- company: '',
52
- streetLine1: '',
53
- streetLine2: '',
54
- city: '',
55
- province: '',
56
- postalCode: '',
57
- countryCode: '',
58
- phoneNumber: '',
59
- };
51
+ fullName: '',
52
+ company: '',
53
+ streetLine1: '',
54
+ streetLine2: '',
55
+ city: '',
56
+ province: '',
57
+ postalCode: '',
58
+ countryCode: '',
59
+ phoneNumber: '',
60
+ };
60
61
  } catch {
61
62
  return {
62
63
  fullName: '',
@@ -92,7 +93,7 @@ export function TestAddressForm({ onAddressChange }: Readonly<TestAddressFormPro
92
93
  previousValuesRef.current = currentValueString;
93
94
 
94
95
  try {
95
- localStorage.setItem('shippingTestAddress', currentValueString);
96
+ localStorage.setItem(LS_KEY_SHIPPING_TEST_ADDRESS, currentValueString);
96
97
  } catch {
97
98
  // Ignore localStorage errors
98
99
  }
@@ -7,6 +7,7 @@ import { AccordionContent, AccordionItem, AccordionTrigger } from '@/vdb/compone
7
7
  import { Button } from '@/vdb/components/ui/button.js';
8
8
  import { Input } from '@/vdb/components/ui/input.js';
9
9
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/vdb/components/ui/table.js';
10
+ import { LS_KEY_SHIPPING_TEST_ORDER } from '@/vdb/constants.js';
10
11
  import { useChannel } from '@/vdb/hooks/use-channel.js';
11
12
  import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
12
13
  import { Trans } from '@lingui/react/macro';
@@ -38,7 +39,7 @@ export function TestOrderBuilder({ onOrderLinesChange }: Readonly<TestOrderBuild
38
39
  const { activeChannel } = useChannel();
39
40
  const [lines, setLines] = useState<TestOrderLine[]>(() => {
40
41
  try {
41
- const stored = localStorage.getItem('shippingTestOrder');
42
+ const stored = localStorage.getItem(LS_KEY_SHIPPING_TEST_ORDER);
42
43
  return stored ? JSON.parse(stored) : [];
43
44
  } catch {
44
45
  return [];
@@ -51,7 +52,7 @@ export function TestOrderBuilder({ onOrderLinesChange }: Readonly<TestOrderBuild
51
52
 
52
53
  useEffect(() => {
53
54
  try {
54
- localStorage.setItem('shippingTestOrder', JSON.stringify(lines));
55
+ localStorage.setItem(LS_KEY_SHIPPING_TEST_ORDER, JSON.stringify(lines));
55
56
  } catch {
56
57
  // Ignore localStorage errors
57
58
  }
@@ -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 => {
@@ -1,23 +1,27 @@
1
- import { Bookmark } from 'lucide-react';
2
- import React, { useState, useMemo } from 'react';
3
- import { Button } from '../ui/button.js';
4
- import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.js';
5
1
  import { Trans } from '@lingui/react/macro';
6
- import { UserViewsSheet } from './user-views-sheet.js';
2
+ import { Bookmark } from 'lucide-react';
3
+ import React, { useMemo, useState } from 'react';
7
4
  import { useSavedViews } from '../../hooks/use-saved-views.js';
8
5
  import { findMatchingSavedView } from '../../utils/saved-views-utils.js';
6
+ import { Button } from '../ui/button.js';
7
+ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.js';
9
8
  import { useDataTableContext } from './data-table-context.js';
9
+ import { UserViewsSheet } from './user-views-sheet.js';
10
10
 
11
11
  export const MyViewsButton: React.FC = () => {
12
12
  const [sheetOpen, setSheetOpen] = useState(false);
13
- const { userViews } = useSavedViews();
14
- const { columnFilters, searchTerm, handleApplyView } = useDataTableContext();
13
+ const { userViews, savedViewsAreAvailable } = useSavedViews();
14
+ const { columnFilters, searchTerm } = useDataTableContext();
15
15
 
16
16
  // Find the active view using centralized utility
17
17
  const activeView = useMemo(() => {
18
18
  return findMatchingSavedView(columnFilters, searchTerm, userViews);
19
19
  }, [userViews, columnFilters, searchTerm]);
20
20
 
21
+ if (!savedViewsAreAvailable) {
22
+ return null;
23
+ }
24
+
21
25
  return (
22
26
  <>
23
27
  <div className="flex items-center gap-2">
@@ -35,11 +39,7 @@ export const MyViewsButton: React.FC = () => {
35
39
  <Trans>My saved views</Trans>
36
40
  </TooltipContent>
37
41
  </Tooltip>
38
- {activeView && (
39
- <span className="text-sm text-muted-foreground">
40
- {activeView.name}
41
- </span>
42
- )}
42
+ {activeView && <span className="text-sm text-muted-foreground">{activeView.name}</span>}
43
43
  </div>
44
44
  <UserViewsSheet open={sheetOpen} onOpenChange={setSheetOpen} />
45
45
  </>
@@ -13,7 +13,7 @@ interface SaveViewButtonProps {
13
13
 
14
14
  export const SaveViewButton: React.FC<SaveViewButtonProps> = ({ disabled }) => {
15
15
  const [dialogOpen, setDialogOpen] = useState(false);
16
- const { userViews, globalViews } = useSavedViews();
16
+ const { userViews, globalViews, savedViewsAreAvailable } = useSavedViews();
17
17
  const { columnFilters, searchTerm } = useDataTableContext();
18
18
 
19
19
  const hasFilters = columnFilters.length > 0 || (searchTerm && searchTerm.length > 0);
@@ -24,6 +24,10 @@ export const SaveViewButton: React.FC<SaveViewButtonProps> = ({ disabled }) => {
24
24
  return null;
25
25
  }
26
26
 
27
+ if (!savedViewsAreAvailable) {
28
+ return null;
29
+ }
30
+
27
31
  return (
28
32
  <>
29
33
  <Button variant="outline" size="sm" onClick={() => setDialogOpen(true)} disabled={disabled}>