@vendure/dashboard 3.3.6-master-202507011151 → 3.3.6-master-202507020959

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 (86) hide show
  1. package/package.json +4 -4
  2. package/src/app/common/delete-bulk-action.tsx +147 -0
  3. package/src/app/common/duplicate-bulk-action.tsx +1 -1
  4. package/src/app/routes/_authenticated/_administrators/administrators.graphql.ts +9 -0
  5. package/src/app/routes/_authenticated/_administrators/administrators.tsx +7 -0
  6. package/src/app/routes/_authenticated/_administrators/components/administrator-bulk-actions.tsx +15 -0
  7. package/src/app/routes/_authenticated/_assets/assets.graphql.ts +11 -0
  8. package/src/app/routes/_authenticated/_assets/assets.tsx +10 -2
  9. package/src/app/routes/_authenticated/_assets/components/asset-bulk-actions.tsx +45 -0
  10. package/src/app/routes/_authenticated/_channels/channels.graphql.ts +9 -0
  11. package/src/app/routes/_authenticated/_channels/channels.tsx +7 -0
  12. package/src/app/routes/_authenticated/_channels/components/channel-bulk-actions.tsx +15 -0
  13. package/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx +39 -110
  14. package/src/app/routes/_authenticated/_countries/components/country-bulk-actions.tsx +15 -0
  15. package/src/app/routes/_authenticated/_countries/countries.graphql.ts +9 -0
  16. package/src/app/routes/_authenticated/_countries/countries.tsx +7 -0
  17. package/src/app/routes/_authenticated/_customer-groups/components/customer-group-bulk-actions.tsx +15 -0
  18. package/src/app/routes/_authenticated/_customer-groups/customer-groups.graphql.ts +9 -0
  19. package/src/app/routes/_authenticated/_customer-groups/customer-groups.tsx +7 -0
  20. package/src/app/routes/_authenticated/_customers/components/customer-bulk-actions.tsx +15 -0
  21. package/src/app/routes/_authenticated/_customers/customers.graphql.ts +9 -1
  22. package/src/app/routes/_authenticated/_customers/customers.tsx +7 -0
  23. package/src/app/routes/_authenticated/_facets/components/facet-bulk-actions.tsx +104 -0
  24. package/src/app/routes/_authenticated/_facets/facets.graphql.ts +30 -0
  25. package/src/app/routes/_authenticated/_facets/facets.tsx +24 -0
  26. package/src/app/routes/_authenticated/_payment-methods/components/payment-method-bulk-actions.tsx +58 -0
  27. package/src/app/routes/_authenticated/_payment-methods/payment-methods.graphql.ts +27 -0
  28. package/src/app/routes/_authenticated/_payment-methods/payment-methods.tsx +30 -8
  29. package/src/app/routes/_authenticated/_payment-methods/payment-methods_.$id.tsx +4 -1
  30. package/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx +36 -110
  31. package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +36 -105
  32. package/src/app/routes/_authenticated/_promotions/components/promotion-bulk-actions.tsx +82 -0
  33. package/src/app/routes/_authenticated/_promotions/promotions.graphql.ts +25 -0
  34. package/src/app/routes/_authenticated/_promotions/promotions.tsx +24 -0
  35. package/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx +1 -1
  36. package/src/app/routes/_authenticated/_roles/components/role-bulk-actions.tsx +15 -0
  37. package/src/app/routes/_authenticated/_roles/roles.graphql.ts +9 -0
  38. package/src/app/routes/_authenticated/_roles/roles.tsx +7 -0
  39. package/src/app/routes/_authenticated/_sellers/components/seller-bulk-actions.tsx +15 -0
  40. package/src/app/routes/_authenticated/_sellers/sellers.graphql.ts +9 -0
  41. package/src/app/routes/_authenticated/_sellers/sellers.tsx +7 -0
  42. package/src/app/routes/_authenticated/_sellers/sellers_.$id.tsx +1 -1
  43. package/src/app/routes/_authenticated/_shipping-methods/components/shipping-method-bulk-actions.tsx +61 -0
  44. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods.graphql.ts +27 -0
  45. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx +19 -0
  46. package/src/app/routes/_authenticated/_stock-locations/components/stock-location-bulk-actions.tsx +58 -0
  47. package/src/app/routes/_authenticated/_stock-locations/stock-locations.graphql.ts +25 -0
  48. package/src/app/routes/_authenticated/_stock-locations/stock-locations.tsx +19 -0
  49. package/src/app/routes/_authenticated/_tax-categories/components/tax-category-bulk-actions.tsx +15 -0
  50. package/src/app/routes/_authenticated/_tax-categories/tax-categories.graphql.ts +9 -0
  51. package/src/app/routes/_authenticated/_tax-categories/tax-categories.tsx +7 -0
  52. package/src/app/routes/_authenticated/_tax-rates/components/tax-rate-bulk-actions.tsx +15 -0
  53. package/src/app/routes/_authenticated/_tax-rates/tax-rates.graphql.ts +9 -0
  54. package/src/app/routes/_authenticated/_tax-rates/tax-rates.tsx +7 -0
  55. package/src/app/routes/_authenticated/_zones/components/zone-bulk-actions.tsx +15 -0
  56. package/src/app/routes/_authenticated/_zones/zones.graphql.ts +9 -0
  57. package/src/app/routes/_authenticated/_zones/zones.tsx +7 -0
  58. package/src/lib/components/shared/asset/asset-bulk-actions.tsx +90 -0
  59. package/src/lib/components/shared/asset/asset-gallery.tsx +12 -7
  60. package/src/lib/components/shared/assign-to-channel-bulk-action.tsx +70 -0
  61. package/src/{app/routes/_authenticated/_products/components → lib/components/shared}/assign-to-channel-dialog.tsx +48 -30
  62. package/src/lib/components/shared/remove-from-channel-bulk-action.tsx +89 -0
  63. package/src/lib/framework/component-registry/component-registry.tsx +31 -47
  64. package/src/lib/framework/extension-api/define-dashboard-extension.ts +29 -95
  65. package/src/lib/framework/extension-api/display-component-extensions.tsx +69 -0
  66. package/src/lib/framework/extension-api/extension-api-types.ts +18 -160
  67. package/src/lib/framework/extension-api/input-component-extensions.tsx +69 -0
  68. package/src/lib/framework/extension-api/logic/alerts.ts +10 -0
  69. package/src/lib/framework/extension-api/logic/data-table.ts +60 -0
  70. package/src/lib/framework/extension-api/logic/detail-forms.ts +48 -0
  71. package/src/lib/framework/extension-api/logic/form-components.ts +13 -0
  72. package/src/lib/framework/extension-api/logic/index.ts +8 -0
  73. package/src/lib/framework/extension-api/logic/layout.ts +22 -0
  74. package/src/lib/framework/extension-api/logic/navigation.ts +37 -0
  75. package/src/lib/framework/extension-api/logic/widgets.ts +10 -0
  76. package/src/lib/framework/extension-api/types/alerts.ts +54 -0
  77. package/src/lib/framework/extension-api/types/data-table.ts +64 -0
  78. package/src/lib/framework/extension-api/types/detail-forms.ts +81 -0
  79. package/src/lib/framework/extension-api/types/form-components.ts +32 -0
  80. package/src/lib/framework/extension-api/types/index.ts +8 -0
  81. package/src/lib/framework/extension-api/types/layout.ts +78 -0
  82. package/src/lib/framework/extension-api/types/navigation.ts +19 -0
  83. package/src/lib/framework/extension-api/types/widgets.ts +94 -0
  84. package/src/lib/framework/page/detail-page.tsx +48 -3
  85. package/src/lib/framework/registry/registry-types.ts +3 -0
  86. package/src/app/routes/_authenticated/_collections/components/assign-collections-to-channel-dialog.tsx +0 -110
