diff --git a/CHANGELOG.md b/CHANGELOG.md index 209edd0..f404960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.12.0] + +### Added + +- Added `QuickRangePicker` component. + +### Changed + +- Improve `Filter` component. + +### Fixed + +- Fixed `localization` props in `Filter` component. + ## [1.11.3] ### Remove diff --git a/package.json b/package.json index c682c63..4e56d84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "1byte-react-design", - "version": "1.11.3", + "version": "1.12.0", "description": "A simple React UI library", "main": "dist/index.js", "module": "dist/index.js", @@ -18,7 +18,8 @@ "peerDependencies": { "react": "^18.0.0", "react-router": "^7.0.0", - "yup": "1.7.0" + "yup": "1.7.0", + "i18next": "^25.5.3" }, "dependencies": { "@ant-design/cssinjs": "^1.24.0", @@ -34,7 +35,6 @@ "antd": "^5.21.2", "clsx": "^2.1.1", "dayjs": "^1.11.13", - "i18next": "^25.5.3", "polished": "^4.3.1", "rc-field-form": "^2.7.0", "rc-image": "^7.12.0", diff --git a/src/organisms/Filter/components/Footer/index.tsx b/src/organisms/Filter/components/Footer/index.tsx index e4676d5..236654c 100644 --- a/src/organisms/Filter/components/Footer/index.tsx +++ b/src/organisms/Filter/components/Footer/index.tsx @@ -6,6 +6,19 @@ import { Flex, Typography } from '../../../../atomics'; import { Form, Select, Space, Tooltip } from '../../../../molecules'; import { FilterFooterWrapper } from './styles'; import { RdFilterFooterProps } from './types'; +import { localize } from '../../../../utils/localize'; + +// export const rdI118next = i18next; +// (rdI118next as any).fromLib = '1byte-react-design'; + +// export const useRdLocation = useLocation; +// (useRdLocation as any).fromLib = '1byte-react-design'; + +// export const rdReact = React; +// (rdReact as any).fromLib = '1byte-react-design'; + +// export const rdYup = Yup; +// (rdYup as any).fromLib = '1byte-react-design'; export const FilterFooter = >(props: RdFilterFooterProps) => { const { @@ -16,6 +29,7 @@ export const FilterFooter = >(props: RdFilterFo isLoading, filterValue, localization, + children, onChangeFilterValue, } = props; @@ -39,38 +53,46 @@ export const FilterFooter = >(props: RdFilterFo {Boolean(fields?.length) && ( - {fields?.map(field => ( - - { + const newFilterValue = { ...filterValue } as T; + newFilterValue[field.name] = e; - onChangeFilterValue?.(newFilterValue); - }} - popupMatchSelectWidth={false} - /> - - ))} + onChangeFilterValue?.(newFilterValue); + }} + popupMatchSelectWidth={false} + /> + )} + + ); + })} )} + {children} + {Boolean(totalItems || showTotalItemsCount) && ( {isLoading && } - {i18next.t(showing, { total: totalItems, count: showTotalItemsCount })}{' '} - + {localize(showing, { total: totalItems, count: showTotalItemsCount })}{' '} + diff --git a/src/organisms/Filter/components/Footer/types.ts b/src/organisms/Filter/components/Footer/types.ts index f3a2f7a..0a740cf 100644 --- a/src/organisms/Filter/components/Footer/types.ts +++ b/src/organisms/Filter/components/Footer/types.ts @@ -24,6 +24,7 @@ export type RdFilterFooterComponent = ( interface IFieldItem extends RdSelectProps { name: keyof T; label: ReactNode; + render?: () => ReactNode; } export interface FilterFooterLocalization { diff --git a/src/organisms/Filter/components/Header/index.tsx b/src/organisms/Filter/components/Header/index.tsx index 7c8e7e0..4d52ce3 100644 --- a/src/organisms/Filter/components/Header/index.tsx +++ b/src/organisms/Filter/components/Header/index.tsx @@ -3,6 +3,7 @@ import { RdSearchProps, Space } from '../../../../molecules'; import { FilterHeaderWrapper, InputFilterStyles } from './styles'; import { RdFilterHeaderProps } from './types'; import i18next from 'i18next'; +import { localize } from '../../../../utils/localize'; export const FilterHeader = (props: RdFilterHeaderProps) => { const { defaultKeywords, className, localization, onChangeKeywords } = props; @@ -17,7 +18,7 @@ export const FilterHeader = (props: RdFilterHeaderProps) => { diff --git a/src/organisms/Filter/index.ts b/src/organisms/Filter/index.ts index 0eea779..a2da1ed 100644 --- a/src/organisms/Filter/index.ts +++ b/src/organisms/Filter/index.ts @@ -1 +1,2 @@ +export * from './components/Footer'; export * from './Filter'; diff --git a/src/organisms/QuickRangePicker/QuickRangePicker.tsx b/src/organisms/QuickRangePicker/QuickRangePicker.tsx new file mode 100644 index 0000000..1e96ef8 --- /dev/null +++ b/src/organisms/QuickRangePicker/QuickRangePicker.tsx @@ -0,0 +1,326 @@ +import dayjs, { Dayjs } from 'dayjs'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { DatePicker, Dropdown, RdRangePickerProps, Select } from '../../molecules'; +import { defaultQuickTypeOptions } from './constants'; +import { QuickOptionType, QuickRangePickerProps } from './types'; + +/** + * A reusable dropdown-based quick range picker component. + * Inspired by Pagination's pageSizeOptions, it allows configurable quick date range selections + * with an optional custom range picker. Supports controlled and uncontrolled modes via RdRangePickerProps. + * + * @example + * [dayjs().startOf('day'), dayjs().endOf('day')] }, + * { key: 'custom', label: 'Tùy chỉnh' } + * ]} + * onChange={(dates, dateStrings) => console.log(dates, dateStrings)} + * /> + * + * @param props - Component props extending RdRangePickerProps. + * @returns A React component rendering the quick range picker. + */ +export const QuickRangePicker: React.FC = props => { + const { + quickTypeOptions, + defaultSelectedType = 'all', + onChange, + defaultValue, + value, + ...rangePickerProps + } = props; + + const options = useMemo(() => quickTypeOptions || defaultQuickTypeOptions, [quickTypeOptions]); + + const [visible, setVisible] = useState(false); + const [panelVisible, setPanelVisible] = useState(false); + const [selectedType, setSelectedType] = useState(null); + const [dates, setDates] = useState>(null); + + // Fallback function to compute predefined date ranges based on option key + const getDateRange = useCallback((type: string): [Dayjs, Dayjs] => { + const now = dayjs(); + const t = type as QuickOptionType; + switch (t) { + case 'all': + return [dayjs('1970-01-01'), dayjs('9999-12-31')]; + case 'today': + return [now.startOf('day'), now.endOf('day')]; + case 'yesterday': { + const y = now.subtract(1, 'day'); + return [y.startOf('day'), y.endOf('day')]; + } + case 'thisWeek': + return [now.startOf('week'), now.endOf('week')]; + case 'thisMonth': + return [now.startOf('month'), now.endOf('month')]; + case 'lastMonth': { + const last = now.subtract(1, 'month'); + return [last.startOf('month'), last.endOf('month')]; + } + case 'thisYear': + return [now.startOf('year'), now.endOf('year')]; + default: + // Fallback to today if unknown key + return [now.startOf('day'), now.endOf('day')]; + } + }, []); + + // Get effective range for a given key, using option.getRange or fallback + const getEffectiveRange = useCallback( + (key: string): [Dayjs, Dayjs] => { + const option = options.find(o => o.key === key); + return option?.getRange ? option.getRange() : getDateRange(key); + }, + [options, getDateRange] + ); + + // Detect the option type that matches the given date range + const detectType = useCallback( + (range: Required): string | null => { + if (!range || range[0] == null || range[1] == null) return null; + for (const option of options) { + if (option.key === 'custom') continue; + const optRange = getEffectiveRange(option.key); + if (range[0]!.isSame(optRange[0]) && range[1]!.isSame(optRange[1])) { + return option.key; + } + } + return null; + }, + [options, getEffectiveRange] + ); + + // Initialize state based on defaultValue and defaultSelectedType + const initialDates = useMemo>( + () => defaultValue || (defaultSelectedType ? getEffectiveRange(defaultSelectedType) : null), + [defaultValue, defaultSelectedType, getEffectiveRange] + ); + + const initialSelectedType = useMemo( + () => + defaultSelectedType || + detectType(initialDates) || + (initialDates && initialDates[0] != null && initialDates[1] != null ? 'custom' : null), + [defaultSelectedType, initialDates, detectType] + ); + + // Set initial state + useEffect(() => { + setDates(initialDates); + setSelectedType(initialSelectedType); + }, []); // Empty deps: run once on mount + + // Sync with controlled value prop + useEffect(() => { + if (value !== undefined) { + setDates(value); + setSelectedType( + value && value[0] != null && value[1] != null ? detectType(value) || 'custom' : null + ); + } + }, [value, detectType]); + + /** + * Computes the format string for dateStrings based on Ant Design RangePicker behavior. + * - Uses `format` prop if provided (handles string, object {format, type?}, array, or undefined). + * - If `showTime` is true (boolean), appends 'HH:mm:ss' if no time in format. + * - If `showTime` is object: + * - Uses `showTime.format` if provided. + * - Otherwise, builds time format from `showHour`, `showMinute`, `showSecond`, `showMillisecond`. + * Mimics Ant Design's default formatting for onChange dateStrings. + */ + const getFormat = useCallback((pickerProps: RdRangePickerProps): string => { + let fmt: string; + + // Handle format prop: string, object {format, type?}, array, or undefined + const rawFormat = pickerProps.format; + if (typeof rawFormat === 'string') { + fmt = rawFormat; + } else if (rawFormat && typeof rawFormat === 'object' && 'format' in rawFormat) { + fmt = (rawFormat as { format: string }).format; + } else if (Array.isArray(rawFormat) && rawFormat.length > 0) { + // Take first format if array + const first = rawFormat[0]; + if (typeof first === 'string') { + fmt = first; + } else if (first && typeof first === 'object' && 'format' in first) { + fmt = (first as { format: string }).format || 'YYYY-MM-DD'; + } else { + fmt = 'YYYY-MM-DD'; + } + } else { + fmt = 'YYYY-MM-DD'; + } + + const showTime = pickerProps.showTime; + if (showTime) { + let timeFormat = ''; + if (typeof showTime === 'boolean') { + timeFormat = 'HH:mm:ss'; + } else { + timeFormat = showTime.format || ''; + if (!timeFormat) { + const { + showHour = true, + showMinute = true, + showSecond = true, + showMillisecond = false, // Assuming default false, as not standard in AntD + } = showTime; + const parts: string[] = []; + if (showHour) parts.push('HH'); + if (showMinute) parts.push('mm'); + if (showSecond) parts.push('ss'); + if (showMillisecond) parts.push('SSS'); + timeFormat = parts.join(':'); + } + } + + // Append time format if base format doesn't already include time components + if (!/\bHH|mm|ss|SSS\b/.test(fmt)) { + if (timeFormat) { + fmt += ` ${timeFormat}`; + } + } + } + + return fmt; + }, []); + + // Handle quick option selection (excludes custom) + const handleQuickSelect = useCallback( + (key: string) => { + const range = getEffectiveRange(key); + const fmt = getFormat(rangePickerProps); + const dateStrings: [string, string] = [range[0].format(fmt), range[1].format(fmt)]; + setSelectedType(key); + setDates(range); + onChange?.(range as [Dayjs, Dayjs], dateStrings); + setVisible(false); + }, + [getEffectiveRange, onChange, getFormat, rangePickerProps] + ); + + // Handle custom range picker change + const handleCustomChange = useCallback>( + (dates, dateStrings) => { + setDates(dates); + if (dates?.[0] && dates?.[1]) { + setSelectedType('custom'); + onChange?.(dates as [Dayjs, Dayjs], dateStrings); + } else { + setDates(null); + setSelectedType(null); + onChange?.(null, dateStrings); + } + setVisible(false); + setPanelVisible(false); + }, + [onChange] + ); + + // Generate display label based on selected type and dates + const displayLabel = useMemo(() => { + if (!selectedType) { + return 'Select range'; + } + + if (selectedType !== 'custom') { + const option = options.find(o => o.key === selectedType); + return option?.label ?? selectedType; + } + + // Custom case + if (dates && dates[0] != null && dates[1] != null) { + const [start, end] = dates as [Dayjs, Dayjs]; + return `${start.format('YYYY-MM-DD')} ~ ${end.format('YYYY-MM-DD')}`; + } + + const option = options.find(o => o.key === selectedType); + return option?.label ?? 'Custom range'; + }, [selectedType, dates, options]); + + // Generate menu items from options + const menuItems = useMemo(() => { + return options.map(option => { + const { key, label } = option; + + if (key === 'custom') { + return { + key, + label: ( +
{ + e.stopPropagation(); + setPanelVisible(true); + }} + > +
Customize
+
{ + e.stopPropagation(); + }} + > + { + if (ranges?.[0] && ranges?.[1]) { + setDates([ranges[0], ranges[1]]); + } else { + setDates(null); + } + setVisible(false); + setPanelVisible(false); + handleCustomChange(ranges, rangesStrings); + }} + // value={dates} + {...rangePickerProps} + /> +
+
+ ), + }; + } + + return { + key, + label, + onClick: () => handleQuickSelect(key), + }; + }); + }, [options, handleQuickSelect, panelVisible, handleCustomChange, dates, rangePickerProps]); + + // Handle dropdown open/close + const handleOpenChange = useCallback((open: boolean) => { + setVisible(open); + if (!open) { + setPanelVisible(false); + } + }, []); + + console.debug('displayLabel', displayLabel); + + return ( + +