diff --git a/src/controls/GroupSlider/GroupSlider.stories.tsx b/src/controls/GroupSlider/GroupSlider.stories.tsx new file mode 100644 index 00000000..d2bc08e7 --- /dev/null +++ b/src/controls/GroupSlider/GroupSlider.stories.tsx @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; + +import { GroupWizardBus } from 'src/controls/GroupWizard'; + +import { CONTEXT, SLIDER_ITEM, WithGroupSliderProviderDecorator } from './GroupSlider.stories.utils'; +import { GroupSlider } from './index'; + +const meta: Meta = { + title: 'Questionnaire / questions / group / slider', + component: GroupSlider, + decorators: [WithGroupSliderProviderDecorator], +}; + +export default meta; +type Story = StoryObj; + +export const Slider: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByTestId('group-slider-empty')).toBeInTheDocument(); + await expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('0 of 0'); + + const addButton = canvas.getByTestId('group-slider-add-button'); + + // Adding the first item renders it as the only slide. + await userEvent.click(addButton); + await expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('1 of 1'); + await expect(canvas.getAllByTestId('medication-name')).toHaveLength(1); + + const firstNameInput = canvas.getByTestId('medication-name').querySelector('input')!; + await userEvent.type(firstNameInput, 'Aspirin'); + + // Adding a second item navigates to it and the first item is no longer rendered. + await userEvent.click(addButton); + await expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('2 of 2'); + await expect(canvas.getAllByTestId('medication-name')).toHaveLength(1); + + const secondNameInput = canvas.getByTestId('medication-name').querySelector('input')!; + await expect(secondNameInput).toHaveValue(''); + await userEvent.type(secondNameInput, 'Ibuprofen'); + + // Navigating back shows only the first item's data, the second is not rendered. + const prevButton = canvas.getByTestId('group-slider-prev-button'); + await userEvent.click(prevButton); + await expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('1 of 2'); + await expect(canvas.getAllByTestId('medication-name')).toHaveLength(1); + await expect(canvas.getByTestId('medication-name').querySelector('input')).toHaveValue('Aspirin'); + + const nextButton = canvas.getByTestId('group-slider-next-button'); + await userEvent.click(nextButton); + await expect(canvas.getByTestId('medication-name').querySelector('input')).toHaveValue('Ibuprofen'); + + // Removing the current item drops it and shows the remaining one. + const removeButton = canvas.getByTestId('group-slider-remove-button'); + await userEvent.click(removeButton); + await expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('1 of 1'); + await expect(canvas.getByTestId('medication-name').querySelector('input')).toHaveValue('Aspirin'); + }, +}; + +export const Empty: Story = { + render: () => , +}; + +export const BusControlled: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const groupLinkId = SLIDER_ITEM.linkId; + const nameInput = () => canvas.getByTestId('medication-name').querySelector('input')!; + + await waitFor(() => expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('0 of 0')); + + // 'addItem' adds a slide without clicking the Add button. + GroupWizardBus.dispatch({ type: 'addItem', groupLinkId }); + await waitFor(() => expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('1 of 1')); + await expect(canvas.getAllByTestId('medication-name')).toHaveLength(1); + await userEvent.type(nameInput(), 'Aspirin'); + + // Adding a second item navigates to it; the first item is no longer rendered. + GroupWizardBus.dispatch({ type: 'addItem', groupLinkId }); + await waitFor(() => expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('2 of 2')); + await expect(canvas.getAllByTestId('medication-name')).toHaveLength(1); + await expect(nameInput()).toHaveValue(''); + await userEvent.type(nameInput(), 'Ibuprofen'); + + // 'openLeft' steps back to the first item; the second is not rendered. + GroupWizardBus.dispatch({ type: 'openLeft', groupLinkId }); + await waitFor(() => expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('1 of 2')); + await expect(canvas.getAllByTestId('medication-name')).toHaveLength(1); + await expect(nameInput()).toHaveValue('Aspirin'); + + // 'openRight' steps forward again. + GroupWizardBus.dispatch({ type: 'openRight', groupLinkId }); + await waitFor(() => expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('2 of 2')); + await expect(nameInput()).toHaveValue('Ibuprofen'); + + // 'removeItem' with no explicit index drops the current (second) slide. + GroupWizardBus.dispatch({ type: 'removeItem', groupLinkId }); + await waitFor(() => expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('1 of 1')); + await expect(nameInput()).toHaveValue('Aspirin'); + + // Events targeting a different group are ignored. + GroupWizardBus.dispatch({ type: 'addItem', groupLinkId: 'unrelated-group' }); + await waitFor(() => expect(canvas.getByTestId('group-slider-position')).toHaveTextContent('1 of 1')); + }, +}; diff --git a/src/controls/GroupSlider/GroupSlider.stories.utils.tsx b/src/controls/GroupSlider/GroupSlider.stories.utils.tsx new file mode 100644 index 00000000..d388230d --- /dev/null +++ b/src/controls/GroupSlider/GroupSlider.stories.utils.tsx @@ -0,0 +1,86 @@ +import type { Decorator } from '@storybook/react'; +import { Questionnaire, QuestionnaireResponse } from 'fhir/r4b'; +import { FormProvider, useForm } from 'react-hook-form'; +import { FCEQuestionnaireItem, FormItems, ItemContext, QuestionnaireResponseFormProvider } from 'sdc-qrf'; + +import { BaseQuestionnaireResponseFormPropsContext } from '@beda.software/fhir-questionnaire/contexts'; +import { success } from '@beda.software/remote-data'; + +import s from 'src/components/BaseQuestionnaireResponseForm/BaseQuestionnaireResponseForm.module.scss'; +import { ValueSetExpandProvider } from 'src/contexts'; +import { questionItemComponents } from 'src/controls'; + +export const SLIDER_ITEM: FCEQuestionnaireItem = { + linkId: 'medications', + text: 'Medications', + type: 'group', + repeats: true, + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', + valueCodeableConcept: { + coding: [ + { + code: 'group-slider', + }, + ], + }, + }, + ], + item: [ + { + linkId: 'medication-name', + text: 'Medication name', + type: 'string', + required: true, + }, + { + linkId: 'medication-dosage', + text: 'Dosage', + type: 'string', + }, + ], +}; + +export const QUESTIONNAIRE: Questionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [SLIDER_ITEM], +}; + +export const QUESTIONNAIRE_RESPONSE: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'in-progress', +}; + +export const CONTEXT: ItemContext[] = [ + { + questionnaire: QUESTIONNAIRE, + resource: QUESTIONNAIRE_RESPONSE, + context: QUESTIONNAIRE_RESPONSE, + }, +]; + +export const WithGroupSliderProviderDecorator: Decorator = (Story) => { + const methods = useForm(); + + return ( + + undefined} + fhirService={async () => success(undefined)} + evaluateFhirpath={() => []} + > + []}> + +
+ + +
+
+
+
+ ); +}; diff --git a/src/controls/GroupSlider/hooks.tsx b/src/controls/GroupSlider/hooks.tsx new file mode 100644 index 00000000..26f66d9d --- /dev/null +++ b/src/controls/GroupSlider/hooks.tsx @@ -0,0 +1,95 @@ +import _ from 'lodash'; +import { useState } from 'react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { FormItems, GroupItemProps, RepeatableFormGroupItems, getItemKey, populateItemKey } from 'sdc-qrf'; + +import { useFieldController } from 'src/components/BaseQuestionnaireResponseForm/hooks'; +import { GroupWizardBus } from 'src/controls/GroupWizard'; + +export function useGroupSlider(props: GroupItemProps) { + const { parentPath, questionItem } = props; + const { linkId, readOnly } = questionItem; + + const fieldName = [...parentPath, linkId]; + + const { onChange } = useFieldController(fieldName, questionItem); + + const { control, getValues } = useFormContext(); + + const value = _.get(getValues(), fieldName); + const items: FormItems[] = value?.items || []; + + const fieldArrayName = [...parentPath, linkId, 'items'].join('.'); + const { remove } = useFieldArray({ control, name: fieldArrayName }); + + const [rawIndex, setCurrentIndex] = useState(0); + const currentIndex = Math.min(rawIndex, Math.max(items.length - 1, 0)); + + const onAdd = () => { + const updatedItems = [...items, {}].map(populateItemKey); + onChange({ ...value, items: updatedItems }); + setCurrentIndex(updatedItems.length - 1); + }; + + const onRemove = (index: number) => { + remove(index); + const filteredItems = items.filter((_item, itemIndex) => itemIndex !== index); + onChange({ ...value, items: filteredItems }); + setCurrentIndex((current) => Math.min(current, Math.max(filteredItems.length - 1, 0))); + }; + + const goLeft = () => setCurrentIndex((index) => Math.max(index - 1, 0)); + const goRight = () => setCurrentIndex((index) => Math.min(index + 1, Math.max(items.length - 1, 0))); + + GroupWizardBus.useBus( + 'addItem', + ({ groupLinkId }) => { + if (groupLinkId === linkId) { + onAdd(); + } + }, + [linkId, items], + ); + + GroupWizardBus.useBus( + 'removeItem', + ({ groupLinkId, index }) => { + if (groupLinkId === linkId) { + onRemove(index ?? currentIndex); + } + }, + [linkId, items, currentIndex], + ); + + GroupWizardBus.useBus( + 'openLeft', + ({ groupLinkId }) => { + if (groupLinkId === linkId) { + goLeft(); + } + }, + [linkId], + ); + + GroupWizardBus.useBus( + 'openRight', + ({ groupLinkId }) => { + if (groupLinkId === linkId) { + goRight(); + } + }, + [linkId, items.length], + ); + + return { + readOnly: !!readOnly, + items, + currentIndex, + setCurrentIndex, + onAdd, + onRemove, + goLeft, + goRight, + getKey: getItemKey, + }; +} diff --git a/src/controls/GroupSlider/index.tsx b/src/controls/GroupSlider/index.tsx new file mode 100644 index 00000000..d131d256 --- /dev/null +++ b/src/controls/GroupSlider/index.tsx @@ -0,0 +1,98 @@ +import { DeleteOutlined, LeftOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'; +import { t, Trans } from '@lingui/macro'; +import { Alert, Button } from 'antd'; +import { GroupItemProps, QuestionItems } from 'sdc-qrf'; + +import { ItemHelpText } from 'src/components/BaseQuestionnaireResponseForm/ItemHelpText'; +import { Text, Title } from 'src/components/Typography'; + +import { useGroupSlider } from './hooks'; +import { S } from './styles'; + +export { useGroupSlider } from './hooks'; + +export function GroupSlider(props: GroupItemProps) { + const { parentPath, questionItem, context } = props; + const { linkId, text, helpText, hidden, repeats, item } = questionItem; + + const { readOnly, items, currentIndex, onAdd, onRemove, goLeft, goRight, getKey } = useGroupSlider(props); + + if (hidden) { + return null; + } + + if (!repeats) { + return ; + } + + const itemsCount = items.length; + const currentItem = items[currentIndex]; + const itemContext = context[currentIndex] ?? context[0]!; + const itemParentPath = [...parentPath, linkId, 'items', currentIndex.toString()]; + + return ( + + {text || helpText ? ( + + {text && {text}} + {helpText && } + + ) : null} + + {itemsCount === 0 ? ( + + No items yet + + ) : ( + + + + )} + + + + + ) : null} + + + )} + + + ); +} diff --git a/src/controls/GroupSlider/styles.ts b/src/controls/GroupSlider/styles.ts new file mode 100644 index 00000000..5f16d045 --- /dev/null +++ b/src/controls/GroupSlider/styles.ts @@ -0,0 +1,43 @@ +import styled from 'styled-components'; + +export const S = { + Group: styled.div` + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + `, + Header: styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + `, + Slide: styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + border: 1px solid ${({ theme }) => theme.neutral.dividers}; + border-radius: 6px; + `, + Footer: styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 16px; + `, + Nav: styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + `, + Actions: styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + `, +}; diff --git a/src/controls/GroupWizard/index.tsx b/src/controls/GroupWizard/index.tsx index abf7e575..4a86c2ab 100644 --- a/src/controls/GroupWizard/index.tsx +++ b/src/controls/GroupWizard/index.tsx @@ -25,8 +25,9 @@ export interface GroupWizardProps extends GroupItemProps { } export const GroupWizardBus = createBus<{ - type: 'scrollTo'; + type: 'scrollTo' | 'addItem' | 'removeItem' | 'openLeft' | 'openRight'; groupLinkId: string; + index?: number; }>(); export function GroupWizardVertical(props: GroupWizardProps) { diff --git a/src/controls/controls.tsx b/src/controls/controls.tsx index 70db076d..6d96c18e 100644 --- a/src/controls/controls.tsx +++ b/src/controls/controls.tsx @@ -14,6 +14,7 @@ import { EditableGroup, Grid, Group, + GroupSlider, GroupTable, GroupTabs, GroupWizard, @@ -104,5 +105,6 @@ export const itemControlGroupItemComponents: ItemControlGroupItemComponentMappin 'wizard-vertical': GroupWizardVertical, 'group-tabs': GroupTabs, 'group-table': GroupTable, + 'group-slider': GroupSlider, 'editable-group': EditableGroup, }; diff --git a/src/controls/index.ts b/src/controls/index.ts index 0cfc4e80..dc938b2e 100644 --- a/src/controls/index.ts +++ b/src/controls/index.ts @@ -13,6 +13,7 @@ export * from './Group/GroupCard'; export * from './Group/GTable'; export * from './Group/RepeatableGroups'; export * from './Group/RepeatableGroups/RepeatableGroupCard'; +export * from './GroupSlider'; export * from './GroupTable'; export * from './GroupTable/GroupTableChart'; export * from './GroupTable/GroupTableChart/HighlightArea';