@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.
- package/package.json +4 -4
- package/src/app/routes/_authenticated/_collections/collections.graphql.ts +16 -0
- package/src/app/routes/_authenticated/_collections/collections.tsx +16 -2
- package/src/app/routes/_authenticated/_collections/components/assign-collections-to-channel-dialog.tsx +110 -0
- package/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx +99 -0
- package/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx +184 -0
- package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +62 -1
- package/src/app/routes/_authenticated/_product-variants/product-variants.tsx +33 -3
- package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +9 -2
- package/src/app/routes/_authenticated/_products/components/assign-facet-values-dialog.tsx +67 -36
- package/src/app/routes/_authenticated/_products/components/assign-to-channel-dialog.tsx +28 -17
- package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +12 -2
- package/src/app/routes/_authenticated/_products/components/product-variants-table.tsx +74 -55
- package/src/app/routes/_authenticated/_products/products_.$id.tsx +1 -0
- package/src/lib/components/shared/detail-page-button.tsx +3 -1
- package/src/lib/components/shared/paginated-list-data-table.tsx +6 -4
- package/src/lib/framework/data-table/data-table-extensions.ts +14 -0
- package/src/lib/framework/document-extension/extend-document.spec.ts +549 -0
- package/src/lib/framework/document-extension/extend-document.ts +159 -0
- package/src/lib/framework/extension-api/define-dashboard-extension.ts +14 -1
- package/src/lib/framework/extension-api/extension-api-types.ts +6 -0
- package/src/lib/framework/page/detail-page-route-loader.tsx +9 -3
- package/src/lib/framework/registry/registry-types.ts +2 -0
- 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
|
+
}
|