@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.
- package/dist/plugin/tests/barrel-exports.spec.js +1 -1
- package/dist/plugin/vite-plugin-config.js +1 -0
- package/dist/plugin/vite-plugin-dashboard-metadata.d.ts +1 -3
- package/dist/plugin/vite-plugin-dashboard-metadata.js +1 -8
- package/dist/plugin/vite-plugin-tailwind-source.d.ts +7 -0
- package/dist/plugin/vite-plugin-tailwind-source.js +49 -0
- package/dist/plugin/vite-plugin-vendure-dashboard.js +3 -1
- package/package.json +4 -4
- package/src/app/routes/_authenticated/_products/components/assign-facet-values-dialog.tsx +98 -0
- package/src/app/routes/_authenticated/_products/components/assign-to-channel-dialog.tsx +126 -0
- package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +268 -0
- package/src/app/routes/_authenticated/_products/products.graphql.ts +64 -0
- package/src/app/routes/_authenticated/_products/products.tsx +31 -2
- package/src/app/routes/_authenticated/_products/products_.$id.tsx +3 -1
- package/src/app/styles.css +3 -0
- package/src/lib/components/data-table/data-table-bulk-action-item.tsx +101 -0
- package/src/lib/components/data-table/data-table-bulk-actions.tsx +89 -0
- package/src/lib/components/data-table/data-table-filter-badge.tsx +16 -8
- package/src/lib/components/data-table/data-table-filter-dialog.tsx +4 -4
- package/src/lib/components/data-table/data-table-pagination.tsx +2 -2
- package/src/lib/components/data-table/data-table.tsx +50 -31
- package/src/lib/components/data-table/human-readable-operator.tsx +3 -3
- package/src/lib/components/shared/assigned-facet-values.tsx +1 -5
- package/src/lib/components/shared/custom-fields-form.tsx +141 -67
- package/src/lib/components/shared/paginated-list-data-table.tsx +47 -11
- package/src/lib/framework/data-table/data-table-extensions.ts +21 -0
- package/src/lib/framework/data-table/data-table-types.ts +25 -0
- package/src/lib/framework/extension-api/define-dashboard-extension.ts +11 -0
- package/src/lib/framework/extension-api/extension-api-types.ts +35 -0
- package/src/lib/framework/form-engine/use-generated-form.tsx +2 -5
- package/src/lib/framework/layout-engine/page-block-provider.tsx +6 -0
- package/src/lib/framework/layout-engine/page-layout.tsx +43 -33
- package/src/lib/framework/page/list-page.tsx +6 -8
- package/src/lib/framework/registry/registry-types.ts +4 -2
- package/src/lib/hooks/use-page-block.tsx +10 -0
- package/src/lib/index.ts +8 -1
- package/vite/tests/barrel-exports.spec.ts +13 -9
- package/vite/vite-plugin-config.ts +1 -0
- package/vite/vite-plugin-dashboard-metadata.ts +1 -9
- package/vite/vite-plugin-tailwind-source.ts +65 -0
- package/vite/vite-plugin-vendure-dashboard.ts +5 -3
- /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(
|
|
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(
|
|
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(
|
|
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-
|
|
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-
|
|
90
|
-
"@vendure/core": "^3.3.5-master-
|
|
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": "
|
|
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
|
+
};
|