From 190a71ddb1f5837ecb914e52879873d7128382c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poul=20Kjeldager=20S=C3=B8rensen?= Date: Mon, 14 Oct 2024 08:46:57 +0200 Subject: [PATCH] feat: Added validation support for submit fields and submit --- .../core/src/components/question/Question.tsx | 4 +- .../question/components/QuestionHeading.tsx | 4 +- packages/core/src/model/QuestionModel.ts | 11 ++++ .../model/json-definitions/JsonDataModels.ts | 4 ++ .../defaults/DefaultModelTransformer.ts | 1 + .../defaults/DefaultQuestionTransformer.ts | 1 + packages/core/src/state/QuickformAction.ts | 8 ++- packages/core/src/state/QuickformReducer.ts | 34 ++++++++-- packages/core/src/state/QuickformState.ts | 3 +- .../action-handlers/QuestionActionHandler.ts | 14 ++-- .../action-handlers/SubmitActionHandler.ts | 64 ++++++++++--------- .../action-handlers/VisibilityHandler.ts | 2 +- packages/core/src/utils/quickformUtils.ts | 6 +- 13 files changed, 107 insertions(+), 49 deletions(-) diff --git a/packages/core/src/components/question/Question.tsx b/packages/core/src/components/question/Question.tsx index 9b390cb..ada1e92 100644 --- a/packages/core/src/components/question/Question.tsx +++ b/packages/core/src/components/question/Question.tsx @@ -27,7 +27,7 @@ export const Question: React.FC = ({ model, style, className }) = const InputType = resolveInputComponent(model.inputType); const logger = resolveQuickFormService("logger"); const { state } = useQuickForm(); - logger.log("QuestionRender for question {@model} InputProps", model); + logger.log("QuestionRender for question {logicalName} {@model} InputProps", model.logicalName, model); const ql = state.slides[state.currIdx].questions.length === 1 ? '' : `.${String.fromCharCode('A'.charCodeAt(0) + state.slides[state.currIdx].questions.indexOf(model))}`; const label = state.isSubmitSlide ? '' : `${state.currIdx + 1}${ql}`; @@ -45,7 +45,7 @@ export const Question: React.FC = ({ model, style, className }) = style={{ ...questionStyling, ...style }} > {model.text && - + {model.text} } diff --git a/packages/core/src/components/question/components/QuestionHeading.tsx b/packages/core/src/components/question/components/QuestionHeading.tsx index de4c717..0a5113f 100644 --- a/packages/core/src/components/question/components/QuestionHeading.tsx +++ b/packages/core/src/components/question/components/QuestionHeading.tsx @@ -10,9 +10,10 @@ type QuestionHeadingProps = { readonly style?: React.CSSProperties; readonly className?: string; readonly label?: string; + readonly required?: boolean; }; -export const QuestionHeading: React.FC = ({ children, label, style = {} }: QuestionHeadingProps) => { +export const QuestionHeading: React.FC = ({ children, label, style = {}, required=false }: QuestionHeadingProps) => { const shouldDisplayNumber = resolveQuickFormService("headingNumberDisplayProvider")(); @@ -43,6 +44,7 @@ export const QuestionHeading: React.FC = ({ children, labe } {children} + {required && *} ); } diff --git a/packages/core/src/model/QuestionModel.ts b/packages/core/src/model/QuestionModel.ts index f4c1782..c2da985 100644 --- a/packages/core/src/model/QuestionModel.ts +++ b/packages/core/src/model/QuestionModel.ts @@ -83,6 +83,17 @@ export type QuestionModel = { */ validationResult?: ValidationResult; + /** + * https://json-schema.org/draft/2019-09/json-schema-validation + */ + validation?: { + + } + /** + * is true if the question is required. Input controls should mark the question as required + */ + isRequired?: boolean + /** is true if the question is active. Input controls should set focus if set to active*/ isActive?: boolean diff --git a/packages/core/src/model/json-definitions/JsonDataModels.ts b/packages/core/src/model/json-definitions/JsonDataModels.ts index 954e97b..3c97fc3 100644 --- a/packages/core/src/model/json-definitions/JsonDataModels.ts +++ b/packages/core/src/model/json-definitions/JsonDataModels.ts @@ -69,6 +69,10 @@ type QuickFormQuestionDefinition = { */ order?: number + /** + * Is the question required to be answered. + */ + isRequired?: boolean } /** diff --git a/packages/core/src/services/defaults/DefaultModelTransformer.ts b/packages/core/src/services/defaults/DefaultModelTransformer.ts index 086d9cc..5461b9f 100644 --- a/packages/core/src/services/defaults/DefaultModelTransformer.ts +++ b/packages/core/src/services/defaults/DefaultModelTransformer.ts @@ -164,6 +164,7 @@ function handleSubmit(submit: QuickFormSubmitDefinition, payload: any): SubmitMo placeholder: uiSchema?.[k]?.["ui:placeholder"], text: (uiSchema?.[k]?.["ui:label"] ?? true) ? v.title : undefined, paragraph: v.description, + isRequired: schema?.required?.includes(k) ?? false, dataType: v.type, ...uiSchema?.[k]?.["ui:inputProps"] ?? {} diff --git a/packages/core/src/services/defaults/DefaultQuestionTransformer.ts b/packages/core/src/services/defaults/DefaultQuestionTransformer.ts index 7f61393..5ecc431 100644 --- a/packages/core/src/services/defaults/DefaultQuestionTransformer.ts +++ b/packages/core/src/services/defaults/DefaultQuestionTransformer.ts @@ -20,6 +20,7 @@ function mapJsonQuestionToModelQuestion(questionKey: string, question: QuestionJ return { answered: hasDefaultValueOrPayload, + isRequired: question.isRequired ?? true, dataType: question.dataType ?? "string", inputProperties: parseInputProperties(question), inputType: question.inputType ?? "text", diff --git a/packages/core/src/state/QuickformAction.ts b/packages/core/src/state/QuickformAction.ts index 184bb7c..3ab78c5 100644 --- a/packages/core/src/state/QuickformAction.ts +++ b/packages/core/src/state/QuickformAction.ts @@ -1,6 +1,7 @@ import { ValidationResult } from "../model/ValidationResult"; import { SubmitStatus } from "../model/SubmitStatus"; import { QuickFormDefinition } from "../model/json-definitions/QuickFormDefinition"; +import { QuickformState } from "./QuickformState"; export type QuickformAnswerQuestionAction = { type: 'ANSWER_QUESTION'; logicalName: string; output: string; dispatch: React.Dispatch, intermediate?: boolean, validationResult?: ValidationResult }; @@ -9,7 +10,12 @@ export type QuickformAction = | { type: 'NEXT_SLIDE' } | { type: 'PREV_SLIDE' } | { type: 'SET_ERROR_MSG'; msg: string } - | { type: 'PROCESS_INTERMEDIATE_QUESTIONS'; dispatch: React.Dispatch; logicalName?: string } + | { + type: 'PROCESS_INTERMEDIATE_QUESTIONS'; + dispatch: React.Dispatch; + logicalName?: string + } + | { type: 'ON_VALIDATION_COMPLETED', callback: (state: QuickformState) => void } | QuickformAnswerQuestionAction | { type: 'SET_VALIDATION_RESULT'; logicalName: string; validationResult: ValidationResult; timestamp: number } | { type: 'COMPUTE_PROGRESS' } diff --git a/packages/core/src/state/QuickformReducer.ts b/packages/core/src/state/QuickformReducer.ts index 8a7e16e..69d9ec0 100644 --- a/packages/core/src/state/QuickformReducer.ts +++ b/packages/core/src/state/QuickformReducer.ts @@ -8,6 +8,7 @@ import { QuestionActionHandler } from "./action-handlers/QuestionActionHandler"; import { VisibilityHandler } from "./action-handlers/VisibilityHandler"; import { trace } from "@opentelemetry/api"; +import { ValidationResult } from "../model/ValidationResult"; export const quickformReducer = (state: QuickformState, action: QuickformAction): QuickformState => { const logger = resolveQuickFormService("logger"); @@ -97,9 +98,25 @@ export const quickformReducer = (state: QuickformState, action: QuickformAction) } case 'SET_VALIDATION_RESULT': { - return QuestionActionHandler.updateQuestionValidation(state, action.logicalName, action.validationResult, action.timestamp) - } + let updatedState= QuestionActionHandler.updateQuestionValidation(state, action.logicalName, action.validationResult, action.timestamp) + + let isValidating = getAllQuestions(updatedState).some(q => q.validationResult?.isValidating ?? false); + + if (!isValidating && updatedState.onValidationCompleteCallback) { + updatedState.onValidationCompleteCallback(updatedState); + } + + return updatedState; + } + case 'ON_VALIDATION_COMPLETED': { + let isValidating = getAllQuestions(state).some(q => q.validationResult?.isValidating ?? false); + if (isValidating) { + return { ...state, onValidationCompleteCallback: action.callback } + } + action.callback(state); + return state; + } case 'PROCESS_INTERMEDIATE_QUESTIONS': { /* Processes all questions that are in an Intermediate-state by ensuring they are successfully transitioned to a fully answered state. * 1. Iterating through all questions in the state.slides array, it checks each question to determine if it's not yet marked as answered and if it has a valid output. @@ -108,7 +125,8 @@ export const quickformReducer = (state: QuickformState, action: QuickformAction) * The overall effect is to ensure no intermediate questions are left behind unanswered or unvalidated. */ - let allIntermediateQuestions = getAllIntermediateQuestions(state.slides); + let tasks: Array> = []; + let allIntermediateQuestions = getAllIntermediateQuestions(state); // Dont include the question currently being answered if (action.logicalName) { @@ -126,11 +144,15 @@ export const quickformReducer = (state: QuickformState, action: QuickformAction) } ); - QuestionActionHandler.validateInput(state, intermediateQuestion.logicalName).then(result => { - action.dispatch({ type: 'SET_VALIDATION_RESULT', logicalName: intermediateQuestion.logicalName, validationResult: result, timestamp: timestamp }) - }); + tasks.push(QuestionActionHandler.validateInput(state, intermediateQuestion.logicalName).then(result => { + action.dispatch({ type: 'SET_VALIDATION_RESULT', logicalName: intermediateQuestion.logicalName, validationResult: result, timestamp: timestamp }); + return result; + })); } } + + + return state; //DISCUSS WITH KBA - should we not run this in answer insteaad? return VisibilityHandler.updateVisibleState(state);; diff --git a/packages/core/src/state/QuickformState.ts b/packages/core/src/state/QuickformState.ts index ccc295c..4eb3f64 100644 --- a/packages/core/src/state/QuickformState.ts +++ b/packages/core/src/state/QuickformState.ts @@ -34,7 +34,8 @@ export type QuickformState = { submitStatus: SubmitStatus; totalSteps: number; classes: Partial, - payloadAugments: Array<(payload: any) => any> + payloadAugments: Array<(payload: any) => any>, + onValidationCompleteCallback?: (state: QuickformState) => void; } export const defaultState = (data: QuickFormModel = defaultData, layout?: LayoutDefinition): QuickformState => { diff --git a/packages/core/src/state/action-handlers/QuestionActionHandler.ts b/packages/core/src/state/action-handlers/QuestionActionHandler.ts index 6ebee67..3c96f49 100644 --- a/packages/core/src/state/action-handlers/QuestionActionHandler.ts +++ b/packages/core/src/state/action-handlers/QuestionActionHandler.ts @@ -31,8 +31,12 @@ export class QuestionActionHandler { newState.data.submit.submitFields[questionIndex] : newState.slides[slideIndex].questions[questionIndex]; - if (!targetQuestion) + if (!targetQuestion) { + const logger = resolveQuickFormService("logger"); + logger.log("QuickForm Reducer - Question not found: {logicalName} {questionIndex} {isSubmitSlide}", + logicalName, questionIndex, state.isSubmitSlide); return state; + } Object.entries(propertiesToUpdate).forEach(([key, value]) => { if (targetQuestion.hasOwnProperty(key) && typeof value === 'object' && !Array.isArray(value) && value !== null) { @@ -66,7 +70,7 @@ export class QuestionActionHandler { }; static startQuestionValidation = (state: QuickformState, logicalName: string, timestamp: number) => { - const currentValidationResult = findQuestionByLogicalName(logicalName, getAllQuestions(state.slides))?.validationResult; + const currentValidationResult = findQuestionByLogicalName(logicalName, getAllQuestions(state))?.validationResult; return this.updateQuestionProperties(state, logicalName, { validationResult: { ...currentValidationResult, timestamp: timestamp, isValidating: true, isValid: false } @@ -83,7 +87,7 @@ export class QuestionActionHandler { * @returns the updated state */ static updateQuestionValidation = (state: QuickformState, logicalName: string, validationResult: ValidationResult, timestamp: number) => { - const currentValidationResult = findQuestionByLogicalName(logicalName, getAllQuestions(state.slides))?.validationResult; + const currentValidationResult = findQuestionByLogicalName(logicalName, getAllQuestions(state))?.validationResult; if (currentValidationResult?.timestamp !== timestamp) { return state; } @@ -93,9 +97,9 @@ export class QuestionActionHandler { } static async validateInput(state: QuickformState, logicalName: string): Promise { - const questionRef = findQuestionByLogicalName(logicalName, getAllQuestions(state.slides)); + const questionRef = findQuestionByLogicalName(logicalName, getAllQuestions(state)); if (!questionRef) { - console.log("Question not valid", [logicalName, getAllQuestions(state.slides)]) + console.log("Question not valid", [logicalName, getAllQuestions(state)]) return { isValid: false, message: 'Question not valid', diff --git a/packages/core/src/state/action-handlers/SubmitActionHandler.ts b/packages/core/src/state/action-handlers/SubmitActionHandler.ts index adbeeab..926b6e5 100644 --- a/packages/core/src/state/action-handlers/SubmitActionHandler.ts +++ b/packages/core/src/state/action-handlers/SubmitActionHandler.ts @@ -4,43 +4,49 @@ import { QuickformAction, QuickformState } from "../index"; export type ServerActionSubmitHandler = (data: any) => Promise>; export class SubmitActionHandler { - static submit = async (state: QuickformState, dispatch: React.Dispatch, onSubmitAsync?: ServerActionSubmitHandler) => { + static submit = (state: QuickformState, dispatch: React.Dispatch, onSubmitAsync?: ServerActionSubmitHandler) => { - try { - const body = this.generatePayload(state); - if (onSubmitAsync) { - const rsp = await onSubmitAsync(body); + dispatch({ type: "PROCESS_INTERMEDIATE_QUESTIONS", dispatch, logicalName: undefined }); + dispatch({ + type: "ON_VALIDATION_COMPLETED", callback: async (state) => { + try { - dispatch({ type: "UPDATE_QUICKFORM_DEFINITION", definition: rsp }); - } else { + const body = this.generatePayload(state); - let rsp = await fetch(state.data.submit.submitUrl, { - method: state.data.submit.submitMethod, - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(body), - credentials: "include" - }); - if (!rsp.ok) { - throw new Error("Failed to submit:" + await rsp.text()); - } - } + if (onSubmitAsync) { + const rsp = await onSubmitAsync(body); - dispatch({ type: "SET_SUBMIT_STATUS", status: { isSubmitSuccess: true, isSubmitting: false, isSubmitError: false } }); - dispatch({ type: 'GO_TO_ENDING' }); + dispatch({ type: "UPDATE_QUICKFORM_DEFINITION", definition: rsp }); - } catch (error: any) { - console.error(error.message); - dispatch({ type: "SET_SUBMIT_STATUS", status: { isSubmitting: false, isSubmitError: true, isSubmitSuccess: false } }); - return; + } else { + + let rsp = await fetch(state.data.submit.submitUrl, { + method: state.data.submit.submitMethod, + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(body), + credentials: "include" + }); + if (!rsp.ok) { + throw new Error("Failed to submit:" + await rsp.text()); + } + } + + dispatch({ type: "SET_SUBMIT_STATUS", status: { isSubmitSuccess: true, isSubmitting: false, isSubmitError: false } }); + dispatch({ type: 'GO_TO_ENDING' }); + + } catch (error: any) { + console.error(error.message); + dispatch({ type: "SET_SUBMIT_STATUS", status: { isSubmitting: false, isSubmitError: true, isSubmitSuccess: false } }); + return; + + } + } + }); - } - // finally { - // dispatch({ type: "SET_SUBMIT_STATUS", status: { isSubmitting: false, isSubmitOK: true } }); - // } } diff --git a/packages/core/src/state/action-handlers/VisibilityHandler.ts b/packages/core/src/state/action-handlers/VisibilityHandler.ts index 2c416a9..0f80845 100644 --- a/packages/core/src/state/action-handlers/VisibilityHandler.ts +++ b/packages/core/src/state/action-handlers/VisibilityHandler.ts @@ -35,7 +35,7 @@ export class VisibilityHandler { while (hasChanges) { hasChanges = false; - for (let question of getAllQuestionsWithVisibilityRule(state.slides)) { + for (let question of getAllQuestionsWithVisibilityRule(state)) { let result = false; logger.log("[visibility handler] [{@engines}] for {question}: {@visibility}", Object.keys(engines), question.questionKey, question.visible, context); diff --git a/packages/core/src/utils/quickformUtils.ts b/packages/core/src/utils/quickformUtils.ts index 72c6ce5..45c25f2 100644 --- a/packages/core/src/utils/quickformUtils.ts +++ b/packages/core/src/utils/quickformUtils.ts @@ -14,11 +14,11 @@ export const isSlideVisited = (slide: SlideModel): boolean => (slide.questions.l export const getCurrentSlide = (state: QuickformState) => (state.slides[state.currIdx]); -export const getAllQuestions = (slides: SlideModel[]): QuestionModel[] => (slides.map(slide => slide.questions).flat()); +export const getAllQuestions = (state: QuickformState): QuestionModel[] => (state.slides.map(slide => slide.questions).flat().concat(state.data.submit.submitFields)); export const updateAllQuestions = (slides: SlideModel[], update: (q: QuestionModel) => QuestionModel) => slides.forEach(slide => { slide.questions = slide.questions.map(update); }); -export const getAllIntermediateQuestions = (slides: SlideModel[]): QuestionModel[] => getAllQuestions(slides).filter(q => q.intermediate); +export const getAllIntermediateQuestions = (state: QuickformState): QuestionModel[] => getAllQuestions(state).filter(q => q.intermediate); type WithRequired = T & { [P in K]-?: T[P] } @@ -26,7 +26,7 @@ function hasVisibilityRule(question: QuestionModel): question is WithRequired getAllQuestions(slides).filter(hasVisibilityRule); +export const getAllQuestionsWithVisibilityRule = (state: QuickformState) => getAllQuestions(state).filter(hasVisibilityRule); export const allQuestionsMap = (slides: SlideModel[]): { [key: string]: QuestionModel } => slides .map(s => s.questions)