@@ -6,6 +6,11 @@ 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
8
  import { PlusIcon } from 'lucide-react';
9
+ import {
10
+ AssignStockLocationsToChannelBulkAction,
11
+ DeleteStockLocationsBulkAction,
12
+ RemoveStockLocationsFromChannelBulkAction,
13
+ } from './components/stock-location-bulk-actions.js';
9
14
  import { deleteStockLocationDocument, stockLocationListQuery } from './stock-locations.graphql.js';
10
15
 
11
16
  export const Route = createFileRoute('/_authenticated/_stock-locations/stock-locations')({
@@ -32,6 +37,20 @@ function StockLocationListPage() {
32
37
  name: { contains: searchTerm },
33
38
  };
34
39
  }}
40
+ bulkActions={[
41
+ {
42
+ component: AssignStockLocationsToChannelBulkAction,
43
+ order: 100,
44
+ },
45
+ {
46
+ component: RemoveStockLocationsFromChannelBulkAction,
47
+ order: 200,
48
+ },
49
+ {
50
+ component: DeleteStockLocationsBulkAction,
51
+ order: 500,
52
+ },
53
+ ]}
35
54
  >
36
55
  <PageActionBarRight>
37
56
  <PermissionGuard requires={['CreateStockLocation']}>
@@ -0,0 +1,15 @@
1
+ import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
2
+ import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
3
+ import { deleteTaxCategoriesDocument } from '../tax-categories.graphql.js';
4
+
5
+ export const DeleteTaxCategoriesBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
6
+ return (
7
+ <DeleteBulkAction
8
+ mutationDocument={deleteTaxCategoriesDocument}
9
+ entityName="tax categories"
10
+ requiredPermissions={['DeleteTaxCategory']}
11
+ selection={selection}
12
+ table={table}
13
+ />
14
+ );
15
+ };
@@ -61,3 +61,12 @@ export const deleteTaxCategoryDocument = graphql(`
61
61
  }
62
62
  }
63
63
  `);
