diff --git a/src/components/DashboardPage/DashboardPage.tsx b/src/components/DashboardPage/DashboardPage.tsx index 610e36085..a94a6a183 100644 --- a/src/components/DashboardPage/DashboardPage.tsx +++ b/src/components/DashboardPage/DashboardPage.tsx @@ -46,7 +46,6 @@ export const DashboardPage = ({ user, ssrTime, defaultPresetFallback }: External queryState, queryString, setTagsFilterOutside, - setEstimateFilter, setStarredFilter, setWatchingFilter, setFulltextFilter, @@ -174,7 +173,6 @@ export const DashboardPage = ({ user, ssrTime, defaultPresetFallback }: External preset={currentPreset} presets={userFilters} onSearchChange={setFulltextFilter} - onEstimateChange={setEstimateFilter} onStarredChange={setStarredFilter} onWatchingChange={setWatchingFilter} onPresetChange={setPreset} diff --git a/src/components/Estimate/Estimate.i18n/en.json b/src/components/Estimate/Estimate.i18n/en.json index e31666c0b..ce11ff4b5 100644 --- a/src/components/Estimate/Estimate.i18n/en.json +++ b/src/components/Estimate/Estimate.i18n/en.json @@ -1,8 +1,9 @@ { "Year title": "Year", - "Year clue": "If you are selecting only year it means goal will be full year long", + "Year clue": "If you are selecting only year it means the deadline will be the end of the year. For example: {end}.", "Quarter title": "Quarter", - "Quarter clue": "This is the date shortcut: Q1, Q2, Q3, Q4. Currently is", + "Quarter clue": "This is the date shortcut: Q1, Q2, Q3, Q4. Currently is {quarter}. End is {end}.", "Date title": "Strict date", + "Date clue": "Or you can choose a date if you have strict deadline.", "Date is past": "Selected date is past" } diff --git a/src/components/Estimate/Estimate.i18n/index.tsx b/src/components/Estimate/Estimate.i18n/index.ts similarity index 100% rename from src/components/Estimate/Estimate.i18n/index.tsx rename to src/components/Estimate/Estimate.i18n/index.ts diff --git a/src/components/Estimate/Estimate.i18n/ru.json b/src/components/Estimate/Estimate.i18n/ru.json index ffc2dcd56..92095cb06 100644 --- a/src/components/Estimate/Estimate.i18n/ru.json +++ b/src/components/Estimate/Estimate.i18n/ru.json @@ -1,8 +1,9 @@ { "Year title": "Год", - "Year clue": "Если Вы выберите только год, цель будет до конца выбранного года.", + "Year clue": "Если выбрать только год, то датой будет выбран конец года, например: {end}.", "Quarter title": "Квартал", - "Quarter clue": "Это кварталы выбранного года: Q1, Q2, Q3, Q4. Сейчас", + "Quarter clue": "Можно указать квартал: Q1, Q2, Q3, Q4. Сейчас {quarter}, заканчивается {end}.", "Date title": "Дата", + "Date clue": "Или можно указать точную дату вручную, если есть строгий дедлайн.", "Date is past": "Выбранная дата уже прошла" } diff --git a/src/components/Estimate/Estimate.tsx b/src/components/Estimate/Estimate.tsx index 8b453b5f8..b28c1aee5 100644 --- a/src/components/Estimate/Estimate.tsx +++ b/src/components/Estimate/Estimate.tsx @@ -1,55 +1,94 @@ -import React, { ComponentProps, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { gapXs, gapS, warn0 } from '@taskany/colors'; +import { nullable, Text } from '@taskany/bricks'; +import { IconExclamationSmallOutline } from '@taskany/icons'; -import { createDateRange, isPastDate, getQuarterFromDate, getYearFromDate } from '../../utils/dateTime'; +import { + createDateRange, + isPastDate, + getQuarterFromDate, + getYearFromDate, + getDateString, + getRelativeQuarterRange, + createLocaleDate, + createYearRange, +} from '../../utils/dateTime'; +import { useLocale } from '../../hooks/useLocale'; import { DateType, DateRange } from '../../types/date'; -import { EstimateDate } from '../EstimateDate'; +import { EstimateDate } from '../EstimateDate/EstimateDate'; import { EstimateQuarter } from '../EstimateQuarter'; import { EstimateYear } from '../EstimateYear'; -import { Option } from '../../types/estimate'; -import { EstimatePopup } from '../EstimatePopup'; import { tr } from './Estimate.i18n'; -type EstimateValue = { +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${gapS}; +`; + +const StyledWarningWrapper = styled.div` + display: flex; + align-items: center; + gap: ${gapXs}; +`; + +const StyledIconExclamationSmallOutline = styled(IconExclamationSmallOutline)` + border-radius: 100%; + align-items: center; + display: block; + background: ${warn0}; + font-size: 0; +`; + +export type EstimateValue = { type: DateType; range: DateRange; }; export interface EstimateProps { - mask: string; - placeholder: string; value?: EstimateValue; - placement?: ComponentProps['placement']; - error?: { message?: string }; - renderTrigger: (values: { onClick: () => void }) => ReactNode; onChange?: (value?: EstimateValue) => void; - onClose?: () => void; } -export const Estimate: React.FC = ({ - mask, - placeholder, - value, - placement, - error, - renderTrigger, - onChange, - onClose, -}) => { - const [readOnly, setReadOnly] = useState({ year: true, quarter: true, date: true }); +export const getReadOnlyFields = (type: EstimateValue['type']) => { + if (type === 'Strict') { + return { year: true, quarter: true, date: false }; + } + if (type === 'Quarter') { + return { year: false, quarter: false, date: true }; + } + return { year: false, quarter: true, date: true }; +}; + +const currentQuarterRange = getRelativeQuarterRange('current'); +const currentQuarter = getQuarterFromDate(currentQuarterRange.end); +const currentYearRange = createYearRange(getYearFromDate(new Date())); + +export const Estimate = React.forwardRef(({ value, onChange }, ref) => { + const locale = useLocale(); + const [readOnly, setReadOnly] = useState( + value ? getReadOnlyFields(value.type) : { year: true, quarter: true, date: true }, + ); const [year, setYear] = useState(value ? getYearFromDate(value.range.end) : undefined); const [quarter, setQuarter] = useState(value ? getQuarterFromDate(value.range.end) : undefined); const [date, setDate] = useState(value ? value.range.end : undefined); - const onOpen = useCallback(() => { - if (value?.type === 'Strict') { - setReadOnly({ year: true, quarter: true, date: false }); - } else if (value?.type === 'Quarter') { - setReadOnly({ year: false, quarter: false, date: true }); - } else { - setReadOnly({ year: false, quarter: true, date: true }); - } - }, [value]); + const onChangeHandler = useCallback( + (newValue?: EstimateValue) => { + const oldStartDate = value && value.range.start ? getDateString(value.range.start) : null; + const newStartDate = newValue && newValue.range.start ? getDateString(newValue.range.start) : null; + + const oldEndDate = value ? getDateString(value.range.end) : null; + const newEndDate = newValue ? getDateString(newValue.range.end) : null; + + if (value?.type !== newValue?.type || oldStartDate !== newStartDate || oldEndDate !== newEndDate) { + onChange?.(newValue); + } + }, + [value, onChange], + ); useEffect(() => { if (readOnly.quarter && readOnly.year && readOnly.date) { @@ -57,7 +96,7 @@ export const Estimate: React.FC = ({ } if (!readOnly.quarter) { - onChange?.( + onChangeHandler?.( year ? { range: createDateRange(year, quarter), @@ -66,7 +105,7 @@ export const Estimate: React.FC = ({ : undefined, ); } else if (!readOnly.year) { - onChange?.( + onChangeHandler?.( year ? { range: createDateRange(year), @@ -75,7 +114,7 @@ export const Estimate: React.FC = ({ : undefined, ); } else { - onChange?.( + onChangeHandler?.( date ? { range: { end: date }, @@ -84,57 +123,7 @@ export const Estimate: React.FC = ({ : undefined, ); } - }, [year, quarter, date, onChange, readOnly]); - - const options = useMemo( - () => [ - { - title: tr('Year title'), - clue: tr('Year clue'), - renderItem: (option: Option) => ( - - ), - }, - { - title: tr('Quarter title'), - clue: `${tr('Quarter clue')} ${getQuarterFromDate(new Date())}.`, - renderItem: (option: Option) => ( - - ), - }, - { - title: tr('Date title'), - clue: null, - renderItem: (option: Option) => ( - - ), - }, - ], - [date, quarter, year, mask, placeholder, readOnly], - ); + }, [year, quarter, date, onChangeHandler, readOnly]); const warning = useMemo( () => (value && isPastDate(value.range.end) ? { message: tr('Date is past') } : undefined), @@ -142,15 +131,48 @@ export const Estimate: React.FC = ({ ); return ( - renderItem?.(option)} - renderTrigger={renderTrigger} - onClose={onClose} - onOpen={onOpen} - /> + + {nullable(warning, (warn) => ( + + + + {warn.message} + + + ))} + + + + ); -}; +}); diff --git a/src/components/EstimateDate/EstimateDate.i18n/en.json b/src/components/EstimateDate/EstimateDate.i18n/en.json new file mode 100644 index 000000000..4ca519faa --- /dev/null +++ b/src/components/EstimateDate/EstimateDate.i18n/en.json @@ -0,0 +1,4 @@ +{ + "Date input mask": "99/99/9999", + "Date input mask placeholder": "mm/dd/yyyy" +} diff --git a/src/components/EstimateDate/EstimateDate.i18n/index.ts b/src/components/EstimateDate/EstimateDate.i18n/index.ts new file mode 100644 index 000000000..5c148475b --- /dev/null +++ b/src/components/EstimateDate/EstimateDate.i18n/index.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +// Do not edit, use generator to update +import { i18n, fmt, I18nLangSet } from 'easy-typed-intl'; +import getLang from '../../../utils/getLang'; + +import ru from './ru.json'; +import en from './en.json'; + +export type I18nKey = keyof typeof ru & keyof typeof en; +type I18nLang = 'ru' | 'en'; + +const keyset: I18nLangSet = {}; + +keyset['ru'] = ru; +keyset['en'] = en; + +export const tr = i18n(keyset, fmt, getLang); diff --git a/src/components/EstimateDate/EstimateDate.i18n/ru.json b/src/components/EstimateDate/EstimateDate.i18n/ru.json new file mode 100644 index 000000000..ea80edc76 --- /dev/null +++ b/src/components/EstimateDate/EstimateDate.i18n/ru.json @@ -0,0 +1,4 @@ +{ + "Date input mask": "99.99.9999", + "Date input mask placeholder": "dd.mm.yyyy" +} diff --git a/src/components/EstimateDate.tsx b/src/components/EstimateDate/EstimateDate.tsx similarity index 80% rename from src/components/EstimateDate.tsx rename to src/components/EstimateDate/EstimateDate.tsx index cc4c5d633..29ab925cd 100644 --- a/src/components/EstimateDate.tsx +++ b/src/components/EstimateDate/EstimateDate.tsx @@ -3,16 +3,14 @@ import { IconXSolid } from '@taskany/icons'; import { useState, useRef, useCallback, Dispatch, SetStateAction, useEffect } from 'react'; import InputMask from 'react-input-mask'; -import { useLocale } from '../hooks/useLocale'; -import { currentLocaleDate, parseLocaleDate, createLocaleDate } from '../utils/dateTime'; -import { Option } from '../types/estimate'; +import { useLocale } from '../../hooks/useLocale'; +import { currentLocaleDate, parseLocaleDate, createLocaleDate } from '../../utils/dateTime'; +import { Option } from '../../types/estimate'; +import { EstimateOption } from '../EstimateOption'; -import { EstimateOption } from './EstimateOption'; +import { tr } from './EstimateDate.i18n'; -interface EstimateDateProps { - mask: string; - placeholder: string; - option: Omit; +interface EstimateDateProps extends Option { value?: Date; readOnly?: boolean; onChange?: (value?: Date) => void; @@ -31,14 +29,7 @@ const isDateFullyFilled = (date: string) => { return cleanedDate.length === expectedLength; }; -export const EstimateDate: React.FC = ({ - mask, - placeholder, - option, - readOnly, - onChange, - setReadOnly, -}) => { +export const EstimateDate: React.FC = ({ title, clue, readOnly, onChange, setReadOnly }) => { const locale = useLocale(); const currentDate = currentLocaleDate({ locale }); const [fullDate, setFullDate] = useState(currentDate); @@ -104,11 +95,17 @@ export const EstimateDate: React.FC = ({ return ( ( - + {/* @ts-ignore incorrect type in react-input-mask */} {(props) => ( } {...props} /> diff --git a/src/components/EstimateFilter.tsx b/src/components/EstimateFilter.tsx index 98b46a4f8..35ee9b62c 100644 --- a/src/components/EstimateFilter.tsx +++ b/src/components/EstimateFilter.tsx @@ -1,54 +1,61 @@ -import { FiltersDropdown } from '@taskany/bricks'; -import { FC, useMemo } from 'react'; -import { Estimate as EstimateType } from '@prisma/client'; +import { Tab } from '@taskany/bricks'; +import { ComponentProps, FC, useCallback, useMemo } from 'react'; -import { DateRange, QuartersKeys } from '../types/date'; -import { createDateRange, encodeUrlDateRange, getRelativeQuarterRange } from '../utils/dateTime'; +import { useLocale } from '../hooks/useLocale'; +import { encodeUrlDateRange, decodeUrlDateRange, getDateTypeFromRange, formateEstimate } from '../utils/dateTime'; -type Estimate = { q: EstimateType['q']; y: EstimateType['y'] }; +import { FilterTabLabel } from './FilterTabLabel'; +import { Estimate, EstimateValue } from './Estimate/Estimate'; -const getDateRangeFrom = ({ q, y }: Estimate): DateRange => createDateRange(Number(y), q as QuartersKeys); +export const decodeEstimateFromUrl = (value: string): EstimateValue | undefined => { + if (!value) { + return undefined; + } + + const range = decodeUrlDateRange(value); -const estimateTitle = (estimate: Estimate) => { - if (!estimate.q) { - return estimate.y; + if (!range) { + return undefined; } - return `${estimate.q}/${estimate.y}`; -}; -const getEstimateAliases = (): { data: string; id: string }[] => { - return [ - { - id: encodeUrlDateRange(getRelativeQuarterRange('current')), - data: '@current', - }, - { - id: encodeUrlDateRange(getRelativeQuarterRange('prev')), - data: '@previous', - }, - { - id: encodeUrlDateRange(getRelativeQuarterRange('next')), - data: '@next', - }, - ]; + return { + range, + type: getDateTypeFromRange(range), + }; }; export const EstimateFilter: FC<{ text: string; - value: string[]; - estimates?: Estimate[]; + value?: string[]; onChange: (value: string[]) => void; -}> = ({ text, value, estimates, onChange }) => { - const items = useMemo( - () => [ - ...getEstimateAliases(), - ...(estimates?.map((estimate) => ({ - id: encodeUrlDateRange(getDateRangeFrom(estimate)), - data: estimateTitle(estimate), - })) ?? []), - ], - [estimates], +}> = ({ text, value = [], onChange }) => { + const locale = useLocale(); + + const onChangeHandler = useCallback( + (value?: ComponentProps['value']) => { + onChange(value ? [encodeUrlDateRange(value.range)] : []); + }, + [onChange], ); - return ; + const { estiamteValue, estiamteLabel } = useMemo(() => { + const estiamteValue = decodeEstimateFromUrl(value[0]); + return { + estiamteValue, + estiamteLabel: estiamteValue + ? [ + formateEstimate(estiamteValue.range.end, { + locale, + type: estiamteValue.type, + }), + ] + : [], + }; + }, [value, locale]); + + return ( + }> + + + ); }; diff --git a/src/components/EstimateOption.tsx b/src/components/EstimateOption.tsx index 3ac0081f3..f786665ce 100644 --- a/src/components/EstimateOption.tsx +++ b/src/components/EstimateOption.tsx @@ -12,7 +12,7 @@ const StyledWrapper = styled.div` const StyledContent = styled.div` display: flex; - align-items: end; + align-items: center; gap: ${gapS}; `; @@ -38,7 +38,7 @@ export const EstimateOption: React.FC = ({ title, clue, rea {nullable(clue, (c) => ( - + {c} ))} diff --git a/src/components/EstimatePopup.tsx b/src/components/EstimatePopup.tsx index fe4eefce5..b350cf4df 100644 --- a/src/components/EstimatePopup.tsx +++ b/src/components/EstimatePopup.tsx @@ -1,16 +1,9 @@ -import { useClickOutside, Popup, nullable, Text } from '@taskany/bricks'; -import { gapS, danger10, warn0, gapXs, radiusM } from '@taskany/colors'; -import { ReactNode, useRef, useState, useCallback, ComponentProps } from 'react'; +import React, { ReactNode, useRef, useState, useCallback, ComponentProps } from 'react'; import styled from 'styled-components'; -import { IconExclamationSmallOutline } from '@taskany/icons'; +import { useClickOutside, Popup, nullable } from '@taskany/bricks'; +import { gapS, danger10, radiusM } from '@taskany/colors'; -import { Option } from '../types/estimate'; - -const StyledWrapper = styled.div` - display: flex; - flex-direction: column; - gap: ${gapS}; -`; +import { EstimateProps, Estimate } from './Estimate/Estimate'; const StyledErrorTrigger = styled.div` position: absolute; @@ -23,113 +16,73 @@ const StyledErrorTrigger = styled.div` z-index: 1; `; -const StyledWarningCircle = styled.div` - width: 12px; - height: 12px; - background: ${warn0}; - border-radius: 100%; - display: flex; - align-items: center; -`; - -const StyledWarningWrapper = styled.div` - display: flex; - align-items: center; - gap: ${gapXs}; -`; - const StyledPopup = styled(Popup)` border-radius: ${radiusM}; padding: ${gapS}; `; -interface EstimateOption extends Option { - renderItem?: (option: Option) => ReactNode; -} -interface EstimatePopupProps { - renderItem: (option: EstimateOption) => ReactNode; +export interface EstimatePopupProps extends EstimateProps { + error?: { message?: string }; renderTrigger: (values: { onClick: () => void }) => ReactNode; + placement?: ComponentProps['placement']; onClose?: () => void; onOpen?: () => void; - items: EstimateOption[]; - placement?: ComponentProps['placement']; - error?: { message?: string }; - warning?: { message?: string }; } -export const EstimatePopup: React.FC = ({ - renderItem, - renderTrigger, - onClose, - onOpen, - items, - placement, - error, - warning, -}) => { - const triggerRef = useRef(null); - const popupContentRef = useRef(null); - const [visible, setVisible] = useState(false); +export const EstimatePopup = React.forwardRef( + ({ renderTrigger, onClose, onOpen, placement, error, value, onChange }, ref) => { + const triggerRef = useRef(null); + const popupContentRef = useRef(null); + const [visible, setVisible] = useState(false); + + const errorRef = useRef(null); + const [errorVisible, setErrorVisible] = useState(false); - const errorRef = useRef(null); - const [errorVisible, setErrorVisible] = useState(false); + const onMouseEnter = useCallback(() => setErrorVisible(true), []); + const onMouseLeave = useCallback(() => setErrorVisible(false), []); - const onMouseEnter = useCallback(() => setErrorVisible(true), []); - const onMouseLeave = useCallback(() => setErrorVisible(false), []); + const onToggleVisible = useCallback(() => { + setVisible((prev) => { + if (prev) { + onClose?.(); + } else { + onOpen?.(); + } + return !prev; + }); + }, [onClose, onOpen]); - const onToggleVisible = useCallback(() => { - setVisible((prev) => { - if (prev) { + useClickOutside(triggerRef, (e) => { + if (!popupContentRef.current?.contains(e.target as Node)) { + setVisible(false); onClose?.(); - } else { - onOpen?.(); } - return !prev; }); - }, [onClose, onOpen]); - - useClickOutside(triggerRef, (e) => { - if (!popupContentRef.current?.contains(e.target as Node)) { - setVisible(false); - onClose?.(); - } - }); - return ( - <> - {nullable(error, (err) => ( - <> - - - {err.message} - - - ))} + return ( +
+ {nullable(error, (err) => ( + <> + + + {err.message} + + + ))} -
{renderTrigger({ onClick: onToggleVisible })}
+
{renderTrigger({ onClick: onToggleVisible })}
- - - {nullable(warning, (warn) => ( - - - - - - {warn.message} - - - ))} - {items.map((item) => renderItem?.(item))} - - - - ); -}; + + + +
+ ); + }, +); diff --git a/src/components/EstimateQuarter.tsx b/src/components/EstimateQuarter.tsx index 92974c062..4f1886ba7 100644 --- a/src/components/EstimateQuarter.tsx +++ b/src/components/EstimateQuarter.tsx @@ -14,8 +14,12 @@ const StyledQuarters = styled.div` gap: ${gapXs}; `; -interface EstimateQuarterProps { - option: Option; +const StyledButton = styled(Button)` + display: flex; + justify-content: center; +`; + +interface EstimateQuarterProps extends Option { value?: QuartersKeys; readOnly?: boolean; onChange?: (value?: QuartersKeys) => void; @@ -31,7 +35,8 @@ interface EstimateQuarterProps { const quartersList = Object.values(Quarters); export const EstimateQuarter: React.FC = ({ - option, + title, + clue, value = null, readOnly, onChange, @@ -72,15 +77,15 @@ export const EstimateQuarter: React.FC = ({ return ( ( {quartersList.map((quarter) => { return ( -