@vendure/dashboard 3.3.5-master-202506250724 → 3.3.5-master-202506251305
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/_administrators/administrators_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_assets/assets_.$id.tsx +43 -34
- package/src/app/routes/_authenticated/_channels/channels_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_countries/countries_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_customer-groups/customer-groups_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_customers/customers_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_facets/facets_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_global-settings/global-settings.tsx +1 -1
- package/src/app/routes/_authenticated/_orders/orders_.$id.tsx +2 -5
- package/src/app/routes/_authenticated/_payment-methods/payment-methods_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +1 -1
- 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 +14 -9
- package/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_roles/roles_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_sellers/sellers_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_stock-locations/stock-locations_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_tax-categories/tax-categories_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_tax-rates/tax-rates_.$id.tsx +1 -1
- package/src/app/routes/_authenticated/_zones/zones_.$id.tsx +1 -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/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
|
@@ -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
|
+
};
|
|
@@ -119,3 +119,67 @@ export const deleteProductDocument = graphql(`
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
`);
|
|
122
|
+
|
|
123
|
+
export const deleteProductsDocument = graphql(`
|
|
124
|
+
mutation DeleteProducts($ids: [ID!]!) {
|
|
125
|
+
deleteProducts(ids: $ids) {
|
|
126
|
+
result
|
|
127
|
+
message
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
`);
|
|
131
|
+
|
|
132
|
+
export const assignProductsToChannelDocument = graphql(`
|
|
133
|
+
mutation AssignProductsToChannel($input: AssignProductsToChannelInput!) {
|
|
134
|
+
assignProductsToChannel(input: $input) {
|
|
135
|
+
id
|
|
136
|
+
channels {
|
|
137
|
+
id
|
|
138
|
+
code
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
`);
|
|
143
|
+
|
|
144
|
+
export const removeProductsFromChannelDocument = graphql(`
|
|
145
|
+
mutation RemoveProductsFromChannel($input: RemoveProductsFromChannelInput!) {
|
|
146
|
+
removeProductsFromChannel(input: $input) {
|
|
147
|
+
id
|
|
148
|
+
channels {
|
|
149
|
+
id
|
|
150
|
+
code
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
`);
|
|
155
|
+
|
|
156
|
+
export const updateProductsDocument = graphql(`
|
|
157
|
+
mutation UpdateProducts($input: [UpdateProductInput!]!) {
|
|
158
|
+
updateProducts(input: $input) {
|
|
159
|
+
id
|
|
160
|
+
name
|
|
161
|
+
facetValues {
|
|
162
|
+
id
|
|
163
|
+
name
|
|
164
|
+
code
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
`);
|
|
169
|
+
|
|
170
|
+
export const duplicateEntityDocument = graphql(`
|
|
171
|
+
mutation DuplicateEntity($input: DuplicateEntityInput!) {
|
|
172
|
+
duplicateEntity(input: $input) {
|
|
173
|
+
... on DuplicateEntitySuccess {
|
|
174
|
+
newEntityId
|
|
175
|
+
}
|
|
176
|
+
... on ErrorResult {
|
|
177
|
+
errorCode
|
|
178
|
+
message
|
|
179
|
+
}
|
|
180
|
+
... on DuplicateEntityError {
|
|
181
|
+
duplicationError
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
`);
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { DetailPageButton } from '@/components/shared/detail-page-button.js';
|
|
2
2
|
import { PermissionGuard } from '@/components/shared/permission-guard.js';
|
|
3
3
|
import { Button } from '@/components/ui/button.js';
|
|
4
|
-
import {
|
|
4
|
+
import { PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
|
|
5
5
|
import { ListPage } from '@/framework/page/list-page.js';
|
|
6
6
|
import { Trans } from '@/lib/trans.js';
|
|
7
7
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
|
8
|
-
import { PlusIcon
|
|
8
|
+
import { PlusIcon } from 'lucide-react';
|
|
9
|
+
import {
|
|
10
|
+
AssignFacetValuesToProductsBulkAction,
|
|
11
|
+
AssignProductsToChannelBulkAction,
|
|
12
|
+
DeleteProductsBulkAction,
|
|
13
|
+
DuplicateProductsBulkAction,
|
|
14
|
+
RemoveProductsFromChannelBulkAction,
|
|
15
|
+
} from './components/product-bulk-actions.js';
|
|
9
16
|
import { deleteProductDocument, productListDocument } from './products.graphql.js';
|
|
10
17
|
|
|
11
18
|
export const Route = createFileRoute('/_authenticated/_products/products')({
|
|
@@ -32,6 +39,28 @@ function ProductListPage() {
|
|
|
32
39
|
};
|
|
33
40
|
}}
|
|
34
41
|
route={Route}
|
|
42
|
+
bulkActions={[
|
|
43
|
+
{
|
|
44
|
+
component: AssignProductsToChannelBulkAction,
|
|
45
|
+
order: 100,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
component: RemoveProductsFromChannelBulkAction,
|
|
49
|
+
order: 200,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
component: AssignFacetValuesToProductsBulkAction,
|
|
53
|
+
order: 300,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
component: DuplicateProductsBulkAction,
|
|
57
|
+
order: 400,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
component: DeleteProductsBulkAction,
|
|
61
|
+
order: 500,
|
|
62
|
+
},
|
|
63
|
+
]}
|
|
35
64
|
>
|
|
36
65
|
<PageActionBarRight>
|
|
37
66
|
<PermissionGuard requires={['CreateProduct', 'CreateCatalog']}>
|
|
@@ -24,12 +24,12 @@ import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader
|
|
|
24
24
|
import { useDetailPage } from '@/framework/page/use-detail-page.js';
|
|
25
25
|
import { Trans, useLingui } from '@/lib/trans.js';
|
|
26
26
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
27
|
+
import { useRef } from 'react';
|
|
27
28
|
import { toast } from 'sonner';
|
|
29
|
+
import { AddProductVariantDialog } from './components/add-product-variant-dialog.js';
|
|
28
30
|
import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
|
|
29
31
|
import { ProductVariantsTable } from './components/product-variants-table.js';
|
|
30
|
-
import { AddProductVariantDialog } from './components/add-product-variant-dialog.js';
|
|
31
32
|
import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
|
|
32
|
-
import { useRef } from 'react';
|
|
33
33
|
|
|
34
34
|
export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
|
|
35
35
|
component: ProductDetailPage,
|
|
@@ -50,9 +50,11 @@ function ProductDetailPage() {
|
|
|
50
50
|
const navigate = useNavigate();
|
|
51
51
|
const creatingNewEntity = params.id === NEW_ENTITY_PATH;
|
|
52
52
|
const { i18n } = useLingui();
|
|
53
|
-
const refreshRef = useRef<() => void>(() => {
|
|
53
|
+
const refreshRef = useRef<() => void>(() => {
|
|
54
|
+
});
|
|
54
55
|
|
|
55
56
|
const { form, submitHandler, entity, isPending, refreshEntity, resetForm } = useDetailPage({
|
|
57
|
+
entityName: 'Product',
|
|
56
58
|
queryDocument: productDetailDocument,
|
|
57
59
|
createDocument: createProductDocument,
|
|
58
60
|
updateDocument: updateProductDocument,
|
|
@@ -88,9 +90,9 @@ function ProductDetailPage() {
|
|
|
88
90
|
});
|
|
89
91
|
},
|
|
90
92
|
});
|
|
91
|
-
|
|
93
|
+
|
|
92
94
|
return (
|
|
93
|
-
<Page pageId="product-detail"
|
|
95
|
+
<Page pageId="product-detail" form={form} submitHandler={submitHandler} entity={entity}>
|
|
94
96
|
<PageTitle>{creatingNewEntity ? <Trans>New product</Trans> : (entity?.name ?? '')}</PageTitle>
|
|
95
97
|
<PageActionBar>
|
|
96
98
|
<PageActionBarRight>
|
|
@@ -142,10 +144,13 @@ function ProductDetailPage() {
|
|
|
142
144
|
<CustomFieldsPageBlock column="main" entityType="Product" control={form.control} />
|
|
143
145
|
{entity && entity.variantList.totalItems > 0 && (
|
|
144
146
|
<PageBlock column="main" blockId="product-variants-table">
|
|
145
|
-
<ProductVariantsTable
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
<ProductVariantsTable
|
|
148
|
+
productId={params.id}
|
|
149
|
+
registerRefresher={refresher => {
|
|
150
|
+
refreshRef.current = refresher;
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
<div className="mt-4">
|
|
149
154
|
<AddProductVariantDialog
|
|
150
155
|
productId={params.id}
|
|
151
156
|
onSuccess={() => {
|
|
@@ -114,7 +114,7 @@ function PromotionDetailPage() {
|
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
return (
|
|
117
|
-
<Page pageId="promotion-detail" form={form} submitHandler={submitHandler}>
|
|
117
|
+
<Page pageId="promotion-detail" form={form} submitHandler={submitHandler} entity={entity}>
|
|
118
118
|
<PageTitle>{creatingNewEntity ? <Trans>New promotion</Trans> : (entity?.name ?? '')}</PageTitle>
|
|
119
119
|
<PageActionBar>
|
|
120
120
|
<PageActionBarRight>
|
|
@@ -71,7 +71,7 @@ function RoleDetailPage() {
|
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
return (
|
|
74
|
-
<Page pageId="role-detail" form={form} submitHandler={submitHandler}>
|
|
74
|
+
<Page pageId="role-detail" form={form} submitHandler={submitHandler} entity={entity}>
|
|
75
75
|
<PageTitle>{creatingNewEntity ? <Trans>New role</Trans> : (entity?.description ?? '')}</PageTitle>
|
|
76
76
|
<PageActionBar>
|
|
77
77
|
<PageActionBarRight>
|
|
@@ -65,7 +65,7 @@ function SellerDetailPage() {
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
return (
|
|
68
|
-
<Page pageId="seller-detail" form={form} submitHandler={submitHandler}>
|
|
68
|
+
<Page pageId="seller-detail" form={form} submitHandler={submitHandler} entity={entity}>
|
|
69
69
|
<PageTitle>{creatingNewEntity ? <Trans>New seller</Trans> : (entity?.name ?? '')}</PageTitle>
|
|
70
70
|
<PageActionBar>
|
|
71
71
|
<PageActionBarRight>
|
|
@@ -94,7 +94,7 @@ function ShippingMethodDetailPage() {
|
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
return (
|
|
97
|
-
<Page pageId="shipping-method-detail" form={form} submitHandler={submitHandler}>
|
|
97
|
+
<Page pageId="shipping-method-detail" form={form} submitHandler={submitHandler} entity={entity}>
|
|
98
98
|
<PageTitle>
|
|
99
99
|
{creatingNewEntity ? <Trans>New shipping method</Trans> : (entity?.name ?? '')}
|
|
100
100
|
</PageTitle>
|
|
@@ -74,7 +74,7 @@ function StockLocationDetailPage() {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
return (
|
|
77
|
-
<Page pageId="stock-location-detail" form={form} submitHandler={submitHandler}>
|
|
77
|
+
<Page pageId="stock-location-detail" form={form} submitHandler={submitHandler} entity={entity}>
|
|
78
78
|
<PageTitle>
|
|
79
79
|
{creatingNewEntity ? <Trans>New stock location</Trans> : (entity?.name ?? '')}
|
|
80
80
|
</PageTitle>
|
|
@@ -73,7 +73,7 @@ function TaxCategoryDetailPage() {
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
return (
|
|
76
|
-
<Page pageId="tax-category-detail" form={form} submitHandler={submitHandler}>
|
|
76
|
+
<Page pageId="tax-category-detail" form={form} submitHandler={submitHandler} entity={entity}>
|
|
77
77
|
<PageTitle>
|
|
78
78
|
{creatingNewEntity ? <Trans>New tax category</Trans> : (entity?.name ?? '')}
|
|
79
79
|
</PageTitle>
|
|
@@ -77,7 +77,7 @@ function TaxRateDetailPage() {
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
return (
|
|
80
|
-
<Page pageId="tax-rate-detail" form={form} submitHandler={submitHandler}>
|
|
80
|
+
<Page pageId="tax-rate-detail" form={form} submitHandler={submitHandler} entity={entity}>
|
|
81
81
|
<PageTitle>{creatingNewEntity ? <Trans>New tax rate</Trans> : (entity?.name ?? '')}</PageTitle>
|
|
82
82
|
<PageActionBar>
|
|
83
83
|
<PageActionBarRight>
|
|
@@ -66,7 +66,7 @@ function ZoneDetailPage() {
|
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
return (
|
|
69
|
-
<Page pageId="zone-detail" form={form} submitHandler={submitHandler}>
|
|
69
|
+
<Page pageId="zone-detail" form={form} submitHandler={submitHandler} entity={entity}>
|
|
70
70
|
<PageTitle>{creatingNewEntity ? <Trans>New zone</Trans> : (entity?.name ?? '')}</PageTitle>
|
|
71
71
|
<PageActionBar>
|
|
72
72
|
<PageActionBarRight>
|
package/src/app/styles.css
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
@custom-variant dark (&:is(.dark *));
|
|
6
6
|
|
|
7
|
+
/* @source rules from extensions will be added here by the dashboardTailwindSourcePlugin */
|
|
8
|
+
|
|
7
9
|
/*
|
|
8
10
|
* Important: This is not an actual import. We are pre-processing this CSS file
|
|
9
11
|
* to inject the theme variables into the CSS. This import will be replaced
|
|
@@ -64,6 +66,7 @@
|
|
|
64
66
|
* {
|
|
65
67
|
@apply border-border outline-ring/50;
|
|
66
68
|
}
|
|
69
|
+
|
|
67
70
|
body {
|
|
68
71
|
@apply bg-background text-foreground;
|
|
69
72
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { usePermissions } from '@/hooks/use-permissions.js';
|
|
2
|
+
import { Trans } from '@/lib/trans.js';
|
|
3
|
+
import { cn } from '@/lib/utils.js';
|
|
4
|
+
import { LucideIcon } from 'lucide-react';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
AlertDialog,
|
|
8
|
+
AlertDialogAction,
|
|
9
|
+
AlertDialogCancel,
|
|
10
|
+
AlertDialogContent,
|
|
11
|
+
AlertDialogDescription,
|
|
12
|
+
AlertDialogFooter,
|
|
13
|
+
AlertDialogHeader,
|
|
14
|
+
AlertDialogTitle,
|
|
15
|
+
AlertDialogTrigger,
|
|
16
|
+
} from '../ui/alert-dialog.js';
|
|
17
|
+
import { DropdownMenuItem } from '../ui/dropdown-menu.js';
|
|
18
|
+
|
|
19
|
+
export interface DataTableBulkActionItemProps {
|
|
20
|
+
label: React.ReactNode;
|
|
21
|
+
icon?: LucideIcon;
|
|
22
|
+
confirmationText?: React.ReactNode;
|
|
23
|
+
onClick: () => void;
|
|
24
|
+
className?: string;
|
|
25
|
+
requiresPermission?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function DataTableBulkActionItem({
|
|
29
|
+
label,
|
|
30
|
+
icon: Icon,
|
|
31
|
+
confirmationText,
|
|
32
|
+
className,
|
|
33
|
+
onClick,
|
|
34
|
+
requiresPermission,
|
|
35
|
+
}: DataTableBulkActionItemProps) {
|
|
36
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
37
|
+
const { hasPermissions } = usePermissions();
|
|
38
|
+
const userHasPermission = hasPermissions(requiresPermission ?? []);
|
|
39
|
+
|
|
40
|
+
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
e.stopPropagation();
|
|
43
|
+
if (!userHasPermission) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (confirmationText) {
|
|
47
|
+
setIsOpen(true);
|
|
48
|
+
} else {
|
|
49
|
+
onClick?.();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handleConfirm = () => {
|
|
54
|
+
setIsOpen(false);
|
|
55
|
+
onClick?.();
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleCancel = () => {
|
|
59
|
+
setIsOpen(false);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (confirmationText) {
|
|
63
|
+
return (
|
|
64
|
+
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
|
65
|
+
<AlertDialogTrigger asChild>
|
|
66
|
+
<DropdownMenuItem onClick={handleClick} disabled={!userHasPermission}>
|
|
67
|
+
{Icon && <Icon className={cn('mr-1 h-4 w-4', className)} />}
|
|
68
|
+
<span className={cn('text-sm', className)}>
|
|
69
|
+
<Trans>{label}</Trans>
|
|
70
|
+
</span>
|
|
71
|
+
</DropdownMenuItem>
|
|
72
|
+
</AlertDialogTrigger>
|
|
73
|
+
<AlertDialogContent>
|
|
74
|
+
<AlertDialogHeader>
|
|
75
|
+
<AlertDialogTitle>
|
|
76
|
+
<Trans>Confirm Action</Trans>
|
|
77
|
+
</AlertDialogTitle>
|
|
78
|
+
<AlertDialogDescription>{confirmationText}</AlertDialogDescription>
|
|
79
|
+
</AlertDialogHeader>
|
|
80
|
+
<AlertDialogFooter>
|
|
81
|
+
<AlertDialogCancel onClick={handleCancel}>
|
|
82
|
+
<Trans>Cancel</Trans>
|
|
83
|
+
</AlertDialogCancel>
|
|
84
|
+
<AlertDialogAction onClick={handleConfirm}>
|
|
85
|
+
<Trans>Continue</Trans>
|
|
86
|
+
</AlertDialogAction>
|
|
87
|
+
</AlertDialogFooter>
|
|
88
|
+
</AlertDialogContent>
|
|
89
|
+
</AlertDialog>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<DropdownMenuItem onClick={handleClick}>
|
|
95
|
+
{Icon && <Icon className={cn('mr-1 h-4 w-4', className)} />}
|
|
96
|
+
<span className={cn('text-sm', className)}>
|
|
97
|
+
<Trans>{label}</Trans>
|
|
98
|
+
</span>
|
|
99
|
+
</DropdownMenuItem>
|
|
100
|
+
);
|
|
101
|
+
}
|