64
+
65
+ export const deleteTaxCategoriesDocument = graphql(`
66
+ mutation DeleteTaxCategories($ids: [ID!]!) {
67
+ deleteTaxCategories(ids: $ids) {
68
+ result
69
+ message
70
+ }
71
+ }
72
+ `);
@@ -7,6 +7,7 @@ import { ListPage } from '@/framework/page/list-page.js';
7
7
  import { Trans } from '@/lib/trans.js';
8
8
  import { createFileRoute, Link } from '@tanstack/react-router';
9
9
  import { PlusIcon } from 'lucide-react';
10
+ import { DeleteTaxCategoriesBulkAction } from './components/tax-category-bulk-actions.js';
10
11
  import { deleteTaxCategoryDocument, taxCategoryListQuery } from './tax-categories.graphql.js';
11
12
 
12
13
  export const Route = createFileRoute('/_authenticated/_tax-categories/tax-categories')({
@@ -49,6 +50,12 @@ function TaxCategoryListPage() {
49
50
  ),
50
51
  },
51
52
  }}
53
+ bulkActions={[
54
+ {
55
+ component: DeleteTaxCategoriesBulkAction,
56
+ order: 500,
57
+ },
58
+ ]}
52
59
  >
53
60
  <PageActionBarRight>
54
61
  <PermissionGuard requires={['CreateTaxCategory']}>
@@ -0,0 +1,15 @@
1
+ import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
2
+ import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
3
+ import { deleteTaxRatesDocument } from '../tax-rates.graphql.js';
4
+
5
+ export const DeleteTaxRatesBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
6
+ return (
7
+ <DeleteBulkAction
8
+ mutationDocument={deleteTaxRatesDocument}
9
+ entityName="tax rates"
10
+ requiredPermissions={['DeleteTaxRate']}
11
+ selection={selection}
12
+ table={table}
13
+ />
14
+ );
15
+ };
@@ -73,3 +73,12 @@ export const deleteTaxRateDocument = graphql(`
73
73
  }
74
74
  }
75
75
  `);
76
+
77
+ export const deleteTaxRatesDocument = graphql(`
78
+ mutation DeleteTaxRates($ids: [ID!]!) {
79
+ deleteTaxRates(ids: $ids) {
80
+ result
81
+ message
82
+ }
83
+ }
84
+ `);
@@ -10,6 +10,7 @@ import { createFileRoute, Link } from '@tanstack/react-router';
10
10
  import { PlusIcon } from 'lucide-react';
