@vendure/dashboard 3.3.6-master-202506290242 → 3.3.6-master-202507010731

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 (54) hide show
  1. package/package.json +4 -4
  2. package/src/app/routes/_authenticated/_administrators/administrators_.$id.tsx +5 -1
  3. package/src/app/routes/_authenticated/_assets/assets_.$id.tsx +7 -2
  4. package/src/app/routes/_authenticated/_channels/channels_.$id.tsx +5 -1
  5. package/src/app/routes/_authenticated/_collections/collections.graphql.ts +16 -0
  6. package/src/app/routes/_authenticated/_collections/collections.tsx +16 -2
  7. package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +5 -1
  8. package/src/app/routes/_authenticated/_collections/components/assign-collections-to-channel-dialog.tsx +110 -0
  9. package/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx +99 -0
  10. package/src/app/routes/_authenticated/_countries/countries.graphql.ts +1 -1
  11. package/src/app/routes/_authenticated/_countries/countries_.$id.tsx +9 -5
  12. package/src/app/routes/_authenticated/_customer-groups/customer-groups.graphql.ts +1 -1
  13. package/src/app/routes/_authenticated/_customer-groups/customer-groups_.$id.tsx +8 -5
  14. package/src/app/routes/_authenticated/_customers/customers_.$id.tsx +5 -1
  15. package/src/app/routes/_authenticated/_facets/facets_.$id.tsx +5 -1
  16. package/src/app/routes/_authenticated/_orders/orders_.$id.tsx +5 -2
  17. package/src/app/routes/_authenticated/_payment-methods/payment-methods_.$id.tsx +5 -1
  18. package/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx +184 -0
  19. package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +62 -1
  20. package/src/app/routes/_authenticated/_product-variants/product-variants.tsx +33 -3
  21. package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +14 -3
  22. package/src/app/routes/_authenticated/_products/components/assign-facet-values-dialog.tsx +67 -36
  23. package/src/app/routes/_authenticated/_products/components/assign-to-channel-dialog.tsx +28 -17
  24. package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +12 -2
  25. package/src/app/routes/_authenticated/_products/components/product-variants-table.tsx +74 -55
  26. package/src/app/routes/_authenticated/_products/products_.$id.tsx +6 -1
  27. package/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx +5 -1
  28. package/src/app/routes/_authenticated/_roles/roles_.$id.tsx +5 -1
  29. package/src/app/routes/_authenticated/_sellers/sellers_.$id.tsx +6 -2
  30. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx +5 -1
  31. package/src/app/routes/_authenticated/_stock-locations/stock-locations_.$id.tsx +5 -1
  32. package/src/app/routes/_authenticated/_tax-categories/tax-categories.graphql.ts +1 -1
  33. package/src/app/routes/_authenticated/_tax-categories/tax-categories_.$id.tsx +9 -5
  34. package/src/app/routes/_authenticated/_tax-rates/tax-rates.graphql.ts +1 -1
  35. package/src/app/routes/_authenticated/_tax-rates/tax-rates_.$id.tsx +8 -4
  36. package/src/app/routes/_authenticated/_zones/zones.graphql.ts +1 -1
  37. package/src/app/routes/_authenticated/_zones/zones_.$id.tsx +8 -4
  38. package/src/lib/components/shared/detail-page-button.tsx +3 -1
  39. package/src/lib/components/shared/paginated-list-data-table.tsx +6 -4
  40. package/src/lib/framework/data-table/data-table-extensions.ts +14 -0
  41. package/src/lib/framework/document-extension/extend-detail-form-query.ts +50 -0
  42. package/src/lib/framework/document-extension/extend-document.spec.ts +884 -0
  43. package/src/lib/framework/document-extension/extend-document.ts +159 -0
  44. package/src/lib/framework/document-introspection/add-custom-fields.ts +48 -0
  45. package/src/lib/framework/extension-api/define-dashboard-extension.ts +33 -2
  46. package/src/lib/framework/extension-api/extension-api-types.ts +21 -2
  47. package/src/lib/framework/form-engine/custom-form-component-extensions.ts +13 -3
  48. package/src/lib/framework/layout-engine/page-layout.tsx +1 -0
  49. package/src/lib/framework/page/detail-page-route-loader.tsx +22 -4
  50. package/src/lib/framework/page/use-detail-page.ts +11 -2
  51. package/src/lib/framework/registry/registry-types.ts +3 -0
  52. package/src/lib/graphql/graphql-env.d.ts +8 -6
  53. package/src/lib/hooks/use-extended-detail-query.ts +37 -0
  54. package/src/lib/hooks/use-extended-list-query.ts +73 -0
