@vendure/dashboard 3.3.5-master-202506250727 → 3.3.5-master-202506251318

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 (42) hide show
  1. package/dist/plugin/tests/barrel-exports.spec.js +1 -1
  2. package/dist/plugin/vite-plugin-config.js +1 -0
  3. package/dist/plugin/vite-plugin-dashboard-metadata.d.ts +1 -3
  4. package/dist/plugin/vite-plugin-dashboard-metadata.js +1 -8
  5. package/dist/plugin/vite-plugin-tailwind-source.d.ts +7 -0
  6. package/dist/plugin/vite-plugin-tailwind-source.js +49 -0
  7. package/dist/plugin/vite-plugin-vendure-dashboard.js +3 -1
  8. package/package.json +4 -4
  9. package/src/app/routes/_authenticated/_products/components/assign-facet-values-dialog.tsx +98 -0
  10. package/src/app/routes/_authenticated/_products/components/assign-to-channel-dialog.tsx +126 -0
  11. package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +268 -0
  12. package/src/app/routes/_authenticated/_products/products.graphql.ts +64 -0
  13. package/src/app/routes/_authenticated/_products/products.tsx +31 -2
  14. package/src/app/routes/_authenticated/_products/products_.$id.tsx +3 -1
  15. package/src/app/styles.css +3 -0
  16. package/src/lib/components/data-table/data-table-bulk-action-item.tsx +101 -0
  17. package/src/lib/components/data-table/data-table-bulk-actions.tsx +89 -0
  18. package/src/lib/components/data-table/data-table-filter-badge.tsx +16 -8
  19. package/src/lib/components/data-table/data-table-filter-dialog.tsx +4 -4
  20. package/src/lib/components/data-table/data-table-pagination.tsx +2 -2
  21. package/src/lib/components/data-table/data-table.tsx +50 -31
  22. package/src/lib/components/data-table/human-readable-operator.tsx +3 -3
  23. package/src/lib/components/shared/assigned-facet-values.tsx +1 -5
  24. package/src/lib/components/shared/custom-fields-form.tsx +141 -67
  25. package/src/lib/components/shared/paginated-list-data-table.tsx +47 -11
  26. package/src/lib/framework/data-table/data-table-extensions.ts +21 -0
  27. package/src/lib/framework/data-table/data-table-types.ts +25 -0
  28. package/src/lib/framework/extension-api/define-dashboard-extension.ts +11 -0
  29. package/src/lib/framework/extension-api/extension-api-types.ts +35 -0
  30. package/src/lib/framework/form-engine/use-generated-form.tsx +2 -5
  31. package/src/lib/framework/layout-engine/page-block-provider.tsx +6 -0
  32. package/src/lib/framework/layout-engine/page-layout.tsx +43 -33
  33. package/src/lib/framework/page/list-page.tsx +6 -8
  34. package/src/lib/framework/registry/registry-types.ts +4 -2
  35. package/src/lib/hooks/use-page-block.tsx +10 -0
  36. package/src/lib/index.ts +8 -1
  37. package/vite/tests/barrel-exports.spec.ts +13 -9
  38. package/vite/vite-plugin-config.ts +1 -0
  39. package/vite/vite-plugin-dashboard-metadata.ts +1 -9
  40. package/vite/vite-plugin-tailwind-source.ts +65 -0
  41. package/vite/vite-plugin-vendure-dashboard.ts +5 -3
  42. /package/src/lib/components/data-table/{data-table-types.ts → types.ts} +0 -0
@@ -10,5 +10,5 @@ describe('detecting plugins in barrel exports', () => {
10
10
  expect(result.pluginInfo).toHaveLength(1);
11
11
  expect(result.pluginInfo[0].name).toBe('MyPlugin');
12
12
  expect(result.pluginInfo[0].dashboardEntryPath).toBe('./dashboard/index.tsx');
13
- });
13
+ }, { timeout: 10000 });
14
14
  });
@@ -49,6 +49,7 @@ export function viteConfigPlugin({ packageRoot }) {
49
49
  ...(((_e = config.optimizeDeps) === null || _e === void 0 ? void 0 : _e.include) || []),
50
50
  '@/components > recharts',
51
51
  '@/components > react-dropzone',
52
+ '@vendure/common/lib/generated-types',
52
53
  ] });
