Added relative date filter to dashboards (#16292)

This PR adds relative date filter operand to dashboard filters : 

<img width="1685" height="558" alt="image"
src="https://github.com/user-attachments/assets/a1f927e7-8c99-4171-b487-4b6a28779547"
/>

It also refactors the logic to add timezone and first day of the week
taking users preferences.

It has been tested on workflows and regular advanced filters also.


For step filters, which use a JSON format to store relative date
filters, I kept the current way to handle it.

There are a few workspaces that use a relative date filter in step
filter, so we want to avoid a migration, and instead handle both code
paths, and refactor everything to JSON later.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2025-12-15 18:51:57 +01:00
committed by GitHub
parent 31467f2173
commit e8fd5fa3ed
9 changed files with 167 additions and 36 deletions
@@ -2,7 +2,6 @@ import { AdvancedFilterRecordFilterOperandSelectContent } from '@/object-record/
import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext';
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -33,7 +32,7 @@ export const AdvancedFilterCommandMenuRecordFilterOperandSelect = ({
? getRecordFilterOperands({
filterType,
subFieldName: filter?.subFieldName,
}).filter((operand) => operand !== RecordFilterOperand.IS_RELATIVE)
})
: [];
if (isDisabled === true) {
@@ -9,6 +9,7 @@ import { configurableViewFilterOperands } from '@/object-record/object-filter-dr
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput';
import { FormRelativeDatePicker } from '@/object-record/record-field/ui/form-types/components/FormRelativeDatePicker';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import {
type FieldMetadata,
@@ -16,11 +17,13 @@ import {
type FieldSelectMetadata,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
import { isObject, isString } from '@sniptt/guards';
import { useContext } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, type RelativeDateFilter } from 'twenty-shared/utils';
import { parseBooleanFromStringValue } from 'twenty-shared/workflow';
import { type JsonValue } from 'type-fest';
@@ -65,6 +68,10 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
}
};
const handleRelativeDateFilterChange = (newValue: RelativeDateFilter) => {
applyObjectFilterDropdownFilterValue(stringifyRelativeDateFilter(newValue));
};
const fieldMetadataItemUsedInDropdown = useRecoilComponentValue(
fieldMetadataItemUsedInDropdownComponentSelector,
);
@@ -93,10 +100,24 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
recordFilter.type === FieldMetadataType.DATE ||
recordFilter.type === FieldMetadataType.DATE_TIME;
const isRelativeDateFilter =
isFilterableByDateValue &&
recordFilter.operand === RecordFilterOperand.IS_RELATIVE;
if (isDisabled || operandHasNoInput) {
return null;
}
if (isRelativeDateFilter) {
return (
<FormRelativeDatePicker
defaultValue={recordFilter.value}
onChange={handleRelativeDateFilterChange}
readonly={readonly}
/>
);
}
if (isFilterableByTextValue) {
return (
<FormTextFieldInput
@@ -6,16 +6,16 @@ import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue';
import { useCreateEmptyRecordFilterFromFieldMetadataItem } from '@/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem';
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 { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
import { isDefined, type RelativeDateFilter } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
export const useApplyObjectFilterDropdownOperand = () => {
const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValue(
@@ -42,7 +42,8 @@ export const useApplyObjectFilterDropdownOperand = () => {
const { getInitialFilterValue } = useGetInitialFilterValue();
const { userTimezone } = useUserTimezone();
const { getRelativeDateFilterWithUserTimezone } =
useGetRelativeDateFilterWithUserTimezone();
const applyObjectFilterDropdownOperand = (
newOperand: RecordFilterOperand,
@@ -103,16 +104,17 @@ export const useApplyObjectFilterDropdownOperand = () => {
recordFilterToUpsert.displayValue = displayValue;
} else if (newOperand === RecordFilterOperand.IS_RELATIVE) {
const defaultRelativeDate: RelativeDateFilter = {
...DEFAULT_RELATIVE_DATE_FILTER_VALUE,
timezone: userTimezone,
};
const newRelativeDateFilter = getRelativeDateFilterWithUserTimezone(
DEFAULT_RELATIVE_DATE_FILTER_VALUE,
);
recordFilterToUpsert.value =
stringifyRelativeDateFilter(defaultRelativeDate);
recordFilterToUpsert.value = stringifyRelativeDateFilter(
newRelativeDateFilter,
);
recordFilterToUpsert.displayValue =
getRelativeDateDisplayValue(defaultRelativeDate);
recordFilterToUpsert.displayValue = getRelativeDateDisplayValue(
newRelativeDateFilter,
);
} else {
recordFilterToUpsert.value = '';
recordFilterToUpsert.displayValue = '';
@@ -1,19 +1,19 @@
import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone';
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
import { isNonEmptyString, isString } from '@sniptt/guards';
import { isNonEmptyString } from '@sniptt/guards';
import { useId } from 'react';
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
import {
type RelativeDateFilter,
safeParseRelativeDateFilterJSONStringified,
resolveRelativeDateFilterStringified,
} from 'twenty-shared/utils';
import { type JsonValue } from 'type-fest';
export type FormRelativeDatePickerProps = {
label?: string;
defaultValue?: string;
onChange: (value: JsonValue) => void;
onChange: (value: RelativeDateFilter) => void;
readonly?: boolean;
};
@@ -24,22 +24,27 @@ export const FormRelativeDatePicker = ({
}: FormRelativeDatePickerProps) => {
const instanceId = useId();
const value =
isString(defaultValue) && isNonEmptyString(defaultValue)
? safeParseRelativeDateFilterJSONStringified(defaultValue)
: DEFAULT_RELATIVE_DATE_FILTER_VALUE;
const { getRelativeDateFilterWithUserTimezone } =
useGetRelativeDateFilterWithUserTimezone();
const valueParsed = isNonEmptyString(defaultValue)
? resolveRelativeDateFilterStringified(defaultValue)
: DEFAULT_RELATIVE_DATE_FILTER_VALUE;
const handleValueChange = (newValue: RelativeDateFilter) => {
onChange(JSON.stringify(newValue));
const newValueWithTimezone =
getRelativeDateFilterWithUserTimezone(newValue);
onChange(newValueWithTimezone);
};
return (
<RelativeDatePickerHeader
instanceId={instanceId}
onChange={handleValueChange}
direction={value?.direction ?? 'THIS'}
unit={value?.unit ?? 'DAY'}
amount={value?.amount ?? undefined}
direction={valueParsed?.direction ?? 'THIS'}
unit={valueParsed?.unit ?? 'DAY'}
amount={valueParsed?.amount ?? undefined}
isFormField={true}
readonly={readonly}
unitDropdownWidth={150}
@@ -0,0 +1,38 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useRecoilValue } from 'recoil';
import { CalendarStartDay } from 'twenty-shared';
import { type FirstDayOfTheWeek } from 'twenty-shared/types';
import { type RelativeDateFilter } from 'twenty-shared/utils';
export const useGetRelativeDateFilterWithUserTimezone = () => {
const { userTimezone } = useUserTimezone();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const getRelativeDateFilterWithUserTimezone = (
relativeDateFilter: RelativeDateFilter,
): RelativeDateFilter => {
const userDefinedCalendarStartDay =
CalendarStartDay[
currentWorkspaceMember?.calendarStartDay ?? CalendarStartDay.SYSTEM
];
const defaultSystemCalendarStartDay = detectCalendarStartDay();
const resolvedCalendarStartDay = (
userDefinedCalendarStartDay === CalendarStartDay[CalendarStartDay.SYSTEM]
? defaultSystemCalendarStartDay
: userDefinedCalendarStartDay
) as FirstDayOfTheWeek;
return {
...relativeDateFilter,
timezone: userTimezone,
firstDayOfTheWeek: resolvedCalendarStartDay,
};
};
return {
getRelativeDateFilterWithUserTimezone,
};
};
@@ -9,7 +9,7 @@ export type RecordFilter = {
id: string;
fieldMetadataId: string;
value: string;
/** @deprecated We shouldn't implement new features with this field and instead try to create utils to obtain the displayValue of a filter type at runtime */
/** @deprecated We shouldn't implement new features with this field and instead try to create utils to obtain the displayValue at runtime */
displayValue: string;
type: FilterableAndTSVectorFieldType;
recordFilterGroupId?: string;
@@ -4,6 +4,7 @@ import { RELATIVE_DATE_DIRECTION_SELECT_OPTIONS } from '@/ui/input/components/in
import { RELATIVE_DATE_UNITS_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions';
import styled from '@emotion/styled';
import { useState } from 'react';
import { type Nullable } from 'twenty-shared/types';
import {
relativeDateFilterSchema,
@@ -43,8 +44,10 @@ export const RelativeDatePickerHeader = ({
}: RelativeDatePickerHeaderProps) => {
const amountString = amount?.toString() ?? '';
const textInputValue = direction === 'THIS' ? '' : amountString;
const textInputPlaceholder = direction === 'THIS' ? '-' : 'Number';
const amountTextValue = direction === 'THIS' ? '' : amountString;
const amountInputPlaceholder = direction === 'THIS' ? '-' : 'Number';
const [draftAmountValue, setDraftAmountValue] = useState(amountTextValue);
const isUnitPlural = amount && amount > 1 && direction !== 'THIS';
const unitSelectOptions = RELATIVE_DATE_UNITS_SELECT_OPTIONS.map((unit) => ({
@@ -62,6 +65,14 @@ export const RelativeDatePickerHeader = ({
return;
}
if (draftAmountValue === '') {
setDraftAmountValue('1');
}
if (newDirection === 'THIS') {
setDraftAmountValue('');
}
onChange?.({
direction: newDirection,
amount: amount,
@@ -75,9 +86,11 @@ export const RelativeDatePickerHeader = ({
<SettingsTextInput
instanceId={`relative-date-picker-amount-${instanceId}`}
width={50}
value={textInputValue}
value={draftAmountValue}
onChange={(text) => {
const amountString = text.replace(/[^0-9]|^0+/g, '');
setDraftAmountValue(amountString);
const amount = parseInt(amountString);
const valueParts = {
@@ -90,7 +103,7 @@ export const RelativeDatePickerHeader = ({
onChange?.(valueParts);
}
}}
placeholder={textInputPlaceholder}
placeholder={amountInputPlaceholder}
disabled={direction === 'THIS' || readonly}
/>
<Select
@@ -101,6 +114,10 @@ export const RelativeDatePickerHeader = ({
return;
}
if (draftAmountValue === '' && direction !== 'THIS') {
setDraftAmountValue('1');
}
onChange?.({
direction,
amount: amount,
@@ -1,12 +1,14 @@
import { DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET } from '@/object-record/advanced-filter/constants/DefaultAdvancedFilterDropdownOffset';
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { getStepFilterOperands } from '@/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands';
import { useContext } from 'react';
import { type StepFilter, type ViewFilterOperand } from 'twenty-shared/types';
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
import { ViewFilterOperand, type StepFilter } from 'twenty-shared/types';
type WorkflowStepFilterOperandSelectProps = {
stepFilter: StepFilter;
@@ -28,7 +30,25 @@ export const WorkflowStepFilterOperandSelect = ({
label: getOperandLabel(operand),
}));
const { getRelativeDateFilterWithUserTimezone } =
useGetRelativeDateFilterWithUserTimezone();
const handleChange = (operand: ViewFilterOperand) => {
if (operand === ViewFilterOperand.IS_RELATIVE) {
const newRelativeDateFilter = getRelativeDateFilterWithUserTimezone(
DEFAULT_RELATIVE_DATE_FILTER_VALUE,
);
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
operand,
value: JSON.stringify(newRelativeDateFilter),
},
});
return;
}
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
@@ -9,6 +9,7 @@ import { FormRelativeDatePicker } from '@/object-record/record-field/ui/form-typ
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
import { WorkflowStepFilterValueCompositeInput } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueCompositeInput';
import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings';
@@ -22,7 +23,12 @@ import {
ViewFilterOperand,
type StepFilter,
} from 'twenty-shared/types';
import { isDefined, parseJson } from 'twenty-shared/utils';
import {
isDefined,
parseJson,
safeParseRelativeDateFilterJSONStringified,
type RelativeDateFilter,
} from 'twenty-shared/utils';
import { parseBooleanFromStringValue } from 'twenty-shared/workflow';
import { type JsonValue } from 'type-fest';
@@ -85,6 +91,17 @@ export const WorkflowStepFilterValueInput = ({
});
};
const handleRelativeDateFilterChange = (
newRelativeDateFilter: RelativeDateFilter,
) => {
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
value: JSON.stringify(newRelativeDateFilter),
},
});
};
const isDisabled = !stepFilter.operand;
const operandHasNoInput =
@@ -117,6 +134,18 @@ export const WorkflowStepFilterValueInput = ({
variableType === FieldMetadataType.DATE_TIME ||
variableType === FieldMetadataType.DATE;
const isRelativeDateFilter =
isDateField && stepFilter.operand === ViewFilterOperand.IS_RELATIVE;
const relativeDateFilter = safeParseRelativeDateFilterJSONStringified(
stepFilter.value,
);
const relativeDateFilterValue =
isRelativeDateFilter && isDefined(relativeDateFilter)
? stringifyRelativeDateFilter(relativeDateFilter)
: '';
if (isFullRecord) {
return (
<FormSingleRecordPicker
@@ -166,11 +195,11 @@ export const WorkflowStepFilterValueInput = ({
);
}
if (isDateField && stepFilter.operand === ViewFilterOperand.IS_RELATIVE) {
if (isRelativeDateFilter) {
return (
<FormRelativeDatePicker
defaultValue={stepFilter.value}
onChange={handleValueChange}
defaultValue={relativeDateFilterValue}
onChange={handleRelativeDateFilterChange}
readonly={readonly}
/>
);