@vendure/dashboard 3.6.4-master-202605280309 → 3.6.4

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 (45) hide show
  1. package/package.json +3 -3
  2. package/src/app/common/duplicate-entity-dialog.tsx +2 -1
  3. package/src/app/routes/_authenticated/_orders/components/fulfill-order-dialog.tsx +2 -1
  4. package/src/app/routes/_authenticated/_products/components/generate-variants-panel.tsx +133 -32
  5. package/src/i18n/locales/ar.po +97 -78
  6. package/src/i18n/locales/bg.po +97 -78
  7. package/src/i18n/locales/cs.po +97 -78
  8. package/src/i18n/locales/de.po +97 -78
  9. package/src/i18n/locales/en.po +97 -78
  10. package/src/i18n/locales/es.po +97 -78
  11. package/src/i18n/locales/fa.po +97 -78
  12. package/src/i18n/locales/fr.po +97 -78
  13. package/src/i18n/locales/he.po +97 -78
  14. package/src/i18n/locales/hr.po +97 -78
  15. package/src/i18n/locales/hu.po +97 -78
  16. package/src/i18n/locales/it.po +97 -78
  17. package/src/i18n/locales/ja.po +97 -78
  18. package/src/i18n/locales/nb.po +97 -78
  19. package/src/i18n/locales/ne.po +97 -78
  20. package/src/i18n/locales/nl.po +97 -78
  21. package/src/i18n/locales/pl.po +97 -78
  22. package/src/i18n/locales/pt_BR.po +97 -78
  23. package/src/i18n/locales/pt_PT.po +97 -78
  24. package/src/i18n/locales/ro.po +97 -78
  25. package/src/i18n/locales/ru.po +97 -78
  26. package/src/i18n/locales/sv.po +97 -78
  27. package/src/i18n/locales/tr.po +97 -78
  28. package/src/i18n/locales/uk.po +97 -78
  29. package/src/i18n/locales/zh_Hans.po +97 -78
  30. package/src/i18n/locales/zh_Hant.po +97 -78
  31. package/src/lib/components/data-input/affixed-input.tsx +2 -0
  32. package/src/lib/components/data-input/default-relation-input.tsx +60 -0
  33. package/src/lib/components/data-input/select-with-options.tsx +12 -5
  34. package/src/lib/components/shared/configurable-operation-multi-selector.tsx +2 -1
  35. package/src/lib/components/shared/configurable-operation-selector.tsx +2 -1
  36. package/src/lib/components/shared/configurable-operation-utils.spec.ts +49 -0
  37. package/src/lib/components/shared/configurable-operation-utils.ts +18 -0
  38. package/src/lib/framework/form-engine/form-schema-tools.spec.ts +39 -0
  39. package/src/lib/framework/form-engine/form-schema-tools.ts +72 -2
  40. package/src/lib/framework/form-engine/use-generated-form.tsx +13 -10
  41. package/src/lib/framework/form-engine/utils.spec.ts +50 -0
  42. package/src/lib/framework/form-engine/utils.ts +14 -0
  43. package/src/lib/lib/utils.spec.ts +253 -0
  44. package/src/lib/lib/utils.ts +40 -9
  45. package/src/lib/providers/auth.tsx +52 -13
@@ -548,6 +548,66 @@ const createEntityConfigs = (i18n: any) => ({
548
548
  );
549
549
  },
550
550
  }),
