Skip to content

Commit

Permalink
feat(components): gs-date-range-selector: make the component controll…
Browse files Browse the repository at this point in the history
…able from a surrounding JS app (#710)

BREAKING CHANGE: gs-date-range-selector: remove `initialValue`, `initialDateFrom`, `initialDateTo`. Use `value` instead.

resolves #683
  • Loading branch information
fengelniederhammer authored Feb 4, 2025
1 parent e667d2d commit 250587e
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 125 deletions.
4 changes: 4 additions & 0 deletions components/.storybook-preact/preview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Preview } from '@storybook/preact';
import { clearAllMocks } from '@storybook/test';

import '../src/styles/tailwind.css';
import { withActions } from '@storybook/addon-actions/decorator';
Expand All @@ -15,6 +16,9 @@ const preview: Preview = {
actions: { handles: [GS_ERROR_EVENT_TYPE] },
},
decorators: [withActions],
beforeEach: () => {
clearAllMocks();
},
};

export default preview;
Original file line number Diff line number Diff line change
Expand Up @@ -18,72 +18,79 @@ const dateRangeOptions = [
];

describe('computeInitialValues', () => {
it('should compute for initial value if initial "from" and "to" are unset', () => {
const result = computeInitialValues(fromToOption, undefined, undefined, earliestDate, dateRangeOptions);
it('should compute initial value if value is dateRangeOption label', () => {
const result = computeInitialValues(fromToOption, earliestDate, dateRangeOptions);

expect(result.initialSelectedDateRange).toEqual(fromToOption);
expectDateMatches(result.initialSelectedDateFrom, new Date(dateFromOptionValue));
expectDateMatches(result.initialSelectedDateTo, new Date(dateToOptionValue));
});

it('should use today as "dateTo" if it is unset in selected option', () => {
const result = computeInitialValues(fromOption, undefined, undefined, earliestDate, dateRangeOptions);
const result = computeInitialValues(fromOption, earliestDate, dateRangeOptions);

expect(result.initialSelectedDateRange).toEqual(fromOption);
expectDateMatches(result.initialSelectedDateFrom, new Date(dateFromOptionValue));
expectDateMatches(result.initialSelectedDateTo, today);
});

it('should use earliest date as "dateFrom" if it is unset in selected option', () => {
const result = computeInitialValues(toOption, undefined, undefined, earliestDate, dateRangeOptions);
const result = computeInitialValues(toOption, earliestDate, dateRangeOptions);

expect(result.initialSelectedDateRange).toEqual(toOption);
expectDateMatches(result.initialSelectedDateFrom, new Date(earliestDate));
expectDateMatches(result.initialSelectedDateTo, new Date(dateToOptionValue));
});

it('should fall back to full range if initial value is not set', () => {
const result = computeInitialValues(undefined, undefined, undefined, earliestDate, dateRangeOptions);
const result = computeInitialValues(undefined, earliestDate, dateRangeOptions);

expect(result.initialSelectedDateRange).toBeUndefined();
expectDateMatches(result.initialSelectedDateFrom, new Date(earliestDate));
expectDateMatches(result.initialSelectedDateTo, today);
});

it('should throw when initial value is unknown', () => {
expect(() =>
computeInitialValues('not a known value', undefined, undefined, earliestDate, dateRangeOptions),
).toThrowError(/Invalid initialValue "not a known value", It must be one of/);
expect(() => computeInitialValues('not a known value', earliestDate, dateRangeOptions)).toThrowError(
/Invalid value "not a known value", It must be one of/,
);
});

it('should throw when initial value is set but no options are provided', () => {
expect(() => computeInitialValues('not a known value', undefined, undefined, earliestDate, [])).toThrowError(
expect(() => computeInitialValues('not a known value', earliestDate, [])).toThrowError(
/There are no selectable options/,
);
});

it('should overwrite initial value if initial "from" is set', () => {
it('should select from date until today if only dateFrom is given', () => {
const initialDateFrom = '2020-01-01';
const result = computeInitialValues(fromOption, initialDateFrom, undefined, earliestDate, dateRangeOptions);
const result = computeInitialValues({ dateFrom: initialDateFrom }, earliestDate, dateRangeOptions);

expect(result.initialSelectedDateRange).toBeUndefined();
expectDateMatches(result.initialSelectedDateFrom, new Date(initialDateFrom));
expectDateMatches(result.initialSelectedDateTo, today);
});

it('should overwrite initial value if initial "to" is set', () => {
it('should select from earliest date until date if only dateTo is given', () => {
const initialDateTo = '2020-01-01';
const result = computeInitialValues(fromOption, undefined, initialDateTo, earliestDate, dateRangeOptions);
const result = computeInitialValues({ dateTo: initialDateTo }, earliestDate, dateRangeOptions);

expect(result.initialSelectedDateRange).toBeUndefined();
expectDateMatches(result.initialSelectedDateFrom, new Date(earliestDate));
expectDateMatches(result.initialSelectedDateTo, new Date(initialDateTo));
});

it('should overwrite initial value if initial "to" and "from" are set', () => {
it('should select date range is dateFrom and dateTo are given', () => {
const initialDateFrom = '2020-01-01';
const initialDateTo = '2022-01-01';
const result = computeInitialValues(fromOption, initialDateFrom, initialDateTo, earliestDate, dateRangeOptions);
const result = computeInitialValues(
{
dateFrom: initialDateFrom,
dateTo: initialDateTo,
},
earliestDate,
dateRangeOptions,
);

expect(result.initialSelectedDateRange).toBeUndefined();
expectDateMatches(result.initialSelectedDateFrom, new Date(initialDateFrom));
Expand All @@ -93,22 +100,29 @@ describe('computeInitialValues', () => {
it('should set initial "to" to "from" if "from" is after "to"', () => {
const initialDateFrom = '2020-01-01';
const initialDateTo = '1900-01-01';
const result = computeInitialValues(undefined, initialDateFrom, initialDateTo, earliestDate, dateRangeOptions);
const result = computeInitialValues(
{
dateFrom: initialDateFrom,
dateTo: initialDateTo,
},
earliestDate,
dateRangeOptions,
);

expect(result.initialSelectedDateRange).toBeUndefined();
expectDateMatches(result.initialSelectedDateFrom, new Date(initialDateFrom));
expectDateMatches(result.initialSelectedDateTo, new Date(initialDateFrom));
});

it('should throw if initial "from" is not a valid date', () => {
expect(() => computeInitialValues(undefined, 'not a date', undefined, earliestDate, [])).toThrowError(
'Invalid initialDateFrom',
expect(() => computeInitialValues({ dateFrom: 'not a date' }, earliestDate, [])).toThrowError(
'Invalid value.dateFrom',
);
});

it('should throw if initial "to" is not a valid date', () => {
expect(() => computeInitialValues(undefined, undefined, 'not a date', earliestDate, [])).toThrowError(
'Invalid initialDateTo',
expect(() => computeInitialValues({ dateTo: 'not a date' }, earliestDate, [])).toThrowError(
'Invalid value.dateTo',
);
});

Expand Down
46 changes: 25 additions & 21 deletions components/src/preact/dateRangeSelector/computeInitialValues.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,36 @@
import { type DateRangeOption } from './dateRangeOption';
import { type DateRangeOption, type DateRangeValue } from './dateRangeOption';
import { getDatesForSelectorValue, getSelectableOptions } from './selectableOptions';
import { UserFacingError } from '../components/error-display';

export function computeInitialValues(
initialValue: string | undefined,
initialDateFrom: string | undefined,
initialDateTo: string | undefined,
value: DateRangeValue | undefined,
earliestDate: string,
dateRangeOptions: DateRangeOption[],
): {
initialSelectedDateRange: string | undefined;
initialSelectedDateFrom: Date;
initialSelectedDateTo: Date;
} {
if (isUndefinedOrEmpty(initialDateFrom) && isUndefinedOrEmpty(initialDateTo)) {
if (value === undefined) {
const { dateFrom, dateTo } = getDatesForSelectorValue(undefined, dateRangeOptions, earliestDate);
return {
initialSelectedDateRange: undefined,
initialSelectedDateFrom: dateFrom,
initialSelectedDateTo: dateTo,
};
}

if (typeof value === 'string') {
const selectableOptions = getSelectableOptions(dateRangeOptions);
const initialSelectedDateRange = selectableOptions.find((option) => option.value === initialValue)?.value;
const initialSelectedDateRange = selectableOptions.find((option) => option.value === value)?.value;

if (initialValue !== undefined && initialSelectedDateRange === undefined) {
if (initialSelectedDateRange === undefined) {
if (selectableOptions.length === 0) {
throw new UserFacingError(
'Invalid initialValue',
'There are no selectable options, but initialValue is set.',
);
throw new UserFacingError('Invalid value', 'There are no selectable options, but value is set.');
}
throw new UserFacingError(
'Invalid initialValue',
`Invalid initialValue "${initialValue}", It must be one of ${selectableOptions.map((option) => `'${option.value}'`).join(', ')}`,
'Invalid value',
`Invalid value "${value}", It must be one of ${selectableOptions.map((option) => `'${option.value}'`).join(', ')}`,
);
}

Expand All @@ -39,21 +43,21 @@ export function computeInitialValues(
};
}

const initialSelectedDateFrom = isUndefinedOrEmpty(initialDateFrom)
? new Date(earliestDate)
: new Date(initialDateFrom);
let initialSelectedDateTo = isUndefinedOrEmpty(initialDateTo) ? new Date() : new Date(initialDateTo);
const { dateFrom, dateTo } = value;

const initialSelectedDateFrom = isUndefinedOrEmpty(dateFrom) ? new Date(earliestDate) : new Date(dateFrom);
let initialSelectedDateTo = isUndefinedOrEmpty(dateTo) ? new Date() : new Date(dateTo);

if (isNaN(initialSelectedDateFrom.getTime())) {
throw new UserFacingError(
'Invalid initialDateFrom',
`Invalid initialDateFrom "${initialDateFrom}", It must be of the format YYYY-MM-DD`,
'Invalid value.dateFrom',
`Invalid value.dateFrom "${dateFrom}", It must be of the format YYYY-MM-DD`,
);
}
if (isNaN(initialSelectedDateTo.getTime())) {
throw new UserFacingError(
'Invalid initialDateTo',
`Invalid initialDateTo "${initialDateTo}", It must be of the format YYYY-MM-DD`,
'Invalid value.dateTo',
`Invalid value.dateTo "${dateTo}", It must be of the format YYYY-MM-DD`,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type Meta, type PreactRenderer, type StoryObj } from '@storybook/preact
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import type { StepFunction } from '@storybook/types';
import dayjs from 'dayjs/esm';
import { useEffect, useRef, useState } from 'preact/hooks';

import { DateRangeSelector, type DateRangeSelectorProps } from './date-range-selector';
import { previewHandles } from '../../../.storybook/preview';
Expand All @@ -28,11 +29,10 @@ const meta: Meta<DateRangeSelectorProps> = {
fetchMock: {},
},
argTypes: {
initialValue: {
value: {
control: {
type: 'select',
type: 'object',
},
options: [dateRangeOptionPresets.lastMonth.label, dateRangeOptionPresets.allTimes.label, 'CustomDateRange'],
},
dateRangeOptions: {
control: {
Expand All @@ -53,11 +53,9 @@ const meta: Meta<DateRangeSelectorProps> = {
args: {
dateRangeOptions: [dateRangeOptionPresets.lastMonth, dateRangeOptionPresets.allTimes, customDateRange],
earliestDate,
initialValue: dateRangeOptionPresets.lastMonth.label,
value: undefined,
lapisDateField: 'aDateColumn',
width: '100%',
initialDateFrom: undefined,
initialDateTo: undefined,
},
};

Expand All @@ -75,7 +73,7 @@ export const SetCorrectInitialValues: StoryObj<DateRangeSelectorProps> = {
...Primary,
args: {
...Primary.args,
initialValue: 'CustomDateRange',
value: 'CustomDateRange',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
Expand All @@ -94,7 +92,7 @@ export const SetCorrectInitialDateFrom: StoryObj<DateRangeSelectorProps> = {
...Primary,
args: {
...Primary.args,
initialDateFrom,
value: { dateFrom: initialDateFrom },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
Expand All @@ -113,7 +111,7 @@ export const SetCorrectInitialDateTo: StoryObj<DateRangeSelectorProps> = {
...Primary,
args: {
...Primary.args,
initialDateTo,
value: { dateTo: initialDateTo },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
Expand All @@ -128,6 +126,10 @@ export const SetCorrectInitialDateTo: StoryObj<DateRangeSelectorProps> = {

export const ChangingDateSetsOptionToCustom: StoryObj<DateRangeSelectorProps> = {
...Primary,
args: {
...Primary.args,
value: dateRangeOptionPresets.lastMonth.label,
},
play: async ({ canvasElement, step }) => {
const { canvas, filterChangedListenerMock, optionChangedListenerMock } = await prepare(canvasElement, step);

Expand Down Expand Up @@ -165,15 +167,78 @@ export const ChangingDateSetsOptionToCustom: StoryObj<DateRangeSelectorProps> =
},
};

export const ChangingDateOption: StoryObj<DateRangeSelectorProps> = {
export const ChangingTheValueProgrammatically: StoryObj<DateRangeSelectorProps> = {
...Primary,
render: (args) => {
const StatefulWrapper = () => {
const [value, setValue] = useState('Last month');
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
ref.current?.addEventListener('gs-date-range-option-changed', (event) => {
setValue((event as CustomEvent).detail);
});
}, []);

return (
<div ref={ref}>
<LapisUrlContextProvider value={LAPIS_URL}>
<DateRangeSelector {...args} value={value} />
</LapisUrlContextProvider>
<button className='btn' onClick={() => setValue(customDateRange.label)}>
Set to Custom
</button>
<button className='btn' onClick={() => setValue(dateRangeOptionPresets.lastMonth.label)}>
Set to Last month
</button>
</div>
);
};

return <StatefulWrapper />;
},
play: async ({ canvasElement, step }) => {
const { canvas, filterChangedListenerMock, optionChangedListenerMock } = await prepare(canvasElement, step);

await waitFor(async () => {
await expect(selectField(canvas)).toHaveValue('Last month');
});

await step('Change the value of the component programmatically', async () => {
await userEvent.click(canvas.getByRole('button', { name: 'Set to Custom' }));
await waitFor(async () => {
await expect(selectField(canvas)).toHaveValue(customDateRange.label);
});

await userEvent.click(canvas.getByRole('button', { name: 'Set to Last month' }));
await waitFor(async () => {
await expect(selectField(canvas)).toHaveValue('Last month');
});

await expect(filterChangedListenerMock).toHaveBeenCalledTimes(0);
await expect(optionChangedListenerMock).toHaveBeenCalledTimes(0);
});

await step('Changing the value from within the component is still possible', async () => {
await userEvent.selectOptions(selectField(canvas), 'All times');
await waitFor(async () => {
await expect(selectField(canvas)).toHaveValue('All times');
});
await expect(filterChangedListenerMock).toHaveBeenCalledTimes(1);
await expect(optionChangedListenerMock).toHaveBeenCalledTimes(1);
});
},
};

export const ChangingDateOption: StoryObj<DateRangeSelectorProps> = {
...Primary,
play: async ({ canvasElement, step }) => {
const { canvas, filterChangedListenerMock, optionChangedListenerMock } = await prepare(canvasElement, step);

await waitFor(async () => {
await expect(selectField(canvas)).toHaveValue('Custom');
});

await step('Change date to custom', async () => {
await userEvent.selectOptions(selectField(canvas), 'CustomDateRange');

Expand Down Expand Up @@ -203,7 +268,7 @@ export const HandlesInvalidInitialDateFrom: StoryObj<DateRangeSelectorProps> = {
...Primary,
args: {
...Primary.args,
initialDateFrom: 'not a date',
value: { dateFrom: 'not a date' },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
Expand Down
Loading

0 comments on commit 250587e

Please sign in to comment.