fix(web-forms#883): allow users to recover from xpath errors - #1908
garethbowen wants to merge 41 commits into
Conversation
🦋 Changeset detectedLatest commit: a826b71 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| fields: references.join(', '), | ||
| count: references.length | ||
| }) }} | ||
| </li> |
There was a problem hiding this comment.
The most common case is a single error violation, potentially impacting multiple fields, so I've optimised the UX for that case.
| @@ -91,14 +91,23 @@ describe('#format-date()', () => { | |||
|
|
|||
| describe('invalid dates', () => { | |||
There was a problem hiding this comment.
Now some of these throw and some return null... It may still not be right but it's closer.
|
@latin-panda This is now ready for review, but given the size the change ended up being I'm not sure we want to merge it in 1.1.1. I'm happy to leave this for 1.2.0 if that feels safer to you. |
latin-panda
left a comment
There was a problem hiding this comment.
I haven't finished reviewing or testing, but I wanted to share my feedback so far. I'll continue tomorrow
| const chunks: TextChunk[] = []; | ||
| const mediaSources: MediaSources = {}; | ||
| const chunkExpressions = getChunkExpressions(context, definition); | ||
| context.setError(null); |
There was a problem hiding this comment.
Let me see if I'm getting this correctly:
Each node/field has one error "slot". And many things write to that slot (the field's calculate, relevant, label, constraint). Each of them sets the slot to null when it succeeds.
So the label succeeds and sets it to null. But if the calculate failed before then, the error is gone. Does it make sense? 🤔
Maybe it's better to have a map of actors (calculate, constraint, etc.) and the corresponding error.
There was a problem hiding this comment.
Done, thanks for spotting this!
My solution feels clunky so if you have a better way to do it I'd love to hear it, but this does work.
There was a problem hiding this comment.
Thank you! The map is better, but each place still has to set and clear its own key. I haven't checked all cases but I found that createTextRange L111 sets the label error and clears it on the line below, so a broken itext shows nothing, and label, hint and messages share the same key, so one can hide the other's error.
I was having a deeper look, and each expression is already a memo that returns a Result, and result.error.message has the text we need. So we could have one memo per node that reads all the results and returns the ones that failed. Since it's reactive, it re-runs when something changes and then a fixed expression returns success, the error is removed automatically.
The createErrorValidation can read that memo instead of getError(), and the violations list stays the same.
The only thing I see so far with that approach is the calculate, setvalue and the text chunks don't return a Result yet, so they'd need that.
There was a problem hiding this comment.
🤔
It's definitely a more reactive approach, and solves all the issues about keeping the error state correct. If I understand correctly, the InstanceNode would have an abstract errors accessor that child nodes would override with a memo which reactively updates based on the computation Results that it knows about. For example, the ValueNode would check for errors on the value Result, and the RepeatRangeControlled could check for errors on the computeCount Result. Each Node's accessor would probably also call the accessor on their parent node to get a combined list of errors. Is that what you were thinking?
It's another significant change so I wanted to confirm I understood what you meant before spending too much time building it out.
There was a problem hiding this comment.
Yes, correct! Just one thing about call the accessor on their parent, if you mean the parent class (super.errors() + the node's own results), yes. But if you mean the parent node, the createAggregatedViolations already walks the children up to the root, so pulling from the parent would list the same error once per descendant, right?
Thinking about setvalue from my last comment, the actions run on events, so there is no Result to read. They would need to store the error (or null) of their last run on the destination node, and the node's errors() reads that 🤔
There was a problem hiding this comment.
if you mean the parent class (super.errors() + the node's own results), yes
Yes this is what I meant.
Thinking about setvalue from my last comment, the actions run on events, so there is no Result to read.
Yes I think there will need to be some calculations that store errors outside of the Result. I'll see what I can come up with today.
| @@ -339,7 +343,11 @@ const showValidationError = computed(() => { | |||
| if (errorBannerDismissed.value) { | |||
There was a problem hiding this comment.
I haven't tested it, but I think errorBannerDismissed should be set to false here so that new evaluations with errors display the banner.
There was a problem hiding this comment.
We don't currently do that for other violations until you hit the next page or submit button, right? I feel like it would just be annoying if you have explicitly dismissed it for it to pop up again, even if now the message is different.
a33c7bf to
a18801b
Compare
There was a problem hiding this comment.
I'm still digging into the details :) Meanwhile, I wanted to share some feedback, and I replied to one of the threads with an idea for catching errors from Result more reactively. Let me know what you think!
|
|
||
| case 'upload': | ||
| // leaf node | ||
| return violationReference(child); |
There was a problem hiding this comment.
In L38 collects attributes for the parent, right? but a leaf question never becomes context, so its attributes are skipped (the submit is not blocked on error)
| return violationReference(child); | |
| return [...violationReference(child), ...child.getAttributes().flatMap(violationReference)]; |
There was a problem hiding this comment.
True! I've fixed this by moving the attributes violations into the violationReference function, so when it's getting violations for the node it also checks the attributes.
| readonly evaluator: EngineXPathEvaluator; | ||
| readonly contextReference: Accessor<string>; | ||
| readonly getActiveLanguage: Accessor<ActiveLanguage>; | ||
| readonly errorState: Accessor<ErrorState>; |
There was a problem hiding this comment.
I couldn't find anything that reads this error state. When an item label is broken (<label ref="badFn()"/>) the error seems lost?. I wonder if it could bubble up to the control's state.
| message: error, | ||
| } as const; | ||
| } | ||
| return null; |
There was a problem hiding this comment.
We don't block the form if the question is relevant=false, right?
I think it should return null in that case
if !context.isRelevant() { returns null
There was a problem hiding this comment.
Hmm.. In this case I'm not sure. One example is what if the expression for relevant is the thing with the error? Ultimately if this field is in an error state we can't be confident if it is relevant or not. To be on the safe side I think it's fair to block any form with an unresolved error in it.
There was a problem hiding this comment.
Sounds good! If the question isn't visible and the relevant value is false, could this be used to improve the error message?
"the hidden field has invalid ....."
Or the question name/ref.
3307a21 to
55326b8
Compare
8818fdd to
4cf9e96
Compare
|
@latin-panda This is one of those changes that keeps going, and I'd appreciate a gut check to see if it's worth continuing. I've implemented your suggestion to make the error handling more reactive, rather than storing it in a map, and it's now reached a stable point in this PR. I have yet to handle errors for node options, labels, and hints. These are all significant additional changes so I paused to do some re-evaluation if this is the right approach. Firstly the code is significantly more complicated after this. Just about every file in the engine now needs to have additional branches to handle errors that may be reactively returned on any update. Secondly the engine is somewhat slower. There may be some optimisation missing but as it currently stands, the two child-vaccination smoketests run in 7.5s and 22s, up from 6.1s and 18s. So that's about a 20% slow down. It does make sense that it's somewhat slower because every change now needs to be checked to see if it's a failure, and the validation state checks many more potential sources of error. On the other hand, a definite benefit is it will give us a clean way to handle error states that occur within the Engine itself which are currently still throwing. Because this should be a very rare occurrence I no longer believe it's worth the performance and complexity cost. What do you think? If you agree, then we have a couple of options, in addition to those I outlined in the issue.
|
|
@garethbowen, thanks for all the work on this, and for being open to trying all these ideas! I was curious about where the slowdown comes from, so I dived into the code and ended up trying an idea on top of your branch. I didn't want to step on your code, so I put it in a separate draft PR for you to look at, no pressure to use it (the PR is not complete, it needs more test coverage and more polishing). I found that every evaluation returns a new
I agree it's not worth it with the complexity and the slowdown, but I think the draft PR removes both. It's actually close to your option 2. The difference is that the error is kept on the node and not on the root, so it clears by itself when the input is fixed. It also covers labels, hints and node options. If you prefer to keep your approach, there is also a small change that helps a lot. The |
| return this.getValidationViolation(); | ||
| } | ||
|
|
||
| // Attributes never change once the node is built, so they are read without tracking. |
There was a problem hiding this comment.
In which case, shouldn't the getAttributes function also be untracked? Should attributes just be an array removing the need for the createAttributeState call at all? This is probably outside the scope of this change but could be a nice performance and simplicity win.
| // prettier-ignore | ||
| type ComputedExpression<Type extends DependentExpressionResultType> = Accessor< | ||
| EvaluatedExpression<Type> | ||
| export type ComputedExpression<Type extends DependentExpressionResultType> = Accessor< |
There was a problem hiding this comment.
This is the main difference between your draft pr and this... In the draft PR the computed expression had an error property with a function. I found this difficult to read, and meant there were two possible ways errors were being tracked, either through the error() fn or through the registerExpressionError
My latest commit uses the registerExpressionError exclusively so the ComputedExpression is unchanged from master.
|
@latin-panda Thanks! I merged your draft PR and then made a few changes...
This passes the tests, and executes the smoke test in about the same time as your draft PR. What do you think? |
Closes getodk/web-forms#883
What has been done to verify that this works as intended?
Manual testing, CI.
Why is this the best possible solution? Were any other approaches considered?
Discussed on the issue.
How does this change impact users? Describe intentional behavior changes from code updates. What are the regression risks?
Error messages are now shown without interrupting flow, and inline if possible.
Does this change require updates to user documentation? If so, please file an issue here and include the link below.
No.