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>;
|
||||
};
|
||||
Reference in New Issue
Block a user