Skip to content

Commit

Permalink
feat: Added validation support for submit fields and submit
Browse files Browse the repository at this point in the history
  • Loading branch information
pksorensen committed Oct 14, 2024
1 parent d06a208 commit 190a71d
Show file tree
Hide file tree
Showing 13 changed files with 107 additions and 49 deletions.
4 changes: 2 additions & 2 deletions packages/core/src/components/question/Question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const Question: React.FC<QuestionProps> = ({ 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}`;
Expand All @@ -45,7 +45,7 @@ export const Question: React.FC<QuestionProps> = ({ model, style, className }) =
style={{ ...questionStyling, ...style }}
>
{model.text &&
<QuestionHeading label={label} >
<QuestionHeading required={model.isRequired} label={label} >
{model.text}
</QuestionHeading>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ type QuestionHeadingProps = {
readonly style?: React.CSSProperties;
readonly className?: string;
readonly label?: string;
readonly required?: boolean;
};

export const QuestionHeading: React.FC<QuestionHeadingProps> = ({ children, label, style = {} }: QuestionHeadingProps) => {
export const QuestionHeading: React.FC<QuestionHeadingProps> = ({ children, label, style = {}, required=false }: QuestionHeadingProps) => {

const shouldDisplayNumber = resolveQuickFormService("headingNumberDisplayProvider")();

Expand Down Expand Up @@ -43,6 +44,7 @@ export const QuestionHeading: React.FC<QuestionHeadingProps> = ({ children, labe
<ImArrowRightIcon size="12px" />
</div>}
{children}
{required && <span style={{ color: quickformtokens.onSurface, marginLeft: quickformtokens.gap1 }}>*</span>}
</h1>
);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/model/QuestionModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ export type QuestionModel<TProps = InputPropertiesTypes> = {
*/
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
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/model/json-definitions/JsonDataModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ type QuickFormQuestionDefinition = {
*/
order?: number

/**
* Is the question required to be answered.
*/
isRequired?: boolean
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"] ?? {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/state/QuickformAction.ts
Original file line number Diff line number Diff line change
@@ -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<QuickformAction>, intermediate?: boolean, validationResult?: ValidationResult };

Expand All @@ -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<QuickformAction>; logicalName?: string }
| {
type: 'PROCESS_INTERMEDIATE_QUESTIONS';
dispatch: React.Dispatch<QuickformAction>;
logicalName?: string
}
| { type: 'ON_VALIDATION_COMPLETED', callback: (state: QuickformState) => void }
| QuickformAnswerQuestionAction
| { type: 'SET_VALIDATION_RESULT'; logicalName: string; validationResult: ValidationResult; timestamp: number }
| { type: 'COMPUTE_PROGRESS' }
Expand Down
34 changes: 28 additions & 6 deletions packages/core/src/state/QuickformReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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.
Expand All @@ -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<PromiseLike<ValidationResult>> = [];
let allIntermediateQuestions = getAllIntermediateQuestions(state);

// Dont include the question currently being answered
if (action.logicalName) {
Expand All @@ -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);;
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/state/QuickformState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export type QuickformState = {
submitStatus: SubmitStatus;
totalSteps: number;
classes: Partial<QuickformClassNames>,
payloadAugments: Array<(payload: any) => any>
payloadAugments: Array<(payload: any) => any>,
onValidationCompleteCallback?: (state: QuickformState) => void;
}

export const defaultState = (data: QuickFormModel = defaultData, layout?: LayoutDefinition): QuickformState => {
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/state/action-handlers/QuestionActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }
Expand All @@ -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;
}
Expand All @@ -93,9 +97,9 @@ export class QuestionActionHandler {
}

static async validateInput(state: QuickformState, logicalName: string): Promise<ValidationResult> {
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',
Expand Down
64 changes: 35 additions & 29 deletions packages/core/src/state/action-handlers/SubmitActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,49 @@ import { QuickformAction, QuickformState } from "../index";

export type ServerActionSubmitHandler = (data: any) => Promise<Partial<QuickFormDefinition>>;
export class SubmitActionHandler {
static submit = async (state: QuickformState, dispatch: React.Dispatch<QuickformAction>, onSubmitAsync?: ServerActionSubmitHandler) => {
static submit = (state: QuickformState, dispatch: React.Dispatch<QuickformAction>, 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 } });
// }

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/utils/quickformUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,19 @@ 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, K extends keyof T> = T & { [P in K]-?: T[P] }

function hasVisibilityRule(question: QuestionModel): question is WithRequired<QuestionModel, 'visible'> {
return question.visible && question.visible?.engine && question.visible?.rule;
}

export const getAllQuestionsWithVisibilityRule = (slides: SlideModel[]) => 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)
Expand Down

0 comments on commit 190a71d

Please sign in to comment.