551
+
552
+ Administrator: createRelationSelectorConfig({
553
+ idKey: 'id',
554
+ labelKey: 'lastName',
555
+ placeholder: i18n`Search administrator...`,
556
+ // Match the search term against any of the name/email fields independently
557
+ buildSearchFilter: (term: string) => ({
558
+ _or: [
559
+ { firstName: { contains: term } },
560
+ { lastName: { contains: term } },
561
+ { emailAddress: { contains: term } },
562
+ ],
563
+ }),
564
+ listQuery: graphql(`
565
+ query GetAdministratorsForRelationSelector($options: AdministratorListOptions) {
566
+ administrators(options: $options) {
567
+ items {
568
+ id
569
+ firstName
570
+ lastName
571
+ emailAddress
572
+ }
573
+ totalItems
574
+ }
575
+ }
576
+ `),
577
+ label: (item: any) => (
578
+ <EntityLabel
579
+ title={`${item.firstName} ${item.lastName}`}
580
+ subtitle={item.emailAddress}
581
+ placeholderLetter={item.firstName?.[0]?.toUpperCase() || 'A'}
582
+ rounded
583
+ tooltipText={`${item.firstName} ${item.lastName} (${item.emailAddress})`}
584
+ />
585
+ ),
586
+ }),
587
+
588
+ Seller: createRelationSelectorConfig({
589
+ ...createBaseEntityConfig('Seller', i18n),
590
+ listQuery: graphql(`
591
+ query GetSellersForRelationSelector($options: SellerListOptions) {
592
+ sellers(options: $options) {
593
+ items {
594
+ id
595
+ name
596
+ }
597
+ totalItems
598
+ }
599
+ }
600
+ `),
601
+ label: (item: any) => (
602
+ <EntityLabel
603
+ title={item.name}
604
+ subtitle={''}
605
+ placeholderLetter={item.name?.[0]?.toUpperCase() || 'S'}
606
+ rounded
607
+ tooltipText={item.name}
608
+ />
609
+ ),
610
+ }),
551
611
  });
552
612
 
