diff --git a/packages/ui/src/components/calendar/option-data/months.ts b/packages/ui/src/components/calendar/option-data/months.ts index 3c0dd3b4..8956e42e 100644 --- a/packages/ui/src/components/calendar/option-data/months.ts +++ b/packages/ui/src/components/calendar/option-data/months.ts @@ -5,6 +5,21 @@ export type MONTHS_OPTION = { text: string; }; +export const MONTH_NAMES: string[] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + export const MONTHS_RANGE: MONTHS_OPTION[] = [ { value: 0, text: "January" }, { value: 1, text: "February" }, diff --git a/packages/ui/src/components/date-picker/date-picker-calendar.test.tsx b/packages/ui/src/components/date-picker/date-picker-calendar.test.tsx new file mode 100644 index 00000000..4c12a39f --- /dev/null +++ b/packages/ui/src/components/date-picker/date-picker-calendar.test.tsx @@ -0,0 +1,91 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import DatePickerCalendar from "./date-picker-calendar"; +import { MONTH_NAMES } from "../calendar/option-data/months"; +import userEvent from "@testing-library/user-event"; + +describe("Date Range 2 month Calendar", () => { + const todayDate = new Date(); + + beforeEach(async () => { + render( + {}} + setEndDate={() => {}} + handleReset={() => {}} + />, + ); + }); + + test("renders Back Arrow Icon", () => { + expect(screen.getByTestId("ArrowBackIosNewIcon")).toBeInTheDocument(); + }); + + test("renders Forward Arrow Icon", () => { + expect(screen.getByTestId("ArrowForwardIosIcon")).toBeInTheDocument(); + }); + + test("renders current month and next month", () => { + const currMonthIdx = todayDate.getMonth(); + const nextMonthIdx = (currMonthIdx + 1) % 12; + + expect(screen.getByText(MONTH_NAMES[currMonthIdx])).toBeInTheDocument(); + expect(screen.getByText(MONTH_NAMES[nextMonthIdx])).toBeInTheDocument(); + }); + + test("render current month's year and next month's year", () => { + const currMonthIdx = todayDate.getMonth(); + const nextMonthIdx = (currMonthIdx + 1) % 12; + + expect(screen.getByText(MONTH_NAMES[currMonthIdx])).toBeInTheDocument(); + expect(screen.getByText(MONTH_NAMES[nextMonthIdx])).toBeInTheDocument(); + }); +}); + +describe("Date Range Call to Action ", () => { + const date = new Date("11-25-2024"); + const user = userEvent.setup(); + beforeEach(async () => { + render( + {}} + setEndDate={() => {}} + handleReset={() => {}} + />, + ); + }); + + test("renders current month and next month", () => { + expect(screen.getByText("November")).toBeInTheDocument(); + expect(screen.getByText("December")).toBeInTheDocument(); + expect(screen.getByText((_, ele) => ele?.textContent === "2024")).toBeInTheDocument(); + }); + + test("render December 2024 and January 2025 when Forward Arrow is pressed", async () => { + const nextMonthButton = screen.getByRole("button", { name: /next-months/i }); + await user.click(nextMonthButton); + + expect(screen.getByText("December")).toBeInTheDocument(); + expect(screen.getByText("2024")).toBeInTheDocument(); + + expect(screen.getByText("January")).toBeInTheDocument(); + expect(screen.getByText("2025")).toBeInTheDocument(); + }); + + test("render October 2024 and November 2024 when Forward Arrow is pressed", async () => { + const nextMonthButton = screen.getByRole("button", { name: /prev-months/i }); + await user.click(nextMonthButton); + + expect(screen.getByText("October")).toBeInTheDocument(); + expect(screen.getByText("November")).toBeInTheDocument(); + expect(screen.getByText((_, ele) => ele?.textContent === "2024")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/date-picker/date-picker-calendar.tsx b/packages/ui/src/components/date-picker/date-picker-calendar.tsx new file mode 100644 index 00000000..25062175 --- /dev/null +++ b/packages/ui/src/components/date-picker/date-picker-calendar.tsx @@ -0,0 +1,200 @@ +import { useState } from "react"; +import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; +import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; +import { isEmpty } from "lodash"; + +import { Month, MONTH_NAMES } from "../calendar/option-data/months"; +import { Year } from "../calendar/option-data/years"; +import { CalendarDate } from "../calendar/utils/create-calendar"; +import { twMerge } from "tailwind-merge"; +import { createTwoMonthCalendar, CALENDAR_MONTH } from "./utils/create-two-month-calendar"; +import { isEqual } from "../calendar/utils/is-equal"; +import { addMonths } from "date-fns"; + +interface DatePickerCalendarProps { + initDate?: Date; + endDate: Date | null; + startDate: Date | null; + setEndDate: (arg0: Date | null) => void; + setStartDate: (arg0: Date | null) => void; + handleReset: () => void; +} + +export default function DatePickerCalendar(props: DatePickerCalendarProps) { + const { initDate = new Date(), startDate, endDate, setStartDate, setEndDate } = props; + + const [date, setDate] = useState(initDate); + const [month, setMonth] = useState(initDate.getMonth() as Month); + const [year, setYear] = useState(initDate.getFullYear()); + const [firstMonth, secondMonth] = createTwoMonthCalendar(year as Year, month); + + function handleUpdateMonth(type: "prev" | "next") { + const modify = type === "prev" ? -1 : 1; + const newDate = addMonths(new Date(date), modify); + + setMonth(newDate.getMonth() as Month); + setYear(newDate.getFullYear() as Year); + setDate(newDate); + } + + const handleSelected = ({ day, month, year }: CalendarDate) => { + const newDate = new Date(year, month, day); + + if (!startDate && !endDate) { + setStartDate(newDate); + } else if (startDate && !endDate) { + setEndDate(newDate); + } else { + /** + * This handles the edge cases when new End date comes before start date + * Note: state is represent as date, but in system refer to CalendarDate interface + * EG: + * t-0 state: + * start : July 16, 1969 + * end : July 24, 1969 + * + * t-1 state: + * new End : July 15, 1969 selected + * + * t-final state: + * start : July 15, 1969 + * end : null + */ + + if (startDate && newDate < startDate) { + setStartDate(newDate); + setEndDate(null); + } else { + setEndDate(newDate); + } + } + }; + + return ( +
+
+
+ {/*Left Calendar Nav*/} +
+ +
+ {MONTH_NAMES[month]} + {year} +
+ +
+ + {/*Left Calendar*/} +
+ + + + {["S", "M", "T", "W", "T", "F", "S"].map((ele: string, index: number) => ( + + ))} + + + + {firstMonth.map((week: CALENDAR_MONTH[], weekIdx: number) => ( + + {week.map((ele: CALENDAR_MONTH, colIdx: number) => { + if (isEmpty(ele)) { + return + ); + })} + + ))} + +
+ {ele} +
; + } + const { day, month, year } = ele as CalendarDate; + const key = `first-month-${month}/${day}/${year}`; + const isSelected = + isEqual(ele as CalendarDate, startDate) || isEqual(ele as CalendarDate, endDate); + const isCurrDate = isEqual(ele as CalendarDate, initDate); + + return ( + handleSelected(ele as CalendarDate)} + className={twMerge( + "h-8 w-8 text-center text-xs font-normal leading-[18.8px]", + isCurrDate && "inline-flex items-center justify-center rounded-full border-[1px]", + !isSelected && "rounded-full hover:bg-blue-200", + isSelected && "text-white-100 rounded-full bg-blue-500", + )}> + {day} +
+
+
+ +
+ {/**Right Calendar Nav */} +
+ +
+ {MONTH_NAMES[(month + 1) % 12]} + {(month + 1) % 12 === 0 ? year + 1 : year} +
+ +
+ + {/**Right Calendar*/} +
+ + + + {["S", "M", "T", "W", "T", "F", "S"].map((ele: string, index: number) => ( + + ))} + + + + {secondMonth.map((week: CALENDAR_MONTH[], weekIdx: number) => ( + + {week.map((ele: CALENDAR_MONTH, colIdx: number) => { + if (isEmpty(ele)) { + return + ); + })} + + ))} + +
+ {ele} +
; + } + const { day, month, year } = ele as CalendarDate; + const key = `second-month-${month}/${day}/${year}`; + const isSelected = + isEqual(ele as CalendarDate, startDate) || isEqual(ele as CalendarDate, endDate); + const isCurrDate = isEqual(ele as CalendarDate, initDate); + + return ( + handleSelected(ele as CalendarDate)} + className={twMerge( + "h-8 w-8 text-center text-xs font-normal leading-[18.8px]", + isCurrDate && "inline-flex items-center justify-center rounded-full border-[1px]", + !isSelected && "rounded-full hover:bg-blue-200", + isSelected && "text-white-100 rounded-full bg-blue-500", + )}> + {day} +
+
+
+
+
+ ); +} diff --git a/packages/ui/src/components/date-picker/date-picker-suggestions.test.tsx b/packages/ui/src/components/date-picker/date-picker-suggestions.test.tsx new file mode 100644 index 00000000..4d02564a --- /dev/null +++ b/packages/ui/src/components/date-picker/date-picker-suggestions.test.tsx @@ -0,0 +1,17 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import DatePickerSuggestions from "./date-picker-suggestions"; + +describe("Date Range Suggestions Radio Group", () => { + beforeEach(() => { + render( console.log(value)} />); + }); + + test("renders radio group and all it's options", () => { + expect(screen.getByLabelText("Past 1 Year")); + expect(screen.getByLabelText("Past 3 Months")); + expect(screen.getByLabelText("Past 1 Month")); + expect(screen.getByLabelText("Year to Date")); + expect(screen.queryByLabelText("It's over 9000")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/date-picker/date-picker-suggestions.tsx b/packages/ui/src/components/date-picker/date-picker-suggestions.tsx new file mode 100644 index 00000000..e9a6ae28 --- /dev/null +++ b/packages/ui/src/components/date-picker/date-picker-suggestions.tsx @@ -0,0 +1,25 @@ +import _ from "lodash"; +import RadioGroup from "../radio-group"; + +interface DatePickerSuggestion { + onSuggestionChange: (arg0: string) => void; +} + +export enum RelativeDatePresets { + "YEARS_1" = "Past 1 Year", + "MONTHS_3" = "Past 3 Months", + "MONTHS_1" = "Past 1 Month", + "YTD" = "Year to Date", +} + +const CITATION_DATE_PRESETS = _.map(RelativeDatePresets, (value) => value); + +export default function DatePickerSuggestions(props: DatePickerSuggestion) { + const { onSuggestionChange } = props; + + return ( +
+ +
+ ); +} diff --git a/packages/ui/src/components/date-picker/date-picker.stories.tsx b/packages/ui/src/components/date-picker/date-picker.stories.tsx new file mode 100644 index 00000000..918edae0 --- /dev/null +++ b/packages/ui/src/components/date-picker/date-picker.stories.tsx @@ -0,0 +1,18 @@ +import { Meta, StoryObj } from "@storybook/react"; +import DatePicker from "./date-picker"; + +const meta: Meta = { + title: "Components/Date Picker", + component: DatePicker, +}; + +type DatePickerStory = StoryObj; + +export const Default: DatePickerStory = { + render: (args) => , + args: { + //todo + }, +}; + +export default meta; diff --git a/packages/ui/src/components/date-picker/date-picker.test.tsx b/packages/ui/src/components/date-picker/date-picker.test.tsx new file mode 100644 index 00000000..67cd0f2c --- /dev/null +++ b/packages/ui/src/components/date-picker/date-picker.test.tsx @@ -0,0 +1,74 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import DatePicker from "./date-picker"; +import userEvent from "@testing-library/user-event"; + +describe("Date Input", () => { + beforeEach(() => { + render( + console.log(JSON.stringify(value))} />, + ); + }); + + test("renders DateRangeIcon", () => { + expect(screen.getByTestId("DateRangeIcon")).toBeInTheDocument(); + }); + + test("renders start date text", () => { + expect(screen.getByText("start date")).toBeInTheDocument(); + }); + -test("renders ChevronRightIcon", () => { + expect(screen.getByTestId("ChevronRightIcon")).toBeInTheDocument(); + }); + + test("renders end date text", () => { + expect(screen.getByText("end date")).toBeInTheDocument(); + }); +}); + +describe("Date Input's Modal", () => { + const user = userEvent.setup(); + beforeEach(async () => { + render( + console.log(JSON.stringify(value))} + />, + ); + + const dateRangeModalTrigger = screen.getByLabelText("open-date-range-modal"); + await user.click(dateRangeModalTrigger); + }); + + test("renders date-range-picker trigger button", () => { + expect(screen.getByLabelText("open-date-range-modal")).toBeInTheDocument(); + }); + + test("renders Exact button", async () => { + expect(screen.getByRole("button", { name: /Exact/i })).toBeInTheDocument(); + }); + + test("renders Suggestions button", () => { + expect(screen.getByRole("button", { name: /Suggestions/i })).toBeInTheDocument(); + }); + + test("renders Reset button", () => { + expect(screen.getByRole("button", { name: /reset/i })).toBeInTheDocument(); + }); + + test("renders Clear button", () => { + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + + test("renders Apply button", () => { + expect(screen.getByRole("button", { name: /apply/i })).toBeInTheDocument(); + }); + + test("renders Start Date Label Text", () => { + expect(screen.getByText("Start Date")).toBeInTheDocument(); + }); + + test("renders End Date Label Text", () => { + expect(screen.getByText("End Date")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/date-picker/date-picker.tsx b/packages/ui/src/components/date-picker/date-picker.tsx new file mode 100644 index 00000000..69803cc7 --- /dev/null +++ b/packages/ui/src/components/date-picker/date-picker.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { Modal, ModalContent, ModalTrigger } from "../modal"; + +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import DateRangeIcon from "@mui/icons-material/DateRange"; +import { addMonths, addYears, startOfYear } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import Button, { ButtonVariant } from "../button"; +import DatePickerSuggestions, { RelativeDatePresets } from "./date-picker-suggestions"; +import DatePickerCalendar from "./date-picker-calendar"; +import Input from "../input"; +import { formatToMiddleEndian } from "@lucky-parking/utilities/dist/date"; + +interface DatePickerProps { + onDateRangeValueChange?: (arg0: { [key: string]: Date }) => void | null; +} + +type selectedCalendar = "exact" | "suggestions"; + +export default function DatePicker(props: DatePickerProps) { + const { onDateRangeValueChange = null } = props; + + const [isCalendarVisible, setCalendarVisible] = useState(false); + const [selected, setSelected] = useState("exact"); + const [selectedDates, setSelectedDates] = useState<{ [key: string]: Date | null }>({ + start: null, + end: null, + }); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + const handleApply = () => { + if (startDate && endDate) { + const payload = { + start: startDate, + end: endDate, + }; + setSelectedDates(payload); + onDateRangeValueChange && onDateRangeValueChange(payload); + } + + setCalendarVisible(false); + }; + + const handleSuggestionChange = (value: string) => { + const today = new Date(); + + switch (value) { + case RelativeDatePresets.YEARS_1: { + const oneYearAgo = addYears(today, -1); + setStartDate(oneYearAgo); + setEndDate(today); + break; + } + + case RelativeDatePresets.MONTHS_3: { + const threeMonthAgo = addMonths(today, -3); + setStartDate(threeMonthAgo); + setEndDate(today); + break; + } + + case RelativeDatePresets.MONTHS_1: { + const oneMonthAgo = addMonths(today, -1); + setStartDate(oneMonthAgo); + setEndDate(today); + break; + } + + case RelativeDatePresets.YTD: { + const firstDateOfTheYear = startOfYear(today); + setStartDate(firstDateOfTheYear); + setEndDate(today); + break; + } + + default: + console.log("oopies mom's spaghetti o something went wrong!"); + } + }; + + const handleReset = () => { + setStartDate(null); + setEndDate(null); + }; + + return ( +
+ + +
setCalendarVisible((prevState) => !prevState)}> +
+ +
+ {(selectedDates.start && formatToMiddleEndian(selectedDates.start)) || "start date"} +
+
+ +
+ +
+ {(selectedDates.end && formatToMiddleEndian(selectedDates.end)) || "end date"} +
+
+
+
+ +
+ + +
+ +
+ +
+ {selected === "exact" ? ( + + ) : ( + + )} +
+ +
+
+ Start Date + +
+ +
+ End Date + +
+
+ +
+ + {/** Call to Action */} +
+ + +
+ + +
+
+ + +
+ ); +} diff --git a/packages/ui/src/components/date-picker/index.ts b/packages/ui/src/components/date-picker/index.ts new file mode 100644 index 00000000..bc2a5f1e --- /dev/null +++ b/packages/ui/src/components/date-picker/index.ts @@ -0,0 +1 @@ +export { default } from "./date-picker.tsx"; diff --git a/packages/ui/src/components/date-picker/utils/create-two-month-calendar.ts b/packages/ui/src/components/date-picker/utils/create-two-month-calendar.ts new file mode 100644 index 00000000..1f9fd0f9 --- /dev/null +++ b/packages/ui/src/components/date-picker/utils/create-two-month-calendar.ts @@ -0,0 +1,40 @@ +import { CalendarDate } from "@/components/calendar/utils/create-calendar"; + +export type CALENDAR_MONTH = CalendarDate | object | null; + +export function createTwoMonthCalendar(year: number, month: number) { + const firstMonth = createCalendarMonth(year, month); + const nextMonth = (month + 1) % 12; + const secondMonth = createCalendarMonth(year, nextMonth); + + return [firstMonth, secondMonth]; +} + +export function createCalendarMonth(year: number, month: number) { + const startDay = 0; + const currentDate = new Date(year, month, 1); + + currentDate.setDate(currentDate.getDate() - ((currentDate.getDay() - startDay + 7) % 7)); + + // Initialize a two-dimensional array to hold the calendar + const calendar: CALENDAR_MONTH[][] = []; + // Populate the calendar with dates + for (let row = 0; row < 5; row++) { + calendar[row] = []; + for (let col = 0; col < 7; col++) { + //append days for current month and next month + + calendar[row][col] = + currentDate.getMonth() === month + ? { + day: currentDate.getDate(), + month: currentDate.getMonth(), + year: currentDate.getFullYear(), + } + : {}; + + currentDate.setDate(currentDate.getDate() + 1); + } + } + return calendar; +}