@vendure/dashboard 3.4.3-master-202509250229 → 3.5.0-minor-202509261210

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 (75) hide show
  1. package/dist/plugin/api/api-extensions.js +11 -14
  2. package/dist/plugin/api/metrics.resolver.d.ts +2 -2
  3. package/dist/plugin/api/metrics.resolver.js +2 -2
  4. package/dist/plugin/config/metrics-strategies.d.ts +9 -9
  5. package/dist/plugin/config/metrics-strategies.js +6 -6
  6. package/dist/plugin/constants.d.ts +2 -0
  7. package/dist/plugin/constants.js +3 -1
  8. package/dist/plugin/dashboard.plugin.js +13 -0
  9. package/dist/plugin/service/metrics.service.d.ts +3 -3
  10. package/dist/plugin/service/metrics.service.js +37 -53
  11. package/dist/plugin/types.d.ts +9 -12
  12. package/dist/plugin/types.js +7 -11
  13. package/dist/vite/utils/compiler.js +2 -0
  14. package/dist/vite/vite-plugin-vendure-dashboard.js +2 -2
  15. package/package.json +4 -4
  16. package/src/app/routes/_authenticated/_collections/collections.tsx +7 -2
  17. package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +15 -2
  18. package/src/app/routes/_authenticated/_facets/facets_.$id.tsx +14 -2
  19. package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +10 -0
  20. package/src/app/routes/_authenticated/_products/components/product-option-group-badge.tsx +19 -0
  21. package/src/app/routes/_authenticated/_products/components/product-options-table.tsx +111 -0
  22. package/src/app/routes/_authenticated/_products/product-option-groups.graphql.ts +103 -0
  23. package/src/app/routes/_authenticated/_products/products.graphql.ts +13 -1
  24. package/src/app/routes/_authenticated/_products/products.tsx +27 -3
  25. package/src/app/routes/_authenticated/_products/products_.$id.tsx +26 -9
  26. package/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$id.tsx +181 -0
  27. package/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$productOptionGroupId.options_.$id.tsx +208 -0
  28. package/src/app/routes/_authenticated/_zones/components/zone-countries-sheet.tsx +4 -1
  29. package/src/app/routes/_authenticated/index.tsx +41 -24
  30. package/src/lib/components/data-display/json.tsx +16 -1
  31. package/src/lib/components/data-input/index.ts +3 -0
  32. package/src/lib/components/data-input/slug-input.tsx +296 -0
  33. package/src/lib/components/data-table/add-filter-menu.tsx +13 -6
  34. package/src/lib/components/data-table/data-table-bulk-action-item.tsx +38 -1
  35. package/src/lib/components/data-table/data-table-context.tsx +91 -0
  36. package/src/lib/components/data-table/data-table-filter-badge.tsx +9 -5
  37. package/src/lib/components/data-table/data-table-view-options.tsx +17 -8
  38. package/src/lib/components/data-table/data-table.tsx +146 -94
  39. package/src/lib/components/data-table/global-views-bar.tsx +97 -0
  40. package/src/lib/components/data-table/global-views-sheet.tsx +11 -0
  41. package/src/lib/components/data-table/manage-global-views-button.tsx +26 -0
  42. package/src/lib/components/data-table/my-views-button.tsx +47 -0
  43. package/src/lib/components/data-table/refresh-button.tsx +12 -3
  44. package/src/lib/components/data-table/save-view-button.tsx +45 -0
  45. package/src/lib/components/data-table/save-view-dialog.tsx +113 -0
  46. package/src/lib/components/data-table/use-generated-columns.tsx +3 -1
  47. package/src/lib/components/data-table/user-views-sheet.tsx +11 -0
  48. package/src/lib/components/data-table/views-sheet.tsx +297 -0
  49. package/src/lib/components/date-range-picker.tsx +184 -0
  50. package/src/lib/components/shared/paginated-list-data-table.tsx +59 -32
  51. package/src/lib/components/ui/button.tsx +1 -1
  52. package/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx +29 -2
  53. package/src/lib/framework/dashboard-widget/metrics-widget/index.tsx +10 -7
  54. package/src/lib/framework/dashboard-widget/metrics-widget/metrics-widget.graphql.ts +9 -3
  55. package/src/lib/framework/dashboard-widget/orders-summary/index.tsx +19 -75
  56. package/src/lib/framework/dashboard-widget/widget-filters-context.tsx +33 -0
  57. package/src/lib/framework/document-introspection/add-custom-fields.spec.ts +319 -9
  58. package/src/lib/framework/document-introspection/add-custom-fields.ts +60 -31
  59. package/src/lib/framework/document-introspection/get-document-structure.spec.ts +1 -159
  60. package/src/lib/framework/document-introspection/include-only-selected-list-fields.spec.ts +1840 -0
  61. package/src/lib/framework/document-introspection/include-only-selected-list-fields.ts +940 -0
  62. package/src/lib/framework/document-introspection/testing-utils.ts +161 -0
  63. package/src/lib/framework/extension-api/display-component-extensions.tsx +2 -0
  64. package/src/lib/framework/extension-api/types/data-table.ts +62 -4
  65. package/src/lib/framework/extension-api/types/navigation.ts +16 -0
  66. package/src/lib/framework/form-engine/utils.ts +34 -0
  67. package/src/lib/framework/page/list-page.tsx +289 -4
  68. package/src/lib/framework/page/use-extended-router.tsx +59 -17
  69. package/src/lib/graphql/api.ts +4 -2
  70. package/src/lib/graphql/graphql-env.d.ts +13 -10
  71. package/src/lib/hooks/use-extended-list-query.ts +5 -0
  72. package/src/lib/hooks/use-saved-views.ts +230 -0
  73. package/src/lib/index.ts +15 -0
  74. package/src/lib/types/saved-views.ts +39 -0
  75. package/src/lib/utils/saved-views-utils.ts +40 -0