553
613
  type DefaultRelationInputProps = DashboardFormComponentProps & {
@@ -8,6 +8,7 @@ import {
8
8
  } from '@/vdb/framework/form-engine/form-engine-types.js';
9
9
  import {
10
10
  extractFieldOptions,
11
+ isFieldNullable,
11
12
  isReadonlyField,
12
13
  isStringFieldWithOptions,
13
14
  isStringStructFieldWithOptions,
@@ -83,24 +84,30 @@ export function SelectWithOptions({
83
84
  }
84
85
 
85
86
  // For single fields, use regular Select
86
- const currentValue = value ?? '';
87
+ const isNullable = isFieldNullable(fieldDef);
88
+ const selectValue = isNullable ? (value == null || value === '' ? null : value) : (value ?? '');
87
89
 
88
- const handleValueChange = (value: string) => {
89
- if (value) {
90
- onChange(value);
90
+ const handleValueChange = (newValue: string | null) => {
91
+ if (isNullable) {
92
+ onChange(newValue ?? null);
93
+ } else if (newValue) {
94
+ onChange(newValue);
91
95
  }
92
96
  };
93
97
 
94
98
  const selectItems = Object.fromEntries(
95
99
  options.map(option => [option.value, option.label ? getTranslation(option.label) : option.value]),
96
100
  );
101
+ // Add a null item for nullable selects
102
+ if (isNullable) selectItems.null = '';
97
103
 
98
104
  return (
99
- <Select value={currentValue} onValueChange={handleValueChange} disabled={readOnly} items={selectItems}>
105
+ <Select value={selectValue} onValueChange={handleValueChange} disabled={readOnly} items={selectItems}>
100
106
  <SelectTrigger className="mb-0">
101
107
  <SelectValue placeholder={placeholder || <Trans>Select an option</Trans>} />
102
108
  </SelectTrigger>
103
109
  <SelectContent>
110
+ {isNullable && <SelectItem value={null}>{'\u00a0'}</SelectItem>}
104
111
  {options.map(option => (
105
112
  <SelectItem key={option.value} value={option.value}>
106
113
  {option.label ? getTranslation(option.label) : option.value}
@@ -14,6 +14,7 @@ import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@v
14
14
  import { Plus } from 'lucide-react';
15
15
  import { useCallback, useEffect, useRef } from 'react';
16
16
  import { ConfigurableOperationInput } from './configurable-operation-input.js';
17
+ import { getInitialConfigArgValue } from './configurable-operation-utils.js';
17
18
 
18
19
  /**
19
20
  * Props interface for ConfigurableOperationMultiSelector component
@@ -178,7 +179,7 @@ export function ConfigurableOperationMultiSelector({
178
179
  code: operation.code,
179
180
  arguments: operationDef.args.map(arg => ({
180
181
  name: arg.name,
181
- value: arg.defaultValue != null ? arg.defaultValue.toString() : arg.list ? '[]' : '',
182
+ value: getInitialConfigArgValue(arg),
182
183
  })),
183
184
  },
184
185
  ]);
@@ -12,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
12
12
  import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
13
13
  import { Plus } from 'lucide-react';
14
14
  import { ConfigurableOperationInput } from './configurable-operation-input.js';
15
+ import { getInitialConfigArgValue } from './configurable-operation-utils.js';
15
16
 
16
17
  /**
17
18
  * Props interface for ConfigurableOperationSelector component
@@ -103,7 +104,7 @@ export function ConfigurableOperationSelector({
103
104
  code: operation.code,
104
105
  arguments: operationDef.args.map(arg => ({
105
106
  name: arg.name,
106
- value: arg.defaultValue != null ? arg.defaultValue.toString() : '',
107
+ value: getInitialConfigArgValue(arg),
107
108
  })),
108
109
  });
109
110
  };
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
4
+ import { getInitialConfigArgValue } from './configurable-operation-utils.js';
5
+
6
+ type ConfigArgDef = ConfigurableOperationDefFragment['args'][number];
7
+
8
+ const argDef = (overrides: Partial<ConfigArgDef>): ConfigArgDef =>
9
+ ({
10
+ name: 'test',
11
+ type: 'string',
12
+ required: true,
13
+ defaultValue: null,
14
+ list: false,
15
+ ui: null,
16
+ label: 'Test',
17
+ description: null,
18
+ ...overrides,
19
+ }) as ConfigArgDef;
20
+
21
+ describe('getInitialConfigArgValue', () => {
22
+ it('should initialize list args as JSON arrays', () => {
23
+ expect(getInitialConfigArgValue(argDef({ list: true }))).toBe('[]');
24
+ expect(getInitialConfigArgValue(argDef({ list: true, defaultValue: true }))).toBe('[true]');
25
+ });
26
+
27
+ it('should initialize boolean scalar args without defaults as false', () => {
28
+ expect(getInitialConfigArgValue(argDef({ type: 'boolean' }))).toBe('false');
29
+ });
30
+
31
+ it('should preserve scalar defaults and empty scalar fallback', () => {
32
+ expect(getInitialConfigArgValue(argDef({ defaultValue: 1 }))).toBe('1');
33
+ expect(getInitialConfigArgValue(argDef({ type: 'string' }))).toBe('');
34
+ });
35
+
36
+ // The `!= null` guard must treat falsy-but-defined defaults (0, false, '') as
37
+ // real values rather than absent. A naive truthy check (`if (arg.defaultValue)`)
38
+ // would drop these and fall through to the type-based fallbacks.
39
+ it('should preserve falsy-but-defined scalar defaults', () => {
40
+ expect(getInitialConfigArgValue(argDef({ type: 'int', defaultValue: 0 }))).toBe('0');
41
+ expect(getInitialConfigArgValue(argDef({ type: 'boolean', defaultValue: false }))).toBe('false');
42
+ expect(getInitialConfigArgValue(argDef({ type: 'string', defaultValue: '' }))).toBe('');
43
+ });
44
+
45
+ it('should wrap falsy-but-defined list defaults in a JSON array', () => {
46
+ expect(getInitialConfigArgValue(argDef({ list: true, defaultValue: 0 }))).toBe('[0]');
47
+ expect(getInitialConfigArgValue(argDef({ list: true, defaultValue: false }))).toBe('[false]');
48
+ });
49
+ });
@@ -0,0 +1,18 @@
1
+ import { ConfigurableOperationDefFragment } from '@/vdb/graphql/fragments.js';
2
+
3
+ type ConfigurableOperationArgDef = ConfigurableOperationDefFragment['args'][number];
4
+
5
+ export function getInitialConfigArgValue(arg: ConfigurableOperationArgDef): string {
6
+ if (arg.list) {
7
+ return arg.defaultValue != null ? JSON.stringify([arg.defaultValue]) : '[]';
8
+ }
9
+ if (arg.defaultValue != null) {
10
+ return arg.defaultValue.toString();
11
+ }
12
+ // Required boolean args render as an off switch by default, so persist the
13
+ // same explicit false value that the UI communicates to validation.
14
+ if (arg.type === 'boolean') {
15
+ return 'false';
16
+ }
17
+ return '';
18
+ }
@@ -2,6 +2,7 @@ import { FieldInfo } from '@/vdb/framework/document-introspection/get-document-s
2
2
  import { describe, expect, it } from 'vitest';
3
3
 
4
4
  import {
5
+ applyNullableSelectCustomFieldDefaults,
5
6
  createFormSchemaFromFields,
6
7
  getDefaultValuesFromFields,
7
8
  getZodTypeFromField,
@@ -643,6 +644,44 @@ describe('form-schema-tools', () => {
643
644
  const defaults = getDefaultValuesFromFields(fields, 'en');
644
645
  expect(defaults.value).toBe(expected);
645
646
  });
647
+
648
+ it('nullable string custom field with options should default to null', () => {
649
+ const fields: FieldInfo[] = [
650
+ createMockField('customFields', 'Object', false, false, [
651
+ createMockField('featureType', 'String', true),
652
+ ]),
653
+ ];
654
+ const customFieldConfigs = [
655
+ {
656
+ name: 'featureType',
657
+ type: 'string',
658
+ nullable: true,
659
+ readonly: false,
660
+ list: false,
661
+ options: [{ value: 'standard' }, { value: 'premium' }],
662
+ },
663
+ ] as any[];
664
+
665
+ const defaults = getDefaultValuesFromFields(fields, 'en', customFieldConfigs);
666
+ expect(defaults.customFields.featureType).toBeNull();
667
+ });
668
+
669
+ it('applyNullableSelectCustomFieldDefaults should normalize empty string to null', () => {
670
+ const values = { customFields: { featureType: '' } };
671
+ const customFieldConfigs = [
672
+ {
673
+ name: 'featureType',
674
+ type: 'string',
675
+ nullable: true,
676
+ readonly: false,
677
+ list: false,
678
+ options: [{ value: 'a' }],
679
+ },
680
+ ] as any[];
681
+
682
+ const result = applyNullableSelectCustomFieldDefaults(values, customFieldConfigs);
683
+ expect(result.customFields.featureType).toBeNull();
684
+ });
646
685
  });
647
686
 
648
687
  describe('createFormSchemaFromFields - edge cases', () => {
@@ -4,6 +4,7 @@ import {
4
4
  isScalarType,
5
5
  } from '@/vdb/framework/document-introspection/get-document-structure.js';
6
6
  import {
7
+ ConfigurableFieldDef,
7
8
  CustomFieldConfig,
8
9
  DateTimeCustomFieldConfig,
9
10
  FloatCustomFieldConfig,
@@ -12,6 +13,7 @@ import {
12
13
  StructCustomFieldConfig,
13
14
  StructField,
14
15
  } from '@/vdb/framework/form-engine/form-engine-types.js';
16
+ import { isStringFieldWithOptions, isStructCustomFieldConfig } from '@/vdb/framework/form-engine/utils.js';
15
17
  import { z, ZodRawShape, ZodType, ZodTypeAny } from '@/vdb/lib/zod.js';
16
18
 
17
19
  function mapGraphQLCustomFieldToConfig(field: StructField) {
@@ -335,7 +337,11 @@ export function createFormSchemaFromFields(
335
337
  return z.object(schemaConfig);
336
338
  }
337
339
 
338
- export function getDefaultValuesFromFields(fields: FieldInfo[], defaultLanguageCode?: string) {
340
+ export function getDefaultValuesFromFields(
341
+ fields: FieldInfo[],
342
+ defaultLanguageCode?: string,
343
+ customFieldConfigs?: CustomFieldConfig[],
344
+ ) {
339
345
  const defaultValues: Record<string, any> = {};
340
346
  for (const field of fields) {
341
347
  if (field.typeInfo) {
@@ -349,7 +355,71 @@ export function getDefaultValuesFromFields(fields: FieldInfo[], defaultLanguageC
349
355
  defaultValues[field.name] = getDefaultValueFromField(field, defaultLanguageCode);
350
356
  }
351
357
  }
352
- return defaultValues;
358
+ return applyNullableSelectCustomFieldDefaults(defaultValues, customFieldConfigs);
359
+ }
360
+
361
+ /**
362
+ * Nullable string fields with options should default to `null`, not `''`.
363
+ */
364
+ export function applyNullableSelectCustomFieldDefaults<T extends Record<string, any>>(
365
+ values: T,
366
+ customFieldConfigs?: CustomFieldConfig[],
367
+ ): T {
368
+ if (!customFieldConfigs?.length || values.customFields == null) return values;
369
+
370
+ return {
371
+ ...values,
372
+ customFields: applyNullableSelectDefaultsToCustomFieldsObject(
373
+ values.customFields,
374
+ customFieldConfigs,
375
+ ),
376
+ };
377
+ }
378
+
379
+ function applyNullableSelectDefaultsToCustomFieldsObject(
380
+ customFieldsDefaults: Record<string, any>,
381
+ configs: CustomFieldConfig[],
382
+ ): Record<string, any> {
383
+ const result = { ...customFieldsDefaults };
384
+ for (const config of configs) {
385
+ const fieldDef = config as ConfigurableFieldDef;
386
+ if (isStructCustomFieldConfig(fieldDef)) {
387
+ const structValue = result[fieldDef.name];
388
+ if (structValue && typeof structValue === 'object' && !Array.isArray(structValue)) {
389
+ result[fieldDef.name] = applyNullableSelectDefaultsToStructFields(
390
+ structValue,
391
+ fieldDef.fields,
392
+ );
393
+ }
394
+ } else if (
395
+ config.type === 'string' &&
396
+ isStringFieldWithOptions(config as ConfigurableFieldDef) &&
397
+ config.nullable !== false &&
398
+ (result[config.name] === '' || result[config.name] === undefined)
399
+ ) {
400
+ result[config.name] = null;
401
+ }
402
+ }
403
+ return result;
404
+ }
405
+
406
+ function applyNullableSelectDefaultsToStructFields(
407
+ structDefaults: Record<string, any>,
408
+ fields: StructField[],
409
+ ): Record<string, any> {
410
+ const result = { ...structDefaults };
411
+ for (const field of fields) {
412
+ const subFieldConfig = mapGraphQLCustomFieldToConfig(field);
413
+ if (
414
+ subFieldConfig.type === 'string' &&
415
+ isStringFieldWithOptions(subFieldConfig as ConfigurableFieldDef) &&
416
+ subFieldConfig.nullable !== false &&
417
+ (result[field.name] === '' || result[field.name] === undefined)
418
+ ) {
419
+ result[field.name] = null;
420
+ }
421
+ }
422
+ return result;
353
423
  }
354
424
 
355
425
  export function getDefaultValueFromField(field: FieldInfo, defaultLanguageCode?: string) {
@@ -6,7 +6,11 @@ import { useForm } from 'react-hook-form';
6
6
  import { useChannel } from '../../hooks/use-channel.js';
7
7
  import { useServerConfig } from '../../hooks/use-server-config.js';
8
8
  import { getOperationVariablesFields } from '../document-introspection/get-document-structure.js';
9
- import { createFormSchemaFromFields, getDefaultValuesFromFields } from './form-schema-tools.js';
9
+ import {
10
+ applyNullableSelectCustomFieldDefaults,
11
+ createFormSchemaFromFields,
12
+ getDefaultValuesFromFields,
13
+ } from './form-schema-tools.js';
10
14
  import {
11
15
  convertEmptyStringsToNull,
12
16
  removeEmptyIdFields,
@@ -133,8 +137,8 @@ export function useGeneratedForm<
133
137
  [updateFields, customFieldConfig],
134
138
  );
135
139
  const defaultValues = useMemo(
136
- () => getDefaultValuesFromFields(updateFields, activeChannel?.defaultLanguageCode),
137
- [updateFields, activeChannel?.defaultLanguageCode],
140
+ () => getDefaultValuesFromFields(updateFields, activeChannel?.defaultLanguageCode, customFieldConfig),
141
+ [updateFields, activeChannel?.defaultLanguageCode, customFieldConfig],
138
142
  );
139
143
  const processedEntity = useMemo(
140
144
  () => ensureTranslationsForAllLanguages(entity, availableLanguages, defaultValues),
@@ -147,13 +151,12 @@ export function useGeneratedForm<
147
151
  [defaultValues, availableLanguages],
148
152
  );
149
153
 
150
- const values = useMemo(
151
- () =>
152
- processedEntity
153
- ? transformRelationFields(updateFields, setValuesRef.current(processedEntity))
154
- : processedDefaultValues,
155
- [processedEntity, processedDefaultValues, updateFields],
156
- );
154
+ const values = useMemo(() => {
155
+ const raw = processedEntity
156
+ ? transformRelationFields(updateFields, setValuesRef.current(processedEntity))
157
+ : processedDefaultValues;
158
+ return applyNullableSelectCustomFieldDefaults(raw, customFieldConfig);
159
+ }, [processedEntity, processedDefaultValues, updateFields, customFieldConfig]);
157
160
 
158
161
  const form = useForm({
159
162
  resolver: async (values, context, options) => {
@@ -5,10 +5,12 @@ import { FieldInfo, getOperationVariablesFields } from '../document-introspectio
5
5
 
6
6
  import {
7
7
  convertEmptyStringsToNull,
8
+ isFieldNullable,
8
9
  removeEmptyIdFields,
9
10
  stripNullNullableFields,
10
11
  transformRelationFields,
11
12
  } from './utils.js';
13
+ import { ConfigurableFieldDef } from "./form-engine-types.js";
12
14
 
13
15
  const createProductDocument = graphql(`
14
16
  mutation CreateProduct($input: CreateProductInput!) {
@@ -674,3 +676,51 @@ describe('stripNullNullableFields', () => {
674
676
  expect(result).toEqual({});
675
677
  });
676
678
  });
679
+
680
+ describe('isFieldNullable', () => {
681
+ it('should return true for nullable custom fields', () => {
682
+ expect(
683
+ isFieldNullable({
684
+ name: 'featureType',
685
+ type: 'string',
686
+ nullable: true,
687
+ readonly: false,
688
+ list: false,
689
+ } as ConfigurableFieldDef),
690
+ ).toBe(true);
691
+ });
692
+
693
+ it('should return false for non-nullable custom fields', () => {
694
+ expect(
695
+ isFieldNullable({
696
+ name: 'priority',
697
+ type: 'string',
698
+ nullable: false,
699
+ readonly: false,
700
+ list: false,
701
+ } as ConfigurableFieldDef),
702
+ ).toBe(false);
703
+ });
704
+
705
+ it('should return true for nullable struct sub-fields', () => {
706
+ expect(
707
+ isFieldNullable({
708
+ name: 'kind',
709
+ type: 'string',
710
+ nullable: true,
711
+ options: [{ value: 'a' }],
712
+ } as ConfigurableFieldDef),
713
+ ).toBe(true);
714
+ });
715
+
716
+ it('should return false for configurable operation args', () => {
717
+ expect(
718
+ isFieldNullable({
719
+ name: 'arg',
720
+ type: 'string',
721
+ list: false,
722
+ ui: { options: [{ value: 'a' }] },
723
+ } as ConfigurableFieldDef),
724
+ ).toBe(false);
725
+ });
726
+ });
@@ -444,6 +444,20 @@ export function isNullableField(input: ConfigurableFieldDef): boolean {
444
444
  return isCustomFieldConfig(input) && Boolean(input.nullable);
445
445
  }
446
446
 
447
+ /**
448
+ * Determines if a custom field or struct sub-field allows null values.
449
+ * Configurable operation args are never treated as nullable.
450
+ */
451
+ export function isFieldNullable(input: ConfigurableFieldDef | StructField): boolean {
452
+ if (isCustomFieldConfig(input as ConfigurableFieldDef)) {
453
+ return (input as ConfigurableFieldDef & { nullable?: boolean }).nullable !== false;
454
+ }
455
+ if ('nullable' in input && input.nullable) {
456
+ return true;
457
+ }
458
+ return false;
459
+ }
460
+
447
461
  /**
448
462
  * Handles nested form submission to prevent event bubbling in nested forms.
449
463
  * This is useful when you have a form inside a dialog that's within another form.