@vendure/dashboard 3.6.4-master-202605290309 → 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.
- package/package.json +3 -3
- package/src/app/common/duplicate-entity-dialog.tsx +2 -1
- package/src/app/routes/_authenticated/_orders/components/fulfill-order-dialog.tsx +2 -1
- package/src/app/routes/_authenticated/_products/components/generate-variants-panel.tsx +133 -32
- package/src/i18n/locales/ar.po +97 -78
- package/src/i18n/locales/bg.po +97 -78
- package/src/i18n/locales/cs.po +97 -78
- package/src/i18n/locales/de.po +97 -78
- package/src/i18n/locales/en.po +97 -78
- package/src/i18n/locales/es.po +97 -78
- package/src/i18n/locales/fa.po +97 -78
- package/src/i18n/locales/fr.po +97 -78
- package/src/i18n/locales/he.po +97 -78
- package/src/i18n/locales/hr.po +97 -78
- package/src/i18n/locales/hu.po +97 -78
- package/src/i18n/locales/it.po +97 -78
- package/src/i18n/locales/ja.po +97 -78
- package/src/i18n/locales/nb.po +97 -78
- package/src/i18n/locales/ne.po +97 -78
- package/src/i18n/locales/nl.po +97 -78
- package/src/i18n/locales/pl.po +97 -78
- package/src/i18n/locales/pt_BR.po +97 -78
- package/src/i18n/locales/pt_PT.po +97 -78
- package/src/i18n/locales/ro.po +97 -78
- package/src/i18n/locales/ru.po +97 -78
- package/src/i18n/locales/sv.po +97 -78
- package/src/i18n/locales/tr.po +97 -78
- package/src/i18n/locales/uk.po +97 -78
- package/src/i18n/locales/zh_Hans.po +97 -78
- package/src/i18n/locales/zh_Hant.po +97 -78
- package/src/lib/components/data-input/affixed-input.tsx +2 -0
- package/src/lib/components/data-input/default-relation-input.tsx +60 -0
- package/src/lib/components/data-input/select-with-options.tsx +12 -5
- package/src/lib/components/shared/configurable-operation-multi-selector.tsx +2 -1
- package/src/lib/components/shared/configurable-operation-selector.tsx +2 -1
- package/src/lib/components/shared/configurable-operation-utils.spec.ts +49 -0
- package/src/lib/components/shared/configurable-operation-utils.ts +18 -0
- package/src/lib/framework/form-engine/form-schema-tools.spec.ts +39 -0
- package/src/lib/framework/form-engine/form-schema-tools.ts +72 -2
- package/src/lib/framework/form-engine/use-generated-form.tsx +13 -10
- package/src/lib/framework/form-engine/utils.spec.ts +50 -0
- package/src/lib/framework/form-engine/utils.ts +14 -0
- 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
|
|
87
|
+
const isNullable = isFieldNullable(fieldDef);
|
|
88
|
+
const selectValue = isNullable ? (value == null || value === '' ? null : value) : (value ?? '');
|
|
87
89
|
|
|
88
|
-
const handleValueChange = (
|
|
89
|
-
if (
|
|
90
|
-
onChange(
|
|
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={
|
|
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
|
|
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
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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.
|
|
@@ -135,6 +135,9 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
135
135
|
}
|
|
136
136
|
}, [settings.activeChannelId, currentUserData?.me?.channels]);
|
|
137
137
|
|
|
138
|
+
// Determine isAuthenticated from currentUserData
|
|
139
|
+
const isAuthenticated = !!currentUserData?.me?.id;
|
|
140
|
+
|
|
138
141
|
// Auth actions
|
|
139
142
|
const login = React.useCallback(
|
|
140
143
|
(username: string, password: string, onLoginSuccess?: () => void) => {
|
|
@@ -187,26 +190,62 @@ export function AuthProvider({ children }: Readonly<{ children: React.ReactNode
|
|
|
187
190
|
|
|
188
191
|
const logout = React.useCallback(
|
|
189
192
|
async (onLogoutSuccess?: () => void) => {
|
|
193
|
+
// Clear any stale error from a previous logout attempt. Matches the
|
|
194
|
+
// login() pattern (line 149 below clears it on the success path)
|
|
195
|
+
// and ensures UI doesn't keep rendering a previous failure's
|
|
196
|
+
// message during a retry.
|
|
197
|
+
setAuthenticationError(undefined);
|
|
190
198
|
setIsLoginLogoutInProgress(true);
|
|
191
199
|
setStatus('verifying');
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
200
|
+
// Try block scoped to the mutation call only. Exceptions thrown
|
|
201
|
+
// by the success-branch side-effects below (queryClient.clear,
|
|
202
|
+
// localStorage.removeItem, onLogoutSuccess) propagate naturally
|
|
203
|
+
// rather than being misclassified as transport failures.
|
|
204
|
+
let data;
|
|
205
|
+
try {
|
|
206
|
+
data = await api.mutate(LogOutMutation)({});
|
|
207
|
+
} catch (error) {
|
|
208
|
+
// Network/server failure. Transport failure doesn't tell us
|
|
209
|
+
// whether the server applied the logout, so refetch the
|
|
210
|
+
// current user to determine actual state rather than trusting
|
|
211
|
+
// the cached isAuthenticated snapshot (staleTime: Infinity
|
|
212
|
+
// means react-query won't auto-refetch this query).
|
|
213
|
+
setAuthenticationError(error instanceof Error ? error.message : String(error));
|
|
214
|
+
const { data: refreshedData, error: refreshedError } = await refetchCurrentUser();
|
|
215
|
+
if (refreshedError || !refreshedData?.me?.id) {
|
|
198
216
|
setStatus('unauthenticated');
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
} else {
|
|
218
|
+
setStatus('authenticated');
|
|
201
219
|
}
|
|
202
|
-
|
|
220
|
+
setIsLoginLogoutInProgress(false);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (data?.logout.success) {
|
|
225
|
+
// Clear all cached queries to prevent stale data
|
|
226
|
+
queryClient.clear();
|
|
227
|
+
try {
|
|
228
|
+
// localStorage can throw (quota exceeded, Safari private
|
|
229
|
+
// mode, security errors, storage disabled). The server-side
|
|
230
|
+
// logout already succeeded — don't let storage cleanup
|
|
231
|
+
// failure block the UI state transition below.
|
|
232
|
+
localStorage.removeItem(LS_KEY_SELECTED_CHANNEL_TOKEN);
|
|
233
|
+
} catch {
|
|
234
|
+
// intentionally swallowed — see comment above
|
|
235
|
+
}
|
|
236
|
+
setStatus('unauthenticated');
|
|
237
|
+
setIsLoginLogoutInProgress(false);
|
|
238
|
+
onLogoutSuccess?.();
|
|
239
|
+
} else {
|
|
240
|
+
// Server responded but reported success=false. Restore the
|
|
241
|
+
// pre-logout authenticated status so the UI is interactive again.
|
|
242
|
+
setStatus(isAuthenticated ? 'authenticated' : 'unauthenticated');
|
|
243
|
+
setIsLoginLogoutInProgress(false);
|
|
244
|
+
}
|
|
203
245
|
},
|
|
204
|
-
[queryClient],
|
|
246
|
+
[queryClient, isAuthenticated, refetchCurrentUser],
|
|
205
247
|
);
|
|
206
248
|
|
|
207
|
-
// Determine isAuthenticated from currentUserData
|
|
208
|
-
const isAuthenticated = !!currentUserData?.me?.id;
|
|
209
|
-
|
|
210
249
|
// Handle status transitions based on query state
|
|
211
250
|
React.useEffect(() => {
|
|
212
251
|
// Don't change status if we're in the middle of login/logout
|