Add ability to save any field filter to view (#13401)

This PR adds the ability to save an any field filter to a view.

It adds a new `anyFieldFilterValue` on both core view and workspace view
entities.

It also introduces the necessary utils that mimic the logic that manages
the save of record filters and record sorts on views.
This commit is contained in:
Lucas Bordeau
2025-07-24 12:08:16 +02:00
committed by GitHub
parent 7653be8fde
commit d468c3dc84
20 changed files with 302 additions and 6 deletions

View File

@ -25,5 +25,6 @@ export const findAllViewsOperationSignatureFactory: RecordGqlOperationSignatureF
viewSorts: true,
viewFields: true,
viewGroups: true,
anyFieldFilterValue: true,
},
});

View File

@ -14,6 +14,7 @@ import { useAreViewFilterGroupsDifferentFromRecordFilterGroups } from '@/views/h
import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAreViewFiltersDifferentFromRecordFilters';
import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter } from '@/views/hooks/useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter';
import { useSaveCurrentViewFiltersAndSorts } from '@/views/hooks/useSaveCurrentViewFiltersAndSorts';
import { VIEW_PICKER_DROPDOWN_ID } from '@/views/view-picker/constants/ViewPickerDropdownId';
import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode';
@ -84,10 +85,14 @@ export const UpdateViewButtonGroup = () => {
const { viewSortsAreDifferentFromRecordSorts } =
useAreViewSortsDifferentFromRecordSorts();
const { viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter } =
useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter();
const canShowButton =
(viewFiltersAreDifferentFromRecordFilters ||
viewSortsAreDifferentFromRecordSorts ||
viewFilterGroupsAreDifferentFromRecordFilterGroups) &&
viewFilterGroupsAreDifferentFromRecordFilterGroups ||
viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter) &&
!hasFiltersQueryParams;
if (!canShowButton) {

View File

@ -12,6 +12,7 @@ import { ViewPickerDropdown } from '@/views/view-picker/components/ViewPickerDro
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { VIEW_SORT_DROPDOWN_ID } from '@/object-record/object-sort-dropdown/constants/ViewSortDropdownId';
import { ObjectSortDropdownComponentInstanceContext } from '@/object-record/object-sort-dropdown/states/context/ObjectSortDropdownComponentInstanceContext';
import { ViewBarAnyFieldFilterEffect } from '@/views/components/ViewBarAnyFieldFilterEffect';
import { ViewBarFilterDropdown } from '@/views/components/ViewBarFilterDropdown';
import { ViewBarRecordFilterEffect } from '@/views/components/ViewBarRecordFilterEffect';
import { ViewBarRecordFilterGroupEffect } from '@/views/components/ViewBarRecordFilterGroupEffect';
@ -43,6 +44,7 @@ export const ViewBar = ({
value={{ instanceId: VIEW_SORT_DROPDOWN_ID }}
>
<ViewBarRecordFilterGroupEffect />
<ViewBarAnyFieldFilterEffect />
<ViewBarRecordFilterEffect />
<ViewBarRecordSortEffect />
<QueryParamsFiltersEffect />

View File

@ -0,0 +1,58 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { hasInitializedAnyFieldFilterComponentFamilyState } from '@/views/states/hasInitializedAnyFieldFilterComponentFamilyState';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const ViewBarAnyFieldFilterEffect = () => {
const currentViewId = useRecoilComponentValueV2(
contextStoreCurrentViewIdComponentState,
);
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const currentView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
);
const [hasInitializedAnyFieldFilter, setHasInitializedAnyFieldFilter] =
useRecoilComponentFamilyStateV2(
hasInitializedAnyFieldFilterComponentFamilyState,
{
viewId: currentViewId ?? undefined,
},
);
const setAnyFieldFilterValue = useSetRecoilComponentStateV2(
anyFieldFilterValueComponentState,
);
useEffect(() => {
if (!hasInitializedAnyFieldFilter && isDefined(currentView)) {
if (currentView.objectMetadataId !== objectMetadataItem.id) {
return;
}
setAnyFieldFilterValue(currentView.anyFieldFilterValue ?? '');
setHasInitializedAnyFieldFilter(true);
}
}, [
setAnyFieldFilterValue,
currentViewId,
hasInitializedAnyFieldFilter,
setHasInitializedAnyFieldFilter,
currentView,
objectMetadataItem,
]);
return null;
};

View File

@ -27,8 +27,10 @@ import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDrop
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AnyFieldSearchDropdownButton } from '@/views/components/AnyFieldSearchDropdownButton';
import { ANY_FIELD_SEARCH_DROPDOWN_ID } from '@/views/constants/AnyFieldSearchDropdownId';
import { useApplyCurrentViewAnyFieldFilterToAnyFieldFilter } from '@/views/hooks/useApplyCurrentViewAnyFieldFilterToAnyFieldFilter';
import { useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups } from '@/views/hooks/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups';
import { useAreViewFilterGroupsDifferentFromRecordFilterGroups } from '@/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups';
import { useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter } from '@/views/hooks/useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter';
import { isViewBarExpandedComponentState } from '@/views/states/isViewBarExpandedComponentState';
import { t } from '@lingui/core/macro';
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
@ -144,11 +146,8 @@ export const ViewBarDetails = ({
const { viewSortsAreDifferentFromRecordSorts } =
useAreViewSortsDifferentFromRecordSorts();
const canResetView =
(viewFiltersAreDifferentFromRecordFilters ||
viewSortsAreDifferentFromRecordSorts ||
viewFilterGroupsAreDifferentFromRecordFilterGroups) &&
!hasFiltersQueryParams;
const { viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter } =
useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter();
const { checkIsSoftDeleteFilter } = useCheckIsSoftDeleteFilter();
@ -170,6 +169,9 @@ export const ViewBarDetails = ({
const { applyCurrentViewFiltersToCurrentRecordFilters } =
useApplyCurrentViewFiltersToCurrentRecordFilters();
const { applyCurrentViewAnyFieldFilterToAnyFieldFilter } =
useApplyCurrentViewAnyFieldFilterToAnyFieldFilter();
const { applyCurrentViewSortsToCurrentRecordSorts } =
useApplyCurrentViewSortsToCurrentRecordSorts();
@ -177,6 +179,7 @@ export const ViewBarDetails = ({
applyCurrentViewFilterGroupsToCurrentRecordFilterGroups();
applyCurrentViewFiltersToCurrentRecordFilters();
applyCurrentViewSortsToCurrentRecordSorts();
applyCurrentViewAnyFieldFilterToAnyFieldFilter();
toggleSoftDeleteFilterState(false);
};
@ -188,6 +191,13 @@ export const ViewBarDetails = ({
ANY_FIELD_SEARCH_DROPDOWN_ID,
);
const canResetView =
(viewFiltersAreDifferentFromRecordFilters ||
viewSortsAreDifferentFromRecordSorts ||
viewFilterGroupsAreDifferentFromRecordFilterGroups ||
viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter) &&
!hasFiltersQueryParams;
const shouldShowAnyFieldSearchChip =
isNonEmptyString(anyFieldFilterValue) || isAnyFieldSearchDropdownOpen;

View File

@ -0,0 +1,39 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useApplyCurrentViewAnyFieldFilterToAnyFieldFilter = () => {
const currentViewId = useRecoilComponentValueV2(
contextStoreCurrentViewIdComponentState,
);
const setAnyFieldFilterValue = useSetRecoilComponentStateV2(
anyFieldFilterValueComponentState,
);
const applyCurrentViewAnyFieldFilterToAnyFieldFilter = useRecoilCallback(
({ snapshot }) =>
() => {
const currentView = snapshot
.getLoadable(
prefetchViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
)
.getValue();
if (isDefined(currentView)) {
setAnyFieldFilterValue(currentView.anyFieldFilterValue ?? '');
}
},
[currentViewId, setAnyFieldFilterValue],
);
return {
applyCurrentViewAnyFieldFilterToAnyFieldFilter,
};
};

View File

@ -3,6 +3,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
@ -40,6 +41,10 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
objectNameSingular: CoreObjectNameSingular.View,
});
const anyFieldFilterValue = useRecoilComponentValueV2(
anyFieldFilterValueComponentState,
);
const { createViewFieldRecords } = usePersistViewFieldRecords();
const { createViewSortRecords } = usePersistViewSortRecords();
@ -126,6 +131,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
type: type ?? sourceView.type,
objectMetadataId: sourceView.objectMetadataId,
openRecordIn: sourceView.openRecordIn,
anyFieldFilterValue: anyFieldFilterValue,
});
if (isUndefinedOrNull(newView)) {
@ -209,6 +215,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
set(isPersistingViewFieldsState, false);
},
[
anyFieldFilterValue,
currentViewIdCallbackState,
createOneRecord,
createViewFieldRecords,

View File

@ -0,0 +1,18 @@
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { compareNonEmptyStrings } from '~/utils/compareNonEmptyStrings';
export const useIsViewAnyFieldFilterDifferentFromCurrentAnyFieldFilter = () => {
const { currentView } = useGetCurrentViewOnly();
const anyFieldFilterValue = useRecoilComponentValueV2(
anyFieldFilterValueComponentState,
);
const viewAnyFieldFilterValue = currentView?.anyFieldFilterValue;
const viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter =
!compareNonEmptyStrings(viewAnyFieldFilterValue, anyFieldFilterValue);
return { viewAnyFieldFilterDifferentFromCurrentAnyFieldFilter };
};

View File

@ -0,0 +1,43 @@
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { useUpdateView } from '@/views/hooks/useUpdateView';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useSaveAnyFieldFilterToView = () => {
const { updateView } = useUpdateView();
const { currentView } = useGetCurrentViewOnly();
const anyFieldFilterValueCallbackState = useRecoilComponentCallbackStateV2(
anyFieldFilterValueComponentState,
);
const saveAnyFieldFilterToView = useRecoilCallback(
({ snapshot }) =>
async () => {
if (!isDefined(currentView)) {
return;
}
const currentViewAnyFieldFilterValue = currentView?.anyFieldFilterValue;
const currentAnyFieldFilterValue = snapshot
.getLoadable(anyFieldFilterValueCallbackState)
.getValue();
if (currentAnyFieldFilterValue !== currentViewAnyFieldFilterValue) {
await updateView({
...currentView,
anyFieldFilterValue: currentAnyFieldFilterValue,
});
}
},
[updateView, anyFieldFilterValueCallbackState, currentView],
);
return {
saveAnyFieldFilterToView,
};
};

View File

@ -1,3 +1,4 @@
import { useSaveAnyFieldFilterToView } from '@/views/hooks/useSaveAnyFieldFilterToView';
import { useSaveRecordFilterGroupsToViewFilterGroups } from '@/views/hooks/useSaveRecordFilterGroupsToViewFilterGroups';
import { useSaveRecordFiltersToViewFilters } from '@/views/hooks/useSaveRecordFiltersToViewFilters';
import { useSaveRecordSortsToViewSorts } from '@/views/hooks/useSaveRecordSortsToViewSorts';
@ -11,10 +12,13 @@ export const useSaveCurrentViewFiltersAndSorts = () => {
const { saveRecordSortsToViewSorts } = useSaveRecordSortsToViewSorts();
const { saveAnyFieldFilterToView } = useSaveAnyFieldFilterToView();
const saveCurrentViewFilterAndSorts = async () => {
await saveRecordSortsToViewSorts();
await saveRecordFiltersToViewFilters();
await saveRecordFilterGroupsToViewFilterGroups();
await saveAnyFieldFilterToView();
};
return {

View File

@ -0,0 +1,9 @@
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2';
export const hasInitializedAnyFieldFilterComponentFamilyState =
createComponentFamilyStateV2<boolean, { viewId?: string }>({
key: 'hasInitializedAnyFieldFilterComponentFamilyState',
defaultValue: false,
componentInstanceContext: RecordFiltersComponentInstanceContext,
});

View File

@ -29,4 +29,5 @@ export type GraphQLView = {
viewGroups: ViewGroup[];
position: number;
icon: string;
anyFieldFilterValue?: string | null;
};

View File

@ -29,5 +29,6 @@ export type View = {
position: number;
icon: string;
openRecordIn: ViewOpenRecordInType;
anyFieldFilterValue?: string | null;
__typename: 'View';
};

View File

@ -0,0 +1,51 @@
import { compareNonEmptyStrings } from '~/utils/compareNonEmptyStrings';
describe('compareNonEmptyStrings', () => {
it('should return true for undefined === null', () => {
expect(compareNonEmptyStrings(undefined, null)).toBe(true);
});
it('should return true for null === undefined', () => {
expect(compareNonEmptyStrings(null, undefined)).toBe(true);
});
it('should return true for undefined === undefined', () => {
expect(compareNonEmptyStrings(undefined, undefined)).toBe(true);
});
it('should return true for null === null', () => {
expect(compareNonEmptyStrings(null, null)).toBe(true);
});
it('should return true for "" === null', () => {
expect(compareNonEmptyStrings('', null)).toBe(true);
});
it('should return true for "" === undefined', () => {
expect(compareNonEmptyStrings('', undefined)).toBe(true);
});
it('should return true for "" === ""', () => {
expect(compareNonEmptyStrings('', '')).toBe(true);
});
it('should return true for "a" === "a"', () => {
expect(compareNonEmptyStrings('a', 'a')).toBe(true);
});
it('should return false for "a" === "b"', () => {
expect(compareNonEmptyStrings('a', 'b')).toBe(false);
});
it('should return false for undefined === "a"', () => {
expect(compareNonEmptyStrings(undefined, 'a')).toBe(false);
});
it('should return false for null === "a"', () => {
expect(compareNonEmptyStrings(null, 'a')).toBe(false);
});
it('should return false for "" === "a"', () => {
expect(compareNonEmptyStrings('', 'a')).toBe(false);
});
});

View File

@ -0,0 +1,13 @@
import { isNonEmptyString } from '@sniptt/guards';
import { Nullable } from 'twenty-ui/utilities';
export const compareNonEmptyStrings = (
valueA: Nullable<string>,
valueB: Nullable<string>,
) => {
if (!isNonEmptyString(valueA) && !isNonEmptyString(valueB)) {
return true;
}
return valueA === valueB;
};

View File

@ -252,6 +252,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
deletedAt: workspaceView.deletedAt
? new Date(workspaceView.deletedAt)
: null,
anyFieldFilterValue: workspaceView.anyFieldFilterValue,
};
const repository = queryRunner.manager.getRepository(View);

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAnyFieldFilterValueColumnOnView1753349164408
implements MigrationInterface
{
name = 'CreateAnyFieldFilterValueColumnOnView1753349164408';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."view" ADD "anyFieldFilterValue" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."view" DROP COLUMN "anyFieldFilterValue"`,
);
}
}

View File

@ -88,6 +88,9 @@ export class View {
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date | null;
@Column({ nullable: true, type: 'text', default: null })
anyFieldFilterValue?: string | null;
@ManyToOne(() => Workspace, {
onDelete: 'CASCADE',
})

View File

@ -444,6 +444,7 @@ export const VIEW_STANDARD_FIELD_IDS = {
viewFilterGroups: '20202020-0318-474a-84a1-bac895ceaa5a',
viewSorts: '20202020-891b-45c3-9fe1-80a75b4aa043',
favorites: '20202020-c818-4a86-8284-9ec0ef0a59a5',
anyFieldFilterValue: '20202020-3143-46c0-bb05-034063ce0703',
};
export const WEBHOOK_STANDARD_FIELD_IDS = {

View File

@ -307,4 +307,14 @@ export class ViewWorkspaceEntity extends BaseWorkspaceEntity {
})
@WorkspaceIsNullable()
kanbanAggregateOperationFieldMetadataId?: string | null;
@WorkspaceField({
standardId: VIEW_STANDARD_FIELD_IDS.anyFieldFilterValue,
type: FieldMetadataType.TEXT,
label: msg`Any field filter value`,
description: msg`Any field filter value`,
defaultValue: null,
})
@WorkspaceIsNullable()
anyFieldFilterValue?: string | null;
}