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

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 (24) hide show
  1. package/package.json +4 -4
  2. package/src/app/routes/_authenticated/_collections/collections.graphql.ts +16 -0
  3. package/src/app/routes/_authenticated/_collections/collections.tsx +16 -2
  4. package/src/app/routes/_authenticated/_collections/components/assign-collections-to-channel-dialog.tsx +110 -0
  5. package/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx +99 -0
  6. package/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx +184 -0
  7. package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +62 -1
  8. package/src/app/routes/_authenticated/_product-variants/product-variants.tsx +33 -3
  9. package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +9 -2
  10. package/src/app/routes/_authenticated/_products/components/assign-facet-values-dialog.tsx +67 -36
  11. package/src/app/routes/_authenticated/_products/components/assign-to-channel-dialog.tsx +28 -17
  12. package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +12 -2
  13. package/src/app/routes/_authenticated/_products/components/product-variants-table.tsx +74 -55
  14. package/src/app/routes/_authenticated/_products/products_.$id.tsx +1 -0
  15. package/src/lib/components/shared/detail-page-button.tsx +3 -1
  16. package/src/lib/components/shared/paginated-list-data-table.tsx +6 -4
  17. package/src/lib/framework/data-table/data-table-extensions.ts +14 -0
  18. package/src/lib/framework/document-extension/extend-document.spec.ts +549 -0
  19. package/src/lib/framework/document-extension/extend-document.ts +159 -0
  20. package/src/lib/framework/extension-api/define-dashboard-extension.ts +14 -1
  21. package/src/lib/framework/extension-api/extension-api-types.ts +6 -0
  22. package/src/lib/framework/page/detail-page-route-loader.tsx +9 -3
  23. package/src/lib/framework/registry/registry-types.ts +2 -0
  24. package/src/lib/hooks/use-extended-list-query.ts +73 -0
