@vendure/dashboard 3.5.6-master-202603270307 → 3.5.6

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 (33) hide show
  1. package/package.json +3 -3
  2. package/src/app/routes/_authenticated/_collections/collections.tsx +119 -38
  3. package/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx +3 -5
  4. package/src/app/routes/_authenticated/_collections/components/move-collections-dialog.tsx +2 -1
  5. package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +20 -2
  6. package/src/i18n/locales/ar.po +484 -392
  7. package/src/i18n/locales/bg.po +485 -393
  8. package/src/i18n/locales/cs.po +484 -392
  9. package/src/i18n/locales/de.po +484 -392
  10. package/src/i18n/locales/en.po +485 -393
  11. package/src/i18n/locales/es.po +484 -392
  12. package/src/i18n/locales/fa.po +484 -392
  13. package/src/i18n/locales/fr.po +485 -393
  14. package/src/i18n/locales/he.po +484 -392
  15. package/src/i18n/locales/hr.po +485 -393
  16. package/src/i18n/locales/hu.po +346 -276
  17. package/src/i18n/locales/it.po +484 -392
  18. package/src/i18n/locales/ja.po +484 -392
  19. package/src/i18n/locales/nb.po +484 -392
  20. package/src/i18n/locales/ne.po +485 -393
  21. package/src/i18n/locales/nl.po +1441 -732
  22. package/src/i18n/locales/pl.po +485 -393
  23. package/src/i18n/locales/pt_BR.po +484 -392
  24. package/src/i18n/locales/pt_PT.po +484 -392
  25. package/src/i18n/locales/ru.po +485 -393
  26. package/src/i18n/locales/sv.po +484 -392
  27. package/src/i18n/locales/tr.po +484 -392
  28. package/src/i18n/locales/uk.po +484 -392
  29. package/src/i18n/locales/zh_Hans.po +484 -392
  30. package/src/i18n/locales/zh_Hant.po +484 -392
  31. package/src/lib/components/data-input/money-input.tsx +16 -4
  32. package/src/lib/components/data-table/data-table-utils.ts +23 -25
  33. package/src/lib/framework/page/list-page.tsx +2 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.5.6-master-202603270307",
4
+ "version": "3.5.6",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -162,8 +162,8 @@
162
162
  "@storybook/addon-vitest": "^10.0.0-beta.9",
163
163
  "@storybook/react-vite": "^10.0.0-beta.9",
164
164
  "@types/node": "^22.13.4",
165
- "@vendure/common": "^3.5.6-master-202603270307",
166
- "@vendure/core": "^3.5.6-master-202603270307",
165
+ "@vendure/common": "3.5.6",
166
+ "@vendure/core": "3.5.6",
167
167
  "@vitest/browser": "^3.2.4",
168
168
  "@vitest/coverage-v8": "^3.2.4",
169
169
  "eslint": "^9.19.0",
@@ -6,12 +6,12 @@ import { ListPage } from '@/vdb/framework/page/list-page.js';
6
6
  import { api } from '@/vdb/graphql/api.js';
7
7
  import { Trans, useLingui } from '@lingui/react/macro';
8
8
  import { FetchQueryOptions, useQueries, useQueryClient } from '@tanstack/react-query';
9
- import { createFileRoute, Link } from '@tanstack/react-router';
9
+ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
10
10
  import { ExpandedState, getExpandedRowModel } from '@tanstack/react-table';
11
11
  import { TableOptions } from '@tanstack/table-core';
12
12
  import { ResultOf } from 'gql.tada';
13
13
  import { Folder, FolderOpen, PlusIcon } from 'lucide-react';
14
- import { useState } from 'react';
14
+ import { useCallback, useEffect, useState } from 'react';
15
15
  import { toast } from 'sonner';
16
16
 
17
17
  import { RichTextDescriptionCell } from '@/vdb/components/shared/table-cell/order-table-cell-components.js';
