@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.
- package/dist/plugin/default-page.html +1 -1
- package/dist/vite/utils/tsconfig-utils.js +2 -1
- package/package.json +5 -4
- package/src/app/routes/_authenticated/_global-settings/global-settings.tsx +4 -8
- package/src/app/routes/_authenticated/_global-settings/utils/global-languages.ts +268 -0
- package/src/app/routes/_authenticated/_orders/components/order-address.tsx +15 -15
- package/src/app/routes/_authenticated/_orders/components/order-detail-shared.tsx +4 -4
- package/src/app/routes/_authenticated/_product-variants/components/add-currency-dropdown.tsx +49 -0
- package/src/app/routes/_authenticated/_product-variants/components/add-stock-location-dropdown.tsx +56 -0
- package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +12 -0
- package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +178 -50
- package/src/app/routes/_authenticated/_products/components/product-variants-table.tsx +0 -11
- package/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx +3 -14
- package/src/lib/components/data-input/customer-group-input.tsx +0 -1
- package/src/lib/components/data-input/money-input.tsx +7 -11
- package/src/lib/components/data-input/number-input.tsx +6 -1
- package/src/lib/components/data-table/data-table-filter-badge.tsx +15 -8
- package/src/lib/components/data-table/data-table.tsx +2 -2
- package/src/lib/components/layout/generated-breadcrumbs.tsx +4 -12
- package/src/lib/components/shared/configurable-operation-input.tsx +1 -1
- package/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx +0 -2
- package/src/lib/framework/extension-api/types/layout.ts +41 -1
- package/src/lib/framework/form-engine/value-transformers.ts +8 -1
- package/src/lib/framework/layout-engine/page-layout.tsx +58 -48
- package/src/lib/framework/page/detail-page.tsx +12 -15
- 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 {
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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>
|
|
@@ -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
|
|
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(
|
|
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,
|
|
53
|
+
}, [activeCurrency, bcp47Tag]);
|
|
57
54
|
|
|
58
55
|
// Get the currency symbol
|
|
59
56
|
const currencySymbol = useMemo(() => {
|
|
60
57
|
if (!activeCurrency) return '';
|
|
61
|
-
const
|
|
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,
|
|
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
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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,
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|