From f006102f4f90f611a8387be2c10506c00af45e0e Mon Sep 17 00:00:00 2001 From: Riza Nafis <31271087+ryuuzake@users.noreply.github.com> Date: Sat, 27 Jul 2024 22:09:06 +0700 Subject: [PATCH] Add page component (#1) * Add FormBodyPage Renderer Component * Rename tab to page on utils/page * Add storybook story for FormBodyPage Renderer Component * Add pages and currentPageIndex to questionnarieStore * Add next and previous button for Page * Modify Page utils * Make Page rendering logic on Top Level Renderer to help render * Upgrade smart-forms-renderer packages version * Change Page Next and Previous Button Design * Add Custom Styled Fab for Page Button --- packages/smart-forms-renderer/package.json | 2 +- .../FormComponents/Button.styles.ts | 10 ++ .../FormComponents/GroupItem/GroupHeading.tsx | 8 +- .../FormComponents/GroupItem/GroupItem.tsx | 12 +- .../GroupItem/GroupItemView.tsx | 13 ++ .../GroupItem/NextPageButton.tsx | 37 ++++++ .../GroupItem/PageButtonWrapper.tsx | 78 +++++++++++ .../GroupItem/PreviousPageButton.tsx | 37 ++++++ .../src/components/Renderer/BaseRenderer.tsx | 21 +++ .../src/components/Renderer/FormBodyPage.tsx | 70 ++++++++++ .../components/Renderer/FormTopLevelItem.tsx | 1 + .../hooks/useNextAndPreviousVisiblePages.ts | 69 ++++++++++ .../src/interfaces/page.interface.ts | 13 ++ .../questionnaireStore.interface.ts | 2 + .../src/stores/questionnaireStore.ts | 35 ++++- .../questionnaires/QItemControlGroup.ts | 124 ++++++++++++++++++ .../stories/sdc/ItemControlGroup.stories.tsx | 9 +- .../smart-forms-renderer/src/theme/palette.ts | 15 +++ .../src/utils/initialise.ts | 11 ++ .../smart-forms-renderer/src/utils/page.ts | 105 +++++++++++++++ .../createQuestionaireModel.ts | 5 + .../questionnaireStoreUtils/extractPages.ts | 11 ++ 22 files changed, 680 insertions(+), 8 deletions(-) create mode 100644 packages/smart-forms-renderer/src/components/FormComponents/Button.styles.ts create mode 100644 packages/smart-forms-renderer/src/components/FormComponents/GroupItem/NextPageButton.tsx create mode 100644 packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx create mode 100644 packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PreviousPageButton.tsx create mode 100644 packages/smart-forms-renderer/src/components/Renderer/FormBodyPage.tsx create mode 100644 packages/smart-forms-renderer/src/hooks/useNextAndPreviousVisiblePages.ts create mode 100644 packages/smart-forms-renderer/src/interfaces/page.interface.ts create mode 100644 packages/smart-forms-renderer/src/utils/page.ts create mode 100644 packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts diff --git a/packages/smart-forms-renderer/package.json b/packages/smart-forms-renderer/package.json index a1aab1c46..a52464723 100644 --- a/packages/smart-forms-renderer/package.json +++ b/packages/smart-forms-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@aehrc/smart-forms-renderer", - "version": "0.36.1", + "version": "0.37.0", "description": "FHIR Structured Data Captured (SDC) rendering engine for Smart Forms", "main": "lib/index.js", "scripts": { diff --git a/packages/smart-forms-renderer/src/components/FormComponents/Button.styles.ts b/packages/smart-forms-renderer/src/components/FormComponents/Button.styles.ts new file mode 100644 index 000000000..6d3035b95 --- /dev/null +++ b/packages/smart-forms-renderer/src/components/FormComponents/Button.styles.ts @@ -0,0 +1,10 @@ +import { styled } from '@mui/material/styles'; +import Fab from '@mui/material/Fab'; + +export const StandardFab = styled(Fab)(({ theme }) => ({ + color: theme.palette.customButton.foreground, + background: theme.palette.customButton.background, + '&:hover': { + background: theme.palette.customButton.backgroundHover + } +})); diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupHeading.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupHeading.tsx index 7de203f32..556170f30 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupHeading.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupHeading.tsx @@ -29,10 +29,11 @@ interface GroupHeadingProps extends PropsWithIsRepeatedAttribute { qItem: QuestionnaireItem; readOnly: boolean; tabIsMarkedAsComplete?: boolean; + pageIsMarkedAsComplete?: boolean; } const GroupHeading = memo(function GroupHeading(props: GroupHeadingProps) { - const { qItem, readOnly, tabIsMarkedAsComplete, isRepeated } = props; + const { qItem, readOnly, tabIsMarkedAsComplete, pageIsMarkedAsComplete, isRepeated } = props; const contextDisplayItems = getContextDisplays(qItem); @@ -41,14 +42,15 @@ const GroupHeading = memo(function GroupHeading(props: GroupHeadingProps) { } const isTabHeading = tabIsMarkedAsComplete !== undefined; + const isPageHeading = pageIsMarkedAsComplete !== undefined; return ( <> + fontSize={isTabHeading || isPageHeading ? 16 : 15} + color={readOnly && (!isTabHeading || !isPageHeading) ? 'text.secondary' : 'text.primary'}> diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItem.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItem.tsx index aeb4a808b..86aac00bc 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItem.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItem.tsx @@ -28,6 +28,7 @@ import type { import type { QrRepeatGroup } from '../../../interfaces/repeatGroup.interface'; import useHidden from '../../../hooks/useHidden'; import type { Tabs } from '../../../interfaces/tab.interface'; +import type { Pages } from '../../../interfaces/page.interface'; import GroupItemView from './GroupItemView'; interface GroupItemProps @@ -41,6 +42,9 @@ interface GroupItemProps tabIsMarkedAsComplete?: boolean; tabs?: Tabs; currentTabIndex?: number; + pageIsMarkedAsComplete?: boolean; + pages?: Pages; + currentPageIndex?: number; } function GroupItem(props: GroupItemProps) { @@ -52,6 +56,9 @@ function GroupItem(props: GroupItemProps) { tabIsMarkedAsComplete, tabs, currentTabIndex, + pageIsMarkedAsComplete, + pages, + currentPageIndex, parentIsReadOnly, parentIsRepeatGroup, parentRepeatGroupIndex, @@ -83,7 +90,7 @@ function GroupItem(props: GroupItemProps) { } if (!qItems || !qrItems) { - return <>Unable to load group, something has gone terribly wrong.; + return <>Group Item: Unable to load group, something has gone terribly wrong.; } // If an item has multiple answers, it is a repeat group @@ -99,6 +106,9 @@ function GroupItem(props: GroupItemProps) { tabIsMarkedAsComplete={tabIsMarkedAsComplete} tabs={tabs} currentTabIndex={currentTabIndex} + pageIsMarkedAsComplete={pageIsMarkedAsComplete} + pages={pages} + currentPageIndex={currentPageIndex} parentIsReadOnly={parentIsReadOnly} parentIsRepeatGroup={parentIsRepeatGroup} parentRepeatGroupIndex={parentRepeatGroupIndex} diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItemView.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItemView.tsx index cbb39447d..a7e73aa74 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItemView.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItemView.tsx @@ -26,6 +26,7 @@ import type { PropsWithQrRepeatGroupChangeHandler } from '../../../interfaces/renderProps.interface'; import type { Tabs } from '../../../interfaces/tab.interface'; +import type { Pages } from '../../../interfaces/page.interface'; import GroupHeading from './GroupHeading'; import { GroupCard } from './GroupItem.styles'; import TabButtonsWrapper from './TabButtonsWrapper'; @@ -37,6 +38,7 @@ import Divider from '@mui/material/Divider'; import { getGroupCollapsible } from '../../../utils/qItem'; import useReadOnly from '../../../hooks/useReadOnly'; import { GroupAccordion } from './GroupAccordion.styles'; +import PageButtonsWrapper from './PageButtonWrapper'; interface GroupItemViewProps extends PropsWithQrItemChangeHandler, @@ -51,6 +53,9 @@ interface GroupItemViewProps tabIsMarkedAsComplete?: boolean; tabs?: Tabs; currentTabIndex?: number; + pageIsMarkedAsComplete?: boolean; + pages?: Pages; + currentPageIndex?: number; } function GroupItemView(props: GroupItemViewProps) { @@ -63,12 +68,16 @@ function GroupItemView(props: GroupItemViewProps) { tabIsMarkedAsComplete, tabs, currentTabIndex, + pageIsMarkedAsComplete, + pages, + currentPageIndex, parentIsReadOnly, parentIsRepeatGroup, parentRepeatGroupIndex, onQrItemChange, onQrRepeatGroupChange } = props; + console.log({ pages, currentPageIndex }); const readOnly = useReadOnly(qItem, parentIsReadOnly); @@ -91,6 +100,7 @@ function GroupItemView(props: GroupItemViewProps) { qItem={qItem} readOnly={readOnly} tabIsMarkedAsComplete={tabIsMarkedAsComplete} + pageIsMarkedAsComplete={pageIsMarkedAsComplete} isRepeated={isRepeated} /> @@ -117,6 +127,7 @@ function GroupItemView(props: GroupItemViewProps) { {/* Next tab button at the end of each tab group */} + @@ -133,6 +144,7 @@ function GroupItemView(props: GroupItemViewProps) { qItem={qItem} readOnly={readOnly} tabIsMarkedAsComplete={tabIsMarkedAsComplete} + pageIsMarkedAsComplete={pageIsMarkedAsComplete} isRepeated={isRepeated} /> {childQItems.map((qItem: QuestionnaireItem, i) => { @@ -155,6 +167,7 @@ function GroupItemView(props: GroupItemViewProps) { {/* Next tab button at the end of each tab group */} + ); diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/NextPageButton.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/NextPageButton.tsx new file mode 100644 index 000000000..a76dd3f55 --- /dev/null +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/NextPageButton.tsx @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import Iconify from '../../Iconify/Iconify'; +import { StandardFab } from '../Button.styles'; + +interface NextPageButtonProps { + isDisabled: boolean; + onNextPageClick: () => void; +} + +function NextPageButton(props: NextPageButtonProps) { + const { isDisabled, onNextPageClick } = props; + + return ( + + + + ); +} + +export default NextPageButton; diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx new file mode 100644 index 000000000..0d55972d5 --- /dev/null +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx @@ -0,0 +1,78 @@ +import React, { memo } from 'react'; +import Box from '@mui/material/Box'; +import type { Pages } from '../../../interfaces/page.interface'; +import { useQuestionnaireStore } from '../../../stores'; +import NextPageButton from './NextPageButton'; +import PreviousPageButton from './PreviousPageButton'; +import useNextAndPreviousVisiblePages from '../../../hooks/useNextAndPreviousVisiblePages'; + +interface PageButtonsWrapperProps { + currentPageIndex?: number; + pages?: Pages; +} + +const PageButtonsWrapper = memo(function PageButtonsWrapper(props: PageButtonsWrapperProps) { + const { currentPageIndex, pages } = props; + + const switchPage = useQuestionnaireStore.use.switchPage(); + + const { previousPageIndex, nextPageIndex, numOfVisiblePages } = useNextAndPreviousVisiblePages( + currentPageIndex, + pages + ); + + const pagesNotDefined = currentPageIndex === undefined || pages === undefined; + + // Event handlers + function handlePreviousPageButtonClick() { + if (previousPageIndex === null) { + return; + } + + switchPage(previousPageIndex); + + // Scroll to top of page + window.scrollTo(0, 0); + } + + function handleNextPageButtonClick() { + if (nextPageIndex === null) { + return; + } + + switchPage(nextPageIndex); + + // Scroll to top of page + window.scrollTo(0, 0); + } + + if (pagesNotDefined) { + return null; + } + + const previousPageButtonHidden = previousPageIndex === null; + const nextPageButtonHidden = nextPageIndex === null; + + // This is more of a fallback check to prevent the user from navigating to an invisble page if buttons are visble for some reason + const pageButtonsDisabled = numOfVisiblePages <= 1; + + return ( + + {previousPageButtonHidden ? null : ( + + )} + + {nextPageButtonHidden ? null : ( + + )} + + ); +}); + +export default PageButtonsWrapper; diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PreviousPageButton.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PreviousPageButton.tsx new file mode 100644 index 000000000..c62bcbc0b --- /dev/null +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PreviousPageButton.tsx @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import Iconify from '../../Iconify/Iconify'; +import { StandardFab } from '../Button.styles'; + +interface PreviousPageButtonProps { + isDisabled: boolean; + onPreviousPageClick: () => void; +} + +function PreviousPageButton(props: PreviousPageButtonProps) { + const { isDisabled, onPreviousPageClick } = props; + + return ( + + + + ); +} + +export default PreviousPageButton; diff --git a/packages/smart-forms-renderer/src/components/Renderer/BaseRenderer.tsx b/packages/smart-forms-renderer/src/components/Renderer/BaseRenderer.tsx index 9bb53c688..b2514e458 100644 --- a/packages/smart-forms-renderer/src/components/Renderer/BaseRenderer.tsx +++ b/packages/smart-forms-renderer/src/components/Renderer/BaseRenderer.tsx @@ -24,7 +24,9 @@ import { useQuestionnaireResponseStore, useQuestionnaireStore } from '../../stor import cloneDeep from 'lodash.clonedeep'; import { getQrItemsIndex, mapQItemsIndex } from '../../utils/mapItem'; import { updateQrItemsInGroup } from '../../utils/qrItem'; +import { everyIsPages } from '../../utils/page'; import type { QrRepeatGroup } from '../../interfaces/repeatGroup.interface'; +import FormBodyPage from './FormBodyPage'; /** * Main component of the form-rendering engine. @@ -74,6 +76,25 @@ function BaseRenderer() { // If an item has multiple answers, it is a repeat group const topLevelQRItemsByIndex = getQrItemsIndex(topLevelQItems, topLevelQRItems, qItemsIndexMap); + const everyItemIsPage = everyIsPages(topLevelQItems); + + if (everyItemIsPage) { + return ( + + + + handleTopLevelQRItemSingleChange(newTopLevelQRItem) + } + /> + + + ); + } + return ( diff --git a/packages/smart-forms-renderer/src/components/Renderer/FormBodyPage.tsx b/packages/smart-forms-renderer/src/components/Renderer/FormBodyPage.tsx new file mode 100644 index 000000000..41e6e02ea --- /dev/null +++ b/packages/smart-forms-renderer/src/components/Renderer/FormBodyPage.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import Grid from '@mui/material/Grid'; +import type { QuestionnaireItem, QuestionnaireResponseItem } from 'fhir/r4'; +import TabContext from '@mui/lab/TabContext'; +import TabPanel from '@mui/lab/TabPanel'; +import GroupItem from '../FormComponents/GroupItem/GroupItem'; +import type { + PropsWithParentIsReadOnlyAttribute, + PropsWithQrItemChangeHandler +} from '../../interfaces/renderProps.interface'; +import { useQuestionnaireStore } from '../../stores'; + +interface FormBodyPageProps + extends PropsWithQrItemChangeHandler, + PropsWithParentIsReadOnlyAttribute { + topLevelQItems: QuestionnaireItem[]; + topLevelQRItems: (QuestionnaireResponseItem | QuestionnaireResponseItem[] | undefined)[]; +} + +function FormBodyPage(props: FormBodyPageProps) { + const { topLevelQItems, topLevelQRItems, parentIsReadOnly, onQrItemChange } = props; + + const pages = useQuestionnaireStore.use.pages(); + const currentPage = useQuestionnaireStore.use.currentPageIndex(); + + return ( + + + + {topLevelQItems.map((qItem, i) => { + const qrItem = topLevelQRItems[i]; + + const isNotRepeatGroup = !Array.isArray(qrItem); + const isPage = !!pages[qItem.linkId]; + + if (!isPage || !isNotRepeatGroup) { + // Something has gone horribly wrong + return null; + } + + const isRepeated = qItem.repeats ?? false; + const pageIsMarkedAsComplete = pages[qItem.linkId].isComplete ?? false; + + return ( + + + + ); + })} + + + + ); +} + +export default FormBodyPage; diff --git a/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelItem.tsx b/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelItem.tsx index dec56ec3a..2abdd1cd5 100644 --- a/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelItem.tsx +++ b/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelItem.tsx @@ -18,6 +18,7 @@ import React from 'react'; import type { QuestionnaireItem, QuestionnaireResponseItem } from 'fhir/r4'; import FormBodyTabbed from './FormBodyTabbed'; +import FormBodyPage from './FormBodyPage'; import { containsTabs, isTabContainer } from '../../utils/tabs'; import GroupItem from '../FormComponents/GroupItem/GroupItem'; import SingleItem from '../FormComponents/SingleItem/SingleItem'; diff --git a/packages/smart-forms-renderer/src/hooks/useNextAndPreviousVisiblePages.ts b/packages/smart-forms-renderer/src/hooks/useNextAndPreviousVisiblePages.ts new file mode 100644 index 000000000..39921b116 --- /dev/null +++ b/packages/smart-forms-renderer/src/hooks/useNextAndPreviousVisiblePages.ts @@ -0,0 +1,69 @@ +import { useQuestionnaireStore } from '../stores'; +import type { Pages } from '../interfaces/page.interface'; +import { constructPagesWithVisibility } from '../utils/page'; + +function useNextAndPreviousVisiblePages( + currentPageIndex?: number, + pages?: Pages +): { previousPageIndex: number | null; nextPageIndex: number | null; numOfVisiblePages: number } { + const enableWhenIsActivated = useQuestionnaireStore.use.enableWhenIsActivated(); + const enableWhenItems = useQuestionnaireStore.use.enableWhenItems(); + const enableWhenExpressions = useQuestionnaireStore.use.enableWhenExpressions(); + + const pagesNotDefined = currentPageIndex === undefined || pages === undefined; + + if (pagesNotDefined) { + return { previousPageIndex: null, nextPageIndex: null, numOfVisiblePages: 0 }; + } + + const pagesWithVisibility = constructPagesWithVisibility({ + pages, + enableWhenIsActivated, + enableWhenItems, + enableWhenExpressions + }); + + return { + previousPageIndex: getPreviousPageIndex(currentPageIndex, pagesWithVisibility), + nextPageIndex: getNextPageIndex(currentPageIndex, pagesWithVisibility), + numOfVisiblePages: pagesWithVisibility.filter((tab) => tab.isVisible).length + }; +} + +function getPreviousPageIndex( + currentPageIndex: number, + pagesWithVisibility: { linkId: string; isVisible: boolean }[] +): number | null { + const previousPages = pagesWithVisibility.slice(0, currentPageIndex); + const foundIndex = previousPages.reverse().findIndex((tab) => tab.isVisible); + + // Previous visible tab not found + if (foundIndex === -1) { + return null; + } + + // Previous visible tab less than 0 + const previousPageIndex = currentPageIndex - foundIndex - 1; + if (previousPageIndex < 0) { + return null; + } + + return previousPageIndex; +} + +function getNextPageIndex( + currentPageIndex: number, + pagesWithVisibility: { linkId: string; isVisible: boolean }[] +): number | null { + const subsequentPages = pagesWithVisibility.slice(currentPageIndex + 1); + const foundIndex = subsequentPages.findIndex((tab) => tab.isVisible); + + // Next visible tab not found, something is wrong + if (foundIndex === -1) { + return null; + } + + return currentPageIndex + foundIndex + 1; +} + +export default useNextAndPreviousVisiblePages; diff --git a/packages/smart-forms-renderer/src/interfaces/page.interface.ts b/packages/smart-forms-renderer/src/interfaces/page.interface.ts new file mode 100644 index 000000000..47e02e0c9 --- /dev/null +++ b/packages/smart-forms-renderer/src/interfaces/page.interface.ts @@ -0,0 +1,13 @@ +/** + * Page interface + * + * @property pageIndex - The index of the page + * @property isComplete - Whether the page is marked as complete + * @property isHidden - Whether the page is hidden + */ +export type Page = { pageIndex: number; isComplete: boolean; isHidden: boolean }; + +/** + * Key-value pair of pages `Record` + */ +export type Pages = Record; diff --git a/packages/smart-forms-renderer/src/interfaces/questionnaireStore.interface.ts b/packages/smart-forms-renderer/src/interfaces/questionnaireStore.interface.ts index d0ea6b34b..180b0694a 100644 --- a/packages/smart-forms-renderer/src/interfaces/questionnaireStore.interface.ts +++ b/packages/smart-forms-renderer/src/interfaces/questionnaireStore.interface.ts @@ -16,6 +16,7 @@ */ import type { Tabs } from './tab.interface'; +import type { Pages } from './page.interface'; import type { Variables } from './variables.interface'; import type { LaunchContext } from './populate.interface'; import type { EnableWhenExpressions, EnableWhenItems } from './enableWhen.interface'; @@ -27,6 +28,7 @@ import type { InitialExpression } from './initialExpression.interface'; export interface QuestionnaireModel { itemTypes: Record; tabs: Tabs; + pages: Pages; variables: Variables; launchContexts: Record; enableWhenItems: EnableWhenItems; diff --git a/packages/smart-forms-renderer/src/stores/questionnaireStore.ts b/packages/smart-forms-renderer/src/stores/questionnaireStore.ts index 34c3844cb..b1dcb969a 100644 --- a/packages/smart-forms-renderer/src/stores/questionnaireStore.ts +++ b/packages/smart-forms-renderer/src/stores/questionnaireStore.ts @@ -28,6 +28,7 @@ import type { CalculatedExpression } from '../interfaces/calculatedExpression.in import type { EnableWhenExpressions, EnableWhenItems } from '../interfaces/enableWhen.interface'; import type { AnswerExpression } from '../interfaces/answerExpression.interface'; import type { Tabs } from '../interfaces/tab.interface'; +import type { Pages } from '../interfaces/page.interface'; import { mutateRepeatEnableWhenItemInstances, updateEnableWhenItemAnswer @@ -58,6 +59,8 @@ import type { InitialExpression } from '../interfaces/initialExpression.interfac * @property itemTypes - Key-value pair of item types `Record` * @property tabs - Key-value pair of tabs `Record` * @property currentTabIndex - Index of the current tab + * @property pages - Key-value pair of pages `Record` + * @property currentPageIndex - Index of the current page * @property variables - Questionnaire variables object containing FHIRPath and x-fhir-query variables * @property launchContexts - Key-value pair of launch contexts `Record` * @property enableWhenItems - EnableWhenItems object containing enableWhen items and their linked questions @@ -76,7 +79,9 @@ import type { InitialExpression } from '../interfaces/initialExpression.interfac * @property buildSourceQuestionnaire - Used to build the source questionnaire with the provided questionnaire and optionally questionnaire response, additional variables, terminology server url and readyOnly flag * @property destroySourceQuestionnaire - Used to destroy the source questionnaire and reset all properties * @property switchTab - Used to switch the current tab index + * @property switchPage - Used to switch the current page index * @property markTabAsComplete - Used to mark a tab index as complete + * @property markPageAsComplete - Used to mark a page index as complete * @property updateEnableWhenItem - Used to update linked enableWhen items by updating a question with a new answer * @property mutateRepeatEnableWhenItems - Used to add or remove instances of repeating enableWhen items * @property toggleEnableWhenActivation - Used to toggle enableWhen checks on/off @@ -94,6 +99,8 @@ export interface QuestionnaireStoreType { itemTypes: Record; tabs: Tabs; currentTabIndex: number; + pages: Pages; + currentPageIndex: number; variables: Variables; launchContexts: Record; enableWhenItems: EnableWhenItems; @@ -119,7 +126,9 @@ export interface QuestionnaireStoreType { ) => Promise; destroySourceQuestionnaire: () => void; switchTab: (newTabIndex: number) => void; + switchPage: (newPageIndex: number) => void; markTabAsComplete: (tabLinkId: string) => void; + markPageAsComplete: (pageLinkId: string) => void; updateEnableWhenItem: ( linkId: string, newAnswer: QuestionnaireResponseItemAnswer[] | undefined, @@ -155,6 +164,8 @@ export const questionnaireStore = createStore()((set, ge itemTypes: {}, tabs: {}, currentTabIndex: 0, + pages: {}, + currentPageIndex: 0, variables: { fhirPathVariables: {}, xFhirQueryVariables: {} }, launchContexts: {}, calculatedExpressions: {}, @@ -197,6 +208,7 @@ export const questionnaireStore = createStore()((set, ge initialEnableWhenExpressions, initialCalculatedExpressions, firstVisibleTab, + firstVisiblePage, updatedFhirPathContext } = initialiseFormFromResponse({ questionnaireResponse, @@ -205,6 +217,7 @@ export const questionnaireStore = createStore()((set, ge calculatedExpressions: questionnaireModel.calculatedExpressions, variablesFhirPath: questionnaireModel.variables.fhirPathVariables, tabs: questionnaireModel.tabs, + pages: questionnaireModel.pages, fhirPathContext: questionnaireModel.fhirPathContext }); @@ -213,6 +226,8 @@ export const questionnaireStore = createStore()((set, ge itemTypes: questionnaireModel.itemTypes, tabs: questionnaireModel.tabs, currentTabIndex: firstVisibleTab, + pages: questionnaireModel.pages, + currentPageIndex: firstVisiblePage, variables: questionnaireModel.variables, launchContexts: questionnaireModel.launchContexts, enableWhenItems: initialEnableWhenItems, @@ -233,6 +248,8 @@ export const questionnaireStore = createStore()((set, ge itemTypes: {}, tabs: {}, currentTabIndex: 0, + pages: {}, + currentPageIndex: 0, variables: { fhirPathVariables: {}, xFhirQueryVariables: {} }, launchContexts: {}, enableWhenItems: { singleItems: {}, repeatItems: {} }, @@ -246,6 +263,7 @@ export const questionnaireStore = createStore()((set, ge fhirPathContext: {} }), switchTab: (newTabIndex: number) => set(() => ({ currentTabIndex: newTabIndex })), + switchPage: (newPageIndex: number) => set(() => ({ currentPageIndex: newPageIndex })), markTabAsComplete: (tabLinkId: string) => { const tabs = get().tabs; set(() => ({ @@ -255,6 +273,15 @@ export const questionnaireStore = createStore()((set, ge } })); }, + markPageAsComplete: (pageLinkId: string) => { + const pages = get().pages; + set(() => ({ + pages: { + ...pages, + [pageLinkId]: { ...pages[pageLinkId], isComplete: !pages[pageLinkId].isComplete } + } + })); + }, updateEnableWhenItem: ( linkId: string, newAnswer: QuestionnaireResponseItemAnswer[] | undefined, @@ -355,7 +382,8 @@ export const questionnaireStore = createStore()((set, ge updatePopulatedProperties: ( populatedResponse: QuestionnaireResponse, populatedContext?: Record, - persistTabIndex?: boolean + persistTabIndex?: boolean, + persistPageIndex?: boolean ) => { const initialResponseItemMap = createQuestionnaireResponseItemMap(populatedResponse); @@ -379,7 +407,8 @@ export const questionnaireStore = createStore()((set, ge initialEnableWhenItems, initialEnableWhenLinkedQuestions, initialEnableWhenExpressions, - firstVisibleTab + firstVisibleTab, + firstVisiblePage } = initialiseFormFromResponse({ questionnaireResponse: updatedResponse, enableWhenItems: get().enableWhenItems, @@ -387,6 +416,7 @@ export const questionnaireStore = createStore()((set, ge calculatedExpressions: initialCalculatedExpressions, variablesFhirPath: get().variables.fhirPathVariables, tabs: get().tabs, + pages: get().pages, fhirPathContext: updatedFhirPathContext }); updatedFhirPathContext = evaluateInitialCalculatedExpressionsResult.updatedFhirPathContext; @@ -397,6 +427,7 @@ export const questionnaireStore = createStore()((set, ge enableWhenExpressions: initialEnableWhenExpressions, calculatedExpressions: initialCalculatedExpressions, currentTabIndex: persistTabIndex ? get().currentTabIndex : firstVisibleTab, + currentPageIndex: persistPageIndex ? get().currentPageIndex : firstVisiblePage, fhirPathContext: updatedFhirPathContext, populatedContext: populatedContext ?? get().populatedContext })); diff --git a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QItemControlGroup.ts b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QItemControlGroup.ts index 507313dc2..955140057 100644 --- a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QItemControlGroup.ts +++ b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QItemControlGroup.ts @@ -631,3 +631,127 @@ export const qItemControlDisplayTabContainer: Questionnaire = { } ] }; + +export const qItemControlGroupPage: Questionnaire = { + resourceType: 'Questionnaire', + id: 'ItemControlGroupPage', + name: 'ItemControlGroupPage', + title: 'Item Control Group Page', + version: '0.1.0', + status: 'draft', + publisher: 'AEHRC CSIRO', + date: '2024-07-24', + url: 'https://smartforms.csiro.au/docs/advanced/control/item-control-group-page', + item: [ + { + linkId: '1', + text: 'Page 1', + type: 'group', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'page', + display: 'Page' + } + ], + text: 'Page' + } + } + ], + item: [ + { + linkId: '1.1', + text: 'ANC ID', + type: 'string', + required: true, + maxLength: 8, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-instruction', + valueString: 'Unique ANC ID' + } + ] + }, + { + linkId: '1.2', + text: 'First name', + type: 'string', + required: true, + maxLength: 30, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-instruction', + valueString: 'Given name' + } + ] + }, + { + linkId: '1.3', + text: 'Last name', + type: 'string', + required: true, + maxLength: 50, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-instruction', + valueString: 'Family name' + } + ] + } + ] + }, + { + linkId: '2', + text: 'Page 2', + type: 'group', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'page', + display: 'Page' + } + ], + text: 'Page' + } + } + ], + item: [ + { + linkId: '2.1', + text: 'Mobile number', + type: 'integer', + maxLength: 8, + required: true, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-instruction', + valueString: 'Mobile phone number' + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/maxValue', + valueInteger: 200000 + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/minValue', + valueInteger: 10 + } + ] + }, + { + linkId: '2.2', + text: 'Receive SMS Notifications?', + type: 'boolean', + required: true + } + ] + } + ] +}; diff --git a/packages/smart-forms-renderer/src/stories/sdc/ItemControlGroup.stories.tsx b/packages/smart-forms-renderer/src/stories/sdc/ItemControlGroup.stories.tsx index 959698faa..e7ceeb73b 100644 --- a/packages/smart-forms-renderer/src/stories/sdc/ItemControlGroup.stories.tsx +++ b/packages/smart-forms-renderer/src/stories/sdc/ItemControlGroup.stories.tsx @@ -21,7 +21,8 @@ import { qItemControlDisplayTabContainer, qItemControlGroupGridMultiRow, qItemControlGroupGridSingleRow, - qItemControlGroupGTable + qItemControlGroupGTable, + qItemControlGroupPage } from '../assets/questionnaires'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export @@ -60,3 +61,9 @@ export const TabContainer: Story = { questionnaire: qItemControlDisplayTabContainer } }; + +export const Page: Story = { + args: { + questionnaire: qItemControlGroupPage + } +}; diff --git a/packages/smart-forms-renderer/src/theme/palette.ts b/packages/smart-forms-renderer/src/theme/palette.ts index 941e9ccd4..5393ab101 100644 --- a/packages/smart-forms-renderer/src/theme/palette.ts +++ b/packages/smart-forms-renderer/src/theme/palette.ts @@ -31,6 +31,11 @@ declare module '@mui/material/styles' { customBackground: { neutral: string; }; + customButton: { + background: string; + backgroundHover: string; + foreground: string; + }; } // noinspection JSUnusedGlobalSymbols @@ -44,6 +49,11 @@ declare module '@mui/material/styles' { customBackground?: { neutral: string; }; + customButton?: { + background: string; + backgroundHover: string; + foreground: string; + }; } } @@ -84,6 +94,11 @@ const palette: PaletteOptions = { customBackground: { neutral: '#F4F6F8' }, + customButton: { + background: '#0ABDC3', + backgroundHover: '#08979C', + foreground: '#161C26' + }, action: { active: grey['600'], hover: alpha(grey['500'], 0.08), diff --git a/packages/smart-forms-renderer/src/utils/initialise.ts b/packages/smart-forms-renderer/src/utils/initialise.ts index b28e8aefa..d66e438ce 100644 --- a/packages/smart-forms-renderer/src/utils/initialise.ts +++ b/packages/smart-forms-renderer/src/utils/initialise.ts @@ -17,6 +17,7 @@ import { evaluateInitialEnableWhenExpressions } from './enableWhenExpression'; import { getFirstVisibleTab } from './tabs'; +import { getFirstVisiblePage } from './page'; import type { Expression, Questionnaire, @@ -28,6 +29,7 @@ import type { } from 'fhir/r4'; import type { EnableWhenExpressions, EnableWhenItems } from '../interfaces/enableWhen.interface'; import type { Tabs } from '../interfaces/tab.interface'; +import type { Pages } from '../interfaces/page.interface'; import { assignPopulatedAnswersToEnableWhen } from './enableWhen'; import type { CalculatedExpression } from '../interfaces/calculatedExpression.interface'; import { evaluateInitialCalculatedExpressions } from './calculatedExpression'; @@ -320,6 +322,7 @@ export interface initialFormFromResponseParams { calculatedExpressions: Record; variablesFhirPath: Record; tabs: Tabs; + pages: Pages; fhirPathContext: Record; } @@ -329,6 +332,7 @@ export function initialiseFormFromResponse(params: initialFormFromResponseParams initialEnableWhenExpressions: EnableWhenExpressions; initialCalculatedExpressions: Record; firstVisibleTab: number; + firstVisiblePage: number; updatedFhirPathContext: Record; } { const { @@ -338,6 +342,7 @@ export function initialiseFormFromResponse(params: initialFormFromResponseParams calculatedExpressions, variablesFhirPath, tabs, + pages, fhirPathContext } = params; const initialResponseItemMap = createQuestionnaireResponseItemMap(questionnaireResponse); @@ -373,12 +378,18 @@ export function initialiseFormFromResponse(params: initialFormFromResponseParams ? getFirstVisibleTab(tabs, initialisedItems, initialEnableWhenExpressions) : 0; + const firstVisiblePage = + Object.keys(pages).length > 0 + ? getFirstVisiblePage(pages, initialisedItems, initialEnableWhenExpressions) + : 0; + return { initialEnableWhenItems: initialisedItems, initialEnableWhenLinkedQuestions: linkedQuestions, initialEnableWhenExpressions, initialCalculatedExpressions, firstVisibleTab, + firstVisiblePage, updatedFhirPathContext }; } diff --git a/packages/smart-forms-renderer/src/utils/page.ts b/packages/smart-forms-renderer/src/utils/page.ts new file mode 100644 index 000000000..d16360b2e --- /dev/null +++ b/packages/smart-forms-renderer/src/utils/page.ts @@ -0,0 +1,105 @@ +import type { Pages } from '../interfaces/page.interface'; +import type { EnableWhenExpressions, EnableWhenItems } from '../interfaces/enableWhen.interface'; +import type { QuestionnaireItem } from 'fhir/r4'; +import { isSpecificItemControl } from './itemControl'; +import { isHiddenByEnableWhen } from './qItem'; +import { structuredDataCapture } from 'fhir-sdc-helpers'; + +export function getFirstVisiblePage( + pages: Pages, + enableWhenItems: EnableWhenItems, + enableWhenExpressions: EnableWhenExpressions +) { + // Only singleEnableWhenItems are relevant for page operations + const { singleItems } = enableWhenItems; + const { singleExpressions } = enableWhenExpressions; + + return Object.entries(pages) + .sort(([, pageA], [, pageB]) => pageA.pageIndex - pageB.pageIndex) + .findIndex(([pageLinkId, page]) => { + if (page.isHidden) { + return false; + } + + const singleItem = singleItems[pageLinkId]; + if (singleItem) { + return singleItem.isEnabled; + } + + const singleExpression = singleExpressions[pageLinkId]; + if (singleExpression) { + return singleExpression.isEnabled; + } + + return true; + }); +} + +/** + * Checks if any of the items in a qItem array is a page item + * Returns true if there is at least one page item + * + * @author Riza Nafis + */ +export function everyIsPages(topLevelQItem: QuestionnaireItem[] | undefined): boolean { + if (!topLevelQItem) return false; + + return topLevelQItem.every((i: QuestionnaireItem) => isPage(i)); +} + +/** + * Check if a qItem is a page item + * + * @author Riza Nafis + */ +export function isPage(item: QuestionnaireItem) { + return isSpecificItemControl(item, 'page'); +} + +/** + * Create a `Record` key-value pair for all page items in a qItem array + * + * @author Riza Nafis + */ +export function constructPagesWithProperties(qItems: QuestionnaireItem[] | undefined): Pages { + if (!qItems) return {}; + + const qItemPages = qItems.filter(isPage); + + const pages: Pages = {}; + for (const [i, qItem] of qItemPages.entries()) { + pages[qItem.linkId] = { + pageIndex: i, + isComplete: false, + isHidden: structuredDataCapture.getHidden(qItem) ?? false + }; + } + return pages; +} + +interface contructPagesWithVisibilityParams { + pages: Pages; + enableWhenIsActivated: boolean; + enableWhenItems: EnableWhenItems; + enableWhenExpressions: EnableWhenExpressions; +} + +export function constructPagesWithVisibility( + params: contructPagesWithVisibilityParams +): { linkId: string; isVisible: boolean }[] { + const { pages, enableWhenIsActivated, enableWhenItems, enableWhenExpressions } = params; + + return Object.entries(pages).map(([linkId]) => { + const isVisible = !isHiddenByEnableWhen({ + linkId, + enableWhenIsActivated, + enableWhenItems, + enableWhenExpressions + }); + + return { + linkId, + isVisible + }; + }); +} diff --git a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/createQuestionaireModel.ts b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/createQuestionaireModel.ts index f3169d07c..f28b4136d 100644 --- a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/createQuestionaireModel.ts +++ b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/createQuestionaireModel.ts @@ -17,11 +17,13 @@ import type { Questionnaire } from 'fhir/r4'; import type { Tabs } from '../../interfaces/tab.interface'; +import type { Pages } from '../../interfaces/page.interface'; import type { LaunchContext } from '../../interfaces/populate.interface'; import type { QuestionnaireModel } from '../../interfaces/questionnaireStore.interface'; import { extractLaunchContexts } from './extractLaunchContext'; import { extractQuestionnaireLevelVariables } from './extractVariables'; import { extractTabs } from './extractTabs'; +import { extractPages } from './extractPages'; import { extractContainedValueSets } from './extractContainedValueSets'; import { extractOtherExtensions } from './extractOtherExtensions'; import type { Variables } from '../../interfaces/variables.interface'; @@ -41,6 +43,7 @@ export async function createQuestionnaireModel( const itemTypes: Record = Object.fromEntries(getLinkIdTypeTuples(questionnaire)); const tabs: Tabs = extractTabs(questionnaire); + const pages: Pages = extractPages(questionnaire); const launchContexts: Record = extractLaunchContexts(questionnaire); @@ -98,6 +101,7 @@ export async function createQuestionnaireModel( return { itemTypes, tabs, + pages, variables, launchContexts, enableWhenItems, @@ -116,6 +120,7 @@ function createEmptyModel(): QuestionnaireModel { return { itemTypes: {}, tabs: {}, + pages: {}, variables: { fhirPathVariables: {}, xFhirQueryVariables: {} }, launchContexts: {}, calculatedExpressions: {}, diff --git a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts new file mode 100644 index 000000000..3f780328d --- /dev/null +++ b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts @@ -0,0 +1,11 @@ +import type { Questionnaire } from 'fhir/r4'; +import type { Pages } from '../../interfaces/page.interface'; +import { constructPagesWithProperties } from '../page'; + +export function extractPages(questionnaire: Questionnaire): Pages { + if (!questionnaire.item || questionnaire.item.length === 0) { + return {}; + } + + return constructPagesWithProperties(questionnaire.item); +}