diff --git a/apps/smart-forms-app/package.json b/apps/smart-forms-app/package.json index 7314c996..4e9030cc 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 7a7b9d80..e6d642b0 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", @@ -487,6 +487,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, @@ -11993,6 +12043,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -19398,6 +19449,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, @@ -41446,37 +41525,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", @@ -42068,31 +42119,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/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/inline-style-parser": { "version": "0.2.3", "license": "MIT" diff --git a/packages/smart-forms-renderer/package.json b/packages/smart-forms-renderer/package.json index 133ff257..6037993c 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.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 new file mode 100644 index 00000000..684a0e53 --- /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 SecondaryFab = styled(Fab)(({ theme }) => ({ + color: '#fff', + background: theme.palette.secondary.main, + '&:hover': { + background: theme.palette.secondary.dark + } +})); 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 7de203f3..556170f3 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 aeb4a808..86aac00b 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 cbb39447..920cdffd 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,6 +68,9 @@ function GroupItemView(props: GroupItemViewProps) { tabIsMarkedAsComplete, tabs, currentTabIndex, + pageIsMarkedAsComplete, + pages, + currentPageIndex, parentIsReadOnly, parentIsRepeatGroup, parentRepeatGroupIndex, @@ -91,6 +99,7 @@ function GroupItemView(props: GroupItemViewProps) { qItem={qItem} readOnly={readOnly} tabIsMarkedAsComplete={tabIsMarkedAsComplete} + pageIsMarkedAsComplete={pageIsMarkedAsComplete} isRepeated={isRepeated} /> @@ -117,6 +126,7 @@ function GroupItemView(props: GroupItemViewProps) { {/* Next tab button at the end of each tab group */} + @@ -133,6 +143,7 @@ function GroupItemView(props: GroupItemViewProps) { qItem={qItem} readOnly={readOnly} tabIsMarkedAsComplete={tabIsMarkedAsComplete} + pageIsMarkedAsComplete={pageIsMarkedAsComplete} isRepeated={isRepeated} /> {childQItems.map((qItem: QuestionnaireItem, i) => { @@ -155,6 +166,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 00000000..d6063222 --- /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 { SecondaryFab } 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 00000000..712b9119 --- /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 Typography from '@mui/material/Typography'; +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 ( + + + + Page {`${currentPageIndex + 1} / ${numOfVisiblePages}`} + + + + + ); +}); + +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 00000000..a63bd207 --- /dev/null +++ b/packages/smart-forms-renderer/src/components/FormComponents/GroupItem/PreviousPageButton.tsx @@ -0,0 +1,41 @@ +/* + * 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 { SecondaryFab } 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 9bb53c68..6abe9c68 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 FormTopLevelPage from './FormTopLevelPage'; /** * 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 00000000..e9189907 --- /dev/null +++ b/packages/smart-forms-renderer/src/components/Renderer/FormBodyPage.tsx @@ -0,0 +1,93 @@ +import React, { useMemo } 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'; +import { getQrItemsIndex, mapQItemsIndex } from '../../utils/mapItem'; +import { createEmptyQrGroup, updateQrItemsInGroup } from '../../utils/qrItem'; + +interface FormBodyPageProps + extends PropsWithQrItemChangeHandler, + PropsWithParentIsReadOnlyAttribute { + topLevelQItem: QuestionnaireItem; + topLevelQRItem: QuestionnaireResponseItem | null; +} + +function FormBodyPage(props: FormBodyPageProps) { + 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 ( + + + + {qItems.map((qItem, i) => { + const qrItem = qrItemsByIndex[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 dec56ec3..19cfc0ec 100644 --- a/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelItem.tsx +++ b/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelItem.tsx @@ -18,7 +18,9 @@ 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 { containsPages, isPage } from '../../utils/page'; import GroupItem from '../FormComponents/GroupItem/GroupItem'; import SingleItem from '../FormComponents/SingleItem/SingleItem'; import type { @@ -53,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'; @@ -106,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/components/Renderer/FormTopLevelPage.tsx b/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelPage.tsx new file mode 100644 index 00000000..b2ae07b3 --- /dev/null +++ b/packages/smart-forms-renderer/src/components/Renderer/FormTopLevelPage.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 FormTopLevelPageProps + extends PropsWithQrItemChangeHandler, + PropsWithParentIsReadOnlyAttribute { + topLevelQItems: QuestionnaireItem[]; + topLevelQRItems: (QuestionnaireResponseItem | QuestionnaireResponseItem[] | undefined)[]; +} + +function FormTopLevelPage(props: FormTopLevelPageProps) { + 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 FormTopLevelPage; 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 00000000..39921b11 --- /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 00000000..47e02e0c --- /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 d0ea6b34..180b0694 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 34c3844c..b1dcb969 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 507313dc..911cae30 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,676 @@ 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 + } + ] + } + ] +}; + +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 + } + ] + } + ] + } + ] +}; + +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 959698fa..78580fad 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,10 @@ import { qItemControlDisplayTabContainer, qItemControlGroupGridMultiRow, qItemControlGroupGridSingleRow, - qItemControlGroupGTable + qItemControlGroupGTable, + qItemControlGroupPage, + qItemControlGroupPageContainer, + qItemControlGroupPageNonTopLevelPageContainer } from '../assets/questionnaires'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export @@ -60,3 +63,21 @@ export const TabContainer: Story = { questionnaire: qItemControlDisplayTabContainer } }; + +export const Page: Story = { + args: { + questionnaire: qItemControlGroupPage + } +}; + +export const PageContainer: Story = { + args: { + questionnaire: qItemControlGroupPageContainer + } +}; + +export const PageContainerNonSingleTopLevel: Story = { + args: { + questionnaire: qItemControlGroupPageNonTopLevelPageContainer + } +}; diff --git a/packages/smart-forms-renderer/src/utils/initialise.ts b/packages/smart-forms-renderer/src/utils/initialise.ts index b28e8aef..d66e438c 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 00000000..343dfde2 --- /dev/null +++ b/packages/smart-forms-renderer/src/utils/page.ts @@ -0,0 +1,134 @@ +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 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 { + const anyPage = topLevelQItem?.filter(isPage); + + if (!anyPage) return false; + + return anyPage.some((page) => page.item?.every((i) => i.type === 'group') || 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 + * + * @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, + hasPageContainer: boolean +): Pages { + if (!qItems) return {}; + + const qItemPages = hasPageContainer ? qItems : 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 f3169d07..f28b4136 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 00000000..d6a29f60 --- /dev/null +++ b/packages/smart-forms-renderer/src/utils/questionnaireStoreUtils/extractPages.ts @@ -0,0 +1,24 @@ +import type { Questionnaire } from 'fhir/r4'; +import type { Pages } from '../../interfaces/page.interface'; +import { constructPagesWithProperties, isPage, isPageContainer } from '../page'; + +export function extractPages(questionnaire: Questionnaire): Pages { + if (!questionnaire.item || questionnaire.item.length === 0) { + return {}; + } + + 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; +}