11
11
  import { taxCategoryListQuery } from '../_tax-categories/tax-categories.graphql.js';
12
12
  import { zoneListQuery } from '../_zones/zones.graphql.js';
13
+ import { DeleteTaxRatesBulkAction } from './components/tax-rate-bulk-actions.js';
13
14
  import { deleteTaxRateDocument, taxRateListQuery } from './tax-rates.graphql.js';
14
15
 
15
16
  export const Route = createFileRoute('/_authenticated/_tax-rates/tax-rates')({
@@ -92,6 +93,12 @@ function TaxRateListPage() {
92
93
  cell: ({ row }) => `${row.original.value}%`,
93
94
  },
94
95
  }}
96
+ bulkActions={[
97
+ {
98
+ component: DeleteTaxRatesBulkAction,
99
+ order: 500,
100
+ },
101
+ ]}
95
102
  >
96
103
  <PageActionBarRight>
97
104
  <PermissionGuard requires={['CreateTaxRate']}>
@@ -0,0 +1,15 @@
1
+ import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
2
+ import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
3
+ import { deleteZonesDocument } from '../zones.graphql.js';
4
+
5
+ export const DeleteZonesBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
6
+ return (
7
+ <DeleteBulkAction
8
+ mutationDocument={deleteZonesDocument}
9
+ entityName="zones"
10
+ requiredPermissions={['DeleteZone']}
11
+ selection={selection}
12
+ table={table}
13
+ />
14
+ );
15
+ };
@@ -94,3 +94,12 @@ export const deleteZoneDocument = graphql(`
94
94
  }
95
95
  }
96
96
  `);
97
+
98
+ export const deleteZonesDocument = graphql(`
99
+ mutation DeleteZones($ids: [ID!]!) {
100
+ deleteZones(ids: $ids) {
101
+ result
102
+ message
103
+ }
104
+ }
105
+ `);
@@ -6,6 +6,7 @@ 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
8
  import { PlusIcon } from 'lucide-react';
9
+ import { DeleteZonesBulkAction } from './components/zone-bulk-actions.js';
9
10
  import { ZoneCountriesSheet } from './components/zone-countries-sheet.js';
10
11
  import { deleteZoneDocument, zoneListQuery } from './zones.graphql.js';
11
12
 
@@ -41,6 +42,12 @@ function ZoneListPage() {
41
42
  ),
42
43
  },
43
44
  }}
45
+ bulkActions={[
46
+ {
47
+ component: DeleteZonesBulkAction,
48
+ order: 500,
49
+ },
50
+ ]}
44
51
  >
45
52
  <PageActionBarRight>