@@ -33,9 +33,29 @@ import {
33
33
  import { CollectionContentsSheet } from './components/collection-contents-sheet.js';
34
34
 
35
35
 
36
+ function parseExpandedParam(expanded?: string): ExpandedState {
37
+ if (!expanded) return {};
38
+ const ids = expanded.split(',').filter(Boolean);
39
+ return Object.fromEntries(ids.map(id => [id, true]));
40
+ }
41
+
42
+ function serializeExpandedState(expanded: ExpandedState): string | undefined {
43
+ if (expanded === true) return undefined;
44
+ const ids = Object.entries(expanded)
45
+ .filter(([_, v]) => v)
46
+ .map(([id]) => id);
47
+ return ids.length > 0 ? ids.join(',') : undefined;
48
+ }
49
+
36
50
  export const Route = createFileRoute('/_authenticated/_collections/collections')({
37
51
  component: CollectionListPage,
38
52
  loader: () => ({ breadcrumb: () => <Trans>Collections</Trans> }),
53
+ validateSearch: (search: Record<string, unknown>) => {
54
+ return {
55
+ ...search,
56
+ expanded: (search.expanded as string) || undefined,
57
+ };
58
+ },
39
59
  });
40
60
 
41
61
 
@@ -61,14 +81,36 @@ function isLoadMoreRow(row: CollectionOrLoadMore): row is LoadMoreRow {
61
81
  function CollectionListPage() {
62
82
  const { t } = useLingui();
63
83
  const queryClient = useQueryClient();
64
- const [expanded, setExpanded] = useState<ExpandedState>({});
84
+ const routeSearch = Route.useSearch();
85
+ const navigate = useNavigate({ from: Route.fullPath });
86
+ const [expanded, setExpandedState] = useState<ExpandedState>(() => parseExpandedParam(routeSearch.expanded));
65
87
  const [searchTerm, setSearchTerm] = useState<string>('');
66
88
  const [accumulatedChildren, setAccumulatedChildren] = useState<
67
89
  Record<string, { items: Collection[]; totalItems: number }>
68
90
  >({});
69
91
  const [nextPageToFetch, setNextPageToFetch] = useState<Record<string, number>>({});
70
92
 
71
- useQueries({
93
+ const setExpanded = useCallback((updater: ExpandedState | ((prev: ExpandedState) => ExpandedState)) => {
94
+ setExpandedState(prev => {
95
+ const next = typeof updater === 'function' ? updater(prev) : updater;
96
+ navigate({
97
+ search: (old: Record<string, unknown>) => ({
98
+ ...old,
99
+ expanded: serializeExpandedState(next),
100
+ }),
101
+ replace: true,
102
+ });
103
+ return next;
104
+ });
105
+ }, [navigate]);
106
+
107
+ // NOTE: queryFn must be pure (no setState side effects) because TanStack Query
108
+ // skips queryFn entirely when data is served from cache (staleTime: 5min). If we
109
+ // called setAccumulatedChildren inside queryFn, a re-mounted component would get
110
+ // cache hits but accumulatedChildren would never be populated, so children wouldn't
111
+ // render. Instead we sync via useEffect below, which fires for both cache hits and
112
+ // fresh fetches.
113
+ const firstPageChildQueries = useQueries({
72
114
  queries: expanded === true ? [] : Object.entries(expanded)
73
115
  .filter(([collectionId]) => !accumulatedChildren[collectionId])
74
116
  .map(([collectionId]) => {
@@ -84,21 +126,35 @@ function CollectionListPage() {
84
126
  skip: 0,
85
127
  },
86
128
  });
87
- setAccumulatedChildren(prev => ({
88
- ...prev,
89
- [collectionId]: {
90
- items: result.collections.items,
91
- totalItems: result.collections.totalItems,
92
- },
93
- }));
94
- return result;
129
+ return {
130
+ collectionId,
131
+ items: result.collections.items,
132
+ totalItems: result.collections.totalItems,
133
+ };
95
134
  },
96
135
  staleTime: 1000 * 60 * 5,
97
136
  } satisfies FetchQueryOptions;
98
137
  }),
99
138
  });
100
139
 
101
- useQueries({
140
+ useEffect(() => {
141
+ const newChildren: Record<string, { items: Collection[]; totalItems: number }> = {};
142
+ let hasNew = false;
143
+ for (const query of firstPageChildQueries) {
144
+ if (query.data && !accumulatedChildren[query.data.collectionId]) {
145
+ newChildren[query.data.collectionId] = {
146
+ items: query.data.items as Collection[],
147
+ totalItems: query.data.totalItems,
148
+ };
149
+ hasNew = true;
150
+ }
151
+ }
152
+ if (hasNew) {
153
+ setAccumulatedChildren(prev => ({ ...prev, ...newChildren }));
154
+ }
155
+ }, [firstPageChildQueries]);
156
+
157
+ const pagedChildQueries = useQueries({
102
158
  queries: Object.entries(nextPageToFetch)
103
159
  .filter(([_, page]) => page > 0)
104
160
  .map(([collectionId, page]) => {
@@ -114,28 +170,49 @@ function CollectionListPage() {
114
170
  skip: page * CHILDREN_PAGE_SIZE,
115
171
  },
116
172
  });
117
- setAccumulatedChildren(prev => {
118
- const existing = prev[collectionId];
119
- if (!existing) return prev;
120
- return {
121
- ...prev,
122
- [collectionId]: {
123
- items: [...existing.items, ...result.collections.items],
124
- totalItems: result.collections.totalItems,
125
- },
126
- };
127
- });
128
- setNextPageToFetch(prev => {
129
- const { [collectionId]: _, ...rest } = prev;
130
- return rest;
131
- });
132
- return result;
173
+ return {
174
+ collectionId,
175
+ items: result.collections.items,
176
+ totalItems: result.collections.totalItems,
177
+ };
133
178
  },
134
179
  staleTime: 1000 * 60 * 5,
135
180
  } satisfies FetchQueryOptions;
136
181
  }),
137
182
  });