53
54
  return config;
54
55
  },
@@ -4,6 +4,4 @@ import { Plugin } from 'vite';
4
4
  * generates an import statement for each one, wrapped up in a `runDashboardExtensions()`
5
5
  * function which can then be imported and executed in the Dashboard app.
6
6
  */
7
- export declare function dashboardMetadataPlugin(options: {
8
- rootDir: string;
9
- }): Plugin;
7
+ export declare function dashboardMetadataPlugin(): Plugin;
@@ -7,7 +7,7 @@ const resolvedVirtualModuleId = `\0${virtualModuleId}`;
7
7
  * generates an import statement for each one, wrapped up in a `runDashboardExtensions()`
8
8
  * function which can then be imported and executed in the Dashboard app.
9
9
  */
10
- export function dashboardMetadataPlugin(options) {
10
+ export function dashboardMetadataPlugin() {
11
11
  let configLoaderApi;
12
12
  let loadVendureConfigResult;
13
13
  return {
@@ -41,10 +41,3 @@ export function dashboardMetadataPlugin(options) {
41
41
  },
42
42
  };
43
43
  }
44
- /**
45
- * Converts an import path to a normalized path relative to the rootDir.
46
- */
47
- function normalizeImportPath(rootDir, importPath) {
48
- const relativePath = path.relative(rootDir, importPath).replace(/\\/g, '/');
49
- return relativePath.replace(/\.tsx?$/, '.js');
50
- }
@@ -0,0 +1,7 @@
1
+ import { Plugin } from 'vite';
2
+ /**
3
+ * This Vite plugin transforms the `app/styles.css` file to include a `@source` directive
4
+ * for each dashboard extension's source directory. This allows Tailwind CSS to
5
+ * include styles from these extensions when processing the CSS.
6
+ */
7
+ export declare function dashboardTailwindSourcePlugin(): Plugin;
@@ -0,0 +1,49 @@
1
+ import path from 'path';
2
+ import { getConfigLoaderApi } from './vite-plugin-config-loader.js';
3
+ /**
4
+ * This Vite plugin transforms the `app/styles.css` file to include a `@source` directive
5
+ * for each dashboard extension's source directory. This allows Tailwind CSS to
6
+ * include styles from these extensions when processing the CSS.
7
+ */
8
+ export function dashboardTailwindSourcePlugin() {
9
+ let configLoaderApi;
10
+ let loadVendureConfigResult;
11
+ return {
12
+ name: 'vendure:dashboard-tailwind-source',
13
+ // Ensure this plugin runs before Tailwind CSS processing
14
+ enforce: 'pre',
15
+ configResolved({ plugins }) {
16
+ configLoaderApi = getConfigLoaderApi(plugins);
17
+ },
18
+ async transform(src, id) {
19
+ var _a;
20
+ if (/app\/styles.css$/.test(id)) {
21
+ if (!loadVendureConfigResult) {
22
+ loadVendureConfigResult = await configLoaderApi.getVendureConfig();
23
+ }
24
+ const { pluginInfo } = loadVendureConfigResult;
25
+ const dashboardExtensionDirs = (_a = pluginInfo === null || pluginInfo === void 0 ? void 0 : pluginInfo.map(({ dashboardEntryPath, pluginPath }) => dashboardEntryPath && path.join(pluginPath, path.dirname(dashboardEntryPath))).filter(x => x != null)) !== null && _a !== void 0 ? _a : [];
26
+ const sources = dashboardExtensionDirs
27
+ .map(extension => {
28
+ return `@source '${extension}';`;
29
+ })
30
+ .join('\n');
31
+ // Find the line with the specific comment and insert sources after it
32
+ const lines = src.split('\n');
33
+ const sourceCommentIndex = lines.findIndex(line => line.includes('/* @source rules from extensions will be added here by the dashboardTailwindSourcePlugin */'));
34
+ if (sourceCommentIndex !== -1) {
35
+ // Insert the sources after the comment line
36
+ lines.splice(sourceCommentIndex + 1, 0, sources);
37
+ const modifiedSrc = lines.join('\n');
38
+ return {
39
+ code: modifiedSrc,
40
+ };
41
+ }
42
+ // If the comment is not found, append sources at the end
43
+ return {
44
+ code: src + '\n' + sources,
45
+ };
46
+ }
47
+ },
48
+ };
49
+ }
@@ -7,6 +7,7 @@ import { configLoaderPlugin } from './vite-plugin-config-loader.js';
7
7
  import { viteConfigPlugin } from './vite-plugin-config.js';
8
8
  import { dashboardMetadataPlugin } from './vite-plugin-dashboard-metadata.js';
9
9
  import { gqlTadaPlugin } from './vite-plugin-gql-tada.js';
10
+ import { dashboardTailwindSourcePlugin } from './vite-plugin-tailwind-source.js';
10
11
  import { themeVariablesPlugin } from './vite-plugin-theme.js';
11
12
  import { transformIndexHtmlPlugin } from './vite-plugin-transform-index.js';
12
13
  import { uiConfigPlugin } from './vite-plugin-ui-config.js';
@@ -42,6 +43,7 @@ export function vendureDashboardPlugin(options) {
42
43
  // },
43
44
  }),
