Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@makehq/forman-schema",
"version": "1.9.4",
"version": "1.9.5",
"description": "Forman Schema Tools",
"license": "MIT",
"author": "Make",
Expand Down
24 changes: 20 additions & 4 deletions src/forman.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import {
import { udttypeExpand, udttypeExtractInner, udttypeWrapRef } from './composites/udttype';
import { udtspecExpand, udtspecExtractInner, udtspecWrapRef } from './composites/udtspec';

/** Description applied to non-required select-like fields that accept an empty value. */
export const EMPTY_OPTION_DESCRIPTION = 'Optional field, can be left empty.';

/**
* Context for schema conversion operations
*/
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,21 @@ 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];
}
if (result.default === undefined) {
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