@vendure/dashboard 3.3.6-master-202507030732 → 3.3.6-master-202507031127

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.
@@ -32,7 +32,7 @@ export function vendureDashboardPlugin(options) {
32
32
  : [
33
33
  TanStackRouterVite({
34
34
  autoCodeSplitting: true,
35
- routeFileIgnorePattern: '.graphql.ts|components',
35
+ routeFileIgnorePattern: '.graphql.ts|components|hooks',
36
36
  routesDirectory: path.join(packageRoot, 'src/app/routes'),
37
37
  generatedRouteTree: path.join(packageRoot, 'src/app/routeTree.gen.ts'),
38
38
  }),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.3.6-master-202507030732",
4
+ "version": "3.3.6-master-202507031127",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -86,8 +86,8 @@
86
86
  "@types/react-dom": "^19.0.4",
87
87
  "@types/react-grid-layout": "^1.3.5",
88
88
  "@uidotdev/usehooks": "^2.4.1",
89
- "@vendure/common": "^3.3.6-master-202507030732",
90
- "@vendure/core": "^3.3.6-master-202507030732",
89
+ "@vendure/common": "^3.3.6-master-202507031127",
90
+ "@vendure/core": "^3.3.6-master-202507031127",
91
91
  "@vitejs/plugin-react": "^4.3.4",
92
92
  "awesome-graphql-client": "^2.1.0",
93
93
  "class-variance-authority": "^0.7.1",
@@ -130,5 +130,5 @@
130
130
  "lightningcss-linux-arm64-musl": "^1.29.3",
131
131
  "lightningcss-linux-x64-musl": "^1.29.1"
132
132
  },
133
- "gitHead": "0caf3b1c7a4e75924dab2e3e92673c34dadc36f9"
133
+ "gitHead": "82c67a6665c77f59b6bb2e652e73d4580ca4f291"
134
134
  }
@@ -156,3 +156,35 @@ export const deleteCollectionsDocument = graphql(`
156
156
  }
157
157
  }
158
158
  `);
159
+
160
+ export const moveCollectionDocument = graphql(`
161
+ mutation MoveCollection($input: MoveCollectionInput!) {
162
+ moveCollection(input: $input) {
163
+ id
164
+ }
165
+ }
166
+ `);
167
+
168
+ export const collectionListForMoveDocument = graphql(`
169
+ query CollectionListForMove($options: CollectionListOptions) {
170
+ collections(options: $options) {
171
+ items {
172
+ id
173
+ name
174
+ slug
175
+ breadcrumbs {
176
+ id
177
+ name
178
+ slug
179
+ }
180
+ children {
181
+ id
182
+ }
183
+ position
184
+ isPrivate
185
+ parentId
186
+ }
187
+ totalItems
188
+ }
189
+ }
190
+ `);
@@ -10,7 +10,7 @@ import { createFileRoute, Link } 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
- import { Folder, FolderOpen, PlusIcon } from 'lucide-react';
13
+ import { Folder, FolderOpen, FolderTreeIcon, PlusIcon } from 'lucide-react';
14
14
  import { useState } from 'react';
15
15
 
16
16
  import { collectionListDocument, deleteCollectionDocument } from './collections.graphql.js';
@@ -18,9 +18,11 @@ import {
18
18
  AssignCollectionsToChannelBulkAction,
19
19
  DeleteCollectionsBulkAction,
20
20
  DuplicateCollectionsBulkAction,
21
+ MoveCollectionsBulkAction,
21
22
  RemoveCollectionsFromChannelBulkAction,
22
23
  } from './components/collection-bulk-actions.js';
23
24
  import { CollectionContentsSheet } from './components/collection-contents-sheet.js';
25
+ import { useMoveSingleCollection } from './components/move-single-collection.js';
24
26
 
