fix: relative date picker calendar display (#21895)
Part of https://github.com/twentyhq/twenty/issues/19739#issuecomment-4652034526 (Bug 1-3). Maybe it feels like theses bugs are not actually bugs, but we can maybe say it as UX improvements: specially needed in case when an user will choose any past options. ### Bug 1: calendar open on wrong month With Is Relative (e.g. Past 1 Quarter), the calendar opened on today’s month instead of the range start. After the fix, it now opens on the first month of the filtered range. **Testing:** View filter → Date field → Is Relative → Past 1 Quarter. Calendar opens on January (range start), not today’s month https://github.com/user-attachments/assets/8849d00a-4d5c-4f8a-8d31-3a62535eb311 ### Bug 2: Dates not highlighted Ranges older than ~2 months (e.g. Q1 when today is June) showed no highlighted days. Highlighting now covers the full resolved range. **Testing:** Same setup: past 1 Quarter on a date when Q1 is outside the old 2‑month window. Jan 1 - Mar 31 will highlight. https://github.com/user-attachments/assets/d21e2272-c923-4493-80ff-bdf4228842b1 ### Bug 3: No month navigation Relative mode only showed Past - 1 - Quarter controls with no way to browse months. Now see the new arrows move through months without changing the filter. <img width="377" height="455" alt="Screenshot 2026-06-20 181107" src="https://github.com/user-attachments/assets/eb51feb9-af10-489a-b166-8b8d6c642e05" /> > [!NOTE] > 1. We can't do the fixes by one by one, i have to fix them within one PR because all the fixes are inter-related, like we can't test the bug 1 fix alone without implementing bug 3. > 2. Bug 4 will be done in a separate PR which is actually the issue #19739. See https://github.com/twentyhq/twenty/issues/19739#issuecomment-4652034526 for better understanding. > 3. If you see the screen recordings, they are actually done with the alignment fixes from #21881 . So without that changes you will see the alignmemt issues in the calendar grid in your local. --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
committed by
GitHub
parent
6e2df0654b
commit
b3e39e2198
+3
-7
@@ -4,12 +4,12 @@ import { useGetDefaultFieldMetadataItemForFilter } from '@/object-record/advance
|
||||
import { useSetRecordFilterUsedInAdvancedFilterDropdownRow } from '@/object-record/advanced-filter/hooks/useSetRecordFilterUsedInAdvancedFilterDropdownRow';
|
||||
import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext';
|
||||
import { getAdvancedFilterAddFilterRuleSelectDropdownId } from '@/object-record/advanced-filter/utils/getAdvancedFilterAddFilterRuleSelectDropdownId';
|
||||
import { getDefaultAdvancedFilterOperand } from '@/object-record/advanced-filter/utils/getDefaultAdvancedFilterOperand';
|
||||
import { useUpsertRecordFilterGroup } from '@/object-record/record-filter-group/hooks/useUpsertRecordFilterGroup';
|
||||
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
|
||||
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { getDefaultSubFieldNameForCompositeFilterableFieldType } from '@/object-record/record-filter/utils/getDefaultSubFieldNameForCompositeFilterableFieldType';
|
||||
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
@@ -77,9 +77,7 @@ export const AdvancedFilterAddFilterRuleSelect = ({
|
||||
id: v4(),
|
||||
fieldMetadataId: defaultFieldMetadataItemForFilter.id,
|
||||
type: filterType,
|
||||
operand: getRecordFilterOperands({
|
||||
filterType,
|
||||
})[0],
|
||||
operand: getDefaultAdvancedFilterOperand({ filterType }),
|
||||
value: '',
|
||||
displayValue: '',
|
||||
recordFilterGroupId: recordFilterGroup.id,
|
||||
@@ -126,9 +124,7 @@ export const AdvancedFilterAddFilterRuleSelect = ({
|
||||
id: v4(),
|
||||
fieldMetadataId: defaultFieldMetadataItemForFilter.id,
|
||||
type: filterType,
|
||||
operand: getRecordFilterOperands({
|
||||
filterType,
|
||||
})[0],
|
||||
operand: getDefaultAdvancedFilterOperand({ filterType }),
|
||||
value: '',
|
||||
displayValue: '',
|
||||
recordFilterGroupId: newRecordFilterGroupId,
|
||||
|
||||
+7
-7
@@ -1,4 +1,5 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { getDefaultAdvancedFilterOperand } from '@/object-record/advanced-filter/utils/getDefaultAdvancedFilterOperand';
|
||||
import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue';
|
||||
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
|
||||
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
|
||||
@@ -9,7 +10,6 @@ import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object
|
||||
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
|
||||
@@ -65,20 +65,20 @@ export const useApplyAdvancedFilterSourceField = () => {
|
||||
|
||||
const filterType = getFilterTypeFromFieldType(sourceFieldMetadataItem.type);
|
||||
|
||||
const firstOperand = getRecordFilterOperands({
|
||||
const defaultOperand = getDefaultAdvancedFilterOperand({
|
||||
filterType,
|
||||
subFieldName: null,
|
||||
})?.[0];
|
||||
});
|
||||
|
||||
if (!isDefined(firstOperand)) {
|
||||
if (!isDefined(defaultOperand)) {
|
||||
throw new Error(`No valid operand found for filter type: ${filterType}`);
|
||||
}
|
||||
|
||||
setSelectedOperandInDropdown(firstOperand);
|
||||
setSelectedOperandInDropdown(defaultOperand);
|
||||
|
||||
const { value, displayValue } = getInitialFilterValue(
|
||||
filterType,
|
||||
firstOperand,
|
||||
defaultOperand,
|
||||
);
|
||||
|
||||
const existingRecordFilter = currentRecordFilters.find(
|
||||
@@ -89,7 +89,7 @@ export const useApplyAdvancedFilterSourceField = () => {
|
||||
id: recordFilterId,
|
||||
fieldMetadataId: sourceFieldMetadataItem.id,
|
||||
displayValue,
|
||||
operand: firstOperand,
|
||||
operand: defaultOperand,
|
||||
value,
|
||||
recordFilterGroupId: existingRecordFilter?.recordFilterGroupId,
|
||||
positionInRecordFilterGroup:
|
||||
|
||||
+1
@@ -144,6 +144,7 @@ export const AdvancedFilterSidePanelValueFormInput = ({
|
||||
defaultValue={recordFilter.value}
|
||||
onChange={handleRelativeDateFilterChange}
|
||||
readonly={readonly}
|
||||
isDateTimeField={recordFilter.type === FieldMetadataType.DATE_TIME}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { getDefaultAdvancedFilterOperand } from '@/object-record/advanced-filter/utils/getDefaultAdvancedFilterOperand';
|
||||
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
|
||||
describe('getDefaultAdvancedFilterOperand', () => {
|
||||
it('should default DATE fields to IS_RELATIVE', () => {
|
||||
expect(getDefaultAdvancedFilterOperand({ filterType: 'DATE' })).toBe(
|
||||
ViewFilterOperand.IS_RELATIVE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should default DATE_TIME fields to IS_RELATIVE', () => {
|
||||
expect(getDefaultAdvancedFilterOperand({ filterType: 'DATE_TIME' })).toBe(
|
||||
ViewFilterOperand.IS_RELATIVE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep the first available operand for non-date fields', () => {
|
||||
const filterType = 'TEXT';
|
||||
|
||||
expect(getDefaultAdvancedFilterOperand({ filterType })).toBe(
|
||||
getRecordFilterOperands({ filterType })[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import {
|
||||
type FilterableAndTSVectorFieldType,
|
||||
ViewFilterOperand as RecordFilterOperand,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export const getDefaultAdvancedFilterOperand = ({
|
||||
filterType,
|
||||
subFieldName,
|
||||
}: {
|
||||
filterType: FilterableAndTSVectorFieldType;
|
||||
subFieldName?: string | null;
|
||||
}): RecordFilterOperand => {
|
||||
const availableOperands = getRecordFilterOperands({
|
||||
filterType,
|
||||
subFieldName,
|
||||
});
|
||||
|
||||
const isDateFilterType = filterType === 'DATE' || filterType === 'DATE_TIME';
|
||||
|
||||
if (
|
||||
isDateFilterType &&
|
||||
availableOperands.includes(RecordFilterOperand.IS_RELATIVE)
|
||||
) {
|
||||
return RecordFilterOperand.IS_RELATIVE;
|
||||
}
|
||||
|
||||
return availableOperands[0];
|
||||
};
|
||||
+17
-4
@@ -2,9 +2,13 @@ import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue';
|
||||
import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue';
|
||||
import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue';
|
||||
import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
|
||||
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
|
||||
import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types';
|
||||
|
||||
const activeDatePickerOperands = [
|
||||
@@ -17,12 +21,25 @@ export const useGetInitialFilterValue = () => {
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { getDateFilterDisplayValue } = useGetDateFilterDisplayValue();
|
||||
const { getDateTimeFilterDisplayValue } = useGetDateTimeFilterDisplayValue();
|
||||
const { getRelativeDateFilterWithUserTimezone } =
|
||||
useGetRelativeDateFilterWithUserTimezone();
|
||||
|
||||
const getInitialFilterValue = (
|
||||
newType: FilterableAndTSVectorFieldType,
|
||||
newOperand: RecordFilterOperand,
|
||||
alreadyExistingZonedDateTime?: Temporal.ZonedDateTime,
|
||||
): Pick<RecordFilter, 'value' | 'displayValue'> | Record<string, never> => {
|
||||
if (newOperand === RecordFilterOperand.IS_RELATIVE) {
|
||||
const newRelativeDateFilter = getRelativeDateFilterWithUserTimezone(
|
||||
DEFAULT_RELATIVE_DATE_FILTER_VALUE,
|
||||
);
|
||||
|
||||
return {
|
||||
value: stringifyRelativeDateFilter(newRelativeDateFilter),
|
||||
displayValue: getRelativeDateDisplayValue(newRelativeDateFilter),
|
||||
};
|
||||
}
|
||||
|
||||
switch (newType) {
|
||||
case 'DATE': {
|
||||
if (activeDatePickerOperands.includes(newOperand)) {
|
||||
@@ -60,10 +77,6 @@ export const useGetInitialFilterValue = () => {
|
||||
return { value, displayValue };
|
||||
}
|
||||
|
||||
if (newOperand === RecordFilterOperand.IS_RELATIVE) {
|
||||
return { value: '', displayValue: '' };
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'BOOLEAN': {
|
||||
|
||||
+35
-11
@@ -1,15 +1,25 @@
|
||||
import { RelativeDateFilterRangeHint } from '@/object-record/record-field/ui/form-types/components/RelativeDateFilterRangeHint';
|
||||
import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone';
|
||||
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
|
||||
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
|
||||
|
||||
import { styled } from '@linaria/react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useId } from 'react';
|
||||
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import {
|
||||
type RelativeDateFilter,
|
||||
resolveRelativeDateFilterStringified,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
export type FormRelativeDatePickerProps = {
|
||||
label?: string;
|
||||
defaultValue?: string;
|
||||
@@ -33,6 +43,14 @@ export const FormRelativeDatePicker = ({
|
||||
? resolveRelativeDateFilterStringified(defaultValue)
|
||||
: DEFAULT_RELATIVE_DATE_FILTER_VALUE;
|
||||
|
||||
const effectiveRelativeDateFilterValue = isNonEmptyString(defaultValue)
|
||||
? defaultValue
|
||||
: stringifyRelativeDateFilter(
|
||||
getRelativeDateFilterWithUserTimezone(
|
||||
DEFAULT_RELATIVE_DATE_FILTER_VALUE,
|
||||
),
|
||||
);
|
||||
|
||||
const handleValueChange = (newValue: RelativeDateFilter) => {
|
||||
const newValueWithTimezone =
|
||||
getRelativeDateFilterWithUserTimezone(newValue);
|
||||
@@ -41,16 +59,22 @@ export const FormRelativeDatePicker = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<RelativeDatePickerHeader
|
||||
instanceId={instanceId}
|
||||
onChange={handleValueChange}
|
||||
direction={valueParsed?.direction ?? 'THIS'}
|
||||
unit={valueParsed?.unit ?? 'DAY'}
|
||||
amount={valueParsed?.amount ?? undefined}
|
||||
isFormField={true}
|
||||
readonly={readonly}
|
||||
unitDropdownWidth={150}
|
||||
allowIntraDayUnits={isDateTimeField}
|
||||
/>
|
||||
<StyledContainer>
|
||||
<RelativeDatePickerHeader
|
||||
instanceId={instanceId}
|
||||
onChange={handleValueChange}
|
||||
direction={valueParsed?.direction ?? 'THIS'}
|
||||
unit={valueParsed?.unit ?? 'DAY'}
|
||||
amount={valueParsed?.amount ?? undefined}
|
||||
isFormField={true}
|
||||
readonly={readonly}
|
||||
unitDropdownWidth={150}
|
||||
allowIntraDayUnits={isDateTimeField}
|
||||
/>
|
||||
<RelativeDateFilterRangeHint
|
||||
relativeDateFilterValue={effectiveRelativeDateFilterValue}
|
||||
isDateTimeField={isDateTimeField}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { InputHint } from '@/ui/input/components/InputHint';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import {
|
||||
isDefined,
|
||||
isSubDayRelativeDateFilterUnit,
|
||||
resolveRelativeDateFilterStringified,
|
||||
resolveRelativeDateTimeFilterStringified,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
type RelativeDateFilterRangeHintProps = {
|
||||
relativeDateFilterValue?: string | null;
|
||||
isDateTimeField?: boolean;
|
||||
};
|
||||
|
||||
export const RelativeDateFilterRangeHint = ({
|
||||
relativeDateFilterValue,
|
||||
isDateTimeField,
|
||||
}: RelativeDateFilterRangeHintProps) => {
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE;
|
||||
|
||||
const formatPlainDate = (plainDate: Temporal.PlainDate) =>
|
||||
new Intl.DateTimeFormat(userLocale, { dateStyle: 'medium' }).format(
|
||||
new Date(plainDate.year, plainDate.month - 1, plainDate.day),
|
||||
);
|
||||
|
||||
const formatPlainDateRange = (
|
||||
start: Temporal.PlainDate,
|
||||
endInclusive: Temporal.PlainDate,
|
||||
) =>
|
||||
start.equals(endInclusive)
|
||||
? formatPlainDate(start)
|
||||
: `${formatPlainDate(start)} – ${formatPlainDate(endInclusive)}`;
|
||||
|
||||
const formatZonedDateTime = (zonedDateTime: Temporal.ZonedDateTime) =>
|
||||
new Intl.DateTimeFormat(userLocale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: zonedDateTime.timeZoneId,
|
||||
}).format(new Date(zonedDateTime.epochMilliseconds));
|
||||
|
||||
let rangeLabel: string | null = null;
|
||||
|
||||
if (isDateTimeField === true) {
|
||||
const resolved = resolveRelativeDateTimeFilterStringified(
|
||||
relativeDateFilterValue,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(resolved) &&
|
||||
isDefined(resolved.start) &&
|
||||
isDefined(resolved.end)
|
||||
) {
|
||||
rangeLabel = isSubDayRelativeDateFilterUnit(resolved.unit)
|
||||
? `${formatZonedDateTime(resolved.start)} → ${formatZonedDateTime(resolved.end)}`
|
||||
: formatPlainDateRange(
|
||||
resolved.start.toPlainDate(),
|
||||
resolved.end.subtract({ nanoseconds: 1 }).toPlainDate(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const resolved = resolveRelativeDateFilterStringified(
|
||||
relativeDateFilterValue,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(resolved) &&
|
||||
isDefined(resolved.start) &&
|
||||
isDefined(resolved.end)
|
||||
) {
|
||||
rangeLabel = formatPlainDateRange(
|
||||
Temporal.PlainDate.from(resolved.start),
|
||||
Temporal.PlainDate.from(resolved.end).subtract({ days: 1 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDefined(rangeLabel)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <InputHint>{rangeLabel}</InputHint>;
|
||||
};
|
||||
+41
-40
@@ -14,18 +14,18 @@ import {
|
||||
DATE_PICKER_CONTAINER_WIDTH,
|
||||
StyledDatePickerContainer,
|
||||
} from '@/ui/input/components/internal/date/components/StyledDatePickerContainer';
|
||||
import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
|
||||
import { getRelativeDatePickerCalendarRange } from '@/ui/input/components/internal/date/utils/getRelativeDatePickerCalendarRange';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import {
|
||||
isDefined,
|
||||
turnJSDateToPlainDate,
|
||||
turnPlainDateToShiftedDateInSystemTimeZone,
|
||||
type RelativeDateFilter,
|
||||
} from 'twenty-shared/utils';
|
||||
import { IconCalendarX } from 'twenty-ui/icon';
|
||||
@@ -97,16 +97,14 @@ type DatePickerProps = {
|
||||
};
|
||||
|
||||
// react-datepicker v9 types its props as a discriminated union keyed on
|
||||
// selectsRange/selectsMultiple. We drive selectsMultiple dynamically, which TS
|
||||
// cannot narrow to a single union branch, so collapse the discriminants to plain
|
||||
// optionals (selectedDates is accepted but ignored by the library at runtime).
|
||||
// selectsRange/selectsMultiple. We drive selectsRange dynamically (relative
|
||||
// filters highlight a contiguous range), which TS cannot narrow to a single
|
||||
// union branch, so collapse the discriminants to plain optionals.
|
||||
type DatePickerPropsType = Omit<
|
||||
ReactDatePickerLibProps,
|
||||
'selectsRange' | 'selectsMultiple' | 'onChange' | 'formatMultipleDates'
|
||||
> & {
|
||||
selectsRange?: boolean;
|
||||
selectsMultiple?: boolean;
|
||||
selectedDates?: Date[];
|
||||
onChange?: (date: Date | null) => void;
|
||||
};
|
||||
|
||||
@@ -137,7 +135,25 @@ export const DatePicker = ({
|
||||
? Temporal.PlainDate.from(plainDateString)
|
||||
: Temporal.Now.plainDateISO();
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const relativeRangeStart = isRelative ? relativeDate?.start : undefined;
|
||||
const relativeRangeEnd = isRelative ? relativeDate?.end : undefined;
|
||||
|
||||
const relativeRangeStartPlainDate = isDefined(relativeRangeStart)
|
||||
? Temporal.PlainDate.from(relativeRangeStart)
|
||||
: null;
|
||||
|
||||
const relativeRangeEndPlainDate = isDefined(relativeRangeEnd)
|
||||
? Temporal.PlainDate.from(relativeRangeEnd).subtract({ days: 1 })
|
||||
: null;
|
||||
|
||||
const {
|
||||
startDate: relativeRangeStartDate,
|
||||
endDate: relativeRangeEndDate,
|
||||
rangeKey: relativeDateRangeKey,
|
||||
} = getRelativeDatePickerCalendarRange(
|
||||
relativeRangeStartPlainDate,
|
||||
relativeRangeEndPlainDate,
|
||||
);
|
||||
|
||||
const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown();
|
||||
const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown();
|
||||
@@ -199,38 +215,13 @@ export const DatePicker = ({
|
||||
handleClose?.(plainDatePicked.toString());
|
||||
};
|
||||
|
||||
const highlightedDates =
|
||||
isRelative && isDefined(relativeDate?.end) && isDefined(relativeDate?.start)
|
||||
? getHighlightedDates(
|
||||
Temporal.PlainDate.from(relativeDate.start),
|
||||
Temporal.PlainDate.from(relativeDate.end).subtract({ days: 1 }),
|
||||
userTimezone,
|
||||
)
|
||||
: [];
|
||||
|
||||
const dateAsDate = new Date(plainDate.toString());
|
||||
|
||||
const selectedDates = isRelative
|
||||
? highlightedDates.map((plainDate) => new Date(plainDate.toString()))
|
||||
: isDefined(dateAsDate)
|
||||
? [dateAsDate]
|
||||
: [];
|
||||
|
||||
const calendarStartDay =
|
||||
currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM
|
||||
? CalendarStartDay[detectCalendarStartDay()]
|
||||
: (currentWorkspaceMember?.calendarStartDay ?? undefined);
|
||||
|
||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const dateShiftedToISOString = plainDate
|
||||
?.toZonedDateTime(systemTimeZone)
|
||||
.toInstant()
|
||||
.toString();
|
||||
|
||||
const dateForDatePicker = isDefined(dateShiftedToISOString)
|
||||
? new Date(dateShiftedToISOString)
|
||||
: null;
|
||||
const dateForDatePicker =
|
||||
turnPlainDateToShiftedDateInSystemTimeZone(plainDate);
|
||||
|
||||
return (
|
||||
<StyledDatePickerContainer calendarDisabled={isRelative}>
|
||||
@@ -264,16 +255,23 @@ export const DatePicker = ({
|
||||
}
|
||||
>
|
||||
<ReactDatePicker
|
||||
key={relativeDateRangeKey}
|
||||
open={true}
|
||||
selected={dateForDatePicker}
|
||||
selectedDates={selectedDates}
|
||||
openToDate={dateForDatePicker ?? undefined}
|
||||
disabledKeyboardNavigation
|
||||
onChange={handleDateChange}
|
||||
onSelect={handleDateSelect}
|
||||
openToDate={isRelative ? relativeRangeStartDate : dateForDatePicker}
|
||||
selectsRange={isRelative ? true : undefined}
|
||||
startDate={isRelative ? relativeRangeStartDate : undefined}
|
||||
endDate={isRelative ? relativeRangeEndDate : undefined}
|
||||
selected={isRelative ? undefined : dateForDatePicker}
|
||||
calendarStartDay={
|
||||
calendarStartDay as 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined
|
||||
}
|
||||
renderCustomHeader={({
|
||||
monthDate,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) =>
|
||||
@@ -284,6 +282,11 @@ export const DatePicker = ({
|
||||
amount={relativeDate?.amount}
|
||||
unit={relativeDate?.unit ?? 'DAY'}
|
||||
onChange={onRelativeDateChange}
|
||||
calendarMonthDate={monthDate}
|
||||
onPreviousMonth={decreaseMonth}
|
||||
onNextMonth={increaseMonth}
|
||||
prevMonthButtonDisabled={prevMonthButtonDisabled}
|
||||
nextMonthButtonDisabled={nextMonthButtonDisabled}
|
||||
/>
|
||||
) : (
|
||||
<DatePickerHeader
|
||||
@@ -299,8 +302,6 @@ export const DatePicker = ({
|
||||
/>
|
||||
)
|
||||
}
|
||||
onSelect={handleDateSelect}
|
||||
selectsMultiple={isRelative}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
+133
-98
@@ -2,6 +2,7 @@ import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLo
|
||||
import {
|
||||
convertFirstDayOfTheWeekToCalendarStartDayNumber,
|
||||
isDefined,
|
||||
isSubDayRelativeDateFilterUnit,
|
||||
type RelativeDateFilter,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
@@ -10,8 +11,9 @@ import {
|
||||
DateTimePickerHeader,
|
||||
} from '@/ui/input/components/internal/date/components/DateTimePickerHeader';
|
||||
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
|
||||
import { RelativeDateTimeRangeText } from '@/ui/input/components/internal/date/components/RelativeDateTimeRangeText';
|
||||
import { StyledDatePickerContainer } from '@/ui/input/components/internal/date/components/StyledDatePickerContainer';
|
||||
import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
|
||||
import { getRelativeDatePickerCalendarRange } from '@/ui/input/components/internal/date/utils/getRelativeDatePickerCalendarRange';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
@@ -110,16 +112,14 @@ type DateTimePickerProps = {
|
||||
};
|
||||
|
||||
// react-datepicker v9 types its props as a discriminated union keyed on
|
||||
// selectsRange/selectsMultiple. We drive selectsMultiple dynamically, which TS
|
||||
// cannot narrow to a single union branch, so collapse the discriminants to plain
|
||||
// optionals (selectedDates is accepted but ignored by the library at runtime).
|
||||
// selectsRange/selectsMultiple. We drive selectsRange dynamically (relative
|
||||
// filters highlight a contiguous range), which TS cannot narrow to a single
|
||||
// union branch, so collapse the discriminants to plain optionals.
|
||||
type DatePickerPropsType = Omit<
|
||||
ReactDatePickerLibProps,
|
||||
'selectsRange' | 'selectsMultiple' | 'onChange' | 'formatMultipleDates'
|
||||
> & {
|
||||
selectsRange?: boolean;
|
||||
selectsMultiple?: boolean;
|
||||
selectedDates?: Date[];
|
||||
onChange?: (date: Date | null) => void;
|
||||
};
|
||||
|
||||
@@ -228,14 +228,29 @@ export const DateTimePicker = ({
|
||||
handleClose?.(zonedDateTime);
|
||||
};
|
||||
|
||||
const highlightedDates =
|
||||
isRelative && isDefined(relativeDate?.end) && isDefined(relativeDate?.start)
|
||||
? getHighlightedDates(
|
||||
relativeDate?.start.toPlainDate(),
|
||||
relativeDate?.end.subtract({ days: 1 }).toPlainDate(),
|
||||
timeZone ?? userTimezone,
|
||||
)
|
||||
: [];
|
||||
const relativeUnit = relativeDate?.unit ?? 'DAY';
|
||||
const relativeRangeStart = isRelative ? relativeDate?.start : undefined;
|
||||
const relativeRangeEnd = isRelative ? relativeDate?.end : undefined;
|
||||
|
||||
const isSubDayRelativeUnit =
|
||||
isRelative === true && isSubDayRelativeDateFilterUnit(relativeUnit);
|
||||
|
||||
const relativeRangeStartPlainDate = isDefined(relativeRangeStart)
|
||||
? relativeRangeStart.toPlainDate()
|
||||
: null;
|
||||
|
||||
const relativeRangeEndPlainDate = isDefined(relativeRangeEnd)
|
||||
? relativeRangeEnd.subtract({ nanoseconds: 1 }).toPlainDate()
|
||||
: null;
|
||||
|
||||
const {
|
||||
startDate: relativeRangeStartDate,
|
||||
endDate: relativeRangeEndDate,
|
||||
rangeKey: relativeDateRangeKey,
|
||||
} = getRelativeDatePickerCalendarRange(
|
||||
relativeRangeStartPlainDate,
|
||||
relativeRangeEndPlainDate,
|
||||
);
|
||||
|
||||
const nonShiftedDateForReactDatePicker = new Date(
|
||||
dateToUse.toInstant().toString(),
|
||||
@@ -246,96 +261,116 @@ export const DateTimePicker = ({
|
||||
timeZone ?? userTimezone,
|
||||
);
|
||||
|
||||
const selectedDates = isRelative
|
||||
? highlightedDates.map((plainDate) => {
|
||||
const date = new Date();
|
||||
|
||||
date.setDate(1);
|
||||
|
||||
date.setFullYear(plainDate.year);
|
||||
date.setMonth(plainDate.month - 1);
|
||||
|
||||
date.setDate(plainDate.day);
|
||||
|
||||
return date;
|
||||
})
|
||||
: [shiftedDateForReactDatePicker];
|
||||
|
||||
const calendarStartDayNumber =
|
||||
convertFirstDayOfTheWeekToCalendarStartDayNumber(userFirstDayOfTheWeek);
|
||||
|
||||
return (
|
||||
<StyledOuterWrapper>
|
||||
<StyledDatePickerContainer calendarDisabled={isRelative}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<StyledDatePickerFallback>
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.tertiary}
|
||||
highlightColor={theme.background.transparent.lighter}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Skeleton
|
||||
width={200}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
|
||||
/>
|
||||
<Skeleton
|
||||
width={240}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
/>
|
||||
<Skeleton
|
||||
width={220}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
|
||||
/>
|
||||
<Skeleton
|
||||
width={180}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
|
||||
/>
|
||||
</SkeletonTheme>
|
||||
</StyledDatePickerFallback>
|
||||
}
|
||||
>
|
||||
<ReactDatePicker
|
||||
open={true}
|
||||
selected={shiftedDateForReactDatePicker}
|
||||
selectedDates={selectedDates}
|
||||
openToDate={shiftedDateForReactDatePicker}
|
||||
disabledKeyboardNavigation
|
||||
onChange={handleDateChange}
|
||||
calendarStartDay={
|
||||
calendarStartDayNumber as 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined
|
||||
<StyledDatePickerContainer
|
||||
calendarDisabled={isRelative && !isSubDayRelativeUnit}
|
||||
>
|
||||
{isSubDayRelativeUnit ? (
|
||||
<>
|
||||
<RelativeDatePickerHeader
|
||||
instanceId={instanceId}
|
||||
direction={relativeDate?.direction ?? 'PAST'}
|
||||
amount={relativeDate?.amount}
|
||||
unit={relativeUnit}
|
||||
onChange={onRelativeDateChange}
|
||||
allowIntraDayUnits={true}
|
||||
/>
|
||||
{isDefined(relativeRangeStart) && isDefined(relativeRangeEnd) && (
|
||||
<RelativeDateTimeRangeText
|
||||
start={relativeRangeStart}
|
||||
end={relativeRangeEnd}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<StyledDatePickerFallback>
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.tertiary}
|
||||
highlightColor={theme.background.transparent.lighter}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Skeleton
|
||||
width={200}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
|
||||
/>
|
||||
<Skeleton
|
||||
width={240}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
|
||||
/>
|
||||
<Skeleton
|
||||
width={220}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
|
||||
/>
|
||||
<Skeleton
|
||||
width={180}
|
||||
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
|
||||
/>
|
||||
</SkeletonTheme>
|
||||
</StyledDatePickerFallback>
|
||||
}
|
||||
renderCustomHeader={({
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) =>
|
||||
isRelative ? (
|
||||
<RelativeDatePickerHeader
|
||||
instanceId={instanceId}
|
||||
direction={relativeDate?.direction ?? 'PAST'}
|
||||
amount={relativeDate?.amount}
|
||||
unit={relativeDate?.unit ?? 'DAY'}
|
||||
onChange={onRelativeDateChange}
|
||||
allowIntraDayUnits={true}
|
||||
/>
|
||||
) : (
|
||||
<DateTimePickerHeader
|
||||
date={dateToUse}
|
||||
onChange={onChange}
|
||||
onAddMonth={handleAddMonth}
|
||||
onSubtractMonth={handleSubtractMonth}
|
||||
prevMonthButtonDisabled={prevMonthButtonDisabled}
|
||||
nextMonthButtonDisabled={nextMonthButtonDisabled}
|
||||
hideInput={hideHeaderInput}
|
||||
onChangeMonth={handleChangeMonth}
|
||||
onChangeYear={handleChangeYear}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onSelect={handleDateSelect}
|
||||
selectsMultiple={isRelative}
|
||||
/>
|
||||
</Suspense>
|
||||
>
|
||||
<ReactDatePicker
|
||||
key={relativeDateRangeKey}
|
||||
open={true}
|
||||
disabledKeyboardNavigation
|
||||
onChange={handleDateChange}
|
||||
onSelect={handleDateSelect}
|
||||
openToDate={
|
||||
isRelative
|
||||
? relativeRangeStartDate
|
||||
: shiftedDateForReactDatePicker
|
||||
}
|
||||
selectsRange={isRelative ? true : undefined}
|
||||
startDate={isRelative ? relativeRangeStartDate : undefined}
|
||||
endDate={isRelative ? relativeRangeEndDate : undefined}
|
||||
selected={isRelative ? undefined : shiftedDateForReactDatePicker}
|
||||
calendarStartDay={
|
||||
calendarStartDayNumber as 0 | 1 | 2 | 3 | 4 | 5 | 6 | undefined
|
||||
}
|
||||
renderCustomHeader={({
|
||||
monthDate,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) =>
|
||||
isRelative ? (
|
||||
<RelativeDatePickerHeader
|
||||
instanceId={instanceId}
|
||||
direction={relativeDate?.direction ?? 'PAST'}
|
||||
amount={relativeDate?.amount}
|
||||
unit={relativeUnit}
|
||||
onChange={onRelativeDateChange}
|
||||
allowIntraDayUnits={true}
|
||||
calendarMonthDate={monthDate}
|
||||
onPreviousMonth={decreaseMonth}
|
||||
onNextMonth={increaseMonth}
|
||||
prevMonthButtonDisabled={prevMonthButtonDisabled}
|
||||
nextMonthButtonDisabled={nextMonthButtonDisabled}
|
||||
/>
|
||||
) : (
|
||||
<DateTimePickerHeader
|
||||
date={dateToUse}
|
||||
onChange={onChange}
|
||||
onAddMonth={handleAddMonth}
|
||||
onSubtractMonth={handleSubtractMonth}
|
||||
prevMonthButtonDisabled={prevMonthButtonDisabled}
|
||||
nextMonthButtonDisabled={nextMonthButtonDisabled}
|
||||
hideInput={hideHeaderInput}
|
||||
onChangeMonth={handleChangeMonth}
|
||||
onChangeYear={handleChangeYear}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{clearable && (
|
||||
<>
|
||||
<StyledSeparator />
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/icon';
|
||||
import { LightIconButton } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledMonthYearLabel = styled.span`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
flex: 1;
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
type RelativeDatePickerCalendarNavigationProps = {
|
||||
monthLabelDate: Date;
|
||||
onPreviousMonth: () => void;
|
||||
onNextMonth: () => void;
|
||||
prevMonthButtonDisabled: boolean;
|
||||
nextMonthButtonDisabled: boolean;
|
||||
};
|
||||
|
||||
export const RelativeDatePickerCalendarNavigation = ({
|
||||
monthLabelDate,
|
||||
onPreviousMonth,
|
||||
onNextMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}: RelativeDatePickerCalendarNavigationProps) => {
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE;
|
||||
|
||||
const monthYearLabel = new Intl.DateTimeFormat(userLocale, {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(monthLabelDate);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<LightIconButton
|
||||
Icon={IconChevronLeft}
|
||||
onClick={onPreviousMonth}
|
||||
size="medium"
|
||||
disabled={prevMonthButtonDisabled}
|
||||
/>
|
||||
<StyledMonthYearLabel>{monthYearLabel}</StyledMonthYearLabel>
|
||||
<LightIconButton
|
||||
Icon={IconChevronRight}
|
||||
onClick={onNextMonth}
|
||||
size="medium"
|
||||
disabled={nextMonthButtonDisabled}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+131
-73
@@ -1,14 +1,16 @@
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { RelativeDatePickerCalendarNavigation } from '@/ui/input/components/internal/date/components/RelativeDatePickerCalendarNavigation';
|
||||
import { RELATIVE_DATE_DIRECTION_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions';
|
||||
import { RELATIVE_DATETIME_UNITS_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateTimeUnitSelectOptions';
|
||||
import { RELATIVE_DATE_UNITS_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions';
|
||||
import { RELATIVE_DATETIME_UNITS } from '@/ui/input/components/internal/date/constants/RelativeDateTimeUnits';
|
||||
import { RELATIVE_DATE_UNITS } from '@/ui/input/components/internal/date/constants/RelativeDateUnits';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { plural, t } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useState } from 'react';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import {
|
||||
assertUnreachable,
|
||||
isDefined,
|
||||
relativeDateFilterSchema,
|
||||
type RelativeDateFilter,
|
||||
@@ -18,14 +20,20 @@ import {
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledContainer = styled.div<{ noPadding: boolean }>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
padding: ${({ noPadding }) =>
|
||||
noPadding ? '0' : themeCssVariables.spacing[2]};
|
||||
padding-bottom: 0;
|
||||
`;
|
||||
|
||||
const StyledControlsRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
type RelativeDatePickerHeaderProps = {
|
||||
instanceId: string;
|
||||
direction: RelativeDateFilterDirection;
|
||||
@@ -36,6 +44,11 @@ type RelativeDatePickerHeaderProps = {
|
||||
readonly?: boolean;
|
||||
unitDropdownWidth?: number;
|
||||
allowIntraDayUnits?: boolean;
|
||||
calendarMonthDate?: Date;
|
||||
onPreviousMonth?: () => void;
|
||||
onNextMonth?: () => void;
|
||||
prevMonthButtonDisabled?: boolean;
|
||||
nextMonthButtonDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const RelativeDatePickerHeader = ({
|
||||
@@ -48,6 +61,11 @@ export const RelativeDatePickerHeader = ({
|
||||
readonly,
|
||||
unitDropdownWidth,
|
||||
allowIntraDayUnits,
|
||||
calendarMonthDate,
|
||||
onPreviousMonth,
|
||||
onNextMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}: RelativeDatePickerHeaderProps) => {
|
||||
const amountString = amount?.toString() ?? '';
|
||||
|
||||
@@ -57,88 +75,128 @@ export const RelativeDatePickerHeader = ({
|
||||
const [draftAmountValue, setDraftAmountValue] = useState(amountTextValue);
|
||||
|
||||
const isUnitPlural = isDefined(amount) && amount > 1 && direction !== 'THIS';
|
||||
const unitCount = isUnitPlural ? 2 : 1;
|
||||
|
||||
const getUnitLabel = (unitToLabel: RelativeDateFilterUnit): string => {
|
||||
switch (unitToLabel) {
|
||||
case 'SECOND':
|
||||
return plural(unitCount, { one: 'Second', other: 'Seconds' });
|
||||
case 'MINUTE':
|
||||
return plural(unitCount, { one: 'Minute', other: 'Minutes' });
|
||||
case 'HOUR':
|
||||
return plural(unitCount, { one: 'Hour', other: 'Hours' });
|
||||
case 'DAY':
|
||||
return plural(unitCount, { one: 'Day', other: 'Days' });
|
||||
case 'WEEK':
|
||||
return plural(unitCount, { one: 'Week', other: 'Weeks' });
|
||||
case 'MONTH':
|
||||
return plural(unitCount, { one: 'Month', other: 'Months' });
|
||||
case 'QUARTER':
|
||||
return plural(unitCount, { one: 'Quarter', other: 'Quarters' });
|
||||
case 'YEAR':
|
||||
return plural(unitCount, { one: 'Year', other: 'Years' });
|
||||
default:
|
||||
return assertUnreachable(unitToLabel);
|
||||
}
|
||||
};
|
||||
|
||||
const unitOptionsSource = allowIntraDayUnits
|
||||
? RELATIVE_DATETIME_UNITS_SELECT_OPTIONS
|
||||
: RELATIVE_DATE_UNITS_SELECT_OPTIONS;
|
||||
const unitSelectOptions = unitOptionsSource.map((unit) => ({
|
||||
...unit,
|
||||
label: `${unit.label}${isUnitPlural ? 's' : ''}`,
|
||||
? RELATIVE_DATETIME_UNITS
|
||||
: RELATIVE_DATE_UNITS;
|
||||
const unitSelectOptions = unitOptionsSource.map((unitOption) => ({
|
||||
value: unitOption,
|
||||
label: getUnitLabel(unitOption),
|
||||
}));
|
||||
|
||||
return (
|
||||
<StyledContainer noPadding={isFormField ?? false}>
|
||||
<Select
|
||||
dropdownId={`direction-select-${instanceId}`}
|
||||
value={direction}
|
||||
onChange={(newDirection) => {
|
||||
if (amount === undefined && newDirection !== 'THIS') {
|
||||
return;
|
||||
}
|
||||
<StyledControlsRow>
|
||||
<Select
|
||||
dropdownId={`direction-select-${instanceId}`}
|
||||
value={direction}
|
||||
onChange={(newDirection) => {
|
||||
if (amount === undefined && newDirection !== 'THIS') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draftAmountValue === '') {
|
||||
setDraftAmountValue('1');
|
||||
}
|
||||
if (draftAmountValue === '') {
|
||||
setDraftAmountValue('1');
|
||||
}
|
||||
|
||||
if (newDirection === 'THIS') {
|
||||
setDraftAmountValue('');
|
||||
}
|
||||
if (newDirection === 'THIS') {
|
||||
setDraftAmountValue('');
|
||||
}
|
||||
|
||||
onChange?.({
|
||||
direction: newDirection,
|
||||
amount: amount,
|
||||
unit: unit,
|
||||
});
|
||||
}}
|
||||
options={RELATIVE_DATE_DIRECTION_SELECT_OPTIONS}
|
||||
fullWidth
|
||||
disabled={readonly}
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId={`relative-date-picker-amount-${instanceId}`}
|
||||
width={50}
|
||||
value={draftAmountValue}
|
||||
onChange={(text) => {
|
||||
const amountString = text.replace(/[^0-9]|^0+/g, '');
|
||||
setDraftAmountValue(amountString);
|
||||
onChange?.({
|
||||
direction: newDirection,
|
||||
amount: amount,
|
||||
unit: unit,
|
||||
});
|
||||
}}
|
||||
options={RELATIVE_DATE_DIRECTION_SELECT_OPTIONS}
|
||||
fullWidth
|
||||
disabled={readonly}
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId={`relative-date-picker-amount-${instanceId}`}
|
||||
width={50}
|
||||
value={draftAmountValue}
|
||||
onChange={(text) => {
|
||||
const amountString = text.replace(/[^0-9]|^0+/g, '');
|
||||
setDraftAmountValue(amountString);
|
||||
|
||||
const amount = parseInt(amountString);
|
||||
const amount = parseInt(amountString);
|
||||
|
||||
const valueParts = {
|
||||
direction,
|
||||
amount,
|
||||
unit,
|
||||
};
|
||||
const valueParts = {
|
||||
direction,
|
||||
amount,
|
||||
unit,
|
||||
};
|
||||
|
||||
if (relativeDateFilterSchema.safeParse(valueParts).success === true) {
|
||||
onChange?.(valueParts);
|
||||
}
|
||||
}}
|
||||
placeholder={amountInputPlaceholder}
|
||||
disabled={direction === 'THIS' || readonly}
|
||||
/>
|
||||
<Select
|
||||
dropdownId={`unit-select-${instanceId}`}
|
||||
value={unit}
|
||||
onChange={(newUnit) => {
|
||||
if (direction !== 'THIS' && amount === undefined) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
relativeDateFilterSchema.safeParse(valueParts).success === true
|
||||
) {
|
||||
onChange?.(valueParts);
|
||||
}
|
||||
}}
|
||||
placeholder={amountInputPlaceholder}
|
||||
disabled={direction === 'THIS' || readonly}
|
||||
/>
|
||||
<Select
|
||||
dropdownId={`unit-select-${instanceId}`}
|
||||
value={unit}
|
||||
onChange={(newUnit) => {
|
||||
if (direction !== 'THIS' && amount === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draftAmountValue === '' && direction !== 'THIS') {
|
||||
setDraftAmountValue('1');
|
||||
}
|
||||
if (draftAmountValue === '' && direction !== 'THIS') {
|
||||
setDraftAmountValue('1');
|
||||
}
|
||||
|
||||
onChange?.({
|
||||
direction,
|
||||
amount: amount,
|
||||
unit: newUnit,
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
options={unitSelectOptions}
|
||||
disabled={readonly}
|
||||
dropdownWidth={unitDropdownWidth}
|
||||
/>
|
||||
onChange?.({
|
||||
direction,
|
||||
amount: amount,
|
||||
unit: newUnit,
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
options={unitSelectOptions}
|
||||
disabled={readonly}
|
||||
dropdownWidth={unitDropdownWidth}
|
||||
/>
|
||||
</StyledControlsRow>
|
||||
{isDefined(calendarMonthDate) &&
|
||||
isDefined(onPreviousMonth) &&
|
||||
isDefined(onNextMonth) && (
|
||||
<RelativeDatePickerCalendarNavigation
|
||||
monthLabelDate={calendarMonthDate}
|
||||
onPreviousMonth={onPreviousMonth}
|
||||
onNextMonth={onNextMonth}
|
||||
prevMonthButtonDisabled={prevMonthButtonDisabled ?? false}
|
||||
nextMonthButtonDisabled={nextMonthButtonDisabled ?? false}
|
||||
/>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { IconArrowDown } from 'twenty-ui/icon';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
justify-content: center;
|
||||
min-height: 96px;
|
||||
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[2]};
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const StyledBound = styled.span`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
type RelativeDateTimeRangeTextProps = {
|
||||
start: Temporal.ZonedDateTime;
|
||||
end: Temporal.ZonedDateTime;
|
||||
};
|
||||
|
||||
export const RelativeDateTimeRangeText = ({
|
||||
start,
|
||||
end,
|
||||
}: RelativeDateTimeRangeTextProps) => {
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE;
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(userLocale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
timeZone: start.timeZoneId,
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledBound>
|
||||
{formatter.format(new Date(start.epochMilliseconds))}
|
||||
</StyledBound>
|
||||
<IconArrowDown size={14} color={themeCssVariables.font.color.tertiary} />
|
||||
<StyledBound>
|
||||
{formatter.format(new Date(end.epochMilliseconds))}
|
||||
</StyledBound>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+4
-7
@@ -3,12 +3,6 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
export const DATE_PICKER_CONTAINER_WIDTH = 280;
|
||||
|
||||
// Shared react-datepicker overrides for every internal date picker
|
||||
// (DatePicker, DateTimePicker, DatePickerWithoutCalendar). Keep the calendar
|
||||
// grid styling in one place so day/day-name spacing stays consistent across all
|
||||
// pickers instead of drifting between per-component copies.
|
||||
// - calendarDisabled: dims and freezes the grid (used for relative date mode)
|
||||
// - hideCalendar: hides the day grid, keeping only the month/year header
|
||||
export const StyledDatePickerContainer = styled.div<{
|
||||
calendarDisabled?: boolean;
|
||||
hideCalendar?: boolean;
|
||||
@@ -240,7 +234,10 @@ export const StyledDatePickerContainer = styled.div<{
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
}
|
||||
|
||||
& .react-datepicker__day--selected {
|
||||
& .react-datepicker__day--selected,
|
||||
& .react-datepicker__day--in-range,
|
||||
& .react-datepicker__day--range-start,
|
||||
& .react-datepicker__day--range-end {
|
||||
background-color: ${themeCssVariables.color.blue};
|
||||
color: ${themeCssVariables.background.primary};
|
||||
|
||||
|
||||
+42
@@ -9,6 +9,22 @@ const INITIAL_DATE = Temporal.ZonedDateTime.from(
|
||||
'2023-01-01T02:00:00+00:00[UTC]',
|
||||
);
|
||||
|
||||
const RELATIVE_CALENDAR_RANGE = {
|
||||
direction: 'PAST' as const,
|
||||
amount: 1,
|
||||
unit: 'MONTH' as const,
|
||||
start: Temporal.ZonedDateTime.from('2022-12-01T00:00:00+00:00[UTC]'),
|
||||
end: Temporal.ZonedDateTime.from('2023-01-01T00:00:00+00:00[UTC]'),
|
||||
};
|
||||
|
||||
const RELATIVE_SUB_DAY_RANGE = {
|
||||
direction: 'PAST' as const,
|
||||
amount: 3,
|
||||
unit: 'HOUR' as const,
|
||||
start: Temporal.ZonedDateTime.from('2023-01-01T00:00:00+00:00[UTC]'),
|
||||
end: Temporal.ZonedDateTime.from('2023-01-01T03:00:00+00:00[UTC]'),
|
||||
};
|
||||
|
||||
const DateTimePickerStory = () => {
|
||||
const [date, setDate] = useState<Temporal.ZonedDateTime | null>(INITIAL_DATE);
|
||||
|
||||
@@ -125,3 +141,29 @@ export const WithTimeInput: Story = {
|
||||
expect(timeInput).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const RelativeWithCalendarRange: Story = {
|
||||
render: () => (
|
||||
<DateTimePicker
|
||||
instanceId="story-relative-date-time-picker"
|
||||
date={null}
|
||||
isRelative
|
||||
relativeDate={RELATIVE_CALENDAR_RANGE}
|
||||
onChange={() => {}}
|
||||
onRelativeDateChange={() => {}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const RelativeWithSubDayText: Story = {
|
||||
render: () => (
|
||||
<DateTimePicker
|
||||
instanceId="story-relative-sub-day-picker"
|
||||
date={null}
|
||||
isRelative
|
||||
relativeDate={RELATIVE_SUB_DAY_RANGE}
|
||||
onChange={() => {}}
|
||||
onRelativeDateChange={() => {}}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { RELATIVE_DATE_UNITS_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions';
|
||||
import { type RelativeDateFilterUnit } from 'twenty-shared/utils';
|
||||
|
||||
type RelativeDateUnitOption = {
|
||||
value: RelativeDateFilterUnit;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const RELATIVE_DATETIME_UNITS_SELECT_OPTIONS: RelativeDateUnitOption[] =
|
||||
[
|
||||
...RELATIVE_DATE_UNITS_SELECT_OPTIONS,
|
||||
{ value: 'HOUR', label: 'Hour' },
|
||||
{ value: 'MINUTE', label: 'Minute' },
|
||||
{ value: 'SECOND', label: 'Second' },
|
||||
];
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { RELATIVE_DATE_UNITS } from '@/ui/input/components/internal/date/constants/RelativeDateUnits';
|
||||
import { type RelativeDateFilterUnit } from 'twenty-shared/utils';
|
||||
|
||||
export const RELATIVE_DATETIME_UNITS: RelativeDateFilterUnit[] = [
|
||||
'SECOND',
|
||||
'MINUTE',
|
||||
'HOUR',
|
||||
...RELATIVE_DATE_UNITS,
|
||||
];
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import { type RelativeDateFilterUnit } from 'twenty-shared/utils';
|
||||
|
||||
type RelativeDateUnitOption = {
|
||||
value: RelativeDateFilterUnit;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const RELATIVE_DATE_UNITS_SELECT_OPTIONS: RelativeDateUnitOption[] = [
|
||||
{ value: 'DAY', label: 'Day' },
|
||||
{ value: 'WEEK', label: 'Week' },
|
||||
{ value: 'MONTH', label: 'Month' },
|
||||
{ value: 'QUARTER', label: 'Quarter' },
|
||||
{ value: 'YEAR', label: 'Year' },
|
||||
];
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { type RelativeDateFilterUnit } from 'twenty-shared/utils';
|
||||
|
||||
export const RELATIVE_DATE_UNITS: RelativeDateFilterUnit[] = [
|
||||
'DAY',
|
||||
'WEEK',
|
||||
'MONTH',
|
||||
'QUARTER',
|
||||
'YEAR',
|
||||
];
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
jest.useFakeTimers().setSystemTime(new Date('2024-10-01T00:00:00.000Z'));
|
||||
|
||||
const TIME_ZONE = 'UTC';
|
||||
|
||||
const getUTCPlainDateFromISO = (isoStringDate: string) => {
|
||||
return Temporal.Instant.from(isoStringDate)
|
||||
.toZonedDateTimeISO('UTC')
|
||||
.toPlainDate();
|
||||
};
|
||||
|
||||
describe('getHighlightedDates', () => {
|
||||
it('should should return one day if range is one day', () => {
|
||||
const dateRange = {
|
||||
start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
end: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
};
|
||||
expect(
|
||||
getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE),
|
||||
).toEqual([getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z')]);
|
||||
});
|
||||
|
||||
it('should should return two days if range is 2 days', () => {
|
||||
const dateRange = {
|
||||
start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
end: getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'),
|
||||
};
|
||||
expect(
|
||||
getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE),
|
||||
).toEqual([
|
||||
getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should should return 10 days if range is 10 days', () => {
|
||||
const dateRange = {
|
||||
start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
end: getUTCPlainDateFromISO('2024-10-21T00:00:00.000Z'),
|
||||
};
|
||||
expect(
|
||||
getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE),
|
||||
).toEqual([
|
||||
getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-14T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-15T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-16T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-17T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-18T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-19T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-20T00:00:00.000Z'),
|
||||
getUTCPlainDateFromISO('2024-10-21T00:00:00.000Z'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should should return empty if range is 10 days but out of range', () => {
|
||||
const dateRange = {
|
||||
start: getUTCPlainDateFromISO('2023-10-01T00:00:00.000Z'),
|
||||
end: getUTCPlainDateFromISO('2023-10-10T00:00:00.000Z'),
|
||||
};
|
||||
expect(
|
||||
getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { isPlainDateAfter, isPlainDateBefore } from 'twenty-shared/utils';
|
||||
|
||||
export const getHighlightedDates = (
|
||||
start: Temporal.PlainDate,
|
||||
end: Temporal.PlainDate,
|
||||
timeZone: string,
|
||||
): Temporal.PlainDate[] => {
|
||||
const highlightedDates: Temporal.PlainDate[] = [];
|
||||
|
||||
const currentDate = Temporal.Now.zonedDateTimeISO(timeZone)
|
||||
.startOfDay()
|
||||
.toPlainDate();
|
||||
|
||||
const minDate = currentDate.subtract({ months: 2 });
|
||||
const maxDate = currentDate.add({ months: 2 });
|
||||
|
||||
const startDate = isPlainDateBefore(start, minDate) ? minDate : start;
|
||||
const lastDate = isPlainDateAfter(end, maxDate) ? maxDate : end;
|
||||
|
||||
let dateToHighlight = startDate;
|
||||
|
||||
while (isPlainDateBefore(dateToHighlight, lastDate.add({ days: 1 }))) {
|
||||
highlightedDates.push(dateToHighlight);
|
||||
dateToHighlight = dateToHighlight.add({ days: 1 });
|
||||
}
|
||||
|
||||
return highlightedDates;
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
isDefined,
|
||||
turnPlainDateToShiftedDateInSystemTimeZone,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
type RelativeDatePickerCalendarRange = {
|
||||
startDate: Date | undefined;
|
||||
endDate: Date | undefined;
|
||||
rangeKey: string | undefined;
|
||||
};
|
||||
|
||||
export const getRelativeDatePickerCalendarRange = (
|
||||
startPlainDate: Temporal.PlainDate | null,
|
||||
endInclusivePlainDate: Temporal.PlainDate | null,
|
||||
): RelativeDatePickerCalendarRange => ({
|
||||
startDate: isDefined(startPlainDate)
|
||||
? turnPlainDateToShiftedDateInSystemTimeZone(startPlainDate)
|
||||
: undefined,
|
||||
endDate: isDefined(endInclusivePlainDate)
|
||||
? turnPlainDateToShiftedDateInSystemTimeZone(endInclusivePlainDate)
|
||||
: undefined,
|
||||
rangeKey:
|
||||
isDefined(startPlainDate) && isDefined(endInclusivePlainDate)
|
||||
? `${startPlainDate.toString()}-${endInclusivePlainDate.toString()}`
|
||||
: undefined,
|
||||
});
|
||||
Reference in New Issue
Block a user