diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bae0d1812..4c6fe75006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ### Fixes * Fixes a bug where images in Media manager are not selectable (click on an image does nothing) in both default and relationship mode. +* Eliminated superfluous error messages. The convert method now waits for all recursive invocations to complete before attempting to determine if fields are visible. + +### Adds + +* Possibility to set a field not ready when performing async operations, when a field isn't ready, the validation and emit won't occur. ## 4.11.1 (2024-12-18) @@ -22,7 +27,6 @@ * Adds option to disable `tabindex` on `AposToggle` component. A new prop `disableFocus` can be set to `false` to disable the focus on the toggle button. It's enabled by default. * Adds support for event on `addContextOperation`, an option `type` can now be passed and can be `modal` (default) or `event`, in this case it does not try to open a modal but emit a bus event using the action as name. - ### Fixes * Focus properly Widget Editor modals when opened. Keep the previous active focus on the modal when closing the widget editor. diff --git a/modules/@apostrophecms/schema/index.js b/modules/@apostrophecms/schema/index.js index 66cec3f1d7..2b0389b90c 100644 --- a/modules/@apostrophecms/schema/index.js +++ b/modules/@apostrophecms/schema/index.js @@ -24,7 +24,6 @@ module.exports = { alias: 'schema' }, init(self) { - self.fieldTypes = {}; self.fieldsById = {}; self.arrayManagers = {}; @@ -489,7 +488,15 @@ module.exports = { const destinationKey = _.get(destination, key); if (key === '$or') { - const results = await Promise.all(val.map(clause => self.evaluateCondition(req, field, clause, destination, conditionalFields))); + const results = await Promise.all( + val.map(clause => self.evaluateCondition( + req, + field, + clause, + destination, + conditionalFields) + ) + ); const testResults = _.isPlainObject(results?.[0]) ? results.some(({ value }) => value) : results.some((value) => value); @@ -585,20 +592,18 @@ module.exports = { { fetchRelationships = true, ancestors = [], - isParentVisible = true + rootConvert = true } = {} ) { const options = { fetchRelationships, - ancestors, - isParentVisible + ancestors }; if (Array.isArray(req)) { throw new Error('convert invoked without a req, do you have one in your context?'); } - const errors = []; - + const convertErrors = []; for (const field of schema) { if (field.readOnly) { continue; @@ -611,92 +616,207 @@ module.exports = { } const { convert } = self.fieldTypes[field.type]; + if (!convert) { + continue; + } - if (convert) { - try { - const isAllParentsVisible = isParentVisible === false - ? false - : await self.isVisible(req, schema, destination, field.name); - const isRequired = await self.isFieldRequired(req, field, destination); - await convert( - req, - { - ...field, - required: isRequired - }, - data, - destination, - { - ...options, - isParentVisible: isAllParentsVisible - } - ); - } catch (error) { - if (Array.isArray(error)) { - const invalid = self.apos.error('invalid', { - errors: error - }); - invalid.path = field.name; - errors.push(invalid); - } else { - error.path = field.name; - errors.push(error); + try { + const isRequired = await self.isFieldRequired(req, field, destination); + await convert( + req, + { + ...field, + required: isRequired + }, + data, + destination, + { + ...options, + rootConvert: false } - } + ); + } catch (err) { + const error = Array.isArray(err) + ? self.apos.error('invalid', { errors: err }) + : err; + + error.path = field.name; + error.schemaPath = field.aposPath; + convertErrors.push(error); } } - const errorsList = []; - - for (const error of errors) { - if (error.path) { - // `self.isVisible` will only throw for required fields that have - // an external condition containing an unknown module or method: - const isVisible = isParentVisible === false - ? false - : await self.isVisible(req, schema, destination, error.path); - - if (!isVisible) { - // It is not reasonable to enforce required, - // min, max or anything else for fields - // hidden via "if" as the user cannot correct it - // and it will not be used. If the user changes - // the conditional field later then they won't - // be able to save until the erroneous field - // is corrected - const name = error.path; - const field = schema.find(field => field.name === name); - if (field) { - // To protect against security issues, an invalid value - // for a field that is not visible should be quietly discarded. - // We only worry about this if the value is not valid, as otherwise - // it's a kindness to save the work so the user can toggle back to it - destination[field.name] = klona((field.def !== undefined) - ? field.def - : self.fieldTypes[field.type]?.def); - continue; - } + if (!rootConvert) { + if (convertErrors.length) { + throw convertErrors; + } + + return; + } + + const nonVisibleFields = await self.getNonVisibleFields({ + req, + schema, + destination + }); + + const validErrors = await self.handleConvertErrors({ + req, + schema, + convertErrors, + destination, + nonVisibleFields + }); + + for (const error of validErrors) { + self.apos.util.error(error.stack); + } + + if (validErrors.length) { + throw validErrors; + } + }, + + async getNonVisibleFields({ + req, schema, destination, nonVisibleFields = new Set(), fieldPath = '' + }) { + for (const field of schema) { + const curPath = fieldPath ? `${fieldPath}.${field.name}` : field.name; + const isVisible = await self.isVisible(req, schema, destination, field.name); + if (!isVisible) { + nonVisibleFields.add(curPath); + continue; + } + if (!field.schema) { + continue; + } + + // Relationship does not support conditional fields right now + if ([ 'array' /*, 'relationship' */].includes(field.type) && field.schema) { + for (const arrayItem of destination[field.name] || []) { + await self.getNonVisibleFields({ + req, + schema: field.schema, + destination: arrayItem, + nonVisibleFields, + fieldPath: `${curPath}.${arrayItem._id}` + }); } - if (isParentVisible === false) { + } else if (field.type === 'object') { + await self.getNonVisibleFields({ + req, + schema: field.schema, + destination: destination[field.name], + nonVisibleFields, + fieldPath: curPath + }); + } + } + + return nonVisibleFields; + }, + + async handleConvertErrors({ + req, + schema, + convertErrors, + nonVisibleFields, + destination, + destinationPath = '', + hiddenAncestors = false + }) { + const validErrors = []; + for (const error of convertErrors) { + const [ destId, destPath ] = error.path.includes('.') + ? error.path.split('.') + : [ null, error.path ]; + + const curDestination = destId + ? destination.find(({ _id }) => _id === destId) + : destination; + + const errorPath = destinationPath + ? `${destinationPath}.${error.path}` + : error.path; + + // Case were this error field hasn't been treated + // Should check if path starts with, because parent can be invisible + const nonVisibleField = hiddenAncestors || nonVisibleFields.has(errorPath); + + // We set default values only on final error fields + if (nonVisibleField && !error.data?.errors) { + const curSchema = self.getFieldLevelSchema(schema, error.schemaPath); + self.setDefaultToInvisibleField(curDestination, curSchema, error.path); + continue; + } + + if (error.data?.errors) { + const subErrors = await self.handleConvertErrors({ + req, + schema, + convertErrors: error.data.errors, + nonVisibleFields, + destination: curDestination[destPath], + destinationPath: errorPath, + hiddenAncestors: nonVisibleField + }); + + // If invalid error has no sub error, this one can be removed + if (!subErrors.length) { continue; } - } - if (!Array.isArray(error) && typeof error !== 'string') { - self.apos.util.error(error + '\n\n' + error.stack); + error.data.errors = subErrors; } - errorsList.push(error); + validErrors.push(error); + } + + return validErrors; + }, + + setDefaultToInvisibleField(destination, schema, fieldPath) { + // Field path might contain the ID of the object in which it is contained + // We just want the field name here + const [ _id, fieldName ] = fieldPath.includes('.') + ? fieldPath.split('.') + : [ null, fieldPath ]; + // It is not reasonable to enforce required, + // min, max or anything else for fields + // hidden via "if" as the user cannot correct it + // and it will not be used. If the user changes + // the conditional field later then they won't + // be able to save until the erroneous field + // is corrected + const field = schema.find(field => field.name === fieldName); + if (field) { + // To protect against security issues, an invalid value + // for a field that is not visible should be quietly discarded. + // We only worry about this if the value is not valid, as otherwise + // it's a kindness to save the work so the user can toggle back to it + destination[field.name] = klona((field.def !== undefined) + ? field.def + : self.fieldTypes[field.type]?.def); } + }, - if (errorsList.length) { - throw errorsList; + getFieldLevelSchema(schema, fieldPath) { + if (!fieldPath || fieldPath === '/') { + return schema; } + let curSchema = schema; + const parts = fieldPath.split('/'); + parts.pop(); + for (const part of parts) { + const curField = curSchema.find(({ name }) => name === part); + curSchema = curField.schema; + } + + return curSchema; }, // Determine whether the given field is visible // based on `if` conditions of all fields - - async isVisible(req, schema, object, name) { + async isVisible(req, schema, destination, name) { const conditionalFields = {}; const errors = {}; @@ -705,7 +825,13 @@ module.exports = { for (const field of schema) { if (field.if) { try { - const result = await self.evaluateCondition(req, field, field.if, object, conditionalFields); + const result = await self.evaluateCondition( + req, + field, + field.if, + destination, + conditionalFields + ); const previous = conditionalFields[field.name]; if (previous !== result) { change = true; @@ -1332,19 +1458,23 @@ module.exports = { // reasonable values for certain properties, such as the `idsStorage` property // of a `relationship` field, or the `label` property of anything. - validate(schema, options) { + validate(schema, options, parent = null) { schema.forEach(field => { // Infinite recursion prevention const key = `${options.type}:${options.subtype}.${field.name}`; if (!self.validatedSchemas[key]) { self.validatedSchemas[key] = true; - self.validateField(field, options); + self.validateField(field, options, parent); } }); }, // Validates a single schema field. See `validate`. validateField(field, options, parent = null) { + field.aposPath = parent + ? `${parent.aposPath}/${field.name}` + : field.name; + const fieldType = self.fieldTypes[field.type]; if (!fieldType) { fail('Unknown schema field type.'); diff --git a/modules/@apostrophecms/schema/lib/addFieldTypes.js b/modules/@apostrophecms/schema/lib/addFieldTypes.js index 2c9e7d5c75..77ca432aee 100644 --- a/modules/@apostrophecms/schema/lib/addFieldTypes.js +++ b/modules/@apostrophecms/schema/lib/addFieldTypes.js @@ -751,7 +751,7 @@ module.exports = (self) => { { fetchRelationships = true, ancestors = [], - isParentVisible = true + rootConvert = true } = {} ) { const schema = field.schema; @@ -777,7 +777,7 @@ module.exports = (self) => { const options = { fetchRelationships, ancestors: [ ...ancestors, destination ], - isParentVisible + rootConvert }; await self.convert(req, schema, datum, result, options); } catch (e) { @@ -867,7 +867,7 @@ module.exports = (self) => { { fetchRelationships = true, ancestors = {}, - isParentVisible = true, + rootConvert = true, doc = {} } = {} ) { @@ -881,7 +881,7 @@ module.exports = (self) => { const options = { fetchRelationships, ancestors: [ ...ancestors, destination ], - isParentVisible + rootConvert }; if (data == null || typeof data !== 'object' || Array.isArray(data)) { data = {}; @@ -978,12 +978,12 @@ module.exports = (self) => { destination, { fetchRelationships = true, - isParentVisible = true + rootConvert = true } = {} ) { const options = { fetchRelationships, - isParentVisible + rootConvert }; const manager = self.apos.doc.getManager(field.withType); if (!manager) { @@ -1067,15 +1067,16 @@ module.exports = (self) => { if (result) { actualDocs.push(result); } - } else if ((item && ((typeof item._id) === 'string'))) { + } else if ((item && (typeof item._id === 'string'))) { const result = results.find(doc => (doc._id === item._id)); if (result) { if (field.schema) { + const destItem = (Array.isArray(destination[field.name]) ? destination[field.name] : []) + .find((doc) => doc._id === item._id); result._fields = { - ...(destination[field.name] - ?.find?.(doc => doc._id === item._id) - ?._fields || {}) + ...destItem?._fields || {} }; + if (item && ((typeof item._fields === 'object'))) { await self.convert(req, field.schema, item._fields || {}, result._fields, options); } @@ -1218,7 +1219,7 @@ module.exports = (self) => { self.validate(_field.schema, { type: 'relationship', subtype: _field.withType - }); + }, _field); if (!_field.fieldsStorage) { _field.fieldsStorage = _field.name.replace(/^_/, '') + 'Fields'; } diff --git a/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue b/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue index 39c8d6db34..733237c61a 100644 --- a/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue +++ b/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue @@ -52,6 +52,7 @@ :server-error="fields[field.name].serverError" :doc-id="docId" :generation="generation" + @update:model-value="updateNextAndEmit" @update-doc-data="onUpdateDocData" @validate="emitValidate()" /> diff --git a/modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js b/modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js index caadf492ab..076e69e4c0 100644 --- a/modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js +++ b/modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js @@ -164,12 +164,6 @@ export default { } }, watch: { - fieldState: { - deep: true, - handler() { - this.updateNextAndEmit(); - } - }, schema() { this.populateDocData(); }, @@ -282,20 +276,23 @@ export default { this.next.hasErrors = false; this.next.fieldState = { ...this.fieldState }; - this.schema.filter(field => this.displayComponent(field)).forEach(field => { - if (this.fieldState[field.name].error) { - this.next.hasErrors = true; - } - if ( - this.fieldState[field.name].data !== undefined && + this.schema + .filter(field => this.displayComponent(field)) + .forEach(field => { + if (this.fieldState[field.name].error) { + this.next.hasErrors = true; + } + // This simply check if a field has changed since it has been instantiated + if ( + this.fieldState[field.name].data !== undefined && detectFieldChange(field, this.next.data[field.name], this.fieldState[field.name].data) - ) { - changeFound = true; - this.next.data[field.name] = this.fieldState[field.name].data; - } else { - this.next.data[field.name] = this.modelValue.data[field.name]; - } - }); + ) { + changeFound = true; + this.next.data[field.name] = this.fieldState[field.name].data; + } else { + this.next.data[field.name] = this.modelValue.data[field.name]; + } + }); if ( oldHasErrors !== this.next.hasErrors || oldFieldState !== newFieldState diff --git a/modules/@apostrophecms/schema/ui/apos/mixins/AposInputChoicesMixin.js b/modules/@apostrophecms/schema/ui/apos/mixins/AposInputChoicesMixin.js index b74fb2ca90..f15f603943 100644 --- a/modules/@apostrophecms/schema/ui/apos/mixins/AposInputChoicesMixin.js +++ b/modules/@apostrophecms/schema/ui/apos/mixins/AposInputChoicesMixin.js @@ -11,7 +11,7 @@ export default { data() { return { choices: [], - enableValidate: false + fieldReady: false }; }, @@ -20,6 +20,7 @@ export default { onSuccess: this.updateChoices }); await this.debouncedUpdateChoices.skipDelay(); + this.fieldReady = true; }, watch: { @@ -34,7 +35,7 @@ export default { methods: { async getChoices() { - this.enableValidate = false; + this.fieldReady = false; if (typeof this.field.choices === 'string') { const action = this.options.action; const response = await apos.http.post( @@ -60,8 +61,8 @@ export default { } }, updateChoices(choices) { - this.enableValidate = true; this.choices = choices; + this.fieldReady = true; if (this.field.type === 'select') { this.prependEmptyChoice(); } diff --git a/modules/@apostrophecms/schema/ui/apos/mixins/AposInputMixin.js b/modules/@apostrophecms/schema/ui/apos/mixins/AposInputMixin.js index 2ee7702682..a9c6f1a124 100644 --- a/modules/@apostrophecms/schema/ui/apos/mixins/AposInputMixin.js +++ b/modules/@apostrophecms/schema/ui/apos/mixins/AposInputMixin.js @@ -70,7 +70,9 @@ export default { // in the UI between id attributes uid: Math.random(), // Automatically updated for you, can be watched - focus: false + focus: false, + // Can be overriden at input component level to handle async field preparation + fieldReady: true }; }, mounted () { @@ -136,16 +138,20 @@ export default { // You must supply the validate method. It receives the // internal representation used for editing (a string, for instance) validateAndEmit () { - if (this.enableValidate === false) { + if (!this.fieldReady) { return; } // If the field is conditional and isn't shown, disregard any errors. - const error = this.conditionMet === false ? false : this.validate(this.next); + // If field isn't ready we don't want to validate its value + const shouldValidate = this.conditionMet !== false; + const error = shouldValidate + ? this.validate(this.next) + : false; this.$emit('update:modelValue', { data: error ? this.next : this.convert(this.next), error, - ranValidation: this.conditionMet === false ? this.modelValue.ranValidation : true + ranValidation: shouldValidate ? true : this.modelValue.ranValidation }); }, // Allows replacing the current component value externally, e.g. via diff --git a/test/schemas.js b/test/schemas.js index 1c106919d8..f094aa1351 100644 --- a/test/schemas.js +++ b/test/schemas.js @@ -241,6 +241,10 @@ describe('Schemas', function() { this.timeout(t.timeout); + beforeEach(async function () { + apos.schema.validatedSchemas = {}; + }); + before(async function() { apos = await t.create({ root: module, @@ -3129,6 +3133,43 @@ describe('Schemas', function() { assert(!output.requiredProp); }); + // HERE + it('should not error nested required property if parent is not visible', async function() { + const req = apos.task.getReq(); + const schema = apos.schema.compose({ + addFields: [ + { + name: 'object', + type: 'object', + if: { + showObject: true + }, + schema: [ + { + name: 'subfield', + type: 'string', + required: true + } + ] + }, + { + name: 'showObject', + type: 'boolean' + } + ] + }); + const output = {}; + + try { + await apos.schema.convert(req, schema, { + showObject: false + }, output); + assert(true); + } catch (err) { + assert(!err); + } + }); + it('should error required property nested boolean', async function() { const schema = apos.schema.compose({ addFields: [ @@ -4946,7 +4987,484 @@ describe('Schemas', function() { await testSchemaError(schema, {}, 'age', 'required'); }); + + it('should not error complex nested object required property if parents are not visible', async function() { + const schema = apos.schema.compose({ + addFields: [ + { + name: 'object', + type: 'object', + if: { + showObject: true + }, + schema: [ + { + name: 'objectString', + type: 'string', + required: true + }, + { + name: 'objectArray', + type: 'array', + required: true, + if: { + showObjectArray: true + }, + schema: [ + { + name: 'objectArrayString', + type: 'string', + required: true + } + ] + }, + { + name: 'showObjectArray', + type: 'boolean' + } + ] + }, + { + name: 'showObject', + type: 'boolean' + } + ] + }); + + const data = { + object: { + objectString: 'toto', + objectArray: [ + { + _id: 'tutu', + metaType: 'arrayItem' + } + ], + showObjectArray: false + }, + showObject: true + }; + + const output = {}; + const [ success, errors ] = await testConvert(apos, data, schema, output); + + const expected = { + success: true, + errors: [], + output: { + object: { + _id: output.object._id, + objectString: 'toto', + objectArray: [ + { + _id: 'tutu', + metaType: 'arrayItem', + scopedArrayName: undefined, + objectArrayString: '' + } + ], + showObjectArray: false, + metaType: 'objectItem', + scopedObjectName: undefined + }, + showObject: true + } + }; + + const actual = { + success, + errors, + output + }; + + assert.deepEqual(expected, actual); + + }); + + it('should not error complex nested arrays required property if parents are not visible', async function() { + const schema = apos.schema.compose({ + addFields: [ + { + name: 'root', + type: 'array', + if: { + showRoot: true + }, + schema: [ + { + name: 'rootString', + type: 'string', + required: true + }, + { + name: 'rootArray', + type: 'array', + required: true, + schema: [ + { + name: 'rootArrayString', + type: 'string', + required: true, + if: { + showRootArrayString: true + } + }, + { + name: 'showRootArrayString', + type: 'boolean' + } + ] + } + ] + }, + { + name: 'showRoot', + type: 'boolean' + } + ] + }); + + const data1 = { + root: [ + { + _id: 'root_id', + metaType: 'arrayItem', + rootString: 'toto', + rootArray: [ + { + _id: 'root_array_id', + metaType: 'arrayItem', + showRootArrayString: true + }, + { + _id: 'root_array_id2', + metaType: 'arrayItem', + rootArrayBool: true, + showRootArrayString: false + } + ] + } + ], + showRoot: true + }; + + const output1 = {}; + const [ success1, errors1 ] = await testConvert(apos, data1, schema, output1); + const foundError1 = findError(errors1, 'root_array_id.rootArrayString', 'required'); + + const data2 = { + root: [ + { + _id: 'root_id', + metaType: 'arrayItem', + rootString: 'toto', + rootArray: [ + { + _id: 'root_array_id', + metaType: 'arrayItem', + rootArrayString: 'Item 1', + showRootArrayString: true + }, + { + _id: 'root_array_id2', + metaType: 'arrayItem', + rootArrayBool: true, + showRootArrayString: false + } + ] + } + ], + showRoot: true + }; + + const output2 = {}; + const [ success2, errors2 ] = await testConvert(apos, data2, schema, output2); + + const expected = { + success1: false, + foundError1: true, + success2: true, + errors2: [], + output2: { + root: [ + { + _id: 'root_id', + metaType: 'arrayItem', + scopedArrayName: undefined, + rootString: 'toto', + rootArray: [ + { + _id: 'root_array_id', + metaType: 'arrayItem', + scopedArrayName: undefined, + rootArrayString: 'Item 1', + showRootArrayString: true + }, + { + _id: 'root_array_id2', + metaType: 'arrayItem', + scopedArrayName: undefined, + rootArrayString: '', + showRootArrayString: false + } + ] + } + ], + showRoot: true + } + }; + + const actual = { + success1, + foundError1, + success2, + errors2, + output2 + }; + + assert.deepEqual(expected, actual); + }); + + // TODO: update this test when support for conditional fields is added to relationships schemas + it('should not error complex nested relationships required property if parents are not visible', async function() { + const req = apos.task.getReq({ mode: 'draft' }); + const schema = apos.schema.compose({ + addFields: [ + { + name: 'title', + type: 'string', + required: true + }, + { + name: '_rel', + type: 'relationship', + withType: 'article', + schema: [ + { + name: 'relString', + type: 'string', + required: true, + if: { + showRelString: true + } + }, + { + name: 'showRelString', + type: 'boolean' + } + ] + } + ] + }); + + const article1 = await apos.article.insert(req, { + ...apos.article.newInstance(), + title: 'article 1' + }); + const article2 = await apos.article.insert(req, { + ...apos.article.newInstance(), + title: 'article 2' + }); + + article1._fields = { + showRelString: false + }; + + article2._fields = { + relString: 'article 2 rel string', + showRelString: true + }; + + const data = { + title: 'toto', + _rel: [ + article1, + article2 + ] + }; + + const output = {}; + const [ success, errors ] = await testConvert(apos, data, schema, output); + const foundError = findError(errors, 'relString', 'required'); + + const expected = { + success: false, + foundError: true + }; + + const actual = { + success, + foundError + }; + + assert.deepEqual(expected, actual); + }); + + it('should add proper aposPath to all fields when validation schema', async function () { + const schema = apos.schema.compose({ + addFields: [ + { + name: 'title', + type: 'string', + required: true + }, + { + name: '_rel', + type: 'relationship', + withType: 'article', + schema: [ + { + name: 'relString', + type: 'string' + } + ] + }, + { + name: 'array', + type: 'array', + schema: [ + { + name: 'arrayString', + type: 'string' + }, + { + name: 'arrayObject', + type: 'object', + schema: [ + { + name: 'arrayObjectString', + type: 'string' + } + ] + } + ] + }, + { + name: 'object', + type: 'object', + schema: [ + { + name: 'objectString', + type: 'string' + }, + { + name: 'objectArray', + type: 'array', + schema: [ + { + name: 'objectArrayString', + type: 'string' + } + ] + } + ] + } + ] + }); + + apos.schema.validate(schema, 'article'); + + const [ titleField, relField, arrayField, objectField ] = schema; + const expected = { + title: 'title', + rel: '_rel', + relString: '_rel/relString', + array: 'array', + arrayString: 'array/arrayString', + arrayObject: 'array/arrayObject', + arrayObjectString: 'array/arrayObject/arrayObjectString', + object: 'object', + objectString: 'object/objectString', + objectArray: 'object/objectArray', + objectArrayString: 'object/objectArray/objectArrayString' + }; + + const actual = { + title: titleField.aposPath, + rel: relField.aposPath, + relString: relField.schema[0].aposPath, + array: arrayField.aposPath, + arrayString: arrayField.schema[0].aposPath, + arrayObject: arrayField.schema[1].aposPath, + arrayObjectString: arrayField.schema[1].schema[0].aposPath, + object: objectField.aposPath, + objectString: objectField.schema[0].aposPath, + objectArray: objectField.schema[1].aposPath, + objectArrayString: objectField.schema[1].schema[0].aposPath + }; + + assert.deepEqual(actual, expected); + }); + + it('should set default value to invisible fields that triggered convert errors', async function () { + const schema = apos.schema.compose({ + addFields: [ + { + name: 'array', + type: 'array', + if: { + showArray: true + }, + schema: [ + { + name: 'arrayString', + type: 'string', + pattern: '^cool-' + }, + { + name: 'arrayMin', + type: 'integer', + min: 5 + }, + { + name: 'arrayMax', + type: 'integer', + max: 10 + } + ] + }, + { + name: 'showArray', + type: 'boolean' + } + ] + }); + apos.schema.validate(schema, 'foo'); + + const input = { + showArray: false, + array: [ + { + arrayString: 'bad string', + arrayMin: 2, + arrayMax: 13 + } + ] + }; + + const req = apos.task.getReq(); + const result = {}; + await apos.schema.convert(req, schema, input, result); + + const expected = { + arrayString: '', + arrayMin: 5, + arrayMax: 10 + }; + + const { + arrayString, arrayMin, arrayMax + } = result.array[0]; + const actual = { + arrayString, + arrayMin, + arrayMax + }; + + assert.deepEqual(actual, expected); + }); }); + async function testSchemaError(schema, input, path, name) { const req = apos.task.getReq(); const result = {}; @@ -4965,3 +5483,34 @@ describe('Schemas', function() { } } }); + +async function testConvert( + apos, + data, + schema, + output +) { + const req = apos.task.getReq({ mode: 'draft' }); + try { + await apos.schema.convert(req, schema, data, output); + return [ true, [] ]; + } catch (err) { + return [ false, err ]; + } +} + +function findError(errors, fieldPath, errorName) { + if (!Array.isArray(errors)) { + return false; + } + return errors.some((err) => { + if (err.data?.errors) { + const deepFound = findError(err.data.errors, fieldPath, errorName); + if (deepFound) { + return deepFound; + } + } + + return err.name === errorName && err.path === fieldPath; + }); +}