Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/controls/GroupSlider/GroupSlider.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof GroupSlider> = {
title: 'Questionnaire / questions / group / slider',
component: GroupSlider,
decorators: [WithGroupSliderProviderDecorator],
};

export default meta;
type Story = StoryObj<typeof GroupSlider>;

export const Slider: Story = {
render: () => <GroupSlider parentPath={[]} questionItem={SLIDER_ITEM} context={CONTEXT} />,
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: () => <GroupSlider parentPath={[]} questionItem={SLIDER_ITEM} context={CONTEXT} />,
};

export const BusControlled: Story = {
render: () => <GroupSlider parentPath={[]} questionItem={SLIDER_ITEM} context={CONTEXT} />,
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'));
},
};
86 changes: 86 additions & 0 deletions src/controls/GroupSlider/GroupSlider.stories.utils.tsx
Original file line number Diff line number Diff line change
@@ -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<FormItems>();

return (
<FormProvider {...methods}>
<QuestionnaireResponseFormProvider
questionItemComponents={questionItemComponents}
formValues={{}}
setFormValues={() => undefined}
fhirService={async () => success(undefined)}
evaluateFhirpath={() => []}
>
<ValueSetExpandProvider.Provider value={async () => []}>
<BaseQuestionnaireResponseFormPropsContext.Provider value={{ submitting: false }}>
<form className={s.form}>
<Story />
</form>
</BaseQuestionnaireResponseFormPropsContext.Provider>
</ValueSetExpandProvider.Provider>
</QuestionnaireResponseFormProvider>
</FormProvider>
);
};
95 changes: 95 additions & 0 deletions src/controls/GroupSlider/hooks.tsx
Original file line number Diff line number Diff line change
@@ -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<RepeatableFormGroupItems>(fieldName, questionItem);

const { control, getValues } = useFormContext<FormItems>();

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,
};
}
98 changes: 98 additions & 0 deletions src/controls/GroupSlider/index.tsx
Original file line number Diff line number Diff line change
@@ -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 <Alert type="error" message={t`The group-slider itemControl is designed for repeatable groups`} />;
}

const itemsCount = items.length;
const currentItem = items[currentIndex];
const itemContext = context[currentIndex] ?? context[0]!;
const itemParentPath = [...parentPath, linkId, 'items', currentIndex.toString()];

return (
<S.Group>
{text || helpText ? (
<S.Header>
{text && <Title level={5}>{text}</Title>}
{helpText && <ItemHelpText helpText={helpText} />}
</S.Header>
) : null}

{itemsCount === 0 ? (
<Text data-testid="group-slider-empty">
<Trans>No items yet</Trans>
</Text>
) : (
<S.Slide data-testid={`group-slider-item-${currentIndex}`} key={getKey(currentItem!)}>
<QuestionItems questionItems={item!} parentPath={itemParentPath} context={itemContext} />
</S.Slide>
)}

<S.Footer>
<S.Nav>
<Button
disabled={currentIndex <= 0}
onClick={goLeft}
icon={<LeftOutlined />}
data-testid="group-slider-prev-button"
/>
<Text data-testid="group-slider-position">
{itemsCount === 0 ? t`0 of 0` : `${currentIndex + 1} of ${itemsCount}`}
</Text>
<Button
disabled={currentIndex >= itemsCount - 1}
onClick={goRight}
icon={<RightOutlined />}
data-testid="group-slider-next-button"
/>
</S.Nav>

{readOnly ? null : (
<S.Actions>
{itemsCount > 0 ? (
<Button
icon={<DeleteOutlined />}
onClick={() => onRemove(currentIndex)}
data-testid="group-slider-remove-button"
>
<span>
<Trans>Remove</Trans>
</span>
</Button>
) : null}
<Button
type="primary"
ghost
icon={<PlusOutlined />}
onClick={onAdd}
data-testid="group-slider-add-button"
>
<span>{text ? <Trans>Add {text}</Trans> : <Trans>Add another answer</Trans>}</span>
</Button>
</S.Actions>
)}
</S.Footer>
</S.Group>
);
}
Loading
Loading