Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions src/forman.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { JSONSchema7, JSONSchema7Definition, JSONSchema7TypeName } from 'json-schema';

/** Description applied to non-required select-like fields that accept an empty value. */
export const EMPTY_OPTION_DESCRIPTION = 'Optional field, can be left empty.';
import type {
FormanSchemaField,
FormanSchemaValue,
Expand Down Expand Up @@ -567,17 +570,15 @@ function handleSelectOrPathType(
return optionOrGroup as FormanSchemaOption;
});

// For non-required selects with placeholder.nested, inject placeholder as an artificial null-value option.
// null is used (not undefined) because it's a valid JSON Schema const value and FormanSchemaValue.
// For non-required selects with placeholder.nested, inject placeholder as an artificial empty-string option.
if (field.type === 'select' && !field.required && isObject<FormanSchemaExtendedOptions>(field.options)) {
const placeholder = field.options.placeholder;
if (isObject<{ label: string; nested?: FormanSchemaNested }>(placeholder) && placeholder.nested) {
(options ||= []).push({
value: null,
value: '',
label: placeholder.label,
nested: placeholder.nested,
});
result.type = [result.type as JSONSchema7TypeName, 'null'];
}
}

Expand Down Expand Up @@ -651,6 +652,19 @@ function handleSelectOrPathType(
}
}

// For non-required select-like fields, prepend '' as a valid "no selection" option and set default.
if (!field.required) {
if (result.enum && !result.enum.includes('')) {
result.enum = ['', ...result.enum];
} else if (result.oneOf && !result.oneOf.some((entry): entry is JSONSchema7 => isObject(entry) && entry.const === '')) {
result.oneOf = [{ title: 'Empty', const: '' }, ...result.oneOf];
}
result.default = '';
if (!result.description) {
result.description = EMPTY_OPTION_DESCRIPTION;
}
}

result = handleNestedWithDomain(field, nested, domain, result, context);

if (field.rpc) result = processRpcDirective(field, result, context);
Expand Down
18 changes: 12 additions & 6 deletions src/json.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { JSONSchema7 } from 'json-schema';
import { noEmpty, isObject } from './utils';
import type { FormanSchemaField, FormanSchemaFieldType, FormanSchemaValue } from './types';
import { EMPTY_OPTION_DESCRIPTION } from './forman';
import { udttypeCollapse } from './composites/udttype';
import { udtspecCollapse } from './composites/udtspec';