44
45
  themeVariablesPlugin({ theme: options.theme }),
46
+ dashboardTailwindSourcePlugin(),
45
47
  tailwindcss(),
46
48
  configLoaderPlugin({
47
49
  vendureConfigPath: normalizedVendureConfigPath,
@@ -51,7 +53,7 @@ export function vendureDashboardPlugin(options) {
51
53
  }),
52
54
  viteConfigPlugin({ packageRoot }),
53
55
  adminApiSchemaPlugin(),
54
- dashboardMetadataPlugin({ rootDir: tempDir }),
56
+ dashboardMetadataPlugin(),
55
57
  uiConfigPlugin({ adminUiConfig: options.adminUiConfig }),
56
58
  ...(options.gqlTadaOutputPath
57
59
  ? [gqlTadaPlugin({ gqlTadaOutputPath: options.gqlTadaOutputPath, tempDir, packageRoot })]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.3.5-master-202506250727",
4
+ "version": "3.3.5-master-202506251318",
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.5-master-202506250727",
90
- "@vendure/core": "^3.3.5-master-202506250727",
89
+ "@vendure/common": "^3.3.5-master-202506251318",
90
+ "@vendure/core": "^3.3.5-master-202506251318",
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": "a1b6b57cbb9b65a63e88cf06e0e65d5a5dd0a87f"
133
+ "gitHead": "ef8a39dcc2b48b73b38647c8dd96a7891d598e7a"
134
134
  }
@@ -0,0 +1,98 @@
1
+ import { useState } from 'react';
2
+ import { toast } from 'sonner';
3
+ import { useMutation } from '@tanstack/react-query';
4
+
5
+ import { Button } from '@/components/ui/button.js';
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from '@/components/ui/dialog.js';
14
+ import { FacetValueSelector, FacetValue } from '@/components/shared/facet-value-selector.js';
15
+ import { api } from '@/graphql/api.js';
16
+ import { ResultOf } from '@/graphql/graphql.js';
17
+ import { Trans, useLingui } from '@/lib/trans.js';
18
+
19
+ import { updateProductsDocument } from '../products.graphql.js';
20
+
21
+ interface AssignFacetValuesDialogProps {
22
+ open: boolean;
23
+ onOpenChange: (open: boolean) => void;
24
+ productIds: string[];
25
+ onSuccess?: () => void;
26
+ }
27
+
28
+ export function AssignFacetValuesDialog({ open, onOpenChange, productIds, onSuccess }: AssignFacetValuesDialogProps) {
29
+ const { i18n } = useLingui();
30
+ const [selectedFacetValueIds, setSelectedFacetValueIds] = useState<string[]>([]);
31
+
32
+ const { mutate, isPending } = useMutation({
33
+ mutationFn: api.mutate(updateProductsDocument),
34
+ onSuccess: (result: ResultOf<typeof updateProductsDocument>) => {
35
+ toast.success(i18n.t(`Successfully updated facet values for ${productIds.length} products`));
36
+ onSuccess?.();
37
+ onOpenChange(false);
38
+ },
39
+ onError: () => {
40
+ toast.error(`Failed to update facet values for ${productIds.length} products`);
41
+ },
42
+ });
43
+
44
+ const handleAssign = () => {
45
+ if (selectedFacetValueIds.length === 0) {
46
+ toast.error('Please select at least one facet value');
47
+ return;
48
+ }
49
+
50
+ mutate({
51
+ input: productIds.map(productId => ({
52
+ id: productId,
53
+ facetValueIds: selectedFacetValueIds,
54
+ })),
55
+ });
56
+ };
57
+
58
+ const handleFacetValueSelect = (facetValue: FacetValue) => {
59
+ setSelectedFacetValueIds(prev => [...new Set([...prev, facetValue.id])]);
60
+ };
61
+
62
+ return (
63
+ <Dialog open={open} onOpenChange={onOpenChange}>
64
+ <DialogContent className="sm:max-w-[500px]">
65
+ <DialogHeader>
66
+ <DialogTitle><Trans>Edit facet values</Trans></DialogTitle>
67
+ <DialogDescription>
68
+ <Trans>Select facet values to assign to {productIds.length} products</Trans>
69
+ </DialogDescription>
70
+ </DialogHeader>
71
+ <div className="grid gap-4 py-4">
72
+ <div className="grid gap-2">
73
+ <label className="text-sm font-medium">
74
+ <Trans>Facet values</Trans>
75
+ </label>
76
+ <FacetValueSelector
77
+ onValueSelect={handleFacetValueSelect}
78
+ placeholder="Search facet values..."
79
+ />
80
+ </div>
81
+ {selectedFacetValueIds.length > 0 && (
82
+ <div className="text-sm text-muted-foreground">
83
+ <Trans>{selectedFacetValueIds.length} facet value(s) selected</Trans>
84
+ </div>
85
+ )}
86
+ </div>
87
+ <DialogFooter>
88
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
89
+ <Trans>Cancel</Trans>
90
+ </Button>
91
+ <Button onClick={handleAssign} disabled={selectedFacetValueIds.length === 0 || isPending}>
92
+ <Trans>Update</Trans>
93
+ </Button>
94
+ </DialogFooter>
95
+ </DialogContent>
96
+ </Dialog>
97
+ );
98
+ }
@@ -0,0 +1,126 @@
1
+ import { useMutation } from '@tanstack/react-query';
2
+ import { useState } from 'react';
3
+ import { toast } from 'sonner';
4
+
5
+ import { ChannelCodeLabel } from '@/components/shared/channel-code-label.js';
6
+ import { Button } from '@/components/ui/button.js';
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from '@/components/ui/dialog.js';
15
+ import { Input } from '@/components/ui/input.js';
16
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
17
+ import { api } from '@/graphql/api.js';
18
+ import { ResultOf } from '@/graphql/graphql.js';
19
+ import { Trans, useLingui } from '@/lib/trans.js';
20
+
21
+ import { useChannel } from '@/hooks/use-channel.js';
22
+ import { assignProductsToChannelDocument } from '../products.graphql.js';
23
+
24
+ interface AssignToChannelDialogProps {
25
+ open: boolean;
26
+ onOpenChange: (open: boolean) => void;
27
+ productIds: string[];
28
+ onSuccess?: () => void;
29
+ }
30
+
31
+ export function AssignToChannelDialog({
32
+ open,
33
+ onOpenChange,
34
+ productIds,
35
+ onSuccess,
36
+ }: AssignToChannelDialogProps) {
37
+ const { i18n } = useLingui();
38
+ const [selectedChannelId, setSelectedChannelId] = useState<string>('');
39
+ const [priceFactor, setPriceFactor] = useState<number>(1);
40
+ const { channels, selectedChannel } = useChannel();
41
+
42
+ // Filter out the currently selected channel from available options
43
+ const availableChannels = channels.filter(channel => channel.id !== selectedChannel?.id);
44
+
45
+ const { mutate, isPending } = useMutation({
46
+ mutationFn: api.mutate(assignProductsToChannelDocument),
47
+ onSuccess: (result: ResultOf<typeof assignProductsToChannelDocument>) => {
48
+ toast.success(i18n.t(`Successfully assigned ${productIds.length} products to channel`));
49
+ onSuccess?.();
50
+ onOpenChange(false);
51
+ },
52
+ onError: () => {
53
+ toast.error(`Failed to assign ${productIds.length} products to channel`);
54
+ },
55
+ });
56
+
57
+ const handleAssign = () => {
58
+ if (!selectedChannelId) {
59
+ toast.error('Please select a channel');
60
+ return;
61
+ }
62
+
63
+ mutate({
64
+ input: {
65
+ productIds,
66
+ channelId: selectedChannelId,
67
+ priceFactor,
68
+ },
69
+ });
70
+ };
71
+
72
+ return (
73
+ <Dialog open={open} onOpenChange={onOpenChange}>
74
+ <DialogContent className="sm:max-w-[425px]">
75
+ <DialogHeader>
76
+ <DialogTitle>
77
+ <Trans>Assign products to channel</Trans>
78
+ </DialogTitle>
79
+ <DialogDescription>
80
+ <Trans>Select a channel to assign {productIds.length} products to</Trans>
81
+ </DialogDescription>
82
+ </DialogHeader>
83
+ <div className="grid gap-4 py-4">
84
+ <div className="grid gap-2">
85
+ <label className="text-sm font-medium">
86
+ <Trans>Channel</Trans>
87
+ </label>
88
+ <Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
89
+ <SelectTrigger>
90
+ <SelectValue placeholder={i18n.t('Select a channel')} />
91
+ </SelectTrigger>
92
+ <SelectContent>
93
+ {availableChannels.map(channel => (
94
+ <SelectItem key={channel.id} value={channel.id}>
95
+ <ChannelCodeLabel code={channel.code} />
96
+ </SelectItem>
97
+ ))}
98
+ </SelectContent>
99
+ </Select>
100
+ </div>
101
+ <div className="grid gap-2">
102
+ <label className="text-sm font-medium">
103
+ <Trans>Price conversion factor</Trans>
104
+ </label>
105
+ <Input
106
+ type="number"
107
+ min="0"
108
+ max="99999"
109
+ step="0.01"
110
+ value={priceFactor}
111
+ onChange={e => setPriceFactor(parseFloat(e.target.value) || 1)}
112
+ />
113
+ </div>
114
+ </div>
115
+ <DialogFooter>
116
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
117
+ <Trans>Cancel</Trans>
118
+ </Button>
119
+ <Button onClick={handleAssign} disabled={!selectedChannelId || isPending}>
120
+ <Trans>Assign</Trans>
121
+ </Button>
122
+ </DialogFooter>
123
+ </DialogContent>
124
+ </Dialog>
125
+ );
126
+ }
@@ -0,0 +1,268 @@
1
+ import { useMutation } from '@tanstack/react-query';
2
+ import { CopyIcon, LayersIcon, TagIcon, TrashIcon } from 'lucide-react';
3
+ import { useState } from 'react';
4
+ import { toast } from 'sonner';
5
+
6
+ import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
7
+ import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
8
+ import { api } from '@/graphql/api.js';
9
+ import { ResultOf } from '@/graphql/graphql.js';
10
+ import { useChannel, usePaginatedList } from '@/index.js';
11
+ import { Trans, useLingui } from '@/lib/trans.js';
12
+
13
+ import { Permission } from '@vendure/common/lib/generated-types';
14
+ import {
15
+ deleteProductsDocument,
16
+ duplicateEntityDocument,
17
+ removeProductsFromChannelDocument,
18
+ } from '../products.graphql.js';
19
+ import { AssignFacetValuesDialog } from './assign-facet-values-dialog.js';
20
+ import { AssignToChannelDialog } from './assign-to-channel-dialog.js';
21
+
22
+ export const DeleteProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
23
+ const { refetchPaginatedList } = usePaginatedList();
24
+ const { i18n } = useLingui();
25
+ const { mutate } = useMutation({
26
+ mutationFn: api.mutate(deleteProductsDocument),
27
+ onSuccess: (result: ResultOf<typeof deleteProductsDocument>) => {
28
+ let deleted = 0;
29
+ const errors: string[] = [];
30
+ for (const item of result.deleteProducts) {
31
+ if (item.result === 'DELETED') {
32
+ deleted++;
33
+ } else if (item.message) {
34
+ errors.push(item.message);
35
+ }
36
+ }
37
+ if (0 < deleted) {
38
+ toast.success(i18n.t(`Deleted ${deleted} products`));
39
+ }
40
+ if (0 < errors.length) {
41
+ toast.error(i18n.t(`Failed to delete ${errors.length} products`));
42
+ }
43
+ refetchPaginatedList();
44
+ table.resetRowSelection();
45
+ },
46
+ onError: () => {
47
+ toast.error(`Failed to delete ${selection.length} products`);
48
+ },
49
+ });
50
+ return (
51
+ <DataTableBulkActionItem
52
+ requiresPermission={[Permission.DeleteCatalog, Permission.DeleteProduct]}
53
+ onClick={() => mutate({ ids: selection.map(s => s.id) })}
54
+ label={<Trans>Delete</Trans>}
55
+ confirmationText={<Trans>Are you sure you want to delete {selection.length} products?</Trans>}
56
+ icon={TrashIcon}
57
+ className="text-destructive"
58
+ />
59
+ );
60
+ };
61
+
62
+ export const AssignProductsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
63
+ const { refetchPaginatedList } = usePaginatedList();
64
+ const { channels, selectedChannel } = useChannel();
65
+ const [dialogOpen, setDialogOpen] = useState(false);
66
+
67
+ if (channels.length < 2) {
68
+ return null;
69
+ }
70
+
71
+ const handleSuccess = () => {
72
+ refetchPaginatedList();
73
+ table.resetRowSelection();
74
+ };
75
+
76
+ return (
77
+ <>
78
+ <DataTableBulkActionItem
79
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
80
+ onClick={() => setDialogOpen(true)}
81
+ label={<Trans>Assign to channel</Trans>}
82
+ icon={LayersIcon}
83
+ />
84
+ <AssignToChannelDialog
85
+ open={dialogOpen}
86
+ onOpenChange={setDialogOpen}
87
+ productIds={selection.map(s => s.id)}
88
+ onSuccess={handleSuccess}
89
+ />
90
+ </>
91
+ );
92
+ };
93
+
94
+ export const RemoveProductsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
95
+ const { refetchPaginatedList } = usePaginatedList();
96
+ const { selectedChannel } = useChannel();
97
+ const { i18n } = useLingui();
98
+ const { mutate } = useMutation({
99
+ mutationFn: api.mutate(removeProductsFromChannelDocument),
100
+ onSuccess: () => {
101
+ toast.success(i18n.t(`Successfully removed ${selection.length} products from channel`));
102
+ refetchPaginatedList();
103
+ table.resetRowSelection();
104
+ },
105
+ onError: error => {
106
+ toast.error(`Failed to remove ${selection.length} products from channel: ${error.message}`);
107
+ },
108
+ });
109
+
110
+ if (!selectedChannel) {
111
+ return null;
112
+ }
113
+
114
+ const handleRemove = () => {
115
+ mutate({
116
+ input: {
117
+ productIds: selection.map(s => s.id),
118
+ channelId: selectedChannel.id,
119
+ },
120
+ });
121
+ };
122
+
123
+ return (
124
+ <DataTableBulkActionItem
125
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
126
+ onClick={handleRemove}
127
+ label={<Trans>Remove from current channel</Trans>}
128
+ confirmationText={
129
+ <Trans>
130
+ Are you sure you want to remove {selection.length} products from the current channel?
131
+ </Trans>
132
+ }
133
+ icon={LayersIcon}
134
+ className="text-warning"
135
+ />
136
+ );
137
+ };
138
+
139
+ export const AssignFacetValuesToProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
140
+ const { refetchPaginatedList } = usePaginatedList();
141
+ const [dialogOpen, setDialogOpen] = useState(false);
142
+
143
+ const handleSuccess = () => {
144
+ refetchPaginatedList();
145
+ table.resetRowSelection();
146
+ };
147
+
148
+ return (
149
+ <>
150
+ <DataTableBulkActionItem
151
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
152
+ onClick={() => setDialogOpen(true)}
153
+ label={<Trans>Edit facet values</Trans>}
154
+ icon={TagIcon}
155
+ />
156
+ <AssignFacetValuesDialog
157
+ open={dialogOpen}
158
+ onOpenChange={setDialogOpen}
159
+ productIds={selection.map(s => s.id)}
160
+ onSuccess={handleSuccess}
161
+ />
162
+ </>
163
+ );
164
+ };
165
+
166
+ export const DuplicateProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
167
+ const { refetchPaginatedList } = usePaginatedList();
168
+ const { i18n } = useLingui();
169
+ const [isDuplicating, setIsDuplicating] = useState(false);
170
+ const [progress, setProgress] = useState({ completed: 0, total: 0 });
171
+
172
+ const { mutateAsync } = useMutation({
173
+ mutationFn: api.mutate(duplicateEntityDocument),
174
+ });
175
+
176
+ const handleDuplicate = async () => {
177
+ if (isDuplicating) return;
178
+
179
+ setIsDuplicating(true);
180
+ setProgress({ completed: 0, total: selection.length });
181
+
182
+ const results = {
183
+ success: 0,
184
+ failed: 0,
185
+ errors: [] as string[],
186
+ };
187
+
188
+ try {
189
+ // Process products sequentially to avoid overwhelming the server
190
+ for (let i = 0; i < selection.length; i++) {
191
+ const product = selection[i];
192
+
193
+ try {
194
+ const result = await mutateAsync({
195
+ input: {
196
+ entityName: 'Product',
197
+ entityId: product.id,
198
+ duplicatorInput: {
199
+ code: 'product-duplicator',
200
+ arguments: [
201
+ {
202
+ name: 'includeVariants',
203
+ value: 'true',
204
+ },
205
+ ],
206
+ },
207
+ },
208
+ });
209
+
210
+ if ('newEntityId' in result.duplicateEntity) {
211
+ results.success++;
212
+ } else {
213
+ results.failed++;
214
+ const errorMsg =
215
+ result.duplicateEntity.message ||
216
+ result.duplicateEntity.duplicationError ||
217
+ 'Unknown error';
218
+ results.errors.push(`Product ${product.name || product.id}: ${errorMsg}`);
219
+ }
220
+ } catch (error) {
221
+ results.failed++;
222
+ results.errors.push(
223
+ `Product ${product.name || product.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
224
+ );
225
+ }
226
+
227
+ setProgress({ completed: i + 1, total: selection.length });
228
+ }
229
+
230
+ // Show results
231
+ if (results.success > 0) {
232
+ toast.success(i18n.t(`Successfully duplicated ${results.success} products`));
233
+ }
234
+ if (results.failed > 0) {
235
+ const errorMessage =
236
+ results.errors.length > 3
237
+ ? `${results.errors.slice(0, 3).join(', ')}... and ${results.errors.length - 3} more`
238
+ : results.errors.join(', ');
239
+ toast.error(`Failed to duplicate ${results.failed} products: ${errorMessage}`);
240
+ }
241
+
242
+ if (results.success > 0) {
243
+ refetchPaginatedList();
244
+ table.resetRowSelection();
245
+ }
246
+ } finally {
247
+ setIsDuplicating(false);
248
+ setProgress({ completed: 0, total: 0 });
249
+ }
250
+ };
251
+
252
+ return (
253
+ <DataTableBulkActionItem
254
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
255
+ onClick={handleDuplicate}
256
+ label={
257
+ isDuplicating ? (
258
+ <Trans>
259
+ Duplicating... ({progress.completed}/{progress.total})
260
+ </Trans>
261
+ ) : (
262
+ <Trans>Duplicate</Trans>
263
+ )
264
+ }
265
+ icon={CopyIcon}
266
+ />
267
+ );
268
+ };