From 73042a1dd746596e846d7e20d672580b168dd109 Mon Sep 17 00:00:00 2001 From: martinforejt Date: Tue, 7 Oct 2025 20:30:52 +0200 Subject: [PATCH] feat(json_schemas): Number input property in input schema --- .../json_schemas/schemas/input.schema.json | 58 +++++++- test/input_schema.test.ts | 10 +- test/input_schema_definition.test.ts | 44 ++++++- test/utilities.client.test.ts | 124 +++++++++++++++++- 4 files changed, 223 insertions(+), 13 deletions(-) diff --git a/packages/json_schemas/schemas/input.schema.json b/packages/json_schemas/schemas/input.schema.json index b6b7c20df..8122da2e7 100644 --- a/packages/json_schemas/schemas/input.schema.json +++ b/packages/json_schemas/schemas/input.schema.json @@ -39,6 +39,7 @@ { "$ref": "#/definitions/arrayProperty" }, { "$ref": "#/definitions/objectProperty" }, { "$ref": "#/definitions/integerProperty" }, + { "$ref": "#/definitions/numberProperty" }, { "$ref": "#/definitions/booleanProperty" }, { "$ref": "#/definitions/resourceProperty" }, { "$ref": "#/definitions/resourceArrayProperty" }, @@ -434,6 +435,35 @@ "properties": { "default": { "type": ["integer", "null"] } } } }, + "numberProperty": { + "title": "Number property", + "type": "object", + "unevaluatedProperties": false, + "properties": { + "type": { "enum": ["number"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "prefill": { "type": "number" }, + "example": { "type": "number" }, + "nullable": { "type": "boolean" }, + "minimum": { "type": "number" }, + "maximum": { "type": "number" }, + "unit": { "type": "string" }, + "editor": { "enum": ["number", "hidden"] }, + "sectionCaption": { "type": "string" }, + "sectionDescription": { "type": "string" } + }, + "required": ["type", "title", "description"], + "if": { + "properties": { "nullable": { "const": false } } + }, + "then": { + "properties": { "default": { "type": "number" } } + }, + "else": { + "properties": { "default": { "type": ["number", "null"] } } + } + }, "booleanProperty": { "title": "Boolean property", "type": "object", @@ -586,7 +616,7 @@ "type": ["array"], "items": { "type": "string", - "enum": ["object", "array", "string", "integer", "boolean"] + "enum": ["object", "array", "string", "integer", "number", "boolean"] }, "uniqueItems": true, "additionalItems": false, @@ -594,8 +624,8 @@ }, "title": { "type": "string" }, "description": { "type": "string" }, - "prefill": { "type": ["object", "array", "string", "integer", "boolean"] }, - "example": { "type": ["object", "array", "string", "integer", "boolean"] }, + "prefill": { "type": ["object", "array", "string", "integer", "number", "boolean"] }, + "example": { "type": ["object", "array", "string", "integer", "number", "boolean"] }, "nullable": { "type": "boolean" }, "editor": { "enum": ["json", "hidden"] }, "sectionCaption": { "type": "string" }, @@ -606,10 +636,10 @@ "properties": { "nullable": { "const": false } } }, "then": { - "properties": { "default": { "type": ["object", "array", "string", "integer", "boolean"] } } + "properties": { "default": { "type": ["object", "array", "string", "integer", "number", "boolean"] } } }, "else": { - "properties": { "default": { "type": ["object", "array", "string", "integer", "boolean", "null"] } } + "properties": { "default": { "type": ["object", "array", "string", "integer", "number", "boolean", "null"] } } } }, "subSchemaStringEnumProperty": { @@ -779,6 +809,22 @@ }, "required": ["type", "title", "description"] }, + "subSchemaNumberProperty": { + "title": "Sub-schema: Number property", + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "enum": ["number"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minimum": { "type": "number" }, + "maximum": { "type": "number" }, + "unit": { "type": "string" }, + "editor": { "enum": ["number", "hidden"] } + }, + "required": ["type", "title", "description"] + }, "subSchemaBooleanProperty": { "title": "Sub-schema: Boolean property", "type": "object", @@ -902,6 +948,7 @@ { "$ref": "#/definitions/subSchemaArrayProperty" }, { "$ref": "#/definitions/subSchemaObjectProperty" }, { "$ref": "#/definitions/subSchemaIntegerProperty" }, + { "$ref": "#/definitions/subSchemaNumberProperty" }, { "$ref": "#/definitions/subSchemaBooleanProperty" }, { "$ref": "#/definitions/subSchemaResourceProperty" }, { "$ref": "#/definitions/subSchemaResourceArrayProperty" } @@ -1293,6 +1340,7 @@ { "$ref": "#/definitions/subSchemaArrayProperty" }, { "$ref": "#/definitions/subSchemaObjectProperty" }, { "$ref": "#/definitions/subSchemaIntegerProperty" }, + { "$ref": "#/definitions/subSchemaNumberProperty" }, { "$ref": "#/definitions/subSchemaBooleanProperty" }, { "$ref": "#/definitions/subSchemaResourceProperty" }, { "$ref": "#/definitions/subSchemaResourceArrayProperty" } diff --git a/test/input_schema.test.ts b/test/input_schema.test.ts index 1ea2d3a01..b73026cbe 100644 --- a/test/input_schema.test.ts +++ b/test/input_schema.test.ts @@ -14,7 +14,7 @@ describe('input_schema.json', () => { properties: { myField: { title: 'Field title', - type: ['object', 'array', 'string', 'integer', 'boolean'], + type: ['object', 'array', 'string', 'integer', 'number', 'boolean'], description: 'Some description ...', editor: 'json', }, @@ -60,7 +60,7 @@ describe('input_schema.json', () => { properties: { myField: { title: 'Field title', - type: ['object', 'array', 'string', 'integer', 'boolean'], + type: ['object', 'array', 'string', 'integer', 'number', 'boolean'], description: 'Some description ...', editor: 'json', }, @@ -78,7 +78,7 @@ describe('input_schema.json', () => { properties: { myField: { title: 'Field title', - type: ['object', 'array', 'string', 'integer', 'boolean'], + type: ['object', 'array', 'string', 'integer', 'number', 'boolean'], description: 'Some description ...', editor: 'json', }, @@ -98,7 +98,7 @@ describe('input_schema.json', () => { properties: { myField: { title: 'Field title', - type: ['object', 'array', 'string', 'integer', 'boolean'], + type: ['object', 'array', 'string', 'integer', 'number', 'boolean'], description: 'Some description ...', editor: 'textfield', }, @@ -847,6 +847,7 @@ describe('input_schema.json', () => { const types = [ { type: 'string', editor: 'textfield' }, { type: 'integer', editor: 'number' }, + { type: 'number', editor: 'number' }, { type: 'boolean', editor: 'checkbox' }, { type: 'array', editor: 'json' }, { type: 'object', editor: 'json' }, @@ -875,6 +876,7 @@ describe('input_schema.json', () => { const types = [ { type: 'string', editor: 'textfield' }, { type: 'integer', editor: 'number' }, + { type: 'number', editor: 'number' }, { type: 'boolean', editor: 'checkbox' }, { type: 'array', editor: 'json' }, { type: 'object', editor: 'json' }, diff --git a/test/input_schema_definition.test.ts b/test/input_schema_definition.test.ts index be32e4fc4..36efbffa6 100644 --- a/test/input_schema_definition.test.ts +++ b/test/input_schema_definition.test.ts @@ -32,7 +32,7 @@ describe('input_schema.json', () => { properties: { myField: { title: 'Field title', - type: ['object', 'array', 'string', 'integer', 'boolean'], + type: ['object', 'array', 'string', 'integer', 'number', 'boolean'], nullable: false, description: 'Some description ...', editor: 'json', @@ -97,7 +97,7 @@ describe('input_schema.json', () => { properties: { myField: { title: 'Field title', - type: ['object', 'array', 'string', 'integer', 'boolean'], + type: ['object', 'array', 'string', 'integer', 'number', 'boolean'], nullable: false, description: 'Some description ...', editor: 'json', @@ -209,6 +209,45 @@ describe('input_schema.json', () => { turnOffConsoleWarnErrors(); }); + describe('special cases for number and integer types', () => { + const isSchemaValid = (fields: object, type: 'number' | 'integer') => { + return ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type, + editor: 'number', + ...fields, + }, + }, + }); + }; + + it('should allow all number/integer specific fields', () => { + ['minimum', 'maximum', 'default', 'prefill', 'example'].forEach((intField) => { + expect(isSchemaValid({ [intField]: 10 }, 'integer')).toBe(true); + expect(isSchemaValid({ [intField]: 10.0 }, 'integer')).toBe(true); + expect(isSchemaValid({ [intField]: 10.5 }, 'integer')).toBe(false); + + expect(isSchemaValid({ [intField]: 10 }, 'number')).toBe(true); + expect(isSchemaValid({ [intField]: 10.5 }, 'number')).toBe(true); + expect(isSchemaValid({ [intField]: 10.5 }, 'number')).toBe(true); + }); + }); + + it('should allow only number editor', () => { + expect(isSchemaValid({ editor: 'number' }, 'integer')).toBe(true); + expect(isSchemaValid({ editor: 'textfield' }, 'integer')).toBe(false); + + expect(isSchemaValid({ editor: 'number' }, 'number')).toBe(true); + expect(isSchemaValid({ editor: 'textfield' }, 'number')).toBe(false); + }); + }); + describe('special cases for isSecret property', () => { const isSchemaValid = (fields: object, isSecret?: boolean) => { return ajv.validate(inputSchema, { @@ -257,6 +296,7 @@ describe('input_schema.json', () => { [ { type: 'boolean' }, { type: 'integer' }, + { type: 'number' }, ].forEach((fields) => { expect(isSchemaValid(fields, true)).toBe(false); }); diff --git a/test/utilities.client.test.ts b/test/utilities.client.test.ts index cff1f056a..8f4114537 100644 --- a/test/utilities.client.test.ts +++ b/test/utilities.client.test.ts @@ -587,8 +587,8 @@ describe('utilities.client', () => { required: ['field'], }; const ajv = new Ajv({ strict: false }); - const buildInputSchema = (properties: any) => { - const inputSchema = { ...baseInputSchema, properties }; + const buildInputSchema = (properties: any, otherFields = {}) => { + const inputSchema = { ...baseInputSchema, properties, ...otherFields }; const validator = ajv.compile(inputSchema); return { inputSchema, validator }; }; @@ -1424,6 +1424,126 @@ describe('utilities.client', () => { expect(errorResults[2][0].message).toEqual('Field input.field.key.with.dot is required'); }); }); + + describe('special cases for number and integer fields', () => { + it('should allow float number only for number field', () => { + const { inputSchema, validator } = buildInputSchema({ + numberField: { + title: 'Field 1', + description: 'My test field 1', + type: 'number', + editor: 'number', + }, + intField: { + title: 'Field 2', + description: 'My test field 2', + type: 'integer', + editor: 'number', + }, + }, { required: [] }); + + const validInputs = [ + { numberField: 1 }, + { numberField: 1.5 }, + { numberField: -1.5 }, + { intField: 1 }, + { intField: 1.0 }, + { intField: -1 }, + { intField: 0 }, + ]; + + const invalidInputs = [ + { numberField: '1' }, + { numberField: 'a' }, + { numberField: [] }, + { numberField: {} }, + { intField: 1.5 }, + { intField: -1.5 }, + { intField: '1' }, + { intField: 'a' }, + { intField: [] }, + { intField: {} }, + ]; + + let errorResults = validInputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + expect(errorResults.length).toEqual(0); + errorResults = invalidInputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + expect(errorResults.length).toEqual(10); + errorResults.forEach((result) => { + // Only one error should be thrown + expect(result.length).toEqual(1); + expect(['numberField', 'intField']).toContain(result[0].fieldKey); + }); + + let i; + for (i = 0; i < 4; i++) { + expect(errorResults[i][0].message).toEqual('Field input.numberField must be number'); + } + for (i; i < 10; i++) { + expect(errorResults[i][0].message).toEqual('Field input.intField must be integer'); + } + }); + + it('should respect minimum, maximum for number and integer fields', () => { + const { inputSchema, validator } = buildInputSchema({ + numberField: { + title: 'Field 1', + description: 'My test field 1', + type: 'number', + editor: 'number', + minimum: 1.5, + maximum: 5.5, + }, + intField: { + title: 'Field 2', + description: 'My test field 2', + type: 'integer', + editor: 'number', + exclusiveMinimum: 1, + exclusiveMaximum: 5, + }, + }, { required: [] }); + + const validInputs = [ + { numberField: 1.5 }, + { numberField: 3 }, + { numberField: 5.5 }, + { intField: 2 }, + { intField: 3 }, + { intField: 4 }, + ]; + + const invalidInputs = [ + { numberField: 1.4 }, + { numberField: 5.6 }, + { intField: 1 }, + { intField: 5 }, + ]; + + let errorResults = validInputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + expect(errorResults.length).toEqual(0); + errorResults = invalidInputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + expect(errorResults.length).toEqual(4); + errorResults.forEach((result) => { + // Only one error should be thrown + expect(result.length).toEqual(1); + expect(['numberField', 'intField']).toContain(result[0].fieldKey); + }); + + expect(errorResults[0][0].message).toEqual('Field input.numberField must be >= 1.5'); + expect(errorResults[1][0].message).toEqual('Field input.numberField must be <= 5.5'); + expect(errorResults[2][0].message).toEqual('Field input.intField must be > 1'); + expect(errorResults[3][0].message).toEqual('Field input.intField must be < 5'); + }); + }); }); describe('#jsonStringifyExtended()', () => {