138
183
 
184
+ useEffect(() => {
185
+ let hasUpdates = false;
186
+ const childUpdates: Record<string, { items: Collection[]; totalItems: number }> = {};
187
+ const fetchedPages: string[] = [];
188
+ for (const query of pagedChildQueries) {
189
+ if (!query.data) continue;
190
+ const { collectionId, items, totalItems } = query.data as {
191
+ collectionId: string;
192
+ items: Collection[];
193
+ totalItems: number;
194
+ };
195
+ if (accumulatedChildren[collectionId]) {
196
+ childUpdates[collectionId] = {
197
+ items: [...accumulatedChildren[collectionId].items, ...items],
198
+ totalItems,
199
+ };
200
+ fetchedPages.push(collectionId);
201
+ hasUpdates = true;
202
+ }
203
+ }
204
+ if (hasUpdates) {
205
+ setAccumulatedChildren(prev => ({ ...prev, ...childUpdates }));
206
+ setNextPageToFetch(prev => {
207
+ const next = { ...prev };
208
+ for (const id of fetchedPages) {
209
+ delete next[id];
210
+ }
211
+ return next;
212
+ });
213
+ }
214
+ }, [pagedChildQueries]);
215
+
139
216
  const addSubCollections = (data: Collection[]): CollectionOrLoadMore[] => {
140
217
  const allRows: CollectionOrLoadMore[] = [];
141
218
  const addSubRows = (row: Collection) => {
@@ -226,6 +303,13 @@ function CollectionListPage() {
226
303
  },
227
304
  });
228
305
 