@@ -0,0 +1,184 @@
1
+ import { useMutation } from '@tanstack/react-query';
2
+ import { 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 { AssignFacetValuesDialog } from '../../_products/components/assign-facet-values-dialog.js';
15
+ import { AssignToChannelDialog } from '../../_products/components/assign-to-channel-dialog.js';
16
+ import {
17
+ assignProductVariantsToChannelDocument,
18
+ deleteProductVariantsDocument,
19
+ getProductVariantsWithFacetValuesByIdsDocument,
20
+ productVariantDetailDocument,
21
+ removeProductVariantsFromChannelDocument,
22
+ updateProductVariantsDocument,
23
+ } from '../product-variants.graphql.js';
24
+
25
+ export const DeleteProductVariantsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
26
+ const { refetchPaginatedList } = usePaginatedList();
27
+ const { i18n } = useLingui();
28
+ const { mutate } = useMutation({
29
+ mutationFn: api.mutate(deleteProductVariantsDocument),
30
+ onSuccess: (result: ResultOf<typeof deleteProductVariantsDocument>) => {
31
+ let deleted = 0;
32
+ const errors: string[] = [];
33
+ for (const item of result.deleteProductVariants) {
34
+ if (item.result === 'DELETED') {
35
+ deleted++;
36
+ } else if (item.message) {
37
+ errors.push(item.message);
38
+ }
39
+ }
40
+ if (0 < deleted) {
41
+ toast.success(i18n.t(`Deleted ${deleted} product variants`));
42
+ }
43
+ if (0 < errors.length) {
44
+ toast.error(i18n.t(`Failed to delete ${errors.length} product variants`));
45
+ }
46
+ refetchPaginatedList();
47
+ table.resetRowSelection();
48
+ },
49
+ onError: () => {
50
+ toast.error(`Failed to delete ${selection.length} product variants`);
51
+ },
52
+ });
53
+ return (
54
+ <DataTableBulkActionItem
55
+ requiresPermission={[Permission.DeleteCatalog, Permission.DeleteProduct]}
56
+ onClick={() => mutate({ ids: selection.map(s => s.id) })}
57
+ label={<Trans>Delete</Trans>}
58
+ confirmationText={
59
+ <Trans>Are you sure you want to delete {selection.length} product variants?</Trans>
60
+ }
61
+ icon={TrashIcon}
62
+ className="text-destructive"
63
+ />
64
+ );
65
+ };
66
+
67
+ export const AssignProductVariantsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
68
+ const { refetchPaginatedList } = usePaginatedList();
69
+ const { channels } = useChannel();
70
+ const [dialogOpen, setDialogOpen] = useState(false);
71
+
72
+ if (channels.length < 2) {
73
+ return null;
74
+ }
75
+
76
+ const handleSuccess = () => {
77
+ refetchPaginatedList();
78
+ table.resetRowSelection();
79
+ };
80
+
81
+ return (
82
+ <>
83
+ <DataTableBulkActionItem
84
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
85
+ onClick={() => setDialogOpen(true)}
86
+ label={<Trans>Assign to channel</Trans>}
87
+ icon={LayersIcon}
88
+ />
89
+ <AssignToChannelDialog
90
+ open={dialogOpen}
91
+ onOpenChange={setDialogOpen}
92
+ entityIds={selection.map(s => s.id)}
93
+ entityType="variants"
94
+ mutationFn={api.mutate(assignProductVariantsToChannelDocument)}
95
+ onSuccess={handleSuccess}
96
+ />
97
+ </>
98
+ );
99
+ };
100
+
101
+ export const RemoveProductVariantsFromChannelBulkAction: BulkActionComponent<any> = ({
102
+ selection,
103
+ table,
104
+ }) => {
105
+ const { refetchPaginatedList } = usePaginatedList();
106
+ const { selectedChannel } = useChannel();
107
+ const { i18n } = useLingui();
108
+ const { mutate } = useMutation({
109
+ mutationFn: api.mutate(removeProductVariantsFromChannelDocument),
110
+ onSuccess: () => {
111
+ toast.success(i18n.t(`Successfully removed ${selection.length} product variants from channel`));
112
+ refetchPaginatedList();
113
+ table.resetRowSelection();
114
+ },
115
+ onError: error => {
116
+ toast.error(
117
+ `Failed to remove ${selection.length} product variants from channel: ${error.message}`,
118
+ );
119
+ },
120
+ });
121
+
122
+ if (!selectedChannel) {
123
+ return null;
124
+ }
125
+
126
+ const handleRemove = () => {
127
+ mutate({
128
+ input: {
129
+ productVariantIds: selection.map(s => s.id),
130
+ channelId: selectedChannel.id,
131
+ },
132
+ });
133
+ };
134
+
135
+ return (
136
+ <DataTableBulkActionItem
137
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
138
+ onClick={handleRemove}
139
+ label={<Trans>Remove from current channel</Trans>}
140
+ confirmationText={
141
+ <Trans>
142
+ Are you sure you want to remove {selection.length} product variants from the current
143
+ channel?
144
+ </Trans>
145
+ }
146
+ icon={LayersIcon}
147
+ className="text-warning"
148
+ />
149
+ );
150
+ };
151
+
152
+ export const AssignFacetValuesToProductVariantsBulkAction: BulkActionComponent<any> = ({
153
+ selection,
154
+ table,
155
+ }) => {
156
+ const { refetchPaginatedList } = usePaginatedList();
157
+ const [dialogOpen, setDialogOpen] = useState(false);
158
+
159
+ const handleSuccess = () => {
160
+ refetchPaginatedList();
161
+ table.resetRowSelection();
162
+ };
163
+
164
+ return (
165
+ <>
166
+ <DataTableBulkActionItem
167
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
168
+ onClick={() => setDialogOpen(true)}
169
+ label={<Trans>Edit facet values</Trans>}
170
+ icon={TagIcon}
171
+ />
172
+ <AssignFacetValuesDialog
173
+ open={dialogOpen}
174
+ onOpenChange={setDialogOpen}
175
+ entityIds={selection.map(s => s.id)}
176
+ entityType="variants"
177
+ queryFn={variables => api.query(getProductVariantsWithFacetValuesByIdsDocument, variables)}
178
+ mutationFn={api.mutate(updateProductVariantsDocument)}
179
+ detailDocument={productVariantDetailDocument}
180
+ onSuccess={handleSuccess}
181
+ />
182
+ </>
183
+ );
184
+ };
@@ -3,7 +3,7 @@ import { graphql } from '@/graphql/graphql.js';
3
3
 