@@ -0,0 +1,549 @@
1
+ import { graphql } from '@/graphql/graphql.js';
2
+ import { print } from 'graphql';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { extendDocument, gqlExtend } from './extend-document.js';
6
+
7
+ /**
8
+ * Helper to strip indentation and normalize GraphQL SDL for comparison.
9
+ * Allows the expected result to be indented naturally in the code.
10
+ */
11
+ function expectedSDL(str: string): string {
12
+ const lines = str.split('\n');
13
+ // Find the minimum indentation (excluding empty lines)
14
+ let minIndent = Infinity;
15
+ for (const line of lines) {
16
+ if (line.trim() === '') continue;
17
+ const indent = line.match(/^\s*/)?.[0].length || 0;
18
+ minIndent = Math.min(minIndent, indent);
19
+ }
20
+ // Remove the minimum indentation from all lines and normalize
21
+ return lines
22
+ .map(line => line.slice(minIndent).trim())
23
+ .filter(line => line.length > 0)
24
+ .join('\n');
25
+ }
26
+
27
+ describe('extendDocument', () => {
28
+ const baseDocument = graphql(`
29
+ query ProductVariantList($options: ProductVariantListOptions) {
30
+ productVariants(options: $options) {
31
+ items {
32
+ id
33
+ name
34
+ sku
35
+ price
36
+ }
37
+ totalItems
38
+ }
39
+ }
40
+ `);
41
+
42
+ it('should add new fields to existing query', () => {
43
+ const extended = extendDocument(
44
+ baseDocument,
45
+ `
46
+ query ProductVariantList($options: ProductVariantListOptions) {
47
+ productVariants(options: $options) {
48
+ items {
49
+ reviewRating
50
+ customField
51
+ }
52
+ }
53
+ }
54
+ `,
55
+ );
56
+
57
+ const printed = print(extended);
58
+
59
+ expect(expectedSDL(printed)).toBe(
60
+ expectedSDL(`
61
+ query ProductVariantList($options: ProductVariantListOptions) {
62
+ productVariants(options: $options) {
63
+ items {
64
+ id
65
+ name
66
+ sku
67
+ price
68
+ reviewRating
69
+ customField
70
+ }
71
+ totalItems
72
+ }
73
+ }
74
+ `),
75
+ );
76
+ });
77
+
78
+ it('should merge nested selection sets', () => {
79
+ const extended = extendDocument(
80
+ baseDocument,
81
+ `
82
+ query ProductVariantList($options: ProductVariantListOptions) {
83
+ productVariants(options: $options) {
84
+ items {
85
+ featuredAsset {
86
+ id
87
+ name
88
+ }
89
+ }
90
+ }
91
+ }
92
+ `,
93
+ );
94
+
95
+ const printed = print(extended);
96
+
97
+ expect(expectedSDL(printed)).toBe(
98
+ expectedSDL(`
99
+ query ProductVariantList($options: ProductVariantListOptions) {
100
+ productVariants(options: $options) {
101
+ items {
102
+ id
103
+ name
104
+ sku
105
+ price
106
+ featuredAsset {
107
+ id
108
+ name
109
+ }
110
+ }
111
+ totalItems
112
+ }
113
+ }
114
+ `),
115
+ );
116
+ });
117
+
118
+ it('should handle multiple operations', () => {
119
+ const multiOpDocument = graphql(`
120
+ query ProductVariantList($options: ProductVariantListOptions) {
121
+ productVariants(options: $options) {
122
+ items {
123
+ id
124
+ name
125
+ }
126
+ totalItems
127
+ }
128
+ }
129
+
130
+ query ProductVariantDetail($id: ID!) {
131
+ productVariant(id: $id) {
132
+ id
133
+ name
134
+ }
135
+ }
136
+ `);
137
+
138
+ const extended = extendDocument(
139
+ multiOpDocument,
140
+ `
141
+ query ProductVariantList($options: ProductVariantListOptions) {
142
+ productVariants(options: $options) {
143
+ items {
144
+ sku
145
+ }
146
+ }
147
+ }
148
+
149
+ query ProductVariantDetail($id: ID!) {
150
+ productVariant(id: $id) {
151
+ sku
152
+ price
153
+ }
154
+ }
155
+ `,
156
+ );
157
+
158
+ const printed = print(extended);
159
+
160
+ expect(expectedSDL(printed)).toBe(
161
+ expectedSDL(`
162
+ query ProductVariantList($options: ProductVariantListOptions) {
163
+ productVariants(options: $options) {
164
+ items {
165
+ id
166
+ name
167
+ sku
168
+ }
169
+ totalItems
170
+ }
171
+ }
172
+ query ProductVariantDetail($id: ID!) {
173
+ productVariant(id: $id) {
174
+ id
175
+ name
176
+ sku
177
+ price
178
+ }
179
+ }
180
+ `),
181
+ );
182
+ });
183
+
184
+ it('should preserve fragments', () => {
185
+ const fragmentDocument = graphql(`
186
+ fragment ProductVariantFields on ProductVariant {
187
+ id
188
+ name
189
+ }
190
+
191
+ query ProductVariantList($options: ProductVariantListOptions) {
192
+ productVariants(options: $options) {
193
+ items {
194
+ ...ProductVariantFields
195
+ }
196
+ totalItems
197
+ }
198
+ }
199
+ `);
200
+
201
+ const extended = extendDocument(
202
+ fragmentDocument,
203
+ `
204
+ fragment ProductVariantFields on ProductVariant {
205
+ sku
206
+ }
207
+
208
+ query ProductVariantList($options: ProductVariantListOptions) {
209
+ productVariants(options: $options) {
210
+ items {
211
+ price
212
+ }
213
+ }
214
+ }
215
+ `,
216
+ );
217
+
218
+ const printed = print(extended);
219
+
220
+ expect(expectedSDL(printed)).toBe(
221
+ expectedSDL(`
222
+ query ProductVariantList($options: ProductVariantListOptions) {
223
+ productVariants(options: $options) {
224
+ items {
225
+ ...ProductVariantFields
226
+ price
227
+ }
228
+ totalItems
229
+ }
230
+ }
231
+ fragment ProductVariantFields on ProductVariant {
232
+ id
233
+ name
234
+ }
235
+ fragment ProductVariantFields on ProductVariant {
236
+ sku
237
+ }
238
+ `),
239
+ );
240
+ });
241
+
242
+ it('should work with template string interpolation', () => {
243
+ const fieldName = 'reviewRating';
244
+ const extended = extendDocument(
245
+ baseDocument,
246
+ `
247
+ query ProductVariantList($options: ProductVariantListOptions) {
248
+ productVariants(options: $options) {
249
+ items {
250
+ ${fieldName}
251
+ }
252
+ }
253
+ }
254
+ `,
255
+ );
256
+
257
+ const printed = print(extended);
258
+
259
+ expect(expectedSDL(printed)).toBe(
260
+ expectedSDL(`
261
+ query ProductVariantList($options: ProductVariantListOptions) {
262
+ productVariants(options: $options) {
263
+ items {
264
+ id
265
+ name
266
+ sku
267
+ price
268
+ reviewRating
269
+ }
270
+ totalItems
271
+ }
272
+ }
273
+ `),
274
+ );
275
+ });
276
+
277
+ it('should handle the gqlExtend utility function', () => {
278
+ const extender = gqlExtend`
279
+ query ProductVariantList($options: ProductVariantListOptions) {
280
+ productVariants(options: $options) {
281
+ items {
282
+ reviewRating
283
+ }
284
+ }
285
+ }
286
+ `;
287
+
288
+ const extended = extender(baseDocument);
289
+ const printed = print(extended);
290
+
291
+ expect(expectedSDL(printed)).toBe(
292
+ expectedSDL(`
293
+ query ProductVariantList($options: ProductVariantListOptions) {
294
+ productVariants(options: $options) {
295
+ items {
296
+ id
297
+ name
298
+ sku
299
+ price
300
+ reviewRating
301
+ }
302
+ totalItems
303
+ }
304
+ }
305
+ `),
306
+ );
307
+ });
308
+
309
+ it('should not duplicate existing fields', () => {
310
+ const extended = extendDocument(
311
+ baseDocument,
312
+ `
313
+ query ProductVariantList($options: ProductVariantListOptions) {
314
+ productVariants(options: $options) {
315
+ items {
316
+ id
317
+ name
318
+ reviewRating
319
+ }
320
+ }
321
+ }
322
+ `,
323
+ );
324
+
325
+ const printed = print(extended);
326
+
327
+ expect(expectedSDL(printed)).toBe(
328
+ expectedSDL(`
329
+ query ProductVariantList($options: ProductVariantListOptions) {
330
+ productVariants(options: $options) {
331
+ items {
332
+ id
333
+ name
334
+ sku
335
+ price
336
+ reviewRating
337
+ }
338
+ totalItems
339
+ }
340
+ }
341
+ `),
342
+ );
343
+ });
344
+
345
+ it('should merge nested selection sets for existing fields', () => {
346
+ const baseWithNested = graphql(`
347
+ query ProductVariantList($options: ProductVariantListOptions) {
348
+ productVariants(options: $options) {
349
+ items {
350
+ id
351
+ featuredAsset {
352
+ id
353
+ }
354
+ }
355
+ totalItems
356
+ }
357
+ }
358
+ `);
359
+
360
+ const extended = extendDocument(
361
+ baseWithNested,
362
+ `
363
+ query ProductVariantList($options: ProductVariantListOptions) {
364
+ productVariants(options: $options) {
365
+ items {
366
+ featuredAsset {
367
+ name
368
+ preview
369
+ }
370
+ }
371
+ }
372
+ }
373
+ `,
374
+ );
375
+
376
+ const printed = print(extended);
377
+
378
+ expect(expectedSDL(printed)).toBe(
379
+ expectedSDL(`
380
+ query ProductVariantList($options: ProductVariantListOptions) {
381
+ productVariants(options: $options) {
382
+ items {
383
+ id
384
+ featuredAsset {
385
+ id
386
+ name
387
+ preview
388
+ }
389
+ }
390
+ totalItems
391
+ }
392
+ }
393
+ `),
394
+ );
395
+ });
396
+
397
+ it('should ignore different query names and merge by top-level field', () => {
398
+ const extended = extendDocument(
399
+ baseDocument,
400
+ `
401
+ query DifferentQueryName($options: ProductVariantListOptions) {
402
+ productVariants(options: $options) {
403
+ items {
404
+ reviewRating
405
+ }
406
+ }
407
+ }
408
+ `,
409
+ );
410
+
411
+ const printed = print(extended);
412
+
413
+ expect(expectedSDL(printed)).toBe(
414
+ expectedSDL(`
415
+ query ProductVariantList($options: ProductVariantListOptions) {
416
+ productVariants(options: $options) {
417
+ items {
418
+ id
419
+ name
420
+ sku
421
+ price
422
+ reviewRating
423
+ }
424
+ totalItems
425
+ }
426
+ }
427
+ `),
428
+ );
429
+ });
430
+
431
+ it('should ignore different variables and merge by top-level field', () => {
432
+ const extended = extendDocument(
433
+ baseDocument,
434
+ `
435
+ query ProductVariantList($differentOptions: ProductVariantListOptions) {
436
+ productVariants(options: $differentOptions) {
437
+ items {
438
+ reviewRating
439
+ }
440
+ }
441
+ }
442
+ `,
443
+ );
444
+
445
+ const printed = print(extended);
446
+
447
+ expect(expectedSDL(printed)).toBe(
448
+ expectedSDL(`
449
+ query ProductVariantList($options: ProductVariantListOptions) {
450
+ productVariants(options: $options) {
451
+ items {
452
+ id
453
+ name
454
+ sku
455
+ price
456
+ reviewRating
457
+ }
458
+ totalItems
459
+ }
460
+ }
461
+ `),
462
+ );
463
+ });
464
+
465
+ it('should throw error when top-level field differs', () => {
466
+ expect(() => {
467
+ extendDocument(
468
+ baseDocument,
469
+ `
470
+ query CompletelyDifferentQuery($id: ID!) {
471
+ product(id: $id) {
472
+ id
473
+ name
474
+ description
475
+ }
476
+ }
477
+ `,
478
+ );
479
+ }).toThrow("The query extension must extend the 'productVariants' query. Got 'product' instead.");
480
+ });
481
+
482
+ it('should merge anonymous query by top-level field', () => {
483
+ const extended = extendDocument(
484
+ baseDocument,
485
+ `
486
+ {
487
+ productVariants {
488
+ items {
489
+ reviewRating
490
+ }
491
+ }
492
+ }
493
+ `,
494
+ );
495
+
496
+ const printed = print(extended);
497
+
498
+ expect(expectedSDL(printed)).toBe(
499
+ expectedSDL(`
500
+ query ProductVariantList($options: ProductVariantListOptions) {
501
+ productVariants(options: $options) {
502
+ items {
503
+ id
504
+ name
505
+ sku
506
+ price
507
+ reviewRating
508
+ }
509
+ totalItems
510
+ }
511
+ }
512
+ `),
513
+ );
514
+ });
515
+
516
+ it('should accept DocumentNode as extension parameter', () => {
517
+ const extensionDocument = graphql(`
518
+ query ProductVariantList($options: ProductVariantListOptions) {
519
+ productVariants(options: $options) {
520
+ items {
521
+ reviewRating
522
+ customField
523
+ }
524
+ }
525
+ }
526
+ `);
527
+
528
+ const extended = extendDocument(baseDocument, extensionDocument);
529
+ const printed = print(extended);
530
+
531
+ expect(expectedSDL(printed)).toBe(
532
+ expectedSDL(`
533
+ query ProductVariantList($options: ProductVariantListOptions) {
534
+ productVariants(options: $options) {
535
+ items {
536
+ id
537
+ name
538
+ sku
539
+ price
540
+ reviewRating
541
+ customField
542
+ }
543
+ totalItems
544
+ }
545
+ }
546
+ `),
547
+ );
548
+ });
549
+ });
@@ -0,0 +1,159 @@
1
+ import { Variables } from '@/graphql/api.js';
2
+ import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
3
+ import {
4
+ DefinitionNode,
5
+ DocumentNode,
6
+ FieldNode,
7
+ FragmentDefinitionNode,
8
+ Kind,
9
+ OperationDefinitionNode,
10
+ parse,
11
+ SelectionNode,
12
+ SelectionSetNode,
13
+ } from 'graphql';
14
+
15
+ /**
16
+ * Type-safe template string function for extending GraphQL documents
17
+ */
18
+ export function extendDocument<T extends TypedDocumentNode, V extends Variables = Variables>(
19
+ defaultDocument: T,
20
+ template: TemplateStringsArray,
21
+ ...values: any[]
22
+ ): T;
23
+ export function extendDocument<T extends TypedDocumentNode, V extends Variables = Variables>(
24
+ defaultDocument: T,
25
+ sdl: string | DocumentNode,
26
+ ): T;
27
+ export function extendDocument<T extends TypedDocumentNode, V extends Variables = Variables>(
28
+ defaultDocument: T,
29
+ template: TemplateStringsArray | string | DocumentNode,
30
+ ...values: any[]
31
+ ): T {
32
+ // Handle template strings, regular strings, and DocumentNode
33
+ let extensionDocument: DocumentNode;
34
+ if (Array.isArray(template)) {
35
+ // Template string array
36
+ const sdl = (template as TemplateStringsArray).reduce((result, str, i) => {
37
+ return result + str + String(values[i] ?? '');
38
+ }, '');
39
+ extensionDocument = parse(sdl);
40
+ } else if (typeof template === 'string') {
41
+ // Regular string
42
+ extensionDocument = parse(template);
43
+ } else {
44
+ // DocumentNode
45
+ extensionDocument = template as DocumentNode;
46
+ }
47
+
48
+ // Merge the documents
49
+ const mergedDocument = mergeDocuments(defaultDocument, extensionDocument);
50
+
51
+ return mergedDocument as T;
52
+ }
53
+
54
+ /**
55
+ * Merges two GraphQL documents, adding fields from the extension to the base document
56
+ */
57
+ function mergeDocuments(baseDocument: DocumentNode, extensionDocument: DocumentNode): DocumentNode {
58
+ const baseClone = JSON.parse(JSON.stringify(baseDocument)) as DocumentNode;
59
+
60
+ // Get all operation definitions from both documents
61
+ const baseOperations = baseClone.definitions.filter(isOperationDefinition);
62
+ const extensionOperations = extensionDocument.definitions.filter(isOperationDefinition);
63
+
64
+ // Get all fragment definitions from both documents
65
+ const baseFragments = baseClone.definitions.filter(isFragmentDefinition);
66
+ const extensionFragments = extensionDocument.definitions.filter(isFragmentDefinition);
67
+
68
+ // Merge fragments first (extensions can reference them)
69
+ const mergedFragments = [...baseFragments, ...extensionFragments];
70
+
71
+ // For each operation in the extension, find the corresponding base operation and merge
72
+ for (const extensionOp of extensionOperations) {
73
+ // Get the top-level field name from the extension operation
74
+ const extensionField = extensionOp.selectionSet.selections[0] as FieldNode;
75
+ if (!extensionField) {
76
+ throw new Error('Extension query must have at least one top-level field');
77
+ }
78
+
79
+ // Find a base operation that has the same top-level field
80
+ const baseOp = baseOperations.find(op => {
81
+ const baseField = op.selectionSet.selections[0] as FieldNode;
82
+ return baseField && baseField.name.value === extensionField.name.value;
83
+ });
84
+
85
+ if (!baseOp) {
86
+ const validQueryFields = baseOperations
87
+ .map(op => {
88
+ const field = op.selectionSet.selections[0] as FieldNode;
89
+ return field ? field.name.value : 'unknown';
90
+ })
91
+ .join(', ');
92
+ throw new Error(
93
+ `The query extension must extend the '${validQueryFields}' query. ` +
94
+ `Got '${extensionField.name.value}' instead.`,
95
+ );
96
+ }
97
+
98
+ // Merge the selection sets of the matching top-level fields
99
+ const baseFieldNode = baseOp.selectionSet.selections[0] as FieldNode;
100
+ if (baseFieldNode.selectionSet && extensionField.selectionSet) {
101
+ mergeSelectionSets(baseFieldNode.selectionSet, extensionField.selectionSet);
102
+ }
103
+ }
104
+
105
+ // Update the document with merged definitions
106
+ (baseClone as any).definitions = [...baseOperations, ...mergedFragments];
107
+
108
+ return baseClone;
109
+ }
110
+
111
+ /**
112
+ * Merges two selection sets, adding fields from the extension to the base
113
+ */
114
+ function mergeSelectionSets(
115
+ baseSelectionSet: SelectionSetNode,
116
+ extensionSelectionSet: SelectionSetNode,
117
+ ): void {
118
+ const baseFields = baseSelectionSet.selections.filter(isFieldNode);
119
+ const extensionFields = extensionSelectionSet.selections.filter(isFieldNode);
120
+
121
+ for (const extensionField of extensionFields) {
122
+ const existingField = baseFields.find(field => field.name.value === extensionField.name.value);
123
+
124
+ if (existingField) {
125
+ // Field already exists, merge their selection sets if both have them
126
+ if (existingField.selectionSet && extensionField.selectionSet) {
127
+ mergeSelectionSets(existingField.selectionSet, extensionField.selectionSet);
128
+ } else if (extensionField.selectionSet && !existingField.selectionSet) {
129
+ // Extension has a selection set but base doesn't, add it
130
+ (existingField as any).selectionSet = extensionField.selectionSet;
131
+ }
132
+ } else {
133
+ // Field doesn't exist, add it
134
+ (baseSelectionSet as any).selections.push(extensionField);
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Type guards
141
+ */
142
+ function isOperationDefinition(value: DefinitionNode): value is OperationDefinitionNode {
143
+ return value.kind === Kind.OPERATION_DEFINITION;
144
+ }
145
+
146
+ function isFragmentDefinition(value: DefinitionNode): value is FragmentDefinitionNode {
147
+ return value.kind === Kind.FRAGMENT_DEFINITION;
148
+ }
149
+
150
+ function isFieldNode(value: SelectionNode): value is FieldNode {
151
+ return value.kind === Kind.FIELD;
152
+ }
153
+
154
+ /**
155
+ * Utility function to create a template string tag for better DX
156
+ */
157
+ export function gqlExtend(strings: TemplateStringsArray, ...values: any[]) {
158
+ return (defaultDocument: DocumentNode) => extendDocument(defaultDocument, strings, ...values);
159
+ }