src/platform/forms-system/src/js/patterns/array-builder/arrayBuilder.jsx
/* eslint-disable no-unused-vars */
import { getNextPagePath } from 'platform/forms-system/src/js/routing';
import {
createArrayBuilderItemAddPath,
onNavForwardKeepUrlParams,
onNavBackKeepUrlParams,
onNavBackRemoveAddingItem,
createArrayBuilderUpdatedPath,
getArrayIndexFromPathName,
initGetText,
} from './helpers';
import ArrayBuilderItemPage from './ArrayBuilderItemPage';
import ArrayBuilderSummaryPage from './ArrayBuilderSummaryPage';
import { DEFAULT_ARRAY_BUILDER_TEXT } from './arrayBuilderText';
/**
* @typedef {Object} ArrayBuilderPages
* @property {function(FormConfigPage): FormConfigPage} [introPage] Intro page which should be used for required flow
* @property {function(FormConfigPage): FormConfigPage} summaryPage Summary page which includes Cards with edit/remove, and the Yes/No field
* @property {function(FormConfigPage): FormConfigPage} itemPage A repeated page corresponding to an item
*/
/**
* @typedef {Object} ArrayBuilderHelpers
* @property {FormConfigPage['onNavBack']} navBackFirstItem
* @property {FormConfigPage['onNavBack']} navBackKeepUrlParams
* @property {FormConfigPage['onNavForward']} navForwardIntro
* @property {FormConfigPage['onNavForward']} navForwardSummary
* @property {FormConfigPage['onNavForward']} navForwardFinishedItem
* @property {FormConfigPage['onNavForward']} navForwardKeepUrlParams
*/
function throwErrorPage(pageType, option) {
throw new Error(
`arrayBuilderPages \`pageBuilder.${pageType}()\` must include \`${option}\` property like this: ` +
`\`...arrayBuilderPages(options, pageBuilder => ({ examplePage: pageBuilder.${pageType}({ ${option}: ... }) }))\``,
);
}
function throwErrorOption(option) {
throw new Error(
`arrayBuilderPages options must include \`${option}\` property like this: ` +
`\`...arrayBuilderPages({ ${option}: ... }, pageBuilder => { ... })\``,
);
}
function throwErrorNoOptionsOrCallbackFunction() {
throw new Error(
'arrayBuilderPages must include options and a callback like this `...arrayBuilderPages(options, pageBuilder => { ... })`',
);
}
function throwMissingYesNoField() {
throw new Error(
'arrayBuilderPages `pageBuilder.summaryPage()` must either include a `uiSchema` that is using `arrayBuilderYesNoUI` pattern OR option `useLinkInsteadOfYesNo` or `useButtonInsteadOfYesNo` to array builder primary options.',
);
}
function throwMissingYesNoValidation() {
throw new Error(
"arrayBuilderPages `pageBuilder.summaryPage()` must include a `uiSchema` that is using `arrayBuilderYesNoUI` pattern instead of `yesNoUI` pattern, or a similar pattern including `yesNoUI` with `'ui:validations'`",
);
}
function validateNoSchemaAssociatedWithLinkOrButton(
pageConfig,
useLinkInsteadOfYesNo,
useButtonInsteadOfYesNo,
) {
if (useLinkInsteadOfYesNo || useButtonInsteadOfYesNo) {
if (useLinkInsteadOfYesNo && useButtonInsteadOfYesNo) {
throw new Error(
'arrayBuilderPages options cannot include both `useLinkInsteadOfYesNo` and `useButtonInsteadOfYesNo`.',
);
}
const noSchemaProp = useLinkInsteadOfYesNo
? 'useLinkInsteadOfYesNo'
: 'useButtonInsteadOfYesNo';
const error =
(pageConfig.schema?.properties &&
Object.keys(pageConfig.schema.properties).length) ||
(pageConfig.uiSchema && Object.keys(pageConfig.uiSchema).length);
if (error) {
const message = `
arrayBuilderPages \`pageBuilder.summaryPage()\` does not currently support
using \`uiSchema\` or \`schema\` properties when using option \`${noSchemaProp}\`.
Provide an empty object for \`uiSchema\` and \`schema\` properties or remove them.
For adding content, use \`text\` options \`summaryTitle\`, \`summaryTitleWithoutItems\`,
\`summaryDescription\`, \`summaryDescriptionWithoutItems\`, \`summaryAddButtonText\`,
\`summaryAddLinkText\`, as well as \`ContentBeforeButtons\` at the \`form/config\` page.
`.replace(/\s+/g, ' ');
throw new Error(message);
}
}
}
function throwIncorrectItemPath() {
throw new Error(
'arrayBuilderPages item pages must include a `path` property that includes `/:index`',
);
}
function throwValidateRequired() {
throw new Error(
'arrayBuilderPages options must include a `required` boolean or function that returns a boolean. If `required` returns `true`, the flow should start with an "intro" page and expects at least 1 item from the user. If `required` returns `false`, the user can choose to skip the array entirely with a yes no question on the "summary" page.',
);
}
function verifyRequiredArrayBuilderOptions(options, requiredOpts) {
requiredOpts.forEach(option => {
if (options[option] === undefined) {
if (option === 'required') {
throwValidateRequired();
} else {
throwErrorOption(option);
}
}
});
}
function verifyRequiredPropsPageConfig(pageType, requiredOpts, objectToCheck) {
requiredOpts.forEach(option => {
if (objectToCheck[option] === undefined) {
throwErrorPage(pageType, option);
}
});
}
function determineYesNoField(uiSchema) {
let yesNoKey;
if (uiSchema) {
for (const key of Object.keys(uiSchema)) {
if (
uiSchema[key]?.['ui:options']?.classNames?.includes(
'wc-pattern-array-builder-yes-no',
)
) {
yesNoKey = key;
}
}
}
return yesNoKey;
}
export function getPageAfterPageKey(pageList, pageKey) {
let nextPage;
for (let i = 0; i < pageList.length; i += 1) {
if (pageList[i].pageKey === pageKey) {
nextPage = pageList[i + 1];
}
}
return nextPage;
}
export function validatePages(orderedPageTypes) {
const pageTypes = {};
for (const pageType of orderedPageTypes) {
if (pageType === 'intro') {
if (pageTypes.intro || pageTypes.summary || pageTypes.item) {
throw new Error(
"arrayBuilderPages `pageBuilder.introPage` must be first and defined only once. Intro page should be used for 'required' flow, and should contain only text.",
);
}
pageTypes.intro = true;
} else if (pageType === 'summary') {
if (pageTypes.summary || pageTypes.item) {
throw new Error(
'arrayBuilderPages `pageBuilder.summaryPage` must be defined only once and be defined before the item pages. This is so the loop cycle, and back and continue buttons will work consistently and appropriately. In a "required" flow, the summary path will be skipped on the first round despite being defined first.',
);
}
pageTypes.summary = true;
} else if (pageType === 'item') {
pageTypes.item = true;
}
}
if (!pageTypes.summary) {
throw new Error(
'arrayBuilderPages must include a summary page with `pageBuilder.summaryPage`',
);
}
if (!pageTypes.item) {
throw new Error(
'arrayBuilderPages must include at least one item page with `pageBuilder.itemPage`',
);
}
}
export function validateRequired(required) {
if (required == null || typeof required === 'string') {
throwValidateRequired();
}
}
function validatePath(path) {
if (path?.charAt(0) === '/') {
throw new Error(`path ${path} should not start with a \`/\``);
}
}
export function validateReviewPath(reviewPath) {
if (reviewPath?.charAt(0) === '/') {
throw new Error(`reviewPath ${reviewPath} should not start with a \`/\``);
}
}
export function validateMinItems(minItems) {
if (minItems != null) {
// eslint-disable-next-line no-console
console.warn('minItems is not yet implemented. Use "required" instead.');
}
}
export function assignGetItemName(options) {
const safeGetItemName = getItemFn => {
return (item, index) => {
try {
return getItemFn(item, index);
} catch (e) {
return null;
}
};
};
if (options.getItemName) {
return safeGetItemName(options.getItemName);
}
if (options.text?.getItemName) {
return safeGetItemName(options.text.getItemName);
}
return DEFAULT_ARRAY_BUILDER_TEXT.getItemName;
}
/**
* README: {@link https://github.com/department-of-veterans-affairs/vets-website/tree/main/src/platform/forms-system/src/js/patterns/array-builder/README.md|Array Builder Usage/Guidance/Examples}
*
*
* @param {ArrayBuilderOptions} options
* @param {(pageBuilder: ArrayBuilderPages, helpers?: ArrayBuilderHelpers) => FormConfigChapter} pageBuilderCallback
* @returns {FormConfigChapter}
*/
export function arrayBuilderPages(options, pageBuilderCallback) {
let introPath;
let summaryPath;
let hasItemsKey;
const itemPages = [];
const orderedPageTypes = [];
if (
!options ||
typeof options !== 'object' ||
!pageBuilderCallback ||
typeof pageBuilderCallback !== 'function'
) {
throwErrorNoOptionsOrCallbackFunction();
}
verifyRequiredArrayBuilderOptions(options, [
'arrayPath',
'nounSingular',
'nounPlural',
'required',
]);
const {
arrayPath,
nounSingular,
nounPlural,
isItemIncomplete = item => item?.name,
minItems = 1,
maxItems = 100,
text: userText = {},
reviewPath = 'review-and-submit',
required: userRequired,
useLinkInsteadOfYesNo = false,
useButtonInsteadOfYesNo = false,
} = options;
const usesYesNo = !useLinkInsteadOfYesNo && !useButtonInsteadOfYesNo;
const getItemName = assignGetItemName(options);
const getText = initGetText({
getItemName,
nounPlural,
nounSingular,
textOverrides: userText,
});
/**
* @type {{
* [key: string]: (config: FormConfigPage) => FormConfigPage,
* }}
*/
const pageBuilderVerifyAndSetup = {
introPage: pageConfig => {
introPath = pageConfig.path;
validatePath(introPath);
orderedPageTypes.push('intro');
return pageConfig;
},
summaryPage: pageConfig => {
summaryPath = pageConfig.path;
if (usesYesNo) {
try {
hasItemsKey = determineYesNoField(pageConfig.uiSchema);
} catch (e) {
throwMissingYesNoField();
}
if (!hasItemsKey) {
throwMissingYesNoField();
}
if (!pageConfig.uiSchema?.[hasItemsKey]?.['ui:validations']?.[0]) {
throwMissingYesNoValidation();
}
}
validateNoSchemaAssociatedWithLinkOrButton(
pageConfig,
useLinkInsteadOfYesNo,
useButtonInsteadOfYesNo,
);
validatePath(summaryPath);
orderedPageTypes.push('summary');
return pageConfig;
},
itemPage: pageConfig => {
if (!pageConfig?.path?.includes('/:index')) {
throwIncorrectItemPath();
}
validatePath(pageConfig?.path);
itemPages.push(pageConfig);
orderedPageTypes.push('item');
return pageConfig;
},
};
// Verify and setup any initial page options
pageBuilderCallback(pageBuilderVerifyAndSetup);
validatePages(orderedPageTypes);
validateRequired(userRequired);
validateReviewPath(reviewPath);
validateMinItems(options.minItems);
const required =
typeof userRequired === 'function' ? userRequired : () => userRequired;
const lastItemPagePath = itemPages?.[itemPages.length - 1]?.path;
const getActiveItemPages = (formData, index) => {
return itemPages.filter(page => {
try {
if (page.depends) {
return typeof page.depends === 'function'
? page.depends(formData, index)
: page.depends;
}
return true;
} catch (e) {
return false;
}
});
};
const getFirstItemPagePath = (formData, index) => {
return getActiveItemPages(formData, index)?.[0]?.path;
};
const getLastItemPagePath = (formData, index) => {
const activeItemPages = getActiveItemPages(formData, index);
return activeItemPages?.[activeItemPages.length - 1]?.path;
};
// Didn't throw error so success: Validated and setup success
const pageBuilder = pageBuilderVerifyAndSetup;
/** @type {FormConfigPage['onNavForward']} */
const navForwardFinishedItem = ({ goPath, urlParams, pathname, index }) => {
let path = summaryPath;
if (urlParams?.edit || (urlParams?.add && urlParams?.review)) {
const foundIndex = getArrayIndexFromPathName(pathname);
const basePath = urlParams?.review ? reviewPath : summaryPath;
path = createArrayBuilderUpdatedPath({
basePath,
index: foundIndex == null ? index : foundIndex,
nounSingular,
});
}
goPath(path);
};
const navForwardSummaryAddItem = ({ formData, goPath }) => {
const nextIndex = formData[arrayPath]?.length || 0;
const path = createArrayBuilderItemAddPath({
path: getFirstItemPagePath(formData, nextIndex),
index: nextIndex,
});
goPath(path);
};
const navForwardSummaryGoNextChapter = ({ formData, goPath, pageList }) => {
// if 0 items, 1 set of item pages still exist in the form
const lastIndex = formData[arrayPath]?.length
? formData[arrayPath].length - 1
: 0;
const lastActivePath = `/${getLastItemPagePath(formData, lastIndex).replace(
':index',
lastIndex,
)}`;
const nextPagePath = getNextPagePath(pageList, formData, lastActivePath);
goPath(nextPagePath);
};
/** @type {FormConfigPage['onNavBack']} */
const navBackFirstItem = onNavBackRemoveAddingItem({
arrayPath,
introRoute: introPath,
summaryRoute: summaryPath,
reviewRoute: reviewPath,
});
/** @type {FormConfigPage['onNavForward']} */
const navForwardSummary = ({ formData, goPath, pageList }) => {
if (formData?.[hasItemsKey]) {
navForwardSummaryAddItem({ formData, goPath });
} else {
navForwardSummaryGoNextChapter({ formData, goPath, pageList });
}
};
/** @type {FormConfigPage['onNavForward']} */
const navForwardIntro = ({ formData, goPath, urlParams }) => {
let path;
// required flow:
// intro -> items -> summary -> items -> summary
//
// optional flow:
// summary -> items -> summary
if (required(formData) && !formData[arrayPath]?.length) {
path = createArrayBuilderItemAddPath({
path: getFirstItemPagePath(formData, 0),
index: 0,
isReview: urlParams?.review,
});
goPath(path);
} else {
path = summaryPath;
if (urlParams?.review) {
path = `${path}?review=true`;
}
goPath(path);
}
};
function getNavItem(path) {
const onNavBack = props => {
return getFirstItemPagePath(props.formData, props.index) === path
? navBackFirstItem(props)
: onNavBackKeepUrlParams(props);
};
const onNavForward = props => {
return getLastItemPagePath(props.formData, props.index) === path
? navForwardFinishedItem(props)
: onNavForwardKeepUrlParams(props);
};
return { onNavBack, onNavForward };
}
pageBuilder.introPage = pageConfig => {
const requiredOpts = ['title', 'path', 'uiSchema', 'schema'];
verifyRequiredPropsPageConfig('introPage', requiredOpts, pageConfig);
return {
onNavForward: navForwardIntro,
...pageConfig,
};
};
function safeDepends(depends) {
if (typeof depends !== 'function') {
return depends;
}
return (formData, index) => {
try {
return depends(formData, index);
} catch (e) {
return false;
}
};
}
pageBuilder.summaryPage = pageConfig => {
let requiredOpts = ['title', 'path'];
if (usesYesNo) {
requiredOpts = requiredOpts.concat(['uiSchema', 'schema']);
}
verifyRequiredPropsPageConfig('summaryPage', requiredOpts, pageConfig);
const summaryPageProps = {
arrayPath,
hasItemsKey,
getFirstItemPagePath,
getText,
introPath,
isItemIncomplete,
maxItems,
nounPlural,
nounSingular,
required,
useLinkInsteadOfYesNo,
useButtonInsteadOfYesNo,
};
const page = {
CustomPageReview: ArrayBuilderSummaryPage({
isReviewPage: true,
...summaryPageProps,
}),
CustomPage: ArrayBuilderSummaryPage({
isReviewPage: false,
...summaryPageProps,
}),
scrollAndFocusTarget: 'h3',
onNavForward: navForwardSummary,
onNavBack: onNavBackKeepUrlParams,
...pageConfig,
};
if (!pageConfig.uiSchema) {
page.uiSchema = {};
}
if (!pageConfig.schema || !Object.keys(pageConfig.schema).length) {
page.schema = {
type: 'object',
properties: {},
};
}
return page;
};
pageBuilder.itemPage = pageConfig => {
const requiredOpts = ['title', 'path', 'uiSchema', 'schema'];
verifyRequiredPropsPageConfig('itemPage', requiredOpts, pageConfig);
const { onNavBack, onNavForward } = getNavItem(pageConfig.path);
return {
showPagePerItem: true,
allowPathWithNoItems: true,
arrayPath,
CustomPage: ArrayBuilderItemPage({
arrayPath,
introRoute: introPath,
summaryRoute: summaryPath,
reviewRoute: reviewPath,
required,
getText,
}),
CustomPageReview: () => null,
customPageUsesPagePerItemData: true,
scrollAndFocusTarget: 'h3',
onNavBack,
onNavForward,
...pageConfig,
...(pageConfig.depends
? { depends: safeDepends(pageConfig.depends) }
: {}),
uiSchema: {
[arrayPath]: {
items: pageConfig.uiSchema,
},
},
schema: {
type: 'object',
properties: {
[arrayPath]: {
type: 'array',
minItems,
maxItems,
items: pageConfig.schema,
},
},
},
};
};
/**
* @type {ArrayBuilderHelpers}
*/
const helpers = {
navBackFirstItem,
navBackKeepUrlParams: onNavBackKeepUrlParams,
navForwardIntro,
navForwardSummary,
navForwardFinishedItem,
navForwardKeepUrlParams: onNavForwardKeepUrlParams,
};
return pageBuilderCallback(pageBuilder, helpers);
}