@@ -9,13 +9,15 @@ import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
9
9
  import { Link } from '@tanstack/react-router';
10
10
  import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
11
11
  import { formatRelative } from 'date-fns';
12
- import { useState } from 'react';
12
+ import { useEffect, useState } from 'react';
13
13
  import { DashboardBaseWidget } from '../base-widget.js';
14
+ import { useWidgetFilters } from '../widget-filters-context.js';
14
15
  import { latestOrdersQuery } from './latest-orders-widget.graphql.js';
15
16
 
16
17
  export const WIDGET_ID = 'latest-orders-widget';
17
18
 
18
19
  export function LatestOrdersWidget() {
20
+ const { dateRange } = useWidgetFilters();
19
21
  const [sorting, setSorting] = useState<SortingState>([
20
22
  {
21
23
  id: 'orderPlacedAt',
@@ -24,9 +26,34 @@ export function LatestOrdersWidget() {
24
26
  ]);
25
27
  const [page, setPage] = useState(1);
26
28
  const [pageSize, setPageSize] = useState(10);
27
- const [filters, setFilters] = useState<ColumnFiltersState>([]);
29
+ const [filters, setFilters] = useState<ColumnFiltersState>([
30
+ {
31
+ id: 'orderPlacedAt',
32
+ value: {
33
+ between: {
34
+ start: dateRange.from.toISOString(),
35
+ end: dateRange.to.toISOString(),
36
+ },
37
+ },
38
+ },
39
+ ]);
28
40
  const { formatCurrency } = useLocalFormat();
29
41
 
42
+ // Update filters when date range changes
43
+ useEffect(() => {
44
+ setFilters([
45
+ {
46
+ id: 'orderPlacedAt',
47
+ value: {
48
+ between: {
49
+ start: dateRange.from.toISOString(),
50
+ end: dateRange.to.toISOString(),
51
+ },
52
+ },
53
+ },
54
+ ]);
55
+ }, [dateRange]);
56
+
30
57
  return (
31
58
  <DashboardBaseWidget id={WIDGET_ID} title="Latest Orders" description="Your latest orders">
32
59
  <PaginatedListDataTable
@@ -7,6 +7,7 @@ import { useQuery } from '@tanstack/react-query';
7
7
  import { RefreshCw } from 'lucide-react';
8
8
  import { useMemo, useState } from 'react';
9
9
  import { DashboardBaseWidget } from '../base-widget.js';
10
+ import { useWidgetFilters } from '../widget-filters-context.js';
10
11
  import { MetricsChart } from './chart.js';
11
12
  import { orderChartDataQuery } from './metrics-widget.graphql.js';
12
13
 
@@ -19,37 +20,39 @@ enum DATA_TYPES {
19
20
  export function MetricsWidget() {
20
21
  const { formatDate, formatCurrency } = useLocalFormat();
21
22
  const { activeChannel } = useChannel();
23
+ const { dateRange } = useWidgetFilters();
22
24
  const [dataType, setDataType] = useState<DATA_TYPES>(DATA_TYPES.OrderTotal);
23
25
 
24
- const { data, isRefetching, refetch } = useQuery({
25
- queryKey: ['dashboard-order-metrics', dataType],
26
+ const { data, refetch, isRefetching } = useQuery({
27
+ queryKey: ['dashboard-order-metrics', dataType, dateRange],
26
28
  queryFn: () => {
27
29
  return api.query(orderChartDataQuery, {
28
30
  types: [dataType],
29
31
  refresh: true,
32
+ startDate: dateRange.from.toISOString(),
33
+ endDate: dateRange.to.toISOString(),
30
34
  });
31
35
  },
32
36
  });
33
37
 
34
38
  const chartData = useMemo(() => {
35
- const entry = data?.metricSummary.at(0);
39
+ const entry = data?.dashboardMetricSummary.at(0);
36
40
  if (!entry) {
37
41
  return undefined;
38
42
  }
39
43
 
40
- const { interval, type, entries } = entry;
44
+ const { type, entries } = entry;
41
45
 
42
- const values = entries.map(({ label, value }) => ({
46
+ const values = entries.map(({ label, value }: { label: string; value: number }) => ({
43
47
  name: formatDate(label, { month: 'short', day: 'numeric' }),
44
48
  sales: value,
45
49
  }));
46
50
 
47
51
  return {
48
52
  values,
49
- interval,
50
53
  type,
51
54
  };
52
- }, [data]);
55
+ }, [data, formatDate]);
53
56
 
54
57
  return (
55
58
  <DashboardBaseWidget
@@ -1,9 +1,15 @@
1
1
  import { graphql } from '@/vdb/graphql/graphql.js';
2
2
 
3
3
  export const orderChartDataQuery = graphql(`
4
- query GetOrderChartData($refresh: Boolean, $types: [MetricType!]!) {
5
- metricSummary(input: { interval: Daily, types: $types, refresh: $refresh }) {
6
- interval
4
+ query GetOrderChartData(
5
+ $refresh: Boolean
6
+ $types: [DashboardMetricType!]!
7
+ $startDate: DateTime!
8
+ $endDate: DateTime!
9
+ ) {
10
+ dashboardMetricSummary(
11
+ input: { types: $types, refresh: $refresh, startDate: $startDate, endDate: $endDate }
12
+ ) {
7
13
  type
8
14
  entries {
9
15
  label
@@ -1,21 +1,14 @@
1
1
  import { AnimatedCurrency, AnimatedNumber } from '@/vdb/components/shared/animated-number.js';
2
- import { Tabs, TabsList, TabsTrigger } from '@/vdb/components/ui/tabs.js';
3
2
  import { api } from '@/vdb/graphql/api.js';
4
3
  import { useQuery } from '@tanstack/react-query';
5
- import { endOfDay, endOfMonth, startOfDay, startOfMonth, subDays, subMonths } from 'date-fns';
6
- import { useMemo, useState } from 'react';
4
+ import { differenceInDays, subDays } from 'date-fns';
5
+ import { useMemo } from 'react';
7
6
  import { DashboardBaseWidget } from '../base-widget.js';
7
+ import { useWidgetFilters } from '../widget-filters-context.js';
8
8
  import { orderSummaryQuery } from './order-summary-widget.graphql.js';
9
9
 
10
10
  const WIDGET_ID = 'orders-summary-widget';
11
11
 
12
- enum Range {
13
- Today = 'today',
14
- Yesterday = 'yesterday',
15
- ThisWeek = 'thisWeek',
16
- ThisMonth = 'thisMonth',
17
- }
18
-
19
12
  interface PercentageChangeProps {
20
13
  value: number;
21
14
  }
@@ -34,63 +27,24 @@ function PercentageChange({ value }: PercentageChangeProps) {
34
27
  }
35
28
 
36
29
  export function OrdersSummaryWidget() {
37
- const [range, setRange] = useState<Range>(Range.Today);
30
+ const { dateRange } = useWidgetFilters();
38
31
 
39
32
  const variables = useMemo(() => {
40
- const now = new Date();
41
-
42
- switch (range) {
43
- case Range.Today: {
44
- const today = now;
45
- const yesterday = subDays(now, 1);
46
-
47
- return {
48
- start: startOfDay(today).toISOString(),
49
- end: endOfDay(today).toISOString(),
50
- previousStart: startOfDay(yesterday).toISOString(),
51
- previousEnd: endOfDay(yesterday).toISOString(),
52
- };
53
- }
54
- case Range.Yesterday: {
55
- const yesterday = subDays(now, 1);
56
- const dayBeforeYesterday = subDays(now, 2);
57
-
58
- return {
59
- start: startOfDay(yesterday).toISOString(),
60
- end: endOfDay(yesterday).toISOString(),
61
- previousStart: startOfDay(dayBeforeYesterday).toISOString(),
62
- previousEnd: endOfDay(dayBeforeYesterday).toISOString(),
63
- };
64
- }
65
- case Range.ThisWeek: {
66
- const today = now;
67
- const sixDaysAgo = subDays(now, 6);
68
- const sevenDaysAgo = subDays(now, 7);
69
- const thirteenDaysAgo = subDays(now, 13);
70
-
71
- return {
72
- start: startOfDay(sixDaysAgo).toISOString(),
73
- end: endOfDay(today).toISOString(),
74
- previousStart: startOfDay(thirteenDaysAgo).toISOString(),
75
- previousEnd: endOfDay(sevenDaysAgo).toISOString(),
76
- };
77
- }
78
- case Range.ThisMonth: {
79
- const lastMonth = subMonths(now, 1);
80
- const twoMonthsAgo = subMonths(now, 2);
81
-
82
- return {
83
- start: startOfMonth(lastMonth).toISOString(),
84
- end: endOfMonth(lastMonth).toISOString(),
85
- previousStart: startOfMonth(twoMonthsAgo).toISOString(),
86
- previousEnd: endOfMonth(twoMonthsAgo).toISOString(),
87
- };
88
- }
89
- }
90
- }, [range]);
33
+ const rangeLength = differenceInDays(dateRange.to, dateRange.from) + 1;
34
+ // For the previous period, we go back by the same range length
35
+ const previousStart = subDays(dateRange.from, rangeLength);
36
+ const previousEnd = subDays(dateRange.to, rangeLength);
37
+
38
+ return {
39
+ start: dateRange.from.toISOString(),
40
+ end: dateRange.to.toISOString(),
41
+ previousStart: previousStart.toISOString(),
42
+ previousEnd: previousEnd.toISOString(),
43
+ };
44
+ }, [dateRange]);
91
45
 
92
46
  const { data } = useQuery({
93
- queryKey: ['orders-summary', range],
47
+ queryKey: ['orders-summary', dateRange],
94
48
  queryFn: () =>
95
49
  api.query(orderSummaryQuery, {
96
50
  start: variables.start,
@@ -99,7 +53,7 @@ export function OrdersSummaryWidget() {
99
53
  });
100
54
 
101
55
  const { data: previousData } = useQuery({
102
- queryKey: ['orders-summary', 'previous', range],
56
+ queryKey: ['orders-summary', 'previous', dateRange],
103
57
  queryFn: () =>
104
58
  api.query(orderSummaryQuery, {
105
59
  start: variables.previousStart,
@@ -126,16 +80,6 @@ export function OrdersSummaryWidget() {
126
80
  id={WIDGET_ID}
127
81
  title="Orders Summary"
128
82
  description="Your orders summary"
129
- actions={
130
- <Tabs defaultValue={range} onValueChange={value => setRange(value as Range)}>
131
- <TabsList>
132
- <TabsTrigger value={Range.Today}>Today</TabsTrigger>
133
- <TabsTrigger value={Range.Yesterday}>Yesterday</TabsTrigger>
134
- <TabsTrigger value={Range.ThisWeek}>This Week</TabsTrigger>
135
- <TabsTrigger value={Range.ThisMonth}>This Month</TabsTrigger>
136
- </TabsList>
137
- </Tabs>
138
- }
139
83
  >
140
84
  <div className="@container h-full">
141
85
  <div className="flex flex-col h-full @md:flex-row gap-8 items-center justify-center @md:justify-evenly text-center tabular-nums">
@@ -163,4 +107,4 @@ export function OrdersSummaryWidget() {
163
107
  </div>
164
108
  </DashboardBaseWidget>
165
109
  );
166
- }
110
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { createContext, useContext, PropsWithChildren } from 'react';
4
+
5
+ export interface DefinedDateRange {
6
+ from: Date;
7
+ to: Date;
8
+ }
9
+
10
+ export interface WidgetFilters {
11
+ dateRange: DefinedDateRange;
12
+ }
13
+
14
+ const WidgetFiltersContext = createContext<WidgetFilters | undefined>(undefined);
15
+
16
+ export function WidgetFiltersProvider({
17
+ children,
18
+ filters
19
+ }: PropsWithChildren<{ filters: WidgetFilters }>) {
20
+ return (
21
+ <WidgetFiltersContext.Provider value={filters}>
22
+ {children}
23
+ </WidgetFiltersContext.Provider>
24
+ );
25
+ }
26
+
27
+ export function useWidgetFilters() {
28
+ const context = useContext(WidgetFiltersContext);
29
+ if (context === undefined) {
30
+ throw new Error('useWidgetFilters must be used within a WidgetFiltersProvider');
31
+ }
32
+ return context;
33
+ }
@@ -1,14 +1,7 @@
1
1
  import { CustomFieldConfig, CustomFields } from '@vendure/common/lib/generated-types';
2
2
  import { graphql } from 'gql.tada';
3
- import {
4
- DocumentNode,
5
- FieldNode,
6
- FragmentDefinitionNode,
7
- Kind,
8
- OperationDefinitionNode,
9
- print,
10
- } from 'graphql';
11
- import { describe, it, expect, beforeEach } from 'vitest';
3
+ import { DocumentNode, FieldNode, FragmentDefinitionNode, Kind, print } from 'graphql';
4
+ import { beforeEach, describe, expect, it } from 'vitest';
12
5
 
13
6
  import { addCustomFields } from './add-custom-fields.js';
14
7
 
@@ -239,4 +232,321 @@ describe('addCustomFields()', () => {
239
232
  addsCustomFieldsToType('Address', addressFragment);
240
233
  });
241
234
  });
235
+
236
+ describe('Nested entity handling', () => {
237
+ it('User example: Should not add custom fields to Asset fragment used in nested featuredAsset', () => {
238
+ const assetFragment = graphql(`
239
+ fragment Asset on Asset {
240
+ id
241
+ createdAt
242
+ updatedAt
243
+ name
244
+ fileSize
245
+ mimeType
246
+ type
247
+ preview
248
+ source
249
+ width
250
+ height
251
+ focalPoint {
252
+ x
253
+ y
254
+ }
255
+ }
256
+ `);
257
+
258
+ const documentNode = graphql(
259
+ `
260
+ query CollectionList($options: CollectionListOptions) {
261
+ collections(options: $options) {
262
+ items {
263
+ id
264
+ createdAt
265
+ updatedAt
266
+ featuredAsset {
267
+ ...Asset
268
+ }
269
+ name
270
+ slug
271
+ breadcrumbs {
272
+ id
273
+ name
274
+ slug
275
+ }
276
+ children {
277
+ id
278
+ name
279
+ }
280
+ position
281
+ isPrivate
282
+ parentId
283
+ productVariants {
284
+ totalItems
285
+ }
286
+ }
287
+ totalItems
288
+ }
289
+ }
290
+ `,
291
+ [assetFragment],
292
+ );
293
+
294
+ const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
295
+ customFieldsConfig.set('Collection', [
296
+ { name: 'featuredProducts', type: 'relation', list: true, scalarFields: ['id', 'name'] },
297
+ { name: 'alternativeAsset', type: 'relation', list: false, scalarFields: ['id', 'name'] },
298
+ ]);
299
+ customFieldsConfig.set('Asset', [
300
+ { name: 'assetCustomField1', type: 'string', list: false },
301
+ { name: 'assetCustomField2', type: 'string', list: false },
302
+ { name: 'assetCustomField3', type: 'string', list: false },
303
+ ]);
304
+
305
+ const result = addCustomFields(documentNode, { customFieldsMap: customFieldsConfig });
306
+ const printed = print(result);
307
+
308
+ // Should add customFields ONLY to Collection items (top-level entity)
309
+ expect(printed).toContain('customFields {');
310
+ expect(printed).toContain('featuredProducts {');
311
+ expect(printed).toContain('alternativeAsset {');
312
+
313
+ // Should NOT add customFields to Asset fragment (only used in nested context)
314
+ const fragmentMatch = printed.match(/fragment Asset on Asset\s*\{[^}]*\}/s);
315
+ expect(fragmentMatch).toBeTruthy();
316
+ expect(fragmentMatch![0]).not.toContain('customFields');
317
+ expect(fragmentMatch![0]).not.toContain('assetCustomField1');
318
+
319
+ // Should NOT add customFields to children (nested Collection entities)
320
+ const childrenMatch = printed.match(/children\s*\{[^}]+\}/s);
321
+ expect(childrenMatch).toBeTruthy();
322
+ expect(childrenMatch![0]).not.toContain('customFields');
323
+
324
+ // Should NOT add customFields to breadcrumbs
325
+ const breadcrumbsMatch = printed.match(/breadcrumbs\s*\{[^}]+\}/s);
326
+ expect(breadcrumbsMatch).toBeTruthy();
327
+ expect(breadcrumbsMatch![0]).not.toContain('customFields');
328
+ });
329
+
330
+ it('Should only add custom fields to top-level entity, not nested related entities', () => {
331
+ const documentNode = graphql(`
332
+ query CollectionList($options: CollectionListOptions) {
333
+ collections(options: $options) {
334
+ items {
335
+ id
336
+ name
337
+ slug
338
+ featuredAsset {
339
+ id
340
+ preview
341
+ }
342
+ children {
343
+ id
344
+ name
345
+ slug
346
+ }
347
+ breadcrumbs {
348
+ id
349
+ name
350
+ slug
351
+ }
352
+ }
353
+ totalItems
354
+ }
355
+ }
356
+ `);
357
+
358
+ const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
359
+ customFieldsConfig.set('Collection', [
360
+ { name: 'featuredProducts', type: 'relation', list: true, scalarFields: ['id', 'name'] },
361
+ { name: 'alternativeAsset', type: 'relation', list: false, scalarFields: ['id', 'name'] },
362
+ ]);
363
+ customFieldsConfig.set('Asset', [
364
+ { name: 'assetCustomField1', type: 'string', list: false },
365
+ { name: 'assetCustomField2', type: 'string', list: false },
366
+ ]);
367
+
368
+ const result = addCustomFields(documentNode, { customFieldsMap: customFieldsConfig });
369
+ const printed = print(result);
370
+
371
+ // Should add customFields to top-level Collection (items)
372
+ expect(printed).toContain('customFields {');
373
+ expect(printed).toContain('featuredProducts {');
374
+ expect(printed).toContain('alternativeAsset {');
375
+
376
+ // Should NOT add customFields to nested Collection entities (children, breadcrumbs)
377
+ const childrenMatch = printed.match(/children\s*\{[^}]+\}/s);
378
+ const breadcrumbsMatch = printed.match(/breadcrumbs\s*\{[^}]+\}/s);
379
+
380
+ expect(childrenMatch).toBeTruthy();
381
+ expect(breadcrumbsMatch).toBeTruthy();
382
+ expect(childrenMatch![0]).not.toContain('customFields');
383
+ expect(breadcrumbsMatch![0]).not.toContain('customFields');
384
+
385
+ // Should NOT add customFields to nested Asset entity (featuredAsset)
386
+ const featuredAssetMatch = printed.match(/featuredAsset\s*\{[^}]+\}/s);
387
+ expect(featuredAssetMatch).toBeTruthy();
388
+ expect(featuredAssetMatch![0]).not.toContain('customFields');
389
+ expect(featuredAssetMatch![0]).not.toContain('assetCustomField1');
390
+ });
391
+
392
+ it('Should NOT add custom fields to fragments that are only used in nested contexts', () => {
393
+ const assetFragment = graphql(`
394
+ fragment Asset on Asset {
395
+ id
396
+ name
397
+ preview
398
+ }
399
+ `);
400
+
401
+ const documentNode = graphql(
402
+ `
403
+ query ProductList($options: ProductListOptions) {
404
+ products(options: $options) {
405
+ items {
406
+ id
407
+ name
408
+ featuredAsset {
409
+ ...Asset
410
+ }
411
+ variants {
412
+ id
413
+ name
414
+ assets {
415
+ ...Asset
416
+ }
417
+ }
418
+ }
419
+ totalItems
420
+ }
421
+ }
422
+ `,
423
+ [assetFragment],
424
+ );
425
+
426
+ const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
427
+ customFieldsConfig.set('Product', [{ name: 'productCustomField', type: 'string', list: false }]);
428
+ customFieldsConfig.set('Asset', [{ name: 'assetCustomField', type: 'string', list: false }]);
429
+ customFieldsConfig.set('ProductVariant', [
430
+ { name: 'variantCustomField', type: 'string', list: false },
431
+ ]);
432
+
433
+ const result = addCustomFields(documentNode, { customFieldsMap: customFieldsConfig });
434
+ const printed = print(result);
435
+
436
+ // Should add customFields to Product (top-level query entity)
437
+ expect(printed).toContain('customFields {');
438
+ expect(printed).toContain('productCustomField');
439
+
440
+ // Should NOT add customFields to Asset fragment (only used in nested contexts)
441
+ expect(printed).not.toMatch(/fragment Asset on Asset\s*\{[^}]*customFields/s);
442
+
443
+ // Should NOT add customFields to nested ProductVariant entities
444
+ const variantsMatch = printed.match(/variants\s*\{[^}]+\}/s);
445
+ expect(variantsMatch).toBeTruthy();
446
+ expect(variantsMatch![0]).not.toContain('customFields');
447
+ expect(variantsMatch![0]).not.toContain('variantCustomField');
448
+ });
449
+
450
+ it('Should add custom fields to fragments used at top level', () => {
451
+ const productFragment = graphql(`
452
+ fragment ProductDetails on Product {
453
+ id
454
+ name
455
+ slug
456
+ }
457
+ `);
458
+
459
+ const documentNode = graphql(
460
+ `
461
+ query ProductList($options: ProductListOptions) {
462
+ products(options: $options) {
463
+ items {
464
+ ...ProductDetails
465
+ featuredAsset {
466
+ id
467
+ preview
468
+ }
469
+ }
470
+ totalItems
471
+ }
472
+ }
473
+ `,
474
+ [productFragment],
475
+ );
476
+
477
+ const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
478
+ customFieldsConfig.set('Product', [{ name: 'productCustomField', type: 'string', list: false }]);
479
+ customFieldsConfig.set('Asset', [{ name: 'assetCustomField', type: 'string', list: false }]);
480
+
481
+ const result = addCustomFields(documentNode, { customFieldsMap: customFieldsConfig });
482
+ const printed = print(result);
483
+
484
+ // Should add customFields to ProductDetails fragment (used at top level in items)
485
+ expect(printed).toContain('fragment ProductDetails on Product');
486
+ expect(printed).toContain('productCustomField');
487
+
488
+ // Should NOT add customFields to featuredAsset (nested entity)
489
+ const featuredAssetMatch = printed.match(/featuredAsset\s*\{[^}]+\}/s);
490
+ expect(featuredAssetMatch).toBeTruthy();
491
+ expect(featuredAssetMatch![0]).not.toContain('customFields');
492
+ });
493
+
494
+ it('Should handle complex nested structure with multiple entity types', () => {
495
+ const documentNode = graphql(`
496
+ query ComplexQuery {
497
+ orders {
498
+ items {
499
+ id
500
+ code
501
+ customer {
502
+ id
503
+ firstName
504
+ addresses {
505
+ id
506
+ streetLine1
507
+ country {
508
+ id
509
+ name
510
+ }
511
+ }
512
+ }
513
+ lines {
514
+ id
515
+ productVariant {
516
+ id
517
+ name
518
+ product {
519
+ id
520
+ name
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }
526
+ }
527
+ `);
528
+
529
+ const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
530
+ customFieldsConfig.set('Order', [{ name: 'orderCustomField', type: 'string', list: false }]);
531
+ customFieldsConfig.set('Customer', [
532
+ { name: 'customerCustomField', type: 'string', list: false },
533
+ ]);
534
+ customFieldsConfig.set('Address', [{ name: 'addressCustomField', type: 'string', list: false }]);
535
+ customFieldsConfig.set('Product', [{ name: 'productCustomField', type: 'string', list: false }]);
536
+
537
+ const result = addCustomFields(documentNode, { customFieldsMap: customFieldsConfig });
538
+ const printed = print(result);
539
+
540
+ // Should only add customFields to top-level Order entity
541
+ expect(printed).toContain('customFields {');
542
+ expect(printed).toContain('orderCustomField');
543
+
544
+ // Should NOT add customFields to any nested entities
545
+ expect(printed).not.toMatch(/customer\s*\{[^}]*customFields/s);
546
+ expect(printed).not.toMatch(/addresses\s*\{[^}]*customFields/s);
547
+ expect(printed).not.toMatch(/country\s*\{[^}]*customFields/s);
548
+ expect(printed).not.toMatch(/productVariant\s*\{[^}]*customFields/s);
549
+ expect(printed).not.toMatch(/product\s*\{[^}]*customFields/s);
550
+ });
551
+ });
242
552
  });