46
53
  <PermissionGuard requires={['CreateZone']}>
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button.js';
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from '@/components/ui/dropdown-menu.js';
10
+ import { getBulkActions } from '@/framework/data-table/data-table-extensions.js';
11
+ import { usePageBlock } from '@/hooks/use-page-block.js';
12
+ import { usePage } from '@/hooks/use-page.js';
13
+ import { Trans } from '@/lib/trans.js';
14
+ import { ChevronDown } from 'lucide-react';
15
+ import { Asset } from './asset-gallery.js';
16
+
17
+ export type AssetBulkActionContext = {
18
+ selection: Asset[];
19
+ refetch: () => void;
20
+ };
21
+
22
+ export type AssetBulkActionComponent = React.FunctionComponent<AssetBulkActionContext>;
23
+
24
+ export type AssetBulkAction = {
25
+ order?: number;
26
+ component: AssetBulkActionComponent;
27
+ };
28
+
29
+ interface AssetBulkActionsProps {
30
+ selection: Asset[];
31
+ bulkActions?: AssetBulkAction[];
32
+ refetch: () => void;
33
+ }
34
+
35
+ export function AssetBulkActions({ selection, bulkActions, refetch }: AssetBulkActionsProps) {
36
+ const { pageId } = usePage();
37
+ const { blockId } = usePageBlock();
38
+
39
+ if (selection.length === 0) {
40
+ return null;
41
+ }
42
+
43
+ // Get extended bulk actions from the registry
44
+ const extendedBulkActions = pageId ? getBulkActions(pageId, blockId) : [];
45
+
46
+ // Convert DataTable bulk actions to Asset bulk actions
47
+ const convertedBulkActions: AssetBulkAction[] = extendedBulkActions.map(action => ({
48
+ order: action.order,
49
+ component: ({ selection }) => {
50
+ // Create a mock table context for compatibility
51
+ const mockTable = {
52
+ getState: () => ({ rowSelection: {} }),
53
+ getRow: () => null,
54
+ } as any;
55
+
56
+ const ActionComponent = action.component;
57
+ return <ActionComponent selection={selection} table={mockTable} />;
58
+ },
59
+ }));
60
+
61
+ const allBulkActions = [...convertedBulkActions, ...(bulkActions ?? [])];
62
+ allBulkActions.sort((a, b) => (a.order ?? 10_000) - (b.order ?? 10_000));
63
+
64
+ return (
65
+ <div className="flex items-center gap-2 px-2 py-1 mb-2 bg-muted/50 rounded-md border">
66
+ <span className="text-sm text-muted-foreground">
67
+ <Trans>{selection.length} selected</Trans>
68
+ </span>
69
+ <DropdownMenu>
70
+ <DropdownMenuTrigger asChild>
71
+ <Button variant="outline" size="sm" className="h-8">
72
+ <Trans>With selected...</Trans>
73
+ <ChevronDown className="ml-2 h-4 w-4" />
74
+ </Button>
75
+ </DropdownMenuTrigger>
76
+ <DropdownMenuContent align="start">
77
+ {allBulkActions.length > 0 ? (
78
+ allBulkActions.map((action, index) => (
79
+ <action.component key={`asset-bulk-action-${index}`} selection={selection} refetch={refetch} />
80
+ ))
81
+ ) : (
82
+ <DropdownMenuItem className="text-muted-foreground" disabled>
83
+ <Trans>No actions available</Trans>
84
+ </DropdownMenuItem>
85
+ )}
86
+ </DropdownMenuContent>
87
+ </DropdownMenu>
88
+ </div>
89
+ );
90
+ }
@@ -16,14 +16,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
16
16
  import { api } from '@/graphql/api.js';
17
17
  import { assetFragment, AssetFragment } from '@/graphql/fragments.js';
18
18
  import { graphql } from '@/graphql/graphql.js';
19
- import { formatFileSize } from '@/lib/utils.js';
20
19
  import { Trans } from '@/lib/trans.js';
20
+ import { formatFileSize } from '@/lib/utils.js';
21
21
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
22
+ import { useDebounce } from '@uidotdev/usehooks';
22
23
  import { Loader2, Search, Upload, X } from 'lucide-react';
23
24
  import { useCallback, useState } from 'react';
24
25
  import { useDropzone } from 'react-dropzone';
25
- import { useDebounce } from '@uidotdev/usehooks';
26
26
  import { DetailPageButton } from '../detail-page-button.js';
27
+ import { AssetBulkAction, AssetBulkActions } from './asset-bulk-actions.js';
27
28
 
