Copilot commented on code in PR #36111: URL: https://github.com/apache/superset/pull/36111#discussion_r2535129852
########## superset-frontend/src/utils/persianCalendar.ts: ########## @@ -0,0 +1,126 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isValidJalaaliDate, toGregorian, toJalaali } from 'jalaali-js'; + +export interface PersianDateParts { + year: number; + month: number; + day: number; + monthName: string; + weekday: string; +} + +export interface GregorianDateParts { + year: number; + month: number; + day: number; +} + +// Persian calendar months +export const PERSIAN_MONTHS = [ + 'فروردین', + 'اردیبهشت', + 'خرداد', + 'تیر', + 'مرداد', + 'شهریور', + 'مهر', + 'آبان', + 'آذر', + 'دی', + 'بهمن', + 'اسفند', +]; + +// Persian calendar days +export const PERSIAN_WEEKDAYS = [ + 'شنبه', + 'یکشنبه', + 'دوشنبه', + 'سهشنبه', + 'چهارشنبه', + 'پنجشنبه', + 'جمعه', +]; + +const getWeekdayName = (year: number, month: number, day: number) => + PERSIAN_WEEKDAYS[new Date(year, month - 1, day).getDay()]; Review Comment: The `getWeekdayName` function uses Gregorian date to calculate weekday, which may produce incorrect weekday names for Persian dates. The JavaScript `Date` object expects Gregorian dates, but this function receives year/month/day that appear to be intended as Gregorian coordinates. However, this function is called with Gregorian dates from `gregorianToPersian`, so it should work correctly. Actually, on closer inspection, this is being called correctly in line 79 with Gregorian parameters before conversion. However, the parameter names and function placement suggest it might be misunderstood. Consider adding a comment clarifying that this expects Gregorian year/month/day values. ```suggestion /** * Returns the Persian weekday name for a given Gregorian date. * Expects Gregorian year, month, and day as arguments. * @param gregorianYear - Gregorian year * @param gregorianMonth - Gregorian month (1-based) * @param gregorianDay - Gregorian day */ const getWeekdayName = ( gregorianYear: number, gregorianMonth: number, gregorianDay: number, ) => PERSIAN_WEEKDAYS[new Date(gregorianYear, gregorianMonth - 1, gregorianDay).getDay()]; ``` ########## superset-frontend/src/explore/components/controls/DateFilterControl/components/JalaliDatePicker.tsx: ########## @@ -0,0 +1,429 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { CSSProperties } from 'react'; +import DatePicker from 'react-multi-date-picker'; +import persian from 'react-date-object/calendars/persian'; +import persian_fa from 'react-date-object/locales/persian_fa'; +import DateObject, { type Month } from 'react-date-object'; +import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates'; +import type { Dayjs } from 'dayjs'; +import { useResizeDetector } from 'react-resize-detector'; +import { + gregorianToPersian, + persianToGregorian, +} from 'src/utils/persianCalendar'; + +type BasePickerProps = { + placeholder?: string; + style?: CSSProperties; + placement?: 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; + forceRTL?: boolean; + minYear?: number; + maxYear?: number; +}; + +type SingleModeProps = BasePickerProps & { + mode?: 'single'; + value: Dayjs | null; + onChange: (date: Dayjs | null) => void; +}; + +type RangeModeProps = BasePickerProps & { + mode: 'range'; + value: [Dayjs | null, Dayjs | null]; + onChange: (range: [Dayjs | null, Dayjs | null]) => void; +}; + +export type JalaliDatePickerProps = SingleModeProps | RangeModeProps; + +const getMonthNumber = (month?: number | Month): number => { + if (!month) { + return 1; + } + return typeof month === 'number' ? month : month.number; +}; + +const buildDateObject = (year: number, month: number, day: number) => + new DateObject({ + calendar: persian, + locale: persian_fa, + year, + month, + day, + }); + +const detectRTL = () => { + if (typeof document === 'undefined') { + return false; + } + const doc = document.documentElement; + if (doc?.dir === 'rtl' || doc?.lang?.startsWith('fa')) { + return true; + } + if (typeof navigator !== 'undefined') { + return navigator.language?.startsWith('fa') ?? false; + } + return false; +}; + +/** + * JalaliDatePicker component that displays Persian calendar but returns Gregorian dates. + * Supports both single-date and range selection modes. + */ +export function JalaliDatePicker(props: JalaliDatePickerProps) { + const { + placeholder = 'تاریخ را انتخاب کنید', + style, + placement = 'bottomRight', + forceRTL, + minYear = 782, + maxYear = 1500, + } = props; + + const isRangeMode = props.mode === 'range'; + const singleValue = !isRangeMode ? (props.value as Dayjs | null) : null; + const rangeValue = isRangeMode + ? (props.value as [Dayjs | null, Dayjs | null]) + : null; + const [jalaliReady, setJalaliReady] = useState(false); + const isRTL = forceRTL ?? detectRTL(); + const { ref: resizeRef, width: containerWidth } = useResizeDetector({ + refreshMode: 'debounce', + refreshRate: 100, + }); + + useEffect(() => { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const jalaliPlugin = require('dayjs-jalali'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const jalaliDayjsModule = jalaliPlugin.default || jalaliPlugin; + dayjs.extend(jalaliDayjsModule); + setJalaliReady(true); + } catch (error) { + console.warn('Failed to load dayjs-jalali plugin:', error); + setJalaliReady(false); + } + }, []); + + const convertDayjsToDateObject = useCallback( + (currentValue: Dayjs): DateObject | undefined => { + if (!currentValue || !currentValue.isValid()) { + return undefined; + } + + if (jalaliReady) { + try { + const jDate = dayjs(currentValue) as Dayjs & { + jYear: () => number; + jMonth: () => number; + jDate: () => number; + }; + return buildDateObject( + jDate.jYear(), + jDate.jMonth() + 1, + jDate.jDate(), + ); + } catch (error) { + console.warn('Error converting to Jalali:', error); + } + } + + try { + const persianDate = gregorianToPersian( + currentValue.year(), + currentValue.month() + 1, + currentValue.date(), + ); + return buildDateObject( + persianDate.year, + persianDate.month, + persianDate.day, + ); + } catch (error) { + console.error('Error in fallback conversion:', error); + return undefined; + } + }, + [jalaliReady], + ); + + const convertDateObjectToDayjs = useCallback( + (date: DateObject | null): Dayjs | null => { + if (!date) { + return null; + } + const monthValue = getMonthNumber(date.month as number | Month | undefined); + + if (jalaliReady) { + try { + const jalaliDate = dayjs( + `${date.year}/${monthValue}/${date.day}`, + 'jYYYY/jM/jD', + ); + const gregorianDate = dayjs(jalaliDate.toDate()); + if (gregorianDate.isValid()) { + return gregorianDate; + } + } catch (error) { + console.warn('Error converting Jalali to Gregorian:', error); + } + } + + try { + const gregorian = persianToGregorian(date.year, monthValue, date.day); + const converted = dayjs( + `${gregorian.year}-${String(gregorian.month).padStart(2, '0')}-${String( + gregorian.day, + ).padStart(2, '0')}`, + ); + return converted.isValid() ? converted : null; + } catch (error) { + console.error('Error in fallback conversion:', error); + return null; + } + }, + [jalaliReady], + ); + + const jalaliValue = useMemo<DateObject | DateObject[] | undefined>(() => { + if (isRangeMode) { + if (!rangeValue) { + return undefined; + } + const converted = rangeValue + .map(value => (value ? convertDayjsToDateObject(value) : undefined)) + .filter( + (result): result is DateObject => + Boolean(result) && result instanceof DateObject, + ); + return converted.length ? converted : undefined; + } + + if (!singleValue) { + return undefined; + } + + return convertDayjsToDateObject(singleValue); + }, [convertDayjsToDateObject, isRangeMode, rangeValue, singleValue]); + + const todayJalali = useMemo(() => { + const today = dayjs(); + return convertDayjsToDateObject(today); + }, [convertDayjsToDateObject]); + + const minDateObject = useMemo( + () => buildDateObject(minYear, 1, 1), + [minYear], + ); + const maxDateObject = useMemo( + () => buildDateObject(maxYear, 12, 29), + [maxYear], + ); + + const handleDateChange = (selected: DateObject | DateObject[] | null) => { + if (isRangeMode) { + const normalized = Array.isArray(selected) + ? selected + : selected + ? [selected] + : []; + + const startCandidate = normalized[0] ?? null; + const endCandidate = + normalized.length > 1 ? normalized[normalized.length - 1] : null; + const rangeStart = convertDateObjectToDayjs(startCandidate); + const rangeEnd = endCandidate + ? convertDateObjectToDayjs(endCandidate) + : rangeStart; + + (props as RangeModeProps).onChange([rangeStart, rangeEnd]); + return; + } + + const nextValue = convertDateObjectToDayjs(selected as DateObject | null); + (props as SingleModeProps).onChange(nextValue); + }; + + const monthsToShow = useMemo(() => { + if (!isRangeMode) { + return 1; + } + if (!containerWidth) { + return 2; + } + return containerWidth < 520 ? 1 : 2; + }, [containerWidth, isRangeMode]); + + return ( + <div + ref={resizeRef} + style={{ width: '100%', direction: isRTL ? 'rtl' : 'ltr', ...style }} + onClick={event => event.stopPropagation()} + > + <DatePicker + calendar={persian} + locale={persian_fa} + value={jalaliValue} + onChange={handleDateChange} + format="YYYY/MM/DD" + calendarPosition={placement} + containerClassName="custom-rmdp" + inputClass={`jalali-date-input${ + isRangeMode ? ' jalali-date-input--range' : '' + }`} + style={{ + width: '100%', + direction: isRTL ? 'rtl' : 'ltr', + textAlign: isRTL ? 'right' : 'left', + }} + placeholder={placeholder} + multiple={false} + range={isRangeMode} + numberOfMonths={monthsToShow} + rangeHover={isRangeMode} + showOtherDays + minDate={minDateObject} + maxDate={maxDateObject} + portal={false} + mapDays={({ date }) => { + if ( + todayJalali && + date.year === todayJalali.year && + getMonthNumber(date.month) === getMonthNumber(todayJalali.month) && + date.day === todayJalali.day + ) { + return { className: 'rmdp-day-today' }; + } + return {}; + }} + /> + <style>{` + .custom-rmdp { + position: relative; + width: 100%; + } + .custom-rmdp .rmdp-wrapper { + direction: ${isRTL ? 'rtl' : 'ltr'}; + z-index: 10000 !important; + background: white; + border: 1px solid #d9d9d9; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 8px; + width: 100%; + max-width: 100%; + } + .custom-rmdp .rmdp-calendar { + direction: ${isRTL ? 'rtl' : 'ltr'}; + z-index: 10000 !important; + width: 100%; + max-width: 100%; + } + .custom-rmdp .rmdp-header { + padding: 8px; + border-bottom: 1px solid #f0f0f0; + margin-bottom: 8px; + } + .custom-rmdp .rmdp-header-values { + font-weight: 600; + font-size: 14px; + color: #1890ff; + } + .custom-rmdp .rmdp-arrow { + border: solid #1890ff; + border-width: 0 2px 2px 0; + padding: 3px; + cursor: pointer; + } + .custom-rmdp .rmdp-arrow:hover { + border-color: #40a9ff; + } + .custom-rmdp .rmdp-arrow-container { + cursor: pointer; + padding: 4px; + } + .custom-rmdp .rmdp-arrow-container:hover { + background-color: #f0f0f0; + border-radius: 4px; + } + .custom-rmdp .rmdp-week-day { + color: #1890ff; + font-weight: 600; + padding: 8px; + } + .custom-rmdp .rmdp-day { + cursor: pointer; + padding: 4px; + margin: 2px; + border-radius: 4px; + } + .custom-rmdp .rmdp-day:hover { + background-color: #e6f7ff !important; + } + .custom-rmdp .rmdp-day.rmdp-selected span:not(.highlight) { + background-color: #1890ff !important; + color: white !important; + border-radius: 4px; + } + .custom-rmdp .rmdp-day.rmdp-today span, + .custom-rmdp .rmdp-day-today span { + background-color: #e6f7ff !important; + border: 1px solid #1890ff !important; + font-weight: bold; + border-radius: 4px; + } + .custom-rmdp .rmdp-day.rmdp-today:hover span, + .custom-rmdp .rmdp-day-today:hover span { + background-color: #bae7ff !important; + } + .custom-rmdp .rmdp-day.rmdp-disabled { + cursor: not-allowed; + opacity: 0.5; + } + .custom-rmdp .rmdp-day.rmdp-disabled:hover { + background-color: transparent !important; + } + .jalali-date-input { + width: 100%; + cursor: pointer; + padding: 4px 11px; + border: 1px solid #d9d9d9; + border-radius: 4px; + } + .jalali-date-input:hover { + border-color: #40a9ff; + } + .jalali-date-input:focus { + border-color: #40a9ff; + outline: 0; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); + } + .jalali-date-input--range { + text-align: ${isRTL ? 'right' : 'left'}; + } + .rmdp-container, + .rmdp-popup { + z-index: 10000 !important; + } + `}</style> Review Comment: The inline `<style>` tag creates duplicate styles each time the component renders. This is inefficient and can cause memory leaks with repeated mounting/unmounting. Consider using styled-components, CSS modules, or moving styles to a separate stylesheet to avoid re-creating the same styles on every render. ########## superset-frontend/src/preamble.ts: ########## @@ -43,6 +43,12 @@ import 'dayjs/plugin/customParseFormat'; import 'dayjs/plugin/duration'; import 'dayjs/plugin/updateLocale'; import 'dayjs/plugin/localizedFormat'; +import 'dayjs/plugin/isSameOrBefore'; + +// dayjs-jalali will be loaded dynamically in components that need it + +// Import react-multi-date-picker CSS globally +import 'react-multi-date-picker/styles/layouts/mobile.css'; Review Comment: The global CSS import in the preamble may cause styling conflicts with other parts of the application. Consider scoping this import to only the components that need it, or importing it within the `JalaliDatePicker` component instead of globally. ```suggestion ``` ########## superset-frontend/src/explore/components/controls/DateFilterControl/components/JalaliDatePicker.tsx: ########## @@ -0,0 +1,429 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { CSSProperties } from 'react'; +import DatePicker from 'react-multi-date-picker'; +import persian from 'react-date-object/calendars/persian'; +import persian_fa from 'react-date-object/locales/persian_fa'; +import DateObject, { type Month } from 'react-date-object'; +import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates'; +import type { Dayjs } from 'dayjs'; +import { useResizeDetector } from 'react-resize-detector'; +import { + gregorianToPersian, + persianToGregorian, +} from 'src/utils/persianCalendar'; + +type BasePickerProps = { + placeholder?: string; + style?: CSSProperties; + placement?: 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; + forceRTL?: boolean; + minYear?: number; + maxYear?: number; +}; + +type SingleModeProps = BasePickerProps & { + mode?: 'single'; + value: Dayjs | null; + onChange: (date: Dayjs | null) => void; +}; + +type RangeModeProps = BasePickerProps & { + mode: 'range'; + value: [Dayjs | null, Dayjs | null]; + onChange: (range: [Dayjs | null, Dayjs | null]) => void; +}; + +export type JalaliDatePickerProps = SingleModeProps | RangeModeProps; + +const getMonthNumber = (month?: number | Month): number => { + if (!month) { + return 1; + } + return typeof month === 'number' ? month : month.number; +}; + +const buildDateObject = (year: number, month: number, day: number) => + new DateObject({ + calendar: persian, + locale: persian_fa, + year, + month, + day, + }); + +const detectRTL = () => { + if (typeof document === 'undefined') { + return false; + } + const doc = document.documentElement; + if (doc?.dir === 'rtl' || doc?.lang?.startsWith('fa')) { + return true; + } + if (typeof navigator !== 'undefined') { + return navigator.language?.startsWith('fa') ?? false; + } + return false; +}; + +/** + * JalaliDatePicker component that displays Persian calendar but returns Gregorian dates. + * Supports both single-date and range selection modes. + */ +export function JalaliDatePicker(props: JalaliDatePickerProps) { + const { + placeholder = 'تاریخ را انتخاب کنید', + style, + placement = 'bottomRight', + forceRTL, + minYear = 782, + maxYear = 1500, + } = props; + + const isRangeMode = props.mode === 'range'; + const singleValue = !isRangeMode ? (props.value as Dayjs | null) : null; + const rangeValue = isRangeMode + ? (props.value as [Dayjs | null, Dayjs | null]) + : null; + const [jalaliReady, setJalaliReady] = useState(false); + const isRTL = forceRTL ?? detectRTL(); + const { ref: resizeRef, width: containerWidth } = useResizeDetector({ + refreshMode: 'debounce', + refreshRate: 100, + }); + + useEffect(() => { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const jalaliPlugin = require('dayjs-jalali'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const jalaliDayjsModule = jalaliPlugin.default || jalaliPlugin; + dayjs.extend(jalaliDayjsModule); + setJalaliReady(true); + } catch (error) { + console.warn('Failed to load dayjs-jalali plugin:', error); + setJalaliReady(false); + } + }, []); + + const convertDayjsToDateObject = useCallback( + (currentValue: Dayjs): DateObject | undefined => { + if (!currentValue || !currentValue.isValid()) { + return undefined; + } + + if (jalaliReady) { + try { + const jDate = dayjs(currentValue) as Dayjs & { + jYear: () => number; + jMonth: () => number; + jDate: () => number; + }; + return buildDateObject( + jDate.jYear(), + jDate.jMonth() + 1, + jDate.jDate(), + ); + } catch (error) { + console.warn('Error converting to Jalali:', error); + } + } + + try { + const persianDate = gregorianToPersian( + currentValue.year(), + currentValue.month() + 1, + currentValue.date(), + ); + return buildDateObject( + persianDate.year, + persianDate.month, + persianDate.day, + ); + } catch (error) { + console.error('Error in fallback conversion:', error); + return undefined; + } + }, + [jalaliReady], + ); + + const convertDateObjectToDayjs = useCallback( + (date: DateObject | null): Dayjs | null => { + if (!date) { + return null; + } + const monthValue = getMonthNumber(date.month as number | Month | undefined); + + if (jalaliReady) { + try { + const jalaliDate = dayjs( + `${date.year}/${monthValue}/${date.day}`, + 'jYYYY/jM/jD', + ); + const gregorianDate = dayjs(jalaliDate.toDate()); + if (gregorianDate.isValid()) { + return gregorianDate; + } + } catch (error) { + console.warn('Error converting Jalali to Gregorian:', error); + } + } + + try { + const gregorian = persianToGregorian(date.year, monthValue, date.day); + const converted = dayjs( + `${gregorian.year}-${String(gregorian.month).padStart(2, '0')}-${String( + gregorian.day, + ).padStart(2, '0')}`, + ); + return converted.isValid() ? converted : null; + } catch (error) { + console.error('Error in fallback conversion:', error); + return null; + } + }, + [jalaliReady], + ); Review Comment: The error handling in the conversion functions only logs to console (`console.warn`, `console.error`) but doesn't provide user feedback when date conversion fails. Consider adding proper error handling that can surface errors to the user, especially for the `convertDateObjectToDayjs` function which silently returns `null` on errors. ########## superset-frontend/src/explore/components/controls/DateFilterControl/components/PersianCalendarFrame.tsx: ########## @@ -0,0 +1,447 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { NO_TIME_RANGE, t } from '@superset-ui/core'; +import { css, styled } from '@apache-superset/core/ui'; +// eslint-disable-next-line no-restricted-imports +import { Button } from '@superset-ui/core/components/Button'; +import { Radio } from '@superset-ui/core/components/Radio'; +import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates'; +import type { Dayjs } from 'dayjs'; +import { + formatPersianDate, + getCurrentPersianDate, + gregorianToPersian, + persianToGregorian, +} from 'src/utils/persianCalendar'; +import { JalaliDatePicker } from './JalaliDatePicker'; + +type PersianCalendarRangeType = + | 'last_7_days' + | 'last_30_days' + | 'last_90_days' + | 'last_year' + | 'custom_range'; + +interface RangeDefinition { + key: PersianCalendarRangeType; + label: string; + labelFa: string; + timeRange?: string; +} + +const PERSIAN_RANGE_LABELS: Record<PersianCalendarRangeType, string> = { + last_7_days: '۷ روز گذشته', + last_30_days: '۳۰ روز گذشته', + last_90_days: '۹۰ روز گذشته', + last_year: 'یک سال گذشته', + custom_range: 'بازه سفارشی', +}; + +const RANGE_DEFINITIONS: RangeDefinition[] = [ + { + key: 'last_7_days', + label: t('Last 7 days'), + labelFa: PERSIAN_RANGE_LABELS.last_7_days, + timeRange: 'Last 7 days', + }, + { + key: 'last_30_days', + label: t('Last 30 days'), + labelFa: PERSIAN_RANGE_LABELS.last_30_days, + timeRange: 'Last 30 days', + }, + { + key: 'last_90_days', + label: t('Last 90 days'), + labelFa: PERSIAN_RANGE_LABELS.last_90_days, + timeRange: 'Last 90 days', + }, + { + key: 'last_year', + label: t('Last year'), + labelFa: PERSIAN_RANGE_LABELS.last_year, + timeRange: 'Last year', + }, + { + key: 'custom_range', + label: t('Custom range'), + labelFa: PERSIAN_RANGE_LABELS.custom_range, + }, +]; + +const RANGE_VALUE_TO_KEY = new Map( + RANGE_DEFINITIONS.filter(def => def.timeRange).map(def => [ + def.timeRange as string, + def.key, + ]), +); + +const RANGE_KEY_TO_VALUE = new Map( + RANGE_DEFINITIONS.filter(def => def.timeRange).map(def => [ + def.key, + def.timeRange as string, + ]), +); + +const DEFAULT_RANGE: PersianCalendarRangeType = 'last_7_days'; + +const PERSIAN_TEXT = { + title: 'فیلتر تقویم شمسی', + selectTimeRange: 'انتخاب بازه زمانی', + chooseCustomRange: 'انتخاب بازه دلخواه', + selectRangePlaceholder: 'بازه تاریخ را انتخاب کنید', + startToToday: 'تنظیم شروع روی امروز', + endToToday: 'تنظیم پایان روی امروز', + selectedRangeLabel: 'بازه انتخابشده (جلالی)', + currentDateLabel: 'تاریخ جلالی امروز', +}; + +const PERSIAN_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']; + +const toPersianDigits = (value: string) => + value.replace(/\d/g, digit => PERSIAN_DIGITS[Number(digit)]); + +const MIN_GREGORIAN_YEAR = 1700; + +interface FrameComponentProps { + onChange: (value: string) => void; + value?: string; +} + +const Container = styled.div<{ $isRTL: boolean }>` + ${({ theme, $isRTL }) => css` + padding: ${theme.padding}px; + direction: ${$isRTL ? 'rtl' : 'ltr'}; + text-align: ${$isRTL ? 'right' : 'left'}; + `} +`; + +const SectionTitle = styled.h3` + ${({ theme }) => css` + margin: 0 0 ${theme.marginSM}px; + font-size: ${theme.fontSize}px; + font-weight: ${theme.fontWeightStrong}; + `} +`; + +const SectionLabel = styled.div` + ${({ theme }) => css` + margin-bottom: ${theme.marginXS}px; + font-size: ${theme.fontSizeSM}px; + font-weight: ${theme.fontWeightStrong}; + `} +`; + +const SummaryCard = styled.div<{ $variant?: 'default' | 'info' }>` + ${({ theme, $variant = 'default' }) => css` + margin-top: ${theme.marginSM}px; + padding: ${theme.paddingSM}px; + border-radius: ${theme.borderRadius}px; + border: 1px solid ${ + $variant === 'info' ? theme.colorPrimaryBorder : theme.colorBorder + }; + background-color: ${ + $variant === 'info' ? theme.colorPrimaryBgHover : theme.colorBgContainer + }; + `} +`; + +const PickerActions = styled.div` + ${({ theme }) => css` + display: flex; + gap: ${theme.marginXS}px; + margin-top: ${theme.marginXS}px; + justify-content: flex-end; + flex-wrap: wrap; + `} +`; + +const RadioGroup = styled(Radio.Group)` + width: 100%; +`; + +const RadioList = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.marginXS}px; +`; + +export function PersianCalendarFrame({ + onChange, + value, +}: FrameComponentProps) { + const [selectedRange, setSelectedRange] = useState<PersianCalendarRangeType>( + DEFAULT_RANGE, + ); + const [customStartDate, setCustomStartDate] = useState<Dayjs | null>(null); + const [customEndDate, setCustomEndDate] = useState<Dayjs | null>(null); + const [currentPersianDate] = useState(getCurrentPersianDate()); + + const isRTL = useMemo(() => { + if (typeof document === 'undefined') { + return false; + } + const doc = document.documentElement; + if (doc?.dir === 'rtl' || doc?.lang?.startsWith('fa')) { + return true; + } + if (typeof navigator !== 'undefined' && navigator.language?.startsWith('fa')) { + return true; + } + return false; + }, []); + + const shouldUsePersianText = useMemo(() => { + if (typeof document !== 'undefined') { + const doc = document.documentElement; + if (doc?.lang?.startsWith('fa')) { + return true; + } + } + if (typeof navigator !== 'undefined' && navigator.language?.startsWith('fa')) { + return true; + } + return false; + }, []); Review Comment: The Persian/Farsi text detection logic is duplicated (lines 197-209 and 211-222). This code appears twice with nearly identical logic. Consider consolidating into a single computed value or extracting to a utility function. ########## superset-frontend/src/explore/components/controls/DateFilterControl/components/JalaliDatePicker.tsx: ########## @@ -0,0 +1,429 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { CSSProperties } from 'react'; +import DatePicker from 'react-multi-date-picker'; +import persian from 'react-date-object/calendars/persian'; +import persian_fa from 'react-date-object/locales/persian_fa'; +import DateObject, { type Month } from 'react-date-object'; +import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates'; +import type { Dayjs } from 'dayjs'; +import { useResizeDetector } from 'react-resize-detector'; +import { + gregorianToPersian, + persianToGregorian, +} from 'src/utils/persianCalendar'; + +type BasePickerProps = { + placeholder?: string; + style?: CSSProperties; + placement?: 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; + forceRTL?: boolean; + minYear?: number; + maxYear?: number; +}; + +type SingleModeProps = BasePickerProps & { + mode?: 'single'; + value: Dayjs | null; + onChange: (date: Dayjs | null) => void; +}; + +type RangeModeProps = BasePickerProps & { + mode: 'range'; + value: [Dayjs | null, Dayjs | null]; + onChange: (range: [Dayjs | null, Dayjs | null]) => void; +}; + +export type JalaliDatePickerProps = SingleModeProps | RangeModeProps; + +const getMonthNumber = (month?: number | Month): number => { + if (!month) { + return 1; + } + return typeof month === 'number' ? month : month.number; +}; + +const buildDateObject = (year: number, month: number, day: number) => + new DateObject({ + calendar: persian, + locale: persian_fa, + year, + month, + day, + }); + +const detectRTL = () => { + if (typeof document === 'undefined') { + return false; + } + const doc = document.documentElement; + if (doc?.dir === 'rtl' || doc?.lang?.startsWith('fa')) { + return true; + } + if (typeof navigator !== 'undefined') { + return navigator.language?.startsWith('fa') ?? false; + } + return false; +}; + +/** + * JalaliDatePicker component that displays Persian calendar but returns Gregorian dates. + * Supports both single-date and range selection modes. + */ +export function JalaliDatePicker(props: JalaliDatePickerProps) { + const { + placeholder = 'تاریخ را انتخاب کنید', + style, + placement = 'bottomRight', + forceRTL, + minYear = 782, + maxYear = 1500, + } = props; + + const isRangeMode = props.mode === 'range'; + const singleValue = !isRangeMode ? (props.value as Dayjs | null) : null; + const rangeValue = isRangeMode + ? (props.value as [Dayjs | null, Dayjs | null]) + : null; + const [jalaliReady, setJalaliReady] = useState(false); + const isRTL = forceRTL ?? detectRTL(); + const { ref: resizeRef, width: containerWidth } = useResizeDetector({ + refreshMode: 'debounce', + refreshRate: 100, + }); + + useEffect(() => { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const jalaliPlugin = require('dayjs-jalali'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const jalaliDayjsModule = jalaliPlugin.default || jalaliPlugin; + dayjs.extend(jalaliDayjsModule); + setJalaliReady(true); + } catch (error) { + console.warn('Failed to load dayjs-jalali plugin:', error); + setJalaliReady(false); + } + }, []); Review Comment: The `dayjs-jalali` plugin is loaded dynamically with `require()` but never unloaded. This means calling `dayjs.extend()` multiple times could have unintended side effects if the component is mounted and unmounted repeatedly. Consider checking if the plugin is already extended before calling `extend()`, or ensure this side effect is acceptable. ########## superset-frontend/src/explore/components/controls/DateFilterControl/components/PersianCalendarFrame.tsx: ########## @@ -0,0 +1,447 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { NO_TIME_RANGE, t } from '@superset-ui/core'; +import { css, styled } from '@apache-superset/core/ui'; +// eslint-disable-next-line no-restricted-imports +import { Button } from '@superset-ui/core/components/Button'; +import { Radio } from '@superset-ui/core/components/Radio'; +import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates'; +import type { Dayjs } from 'dayjs'; +import { + formatPersianDate, + getCurrentPersianDate, + gregorianToPersian, + persianToGregorian, +} from 'src/utils/persianCalendar'; +import { JalaliDatePicker } from './JalaliDatePicker'; + +type PersianCalendarRangeType = + | 'last_7_days' + | 'last_30_days' + | 'last_90_days' + | 'last_year' + | 'custom_range'; + +interface RangeDefinition { + key: PersianCalendarRangeType; + label: string; + labelFa: string; + timeRange?: string; +} + +const PERSIAN_RANGE_LABELS: Record<PersianCalendarRangeType, string> = { + last_7_days: '۷ روز گذشته', + last_30_days: '۳۰ روز گذشته', + last_90_days: '۹۰ روز گذشته', + last_year: 'یک سال گذشته', + custom_range: 'بازه سفارشی', +}; + +const RANGE_DEFINITIONS: RangeDefinition[] = [ + { + key: 'last_7_days', + label: t('Last 7 days'), + labelFa: PERSIAN_RANGE_LABELS.last_7_days, + timeRange: 'Last 7 days', + }, + { + key: 'last_30_days', + label: t('Last 30 days'), + labelFa: PERSIAN_RANGE_LABELS.last_30_days, + timeRange: 'Last 30 days', + }, + { + key: 'last_90_days', + label: t('Last 90 days'), + labelFa: PERSIAN_RANGE_LABELS.last_90_days, + timeRange: 'Last 90 days', + }, + { + key: 'last_year', + label: t('Last year'), + labelFa: PERSIAN_RANGE_LABELS.last_year, + timeRange: 'Last year', + }, + { + key: 'custom_range', + label: t('Custom range'), + labelFa: PERSIAN_RANGE_LABELS.custom_range, + }, +]; + +const RANGE_VALUE_TO_KEY = new Map( + RANGE_DEFINITIONS.filter(def => def.timeRange).map(def => [ + def.timeRange as string, + def.key, + ]), +); + +const RANGE_KEY_TO_VALUE = new Map( + RANGE_DEFINITIONS.filter(def => def.timeRange).map(def => [ + def.key, + def.timeRange as string, + ]), +); + +const DEFAULT_RANGE: PersianCalendarRangeType = 'last_7_days'; + +const PERSIAN_TEXT = { + title: 'فیلتر تقویم شمسی', + selectTimeRange: 'انتخاب بازه زمانی', + chooseCustomRange: 'انتخاب بازه دلخواه', + selectRangePlaceholder: 'بازه تاریخ را انتخاب کنید', + startToToday: 'تنظیم شروع روی امروز', + endToToday: 'تنظیم پایان روی امروز', + selectedRangeLabel: 'بازه انتخابشده (جلالی)', + currentDateLabel: 'تاریخ جلالی امروز', +}; + +const PERSIAN_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']; + +const toPersianDigits = (value: string) => + value.replace(/\d/g, digit => PERSIAN_DIGITS[Number(digit)]); + +const MIN_GREGORIAN_YEAR = 1700; Review Comment: The magic number `1700` for minimum Gregorian year lacks explanation. This appears to be a threshold to distinguish between Gregorian and Jalali year values (since Jalali years are currently in the 1400s), but this assumption could break in the future. Add a constant with a descriptive name and comment explaining why this value is used, or consider a more robust way to distinguish between the two calendar systems. ```suggestion /** * Threshold year to distinguish between Gregorian and Jalali (Persian) years. * Jalali years are currently in the 1400s, while Gregorian years are above 1700. * This threshold is used to infer the calendar system from a year value. * If the calendars' year ranges ever overlap, this logic should be revisited. */ const GREGORIAN_YEAR_THRESHOLD_FOR_CALENDAR_TYPE = 1700; ``` ########## superset-frontend/src/explore/components/controls/DateFilterControl/components/JalaliDatePicker.tsx: ########## @@ -0,0 +1,429 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { CSSProperties } from 'react'; +import DatePicker from 'react-multi-date-picker'; +import persian from 'react-date-object/calendars/persian'; +import persian_fa from 'react-date-object/locales/persian_fa'; +import DateObject, { type Month } from 'react-date-object'; +import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates'; +import type { Dayjs } from 'dayjs'; +import { useResizeDetector } from 'react-resize-detector'; +import { + gregorianToPersian, + persianToGregorian, +} from 'src/utils/persianCalendar'; + +type BasePickerProps = { + placeholder?: string; + style?: CSSProperties; + placement?: 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; + forceRTL?: boolean; + minYear?: number; + maxYear?: number; +}; + +type SingleModeProps = BasePickerProps & { + mode?: 'single'; + value: Dayjs | null; + onChange: (date: Dayjs | null) => void; +}; + +type RangeModeProps = BasePickerProps & { + mode: 'range'; + value: [Dayjs | null, Dayjs | null]; + onChange: (range: [Dayjs | null, Dayjs | null]) => void; +}; + +export type JalaliDatePickerProps = SingleModeProps | RangeModeProps; + +const getMonthNumber = (month?: number | Month): number => { + if (!month) { + return 1; + } + return typeof month === 'number' ? month : month.number; +}; + +const buildDateObject = (year: number, month: number, day: number) => + new DateObject({ + calendar: persian, + locale: persian_fa, + year, + month, + day, + }); + +const detectRTL = () => { + if (typeof document === 'undefined') { + return false; + } + const doc = document.documentElement; + if (doc?.dir === 'rtl' || doc?.lang?.startsWith('fa')) { + return true; + } + if (typeof navigator !== 'undefined') { + return navigator.language?.startsWith('fa') ?? false; + } + return false; +}; Review Comment: The RTL detection logic is duplicated in both `PersianCalendarFrame` (lines 197-209) and `JalaliDatePicker` (lines 72-84). Consider extracting this into a shared utility function in `persianCalendar.ts` to follow the DRY (Don't Repeat Yourself) principle. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