4
4
  export const productVariantListDocument = graphql(
5
5
  `
6
- query ProductVariantLis($options: ProductVariantListOptions) {
6
+ query ProductVariantList($options: ProductVariantListOptions) {
7
7
  productVariants(options: $options) {
8
8
  items {
9
9
  id
@@ -121,3 +121,64 @@ export const deleteProductVariantDocument = graphql(`
121
121
  }
122
122
  }
123
123
  `);
124
+
125
+ export const deleteProductVariantsDocument = graphql(`
126
+ mutation DeleteProductVariants($ids: [ID!]!) {
127
+ deleteProductVariants(ids: $ids) {
128
+ result
129
+ message
130
+ }
131
+ }
132
+ `);
133
+
134
+ export const assignProductVariantsToChannelDocument = graphql(`
135
+ mutation AssignProductVariantsToChannel($input: AssignProductVariantsToChannelInput!) {
136
+ assignProductVariantsToChannel(input: $input) {
137
+ id
138
+ }
139
+ }
140
+ `);
141
+
142
+ export const removeProductVariantsFromChannelDocument = graphql(`
143
+ mutation RemoveProductVariantsFromChannel($input: RemoveProductVariantsFromChannelInput!) {
144
+ removeProductVariantsFromChannel(input: $input) {
145
+ id
146
+ }
147
+ }
148
+ `);
149
+
150
+ export const getProductVariantsWithFacetValuesByIdsDocument = graphql(`
151
+ query GetProductVariantsWithFacetValuesByIds($ids: [String!]!) {
152
+ productVariants(options: { filter: { id: { in: $ids } } }) {
153
+ items {
154
+ id
155
+ name
156
+ sku
157
+ facetValues {
158
+ id
159
+ name
160
+ code
161
+ facet {
162
+ id
163
+ name
164
+ code
165
+ }
166
+ }
167
+ }
168
+ }
169
+ }
170
+ `);
171
+
172
+ export const updateProductVariantsDocument = graphql(`
173
+ mutation UpdateProductVariants($input: [UpdateProductVariantInput!]!) {
174
+ updateProductVariants(input: $input) {
175
+ id
176
+ name
177
+ facetValues {
178
+ id
179
+ name
180
+ code
181
+ }
182
+ }
183
+ }
184
+ `);
@@ -5,6 +5,12 @@ import { ListPage } from '@/framework/page/list-page.js';
5
5
  import { useLocalFormat } from '@/hooks/use-local-format.js';
6
6
  import { Trans } from '@/lib/trans.js';
7
7
  import { createFileRoute } from '@tanstack/react-router';
8
+ import {
9
+ AssignFacetValuesToProductVariantsBulkAction,
10
+ AssignProductVariantsToChannelBulkAction,
11
+ DeleteProductVariantsBulkAction,
12
+ RemoveProductVariantsFromChannelBulkAction,
13
+ } from './components/product-variant-bulk-actions.js';
8
14
  import { deleteProductVariantDocument, productVariantListDocument } from './product-variants.graphql.js';
9
15
 
10
16
  export const Route = createFileRoute('/_authenticated/_product-variants/product-variants')({
@@ -20,19 +26,43 @@ function ProductListPage() {
20
26
  title={<Trans>Product Variants</Trans>}
21
27
  listQuery={productVariantListDocument}
22
28
  deleteMutation={deleteProductVariantDocument}
29
+ bulkActions={[
30
+ {
31
+ component: AssignProductVariantsToChannelBulkAction,
32
+ order: 100,
33
+ },
34
+ {
35
+ component: RemoveProductVariantsFromChannelBulkAction,
36
+ order: 200,
37
+ },
38
+ {
39
+ component: AssignFacetValuesToProductVariantsBulkAction,
40
+ order: 300,
41
+ },
42
+ {
43
+ component: DeleteProductVariantsBulkAction,
44
+ order: 400,
45
+ },
46
+ ]}
23
47
  customizeColumns={{
24
48
  name: {
25
49
  header: 'Product Name',
26
- cell: ({ row: { original } }) => <DetailPageButton id={original.id} label={original.name} />,
50
+ cell: ({ row: { original } }) => (
51
+ <DetailPageButton id={original.id} label={original.name} />
52
+ ),
27
53
  },
28
54
  currencyCode: {
29
55
  cell: ({ row: { original } }) => formatCurrencyName(original.currencyCode, 'full'),
30
56
  },
31
57
  price: {
32
- cell: ({ row: { original } }) => <Money value={original.price} currency={original.currencyCode} />,
58
+ cell: ({ row: { original } }) => (
59
+ <Money value={original.price} currency={original.currencyCode} />
60
+ ),
33
61
  },
34
62
  priceWithTax: {
35
- cell: ({ row: { original } }) => <Money value={original.priceWithTax} currency={original.currencyCode} />,
63
+ cell: ({ row: { original } }) => (
64
+ <Money value={original.priceWithTax} currency={original.currencyCode} />
65
+ ),
36
66
  },
37
67
  stockLevels: {
38
68
  cell: ({ row: { original } }) => <StockLevelLabel stockLevels={original.stockLevels} />,
@@ -36,12 +36,22 @@ import {
36
36
  updateProductVariantDocument,
37
37
  } from './product-variants.graphql.js';
38
38
 
39
+ const pageId = 'product-variant-detail';
40
+
39
41
  export const Route = createFileRoute('/_authenticated/_product-variants/product-variants_/$id')({
40
42
  component: ProductVariantDetailPage,
41
43
  loader: detailPageRouteLoader({
44
+ pageId,
42
45
  queryDocument: productVariantDetailDocument,
43
- breadcrumb(_isNew, entity) {
44
- return [{ path: '/product-variants', label: 'Product variants' }, entity?.name];
46
+ breadcrumb(_isNew, entity, location) {
47
+ if ((location.search as any).from === 'product') {
48
+ return [
49
+ { path: '/product', label: 'Products' },
50
+ { path: `/products/${entity?.product.id}`, label: entity?.product.name ?? '' },
51
+ entity?.name,
52
+ ];
53
+ }
54
+ return [{ path: '/product-variants', label: 'Product Variants' }, entity?.name];
45
55
  },
46
56
  }),
47
57
  errorComponent: ({ error }) => <ErrorPage message={error.message} />,
@@ -55,6 +65,7 @@ function ProductVariantDetailPage() {
55
65
  const { activeChannel } = useChannel();
56
66
 
57
67
  const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
68
+ pageId,
58
69
  queryDocument: productVariantDetailDocument,
59
70
  createDocument: createProductVariantDocument,
60
71
  updateDocument: updateProductVariantDocument,
@@ -102,7 +113,7 @@ function ProductVariantDetailPage() {
102
113
  const [price, taxCategoryId] = form.watch(['price', 'taxCategoryId']);
103
114
 
104
115
  return (
105
- <Page pageId="product-variant-detail" form={form} submitHandler={submitHandler} entity={entity}>
116
+ <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
106
117
  <PageTitle>
107
118
  {creatingNewEntity ? <Trans>New product variant</Trans> : (entity?.name ?? '')}
108
119
  </PageTitle>
@@ -13,20 +13,15 @@ import {
13
13
  DialogHeader,
14
14
  DialogTitle,
15
15
  } from '@/components/ui/dialog.js';
16
- import { api } from '@/graphql/api.js';
17
16
  import { ResultOf } from '@/graphql/graphql.js';
18
17
  import { Trans, useLingui } from '@/lib/trans.js';
19
18
 
20
19
  import { getDetailQueryOptions } from '@/framework/page/use-detail-page.js';
21
- import {
22
- getProductsWithFacetValuesByIdsDocument,
23
- productDetailDocument,
24
- updateProductsDocument,
25
- } from '../products.graphql.js';
26
20
 
27
- interface ProductWithFacetValues {
21
+ interface EntityWithFacetValues {
28
22
  id: string;
29
23
  name: string;
24
+ sku?: string;
30
25
  facetValues: Array<{
31
26
  id: string;
32
27
  name: string;
@@ -42,14 +37,22 @@ interface ProductWithFacetValues {
42
37
  interface AssignFacetValuesDialogProps {
43
38
  open: boolean;
44
39
  onOpenChange: (open: boolean) => void;
45
- productIds: string[];
40
+ entityIds: string[];
41
+ entityType: 'products' | 'variants';
42
+ queryFn: (variables: any) => Promise<ResultOf<any>>;
43
+ mutationFn: (variables: any) => Promise<ResultOf<any>>;
44
+ detailDocument: any;
46
45
  onSuccess?: () => void;
47
46
  }
48
47
 
49
48
  export function AssignFacetValuesDialog({
50
49
  open,
51
50
  onOpenChange,
52
- productIds,
51
+ entityIds,
52
+ entityType,
53
+ queryFn,
54
+ mutationFn,
55
+ detailDocument,
53
56
  onSuccess,
54
57
  }: AssignFacetValuesDialogProps) {
55
58
  const { i18n } = useLingui();
@@ -58,30 +61,30 @@ export function AssignFacetValuesDialog({
58
61
  const [removedFacetValues, setRemovedFacetValues] = useState<Set<string>>(new Set());
59
62
  const queryClient = useQueryClient();
60
63
 
61
- // Fetch existing facet values for the products
62
- const { data: productsData, isLoading } = useQuery({
63
- queryKey: ['productsWithFacetValues', productIds],
64
- queryFn: () => api.query(getProductsWithFacetValuesByIdsDocument, { ids: productIds }),
65
- enabled: open && productIds.length > 0,
64
+ // Fetch existing facet values for the entities
65
+ const { data: entitiesData, isLoading } = useQuery({
66
+ queryKey: [`${entityType}WithFacetValues`, entityIds],
67
+ queryFn: () => queryFn({ ids: entityIds }),
68
+ enabled: open && entityIds.length > 0,
66
69
  });
67
70
 
68
71
  const { mutate, isPending } = useMutation({
69
- mutationFn: api.mutate(updateProductsDocument),
70
- onSuccess: (result: ResultOf<typeof updateProductsDocument>) => {
71
- toast.success(i18n.t(`Successfully updated facet values for ${productIds.length} products`));
72
+ mutationFn,
73
+ onSuccess: () => {
74
+ toast.success(i18n.t(`Successfully updated facet values for ${entityIds.length} ${entityType}`));
72
75
  onSuccess?.();
73
76
  onOpenChange(false);
74
77
  // Reset state
75
78
  setSelectedValues([]);
76
79
  setFacetValuesRemoved(false);
77
80
  setRemovedFacetValues(new Set());
78
- productIds.forEach(id => {
79
- const { queryKey } = getDetailQueryOptions(productDetailDocument, { id });
81
+ entityIds.forEach(id => {
82
+ const { queryKey } = getDetailQueryOptions(detailDocument, { id });
80
83
  queryClient.removeQueries({ queryKey });
81
84
  });
82
85
  },
83
86
  onError: () => {
84
- toast.error(`Failed to update facet values for ${productIds.length} products`);
87
+ toast.error(`Failed to update facet values for ${entityIds.length} ${entityType}`);
85
88
  },
86
89
  });
87
90
 
@@ -91,18 +94,25 @@ export function AssignFacetValuesDialog({
91
94
  return;
92
95
  }
93
96
 
94
- if (!productsData?.products.items) {
97
+ const items =
98
+ entityType === 'products'
99
+ ? (entitiesData as any)?.products?.items
100
+ : (entitiesData as any)?.productVariants?.items;
101
+
102
+ if (!items) {
95
103
  return;
96
104
  }
97
105
 
98
106
  const selectedFacetValueIds = selectedValues.map(sv => sv.id);
99
107
 
100
108
  mutate({
101
- input: productsData.products.items.map(product => ({
102
- id: product.id,
109
+ input: items.map((entity: EntityWithFacetValues) => ({
110
+ id: entity.id,
103
111
  facetValueIds: [
104
112
  ...new Set([
105
- ...product.facetValues.filter(fv => !removedFacetValues.has(fv.id)).map(fv => fv.id),
113
+ ...entity.facetValues
114
+ .filter((fv: any) => !removedFacetValues.has(fv.id))
115
+ .map((fv: any) => fv.id),
106
116
  ...selectedFacetValueIds,
107
117
  ]),
108
118
  ],
@@ -114,7 +124,7 @@ export function AssignFacetValuesDialog({
114
124
  setSelectedValues(prev => [...prev, facetValue]);
115
125
  };
116
126
 
117
- const removeFacetValue = (productId: string, facetValueId: string) => {
127
+ const removeFacetValue = (entityId: string, facetValueId: string) => {
118
128
  setRemovedFacetValues(prev => new Set([...prev, facetValueId]));
119
129
  setFacetValuesRemoved(true);
120
130
  };
@@ -128,10 +138,15 @@ export function AssignFacetValuesDialog({
128
138
  };
129
139
 
130
140
  // Filter out removed facet values for display
131
- const getDisplayFacetValues = (product: ProductWithFacetValues) => {
132
- return product.facetValues.filter(fv => !removedFacetValues.has(fv.id));
141
+ const getDisplayFacetValues = (entity: EntityWithFacetValues) => {
142
+ return entity.facetValues.filter(fv => !removedFacetValues.has(fv.id));
133
143
  };
134
144
 
145
+ const items =
146
+ entityType === 'products'
147
+ ? (entitiesData as any)?.products?.items
148
+ : (entitiesData as any)?.productVariants?.items;
149
+
135
150
  return (
136
151
  <Dialog open={open} onOpenChange={onOpenChange}>
137
152
  <DialogContent className="sm:max-w-[800px] max-h-[80vh] overflow-hidden flex flex-col">
@@ -140,7 +155,9 @@ export function AssignFacetValuesDialog({
140
155
  <Trans>Edit facet values</Trans>
141
156
  </DialogTitle>
142
157
  <DialogDescription>
143
- <Trans>Add or remove facet values for {productIds.length} products</Trans>
158
+ <Trans>
159
+ Add or remove facet values for {entityIds.length} {entityType}
160
+ </Trans>
144
161
  </DialogDescription>
145
162
  </DialogHeader>
146
163
 
@@ -156,33 +173,47 @@ export function AssignFacetValuesDialog({
156
173
  />
157
174
  </div>
158
175
 
159
- {/* Products table */}
176
+ {/* Entities table */}
160
177
  <div className="flex-1 overflow-auto">
161
178
  {isLoading ? (
162
179
  <div className="flex items-center justify-center py-8">
163
180
  <div className="text-sm text-muted-foreground">Loading...</div>
164
181
  </div>
165
- ) : productsData?.products.items ? (
182
+ ) : items ? (
166
183
  <div className="border rounded-md">
167
184
  <table className="w-full">
168
185
  <thead className="bg-muted/50">
169
186
  <tr>
170
187
  <th className="text-left p-3 text-sm font-medium">
171
- <Trans>Product</Trans>
188
+ <Trans>
189
+ {entityType === 'products' ? 'Product' : 'Variant'}
190
+ </Trans>
172
191
  </th>
192
+ {entityType === 'variants' && (
193
+ <th className="text-left p-3 text-sm font-medium">
194
+ <Trans>SKU</Trans>
195
+ </th>
196
+ )}
173
197
  <th className="text-left p-3 text-sm font-medium">
174
198
  <Trans>Current facet values</Trans>
175
199
  </th>
176
200
  </tr>
177
201
  </thead>
178
202
  <tbody>
179
- {productsData.products.items.map(product => {
180
- const displayFacetValues = getDisplayFacetValues(product);
203
+ {items.map((entity: EntityWithFacetValues) => {
204
+ const displayFacetValues = getDisplayFacetValues(entity);
181
205
  return (
182
- <tr key={product.id} className="border-t">
206
+ <tr key={entity.id} className="border-t">
183
207
  <td className="p-3 align-top">
184
- <div className="font-medium">{product.name}</div>
208
+ <div className="font-medium">{entity.name}</div>
185
209
  </td>
210
+ {entityType === 'variants' && (
211
+ <td className="p-3 align-top">
212
+ <div className="text-sm text-muted-foreground">
213
+ {entity.sku}
214
+ </div>
215
+ </td>
216
+ )}
186
217
  <td className="p-3">
187
218
  <div className="flex flex-wrap gap-2">
188
219
  {displayFacetValues.map(facetValue => (
@@ -192,7 +223,7 @@ export function AssignFacetValuesDialog({
192
223
  removable={true}
193
224
  onRemove={() =>
194
225
  removeFacetValue(
195
- product.id,
226
+ entity.id,
196
227
  facetValue.id,
197
228
  )
198
229
  }
@@ -14,24 +14,26 @@ import {
14
14
  } from '@/components/ui/dialog.js';
15
15
  import { Input } from '@/components/ui/input.js';
16
16
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
17
- import { api } from '@/graphql/api.js';
18
17
  import { ResultOf } from '@/graphql/graphql.js';
19
18
  import { Trans, useLingui } from '@/lib/trans.js';
20
19
 
21
20
  import { useChannel } from '@/hooks/use-channel.js';
22
- import { assignProductsToChannelDocument } from '../products.graphql.js';
23
21
 
24
22
  interface AssignToChannelDialogProps {
25
23
  open: boolean;
26
24
  onOpenChange: (open: boolean) => void;
27
- productIds: string[];
25
+ entityIds: string[];
26
+ entityType: 'products' | 'variants';
27
+ mutationFn: (variables: any) => Promise<ResultOf<any>>;
28
28
  onSuccess?: () => void;
29
29
  }
30
30
 
31
31
  export function AssignToChannelDialog({
32
32
  open,
33
33
  onOpenChange,
34
- productIds,
34
+ entityIds,
35
+ entityType,
36
+ mutationFn,
35
37
  onSuccess,
36
38
  }: AssignToChannelDialogProps) {
37
39
  const { i18n } = useLingui();
@@ -43,14 +45,14 @@ export function AssignToChannelDialog({
43
45
  const availableChannels = channels.filter(channel => channel.id !== selectedChannel?.id);
44
46
 
45
47
  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`));
