From e8fd5fa3ed0d778380c2f9b0ef8509dcc71cc479 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Mon, 15 Dec 2025 18:51:57 +0100 Subject: [PATCH] Added relative date filter to dashboards (#16292) This PR adds relative date filter operand to dashboard filters : image 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 --- ...erCommandMenuRecordFilterOperandSelect.tsx | 3 +- ...dvancedFilterCommandMenuValueFormInput.tsx | 23 ++++++++++- .../useApplyObjectFilterDropdownOperand.ts | 24 ++++++------ .../components/FormRelativeDatePicker.tsx | 29 ++++++++------ ...seGetRelativeDateFilterWithUserTimezone.ts | 38 +++++++++++++++++++ .../record-filter/types/RecordFilter.ts | 2 +- .../components/RelativeDatePickerHeader.tsx | 25 ++++++++++-- .../WorkflowStepFilterOperandSelect.tsx | 22 ++++++++++- .../WorkflowStepFilterValueInput.tsx | 37 ++++++++++++++++-- 9 files changed, 167 insertions(+), 36 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone.ts diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuRecordFilterOperandSelect.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuRecordFilterOperandSelect.tsx index 9f27de297c..82b57581ac 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuRecordFilterOperandSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuRecordFilterOperandSelect.tsx @@ -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) { diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuValueFormInput.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuValueFormInput.tsx index ec97e88574..2816f58731 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuValueFormInput.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuValueFormInput.tsx @@ -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 ( + + ); + } + if (isFilterableByTextValue) { return ( { 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 = ''; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx index d054cc4c9f..903979c341 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx @@ -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 ( { + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts index 2ea165036e..ae9da12e4e 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts @@ -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; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx index d52648a660..d4330a41d5 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx @@ -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 = ({ { 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} />