diff --git a/packages/input_schema/src/input_schema.ts b/packages/input_schema/src/input_schema.ts index a56cd5b3d..99439cf41 100644 --- a/packages/input_schema/src/input_schema.ts +++ b/packages/input_schema/src/input_schema.ts @@ -25,7 +25,7 @@ const { definitions } = schema; export function parseAjvError( error: ErrorObject, rootName: string, - properties: Record = {}, + properties: Record = {}, input: Record = {}, ): { fieldKey: string; message: string } | null { // There are 3 possible errors comming from validation: @@ -47,7 +47,12 @@ export function parseAjvError( message = m('inputSchema.validation.generic', { rootName, fieldKey, message: error.message }); } else if (error.keyword === 'required') { fieldKey = error.params.missingProperty; - message = m('inputSchema.validation.required', { rootName, fieldKey }); + if (fieldKey === 'allowRelative') { + // this is the case, when allowAbsolute is set to false, but allowRelative is not set + message = m('inputSchema.validation.datepickerNoType', { rootName }); + } else { + message = m('inputSchema.validation.required', { rootName, fieldKey }); + } } else if (error.keyword === 'additionalProperties') { fieldKey = error.params.additionalProperty; message = m('inputSchema.validation.additionalProperty', { rootName, fieldKey }); @@ -55,6 +60,14 @@ export function parseAjvError( fieldKey = error.instancePath.split('/').pop()!; const errorMessage = `${error.message}: "${error.params.allowedValues.join('", "')}"`; message = m('inputSchema.validation.generic', { rootName, fieldKey, message: errorMessage }); + } else if (error.keyword === 'const') { + fieldKey = error.instancePath.split('/').pop()!; + // This is a special case for datepicker fields, where both allowAbsolute and allowRelative properties are set to false + if (fieldKey === 'allowRelative' || fieldKey === 'allowAbsolute') { + message = m('inputSchema.validation.datepickerNoType', { rootName }); + } else { + message = m('inputSchema.validation.generic', { rootName, fieldKey, message: error.message }); + } } else { fieldKey = error.instancePath.split('/').pop()!; message = m('inputSchema.validation.generic', { rootName, fieldKey, message: error.message }); diff --git a/packages/input_schema/src/intl.ts b/packages/input_schema/src/intl.ts index 5ce3c7f11..38a3c2c2e 100644 --- a/packages/input_schema/src/intl.ts +++ b/packages/input_schema/src/intl.ts @@ -33,6 +33,16 @@ const intlStrings = { 'Field schema.properties.{fieldKey} does not exist, but it is specified in schema.required. Either define the field or remove it from schema.required.', 'inputSchema.validation.proxyGroupMustBeArrayOfStrings': 'Field {rootName}.{fieldKey}.apifyProxyGroups must be an array of strings.', + 'inputSchema.validation.datepickerInvalidFormatAbsolute': + 'Field {rootName}.{fieldKey} must be a string in format "YYYY-MM-DD".', + 'inputSchema.validation.datepickerInvalidFormatRelative': + 'Field {rootName}.{fieldKey} must be a string in format "+/- number unit". Supported units are "day", "week", "month" and "year".', + 'inputSchema.validation.datepickerInvalidFormatBoth': + 'Field {rootName}.{fieldKey} must be a string in format "YYYY-MM-DD" or "+/- number unit". Supported units are "day", "week", "month" and "year".', + 'inputSchema.validation.datepickerInvalidDate': + 'Field {rootName}.{fieldKey} must be a valid date.', + 'inputSchema.validation.datepickerNoType': + 'Field {rootName} must accept absolute, relative or both dates. Set "allowAbsolute", "allowRelative" or both properties.', }; /** diff --git a/packages/input_schema/src/schema.json b/packages/input_schema/src/schema.json index 3d6bed0e0..ce0f332dc 100644 --- a/packages/input_schema/src/schema.json +++ b/packages/input_schema/src/schema.json @@ -100,22 +100,58 @@ } }, "then": { - "additionalProperties": false, - "properties": { - "type": { "enum": ["string"] }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "default": { "type": "string" }, - "prefill": { "type": "string" }, - "example": { "type": "string" }, - "pattern": { "type": "string" }, - "nullable": { "type": "boolean" }, - "minLength": { "type": "integer" }, - "maxLength": { "type": "integer" }, - "editor": { "enum": ["javascript", "python", "textfield", "textarea", "datepicker", "hidden", "dataset", "keyValueStore", "requestQueue"] }, - "isSecret": { "type": "boolean" }, - "sectionCaption": { "type": "string" }, - "sectionDescription": { "type": "string" } + "if": { + "properties": { + "editor": { "const": "datepicker" } + } + }, + "then": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "default": { "type": "string" }, + "prefill": { "type": "string" }, + "example": { "type": "string" }, + "pattern": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" }, + "editor": { "enum": ["datepicker"] }, + "sectionCaption": { "type": "string" }, + "sectionDescription": { "type": "string" }, + "allowAbsolute": { "type": "boolean" }, + "allowRelative": { "type": "boolean" } + }, + "if": { + "required": ["allowAbsolute"], + "properties": { "allowAbsolute": { "const": false } } + }, + "then": { "required": ["type", "title", "description", "editor", "allowRelative"] }, + "anyOf": [ + { "properties": { "allowAbsolute": { "const": true } } }, + { "properties": { "allowRelative": { "const": true } } } + ] + }, + "else": { + "additionalProperties": false, + "properties": { + "type": { "enum": ["string"] }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "default": { "type": "string" }, + "prefill": { "type": "string" }, + "example": { "type": "string" }, + "pattern": { "type": "string" }, + "nullable": { "type": "boolean" }, + "minLength": { "type": "integer" }, + "maxLength": { "type": "integer" }, + "editor": { "enum": ["javascript", "python", "textfield", "textarea", "datepicker", "hidden", "dataset", "keyValueStore", "requestQueue"] }, + "isSecret": { "type": "boolean" }, + "sectionCaption": { "type": "string" }, + "sectionDescription": { "type": "string" } + } } }, "else": { diff --git a/packages/input_schema/src/types.ts b/packages/input_schema/src/types.ts index 4fa25a885..1b573cc4b 100644 --- a/packages/input_schema/src/types.ts +++ b/packages/input_schema/src/types.ts @@ -18,6 +18,10 @@ export type StringFieldDefinition = CommonFieldDefinition & { enum?: readonly string[]; // required if editor is 'select' enumTitles?: readonly string[] isSecret?: boolean; + // Used for 'datepicker' editor, allowAbsolute is considered with default value true + // If only relative time is wanted, allowAbsolute must be explicitly set to false + allowAbsolute?: boolean; + allowRelative?: boolean; } export type BooleanFieldDefinition = CommonFieldDefinition & { diff --git a/packages/input_schema/src/utilities.ts b/packages/input_schema/src/utilities.ts index 2c2278515..b2919dc0e 100644 --- a/packages/input_schema/src/utilities.ts +++ b/packages/input_schema/src/utilities.ts @@ -137,12 +137,12 @@ export function validateInputUsingValidator( } Object.keys(properties).forEach((property) => { - const value = input[property] as Record; - const { type, editor, patternKey, patternValue } = properties[property]; + const value = input[property]; + const { type, editor, patternKey, patternValue, allowAbsolute, allowRelative } = properties[property]; const fieldErrors = []; // Check that proxy is required, if yes, valides that it's correctly setup if (type === 'object' && editor === 'proxy') { - const proxyValidationErrors = validateProxyField(property as any, value, required.includes(property), options.proxy); + const proxyValidationErrors = validateProxyField(property as any, value as Record, required.includes(property), options.proxy); proxyValidationErrors.forEach((error) => { fieldErrors.push(error); }); @@ -234,7 +234,7 @@ export function validateInputUsingValidator( const check = new RegExp(patternValue); const invalidKeys: any[] = []; Object.keys(value).forEach((key) => { - const propertyValue = value[key]; + const propertyValue = (value as Record)[key]; if (typeof propertyValue !== 'string' || !check.test(propertyValue)) invalidKeys.push(key); }); if (invalidKeys.length) { @@ -247,6 +247,50 @@ export function validateInputUsingValidator( } } } + + // Check datepicker editor format + if (type === 'string' && editor === 'datepicker' && value && typeof value === 'string') { + const acceptAbsolute = allowAbsolute !== false; + const acceptRelative = allowRelative === true; + const isValidAbsolute = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(value); + const isValidRelative = /^[+-] [0-9]+ (day|week|month|year)s?$/.test(value); + let isValidDate: boolean | undefined; + + if (isValidAbsolute) { + const [year, month, day] = value.split('-').map(Number); + const date = new Date(`${year}-${month}-${day}`); + + // Check if the date object is valid and matches the input string + isValidDate = date.getFullYear() === year + && date.getMonth() + 1 === month + && date.getDate() === day; + } + + if (acceptAbsolute && !acceptRelative && !isValidAbsolute) { + fieldErrors.push(m('inputSchema.validation.datepickerInvalidFormatAbsolute', { + rootName: 'input', + fieldKey: property, + })); + } else if (acceptRelative && !acceptAbsolute && !isValidRelative) { + fieldErrors.push(m('inputSchema.validation.datepickerInvalidFormatRelative', { + rootName: 'input', + fieldKey: property, + })); + } else if ((acceptAbsolute && !acceptRelative && !isValidAbsolute) + || (acceptRelative && !acceptAbsolute && !isValidRelative) + || (acceptRelative && acceptAbsolute && !isValidAbsolute && !isValidRelative)) { + fieldErrors.push(m('inputSchema.validation.datepickerInvalidFormatBoth', { + rootName: 'input', + fieldKey: property, + })); + } else if (isValidDate === false && acceptAbsolute) { + fieldErrors.push(m('inputSchema.validation.datepickerInvalidDate', { + rootName: 'input', + fieldKey: property, + })); + } + } + if (fieldErrors.length > 0) { const message = fieldErrors.join(', '); errors.push({ fieldKey: property, message }); diff --git a/test/input_schema_definition.test.ts b/test/input_schema_definition.test.ts index 12e19dc1e..0b049d7c2 100644 --- a/test/input_schema_definition.test.ts +++ b/test/input_schema_definition.test.ts @@ -1,4 +1,4 @@ -import { inputSchema } from '@apify/input_schema'; +import { inputSchema, parseAjvError } from '@apify/input_schema'; import Ajv from 'ajv'; /** @@ -285,5 +285,171 @@ describe('input_schema.json', () => { })).toBe(false); }); }); + + describe('special cases for datepicker editor type', () => { + it('should accept allowAbsolute and allowRelative fields omitted', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + }, + }, + })).toBe(true); + }); + + it('should accept allowAbsolute and allowRelative both set to true', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowAbsolute: true, + allowRelative: true, + }, + }, + })).toBe(true); + }); + + it('should accept allowAbsolute=true and allowRelative=false', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowAbsolute: true, + allowRelative: false, + }, + }, + })).toBe(true); + }); + + it('should accept allowAbsolute=false and allowRelative=true', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowAbsolute: false, + allowRelative: true, + }, + }, + })).toBe(true); + }); + + it('should accept allowAbsolute=true', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowAbsolute: true, + }, + }, + })).toBe(true); + }); + + it('should accept allowRelative=true', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowRelative: true, + }, + }, + })).toBe(true); + }); + + it('should accept allowRelative=false', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowRelative: false, + }, + }, + })).toBe(true); + }); + + it('should not accept allowAbsolute=false', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowAbsolute: false, + }, + }, + })).toBe(false); + expect(ajv.errorsText()).toContain('data/properties/myField must have required property \'allowRelative\''); + expect(parseAjvError(ajv.errors![0], 'schema.properties.myField')?.message) + .toEqual('Field schema.properties.myField must accept absolute, relative or both dates. ' + + 'Set "allowAbsolute", "allowRelative" or both properties.'); + }); + + it('should not accept allowAbsolute=false allowRelative=false', () => { + expect(ajv.validate(inputSchema, { + title: 'Test input schema', + type: 'object', + schemaVersion: 1, + properties: { + myField: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowAbsolute: false, + allowRelative: false, + }, + }, + })).toBe(false); + expect(ajv.errorsText()).toContain('data/properties/myField/allowAbsolute must be equal to constant'); + expect(parseAjvError(ajv.errors![0], 'schema.properties.myField')?.message) + .toEqual('Field schema.properties.myField must accept absolute, relative or both dates. ' + + 'Set "allowAbsolute", "allowRelative" or both properties.'); + }); + }); }); }); diff --git a/test/utilities.client.test.ts b/test/utilities.client.test.ts index 351db6e1d..ad059e70a 100644 --- a/test/utilities.client.test.ts +++ b/test/utilities.client.test.ts @@ -1018,6 +1018,219 @@ describe('utilities.client', () => { expect(result[0].fieldKey).toEqual('field'); }); }); + + describe('special cases for datepicker string type', () => { + it('should allow absolute dates when allowAbsolute is omitted', () => { + const { inputSchema, validator } = buildInputSchema({ + field: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + nullable: true, + }, + }); + const inputs = [ + // 5 invalid inputs + { field: {} }, + { field: [] }, + { field: 'invalid string' }, + { field: '12.10.2024' }, + { field: '+ 1 day' }, + // Valid + { field: '2022-12-31' }, + { field: '2024-01-31' }, + { field: '1999-02-28' }, + { field: null }, + ]; + + const results = inputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + + // There should be 5 invalid inputs + expect(results.length).toEqual(5); + results.forEach((result) => { + // Only one error should be thrown + expect(result.length).toEqual(1); + expect(result[0].fieldKey).toEqual('field'); + }); + }); + + it('should allow absolute dates when allowAbsolute is set to true', () => { + const { inputSchema, validator } = buildInputSchema({ + field: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowAbsolute: true, + }, + }); + const inputs = [ + // 6 invalid inputs + { field: {} }, + { field: [] }, + { field: 'invalid string' }, + { field: '12.10.2024' }, + { field: '+ 1 day' }, + { field: null }, + // Valid + { field: '2022-12-31' }, + { field: '2024-01-31' }, + { field: '1999-02-28' }, + ]; + + const results = inputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + + // There should be 6 invalid inputs + expect(results.length).toEqual(6); + results.forEach((result) => { + // Only one error should be thrown + expect(result.length).toEqual(1); + expect(result[0].fieldKey).toEqual('field'); + }); + }); + + it('should allow only relative dates when allowRelative is true and allowAbsolute is false', () => { + const { inputSchema, validator } = buildInputSchema({ + field: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowAbsolute: false, + allowRelative: true, + }, + }); + const inputs = [ + // 11 invalid inputs + { field: '2022-12-31' }, + { field: '2024-01-31' }, + { field: '1999-02-28' }, + { field: {} }, + { field: [] }, + { field: 'invalid string' }, + { field: '12.10.2024' }, + { field: null }, + { field: '+ 1 minutes' }, + { field: '1 day' }, + { field: '- 11 sec' }, + // Valid + { field: '+ 1 day' }, + { field: '+ 3 days' }, + { field: '+ 5 weeks' }, + { field: '- 10 months' }, + { field: '+ 3 years' }, + { field: '- 0 days' }, + ]; + + const results = inputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + + // There should be 11 invalid inputs + expect(results.length).toEqual(11); + results.forEach((result) => { + // Only one error should be thrown + expect(result.length).toEqual(1); + expect(result[0].fieldKey).toEqual('field'); + }); + }); + + it('should allow both dates when allowRelative is true', () => { + const { inputSchema, validator } = buildInputSchema({ + field: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowRelative: true, + }, + }); + const inputs = [ + // 8 invalid inputs + { field: {} }, + { field: [] }, + { field: 'invalid string' }, + { field: '12.10.2024' }, + { field: null }, + { field: '+ 1 minutes' }, + { field: '1 day' }, + { field: '- 11 sec' }, + // Valid + { field: '2022-12-31' }, + { field: '2024-01-31' }, + { field: '1999-02-28' }, + { field: '+ 1 day' }, + { field: '+ 3 days' }, + { field: '+ 5 weeks' }, + { field: '- 10 months' }, + { field: '+ 3 years' }, + { field: '- 0 days' }, + ]; + + const results = inputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + + // There should be 8 invalid inputs + expect(results.length).toEqual(8); + results.forEach((result) => { + // Only one error should be thrown + expect(result.length).toEqual(1); + expect(result[0].fieldKey).toEqual('field'); + }); + }); + + it('should allow both dates when allowRelative is true and allowAbsolute is true', () => { + const { inputSchema, validator } = buildInputSchema({ + field: { + title: 'Field title', + description: 'My test field', + type: 'string', + editor: 'datepicker', + allowRelative: true, + allowAbsolute: true, + }, + }); + const inputs = [ + // 8 invalid inputs + { field: {} }, + { field: [] }, + { field: 'invalid string' }, + { field: '12.10.2024' }, + { field: null }, + { field: '+ 1 minutes' }, + { field: '1 day' }, + { field: '- 11 sec' }, + // Valid + { field: '2022-12-31' }, + { field: '2024-01-31' }, + { field: '1999-02-28' }, + { field: '+ 1 day' }, + { field: '+ 3 days' }, + { field: '+ 5 weeks' }, + { field: '- 10 months' }, + { field: '+ 3 years' }, + { field: '- 0 days' }, + ]; + + const results = inputs + .map((input) => validateInputUsingValidator(validator, inputSchema, input)) + .filter((errors) => errors.length > 0); + + // There should be 8 invalid inputs + expect(results.length).toEqual(8); + results.forEach((result) => { + // Only one error should be thrown + expect(result.length).toEqual(1); + expect(result[0].fieldKey).toEqual('field'); + }); + }); + }); }); describe('#jsonStringifyExtended()', () => {