25
27
  export const Route = createFileRoute('/_authenticated/_collections/collections')({
26
28
  component: CollectionListPage,
@@ -31,6 +33,7 @@ type Collection = ResultOf<typeof collectionListDocument>['collections']['items'
31
33
 
32
34
  function CollectionListPage() {
33
35
  const [expanded, setExpanded] = useState<ExpandedState>({});
36
+ const { handleMoveClick, MoveDialog } = useMoveSingleCollection();
34
37
  const childrenQueries = useQueries({
35
38
  queries: Object.entries(expanded).map(([collectionId, isExpanded]) => {
36
39
  return {
@@ -77,143 +80,160 @@ function CollectionListPage() {
77
80
  };
78
81
 
79
82
  return (
80
- <ListPage
81
- pageId="collection-list"
82
- title="Collections"
83
- listQuery={collectionListDocument}
84
- transformVariables={input => {
85
- const filterTerm = input.options?.filter?.name?.contains;
86
- const isFiltering = !!filterTerm;
87
- return {
88
- options: {
89
- ...input.options,
90
- topLevelOnly: !isFiltering,
83
+ <>
84
+ <ListPage
85
+ pageId="collection-list"
86
+ title="Collections"
87
+ listQuery={collectionListDocument}
88
+ transformVariables={input => {
89
+ const filterTerm = input.options?.filter?.name?.contains;
90
+ const isFiltering = !!filterTerm;
91
+ return {
92
+ options: {
93
+ ...input.options,
94
+ topLevelOnly: !isFiltering,
95
+ },
96
+ };
97
+ }}
98
+ deleteMutation={deleteCollectionDocument}
99
+ customizeColumns={{
100
+ name: {
101
+ header: 'Collection Name',
102
+ cell: ({ row }) => {
103
+ const isExpanded = row.getIsExpanded();
104
+ const hasChildren = !!row.original.children?.length;
105
+ return (
106
+ <div
107
+ style={{ marginLeft: (row.original.breadcrumbs.length - 2) * 20 + 'px' }}
108
+ className="flex gap-2 items-center"
109
+ >
110
+ <Button
111
+ size="icon"
112
+ variant="secondary"
113
+ onClick={row.getToggleExpandedHandler()}
114
+ disabled={!hasChildren}
115
+ className={!hasChildren ? 'opacity-20' : ''}
116
+ >
117
+ {isExpanded ? <FolderOpen /> : <Folder />}
118
+ </Button>
119
+ <DetailPageButton id={row.original.id} label={row.original.name} />
120
+ </div>
121
+ );
122
+ },
123
+ },
124
+ breadcrumbs: {
125
+ cell: ({ cell }) => {
126
+ const value = cell.getValue();
127
+ if (!Array.isArray(value)) {
128
+ return null;
129
+ }
130
+ return (
131
+ <div>
132
+ {value
133
+ .slice(1)
134
+ .map(breadcrumb => breadcrumb.name)
135
+ .join(' / ')}
136
+ </div>
137
+ );
138
+ },
91
139
  },
92
- };
93
- }}
94
- deleteMutation={deleteCollectionDocument}
95
- customizeColumns={{
96
- name: {
97
- header: 'Collection Name',
98
- cell: ({ row }) => {
99
- const isExpanded = row.getIsExpanded();
100
- const hasChildren = !!row.original.children?.length;
101
- return (
102
- <div
103
- style={{ marginLeft: (row.original.breadcrumbs.length - 2) * 20 + 'px' }}
104
- className="flex gap-2 items-center"
105
- >
106
- <Button
107
- size="icon"
108
- variant="secondary"
109
- onClick={row.getToggleExpandedHandler()}
110
- disabled={!hasChildren}
111
- className={!hasChildren ? 'opacity-20' : ''}
140
+ productVariants: {
141
+ header: 'Contents',
142
+ cell: ({ row }) => {
143
+ return (
144
+ <CollectionContentsSheet
145
+ collectionId={row.original.id}
146
+ collectionName={row.original.name}
112
147
  >
113
- {isExpanded ? <FolderOpen /> : <Folder />}
114
- </Button>
115
- <DetailPageButton id={row.original.id} label={row.original.name} />
116
- </div>
117
- );
148
+ <Trans>{row.original.productVariants.totalItems} variants</Trans>
149
+ </CollectionContentsSheet>
150
+ );
151
+ },
118
152
  },
119
- },
120
- breadcrumbs: {
121
- cell: ({ cell }) => {
122
- const value = cell.getValue();
123
- if (!Array.isArray(value)) {
124
- return null;
125
- }
126
- return (
127
- <div>
128
- {value
129
- .slice(1)
130
- .map(breadcrumb => breadcrumb.name)
131
- .join(' / ')}
153
+ }}
154
+ defaultColumnOrder={[
155
+ 'featuredAsset',
156
+ 'children',
157
+ 'name',
158
+ 'slug',
159
+ 'breadcrumbs',
160
+ 'productVariants',
161
+ ]}
162
+ transformData={data => {
163
+ return addSubCollections(data);
164
+ }}
165
+ setTableOptions={(options: TableOptions<any>) => {
166
+ options.state = {
167
+ ...options.state,
168
+ expanded: expanded,
169
+ };
170
+ options.onExpandedChange = setExpanded;
171
+ options.getExpandedRowModel = getExpandedRowModel();
172
+ options.getRowCanExpand = () => true;
173
+ options.getRowId = row => {
174
+ return row.id;
175
+ };
176
+ return options;
177
+ }}
178
+ defaultVisibility={{
179
+ id: false,
180
+ createdAt: false,
181
+ updatedAt: false,
182
+ position: false,
183
+ parentId: false,
184
+ children: false,
185
+ }}
186
+ onSearchTermChange={searchTerm => {
187
+ return {
188
+ name: { contains: searchTerm },
189
+ };
190
+ }}
191
+ route={Route}
192
+ rowActions={[
193
+ {
194
+ label: (
195
+ <div className="flex items-center gap-2">
196
+ <FolderTreeIcon className="w-4 h-4" /> <Trans>Move</Trans>
132
197
  </div>
133
- );
198
+ ),
199
+ onClick: row => handleMoveClick(row.original),
200
+ },
201
+ ]}
202
+ bulkActions={[
203
+ {
204
+ component: AssignCollectionsToChannelBulkAction,
205
+ order: 100,
206
+ },
207
+ {
208
+ component: RemoveCollectionsFromChannelBulkAction,
209
+ order: 200,
210
+ },
211
+ {
212
+ component: DuplicateCollectionsBulkAction,
213
+ order: 300,
214
+ },
215
+ {
216
+ component: MoveCollectionsBulkAction,
217
+ order: 400,
134
218
  },
135
- },
136
- productVariants: {
137
- header: 'Contents',
138
- cell: ({ row }) => {
139
- return (
140
- <CollectionContentsSheet
141
- collectionId={row.original.id}
142
- collectionName={row.original.name}
143
- >
144
- <Trans>{row.original.productVariants.totalItems} variants</Trans>
145
- </CollectionContentsSheet>
146
- );
219
+ {
220
+ component: DeleteCollectionsBulkAction,
221
+ order: 500,
147
222
  },
148
- },
149
- }}
150
- defaultColumnOrder={[
151
- 'featuredAsset',
152
- 'children',
153
- 'name',
154
- 'slug',
155
- 'breadcrumbs',
156
- 'productVariants',
157
- ]}
158
- transformData={data => {
159
- return addSubCollections(data);
160
- }}
161
- setTableOptions={(options: TableOptions<any>) => {
162
- options.state = {
163
- ...options.state,
164
- expanded: expanded,
165
- };
166
- options.onExpandedChange = setExpanded;
167
- options.getExpandedRowModel = getExpandedRowModel();
168
- options.getRowCanExpand = () => true;
169
- options.getRowId = row => {
170
- return row.id;
171
- };
172
- return options;
173
- }}
174
- defaultVisibility={{
175
- id: false,
176
- createdAt: false,
177
- updatedAt: false,
178
- position: false,
179
- parentId: false,
180
- children: false,
181
- }}
182
- onSearchTermChange={searchTerm => {
183
- return {
184
- name: { contains: searchTerm },
185
- };
186
- }}
187
- route={Route}
188
- bulkActions={[
189
- {
190
- component: AssignCollectionsToChannelBulkAction,
191
- order: 100,
192
- },
193
- {
194
- component: RemoveCollectionsFromChannelBulkAction,
195
- order: 200,
196
- },
197
- {
198
- component: DuplicateCollectionsBulkAction,
199
- order: 300,
200
- },
201
- {
202
- component: DeleteCollectionsBulkAction,
203
- order: 400,
204
- },
205
- ]}
206
- >
207
- <PageActionBarRight>
208
- <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>
209
- <Button asChild>
210
- <Link to="./new">
211
- <PlusIcon className="mr-2 h-4 w-4" />
212
- <Trans>New Collection</Trans>
213
- </Link>
214
- </Button>
215
- </PermissionGuard>
216
- </PageActionBarRight>
217
- </ListPage>
223
+ ]}
224
+ >
225
+ <PageActionBarRight>
226
+ <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>
227
+ <Button asChild>
228
+ <Link to="./new">
229
+ <PlusIcon className="mr-2 h-4 w-4" />
230
+ <Trans>New Collection</Trans>
231
+ </Link>
232
+ </Button>
233
+ </PermissionGuard>
234
+ </PageActionBarRight>
235
+ </ListPage>
236
+ <MoveDialog />
237
+ </>
218
238
  );
219
239
  }
@@ -1,9 +1,12 @@
1
1
  import { useQueryClient } from '@tanstack/react-query';
2
+ import { useState } from 'react';
3
+ import { FolderTree } from 'lucide-react';
2
4
 
5
+ import { Trans } from '@/vdb/lib/trans.js';
3
6
  import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
4
7
  import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
5
8
  import { api } from '@/vdb/graphql/api.js';
6
- import { BulkActionComponent, useChannel } from '@/vdb/index.js';
9
+ import { BulkActionComponent, useChannel, DataTableBulkActionItem, usePaginatedList } from '@/vdb/index.js';
7
10
  import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
8
11
  import { DuplicateBulkAction } from '../../../../common/duplicate-bulk-action.js';
9
12
  import {
@@ -11,6 +14,7 @@ import {
11
14
  deleteCollectionsDocument,
12
15
  removeCollectionFromChannelDocument,
13
16
  } from '../collections.graphql.js';
17
+ import { MoveCollectionsDialog } from './move-collections-dialog.js';
14
18
 
15
19
  export const AssignCollectionsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
16
20
  const queryClient = useQueryClient();
@@ -85,3 +89,32 @@ export const DeleteCollectionsBulkAction: BulkActionComponent<any> = ({ selectio
85
89
  />
86
90
  );
87
91
  };
92
+
93
+ export const MoveCollectionsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
94
+ const [dialogOpen, setDialogOpen] = useState(false);
95
+ const queryClient = useQueryClient();
96
+ const { refetchPaginatedList } = usePaginatedList();
97
+
98
+ const handleSuccess = () => {
99
+ queryClient.invalidateQueries({ queryKey: ['childCollections'] });
100
+ refetchPaginatedList();
101
+ table.resetRowSelection();
102
+ };
103
+
104
+ return (
105
+ <>
106
+ <DataTableBulkActionItem
107
+ requiresPermission={['UpdateCatalog', 'UpdateCollection']}
108
+ onClick={() => setDialogOpen(true)}
109
+ label={<Trans>Move</Trans>}
110
+ icon={FolderTree}
111
+ />
112
+ <MoveCollectionsDialog
113
+ open={dialogOpen}
114
+ onOpenChange={setDialogOpen}
115
+ collectionsToMove={selection}
116
+ onSuccess={handleSuccess}
117
+ />
118
+ </>
119
+ );
120
+ };
@@ -0,0 +1,430 @@
1
+ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useDebounce } from '@uidotdev/usehooks';
3
+ import { useRef, useState } from 'react';
4
+ import { toast } from 'sonner';
5
+
6
+ import { Alert, AlertDescription } from '@/vdb/components/ui/alert.js';
7
+ import { Button } from '@/vdb/components/ui/button.js';
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from '@/vdb/components/ui/dialog.js';
16
+ import { Input } from '@/vdb/components/ui/input.js';
17
+ import { ScrollArea } from '@/vdb/components/ui/scroll-area.js';
18
+ import { api } from '@/vdb/graphql/api.js';
19
+ import { Trans, useLingui } from '@/vdb/lib/trans.js';
20
+ import { ChevronRight, Folder, FolderOpen, Search } from 'lucide-react';
21
+
22
+ import { collectionListForMoveDocument, moveCollectionDocument } from '../collections.graphql.js';
23
+
24
+ type Collection = {
25
+ id: string;
26
+ name: string;
27
+ slug: string;
28
+ children?: { id: string }[] | null;
29
+ breadcrumbs: Array<{ id: string; name: string; slug: string }>;
30
+ parentId?: string;
31
+ };
32
+
33
+ interface MoveCollectionsDialogProps {
34
+ open: boolean;
35
+ onOpenChange: (open: boolean) => void;
36
+ collectionsToMove: Collection[];
37
+ onSuccess?: () => void;
38
+ }
39
+
40
+ interface CollectionTreeNodeProps {
41
+ collection: Collection;
42
+ depth: number;
43
+ expanded: Record<string, boolean>;
44
+ onToggleExpanded: (id: string) => void;
45
+ onSelect: (collection: Collection) => void;
46
+ selectedCollectionId?: string;
47
+ collectionsToMove: Collection[];
48
+ childCollectionsByParentId: Record<string, Collection[]>;
49
+ }
50
+
51
+ interface TargetAlertProps {
52
+ selectedCollectionId?: string;
53
+ collectionsToMove: Collection[];
54
+ topLevelCollectionId?: string;
55
+ collectionNameCache: React.MutableRefObject<Map<string, string>>;
56
+ }
57
+
58
+ interface MoveToTopLevelProps {
59
+ selectedCollectionId?: string;
60
+ topLevelCollectionId?: string;
61
+ onSelect: (id?: string) => void;
62
+ }
63
+
64
+ function TargetAlert({
65
+ selectedCollectionId,
66
+ collectionsToMove,
67
+ topLevelCollectionId,
68
+ collectionNameCache,
69
+ }: Readonly<TargetAlertProps>) {
70
+ return (
71
+ <Alert className={selectedCollectionId ? 'border-blue-200 bg-blue-50' : ''}>
72
+ <Folder className="h-4 w-4" />
73
+ <AlertDescription>
74
+ {selectedCollectionId ? (
75
+ <Trans>
76
+ Moving {collectionsToMove.length} collection
77
+ {collectionsToMove.length === 1 ? '' : 's'} into{' '}
78
+ {selectedCollectionId === topLevelCollectionId
79
+ ? 'top level'
80
+ : collectionNameCache.current.get(selectedCollectionId) || 'selected collection'}
81
+ </Trans>
82
+ ) : (
83
+ <Trans>Select a destination collection</Trans>
84
+ )}
85
+ </AlertDescription>
86
+ </Alert>
87
+ );
88
+ }
89
+
90
+ function MoveToTopLevel({
91
+ selectedCollectionId,
92
+ topLevelCollectionId,
93
+ onSelect,
94
+ }: Readonly<MoveToTopLevelProps>) {
95
+ return (
96
+ <button
97
+ type="button"
98
+ className={`flex items-center gap-2 py-2 px-3 hover:bg-accent rounded-sm cursor-pointer w-full text-left ${
99
+ selectedCollectionId === topLevelCollectionId ? 'bg-accent' : ''
100
+ }`}
101
+ onClick={() => onSelect(topLevelCollectionId)}
102
+ >
103
+ <div className="w-3 h-3" />
104
+ <div className="flex items-center gap-2">
105
+ <Folder className="h-4 w-4 text-muted-foreground" />
106
+ <span className="text-sm font-medium">
107
+ <Trans>Move to the top level</Trans>
108
+ </span>
109
+ </div>
110
+ </button>
111
+ );
112
+ }
113
+
114
+ function CollectionTreeNode({
115
+ collection,
116
+ depth,
117
+ expanded,
118
+ onToggleExpanded,
119
+ onSelect,
120
+ selectedCollectionId,
121
+ collectionsToMove,
122
+ childCollectionsByParentId,
123
+ }: Readonly<CollectionTreeNodeProps>) {
124
+ const hasChildren = collection.children && collection.children.length > 0;
125
+ const isExpanded = expanded[collection.id];
126
+ const isSelected = selectedCollectionId === collection.id;
127
+ const isBeingMoved = collectionsToMove.some(c => c.id === collection.id);
128
+ const isChildOfBeingMoved = collectionsToMove.some(c => collection.breadcrumbs.some(b => b.id === c.id));
129
+
130
+ // Don't allow selecting collections that are being moved or are children of collections being moved
131
+ const isSelectable = !isBeingMoved && !isChildOfBeingMoved;
132
+
133
+ const childCollections = childCollectionsByParentId[collection.id] || [];
134
+
135
+ return (
136
+ <div className="my-0.5">
137
+ <div className="flex items-center" style={{ marginLeft: depth * 20 }}>
138
+ {hasChildren && (
139
+ <Button
140
+ size="icon"
141
+ variant="ghost"
142
+ className="h-4 w-4 p-0 mr-1"
143
+ onClick={() => onToggleExpanded(collection.id)}
144
+ >
145
+ {isExpanded ? (
146
+ <ChevronRight className="h-3 w-3 rotate-90" />
147
+ ) : (
148
+ <ChevronRight className="h-3 w-3" />
149
+ )}
150
+ </Button>
151
+ )}
152
+ {!hasChildren && <div className="w-5 h-4 mr-1" />}
153
+ <button
154
+ type="button"
155
+ className={`flex items-center gap-2 py-2 px-3 hover:bg-accent rounded-sm cursor-pointer w-full text-left ${
156
+ isSelected ? 'bg-accent' : ''
157
+ } ${!isSelectable ? 'opacity-50 cursor-not-allowed' : ''}`}
158
+ onClick={() => {
159
+ if (isSelectable) {
160
+ onSelect(collection);
161
+ }
162
+ }}
163
+ disabled={!isSelectable}
164
+ >
165
+ <div className="flex items-center gap-2">
166
+ {hasChildren &&
167
+ (isExpanded ? (
168
+ <FolderOpen className="h-4 w-4 text-muted-foreground" />
169
+ ) : (
170
+ <Folder className="h-4 w-4 text-muted-foreground" />
171
+ ))}
172
+ {!hasChildren && <div className="w-4 h-4" />}
173
+ <div className="flex flex-col">
174
+ <span className="text-sm">{collection.name}</span>
175
+ {collection.breadcrumbs.length > 1 && (
176
+ <span className="text-xs text-muted-foreground">
177
+ {collection.breadcrumbs
178
+ .slice(1)
179
+ .map(b => b.name)
180
+ .join(' / ')}
181
+ </span>
182
+ )}
183
+ </div>
184
+ </div>
185
+ </button>
186
+ </div>
187
+ {hasChildren && isExpanded && (
188
+ <div>
189
+ {childCollections.map((childCollection: Collection) => (
190
+ <CollectionTreeNode
191
+ key={childCollection.id}
192
+ collection={childCollection}
193
+ depth={depth + 1}
194
+ expanded={expanded}
195
+ onToggleExpanded={onToggleExpanded}
196
+ onSelect={onSelect}
197
+ selectedCollectionId={selectedCollectionId}
198
+ collectionsToMove={collectionsToMove}
199
+ childCollectionsByParentId={childCollectionsByParentId}
200
+ />
201
+ ))}
202
+ </div>
203
+ )}
204
+ </div>
205
+ );
206
+ }
207
+
208
+ export function MoveCollectionsDialog({
209
+ open,
210
+ onOpenChange,
211
+ collectionsToMove,
212
+ onSuccess,
213
+ }: Readonly<MoveCollectionsDialogProps>) {
214
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
215
+ const [selectedCollectionId, setSelectedCollectionId] = useState<string>();
216
+ const [searchTerm, setSearchTerm] = useState('');
217
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
218
+ const collectionNameCache = useRef<Map<string, string>>(new Map());
219
+ const queryClient = useQueryClient();
220
+ const { i18n } = useLingui();
221
+ const collectionForMoveKey = ['collectionsForMove', debouncedSearchTerm];
222
+ const childCollectionsForMoveKey = (collectionId?: string) =>
223
+ collectionId ? ['childCollectionsForMove', collectionId] : ['childCollectionsForMove'];
224
+
225
+ const { data: collectionsData, isLoading } = useQuery({
226
+ queryKey: collectionForMoveKey,
227
+ queryFn: () =>
228
+ api.query(collectionListForMoveDocument, {
229
+ options: {
230
+ take: 100,
231
+ topLevelOnly: !debouncedSearchTerm,
232
+ ...(debouncedSearchTerm && {
233
+ filter: {
234
+ name: { contains: debouncedSearchTerm },
235
+ },
236
+ }),
237
+ },
238
+ }),
239
+ staleTime: 1000 * 60 * 5,
240
+ enabled: open,
241
+ });
242
+
243
+ const topLevelCollectionId = collectionsData?.collections.items[0]?.parentId;
244
+ const selectionHasTopLevelParent = collectionsToMove.some(c => c.parentId === topLevelCollectionId);
245
+
246
+ // Load child collections for expanded nodes
247
+ const childrenQueries = useQueries({
248
+ queries: Object.entries(expanded).map(([collectionId, isExpanded]) => {
249
+ return {
250
+ queryKey: childCollectionsForMoveKey(collectionId),
251
+ queryFn: () =>
252
+ api.query(collectionListForMoveDocument, {
253
+ options: {
254
+ filter: {
255
+ parentId: { eq: collectionId },
256
+ },
257
+ },
258
+ }),
259
+ staleTime: 1000 * 60 * 5,
260
+ };
261
+ }),
262
+ });
263
+
264
+ const childCollectionsByParentId = childrenQueries.reduce(
265
+ (acc, query, index) => {
266
+ const collectionId = Object.keys(expanded)[index];
267
+ if (query.data) {
268
+ const collections = query.data.collections.items as Collection[];
269
+ // Populate the name cache with these collections
270
+ collections.forEach(collection => {
271
+ collectionNameCache.current.set(collection.id, collection.name);
272
+ });
273
+ acc[collectionId] = collections;
274
+ }
275
+ return acc;
276
+ },
277
+ {} as Record<string, Collection[]>,
278
+ );
279
+
280
+ const moveCollectionsMutation = useMutation({
281
+ mutationFn: api.mutate(moveCollectionDocument),
282
+ onSuccess: () => {
283
+ toast.success(i18n.t('Collections moved successfully'));
284
+ queryClient.invalidateQueries({ queryKey: collectionForMoveKey });
285
+ queryClient.invalidateQueries({ queryKey: childCollectionsForMoveKey() });
286
+ onSuccess?.();
287
+ onOpenChange(false);
288
+ },
289
+ onError: error => {
290
+ toast.error(i18n.t('Failed to move collections'));
291
+ console.error('Move collections error:', error);
292
+ },
293
+ });
294
+
295
+ const handleToggleExpanded = (id: string) => {
296
+ setExpanded(prev => ({
297
+ ...prev,
298
+ [id]: !prev[id],
299
+ }));
300
+ };
301
+
302
+ const handleSelect = (collection: Collection) => {
303
+ setSelectedCollectionId(collection.id);
304
+ };
305
+
306
+ const handleMove = () => {
307
+ if (!selectedCollectionId) {
308
+ toast.error(i18n.t('Please select a target collection'));
309
+ return;
310
+ }
311
+ // Move to a specific parent using moveCollection
312
+ const movePromises = collectionsToMove.map((collection: Collection) =>
313
+ moveCollectionsMutation.mutateAsync({
314
+ input: {
315
+ collectionId: collection.id,
316
+ parentId: selectedCollectionId,
317
+ index: 0, // Move to the beginning of the target collection
318
+ },
319
+ }),
320
+ );
321
+ Promise.all(movePromises);
322
+ };
323
+
324
+ const collections = (collectionsData?.collections.items as Collection[]) || [];
325
+
326
+ // Populate the name cache with top-level collections
327
+ collections.forEach(collection => {
328
+ collectionNameCache.current.set(collection.id, collection.name);
329
+ });
330
+
331
+ return (
332
+ <Dialog open={open} onOpenChange={onOpenChange}>
333
+ <DialogContent className="sm:max-w-[600px] max-h-[80vh]">
334
+ <DialogHeader>
335
+ <DialogTitle>
336
+ <Trans>Move Collections</Trans>
337
+ </DialogTitle>
338
+ <DialogDescription>
339
+ <Trans>
340
+ Select a target collection to move{' '}
341
+ {collectionsToMove.length === 1
342
+ ? 'this collection'
343
+ : `${collectionsToMove.length} collections`}{' '}
344
+ to.
345
+ </Trans>
346
+ </DialogDescription>
347
+ </DialogHeader>
348
+ <div className="px-6 py-3 bg-muted/50 border-b">
349
+ <div className="flex flex-wrap gap-2">
350
+ {collectionsToMove.map(collection => (
351
+ <div
352
+ key={collection.id}
353
+ className="flex items-center gap-2 px-3 py-1 bg-background border rounded-md text-sm"
354
+ >
355
+ <Folder className="h-3 w-3 text-muted-foreground" />
356
+ <span>{collection.name}</span>
357
+ </div>
358
+ ))}
359
+ </div>
360
+ </div>
361
+ <div className="py-4">
362
+ <div className="px-6 pb-3">
363
+ <div className="relative mb-3">
364
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
365
+ <Input
366
+ placeholder={i18n.t('Filter by collection name')}
367
+ value={searchTerm}
368
+ onChange={e => setSearchTerm(e.target.value)}
369
+ className="pl-10"
370
+ />
371
+ </div>
372
+ <ScrollArea className="h-[400px]">
373
+ <div className="space-y-1">
374
+ {isLoading ? (
375
+ <div className="flex items-center justify-center py-8">
376
+ <Trans>Loading collections...</Trans>
377
+ </div>
378
+ ) : (
379
+ <>
380
+ {!debouncedSearchTerm && !selectionHasTopLevelParent && (
381
+ <MoveToTopLevel
382
+ selectedCollectionId={selectedCollectionId}
383
+ topLevelCollectionId={topLevelCollectionId}
384
+ onSelect={setSelectedCollectionId}
385
+ />
386
+ )}
387
+ {collections.map((collection: Collection) => (
388
+ <CollectionTreeNode
389
+ key={collection.id}
390
+ collection={collection}
391
+ depth={0}
392
+ expanded={expanded}
393
+ onToggleExpanded={handleToggleExpanded}
394
+ onSelect={handleSelect}
395
+ selectedCollectionId={selectedCollectionId}
396
+ collectionsToMove={collectionsToMove}
397
+ childCollectionsByParentId={childCollectionsByParentId}
398
+ />
399
+ ))}
400
+ </>
401
+ )}
402
+ </div>
403
+ </ScrollArea>
404
+ <TargetAlert
405
+ selectedCollectionId={selectedCollectionId}
406
+ collectionsToMove={collectionsToMove}
407
+ topLevelCollectionId={topLevelCollectionId}
408
+ collectionNameCache={collectionNameCache}
409
+ />
410
+ </div>
411
+ </div>
412
+ <DialogFooter>
413
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
414
+ <Trans>Cancel</Trans>
415
+ </Button>
416
+ <Button
417
+ onClick={handleMove}
418
+ disabled={!selectedCollectionId || moveCollectionsMutation.isPending}
419
+ >
420
+ {moveCollectionsMutation.isPending ? (
421
+ <Trans>Moving...</Trans>
422
+ ) : (
423
+ <Trans>Move Collections</Trans>
424
+ )}
425
+ </Button>
426
+ </DialogFooter>
427
+ </DialogContent>
428
+ </Dialog>
429
+ );
430
+ }
@@ -0,0 +1,33 @@
1
+ import { ResultOf } from 'gql.tada';
2
+ import { useState } from 'react';
3
+
4
+ import { collectionListDocument } from '../collections.graphql.js';
5
+ import { MoveCollectionsDialog } from './move-collections-dialog.js';
6
+
7
+ type Collection = ResultOf<typeof collectionListDocument>['collections']['items'][number];
8
+
9
+ export function useMoveSingleCollection() {
10
+ const [moveDialogOpen, setMoveDialogOpen] = useState(false);
11
+ const [collectionsToMove, setCollectionsToMove] = useState<Collection[]>([]);
12
+
13
+ const handleMoveClick = (collection: Collection) => {
14
+ setCollectionsToMove([collection]);
15
+ setMoveDialogOpen(true);
16
+ };
17
+
18
+ const MoveDialog = () => (
19
+ <MoveCollectionsDialog
20
+ open={moveDialogOpen}
21
+ onOpenChange={setMoveDialogOpen}
22
+ collectionsToMove={collectionsToMove}
23
+ onSuccess={() => {
24
+ // The dialog will handle invalidating queries internally
25
+ }}
26
+ />
27
+ );
28
+
29
+ return {
30
+ handleMoveClick,
31
+ MoveDialog,
32
+ };
33
+ }
@@ -21,7 +21,10 @@ interface DataTableBulkActionsProps<TData> {
21
21
  bulkActions: BulkAction[];
22
22
  }
23
23
 
24
- export function DataTableBulkActions<TData>({ table, bulkActions }: DataTableBulkActionsProps<TData>) {
24
+ export function DataTableBulkActions<TData>({
25
+ table,
26
+ bulkActions,
27
+ }: Readonly<DataTableBulkActionsProps<TData>>) {
25
28
  const { pageId } = usePage();
26
29
  const { blockId } = usePageBlock();
27
30
 
@@ -57,13 +60,16 @@ export function DataTableBulkActions<TData>({ table, bulkActions }: DataTableBul
57
60
  allBulkActions.sort((a, b) => (a.order ?? 10_000) - (b.order ?? 10_000));
58
61
 
59
62
  return (
60
- <div className="flex items-center gap-2 px-2 py-1 bg-muted/50 rounded-md border">
63
+ <div
64
+ className="flex items-center gap-4 px-8 py-2 animate-in fade-in duration-200 absolute bottom-10 left-1/2 transform -translate-x-1/2 bg-white shadow-2xl rounded-md border"
65
+ style={{ height: 'auto', maxHeight: '60px' }}
66
+ >
61
67
  <span className="text-sm text-muted-foreground">
62
68
  <Trans>{selection.length} selected</Trans>
63
69
  </span>
64
70
  <DropdownMenu>
65
71
  <DropdownMenuTrigger asChild>
66
- <Button variant="outline" size="sm" className="h-8">
72
+ <Button variant="outline" size="sm" className="h-8 shadow-none">
67
73
  <Trans>With selected...</Trans>
68
74
  <ChevronDown className="ml-2 h-4 w-4" />
69
75
  </Button>
@@ -81,7 +81,7 @@ export function DataTable<TData>({
81
81
  bulkActions,
82
82
  setTableOptions,
83
83
  onRefresh,
84
- }: DataTableProps<TData>) {
84
+ }: Readonly<DataTableProps<TData>>) {
85
85
  const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
86
86
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(filtersInitialState || []);
87
87
  const { activeChannel } = useChannel();
@@ -207,8 +207,8 @@ export function DataTable<TData>({
207
207
  {onRefresh && <RefreshButton onRefresh={onRefresh} isLoading={isLoading ?? false} />}
208
208
  </div>
209
209
  </div>
210
- <DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
211
- <div className="rounded-md border my-2">
210
+
211
+ <div className="rounded-md border my-2 relative">
212
212
  <Table>
213
213
  <TableHeader>
214
214
  {table.getHeaderGroups().map(headerGroup => (
@@ -268,6 +268,7 @@ export function DataTable<TData>({
268
268
  )}
269
269
  </TableBody>
270
270
  </Table>
271
+ <DataTableBulkActions bulkActions={bulkActions ?? []} table={table} />
271
272
  </div>
272
273
  <DataTablePagination table={table} />
273
274
  </>
@@ -273,7 +273,7 @@ export function PaginatedListDataTable<
273
273
  setTableOptions,
274
274
  transformData,
275
275
  registerRefresher,
276
- }: PaginatedListDataTableProps<T, U, V, AC>) {
276
+ }: Readonly<PaginatedListDataTableProps<T, U, V, AC>>) {
277
277
  const [searchTerm, setSearchTerm] = React.useState<string>('');
278
278
  const debouncedSearchTerm = useDebounce(searchTerm, 500);
279
279
  const queryClient = useQueryClient();
@@ -90,7 +90,7 @@ export function ListPage<
90
90
  transformData,
91
91
  setTableOptions,
92
92
  bulkActions,
93
- }: ListPageProps<T, U, V, AC>) {
93
+ }: Readonly<ListPageProps<T, U, V, AC>>) {
94
94
  const route = typeof routeOrFn === 'function' ? routeOrFn() : routeOrFn;
95
95
  const routeSearch = route.useSearch();
96
96
  const navigate = useNavigate<AnyRouter>({ from: route.fullPath });
@@ -111,7 +111,7 @@ export function vendureDashboardPlugin(options: VitePluginVendureDashboardOption
111
111
  : [
112
112
  TanStackRouterVite({
113
113
  autoCodeSplitting: true,
114
- routeFileIgnorePattern: '.graphql.ts|components',
114
+ routeFileIgnorePattern: '.graphql.ts|components|hooks',
115
115
  routesDirectory: path.join(packageRoot, 'src/app/routes'),
116
116
  generatedRouteTree: path.join(packageRoot, 'src/app/routeTree.gen.ts'),
117
117
  }),