Expand Down Expand Up @@ -102,10 +103,11 @@ export function toFormanSchema(field: JSONSchema7): FormanSchemaField {
// If the field is flagged as path selector field root, then short-circuit to it
const pathInfo = Object.getOwnPropertyDescriptor(field, 'x-path');
if (pathInfo) {
const help = field.description === EMPTY_OPTION_DESCRIPTION ? undefined : noEmpty(field.description);
return {
type: pathInfo.value.type,
label: noEmpty(field.title),
help: noEmpty(field.description),
help,
options: {
store: Object.getOwnPropertyDescriptor(field, 'x-fetch')?.value,
showRoot: pathInfo.value.showRoot,
Expand All @@ -115,20 +117,24 @@ export function toFormanSchema(field: JSONSchema7): FormanSchemaField {
}
if (field.enum) {
// For strings with enum, create a select type
const help = field.description === EMPTY_OPTION_DESCRIPTION ? undefined : noEmpty(field.description);
return {
type: 'select',
label: noEmpty(field.title),
help: noEmpty(field.description),
options: field.enum.map(value => ({ value: value as FormanSchemaValue })),
help,
options: field.enum
.filter(value => value !== '')
.map(value => ({ value: value as FormanSchemaValue })),
};
} else if (field.oneOf) {
// For strings with enum, create a select type
// For strings with oneOf, create a select type
const help = field.description === EMPTY_OPTION_DESCRIPTION ? undefined : noEmpty(field.description);
return {
type: 'select',
label: noEmpty(field.title),
help: noEmpty(field.description),
help,
options: field.oneOf
.filter(value => value)
.filter(value => value && (value as JSONSchema7).const !== '')
.map(value => ({ value: (value as JSONSchema7).const as FormanSchemaValue })),
};
}
Expand Down
8 changes: 4 additions & 4 deletions src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,8 @@ async function validateFormanValue(
};
}

if (value == null) {
// When a select field has placeholder.nested, null means "no selection" and we need to validate the nested fields
if (value == null || value === '') {
// When a select field has placeholder.nested, null/'' means "no selection" and we need to validate the nested fields
if (normalizedField.type === 'select' && hasPlaceholderNested(normalizedField)) {
return handleSelectType(value, normalizedField, context);
}
Expand Down Expand Up @@ -908,8 +908,8 @@ async function handleSelectType(
});
}
}
} else if (value == null && hasPlaceholderNested(field)) {
// Null value with placeholder.nested: treat as valid "no selection" and use placeholder's nested
} else if ((value == null || value === '') && hasPlaceholderNested(field)) {
// Null/empty value with placeholder.nested: treat as valid "no selection" and use placeholder's nested
const placeholder = (field.options as FormanSchemaExtendedOptions).placeholder as {
label: string;
nested?: FormanSchemaNested;
Expand Down
5 changes: 5 additions & 0 deletions test/composites/__snapshots__/udttype.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,12 @@ exports[`udttype composite Forman -> JSON Schema should convert schema with udtt
"x-composite": "udtspec",
},
"udttype": {
"default": "",
"oneOf": [
{
"const": "",
"title": "Empty",
},
{
"const": "array",
"title": "Array",
Expand Down
5 changes: 3 additions & 2 deletions test/composites/udttype.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ describe('udttype composite', () => {
expect(udttypeDef.type).toBe('string');
expect(udttypeDef.title).toBeUndefined();
expect(udttypeDef.oneOf).toBeDefined();
expect(udttypeDef.oneOf!.length).toBe(7);
expect(udttypeDef.oneOf!.length).toBe(8);
expect(udttypeDef.default).toBe('');

const optionValues = udttypeDef.oneOf!.map(o => (o as JSONSchema7).const);
expect(optionValues).toEqual(['array', 'collection', 'date', 'text', 'number', 'boolean', 'buffer']);
expect(optionValues).toEqual(['', 'array', 'collection', 'date', 'text', 'number', 'boolean', 'buffer']);
});

it('should produce conditional nested fields via allOf on parent', () => {
Expand Down
24 changes: 24 additions & 0 deletions test/directives/grouped.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,13 @@ describe('Grouped', () => {
expect(jsonSchema).toEqual({
properties: {
groupedSelect: {
default: '',
description: 'Optional field, can be left empty.',
oneOf: [
{
const: '',
title: 'Empty',
},
{
const: 'alpha',
title: 'Phonetic: Alpha',
Expand All @@ -110,7 +116,13 @@ describe('Grouped', () => {
type: 'string',
},
partiallyGroupedSelect: {
default: '',
description: 'Optional field, can be left empty.',
oneOf: [
{
const: '',
title: 'Empty',
},
{
const: 'alpha',
title: 'Alpha',
Expand All @@ -132,7 +144,13 @@ describe('Grouped', () => {
type: 'string',
},
select: {
default: '',
description: 'Optional field, can be left empty.',
oneOf: [
{
const: '',
title: 'Empty',
},
{
const: 'alpha',
title: 'Alpha',
Expand All @@ -146,7 +164,13 @@ describe('Grouped', () => {
type: 'string',
},
wronglyPartiallyGroupedSelect: {
default: '',
description: 'Optional field, can be left empty.',
oneOf: [
{
const: '',
title: 'Empty',
},
{
const: 'alpha',
title: 'Alpha',
Expand Down
8 changes: 8 additions & 0 deletions test/directives/nested.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ describe('Nested', () => {
nestedSelect: {
title: 'Nested Select',
type: 'string',
default: '',
description: 'Optional field, can be left empty.',
'x-fetch': 'rpc://dropdownExplorer',
'x-nested': {
$ref: 'rpc://renderFields?nestedSelect={{nestedSelect}}',
Expand Down Expand Up @@ -408,7 +410,10 @@ describe('Nested', () => {
booleanSelect: {
title: 'Boolean Select',
type: 'string',
default: '',
description: 'Optional field, can be left empty.',
oneOf: [
{ title: 'Empty', const: '' },
{ title: 'TRUE', const: true },
{ title: 'FALSE', const: false },
],
Expand All @@ -419,7 +424,10 @@ describe('Nested', () => {
mergedSelect: {
title: 'Merged Select',
type: 'string',
default: '',
description: 'Optional field, can be left empty.',
oneOf: [
{ title: 'Empty', const: '' },
{ title: 'TRUE', const: true },
{ title: 'FALSE', const: false },
],
Expand Down
8 changes: 8 additions & 0 deletions test/directives/rpc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ describe('RPC', () => {
select: {
title: 'Select',
type: 'string',
default: '',
description: 'Optional field, can be left empty.',
'x-fetch': 'rpc://searchEntries?parent={{parent}}',
'x-search': {
inputSchema: {
Expand Down Expand Up @@ -134,7 +136,13 @@ describe('RPC', () => {
],
properties: {
parent: {
default: '',
description: 'Optional field, can be left empty.',
oneOf: [
{
const: '',
title: 'Empty',
},
{
const: 'show',
title: 'Show',
Expand Down
Loading