28
29
  const getAssetListDocument = graphql(
29
30
  `
@@ -76,7 +77,7 @@ export interface AssetGalleryProps {
76
77
  /**
77
78
  * @description
78
79
  * Defines whether multiple assets can be selected.
79
- *
80
+ *
80
81
  * If set to 'auto', the asset selection will be toggled when the user clicks on an asset.
81
82
  * If set to 'manual', multiple selection will occur only if the user holds down the control/cmd key.
82
83
  */
@@ -87,6 +88,7 @@ export interface AssetGalleryProps {
87
88
  showHeader?: boolean;
88
89
  className?: string;
89
90
  onFilesDropped?: (files: File[]) => void;
91
+ bulkActions?: AssetBulkAction[];
90
92
  }
91
93
 
92
94
  export function AssetGallery({
@@ -99,6 +101,7 @@ export function AssetGallery({
99
101
  showHeader = true,
100
102
  className = '',
101
103
  onFilesDropped,
104
+ bulkActions,
102
105
  }: AssetGalleryProps) {
103
106
  // State
104
107
  const [page, setPage] = useState(1);
@@ -111,7 +114,7 @@ export function AssetGallery({
111
114
  const queryKey = ['AssetGallery', page, pageSize, debouncedSearch, assetType];
112
115
 
113
116
  // Query for assets
114
- const { data, isLoading } = useQuery({
117
+ const { data, isLoading, refetch } = useQuery({
115
118
  queryKey,
116
119
  queryFn: () => {
117
120
  const filter: Record<string, any> = {};
@@ -173,7 +176,6 @@ export function AssetGallery({
173
176
  return;
174
177
  }
175
178
 
176
-
177
179
  // Manual mode - check for modifier key
178
180
  const isModifierKeyPressed = event.metaKey || event.ctrlKey;
179
181
 
@@ -269,6 +271,9 @@ export function AssetGallery({
269
271
  </div>
270
272
  )}
271
273
 
274
+ {/* Bulk actions bar */}
275
+ <AssetBulkActions selection={selected} bulkActions={bulkActions} refetch={refetch} />
276
+
272
277
  <div
273
278
  {...getRootProps()}
274
279
  className={`
@@ -300,7 +305,7 @@ export function AssetGallery({
300
305
  ${isSelected(asset as Asset) ? 'ring-2 ring-primary' : ''}
301
306
  flex flex-col min-w-[120px]
302
307
  `}
303
- onClick={(e) => handleSelect(asset as Asset, e)}
308
+ onClick={e => handleSelect(asset as Asset, e)}
304
309
  >
305
310
  <div
306
311
  className="relative w-full bg-muted/30"
@@ -324,7 +329,7 @@ export function AssetGallery({
324
329
  <p className="text-xs line-clamp-2 min-h-[2.5rem]" title={asset.name}>
325
330
  {asset.name}
326
331
  </p>
327
- <div className='flex justify-between items-center'>
332
+ <div className="flex justify-between items-center">
328
333
  {asset.fileSize && (
329
334
  <p className="text-xs text-muted-foreground mt-1">
330
335
  {formatFileSize(asset.fileSize)}
@@ -0,0 +1,70 @@
1
+ import { LayersIcon } from 'lucide-react';
2
+ import { useState } from 'react';
3
+
4
+ import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
5
+ import { AssignToChannelDialog } from '@/components/shared/assign-to-channel-dialog.js';
6
+ import { useChannel, usePaginatedList } from '@/index.js';
7
+ import { Trans } from '@/lib/trans.js';
8
+
9
+ interface AssignToChannelBulkActionProps {
10
+ selection: any[];
11
+ table: any;
12
+ entityType: string;
13
+ mutationFn: (variables: any) => Promise<any>;
14
+ requiredPermissions: string[];
15
+ buildInput: (channelId: string, additionalData?: Record<string, any>) => Record<string, any>;
16
+ additionalFields?: React.ReactNode;
17
+ additionalData?: Record<string, any>;
18
+ /**
19
+ * Additional callback to run on success, after the standard refetch and reset
20
+ */
21
+ onSuccess?: () => void;
22
+ }
23
+
24
+ export function AssignToChannelBulkAction({
25
+ selection,
26
+ table,
27
+ entityType,
28
+ mutationFn,
29
+ requiredPermissions,
30
+ buildInput,
31
+ additionalFields,
32
+ additionalData = {},
33
+ onSuccess,
34
+ }: Readonly<AssignToChannelBulkActionProps>) {
35
+ const { refetchPaginatedList } = usePaginatedList();
36
+ const { channels } = useChannel();
37
+ const [dialogOpen, setDialogOpen] = useState(false);
38
+
39
+ if (channels.length < 2) {
40
+ return null;
41
+ }
42
+
43
+ const handleSuccess = () => {
44
+ refetchPaginatedList();
45
+ table.resetRowSelection();
46
+ onSuccess?.();
47
+ };
48
+
49
+ return (
50
+ <>
51
+ <DataTableBulkActionItem
52
+ requiresPermission={requiredPermissions}
53
+ onClick={() => setDialogOpen(true)}
54
+ label={<Trans>Assign to channel</Trans>}
55
+ icon={LayersIcon}
56
+ />
57
+ <AssignToChannelDialog
58
+ open={dialogOpen}
59
+ onOpenChange={setDialogOpen}
60
+ entityIds={selection.map(s => s.id)}
61
+ entityType={entityType}
62
+ mutationFn={mutationFn}
63
+ onSuccess={handleSuccess}
64
+ buildInput={buildInput}
65
+ additionalFields={additionalFields}
66
+ additionalData={additionalData}
67
+ />
68
+ </>
69
+ );
70
+ }
@@ -1,5 +1,5 @@
1
1
  import { useMutation } from '@tanstack/react-query';
2
- import { useState } from 'react';
2
+ import { ReactNode, useState } from 'react';
3
3
  import { toast } from 'sonner';
4
4
 
5
5
  import { ChannelCodeLabel } from '@/components/shared/channel-code-label.js';
@@ -23,9 +23,24 @@ interface AssignToChannelDialogProps {
23
23
  open: boolean;
24
24
  onOpenChange: (open: boolean) => void;
25
25
  entityIds: string[];
26
- entityType: 'products' | 'variants';
26
+ entityType: string;
27
27
  mutationFn: (variables: any) => Promise<ResultOf<any>>;
28
28
  onSuccess?: () => void;
29
+ /**
30
+ * Function to build the input object for the mutation
31
+ * @param channelId - The selected channel ID
32
+ * @param additionalData - Any additional data (like priceFactor for products)
33
+ * @returns The input object for the mutation
34
+ */
35
+ buildInput: (channelId: string, additionalData?: Record<string, any>) => Record<string, any>;
36
+ /**
37
+ * Optional additional form fields to render
38
+ */
39
+ additionalFields?: ReactNode;
40
+ /**
41
+ * Optional additional data to pass to buildInput
42
+ */
43
+ additionalData?: Record<string, any>;
29
44
  }
30
45
 
31
46
  export function AssignToChannelDialog({
@@ -35,10 +50,12 @@ export function AssignToChannelDialog({
35
50
  entityType,
36
51
  mutationFn,
37
52
  onSuccess,
38
- }: AssignToChannelDialogProps) {
53
+ buildInput,
54
+ additionalFields,
55
+ additionalData = {},
56
+ }: Readonly<AssignToChannelDialogProps>) {
39
57
  const { i18n } = useLingui();
40
58
  const [selectedChannelId, setSelectedChannelId] = useState<string>('');
41
- const [priceFactor, setPriceFactor] = useState<number>(1);
42
59
  const { channels, selectedChannel } = useChannel();
43
60
 
44
61
  // Filter out the currently selected channel from available options
@@ -62,19 +79,7 @@ export function AssignToChannelDialog({
62
79
  return;
63
80
  }
64
81
 
65
- const input =
66
- entityType === 'products'
67
- ? {
68
- productIds: entityIds,
69
- channelId: selectedChannelId,
70
- priceFactor,
71
- }
72
- : {
73
- productVariantIds: entityIds,
74
- channelId: selectedChannelId,
75
- priceFactor,
76
- };
77
-
82
+ const input = buildInput(selectedChannelId, additionalData);
78
83
  mutate({ input });
79
84
  };
80
85
 
@@ -109,19 +114,7 @@ export function AssignToChannelDialog({
109
114
  </SelectContent>
110
115
  </Select>
111
116
  </div>
112
- <div className="grid gap-2">
113
- <label className="text-sm font-medium">
114
- <Trans>Price conversion factor</Trans>
115
- </label>
116
- <Input
117
- type="number"
118
- min="0"
119
- max="99999"
120
- step="0.01"
121
- value={priceFactor}
122
- onChange={e => setPriceFactor(parseFloat(e.target.value) || 1)}
123
- />
124
- </div>
117
+ {additionalFields}
125
118
  </div>
126
119
  <DialogFooter>
127
120
  <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -135,3 +128,28 @@ export function AssignToChannelDialog({
135
128
  </Dialog>
136
129
  );
137
130
  }
131
+
132
+ /**
133
+ * Hook for managing price factor state in assign-to-channel dialogs
134
+ */
135
+ export function usePriceFactor() {
136
+ const [priceFactor, setPriceFactor] = useState<number>(1);
137
+
138
+ const priceFactorField = (
139
+ <div className="grid gap-2">
140
+ <label className="text-sm font-medium">
141
+ <Trans>Price conversion factor</Trans>
142
+ </label>
143
+ <Input
144
+ type="number"
145
+ min="0"
146
+ max="99999"
147
+ step="0.01"
148
+ value={priceFactor}
149
+ onChange={e => setPriceFactor(parseFloat(e.target.value) || 1)}
150
+ />
151
+ </div>
152
+ );
153
+
154
+ return { priceFactor, priceFactorField };
155
+ }
@@ -0,0 +1,89 @@
1
+ import { useMutation } from '@tanstack/react-query';
2
+ import { LayersIcon } from 'lucide-react';
3
+ import { toast } from 'sonner';
4
+
5
+ import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
6
+ import { ResultOf } from '@/graphql/graphql.js';
7
+ import { useChannel, usePaginatedList } from '@/index.js';
8
+ import { Trans, useLingui } from '@/lib/trans.js';
9
+
10
+ interface RemoveFromChannelBulkActionProps {
11
+ selection: any[];
12
+ table: any;
13
+ entityType: string;
14
+ mutationFn: (variables: any) => Promise<ResultOf<any>>;
15
+ requiredPermissions: string[];
16
+ buildInput: () => Record<string, any>;
17
+ /**
18
+ * Additional callback to run on success, after the standard refetch and reset
19
+ * @param result - The result from the mutation
20
+ */
21
+ onSuccess?: (result?: ResultOf<any>) => void;
22
+ /**
23
+ * Custom success message. If not provided, a default message will be used.
24
+ */
25
+ successMessage?: string;
26
+ /**
27
+ * Custom error message. If not provided, a default message will be used.
28
+ */
29
+ errorMessage?: string;
30
+ }
31
+
32
+ export function RemoveFromChannelBulkAction({
33
+ selection,
34
+ table,
35
+ entityType,
36
+ mutationFn,
37
+ requiredPermissions,
38
+ buildInput,
39
+ onSuccess,
40
+ successMessage,
41
+ errorMessage,
42
+ }: Readonly<RemoveFromChannelBulkActionProps>) {
43
+ const { refetchPaginatedList } = usePaginatedList();
44
+ const { selectedChannel } = useChannel();
45
+ const { i18n } = useLingui();
46
+ const { mutate } = useMutation({
47
+ mutationFn,
48
+ onSuccess: result => {
49
+ const message =
50
+ successMessage ||
51
+ i18n.t(`Successfully removed ${selection.length} ${entityType} from channel`);
52
+ toast.success(message);
53
+ refetchPaginatedList();
54
+ table.resetRowSelection();
55
+ onSuccess?.(result);
56
+ },
57
+ onError: error => {
58
+ const message =
59
+ errorMessage ||
60
+ `Failed to remove ${selection.length} ${entityType} from channel: ${error.message}`;
61
+ toast.error(message);
62
+ },
63
+ });
64
+
65
+ if (!selectedChannel) {
66
+ return null;
67
+ }
68
+
69
+ const handleRemove = () => {
70
+ mutate({
71
+ input: buildInput(),
72
+ });
73
+ };
74
+
75
+ return (
76
+ <DataTableBulkActionItem
77
+ requiresPermission={requiredPermissions}
78
+ onClick={handleRemove}
79
+ label={<Trans>Remove from current channel</Trans>}
80
+ confirmationText={
81
+ <Trans>
82
+ Are you sure you want to remove {selection.length} {entityType} from the current channel?
83
+ </Trans>
84
+ }
85
+ icon={LayersIcon}
86
+ className="text-warning"
87
+ />
88
+ );
89
+ }