-
Notifications
You must be signed in to change notification settings - Fork 2.2k
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
Initial support for property & schema dependencies #659
Conversation
Using that in my project now. Works great. Happy to provide any needed help for merging this. |
I've seen it argued in other issues such as #430 that instead of using schema dependencies you can dynamically change the schema when data changes. This is indeed very powerful. But I've added a note there with some use cases for dependencies. |
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.
I really like this, thanks for the effort put in this PR. Here are some preliminary remarks before getting further into the patch.
src: | ||
"https://spacetelescope.github.io/understanding-json-schema/reference/object.html#dependencies", | ||
description: | ||
"In the following example, whenever a `credit_card` property is provided, a `billing_address` property must also be present.", |
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.
Markdown is not supported, so backticks won't turn into <code>
blocks, though you could use JSX instead of a string here.
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.
Oh interesting, I didn't think of that. I'll take a look. Thank you.
src/components/fields/ArrayField.js
Outdated
const itemSchema = retrieveSchema( | ||
schema.items, | ||
definitions, | ||
formData[index] |
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.
Nit: here and in some other places, this could be just item
.
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.
Good point, I felt like I was "hacking" a bit as I am still familiarizing myself with the code base. Thank you for the feedback. I will update.
definitions, | ||
formData | ||
); | ||
} else if (schema.hasOwnProperty("dependencies")) { |
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.
How about extracting dependency handling logic into its own dedicated function? This whole retrieveSchema
one is getting huge.
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.
I was just thinking of this this morning when I started thinking about this pull request again due to others commenting on the pull request. I will see if I can have some updates up today or tomorrow. Thank you.
src/utils.js
Outdated
// Drop the dependencies from the source schema. | ||
let { dependencies, ...localSchema } = schema; | ||
// Process dependencies updating the local schema properties as appropriate. | ||
for (const key of Object.keys(dependencies)) { |
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.
Nit: why not just for (const key in dependencies)
?
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.
Because I'm an EcmaScript noob? 😄
src/utils.js
Outdated
true | ||
) | ||
); | ||
if (Array.isArray(value.oneOf)) { |
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.
Hmm so this introduces partial support for oneOf
while fields are still not handled at all, I'd have preferred introducing this after we get proper support for oneOf.
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.
Yeah, I'm still not certain how oneOf
would work in general but I kind of view the usage here as special. The idea is that depending on values in formData
the overall schema will dynamically update to one of several options but this still doesn't support a user selecting one of several schemas to interact with as one would need if using oneOf
elsewhere in a schema. I have started updating README.md locally and I am planning on explaining just this as implementing support for this kind of oneOf
is very independent of general support for oneOf
(and vice versa I believe although I could be wrong).
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.
Right, we may just want to document this specific behavior here.
src/utils.js
Outdated
}; | ||
const { errors } = jsonValidate(formData, conditionSchema); | ||
if (errors.length === 0) { | ||
const dependentSchema = { ...subschema }; |
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.
Beware as cloning this way isn't recursive.
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.
Correct. I am just trying to not mutate the incoming schema at all so I make copies as needed in the tree instead of recursively coping the entire tree (although I suppose the later could be done for readability/maintainability reasons although I'm not sure how to do that off the top of my head).
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.
Deep copying objects is hard, Json serial/deserialization is known to be quite efficient but maybe not enough here as this operation may be executed on each keystroke in a field with this lib.
src/utils.js
Outdated
// Skip this dependency if its trigger property is not present. | ||
if (!formData[key]) { | ||
// fixme: a falsey check may not be appropriate here, I'm not sure | ||
continue; |
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.
Yeah, maybe just check for undefined I think.
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.
Hmm, I'll try that but I may already have done so. If a field is left empty does it come in as undefined
in the formData
or as an empty string ""
? I guess I will find out. :-)
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.
AFAICR it depends if the field value has been edited already or not; by default a schema string field value is set to undefined (property is not preset), but as soon as users manipulate the widget, eg. typing then erasing the entered value, it's set to an empty string. So yeah, it might be a little tricky.
test/utils_test.js
Outdated
}); | ||
}); | ||
}); | ||
describe("true condition", () => { |
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.
Nit: empty line between describe statements.
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.
It can be done! 😄
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.
FYI: I appreciate the "Nit" comments. Every little bit counts.
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.
Thank you for the comments so far @n1k0. I very much appreciate it.
src/utils.js
Outdated
// Process dependencies updating the local schema properties as appropriate. | ||
for (const [key, value] of Object.entries(dependencies)) { | ||
// Skip this dependency if its trigger property is not present. | ||
if (!formData[key]) { |
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.
I looked but I have not yet found a more proper way to determine if a field is defined. This is a "falsey" check which may not be appropriate. If someone could give me a suggestion on what to use instead that would be very much appreciated. Thank you.
src: | ||
"https://spacetelescope.github.io/understanding-json-schema/reference/object.html#dependencies", | ||
description: | ||
"In the following example, whenever a `credit_card` property is provided, a `billing_address` property must also be present.", |
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.
Oh interesting, I didn't think of that. I'll take a look. Thank you.
src/components/fields/ArrayField.js
Outdated
const itemSchema = retrieveSchema( | ||
schema.items, | ||
definitions, | ||
formData[index] |
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.
Good point, I felt like I was "hacking" a bit as I am still familiarizing myself with the code base. Thank you for the feedback. I will update.
definitions, | ||
formData | ||
); | ||
} else if (schema.hasOwnProperty("dependencies")) { |
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.
I was just thinking of this this morning when I started thinking about this pull request again due to others commenting on the pull request. I will see if I can have some updates up today or tomorrow. Thank you.
src/utils.js
Outdated
// Drop the dependencies from the source schema. | ||
let { dependencies, ...localSchema } = schema; | ||
// Process dependencies updating the local schema properties as appropriate. | ||
for (const key of Object.keys(dependencies)) { |
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.
Because I'm an EcmaScript noob? 😄
src/utils.js
Outdated
// Skip this dependency if its trigger property is not present. | ||
if (!formData[key]) { | ||
// fixme: a falsey check may not be appropriate here, I'm not sure | ||
continue; |
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.
Hmm, I'll try that but I may already have done so. If a field is left empty does it come in as undefined
in the formData
or as an empty string ""
? I guess I will find out. :-)
src/utils.js
Outdated
true | ||
) | ||
); | ||
if (Array.isArray(value.oneOf)) { |
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.
Yeah, I'm still not certain how oneOf
would work in general but I kind of view the usage here as special. The idea is that depending on values in formData
the overall schema will dynamically update to one of several options but this still doesn't support a user selecting one of several schemas to interact with as one would need if using oneOf
elsewhere in a schema. I have started updating README.md locally and I am planning on explaining just this as implementing support for this kind of oneOf
is very independent of general support for oneOf
(and vice versa I believe although I could be wrong).
src/utils.js
Outdated
}; | ||
const { errors } = jsonValidate(formData, conditionSchema); | ||
if (errors.length === 0) { | ||
const dependentSchema = { ...subschema }; |
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.
Correct. I am just trying to not mutate the incoming schema at all so I make copies as needed in the tree instead of recursively coping the entire tree (although I suppose the later could be done for readability/maintainability reasons although I'm not sure how to do that off the top of my head).
test/utils_test.js
Outdated
}); | ||
}); | ||
}); | ||
describe("true condition", () => { |
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.
It can be done! 😄
test/utils_test.js
Outdated
}); | ||
}); | ||
}); | ||
describe("true condition", () => { |
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.
FYI: I appreciate the "Nit" comments. Every little bit counts.
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 looks generally very good to me, though I fear missing something important. I'd like more feedback and reviews from people like @olzraiti @mfulton26 @faassen or @glasserc before giving final approval.
src/utils.js
Outdated
} else { | ||
// No $ref or dependencies attribute found, returning the original schema. | ||
return schema; | ||
} | ||
} | ||
|
||
function resolveDependencies(schema, definitions, formData) { | ||
// Drop the dependencies from the source schema. | ||
let { dependencies, ...resolvedSchema } = schema; |
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.
We should probably default dependencies
to an empty object to avoid throwing on Object.keys(undefined)
, what do you think?
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.
Sounds good to me.
src/utils.js
Outdated
// Drop the dependencies from the source schema. | ||
let { dependencies, ...resolvedSchema } = schema; | ||
// Process dependencies updating the local schema properties as appropriate. | ||
for (const key of Object.keys(dependencies)) { |
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.
Nit: this could still be for..in
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.
Yep, I missed that when I moved things around before.
src/utils.js
Outdated
return schema; | ||
} | ||
schema = { ...schema }; | ||
schema.required = Array.isArray(schema.required) ? [...schema.required] : []; |
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.
Nit: Would be so much nicer to have a way to properly clone the whole schema in a single call... maybe just introduce a simple clone
function in utils leveraging JSON.parse(JSON.stringify(obj))
, so we'll have a single generic reference impl. we'll be able to tweak whenever required.
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.
I don't really feel that cloning is necessary here, especially if you replace the for
loop below with a filter
call. You could even take it a step further and use a Set.
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.
I think using a Set
makes for a good solution. I'll try that for now.
src/utils.js
Outdated
continue; | ||
} | ||
schema.required.push(property); | ||
} |
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.
Nit: this for
loop looks like it could be more legibly replaced by a Array#filter
call. Or maybe not, we should at least try.
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.
With the Set
implementation this actually goes away. 👍
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.
Hi, thanks for tagging me in on this @n1k0 ! It's a great PR, thoroughly documented and adding tests, both of which are great signs.
My biggest concern here is that before this PR, rjsf doesn't support oneOf
at all, but after this PR, rjsf "kind of, sort of" supports oneOf
under certain very restrictive conditions. Those conditions aren't really expressed anywhere, either. I imagine this behavior is important to you because the PR would be much simpler without it. On the other hand, this lets us explore oneOf
in a simpler way without having any questions of what UI would be necessary to let the user change to an alternative schema. I guess I'm OK including this behavior if we document exactly what it does and when. (Something explaining that you can use oneOf
to handle more complicated cases, and how we examine oneOf
alternatives one at a time, comparing just the initial property against the definition of that property in each alternative, and adding the rest of the schema if it matches.)
I'm a little concerned about weird recursive cases, like what do we do with
{
"type": "object",
"properties": {
"a": {"enum": ["a1", "a2"]},
"b": {"enum": ["b1", "b2"]}
},
"dependencies": {
"a": {
"oneOf": [
{"properties": {
"a": {"enum": ["a1"]},
"b": {"enum": ["b1"]}
}},
{"properties": {
"a": {"enum": ["a2"]},
"b": {"enum": ["b2"]}
}}
],
"b": {
"oneOf": [
{"properties": {
"b": {"enum": ["b1"]},
"a": {"enum": ["a2"]}
}},
{"properties": {
"b": {"enum": ["b2"]},
"a": {"enum": ["a1"]}
}}
]
}
}
}
... but maybe this isn't a fair criticism, because there are certainly an infinite number of invalid JSON schemas that we can't really expect to detect or handle.
I nitpicked a few of your function names. To me resolve
carries a meaning similar to reduce
-- to take one thing and simplify some aspect of it somehow. But I haven't really been involved with the maintenance of this project much lately so feel free to disregard my opinion on this subject. 😳
src/utils.js
Outdated
return schema; | ||
} | ||
schema = { ...schema }; | ||
schema.required = Array.isArray(schema.required) ? [...schema.required] : []; |
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.
I don't really feel that cloning is necessary here, especially if you replace the for
loop below with a filter
call. You could even take it a step further and use a Set.
src/utils.js
Outdated
return resolvedSchema; | ||
} | ||
|
||
function resolvePropertyDependency(schema, properties) { |
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.
I don't really like this name for this function. How about addRequired
?
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.
I don't like it either but I couldn't think of something better at the time. 😄
I'm actually wonder if withDependentProperties
would make sense. That way it is clear that this function returns a new schema object with the additional dependent properties (in the required property which is how property dependencies work).
Thoughts?
src/utils.js
Outdated
return schema; | ||
} | ||
|
||
function resolveConditionalSchema(schema, definitions, formData, value) { |
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.
I don't like this function name either. How about mergeSchemas
? (Changing the value
parameter to something like additionalSchema
or even conditionalSchema
.)
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.
Agreed.
src/utils.js
Outdated
const { | ||
[key]: conditionProperty, | ||
...dependentProperties | ||
} = subschema.properties; |
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.
I had to stare at this for a little while trying to figure it out.. maybe rename dependentProperties
to propertiesToAdd
, and/or add a comment like Remove key from the properties we will merge in. Instead, use it as a condition
?
src/utils.js
Outdated
const { errors } = jsonValidate(formData, conditionSchema); | ||
if (errors.length === 0) { | ||
const dependentSchema = { ...subschema }; | ||
dependentSchema.properties = dependentProperties; |
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.
You can do this in one line: const dependentSchema = { ...subschema, properties: dependentProperties };
src/utils.js
Outdated
function resolveConditionalSchema(schema, definitions, formData, value) { | ||
return retrieveSchema( | ||
mergeObjects(schema, retrieveSchema(value, definitions, formData), true) | ||
); |
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.
Why is the outer retrieveSchema
necessary? (A comment might help.)
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.
It might not be... I'l do some testing and see. I think I was too liberal with calling retrieveSchema
src/utils.js
Outdated
schema = mergeObjects( | ||
schema, | ||
retrieveSchema(dependentSchema, definitions, formData) | ||
); |
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.
Is an outer retrieveSchema
necessary here? Alternately, should we use mergeSchema
(above)?
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.
(Oops, I posted this comment on the wrong line before...)
I added a test justifying the need for a call to retrieveSchema
, plus fixed a minor bug that such a test uncovered.
src/utils.js
Outdated
schema = mergeObjects( | ||
schema, | ||
retrieveSchema(dependentSchema, definitions, formData) | ||
); |
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.
Should we break
once we find a oneOf
that matches?
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 probably actually needs to filter all of the subschemas and confirm that only one is valid. I will update.
src/utils.js
Outdated
); | ||
if (Array.isArray(oneOf)) { | ||
schema = resolveDynamicSchema(schema, definitions, formData, key, oneOf); | ||
} |
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.
If oneOf
isn't an array, what is the correct behavior? I don't think silent failure is correct..
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.
Hmm, I agree. I haven't looked yet throughout this project. Do we ever display to the user that they have created a bad JSON schema? Or do we log warnings for this kind of thing in the console?
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.
I've changed this to throw, which we appear to do for some schemas that are too dysfunctional to work. (For example, getDefaultFormState
throws on non-object schemas.)
@glasserc I took a quick glance at your input and I totally agree I've just been pretty busy this week with other things. I should be able to make some updates soon though. Thanks! |
Things I still need to do:
Thank you for the feedback, please feel free to reiterate and/or provide additional feedback as some of the decisions I've made in an attempt to "improve" things might not have been the best decisions. 😄 |
Hmmm, I'm not sure why the build is working but I'm not going to look at this again until at least tomorrow so it can wait until at least then. |
2433f70
to
306dcce
Compare
70479e6
to
a5d81a7
Compare
- Property dependencies: Make additional properties required whenever a trigger property is specified. - Schema dependencies: Dynamically add schema properties based on data entered.
a5d81a7
to
9235bac
Compare
Okay, I think this ready to be reviewed again. |
I took the liberty of adding a little bit of documentation to the README explaining in the vaguest possible terms how this works and fixed up a few stylistic nits that bothered me. The tests seem to pass, so I'm merging it. Thanks so much for your work on this! |
src/utils.js
Outdated
@@ -422,7 +422,7 @@ function resolveDependencies(schema, definitions, formData) { | |||
// Process dependencies updating the local schema properties as appropriate. | |||
for (const dependencyKey in dependencies) { | |||
// Skip this dependency if its trigger property is not present. | |||
if (formData[dependencyKey] === undefined) { | |||
if (!formData.hasOwnProperty(dependencyKey)) { |
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.
@glasserc Unfortunately it looks like this change broke properties and schema dependencies. With the change conditionally required properties are always marked as required (with an asterisk (*)) and conditionally declared properties are always shown. See the playground for the examples.
Note that the dynamic schema still works correctly with the specific oneOf
case.
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.
I'm surprised this passed the tests... I must not have written very good ones. 😄
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.
Yeah, that's unfortunate.. the way we computeDefaults
fills missing fields with undefined
s. I don't like that design but it's outside the scope of this PR. Thanks for letting me know that I broke it :)
Awesome. I'm happy you've made the additions. This is really great and thank you all for supporting the change! |
I think the hasOwnProperty change may have caused a hiccup though. Please see my comment on your commit. Thank you. |
Just out of curiosity, how soon might we be able to get the latest version deployed to the playground? Thanks! |
Guys, very nice feature to have. Any idea when this will be released? |
@glasserc This appears to be published to npm. I'm curious how we get the playground updated. Do you know? Thanks. |
Sorry, I forgot to push it to the playground. It should be up now. |
Thanks! |
Reasons for making this change
JSON Schema supports property and schema dependencies which allow changing the schema for an object (even a single object within an array of objects) based on the presence or value of certain properties.
This change brings initial support for property dependencies and schema dependencies which allows developers to define conditionally required fields, conditionally present fields, and dynamic schemas (e.g. changing a property type based on the value of another field).
See #616 and #430 (see also #522).
Checklist
This is a work in progress but I am looking for some initial feedback as I have not contributed to this project before and I am looking for early feedback on the implementation, etc.
npm run cs-format
on my branch to conform my code to prettier coding style