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 1/8] 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); +} From 94e5861a29e1cb85d414c6bbed23cf625cbcb8ad Mon Sep 17 00:00:00 2001 From: Riza Nafis Date: Wed, 31 Jul 2024 22:36:18 +0700 Subject: [PATCH 2/8] Changes color to string literal instead of custom palette --- packages/smart-forms-renderer/package.json | 2 +- .../components/FormComponents/Button.styles.ts | 8 ++++---- .../smart-forms-renderer/src/theme/palette.ts | 15 --------------- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/smart-forms-renderer/package.json b/packages/smart-forms-renderer/package.json index a52464723..05fa88c07 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.37.0", + "version": "0.37.1", "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 index 6d3035b95..b31346e66 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/Button.styles.ts +++ b/packages/smart-forms-renderer/src/components/FormComponents/Button.styles.ts @@ -1,10 +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, +export const StandardFab = styled(Fab)(() => ({ + color: '#161C26', + background: '#0ABDC3', '&:hover': { - background: theme.palette.customButton.backgroundHover + background: '#08979C' } })); diff --git a/packages/smart-forms-renderer/src/theme/palette.ts b/packages/smart-forms-renderer/src/theme/palette.ts index 5393ab101..941e9ccd4 100644 --- a/packages/smart-forms-renderer/src/theme/palette.ts +++ b/packages/smart-forms-renderer/src/theme/palette.ts @@ -31,11 +31,6 @@ declare module '@mui/material/styles' { customBackground: { neutral: string; }; - customButton: { - background: string; - backgroundHover: string; - foreground: string; - }; } // noinspection JSUnusedGlobalSymbols @@ -49,11 +44,6 @@ declare module '@mui/material/styles' { customBackground?: { neutral: string; }; - customButton?: { - background: string; - backgroundHover: string; - foreground: string; - }; } } @@ -94,11 +84,6 @@ const palette: PaletteOptions = { customBackground: { neutral: '#F4F6F8' }, - customButton: { - background: '#0ABDC3', - backgroundHover: '#08979C', - foreground: '#161C26' - }, action: { active: grey['600'], hover: alpha(grey['500'], 0.08), From 37216ff1e8a6c15f050828bf238f6abf4ef56dc0 Mon Sep 17 00:00:00 2001 From: Riza Nafis Date: Wed, 31 Jul 2024 23:25:25 +0700 Subject: [PATCH 3/8] Rename FormBodyPage to FormTopLevelPage for top level renderer --- .../src/components/Renderer/BaseRenderer.tsx | 4 +- .../components/Renderer/FormTopLevelPage.tsx | 70 +++++++++++++++++++ .../smart-forms-renderer/src/utils/page.ts | 4 +- 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 packages/smart-forms-renderer/src/components/Renderer/FormTopLevelPage.tsx diff --git a/packages/smart-forms-renderer/src/components/Renderer/BaseRenderer.tsx b/packages/smart-forms-renderer/src/components/Renderer/BaseRenderer.tsx index b2514e458..6abe9c68a 100644 --- a/packages/smart-forms-renderer/src/components/Renderer/BaseRenderer.tsx +++ b/packages/smart-forms-renderer/src/components/Renderer/BaseRenderer.tsx @@ -26,7 +26,7 @@ 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'; +import FormTopLevelPage from './FormTopLevelPage'; /** * Main component of the form-rendering engine. @@ -82,7 +82,7 @@ function BaseRenderer() { 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 FormTopLevelPage; diff --git a/packages/smart-forms-renderer/src/utils/page.ts b/packages/smart-forms-renderer/src/utils/page.ts index d16360b2e..558f9d0bc 100644 --- a/packages/smart-forms-renderer/src/utils/page.ts +++ b/packages/smart-forms-renderer/src/utils/page.ts @@ -36,8 +36,8 @@ export function getFirstVisiblePage( } /** - * Checks if any of the items in a qItem array is a page item - * Returns true if there is at least one page item + * Checks if all of the items in a qItem array is a page item + * Returns true if all items is page item * * @author Riza Nafis */ From 151f96c62322da10cdf5a4cd5d3033e78fbfd27d Mon Sep 17 00:00:00 2001 From: Riza Nafis Date: Thu, 1 Aug 2024 00:13:54 +0700 Subject: [PATCH 4/8] Add Renderer Logic for Page Container --- .../GroupItem/GroupItemView.tsx | 1 - .../src/components/Renderer/FormBodyPage.tsx | 37 ++++-- .../components/Renderer/FormTopLevelItem.tsx | 16 +++ .../questionnaires/QItemControlGroup.ts | 105 ++++++++++++++++++ .../stories/sdc/ItemControlGroup.stories.tsx | 9 +- .../smart-forms-renderer/src/utils/page.ts | 29 ++++- .../questionnaireStoreUtils/extractPages.ts | 16 ++- 7 files changed, 200 insertions(+), 13 deletions(-) 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 a7e73aa74..920cdffdb 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItemView.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItemView.tsx @@ -77,7 +77,6 @@ function GroupItemView(props: GroupItemViewProps) { onQrItemChange, onQrRepeatGroupChange } = props; - console.log({ pages, currentPageIndex }); const readOnly = useReadOnly(qItem, parentIsReadOnly); diff --git a/packages/smart-forms-renderer/src/components/Renderer/FormBodyPage.tsx b/packages/smart-forms-renderer/src/components/Renderer/FormBodyPage.tsx index 41e6e02ea..e9189907e 100644 --- a/packages/smart-forms-renderer/src/components/Renderer/FormBodyPage.tsx +++ b/packages/smart-forms-renderer/src/components/Renderer/FormBodyPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import Grid from '@mui/material/Grid'; import type { QuestionnaireItem, QuestionnaireResponseItem } from 'fhir/r4'; import TabContext from '@mui/lab/TabContext'; @@ -9,26 +9,49 @@ import type { PropsWithQrItemChangeHandler } from '../../interfaces/renderProps.interface'; import { useQuestionnaireStore } from '../../stores'; +import { getQrItemsIndex, mapQItemsIndex } from '../../utils/mapItem'; +import { createEmptyQrGroup, updateQrItemsInGroup } from '../../utils/qrItem'; interface FormBodyPageProps extends PropsWithQrItemChangeHandler, PropsWithParentIsReadOnlyAttribute { - topLevelQItems: QuestionnaireItem[]; - topLevelQRItems: (QuestionnaireResponseItem | QuestionnaireResponseItem[] | undefined)[]; + topLevelQItem: QuestionnaireItem; + topLevelQRItem: QuestionnaireResponseItem | null; } function FormBodyPage(props: FormBodyPageProps) { - const { topLevelQItems, topLevelQRItems, parentIsReadOnly, onQrItemChange } = props; + const { topLevelQItem, topLevelQRItem, parentIsReadOnly, onQrItemChange } = props; const pages = useQuestionnaireStore.use.pages(); const currentPage = useQuestionnaireStore.use.currentPageIndex(); + const indexMap: Record = useMemo( + () => mapQItemsIndex(topLevelQItem), + [topLevelQItem] + ); + + const nonNullTopLevelQRItem = topLevelQRItem ?? createEmptyQrGroup(topLevelQItem); + + const qItems = topLevelQItem.item; + const qrItems = nonNullTopLevelQRItem.item; + + function handleQrGroupChange(qrItem: QuestionnaireResponseItem) { + updateQrItemsInGroup(qrItem, null, nonNullTopLevelQRItem, indexMap); + onQrItemChange(nonNullTopLevelQRItem); + } + + if (!qItems || !qrItems) { + return <>Unable to load form; + } + + const qrItemsByIndex = getQrItemsIndex(qItems, qrItems, indexMap); + return ( - {topLevelQItems.map((qItem, i) => { - const qrItem = topLevelQRItems[i]; + {qItems.map((qItem, i) => { + const qrItem = qrItemsByIndex[i]; const isNotRepeatGroup = !Array.isArray(qrItem); const isPage = !!pages[qItem.linkId]; @@ -56,7 +79,7 @@ function FormBodyPage(props: FormBodyPageProps) { pages={pages} currentPageIndex={currentPage} parentIsReadOnly={parentIsReadOnly} - onQrItemChange={onQrItemChange} + onQrItemChange={handleQrGroupChange} /> ); diff --git a/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelItem.tsx b/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelItem.tsx index 2abdd1cd5..19cfc0ec2 100644 --- a/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelItem.tsx +++ b/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelItem.tsx @@ -20,6 +20,7 @@ import type { QuestionnaireItem, QuestionnaireResponseItem } from 'fhir/r4'; import FormBodyTabbed from './FormBodyTabbed'; import FormBodyPage from './FormBodyPage'; import { containsTabs, isTabContainer } from '../../utils/tabs'; +import { containsPages, isPage } from '../../utils/page'; import GroupItem from '../FormComponents/GroupItem/GroupItem'; import SingleItem from '../FormComponents/SingleItem/SingleItem'; import type { @@ -54,6 +55,9 @@ function FormTopLevelItem(props: FormTopLevelItemProps) { const itemIsTabContainer = isTabContainer(topLevelQItem); const itemContainsTabs = containsTabs(topLevelQItem); + const itemIsPageContainer = isPage(topLevelQItem); + const itemContainsPages = containsPages(topLevelQItem); + const isTablet = useResponsive('up', 'md'); const itemIsGroup = topLevelQItem.type === 'group'; @@ -107,6 +111,18 @@ function FormTopLevelItem(props: FormTopLevelItemProps) { ); } + if (itemContainsPages || itemIsPageContainer) { + return ( + + ); + } + // If form is untabbed, it is rendered as a regular group if (itemIsGroup) { return ( 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 955140057..65da2cc09 100644 --- a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QItemControlGroup.ts +++ b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QItemControlGroup.ts @@ -755,3 +755,108 @@ export const qItemControlGroupPage: Questionnaire = { } ] }; + +export const qItemControlGroupPageContainer: 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: [ + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + version: '1.0.0', + code: 'page' + } + ] + } + } + ], + linkId: 'page-container', + type: 'group', + repeats: false, + item: [ + { + linkId: 'page-about-health-check', + text: 'About the health check', + type: 'group', + repeats: false, + item: [ + { + linkId: 'health-check-eligible', + text: 'Eligible for health check', + type: 'boolean', + repeats: false + }, + { + linkId: 'health-check-in-progress', + text: 'Health check already in progress?', + type: 'boolean', + repeats: false + }, + { + linkId: 'health-check-last-completed', + text: 'Date of last completed health check', + type: 'date', + repeats: false + }, + { + linkId: 'health-check-this-commenced', + text: 'Date and time this health check commenced', + type: 'dateTime', + repeats: false + } + ] + }, + { + extension: [ + { + url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-shortText', + valueString: 'Current priorities' + } + ], + linkId: 'page-current-priorities', + text: 'Current health/patient priorities', + type: 'group', + repeats: false, + item: [ + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/entryFormat', + valueString: 'Enter details' + } + ], + linkId: 'current-priorities-important-things', + text: 'What are the important things for you in this health check today?', + type: 'text', + repeats: false + }, + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/entryFormat', + valueString: 'Enter details' + } + ], + linkId: 'current-priorities-worried-things', + text: 'Is there anything you are worried about?', + type: 'text', + repeats: false + } + ] + } + ] + } + ] +}; 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 e7ceeb73b..d5406a034 100644 --- a/packages/smart-forms-renderer/src/stories/sdc/ItemControlGroup.stories.tsx +++ b/packages/smart-forms-renderer/src/stories/sdc/ItemControlGroup.stories.tsx @@ -22,7 +22,8 @@ import { qItemControlGroupGridMultiRow, qItemControlGroupGridSingleRow, qItemControlGroupGTable, - qItemControlGroupPage + qItemControlGroupPage, + qItemControlGroupPageContainer } from '../assets/questionnaires'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export @@ -67,3 +68,9 @@ export const Page: Story = { questionnaire: qItemControlGroupPage } }; + +export const PageContainer: Story = { + args: { + questionnaire: qItemControlGroupPageContainer + } +}; diff --git a/packages/smart-forms-renderer/src/utils/page.ts b/packages/smart-forms-renderer/src/utils/page.ts index 558f9d0bc..579cb6249 100644 --- a/packages/smart-forms-renderer/src/utils/page.ts +++ b/packages/smart-forms-renderer/src/utils/page.ts @@ -38,15 +38,37 @@ export function getFirstVisiblePage( /** * Checks if all of the items in a qItem array is a page item * Returns true if all items is page item + * Returns false if only have one item * * @author Riza Nafis */ export function everyIsPages(topLevelQItem: QuestionnaireItem[] | undefined): boolean { if (!topLevelQItem) return false; + if (isPageContainer(topLevelQItem)) return false; + return topLevelQItem.every((i: QuestionnaireItem) => isPage(i)); } +export function isPageContainer(topLevelQItem: QuestionnaireItem[] | undefined): boolean { + if (topLevelQItem && topLevelQItem.length === 1) return false; +} + +/** + * 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 containsPages(topLevelQItem: QuestionnaireItem): boolean { + if (!topLevelQItem.item) { + return false; + } + + const pages = topLevelQItem.item.filter((i) => isPage(i)); + return pages.length > 0; +} + /** * Check if a qItem is a page item * @@ -61,10 +83,13 @@ export function isPage(item: QuestionnaireItem) { * * @author Riza Nafis */ -export function constructPagesWithProperties(qItems: QuestionnaireItem[] | undefined): Pages { +export function constructPagesWithProperties( + qItems: QuestionnaireItem[] | undefined, + hasPageContainer: boolean +): Pages { if (!qItems) return {}; - const qItemPages = qItems.filter(isPage); + const qItemPages = hasPageContainer ? qItems : qItems.filter(isPage); const pages: Pages = {}; for (const [i, qItem] of qItemPages.entries()) { diff --git a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts index 3f780328d..386daf04a 100644 --- a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts +++ b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts @@ -1,11 +1,23 @@ import type { Questionnaire } from 'fhir/r4'; import type { Pages } from '../../interfaces/page.interface'; -import { constructPagesWithProperties } from '../page'; +import { constructPagesWithProperties, isPage, isPageContainer } from '../page'; export function extractPages(questionnaire: Questionnaire): Pages { if (!questionnaire.item || questionnaire.item.length === 0) { return {}; } - return constructPagesWithProperties(questionnaire.item); + if (!isPageContainer(questionnaire.item)) { + return constructPagesWithProperties(questionnaire.item, false); + } + + let totalPages = {}; + for (const topLevelItem of questionnaire.item) { + const items = topLevelItem.item; + const topLevelItemIsPageContainer = isPage(topLevelItem); + + const pages = constructPagesWithProperties(items, topLevelItemIsPageContainer); + totalPages = { ...totalPages, ...pages }; + } + return totalPages; } From 5fb8268868a57bcbc14072f8943a1a8f7c90a13e Mon Sep 17 00:00:00 2001 From: Riza Nafis Date: Thu, 1 Aug 2024 20:29:52 +0700 Subject: [PATCH 5/8] Add Renderer Logic for Page Container - Non Single Top Level --- .../questionnaires/QItemControlGroup.ts | 444 ++++++++++++++++++ .../stories/sdc/ItemControlGroup.stories.tsx | 9 +- .../smart-forms-renderer/src/utils/page.ts | 2 + .../questionnaireStoreUtils/extractPages.ts | 1 + 4 files changed, 455 insertions(+), 1 deletion(-) 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 65da2cc09..911cae30d 100644 --- a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QItemControlGroup.ts +++ b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QItemControlGroup.ts @@ -860,3 +860,447 @@ export const qItemControlGroupPageContainer: Questionnaire = { } ] }; + +export const qItemControlGroupPageNonTopLevelPageContainer: Questionnaire = { + resourceType: 'Questionnaire', + id: 'bit-of-everything', + status: 'draft', + title: 'A bit of everything', + subjectType: ['Patient', 'Person', 'Practitioner'], + item: [ + { + linkId: 'summary', + type: 'display', + text: 'This questionnaire is a bit of everything. It contains virtually every question type we might need in 80% of most surveys.' + }, + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + version: '1.0.0', + code: 'page' + } + ] + } + } + ], + linkId: 'page-container', + type: 'group', + repeats: false, + item: [ + { + linkId: 'group-basic1', + type: 'group', + text: 'A group of basic questions 1', + item: [ + { + linkId: 'radio-choice1', + type: 'choice', + text: 'A question with radio button choices', + answerValueSet: 'http://hl7.org/fhir/ValueSet/yesnodontknow', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'radio-button' + } + ] + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-choiceOrientation', + valueCode: 'vertical' + } + ] + }, + { + linkId: 'dropdown-choice1', + type: 'choice', + text: 'A question with dropdown choices', + answerValueSet: 'http://hl7.org/fhir/ValueSet/yesnodontknow', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'drop-down' + } + ] + } + } + ] + }, + { + linkId: 'checkbox-choice1', + type: 'choice', + text: 'A question with checkbox choices', + answerValueSet: 'http://hl7.org/fhir/ValueSet/yesnodontknow', + repeats: true, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'check-box' + } + ] + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-choiceOrientation', + valueCode: 'vertical' + } + ] + } + ] + }, + { + linkId: 'group-basic2', + type: 'group', + text: 'A group of basic questions 2', + item: [ + { + linkId: 'radio-choice2', + type: 'choice', + text: 'A question with radio button choices', + answerValueSet: 'http://hl7.org/fhir/ValueSet/yesnodontknow', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'radio-button' + } + ] + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-choiceOrientation', + valueCode: 'vertical' + } + ] + }, + { + linkId: 'dropdown-choice2', + type: 'choice', + text: 'A question with dropdown choices', + answerValueSet: 'http://hl7.org/fhir/ValueSet/yesnodontknow', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'drop-down' + } + ] + } + } + ] + }, + { + linkId: 'checkbox-choice2', + type: 'choice', + text: 'A question with checkbox choices', + answerValueSet: 'http://hl7.org/fhir/ValueSet/yesnodontknow', + repeats: true, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'check-box' + } + ] + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-choiceOrientation', + valueCode: 'vertical' + } + ] + } + ] + } + ] + }, + { + linkId: 'group-conditional', + type: 'group', + text: 'A group of conditional logic workflow', + item: [ + { + linkId: 'condition', + text: 'Try to insert a positive/negative number', + type: 'decimal', + initial: [ + { + valueInteger: 0 + } + ], + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/minValue', + valueInteger: -10 + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/maxValue', + valueInteger: 10 + } + ] + }, + { + linkId: 'conditional-gt5', + type: 'display', + text: 'An item that appears when the slider entry > 0', + enableWhen: [ + { + question: 'condition', + operator: '>', + answerDecimal: 0 + } + ] + }, + { + linkId: 'conditional-lt5', + type: 'display', + text: 'An item that appears when the slider entry < 0', + enableWhen: [ + { + question: 'condition', + operator: '<', + answerDecimal: 0 + } + ] + } + ] + }, + { + linkId: 'autofill', + type: 'group', + text: 'A group of questions with autofill options', + item: [ + { + linkId: 'diagnosis', + text: 'Diagnosis', + type: 'open-choice', + repeats: true, + answerValueSet: 'https://clinicaltables.nlm.nih.gov/fhir/R4/ValueSet/icd10cm', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/terminology-server', + valueUrl: 'https://clinicaltables.nlm.nih.gov/fhir/R4' + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'autocomplete' + } + ] + } + } + ] + }, + { + linkId: 'medications_table', + text: 'Medications', + type: 'group', + repeats: true, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/terminology-server', + valueUrl: 'https://clinicaltables.nlm.nih.gov/fhir/R4' + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'gtable' + } + ] + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/variable', + valueExpression: { + name: 'strengthFormLookup', + language: 'application/x-fhir-query', + expression: + "https://clinicaltables.nlm.nih.gov/fhir/R4/CodeSystem/$lookup?system=https://clinicaltables.nlm.nih.gov/fhir/CodeSystem/rxterms&code={{item.where(linkId='medication').answer.valueCoding.code}}&property=STRENGTHS_AND_FORMS" + } + } + ], + item: [ + { + linkId: 'medication', + text: 'Medication Name', + type: 'choice', + answerValueSet: 'https://clinicaltables.nlm.nih.gov/fhir/R4/ValueSet/rxterms', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'autocomplete', + display: 'Auto-complete' + } + ], + text: 'Auto-complete' + } + } + ] + }, + { + linkId: 'strength', + text: 'Strength', + type: 'open-choice', + extension: [ + { + url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerExpression', + valueExpression: { + language: 'text/fhirpath', + expression: + "%strengthFormLookup.parameter.where(name='property' and part.where(name='code' and value='STRENGTHS_AND_FORMS').exists()).part.where(name='value').value" + } + } + ] + } + ] + } + ] + }, + { + linkId: 'scoring', + type: 'group', + text: 'A group of questions with automatic scoring', + item: [ + { + linkId: 'question-table', + text: 'A table with questions in its rows and choices in its columns', + type: 'group', + item: [ + { + linkId: 'question-1', + type: 'choice', + text: 'What is it?', + answerOption: [ + { + valueCoding: { + code: '0', + display: 'This' + } + }, + { + valueCoding: { + code: '1', + display: 'That' + } + } + ] + }, + { + linkId: 'question-2', + type: 'choice', + text: 'What is it again?', + answerOption: [ + { + valueCoding: { + code: '0', + display: 'This' + } + }, + { + valueCoding: { + code: '1', + display: 'That' + } + } + ] + } + ], + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + system: 'http://hl7.org/fhir/questionnaire-item-control', + code: 'table' + } + ] + } + } + ] + }, + { + linkId: 'scored-items', + type: 'decimal', + text: 'Score', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/variable', + valueExpression: { + name: 'answer1', + language: 'text/fhirpath', + expression: + "%resource.item.where(linkId='scoring').item.where(linkId='question-table').item.where(linkId='question-1').answer.valueCoding.code.toDecimal()" + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/variable', + valueExpression: { + name: 'answer2', + language: 'text/fhirpath', + expression: + "%resource.item.where(linkId='scoring').item.where(linkId='question-table').item.where(linkId='question-2').answer.valueCoding.code.toDecimal()" + } + }, + { + url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression', + valueExpression: { + language: 'text/fhirpath', + expression: '%answer1 + %answer2' + } + } + ], + enableWhen: [ + { + question: 'question-1', + operator: 'exists', + answerBoolean: true + }, + { + question: 'question-2', + operator: 'exists', + answerBoolean: true + } + ], + enableBehavior: 'all' + } + ] + } + ] +}; 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 d5406a034..78580fada 100644 --- a/packages/smart-forms-renderer/src/stories/sdc/ItemControlGroup.stories.tsx +++ b/packages/smart-forms-renderer/src/stories/sdc/ItemControlGroup.stories.tsx @@ -23,7 +23,8 @@ import { qItemControlGroupGridSingleRow, qItemControlGroupGTable, qItemControlGroupPage, - qItemControlGroupPageContainer + qItemControlGroupPageContainer, + qItemControlGroupPageNonTopLevelPageContainer } from '../assets/questionnaires'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export @@ -74,3 +75,9 @@ export const PageContainer: Story = { questionnaire: qItemControlGroupPageContainer } }; + +export const PageContainerNonSingleTopLevel: Story = { + args: { + questionnaire: qItemControlGroupPageNonTopLevelPageContainer + } +}; diff --git a/packages/smart-forms-renderer/src/utils/page.ts b/packages/smart-forms-renderer/src/utils/page.ts index 579cb6249..e427fb56a 100644 --- a/packages/smart-forms-renderer/src/utils/page.ts +++ b/packages/smart-forms-renderer/src/utils/page.ts @@ -52,6 +52,8 @@ export function everyIsPages(topLevelQItem: QuestionnaireItem[] | undefined): bo export function isPageContainer(topLevelQItem: QuestionnaireItem[] | undefined): boolean { if (topLevelQItem && topLevelQItem.length === 1) return false; + + return true; } /** diff --git a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts index 386daf04a..d6a29f60e 100644 --- a/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts +++ b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts @@ -19,5 +19,6 @@ export function extractPages(questionnaire: Questionnaire): Pages { const pages = constructPagesWithProperties(items, topLevelItemIsPageContainer); totalPages = { ...totalPages, ...pages }; } + return totalPages; } From fdf65714f5697af47963c4e06d117315a471d3a1 Mon Sep 17 00:00:00 2001 From: Riza Nafis Date: Thu, 1 Aug 2024 21:34:51 +0700 Subject: [PATCH 6/8] Changes Page Button design --- .../GroupItem/PageButtonWrapper.tsx | 24 +++++++++---------- .../smart-forms-renderer/src/utils/page.ts | 6 +++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx index 0d55972d5..4ddcfd788 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx @@ -1,5 +1,6 @@ import React, { memo } from 'react'; import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; import type { Pages } from '../../../interfaces/page.interface'; import { useQuestionnaireStore } from '../../../stores'; import NextPageButton from './NextPageButton'; @@ -57,20 +58,17 @@ const PageButtonsWrapper = memo(function PageButtonsWrapper(props: PageButtonsWr const pageButtonsDisabled = numOfVisiblePages <= 1; return ( - - {previousPageButtonHidden ? null : ( - - )} + - {nextPageButtonHidden ? null : ( - - )} + {`${currentPageIndex + 1} / ${numOfVisiblePages}`} + + ); }); diff --git a/packages/smart-forms-renderer/src/utils/page.ts b/packages/smart-forms-renderer/src/utils/page.ts index e427fb56a..343dfde26 100644 --- a/packages/smart-forms-renderer/src/utils/page.ts +++ b/packages/smart-forms-renderer/src/utils/page.ts @@ -51,9 +51,11 @@ export function everyIsPages(topLevelQItem: QuestionnaireItem[] | undefined): bo } export function isPageContainer(topLevelQItem: QuestionnaireItem[] | undefined): boolean { - if (topLevelQItem && topLevelQItem.length === 1) return false; + const anyPage = topLevelQItem?.filter(isPage); - return true; + if (!anyPage) return false; + + return anyPage.some((page) => page.item?.every((i) => i.type === 'group') || false); } /** From b1f8f9fde7c5cc91ee5ebe93316afb42e4823414 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Mon, 5 Aug 2024 13:50:18 +0930 Subject: [PATCH 7/8] Tweak page button colours to be consistent with tab buttons --- .../src/components/FormComponents/Button.styles.ts | 8 ++++---- .../FormComponents/GroupItem/NextPageButton.tsx | 6 +++--- .../FormComponents/GroupItem/PageButtonWrapper.tsx | 4 +++- .../FormComponents/GroupItem/PreviousPageButton.tsx | 10 +++++++--- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/smart-forms-renderer/src/components/FormComponents/Button.styles.ts b/packages/smart-forms-renderer/src/components/FormComponents/Button.styles.ts index b31346e66..684a0e531 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/Button.styles.ts +++ b/packages/smart-forms-renderer/src/components/FormComponents/Button.styles.ts @@ -1,10 +1,10 @@ import { styled } from '@mui/material/styles'; import Fab from '@mui/material/Fab'; -export const StandardFab = styled(Fab)(() => ({ - color: '#161C26', - background: '#0ABDC3', +export const SecondaryFab = styled(Fab)(({ theme }) => ({ + color: '#fff', + background: theme.palette.secondary.main, '&:hover': { - background: '#08979C' + background: theme.palette.secondary.dark } })); diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/NextPageButton.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/NextPageButton.tsx index a76dd3f55..d60632228 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/NextPageButton.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/NextPageButton.tsx @@ -17,7 +17,7 @@ import React from 'react'; import Iconify from '../../Iconify/Iconify'; -import { StandardFab } from '../Button.styles'; +import { SecondaryFab } from '../Button.styles'; interface NextPageButtonProps { isDisabled: boolean; @@ -28,9 +28,9 @@ function NextPageButton(props: NextPageButtonProps) { const { isDisabled, onNextPageClick } = props; return ( - + - + ); } diff --git a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx index 4ddcfd788..712b91199 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PageButtonWrapper.tsx @@ -60,7 +60,9 @@ const PageButtonsWrapper = memo(function PageButtonsWrapper(props: PageButtonsWr return ( - {`${currentPageIndex + 1} / ${numOfVisiblePages}`} + + Page {`${currentPageIndex + 1} / ${numOfVisiblePages}`} + + - + ); } From 3097d134c1bbcdf24172e5a4c3b8a3e4718f6702 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Mon, 5 Aug 2024 14:09:19 +0930 Subject: [PATCH 8/8] Update package versions --- apps/smart-forms-app/package.json | 2 +- package-lock.json | 136 ++++++++++++++++++------------ 2 files changed, 82 insertions(+), 56 deletions(-) diff --git a/apps/smart-forms-app/package.json b/apps/smart-forms-app/package.json index 20c02cd70..e68808a08 100644 --- a/apps/smart-forms-app/package.json +++ b/apps/smart-forms-app/package.json @@ -28,7 +28,7 @@ "dependencies": { "@aehrc/sdc-assemble": "^1.3.1", "@aehrc/sdc-populate": "^2.3.0", - "@aehrc/smart-forms-renderer": "^0.36.1", + "@aehrc/smart-forms-renderer": "^0.37.1", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/material-icons": "^5.0.18", diff --git a/package-lock.json b/package-lock.json index 07475c401..71df211d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "dependencies": { "@aehrc/sdc-assemble": "^1.3.1", "@aehrc/sdc-populate": "^2.3.0", - "@aehrc/smart-forms-renderer": "^0.36.1", + "@aehrc/smart-forms-renderer": "^0.37.1", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/material-icons": "^5.0.18", @@ -470,6 +470,56 @@ "node": ">=18.0" } }, + "documentation/node_modules/@aehrc/smart-forms-renderer": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/@aehrc/smart-forms-renderer/-/smart-forms-renderer-0.36.1.tgz", + "integrity": "sha512-L0jzXSNy+H5cyXDxyqsY3s3OI9SfXg/ibru9glq6yomPmd5SsHme1HU5YjYd2hd8YuFggcw997Onq7UgjzFACQ==", + "dependencies": { + "@aehrc/sdc-populate": "^2.3.0", + "@iconify/react": "^4.1.1", + "dayjs": "^1.11.10", + "deep-diff": "^1.0.2", + "fhirclient": "^2.5.2", + "fhirpath": "3.11.0", + "html-react-parser": "4.2.10", + "js-base64": "^3.7.7", + "lodash.clonedeep": "^4.5.0", + "lodash.debounce": "^4.0.8", + "lodash.difference": "^4.5.0", + "lodash.intersection": "^4.4.0", + "nanoid": "^5.0.7", + "react-beautiful-dnd": "^13.1.1", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "react-markdown": "^8.0.7", + "style-to-object": "^1.0.6", + "zustand": "^4.5.2" + }, + "peerDependencies": { + "@emotion/react": "^11.11.3", + "@emotion/styled": "^11.11.0", + "@mui/icons-material": "^5.15.10", + "@mui/lab": "^5.0.0-alpha.165", + "@mui/material": "^5.15.10", + "@mui/x-date-pickers": "^6.19.4", + "@tanstack/react-query": "^4.36.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "documentation/node_modules/inline-style-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.3.tgz", + "integrity": "sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==" + }, + "documentation/node_modules/style-to-object": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.6.tgz", + "integrity": "sha512-khxq+Qm3xEyZfKd/y9L3oIWQimxuc4STrQKtQn8aSDRHb8mFgpukgX1hdzfrMEW6JCjyJ8p89x+IUMVnCBI1PA==", + "dependencies": { + "inline-style-parser": "0.2.3" + } + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "devOptional": true, @@ -12260,6 +12310,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -19693,6 +19744,34 @@ "@types/node": ">=14" } }, + "node_modules/fhirpath": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/fhirpath/-/fhirpath-3.11.0.tgz", + "integrity": "sha512-BfCxdzwD9/Eb/iur7ETpaT1pq4KcHahEG0WIjzTO1jm9ImiDlcyvj998JvBCQbf+5AQmwZKglbcWPM7Gm56uZQ==", + "dependencies": { + "@lhncbc/ucum-lhc": "^5.0.0", + "antlr4": "~4.9.3", + "commander": "^2.18.0", + "date-fns": "^1.30.1", + "js-yaml": "^3.13.1" + }, + "bin": { + "fhirpath": "bin/fhirpath" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/fhirpath/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "node_modules/fhirpath/node_modules/date-fns": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", + "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "devOptional": true, @@ -41636,37 +41715,9 @@ "ts-jest": "^29.1.1" } }, - "packages/sdc-populate/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" - }, - "packages/sdc-populate/node_modules/date-fns": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", - "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" - }, - "packages/sdc-populate/node_modules/fhirpath": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/fhirpath/-/fhirpath-3.11.0.tgz", - "integrity": "sha512-BfCxdzwD9/Eb/iur7ETpaT1pq4KcHahEG0WIjzTO1jm9ImiDlcyvj998JvBCQbf+5AQmwZKglbcWPM7Gm56uZQ==", - "dependencies": { - "@lhncbc/ucum-lhc": "^5.0.0", - "antlr4": "~4.9.3", - "commander": "^2.18.0", - "date-fns": "^1.30.1", - "js-yaml": "^3.13.1" - }, - "bin": { - "fhirpath": "bin/fhirpath" - }, - "engines": { - "node": ">=8.9.0" - } - }, "packages/smart-forms-renderer": { "name": "@aehrc/smart-forms-renderer", - "version": "0.36.1", + "version": "0.37.1", "license": "Apache-2.0", "dependencies": { "@aehrc/sdc-populate": "^2.3.0", @@ -42400,10 +42451,6 @@ "undici-types": "~5.26.4" } }, - "packages/smart-forms-renderer/node_modules/date-fns": { - "version": "1.30.1", - "license": "MIT" - }, "packages/smart-forms-renderer/node_modules/execa": { "version": "5.1.1", "dev": true, @@ -42426,27 +42473,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "packages/smart-forms-renderer/node_modules/fhirpath": { - "version": "3.11.0", - "license": "SEE LICENSE in LICENSE.md", - "dependencies": { - "@lhncbc/ucum-lhc": "^5.0.0", - "antlr4": "~4.9.3", - "commander": "^2.18.0", - "date-fns": "^1.30.1", - "js-yaml": "^3.13.1" - }, - "bin": { - "fhirpath": "bin/fhirpath" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "packages/smart-forms-renderer/node_modules/fhirpath/node_modules/commander": { - "version": "2.20.3", - "license": "MIT" - }, "packages/smart-forms-renderer/node_modules/get-stream": { "version": "6.0.1", "dev": true,