306
+ // Remove query cache entries BEFORE clearing accumulated children
307
+ // to prevent stale cached data from being synced back by the useEffect.
308
+ queryClient.removeQueries({ queryKey: ['childCollections', sourceParentId] });
309
+ if (targetParentId !== sourceParentId) {
310
+ queryClient.removeQueries({ queryKey: ['childCollections', targetParentId] });
311
+ }
312
+
229
313
  setAccumulatedChildren(prev => {
230
314
  const newState = { ...prev };
231
315
  delete newState[sourceParentId];
@@ -235,19 +319,11 @@ function CollectionListPage() {
235
319
  return newState;
236
320
  });
237
321
 
238
- const queriesToInvalidate = [
239
- queryClient.invalidateQueries({ queryKey: ['childCollections', sourceParentId] }),
240
- queryClient.invalidateQueries({ queryKey: ['PaginatedListDataTable'] }),
241
- ];
322
+ await queryClient.invalidateQueries({ queryKey: ['PaginatedListDataTable'] });
242
323
 
243
324
  if (targetParentId === sourceParentId) {
244
- await Promise.all(queriesToInvalidate);
245
325
  toast.success(t`Collection position updated`);
246
326
  } else {
247
- queriesToInvalidate.push(
248
- queryClient.invalidateQueries({ queryKey: ['childCollections', targetParentId] })
249
- );
250
- await Promise.all(queriesToInvalidate);
251
327
  toast.success(t`Collection moved to new parent`);
252
328
  }
253
329
  } catch (error) {
@@ -378,6 +454,11 @@ function CollectionListPage() {
378
454
  options.meta = {
379
455
  ...options.meta,
380
456
  resetExpanded: () => setExpanded({}),
457
+ refreshChildCaches: () => {
458
+ queryClient.removeQueries({ queryKey: ['childCollections'] });
459
+ queryClient.removeQueries({ queryKey: ['PaginatedListDataTable'] });
460
+ setAccumulatedChildren({});
461
+ },
381
462
  isUtilityRow: (row: { original: CollectionOrLoadMore }) => isLoadMoreRow(row.original),
382
463
  renderUtilityRow: (row: { original: CollectionOrLoadMore }) => {
383
464
  const original = row.original as LoadMoreRow;
@@ -94,19 +94,17 @@ export const DeleteCollectionsBulkAction: BulkActionComponent<any> = ({ selectio
94
94
 
95
95
  export const MoveCollectionsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
96
96
  const [dialogOpen, setDialogOpen] = useState(false);
97
- const queryClient = useQueryClient();
98
97
  const { refetchPaginatedList } = usePaginatedList();
99
98
 
100
99
  const handleSuccess = () => {
101
- queryClient.invalidateQueries({ queryKey: ['childCollections'] });
102
100
  refetchPaginatedList();
103
101
  table.resetRowSelection();
104
102
  };
105
103
 
106
104
  const handleResetExpanded = () => {
107
- const resetExpanded = (table.options.meta as { resetExpanded: () => void })?.resetExpanded;
108
- if (resetExpanded) {
109
- resetExpanded();
105
+ const refreshChildCaches = (table.options.meta as { refreshChildCaches: () => void })?.refreshChildCaches;
106
+ if (refreshChildCaches) {
107
+ refreshChildCaches();
110
108
  }
111
109
  };
112
110
 
@@ -284,7 +284,8 @@ export function MoveCollectionsDialog({
284
284
  toast.success(t`Collections moved successfully`);
285
285
  queryClient.invalidateQueries({ queryKey: collectionForMoveKey });
286
286
  queryClient.invalidateQueries({ queryKey: childCollectionsForMoveKey() });
287
- queryClient.invalidateQueries({ queryKey: ['PaginatedListDataTable'] });
287
+ // Remove child caches BEFORE invalidating the main list to prevent
288
+ // stale cached children from being synced back (same race as drag-reorder).
288
289
  onResetExpanded?.();
289
290
  onSuccess?.();
290
291
  onOpenChange(false);
@@ -9,6 +9,7 @@ import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js'
9
9
  import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
10
10
  import { TaxCategorySelector } from '@/vdb/components/shared/tax-category-selector.js';
11
11
  import { TranslatableFormFieldWrapper } from '@/vdb/components/shared/translatable-form-field.js';
12
+ import { Badge } from '@/vdb/components/ui/badge.js';
12
13
  import { Button } from '@/vdb/components/ui/button.js';
13
14
  import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from '@/vdb/components/ui/form.js';
14
15
  import { Input } from '@/vdb/components/ui/input.js';
@@ -33,9 +34,9 @@ import { api } from '@/vdb/graphql/api.js';
33
34
  import { useChannel } from '@/vdb/hooks/use-channel.js';
34
35
  import { Trans, useLingui } from '@lingui/react/macro';
35
36
  import { useQuery } from '@tanstack/react-query';
36
- import { createFileRoute, useNavigate } from '@tanstack/react-router';
37
+ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
37
38
  import { VariablesOf } from 'gql.tada';
38
- import { Trash } from 'lucide-react';
39
+ import { Edit2, Trash } from 'lucide-react';
39
40
  import { toast } from 'sonner';
40
41
 
41
42
  import { AddCurrencyDropdown } from './components/add-currency-dropdown.js';
@@ -241,6 +242,23 @@ function ProductVariantDetailPage() {
241
242
  )}
242
243
  />
243
244
  </PageBlock>
245
+ {entity?.options && entity.options.length > 0 && (
246
+ <PageBlock column="side" blockId="options" title={<Trans>Options</Trans>}>
247
+ <div className="flex flex-wrap gap-1.5">
248
+ {entity.options.map(option => (
249
+ <Badge key={option.id} variant="secondary" className="text-xs" title={option.code}>
250
+ <span>{option.group.name}: {option.name}</span>
251
+ <Link
252
+ to={`/products/${entity.product.id}/option-groups/${option.group.id}`}
253
+ className="ml-1.5 inline-flex"
254
+ >
255
+ <Edit2 className="h-3 w-3" />
256
+ </Link>
257
+ </Badge>
258
+ ))}
259
+ </div>
260
+ </PageBlock>
261
+ )}
244
262
  <PageBlock column="main" blockId="main-form">
245
263
  <DetailFormGrid>
246
264
  <TranslatableFormFieldWrapper