diff --git a/backend/chainlit/input_widget.py b/backend/chainlit/input_widget.py index 34331e51d0..b5d61c9cd9 100644 --- a/backend/chainlit/input_widget.py +++ b/backend/chainlit/input_widget.py @@ -1,5 +1,6 @@ from abc import abstractmethod -from typing import Any, Dict, List, Optional +from datetime import date +from typing import Any, Dict, List, Literal, Optional from pydantic import Field from pydantic.dataclasses import dataclass @@ -314,3 +315,110 @@ def to_dict(self) -> dict[str, Any]: "label": self.label, "inputs": [input.to_dict() for input in self.inputs], } + + +@dataclass +class DatePicker(InputWidget): + """ + Datepicker input widget. + Supports both single date and date range selection. + """ + + type: InputWidgetType = "datepicker" + mode: Literal["single", "range"] = "single" + initial: str | date | tuple[str | date, str | date] | None = None + min_date: str | date | None = None + max_date: str | date | None = None + format: str | None = None + """date-fns format string""" + placeholder: str | None = None + """Placeholder to use when no date is selected""" + + def __post_init__(self) -> None: + super().__post_init__() + + if self.mode not in ("single", "range"): + raise ValueError("mode must be 'single' or 'range'") + + if ( + self.mode == "range" + and self.initial is not None + and not isinstance(self.initial, tuple) + ): + raise ValueError("'initial' must be a tuple for range mode") + + (initial_start, initial_end), min_date, max_date = ( + [ + DatePicker._validate_iso_format(date, "initial") + for date in ( + self.initial + if isinstance(self.initial, tuple) + else [self.initial, None] + ) + ], + DatePicker._validate_iso_format(self.min_date, "min_date"), + DatePicker._validate_iso_format(self.max_date, "max_date"), + ) + + if self.mode == "range": + self._validate_range(initial_start, initial_end, "initial") + self._validate_range(min_date, max_date, "[min_date, max_date]") + + # Validate that initial value(s) are within min_date and max_date bounds + for d in [initial_start, initial_end]: + if d is not None and ( + (min_date is not None and d < min_date) + or (max_date is not None and d > max_date) + ): + raise ValueError( + "'initial' must be within 'min_date' and 'max_date' bounds" + ) + + @staticmethod + def _validate_range( + start: date | None, + end: date | None, + field_name: str, + ) -> None: + if start is not None and end is not None and start > end: + raise ValueError( + f"'{field_name}' range is invalid, start must be before end." + ) + + @staticmethod + def _validate_iso_format( + date_value: str | date | None, field_name: str + ) -> date | None: + if isinstance(date_value, str): + try: + return date.fromisoformat(date_value) + except ValueError as e: + raise ValueError(f"'{field_name}' must be in ISO format") from e + + return date_value + + @staticmethod + def _format_date(date_value: str | date | None) -> str | None: + if isinstance(date_value, date): + return date_value.isoformat() + return date_value + + def to_dict(self) -> dict[str, Any]: + return { + "type": self.type, + "id": self.id, + "label": self.label, + "tooltip": self.tooltip, + "description": self.description, + "mode": self.mode, + "initial": ( + self._format_date(self.initial[0]), + self._format_date(self.initial[1]), + ) + if isinstance(self.initial, tuple) + else DatePicker._format_date(self.initial), + "min_date": DatePicker._format_date(self.min_date), + "max_date": DatePicker._format_date(self.max_date), + "format": self.format, + "placeholder": self.placeholder, + } diff --git a/backend/chainlit/translations/bn.json b/backend/chainlit/translations/bn.json index 8ec8b15ab2..9dba561c75 100644 --- a/backend/chainlit/translations/bn.json +++ b/backend/chainlit/translations/bn.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "বেছে নিন..." + }, + "DatePickerInput": { + "placeholder": { + "single": "একটি তারিখ বেছে নিন", + "range": "তারিখের পরিসীমা বেছে নিন" + } } } } diff --git a/backend/chainlit/translations/el-GR.json b/backend/chainlit/translations/el-GR.json index adaa89d830..6e0b8c7f0a 100644 --- a/backend/chainlit/translations/el-GR.json +++ b/backend/chainlit/translations/el-GR.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "Επιλέξτε..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Επιλέξτε ημερομηνία", + "range": "Επιλέξτε εύρος ημερομηνιών" + } } } } diff --git a/backend/chainlit/translations/en-US.json b/backend/chainlit/translations/en-US.json index c6a6af0914..223c286099 100644 --- a/backend/chainlit/translations/en-US.json +++ b/backend/chainlit/translations/en-US.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "Select..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Pick a date", + "range": "Pick a date range" + } } } } diff --git a/backend/chainlit/translations/es.json b/backend/chainlit/translations/es.json index 60ba1390c0..01c7659c2b 100644 --- a/backend/chainlit/translations/es.json +++ b/backend/chainlit/translations/es.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "Seleccionar..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Elige una fecha", + "range": "Elige un rango de fechas" + } } } } \ No newline at end of file diff --git a/backend/chainlit/translations/fr-FR.json b/backend/chainlit/translations/fr-FR.json index 39c1c6f721..a1b7e901ca 100644 --- a/backend/chainlit/translations/fr-FR.json +++ b/backend/chainlit/translations/fr-FR.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "Sélectionner..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Choisir une date", + "range": "Choisir une plage de dates" + } } } } \ No newline at end of file diff --git a/backend/chainlit/translations/gu.json b/backend/chainlit/translations/gu.json index efce7c1bea..1adcc16cde 100644 --- a/backend/chainlit/translations/gu.json +++ b/backend/chainlit/translations/gu.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "બેંચી લો..." + }, + "DatePickerInput": { + "placeholder": { + "single": "તારીખ પસંદ કરો", + "range": "તારીખની શ્રેણી પસંદ કરો" + } } } } diff --git a/backend/chainlit/translations/he-IL.json b/backend/chainlit/translations/he-IL.json index 5ff8460dbe..ffe812d8a6 100644 --- a/backend/chainlit/translations/he-IL.json +++ b/backend/chainlit/translations/he-IL.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "בחר..." + }, + "DatePickerInput": { + "placeholder": { + "single": "בחר תאריך", + "range": "בחר טווח תאריכים" + } } } } diff --git a/backend/chainlit/translations/hi.json b/backend/chainlit/translations/hi.json index eb3dbc7ba0..56ac6fa912 100644 --- a/backend/chainlit/translations/hi.json +++ b/backend/chainlit/translations/hi.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "चुनें..." + }, + "DatePickerInput": { + "placeholder": { + "single": "एक तारीख चुनें", + "range": "तारीख सीमा चुनें" + } } } } diff --git a/backend/chainlit/translations/ja.json b/backend/chainlit/translations/ja.json index c58932d896..e961d216b6 100644 --- a/backend/chainlit/translations/ja.json +++ b/backend/chainlit/translations/ja.json @@ -239,6 +239,12 @@ "components": { "MultiSelectInput": { "placeholder": "選択..." + }, + "DatePickerInput": { + "placeholder": { + "single": "日付を選択", + "range": "日付範囲を選択" + } } } } diff --git a/backend/chainlit/translations/kn.json b/backend/chainlit/translations/kn.json index 576a238c74..2dc6f499f5 100644 --- a/backend/chainlit/translations/kn.json +++ b/backend/chainlit/translations/kn.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "ಚುನಾಯಿಸಿ..." + }, + "DatePickerInput": { + "placeholder": { + "single": "ದಿನಾಂಕವನ್ನು ಆಯ್ಕೆಮಾಡಿ", + "range": "ದಿನಾಂಕ ಶ್ರೇಣಿಯನ್ನು ಆಯ್ಕೆಮಾಡಿ" + } } } } diff --git a/backend/chainlit/translations/ml.json b/backend/chainlit/translations/ml.json index 70ace53c10..fd04585708 100644 --- a/backend/chainlit/translations/ml.json +++ b/backend/chainlit/translations/ml.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "ചൂണ്ടിക്കാണിക്കുക..." + }, + "DatePickerInput": { + "placeholder": { + "single": "തീയതി തിരഞ്ഞെടുക്കുക", + "range": "തീയതി ശ്രേണി തിരഞ്ഞെടുക്കുക" + } } } } diff --git a/backend/chainlit/translations/mr.json b/backend/chainlit/translations/mr.json index 7bec9b2fc7..a6d22cd246 100644 --- a/backend/chainlit/translations/mr.json +++ b/backend/chainlit/translations/mr.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "चुनें..." + }, + "DatePickerInput": { + "placeholder": { + "single": "तारीख निवडा", + "range": "तारीख श्रेणी निवडा" + } } } } diff --git a/backend/chainlit/translations/nl.json b/backend/chainlit/translations/nl.json index 174d1d11a2..171b30b2f6 100644 --- a/backend/chainlit/translations/nl.json +++ b/backend/chainlit/translations/nl.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "Selecteer..." + }, + "DatePickerInput": { + "placeholder": { + "single": "Kies een datum", + "range": "Kies een datumbereik" + } } } } diff --git a/backend/chainlit/translations/ta.json b/backend/chainlit/translations/ta.json index 4935ed0aad..7f49794ed4 100644 --- a/backend/chainlit/translations/ta.json +++ b/backend/chainlit/translations/ta.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "தேர்ந்தெடுக்கவும்..." + }, + "DatePickerInput": { + "placeholder": { + "single": "தேதியைத் தேர்ந்தெடுக்கவும்", + "range": "தேதி வரம்பைத் தேர்ந்தெடுக்கவும்" + } } } } diff --git a/backend/chainlit/translations/te.json b/backend/chainlit/translations/te.json index fdae2b7a2c..f99580ee61 100644 --- a/backend/chainlit/translations/te.json +++ b/backend/chainlit/translations/te.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "ఎంచుకోండి..." + }, + "DatePickerInput": { + "placeholder": { + "single": "తేదీని ఎంచుకోండి", + "range": "తేదీ పరిధిని ఎంచుకోండి" + } } } } diff --git a/backend/chainlit/translations/zh-CN.json b/backend/chainlit/translations/zh-CN.json index 7c034c1e71..2d37868ad3 100644 --- a/backend/chainlit/translations/zh-CN.json +++ b/backend/chainlit/translations/zh-CN.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "选择..." + }, + "DatePickerInput": { + "placeholder": { + "single": "选择日期", + "range": "选择日期范围" + } } } } diff --git a/backend/chainlit/translations/zh-TW.json b/backend/chainlit/translations/zh-TW.json index 56b8d8aa33..17c39a6d30 100644 --- a/backend/chainlit/translations/zh-TW.json +++ b/backend/chainlit/translations/zh-TW.json @@ -240,6 +240,12 @@ "components": { "MultiSelectInput": { "placeholder": "選擇..." + }, + "DatePickerInput": { + "placeholder": { + "single": "選擇日期", + "range": "選擇日期範圍" + } } } } diff --git a/backend/chainlit/types.py b/backend/chainlit/types.py index f3c965d247..5960ede61c 100644 --- a/backend/chainlit/types.py +++ b/backend/chainlit/types.py @@ -32,6 +32,7 @@ "multiselect", "checkbox", "radio", + "datepicker", ] ToastType = Literal["info", "success", "warning", "error"] diff --git a/frontend/package.json b/frontend/package.json index 67f24e62fb..c80b846511 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.0", + "date-fns": "^4.1.0", "embla-carousel-react": "^8.5.1", "highlight.js": "^11.9.0", "i18next": "^23.7.16", @@ -46,6 +47,7 @@ "lucide-react": "^0.468.0", "plotly.js": "^2.27.0", "react": "^18.3.1", + "react-day-picker": "^9.11.1", "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", "react-file-icon": "^1.3.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 122189b8d5..dacfb362e3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: cmdk: specifier: 1.0.0 version: 1.0.0(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + date-fns: + specifier: ^4.1.0 + version: 4.1.0 embla-carousel-react: specifier: ^8.5.1 version: 8.5.1(react@18.3.1) @@ -123,6 +126,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-day-picker: + specifier: ^9.11.1 + version: 9.11.1(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -410,6 +416,9 @@ packages: resolution: {integrity: sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==} hasBin: true + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -2432,6 +2441,12 @@ packages: resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==} engines: {node: '>=14'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3750,6 +3765,12 @@ packages: raf@3.4.1: resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + react-day-picker@9.11.1: + resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -4779,6 +4800,8 @@ snapshots: dependencies: commander: 2.20.3 + '@date-fns/tz@1.4.1': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -6726,6 +6749,10 @@ snapshots: whatwg-mimetype: 3.0.0 whatwg-url: 12.0.1 + date-fns-jalali@4.1.0-0: {} + + date-fns@4.1.0: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -8480,6 +8507,13 @@ snapshots: dependencies: performance-now: 2.1.0 + react-day-picker@9.11.1(react@18.3.1): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 18.3.1 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 diff --git a/frontend/src/components/ChatSettings/DatePickerInput.tsx b/frontend/src/components/ChatSettings/DatePickerInput.tsx new file mode 100644 index 0000000000..26c6ec1ada --- /dev/null +++ b/frontend/src/components/ChatSettings/DatePickerInput.tsx @@ -0,0 +1,347 @@ +import { getDateFnsLocale } from '@/i18n/dateLocale'; +import { cn } from '@/lib/utils'; +import { IInput } from '@/types'; +import { format } from 'date-fns'; +import { Calendar as CalendarIcon, ChevronDownIcon } from 'lucide-react'; +import { ReactNode, useState } from 'react'; +import { DateRange } from 'react-day-picker'; + +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/ui/popover'; +import { useTranslation } from 'components/i18n/Translator'; + +import { InputStateHandler } from './InputStateHandler'; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +const parseDate = (dateStr: string | undefined | null): Date | undefined => { + if (!dateStr) return undefined; + try { + const date = new Date(dateStr); + // Check if date is valid (Invalid Date has NaN time) + if (isNaN(date.getTime())) { + console.warn(`Invalid date string provided: "${dateStr}"`); + return undefined; + } + return date; + } catch { + return undefined; + } +}; + +const formatDateValue = (date: Date | undefined): string | undefined => { + if (!date) return undefined; + return date.toISOString(); +}; + +const formatRangeValue = ( + range: DateRange | undefined +): [string, string] | undefined => { + if (!range?.from) return undefined; + return [ + formatDateValue(range.from)!, + formatDateValue(range.to || range.from)! + ]; +}; + +const getDisabledMatcher = ( + disabled: boolean | undefined, + minDate: Date | undefined, + maxDate: Date | undefined +) => { + if (disabled) return true; + + const matchers = []; + if (minDate) matchers.push({ before: minDate }); + if (maxDate) matchers.push({ after: maxDate }); + + return matchers.length > 0 ? matchers : undefined; +}; + +// ============================================================================ +// Base Component +// ============================================================================ + +interface DatePickerBaseProps extends IInput { + isEmpty: boolean; + buttonText: ReactNode; + calendarContent: ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const DatePickerBase = ({ + id, + label, + description, + tooltip, + hasError, + disabled, + isEmpty, + buttonText, + calendarContent, + open, + onOpenChange, + className +}: DatePickerBaseProps): JSX.Element => { + return ( + + + + + + + {calendarContent} + + + + ); +}; + +// ============================================================================ +// Shared Props +// ============================================================================ + +interface DatePickerSharedProps { + min_date?: string | null; + max_date?: string | null; + format?: string | null; + placeholder?: string | null; + setField?: (field: string, value: any, shouldValidate?: boolean) => void; +} + +// ============================================================================ +// Single Date Picker +// ============================================================================ + +export interface DatePickerSingleProps extends IInput, DatePickerSharedProps { + value?: string; +} + +const DatePickerSingle = ({ + id, + value, + min_date, + max_date, + format: dateFormat, + placeholder, + setField, + ...baseProps +}: DatePickerSingleProps): JSX.Element => { + const { t, i18n } = useTranslation(); + const [open, setOpen] = useState(false); + + const date = parseDate(value); + const minDate = parseDate(min_date); + const maxDate = parseDate(max_date); + const dateFnsLocale = getDateFnsLocale(i18n.language); + + const defaultPlaceholder = + placeholder ?? t('components.DatePickerInput.placeholder.single'); + + const handleDateSelect = (newDate: Date | undefined) => { + const formattedDate = formatDateValue(newDate); + setField?.(id, formattedDate); + setOpen(false); + }; + + const buttonText = date ? ( + format(date, dateFormat || 'PPP', { locale: dateFnsLocale }) + ) : ( + {defaultPlaceholder} + ); + + const calendarContent = ( + + ); + + return ( + + ); +}; + +// ============================================================================ +// Range Date Picker +// ============================================================================ + +export interface DatePickerRangeProps extends IInput, DatePickerSharedProps { + value?: [string, string]; +} + +const DatePickerRange = ({ + id, + value, + min_date, + max_date, + format: dateFormatInput, + placeholder, + setField, + ...baseProps +}: DatePickerRangeProps): JSX.Element => { + const { t, i18n } = useTranslation(); + const [open, setOpen] = useState(false); + + const dateRange: DateRange | undefined = + value && Array.isArray(value) + ? { + from: parseDate(value[0]), + to: parseDate(value[1]) + } + : undefined; + + // Temporary range state for selections before confirmation + const [tempRange, setTempRange] = useState(dateRange); + + const minDate = parseDate(min_date); + const maxDate = parseDate(max_date); + const dateFnsLocale = getDateFnsLocale(i18n.language); + + const dateFormat = dateFormatInput || 'PPP'; + const defaultPlaceholder = + placeholder ?? t('components.DatePickerInput.placeholder.range'); + + // Update temp range when selecting dates (don't commit yet) + const handleRangeDateSelect = (newRange: DateRange | undefined) => { + setTempRange(newRange); + }; + + // Confirm button: commit the temp range and close popover + const handleConfirm = () => { + const formattedRange = formatRangeValue(tempRange); + setField?.(id, formattedRange); + setOpen(false); + }; + + // Reset button: clear the temp range + const handleReset = () => { + setTempRange(undefined); + }; + + // Update temp range when popover opens to sync with current value + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + setTempRange(dateRange); + }; + + const buttonText = tempRange?.from ? ( + tempRange.to ? ( + <> + {format(tempRange.from, dateFormat, { locale: dateFnsLocale })} -{' '} + {format(tempRange.to, dateFormat, { locale: dateFnsLocale })} + + ) : ( + format(tempRange.from, dateFormat, { locale: dateFnsLocale }) + ) + ) : ( + {defaultPlaceholder} + ); + + const calendarContent = ( +
+ +
+ + +
+
+ ); + + return ( + + ); +}; + +// ============================================================================ +// Main Component (Router) +// ============================================================================ + +export interface DatePickerInputProps extends IInput, DatePickerSharedProps { + mode: 'single' | 'range'; + value?: string | [string, string]; +} + +const DatePickerInput = (props: DatePickerInputProps): JSX.Element => { + if (props.mode === 'single') { + return ( + + ); + } + + return ( + + ); +}; + +export { DatePickerInput, DatePickerRange, DatePickerSingle }; diff --git a/frontend/src/components/ChatSettings/FormInput.tsx b/frontend/src/components/ChatSettings/FormInput.tsx index bb412d194f..b0d3714731 100644 --- a/frontend/src/components/ChatSettings/FormInput.tsx +++ b/frontend/src/components/ChatSettings/FormInput.tsx @@ -1,6 +1,7 @@ import { IInput } from 'types/Input'; import { CheckboxInput, CheckboxInputProps } from './CheckboxInput'; +import { DatePickerInput, DatePickerInputProps } from './DatePickerInput'; import { MultiSelectInput, MultiSelectInputProps } from './MultiSelectInput'; import { RadioButtonGroup, RadioButtonGroupProps } from './RadioButtonGroup'; import { SelectInput, SelectInputProps } from './SelectInput'; @@ -27,7 +28,9 @@ type TFormInput = | (Omit & IFormInput<'numberinput', number>) | (Omit & IFormInput<'multiselect', string[]>) | (Omit & IFormInput<'checkbox', boolean>) - | (Omit & IFormInput<'radio', string>); + | (Omit & IFormInput<'radio', string>) + | (DatePickerInputProps & + IFormInput<'datepicker', string | [string, string]>); const FormInput = ({ element }: { element: TFormInput }): JSX.Element => { switch (element?.type) { @@ -55,6 +58,8 @@ const FormInput = ({ element }: { element: TFormInput }): JSX.Element => { return ; case 'radio': return ; + case 'datepicker': + return ; default: // If the element type is not recognized, we indicate an unimplemented type. // This code path should not normally occur and serves as a fallback. diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx new file mode 100644 index 0000000000..f3e0f1d08e --- /dev/null +++ b/frontend/src/components/ui/calendar.tsx @@ -0,0 +1,211 @@ +import { cn } from '@/lib/utils'; +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon +} from 'lucide-react'; +import * as React from 'react'; +import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'; + +import { Button, buttonVariants } from '@/components/ui/button'; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = 'label', + buttonVariant = 'ghost', + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps['variant']; +}) { + const defaultClassNames = getDefaultClassNames(); + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString('default', { month: 'short' }), + ...formatters + }} + classNames={{ + root: cn('w-fit', defaultClassNames.root), + months: cn( + 'relative flex flex-col gap-4 md:flex-row', + defaultClassNames.months + ), + month: cn('flex w-full flex-col gap-4', defaultClassNames.month), + nav: cn( + 'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + 'h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50', + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + 'h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50', + defaultClassNames.button_next + ), + month_caption: cn( + 'flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]', + defaultClassNames.month_caption + ), + dropdowns: cn( + 'flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium', + defaultClassNames.dropdowns + ), + dropdown_root: cn( + 'has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border', + defaultClassNames.dropdown_root + ), + dropdown: cn( + 'bg-popover absolute inset-0 opacity-0', + defaultClassNames.dropdown + ), + caption_label: cn( + 'select-none font-medium', + captionLayout === 'label' + ? 'text-sm' + : '[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5', + defaultClassNames.caption_label + ), + table: 'w-full border-collapse', + weekdays: cn('flex', defaultClassNames.weekdays), + weekday: cn( + 'text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal', + defaultClassNames.weekday + ), + week: cn('mt-2 flex w-full', defaultClassNames.week), + week_number_header: cn( + 'w-[--cell-size] select-none', + defaultClassNames.week_number_header + ), + week_number: cn( + 'text-muted-foreground select-none text-[0.8rem]', + defaultClassNames.week_number + ), + day: cn( + 'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md', + defaultClassNames.day + ), + range_start: cn( + 'bg-accent rounded-l-md', + defaultClassNames.range_start + ), + range_middle: cn('rounded-none', defaultClassNames.range_middle), + range_end: cn('bg-accent rounded-r-md', defaultClassNames.range_end), + today: cn( + 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none', + defaultClassNames.today + ), + outside: cn( + 'text-muted-foreground aria-selected:text-muted-foreground', + defaultClassNames.outside + ), + disabled: cn( + 'text-muted-foreground opacity-50', + defaultClassNames.disabled + ), + hidden: cn('invisible', defaultClassNames.hidden), + ...classNames + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ); + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === 'left') { + return ( + + ); + } + + if (orientation === 'right') { + return ( + + ); + } + + return ( + + ); + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ); + }, + ...components + }} + {...props} + /> + ); +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( +