48
+ mutationFn,
49
+ onSuccess: () => {
50
+ toast.success(i18n.t(`Successfully assigned ${entityIds.length} ${entityType} to channel`));
49
51
  onSuccess?.();
50
52
  onOpenChange(false);
51
53
  },
52
54
  onError: () => {
53
- toast.error(`Failed to assign ${productIds.length} products to channel`);
55
+ toast.error(`Failed to assign ${entityIds.length} ${entityType} to channel`);
54
56
  },
55
57
  });
56
58
 
@@ -60,13 +62,20 @@ export function AssignToChannelDialog({
60
62
  return;
61
63
  }
62
64
 
63
- mutate({
64
- input: {
65
- productIds,
66
- channelId: selectedChannelId,
67
- priceFactor,
68
- },
69
- });
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
+
78
+ mutate({ input });
70
79
  };
71
80
 
72
81
  return (
@@ -74,10 +83,12 @@ export function AssignToChannelDialog({
74
83
  <DialogContent className="sm:max-w-[425px]">
75
84
  <DialogHeader>
76
85
  <DialogTitle>
77
- <Trans>Assign products to channel</Trans>
86
+ <Trans>Assign {entityType} to channel</Trans>
78
87
  </DialogTitle>
79
88
  <DialogDescription>
80
- <Trans>Select a channel to assign {productIds.length} products to</Trans>
89
+ <Trans>
90
+ Select a channel to assign {entityIds.length} {entityType} to
91
+ </Trans>
81
92
  </DialogDescription>
82
93
  </DialogHeader>
83
94
  <div className="grid gap-4 py-4">