From 25bb353685c595c2b05f1a355a381c28fd57526a Mon Sep 17 00:00:00 2001 From: Diego Dias Date: Mon, 30 Sep 2024 11:01:15 -0300 Subject: [PATCH] Feat/date value datepicker (#1190) * feat(datepicker): accepts dateValue as value This issue was found in a scenario where I needed to update the selectedDate without clicking into the component, just by passing a value to the Datepicker, and actually it just accepts changes by clicking which I don't believe covers the use cases. * chore(lint/prettier): cleaning code Cleaning code * removed unecessary debug * test(fixing tests): finding why tests are not running Tests stopped running locally, running it on the pipe * chore: fixed testing for the use case scenario * fix: removing commments Comments removal * chore(reducing complexity for datevalue): assertive state/context management according view/date * chore: linting * feat: added necessary documentation * Update Datepicker.spec.tsx chore: removing screen.debug * chore: pipeline build fixes * fix(clear button): fixed datepicker clear button to not displaying any selected date by using null" Attributed null as a acceptable value to the selectedDate, also removed default args for weekstart which was crashing the app due to an overlap with the component props 1039 * Update Datepicker.spec.tsx * test(datepicker): clear Clear should affect dateValue * docs(datepicker): it should have right type for the new empty date case scenario texting property Updated datepicker component storybook file controls, added the new property and its type so the dev can simulate scenarios on storybook * chore: removing unecessary console * docs(datepicker): label naming Changed the name from labelempty to label * feat(datepicker): datevalue expects null to clear date Now datevalue expects null to clear date, also a diff mechanism was added on the dateValue useffect to prevent mass re-renders. g * feat(datepicker): datepicker should expect value and defaultValue Now datepicker component accepts value and defaultValue as props comes from this is the most intuitive pattern. BREAKING CHANGE: A breaking change which affects the old property defaultDate being named now defaultValue. * feat(datepicker): datepicker default value and sideeffects Setting defaultvalue to empty when the component loads, therefore updating its sideffects on mounting considering dateview initialization | Added test cases to these scenarios | Added propper naming and default values according flowbite patterns BREAKING CHANGE: New defaultValue initialization value | Migrating from defaultDate to defaultValue * feat: adding datevalue property * feat: handling date value with propper test cases * feat: handling date value with propper test cases * feat: removed default value and renamed onchange prop * feat: solved comments | updated tests and parameters * feat: ts compiling * feat: added null as a type reference for selectedDate * feat: prettier * feat: added value as a modifier * feat: updated storybook to reflect controlled and uncontrolled model * feat: removed debugger and renamed defaultDate to defaultValue * feat: removed debugger and renamed defaultDate to defaultValue * feat: format check * feat: addressing hook dependencies * feat: controlled component setup | clearn functionality * feat: remove debugger statement * feat: defaultDate empty and side-effects addressed * feat: added changeset * feat - changset to patch --------- Co-authored-by: Dias, Diego --- .changeset/old-jobs-decide.md | 22 +++++ .../content/docs/components/datepicker.mdx | 6 ++ .../components/Datepicker/Datepicker.spec.tsx | 20 ++-- .../Datepicker/Datepicker.stories.tsx | 97 ++++++++++++++++++- .../src/components/Datepicker/Datepicker.tsx | 63 +++++++----- .../Datepicker/DatepickerContext.tsx | 4 +- .../src/components/Datepicker/Views/Days.tsx | 2 +- .../components/Datepicker/Views/Decades.tsx | 5 +- .../components/Datepicker/Views/Months.tsx | 4 +- .../src/components/Datepicker/Views/Years.tsx | 2 +- 10 files changed, 179 insertions(+), 46 deletions(-) create mode 100644 .changeset/old-jobs-decide.md diff --git a/.changeset/old-jobs-decide.md b/.changeset/old-jobs-decide.md new file mode 100644 index 000000000..1d8d067ef --- /dev/null +++ b/.changeset/old-jobs-decide.md @@ -0,0 +1,22 @@ +--- +"flowbite-react": patch +--- + +### Datepicker Component Updates + +The Datepicker has been enhanced with several improvements: + +1. **Controlled Inputs**: Supports controlled inputs via `value` and `defaultValue` props, enabling programmatic date updates without manual clicks. +2. **State Management**: Optimized internal state management using `useMemo` and `useEffect`. +3. **Documentation**: Added sections in documentation for controlled usage and handling `null` values. +4. **Test Cases**: Comprehensive unit tests added for date handling. +5. **Storybook**: Improved stories, showcasing different states (controlled/uncontrolled). + +### Files Updated: + +- `apps/web/content/docs/components/datepicker.mdx`: Added controlled usage section. +- `Datepicker.spec.tsx`: Added unit tests. +- `Datepicker.stories.tsx`: Enhanced story variants. +- `Datepicker.tsx`: Expanded `DatepickerProps`. +- `DatepickerContext.tsx`: Adjusted `selectedDate` type. +- `Decades.tsx`, `Months.tsx`, `Years.tsx`: Updated logic to check for `selectedDate`. diff --git a/apps/web/content/docs/components/datepicker.mdx b/apps/web/content/docs/components/datepicker.mdx index 6ecad63e7..eaa7509f7 100644 --- a/apps/web/content/docs/components/datepicker.mdx +++ b/apps/web/content/docs/components/datepicker.mdx @@ -69,6 +69,12 @@ Use the `inline` prop to show the datepicker component without having to click i +## Controlled Date/Datepicker. + +Use ``. Pass `null` to clear the input. + + + ## Theme To learn more about how to customize the appearance of components, please see the [Theme docs](/docs/customize/theme). diff --git a/packages/ui/src/components/Datepicker/Datepicker.spec.tsx b/packages/ui/src/components/Datepicker/Datepicker.spec.tsx index 8a8a75a7b..64ec56994 100644 --- a/packages/ui/src/components/Datepicker/Datepicker.spec.tsx +++ b/packages/ui/src/components/Datepicker/Datepicker.spec.tsx @@ -57,17 +57,17 @@ describe("Components / Datepicker", () => { expect(screen.getByDisplayValue(todaysDateInDefaultLanguage)).toBeInTheDocument(); }); - it("should call `onSelectedDateChange` when a new date is selected", async () => { - const onSelectedDateChange = vi.fn(); + it("should call `onChange` when a new date is selected", async () => { + const onChange = vi.fn(); const todaysDayOfMonth = new Date().getDate(); const anotherDay = todaysDayOfMonth === 1 ? 2 : 1; - render(); + render(); await userEvent.click(screen.getByRole("textbox")); await userEvent.click(screen.getAllByText(anotherDay)[0]); - expect(onSelectedDateChange).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledOnce(); }); // TODO: fix @@ -80,7 +80,7 @@ describe("Components / Datepicker", () => { it("should render 1990 - 2100 year range when selecting decade", async () => { const testDate = new Date(2024, 6, 20); - render(); + render(); const textBox = screen.getByRole("textbox"); await userEvent.click(textBox); @@ -96,7 +96,7 @@ describe("Components / Datepicker", () => { it("should allow selecting earlier decades when setting max date", async () => { const testDate = new Date(2024, 6, 20); - render(); + render(); const textBox = screen.getByRole("textbox"); await userEvent.click(textBox); @@ -113,7 +113,7 @@ describe("Components / Datepicker", () => { it("should disallow selecting later decades when setting max date", async () => { const testDate = new Date(2024, 6, 20); - render(); + render(); const textBox = screen.getByRole("textbox"); await userEvent.click(textBox); @@ -130,7 +130,7 @@ describe("Components / Datepicker", () => { it("should disallow selecting earlier decades when setting min date", async () => { const testDate = new Date(2024, 6, 20); - render(); + render(); const textBox = screen.getByRole("textbox"); await userEvent.click(textBox); @@ -147,7 +147,7 @@ describe("Components / Datepicker", () => { it("should allow selecting later decades when setting min date", async () => { const testDate = new Date(2024, 6, 20); - render(); + render(); const textBox = screen.getByRole("textbox"); await userEvent.click(textBox); @@ -167,7 +167,7 @@ describe("Components / Datepicker", () => { const maxDate = new Date(2030, 1, 1); const testDate = new Date(2024, 6, 1); - render(); + render(); const textBox = screen.getByRole("textbox"); await userEvent.click(textBox); diff --git a/packages/ui/src/components/Datepicker/Datepicker.stories.tsx b/packages/ui/src/components/Datepicker/Datepicker.stories.tsx index f7cfaaf2d..354773947 100644 --- a/packages/ui/src/components/Datepicker/Datepicker.stories.tsx +++ b/packages/ui/src/components/Datepicker/Datepicker.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryFn } from "@storybook/react"; +import { useEffect, useState } from "react"; import type { DatepickerProps } from "./Datepicker"; import { Datepicker } from "./Datepicker"; import { getFirstDateInRange, WeekStart } from "./helpers"; @@ -13,6 +14,9 @@ export default { options: ["en", "pt-BR"], }, }, + value: { control: { type: "date", format: "MM/DD/YYYY" } }, + defaultValue: { control: { type: "date", format: "MM/DD/YYYY" } }, + label: { control: { type: "text" } }, weekStart: { options: Object.values(WeekStart).filter((x) => typeof x === "string"), mapping: Object.entries(WeekStart) @@ -28,6 +32,36 @@ export default { }, } as Meta; +const ControlledTemplate: StoryFn = (args) => { + const [selectedDate, setSelectedDate] = useState(args.value ?? null); + + const handleChange = (date: Date | null) => { + setSelectedDate(date); + }; + + useEffect(() => { + const date = args.value && new Date(args.value); + setSelectedDate(date ?? null); + }, [args.value]); + + // https://github.com/storybookjs/storybook/issues/11822 + if (args.minDate) { + args.minDate = new Date(args.minDate); + } + if (args.maxDate) { + args.maxDate = new Date(args.maxDate); + } + + // update defaultValue based on the range + if (args.minDate && args.maxDate) { + if (args.defaultValue) { + args.defaultValue = getFirstDateInRange(args.defaultValue, args.minDate, args.maxDate); + } + } + + return ; +}; + const Template: StoryFn = (args) => { // https://github.com/storybookjs/storybook/issues/11822 if (args.minDate) { @@ -37,27 +71,80 @@ const Template: StoryFn = (args) => { args.maxDate = new Date(args.maxDate); } - // update defaultDate based on the range + // update defaultValue based on the range if (args.minDate && args.maxDate) { - if (args.defaultDate) { - // https://github.com/storybookjs/storybook/issues/11822 - args.defaultDate = getFirstDateInRange(new Date(args.defaultDate), args.minDate, args.maxDate); + if (args.defaultValue) { + args.defaultValue = getFirstDateInRange(args.defaultValue, args.minDate, args.maxDate); } } return ; }; +export const ControlledDefaultEmpty = ControlledTemplate.bind({}); +ControlledDefaultEmpty.args = { + open: false, + autoHide: true, + showClearButton: true, + showTodayButton: true, + value: null, + minDate: undefined, + maxDate: undefined, + language: "en", + theme: {}, + label: "No date selected", +}; + export const Default = Template.bind({}); Default.args = { open: false, autoHide: true, showClearButton: true, showTodayButton: true, - defaultDate: new Date(), + value: undefined, + minDate: undefined, + maxDate: undefined, + language: "en", + theme: {}, +}; + +export const NullDateValue = Template.bind({}); +NullDateValue.args = { + open: false, + autoHide: true, + showClearButton: true, + showTodayButton: true, + minDate: undefined, + maxDate: undefined, + language: "en", + theme: {}, +}; + +export const DateValueSet = Template.bind({}); +DateValueSet.args = { + open: false, + autoHide: true, + showClearButton: true, + showTodayButton: true, + minDate: undefined, + maxDate: undefined, + language: "en", + defaultValue: new Date(), + theme: {}, +}; + +export const EmptyDates = Template.bind({}); +EmptyDates.args = { + open: false, + autoHide: true, + showClearButton: true, + showTodayButton: true, + defaultValue: undefined, + value: undefined, minDate: undefined, maxDate: undefined, language: "en", weekStart: WeekStart.Sunday, theme: {}, + label: "No date selected", }; diff --git a/packages/ui/src/components/Datepicker/Datepicker.tsx b/packages/ui/src/components/Datepicker/Datepicker.tsx index 87a92557d..95d308fad 100644 --- a/packages/ui/src/components/Datepicker/Datepicker.tsx +++ b/packages/ui/src/components/Datepicker/Datepicker.tsx @@ -1,7 +1,7 @@ "use client"; import type { ForwardRefRenderFunction, ReactNode } from "react"; -import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import { HiArrowLeft, HiArrowRight, HiCalendar } from "react-icons/hi"; import { twMerge } from "tailwind-merge"; import { mergeDeep } from "../../helpers/merge-deep"; @@ -77,12 +77,13 @@ export interface DatepickerRef { */ focus: () => void; /** - * Clears the datepicker value back to the defaultDate. + * Clears the datepicker value back to the defaultValue. */ clear: () => void; } -export interface DatepickerProps extends Omit { +export interface DatepickerProps extends Omit { + defaultValue?: Date; open?: boolean; inline?: boolean; autoHide?: boolean; @@ -90,13 +91,14 @@ export interface DatepickerProps extends Omit { labelClearButton?: string; showTodayButton?: boolean; labelTodayButton?: string; - defaultDate?: Date; minDate?: Date; maxDate?: Date; language?: string; weekStart?: WeekStart; theme?: DeepPartial; - onSelectedDateChanged?: (date: Date) => void; + onChange?: (date: Date | null) => void; + value?: Date | null; + label?: string; } const DatepickerRender: ForwardRefRenderFunction = ( @@ -109,39 +111,43 @@ const DatepickerRender: ForwardRefRenderFunction labelClearButton = "Clear", showTodayButton = true, labelTodayButton = "Today", - defaultDate = new Date(), + defaultValue, minDate, maxDate, language = "en", weekStart = WeekStart.Sunday, className, theme: customTheme = {}, - onSelectedDateChanged, + onChange, + label, + value, ...props }, ref, ) => { const theme = mergeDeep(getTheme().datepicker, customTheme); + const initialDate = defaultValue ? getFirstDateInRange(defaultValue, minDate, maxDate) : null; - // Default date should respect the range - defaultDate = getFirstDateInRange(defaultDate, minDate, maxDate); + const effectiveDefaultView = useMemo(() => { + return defaultValue ? getFirstDateInRange(defaultValue, minDate, maxDate) : new Date(); + }, []); const [isOpen, setIsOpen] = useState(open); const [view, setView] = useState(Views.Days); // selectedDate is the date selected by the user - const [selectedDate, setSelectedDate] = useState(defaultDate); + const [selectedDate, setSelectedDate] = useState(value ?? initialDate); // viewDate is only for navigation - const [viewDate, setViewDate] = useState(defaultDate); + const [viewDate, setViewDate] = useState(value ?? effectiveDefaultView); const inputRef = useRef(null); const datepickerRef = useRef(null); // Triggers when user select the date - const changeSelectedDate = (date: Date, useAutohide: boolean) => { + const changeSelectedDate = (date: Date | null, useAutohide: boolean) => { setSelectedDate(date); - if (onSelectedDateChanged) { - onSelectedDateChanged(date); + if ((date === null || date) && onChange) { + onChange(date); } if (autoHide && view === Views.Days && useAutohide == true && !inline) { @@ -150,9 +156,9 @@ const DatepickerRender: ForwardRefRenderFunction }; const clearDate = () => { - changeSelectedDate(defaultDate, true); - if (defaultDate) { - setViewDate(defaultDate); + changeSelectedDate(initialDate, true); + if (defaultValue) { + setViewDate(defaultValue); } }; @@ -241,6 +247,19 @@ const DatepickerRender: ForwardRefRenderFunction }; }, [inputRef, datepickerRef, setIsOpen]); + useEffect(() => { + const effectiveValue = value && getFirstDateInRange(new Date(value), minDate, maxDate); + const effectiveSelectedDate = selectedDate && getFirstDateInRange(new Date(selectedDate), minDate, maxDate); + if (effectiveSelectedDate && effectiveValue && !isDateEqual(effectiveValue, effectiveSelectedDate)) { + setSelectedDate(effectiveValue); + } + if (selectedDate == null) { + setSelectedDate(initialDate); + } + }, [value, setSelectedDate, setViewDate, selectedDate]); + + const displayValue = value === null ? label : getFormattedDate(language, selectedDate || new Date()); + return ( icon={HiCalendar} ref={inputRef} onFocus={() => { - if (!isDateEqual(viewDate, selectedDate)) { + if (selectedDate && !isDateEqual(viewDate, selectedDate)) { setViewDate(selectedDate); } setIsOpen(true); }} - value={selectedDate && getFormattedDate(language, selectedDate)} + value={displayValue} readOnly + defaultValue={initialDate ? getFormattedDate(language, initialDate) : label} {...props} /> )} @@ -336,10 +356,7 @@ const DatepickerRender: ForwardRefRenderFunction type="button" className={twMerge(theme.popup.footer.button.base, theme.popup.footer.button.clear)} onClick={() => { - changeSelectedDate(defaultDate, true); - if (defaultDate) { - setViewDate(defaultDate); - } + changeSelectedDate(null, true); }} > {labelClearButton} diff --git a/packages/ui/src/components/Datepicker/DatepickerContext.tsx b/packages/ui/src/components/Datepicker/DatepickerContext.tsx index 42c4fcbdd..b4a4f7b7b 100644 --- a/packages/ui/src/components/Datepicker/DatepickerContext.tsx +++ b/packages/ui/src/components/Datepicker/DatepickerContext.tsx @@ -14,8 +14,8 @@ type DatepickerContextProps = { setIsOpen: (isOpen: boolean) => void; view: Views; setView: (value: Views) => void; - selectedDate: Date; - setSelectedDate: (date: Date) => void; + selectedDate: Date | null; + setSelectedDate: (date: Date | null) => void; changeSelectedDate: (date: Date, useAutohide: boolean) => void; viewDate: Date; setViewDate: (date: Date) => void; diff --git a/packages/ui/src/components/Datepicker/Views/Days.tsx b/packages/ui/src/components/Datepicker/Views/Days.tsx index ba76b6f09..2d771a5c7 100644 --- a/packages/ui/src/components/Datepicker/Views/Days.tsx +++ b/packages/ui/src/components/Datepicker/Views/Days.tsx @@ -55,7 +55,7 @@ export const DatepickerViewsDays: FC = ({ theme: custo const currentDate = addDays(startDate, index); const day = getFormattedDate(language, currentDate, { day: "numeric" }); - const isSelected = isDateEqual(selectedDate, currentDate); + const isSelected = selectedDate && isDateEqual(selectedDate, currentDate); const isDisabled = !isDateInRange(currentDate, minDate, maxDate); return ( diff --git a/packages/ui/src/components/Datepicker/Views/Decades.tsx b/packages/ui/src/components/Datepicker/Views/Decades.tsx index 82453183e..4455aae3d 100644 --- a/packages/ui/src/components/Datepicker/Views/Decades.tsx +++ b/packages/ui/src/components/Datepicker/Views/Decades.tsx @@ -33,7 +33,7 @@ export const DatepickerViewsDecades: FC = ({ theme: const firstDate = new Date(year, 0, 1); const lastDate = addYears(firstDate, 9); - const isSelected = isDateInDecade(selectedDate, year); + const isSelected = selectedDate && isDateInDecade(selectedDate, year); const isDisabled = !isDateInRange(firstDate, minDate, maxDate) && !isDateInRange(lastDate, minDate, maxDate); return ( @@ -48,7 +48,8 @@ export const DatepickerViewsDecades: FC = ({ theme: )} onClick={() => { if (isDisabled) return; - setViewDate(newDate); + + selectedDate && setViewDate(addYears(viewDate, year - selectedDate.getFullYear())); setView(Views.Years); }} > diff --git a/packages/ui/src/components/Datepicker/Views/Months.tsx b/packages/ui/src/components/Datepicker/Views/Months.tsx index 3930638ba..2faf45e37 100644 --- a/packages/ui/src/components/Datepicker/Views/Months.tsx +++ b/packages/ui/src/components/Datepicker/Views/Months.tsx @@ -2,7 +2,7 @@ import type { FC } from "react"; import { twMerge } from "tailwind-merge"; import { mergeDeep } from "../../../helpers/merge-deep"; import { useDatePickerContext } from "../DatepickerContext"; -import { getFormattedDate, isDateInRange, isMonthEqual, Views } from "../helpers"; +import { getFormattedDate, isDateEqual, isDateInRange, Views } from "../helpers"; export interface FlowbiteDatepickerViewsMonthsTheme { items: { @@ -42,7 +42,7 @@ export const DatepickerViewsMonth: FC = ({ theme: cu newDate.setFullYear(viewDate.getFullYear()); const month = getFormattedDate(language, newDate, { month: "short" }); - const isSelected = isMonthEqual(selectedDate, newDate); + const isSelected = selectedDate && isDateEqual(selectedDate, newDate); const isDisabled = !isDateInRange(newDate, minDate, maxDate); return ( diff --git a/packages/ui/src/components/Datepicker/Views/Years.tsx b/packages/ui/src/components/Datepicker/Views/Years.tsx index 850184f4a..57704c54e 100644 --- a/packages/ui/src/components/Datepicker/Views/Years.tsx +++ b/packages/ui/src/components/Datepicker/Views/Years.tsx @@ -34,7 +34,7 @@ export const DatepickerViewsYears: FC = ({ theme: cus const newDate = new Date(viewDate.getTime()); newDate.setFullYear(year); - const isSelected = isDateEqual(selectedDate, newDate); + const isSelected = selectedDate && isDateEqual(selectedDate, newDate); const isDisabled = !isDateInRange(newDate, minDate, maxDate); return (