-
Notifications
You must be signed in to change notification settings - Fork 7
Spike adding field validation UI feedback
There is a requirement to prevent surveys launching when there are still validation errors.
https://trello.com/c/7DKHeOHG/1067-spike-validator-question-title-1-day
This spike will investigate a possible approach and provide a PoC. This builds on a previous investigative spike: https://github.com/ONSdigital/eq-author-app/wiki/Validation-for-Authors-Spike and is based on prototype https://author.netlify.com/#/eq-author/
The PoC is on this branch: https://github.com/ONSdigital/eq-author-app/compare/1067-spike-validator-question-title-1-day
Specifically, we need to address:
- How error validations should be represented in the schema
- How a page/answer decides if something is invalid
- How a question page calculates & displays the total number of errors
- How a new question page is identified - a newly created page doesn't immediately show validation errors
- How a new answer is identified - a newly created answer doesn't immediately show validation errors
- How question page metadata is validated
- How an answer is validated
- How the Launch Survey button is only enabled when there are no validation errors
A previous spike https://github.com/ONSdigital/eq-author-app/wiki/Validation-for-Authors-Spike and is based on prototype https://author.netlify.com/#/eq-author/ looked at different approaches to storing the validation. Two options considered are:
- Raise an Error when invalid data submitted - this prevents invalid data from being stored. Not technically what we want as we need the user to be able to save invalid data, just not to be able to launch a survey
- Create a validationError property on the question/answer objects - this allows invalid data to be saved and relies on UI to report on validation errors
A new GraphQL fragment has been created to represent validation errors:
fragment ValidationErrorInfo on QuestionPage {
validationErrorInfo {
errors {
field
message
},
totalCount
}
}
where totalCount = number of errors on page + child answers.
This is calculated in the question page resolver on api. Logic is currently done within component but possible rule engine or separate component would be more beneficial?
const questionPageValidation = page => {
const errors = [];
/* Page validation done here - maybe a better way of doing it? Rule engine, separate component? */
if (!page.title) {
errors.push({
field: "title",
message: "Question title is required",
});
}
// Add total answer validation errors
const answerErrorCount = page.answers.reduce((acc, answer) => {
return answer.validationErrorInfo && answer.validationErrorInfo.totalCount
? acc + answer.validationErrorInfo.totalCount
: acc;
}, 0);
return {
errors,
totalCount: errors.length + answerErrorCount,
};
};
Resolvers.QuestionPage = {
...
validationErrorInfo: page => questionPageValidation(page),
}
ISSUE: In questionPageValidation, page.answers has list of answers but without their corresponding validationErrorInfo object. How to access validationErroInfo at this point? This prevents the answers contributing to an invalid page.
The totalCount property calculated in 2. above contains the total number of validation errors on the question page. Adding the validationErrorInfo property to graphql query pageNavItem will return total error count in page
UnwrappedPageNavItem.fragments = {
...
validationErrorInfo {
totalCount
}
...
// The navigation item can display to total count if necessary:
<NavLink>
{page.validationErrorInfo.totalCount
? `${page.displayName} (${page.validationErrorInfo.totalCount})`
: page.displayName}
</NavLink>
eq-author/src/App/QuestionnaireDesignPage/NavigationSidebar/PageNavItem.js
A newly created question page does not show the validation errors immediately so we need to identify it's newness. Apollo v2.5 introduced local stage management which can be used to store data client side: https://www.apollographql.com/docs/react/essentials/local-state
Client side resolvers are created to track new pages/answers etc
This defines additional schema properties, queries & mutations available only on the client.
e.g.
export const resolvers = {
Mutation: {
createQuestionPage: (_, input, { cache }) => {
cache.writeData({ data: { newPageId: _.createQuestionPage.id } });
},
}
}
Writes the latest created question page id to local cache. This can then be used in the page query to create an isNew property on the page object
const GET_NEW_PAGE_ID = gql`
query newPageId {
newPageId @client
}
`;
export const resolvers = {
QuestionPage: {
isNew: (questionPage, _, { cache }) => {
const { newPageId } = cache.readQuery({ query: GET_NEW_PAGE_ID });
if (questionPage.id !== newPageId) {
// Reset new page id
cache.writeData({
data: {
newPageId: null,
newAnswerId: null,
newOptions: [],
},
});
}
return questionPage.id === newPageId;
},
},
Mutation: {
createQuestionPage: (_, input, { cache }) => {
cache.writeData({ data: { newPageId: _.createQuestionPage.id } });
},
}
}
See https://github.com/ONSdigital/eq-author-app/compare/1067-spike-validator-question-title-1-day?expand=1#diff-f1d78d9edde1c83b437cacbc9f770ca3 for working example.
5. How a new answer is identified - a newly created answer doesn't immediately show validation errors
Similar to a page, an answer needs to be identified as new and can be calculated in the same way as questionPage. An answer also needs to track whether it's lost focus as validation messages for new answers are only shown when the answer loses focus.
Whether a form component has lost focus is tracked in withEntityEditor, a wrapper around form components. It listens for an onBlur event bubbling up from its children and sets a local state variable appropriately.
handleLoseFocus = () => {
this.setState({
lostFocus: true,
});
};
<div onBlur={this.handleLoseFocus}>
<WrappedComponent
{...this.props}
</WrappedComponent>
</div>
We also need to track whether the answer itself has lost focus. This is useful for multi-part answers such as radio buttons where the second radio option might not have been in focus so won't trigger the onBlur on its withEntityEdit wrapper.
An answers focus state is tracked in AnswerEditor which wraps each answer. The answer is wrapped around a node with can be referenced in Reacts refs.
<div ref={this.answerNode}>
<Answer onBlur={this.handleLostFocus} data-test="answer-editor">
</div>
The onBlur listener can then check whether the focus is still within the answer or has left
handleLostFocus = () => {
const isWithin = this.answerNode.current.querySelector(":focus-within");
if (!isWithin) {
this.setState({
answerLostFocus: true,
});
}
};
}
The answerLostFocus state can be passed down to it's child elements for reference
<BasicAnswer {...this.props} answerLostFocus={answerLostFocus} />;
The launch survey button can be disabled by checking if any validation errors exist in any of the pages
canLaunchSurvey = () => {
let surveyValid = true;
this.props.questionnaire.sections.forEach(section => {
section.pages.forEach(page => {
if (
page.validationErrorInfo &&
page.validationErrorInfo.totalCount > 0
) {
surveyValid = false;
}
});
});
return surveyValid;
};
eq-author/src/components/Header/index.js
ISSUE On the prototype, a render occurs on QuestionnaireDesignPage which throws an error
if (!loading && !error && !questionnaire) {
throw new Error(ERR_PAGE_NOT_FOUND);
}
No error has occurred but for some reason it renders which loading=false & questionnaire=null, which didn't happen previously.