-
Notifications
You must be signed in to change notification settings - Fork 592
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Pro 6799 design default values invisible fields #4816
base: main
Are you sure you want to change the base?
Changes from all commits
0d499c5
dbc28ab
a1bb170
471f75e
3bb052e
be2ec8b
1e6bd19
082639b
58dc331
ee2f0ec
2e2661b
d137d38
fc5666f
65df650
9ea5540
96e21d5
6dda768
c350d1c
e7adaee
505a73a
647a792
3a1fa44
d83e1e6
cbf794a
db94f7c
f8e73cd
2341d53
8aae2c7
34b24cd
3668419
7a33bc2
214b44c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,20 @@ module.exports = { | |
{ | ||
fetchRelationships = true, | ||
ancestors = [], | ||
isParentVisible = true | ||
rootConvert = true, | ||
ancestorSchemas = {} | ||
} = {} | ||
) { | ||
const options = { | ||
fetchRelationships, | ||
ancestors, | ||
isParentVisible | ||
ancestorSchemas | ||
}; | ||
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 +618,201 @@ 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 = []; | ||
if (!rootConvert) { | ||
if (convertErrors.length) { | ||
throw convertErrors; | ||
} | ||
|
||
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); | ||
return; | ||
} | ||
|
||
const nonVisibleFields = await getNonVisibleFields({ | ||
req, | ||
schema, | ||
destination | ||
}); | ||
|
||
const errors = await handleConvertErrors({ | ||
req, | ||
schema, | ||
convertErrors, | ||
destination, | ||
nonVisibleFields | ||
}); | ||
|
||
if (errors.length) { | ||
throw errors; | ||
} | ||
|
||
async function 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) { | ||
// 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; | ||
nonVisibleFields.add(curPath); | ||
} | ||
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 getNonVisibleFields({ | ||
req, | ||
schema: field.schema, | ||
destination: arrayItem, | ||
nonVisibleFields, | ||
fieldPath: `${curPath}.${arrayItem._id}` | ||
}); | ||
} | ||
} else if (field.type === 'object') { | ||
await getNonVisibleFields({ | ||
req, | ||
schema: field.schema, | ||
destination: destination[field.name], | ||
nonVisibleFields, | ||
fieldPath: curPath | ||
}); | ||
} | ||
if (isParentVisible === false) { | ||
} | ||
|
||
return nonVisibleFields; | ||
} | ||
|
||
async function 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); | ||
setDefaultToInvisibleField(curDestination, curSchema, error.path); | ||
continue; | ||
} | ||
|
||
if (error.data?.errors) { | ||
const subErrors = await 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; | ||
} | ||
|
||
error.data.errors = subErrors; | ||
} | ||
|
||
if (typeof error !== 'string') { | ||
self.apos.util.error(error.path + '\n' + error.stack); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense to me to show what field failed, error path being the name. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it is better to log this detail later when logging the overall error. But I wouldn't die on that hill. Will the error type ever be a string? I believe that went away in A3/A4... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess it's smart to check. |
||
} | ||
validErrors.push(error); | ||
} | ||
|
||
if (!Array.isArray(error) && typeof error !== 'string') { | ||
self.apos.util.error(error + '\n\n' + error.stack); | ||
return validErrors; | ||
} | ||
|
||
function setDefaultToInvisibleField(destination, schema, fieldName) { | ||
// 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); | ||
} | ||
errorsList.push(error); | ||
} | ||
}, | ||
|
||
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 +821,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; | ||
|
@@ -1345,6 +1467,10 @@ module.exports = { | |
|
||
// Validates a single schema field. See `validate`. | ||
validateField(field, options, parent = null) { | ||
field.aposPath = options.ancestorPath | ||
? `${options.ancestorPath}/${field.name}` | ||
: field.name; | ||
|
||
const fieldType = self.fieldTypes[field.type]; | ||
if (!fieldType) { | ||
fail('Unknown schema field type.'); | ||
|
@@ -1374,7 +1500,11 @@ module.exports = { | |
warn(`editPermission or viewPermission must be defined on root fields only, provided on "${parent.name}.${field.name}"`); | ||
} | ||
if (fieldType.validate) { | ||
fieldType.validate(field, options, warn, fail); | ||
const opts = { | ||
...options, | ||
ancestorPath: field.aposPath | ||
}; | ||
fieldType.validate(field, opts, warn, fail); | ||
} | ||
// Ancestors hoisting should happen AFTER the validation recursion, | ||
// so that ancestors are processed as well. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method might be moved somewhere else