Add any field filter requests (#13336)

This PR adds any field filter request generation utils with its unit
test.

It also calls this new util in the relevant requests for table and
board.

This PR also adds a new corresponding state in context store so that the
filter is handled in command menu and actions.

We also add this new filter to the aggregate queries.

The RecordShowPage story was also fixed.
This commit is contained in:
Lucas Bordeau
2025-07-23 14:50:03 +02:00
committed by GitHub
parent 4d3124f840
commit e34ac2967c
32 changed files with 1136 additions and 39 deletions

View File

@ -1,5 +1,6 @@
import { ActionModal } from '@/action-menu/actions/components/ActionModal';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
@ -43,6 +44,10 @@ export const DeleteMultipleRecordsAction = () => {
contextStoreFiltersComponentState,
);
const contextStoreAnyFieldFilterValue = useRecoilComponentValueV2(
contextStoreAnyFieldFilterValueComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const graphqlFilter = computeContextStoreFilters(
@ -50,6 +55,7 @@ export const DeleteMultipleRecordsAction = () => {
contextStoreFilters,
objectMetadataItem,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
);
const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({

View File

@ -1,5 +1,6 @@
import { ActionModal } from '@/action-menu/actions/components/ActionModal';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
@ -43,6 +44,10 @@ export const DestroyMultipleRecordsAction = () => {
contextStoreFiltersComponentState,
);
const contextStoreAnyFieldFilterValue = useRecoilComponentValueV2(
contextStoreAnyFieldFilterValueComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const deletedAtFilter: RecordGqlOperationFilter = {
@ -54,6 +59,7 @@ export const DestroyMultipleRecordsAction = () => {
contextStoreFilters,
objectMetadataItem,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
),
...deletedAtFilter,
};

View File

@ -1,5 +1,6 @@
import { ActionModal } from '@/action-menu/actions/components/ActionModal';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
@ -43,6 +44,10 @@ export const RestoreMultipleRecordsAction = () => {
contextStoreFiltersComponentState,
);
const contextStoreAnyFieldFilterValue = useRecoilComponentValueV2(
contextStoreAnyFieldFilterValueComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const deletedAtFilter: RecordGqlOperationFilter = {
@ -55,6 +60,7 @@ export const RestoreMultipleRecordsAction = () => {
contextStoreFilters,
objectMetadataItem,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
),
...deletedAtFilter,
};

View File

@ -7,6 +7,7 @@ import { COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/command-menu/cons
import { useSetGlobalCommandMenuContext } from '@/command-menu/hooks/useSetGlobalCommandMenuContext';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
@ -80,6 +81,12 @@ describe('useSetGlobalCommandMenuContext', () => {
}),
);
const anyFieldFilterValue = useRecoilValue(
contextStoreAnyFieldFilterValueComponentState.atomFamily({
instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
}),
);
const currentViewType = useRecoilValue(
contextStoreCurrentViewTypeComponentState.atomFamily({
instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
@ -100,6 +107,7 @@ describe('useSetGlobalCommandMenuContext', () => {
currentViewType,
commandMenuPageInfo,
hasUserSelectedCommand,
anyFieldFilterValue,
};
},
{
@ -113,6 +121,7 @@ describe('useSetGlobalCommandMenuContext', () => {
});
expect(result.current.numberOfSelectedRecords).toBe(2);
expect(result.current.filters).toEqual([]);
expect(result.current.anyFieldFilterValue).toEqual('');
expect(result.current.currentViewType).toBe(ContextStoreViewType.Table);
expect(result.current.commandMenuPageInfo).toEqual({
title: undefined,
@ -131,6 +140,7 @@ describe('useSetGlobalCommandMenuContext', () => {
});
expect(result.current.numberOfSelectedRecords).toBe(0);
expect(result.current.filters).toEqual([]);
expect(result.current.anyFieldFilterValue).toEqual('');
expect(result.current.currentViewType).toBe(ContextStoreViewType.Table);
expect(result.current.commandMenuPageInfo).toEqual({
title: undefined,

View File

@ -1,3 +1,4 @@
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
@ -76,6 +77,21 @@ export const useCopyContextStoreStates = () => {
contextStoreFilters,
);
const contextStoreAnyFieldFilterValue = snapshot
.getLoadable(
contextStoreAnyFieldFilterValueComponentState.atomFamily({
instanceId: instanceIdToCopyFrom,
}),
)
.getValue();
set(
contextStoreAnyFieldFilterValueComponentState.atomFamily({
instanceId: instanceIdToCopyTo,
}),
contextStoreAnyFieldFilterValue,
);
const contextStoreCurrentViewId = snapshot
.getLoadable(
contextStoreCurrentViewIdComponentState.atomFamily({

View File

@ -1,3 +1,4 @@
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
@ -39,6 +40,13 @@ export const useResetContextStoreStates = () => {
[],
);
set(
contextStoreAnyFieldFilterValueComponentState.atomFamily({
instanceId,
}),
'',
);
set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId,

View File

@ -3,6 +3,7 @@ import { COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/command-menu/cons
import { useCopyContextStoreStates } from '@/command-menu/hooks/useCopyContextStoreAndActionMenuStates';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
@ -45,6 +46,13 @@ export const useSetGlobalCommandMenuContext = () => {
[],
);
set(
contextStoreAnyFieldFilterValueComponentState.atomFamily({
instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
}),
'',
);
set(
contextStoreCurrentViewTypeComponentState.atomFamily({
instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,

View File

@ -1,3 +1,4 @@
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
@ -36,6 +37,11 @@ export const useFindManyRecordsSelectedInContextStore = ({
instanceId,
);
const contextStoreAnyFieldFilterValue = useRecoilComponentValueV2(
contextStoreAnyFieldFilterValueComponentState,
instanceId,
);
const { filterValueDependencies } = useFilterValueDependencies();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
@ -58,9 +64,9 @@ export const useFindManyRecordsSelectedInContextStore = ({
const queryFilter = computeContextStoreFilters(
contextStoreTargetedRecordsRule,
contextStoreFilters,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
objectMetadataItem!,
objectMetadataItem,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
);
const { records, loading, totalCount } = useFindManyRecords({

View File

@ -0,0 +1,9 @@
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const contextStoreAnyFieldFilterValueComponentState =
createComponentStateV2<string>({
key: 'contextStoreAnyFieldFilterValueComponentState',
defaultValue: '',
componentInstanceContext: ContextStoreComponentInstanceContext,
});

View File

@ -26,10 +26,12 @@ describe('computeContextStoreFilters', () => {
[],
personObjectMetadataItem,
mockFilterValueDependencies,
'',
);
expect(filters).toEqual({
and: [
{},
{
id: {
in: ['1', '2', '3'],
@ -67,10 +69,12 @@ describe('computeContextStoreFilters', () => {
contextStoreFilters,
personObjectMetadataItem,
mockFilterValueDependencies,
'',
);
expect(filters).toEqual({
and: [
{},
{
or: [
{

View File

@ -4,6 +4,7 @@ import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGq
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { turnAnyFieldFilterIntoRecordGqlFilter } from '@/object-record/record-filter/utils/turnAnyFieldFilterIntoRecordGqlFilter';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
export const computeContextStoreFilters = (
@ -11,11 +12,19 @@ export const computeContextStoreFilters = (
contextStoreFilters: RecordFilter[],
objectMetadataItem: ObjectMetadataItem,
filterValueDependencies: RecordFilterValueDependencies,
anyFieldFilterValue: string,
) => {
let queryFilter: RecordGqlOperationFilter | undefined;
const { recordGqlOperationFilter: recordGqlFilterForAnyFieldFilter } =
turnAnyFieldFilterIntoRecordGqlFilter({
filterValue: anyFieldFilterValue,
objectMetadataItem,
});
if (contextStoreTargetedRecordsRule.mode === 'exclusion') {
queryFilter = makeAndFilterVariables([
recordGqlFilterForAnyFieldFilter,
computeRecordGqlOperationFilter({
filterValueDependencies,
fields: objectMetadataItem?.fields ?? [],
@ -35,6 +44,7 @@ export const computeContextStoreFilters = (
}
if (contextStoreTargetedRecordsRule.mode === 'selection') {
queryFilter = makeAndFilterVariables([
recordGqlFilterForAnyFieldFilter,
contextStoreTargetedRecordsRule.selectedRecordIds.length > 0
? {
id: {

View File

@ -1,25 +1,25 @@
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { viewAnyFieldSearchValueComponentState } from '@/views/states/viewAnyFieldSearchValueComponentState';
import { useLingui } from '@lingui/react/macro';
export const ObjectFilterDropdownAnyFieldSearchInput = () => {
const { t } = useLingui();
const [viewAnyFieldSearchValue, setViewAnyFieldSearchValue] =
useRecoilComponentStateV2(viewAnyFieldSearchValueComponentState);
const [anyFieldFilterSearchValue, setAnyFieldFilterSearchValue] =
useRecoilComponentStateV2(anyFieldFilterValueComponentState);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = event.target.value;
setViewAnyFieldSearchValue(inputValue);
setAnyFieldFilterSearchValue(inputValue);
};
return (
<DropdownMenuSearchInput
autoFocus
type="text"
value={viewAnyFieldSearchValue}
value={anyFieldFilterSearchValue}
placeholder={t`Search any field`}
onChange={handleSearchChange}
/>

View File

@ -30,8 +30,8 @@ export const useActorFieldDisplay = (): ActorFieldDisplayValue | undefined => {
}
const relatedWorkspaceMember = [
...currentWorkspaceDeletedMembers,
...currentWorkspaceMembers,
...(currentWorkspaceDeletedMembers ?? []),
...(currentWorkspaceMembers ?? []),
].find(
(workspaceMember) => workspaceMember.id === fieldValue.workspaceMemberId,
);

View File

@ -0,0 +1,10 @@
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
export const anyFieldFilterValueComponentState = createComponentStateV2<string>(
{
key: 'anyFieldFilterValueComponentState',
defaultValue: '',
componentInstanceContext: RecordFiltersComponentInstanceContext,
},
);

View File

@ -0,0 +1,603 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { filterSelectOptionsOfFieldMetadataItem } from '@/object-record/record-filter/utils/filterSelectOptionsOfFieldMetadataItem';
import { turnAnyFieldFilterIntoRecordGqlFilter } from '@/object-record/record-filter/utils/turnAnyFieldFilterIntoRecordGqlFilter';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const baseFieldMetadataItem: FieldMetadataItem = {
id: 'base-field-metadata-item-id',
createdAt: new Date().toISOString(),
label: 'Test',
name: 'test',
type: FieldMetadataType.TEXT,
updatedAt: new Date().toISOString(),
};
const textFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'text-field-id',
name: 'textField',
};
const addressFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'address-field-id',
type: FieldMetadataType.ADDRESS,
name: 'addressField',
};
const linksFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'links-field-id',
type: FieldMetadataType.LINKS,
name: 'linksField',
};
const fullNameFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'full-name-field-id',
type: FieldMetadataType.FULL_NAME,
name: 'fullNameField',
};
const arrayFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'array-field-id',
type: FieldMetadataType.ARRAY,
name: 'arrayField',
};
const emailsFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'emails-field-id',
type: FieldMetadataType.EMAILS,
name: 'emailsField',
};
const phonesFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'phones-field-id',
type: FieldMetadataType.PHONES,
name: 'phonesField',
};
const numberFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'number-field-id',
type: FieldMetadataType.NUMBER,
name: 'numberField',
};
const currencyFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'currency-field-id',
type: FieldMetadataType.CURRENCY,
name: 'currencyField',
};
const selectFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'select-field-id',
type: FieldMetadataType.SELECT,
name: 'selectField',
options: [
{
color: 'blue',
id: '1',
label: 'blue',
position: 1,
value: 'BLUE',
},
{
color: 'red',
id: '2',
label: 'red',
position: 2,
value: 'RED',
},
],
};
const multiSelectFieldMetadataItem: FieldMetadataItem = {
...baseFieldMetadataItem,
id: 'multi-select-field-id',
type: FieldMetadataType.MULTI_SELECT,
name: 'multiSelect',
options: [
{
color: 'blue',
id: '1',
label: 'blue',
position: 1,
value: 'BLUE',
},
{
color: 'red',
id: '2',
label: 'red',
position: 2,
value: 'RED',
},
],
};
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'mock-object-metadata-item',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
indexMetadatas: [],
isActive: true,
isCustom: true,
isLabelSyncedWithName: true,
isRemote: false,
isSearchable: true,
isSystem: false,
labelIdentifierFieldMetadataId: 'mock-id',
labelPlural: 'Tests',
labelSingular: 'Test',
nameSingular: 'test',
namePlural: 'tests',
fields: [],
};
const mockObjectMetadataItemWithAllFields: ObjectMetadataItem = {
...mockObjectMetadataItem,
fields: [
textFieldMetadataItem,
addressFieldMetadataItem,
linksFieldMetadataItem,
fullNameFieldMetadataItem,
arrayFieldMetadataItem,
emailsFieldMetadataItem,
phonesFieldMetadataItem,
numberFieldMetadataItem,
currencyFieldMetadataItem,
selectFieldMetadataItem,
multiSelectFieldMetadataItem,
],
};
describe('turnAnyFieldFilterIntoRecordGqlFilter', () => {
describe('TEXT field type', () => {
it('should generate correct filter for text field', () => {
const filterValue = 'test';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [textFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[textFieldMetadataItem.name]: {
ilike: `%${filterValue}%`,
},
});
});
});
describe('ADDRESS field type', () => {
it('should generate correct filter for address field', () => {
const filterValue = 'New York';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [addressFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
or: [
{
[addressFieldMetadataItem.name]: {
addressStreet1: {
ilike: `%${filterValue}%`,
},
},
},
{
[addressFieldMetadataItem.name]: {
addressStreet2: {
ilike: `%${filterValue}%`,
},
},
},
{
[addressFieldMetadataItem.name]: {
addressCity: {
ilike: `%${filterValue}%`,
},
},
},
{
[addressFieldMetadataItem.name]: {
addressState: {
ilike: `%${filterValue}%`,
},
},
},
{
[addressFieldMetadataItem.name]: {
addressCountry: {
ilike: `%${filterValue}%`,
},
},
},
{
[addressFieldMetadataItem.name]: {
addressPostcode: {
ilike: `%${filterValue}%`,
},
},
},
],
});
});
});
describe('LINKS field type', () => {
it('should generate correct filter for links field', () => {
const filterValue = 'test';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [linksFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
or: [
{
[linksFieldMetadataItem.name]: {
primaryLinkUrl: {
ilike: `%${filterValue}%`,
},
},
},
{
[linksFieldMetadataItem.name]: {
primaryLinkLabel: {
ilike: `%${filterValue}%`,
},
},
},
{
[linksFieldMetadataItem.name]: {
secondaryLinks: {
like: `%${filterValue}%`,
},
},
},
],
});
});
});
describe('FULL_NAME field type', () => {
it('should generate correct filter for full name field', () => {
const filterValue = 'test';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [fullNameFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
or: [
{
[fullNameFieldMetadataItem.name]: {
firstName: {
ilike: `%${filterValue}%`,
},
},
},
{
[fullNameFieldMetadataItem.name]: {
lastName: {
ilike: `%${filterValue}%`,
},
},
},
],
});
});
});
describe('ARRAY field type', () => {
it('should generate correct filter for array field', () => {
const filterValue = 'test';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [arrayFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[arrayFieldMetadataItem.name]: {
containsIlike: `%${filterValue}%`,
},
});
});
});
describe('EMAILS field type', () => {
it('should generate correct filter for emails field', () => {
const filterValue = 'test';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [emailsFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
or: [
{
[emailsFieldMetadataItem.name]: {
primaryEmail: {
ilike: `%${filterValue}%`,
},
},
},
{
[emailsFieldMetadataItem.name]: {
additionalEmails: {
like: `%${filterValue}%`,
},
},
},
],
});
});
});
describe('PHONES field type', () => {
it('should generate correct filter for phones field', () => {
const filterValue = '123';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [phonesFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
or: [
{
[phonesFieldMetadataItem.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
},
},
{
[phonesFieldMetadataItem.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
},
},
{
[phonesFieldMetadataItem.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
},
},
],
});
});
});
describe('NUMBER field type', () => {
it('should generate correct filter for number field with numeric value', () => {
const filterValue = '123.1';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [numberFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[numberFieldMetadataItem.name]: {
eq: 123.1,
},
});
});
it('should not generate filter for number field with non-numeric value', () => {
const filterValue = 'not a number';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [numberFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter).toEqual({});
});
});
describe('CURRENCY field type', () => {
it('should generate correct filter for currency field with numeric value', () => {
const filterValue = '123';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [currencyFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[currencyFieldMetadataItem.name]: {
amountMicros: {
eq: 123000000,
},
},
});
});
it('should generate correct filter for currency field with currency code', () => {
const filterValue = 'USD';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [currencyFieldMetadataItem],
},
});
expect(
(result.recordGqlOperationFilter.or as any)?.[0][
currencyFieldMetadataItem.name
].currencyCode.in.includes(filterValue),
).toBe(true);
});
});
describe('SELECT field type', () => {
it('should generate correct filter for select field with matching option', () => {
const filterValue = 'r';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [selectFieldMetadataItem],
},
});
const { foundCorrespondingSelectOptions: expectedOptions } =
filterSelectOptionsOfFieldMetadataItem({
fieldMetadataItem: selectFieldMetadataItem,
filterValue,
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[selectFieldMetadataItem.name]: {
in: expectedOptions?.map((option) => option.value),
},
});
});
it('should not generate filter for select field with non-matching value', () => {
const filterValue = 'not-found';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [selectFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter).toEqual({});
});
});
describe('MULTI_SELECT field type', () => {
it('should generate correct filter for multi-select field with matching option', () => {
const filterValue = 'r';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [multiSelectFieldMetadataItem],
},
});
const { foundCorrespondingSelectOptions: expectedOptions } =
filterSelectOptionsOfFieldMetadataItem({
fieldMetadataItem: multiSelectFieldMetadataItem,
filterValue,
});
expect(result.recordGqlOperationFilter.or).toContainEqual({
[multiSelectFieldMetadataItem.name]: {
containsAny: expectedOptions?.map((option) => option.value),
},
});
});
it('should not generate filter for multi-select field with non-matching value', () => {
const filterValue = 'not-found';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: {
...mockObjectMetadataItem,
fields: [multiSelectFieldMetadataItem],
},
});
expect(result.recordGqlOperationFilter).toEqual({});
});
});
describe('combined field filters', () => {
it('should generate OR filter combining all matching field types', () => {
const filterValue = 'a';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: mockObjectMetadataItemWithAllFields,
});
expect(result.recordGqlOperationFilter).toHaveProperty('or');
expect(Array.isArray(result.recordGqlOperationFilter.or)).toBe(true);
expect(
(result.recordGqlOperationFilter.or as any[])?.length,
).toBeGreaterThan(0);
});
it('should handle empty filter value', () => {
const filterValue = '';
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue,
objectMetadataItem: mockObjectMetadataItemWithAllFields,
});
expect(result.recordGqlOperationFilter).toEqual({});
});
it('should handle object with no fields', () => {
const emptyObjectMetadata = {
...mockObjectMetadataItem,
fields: [],
};
const result = turnAnyFieldFilterIntoRecordGqlFilter({
filterValue: 'test',
objectMetadataItem: emptyObjectMetadata,
});
expect(result.recordGqlOperationFilter).toEqual({});
});
});
});

View File

@ -303,6 +303,12 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
lte: parseFloat(recordFilter.value),
} as FloatFilter,
};
case RecordFilterOperand.Is:
return {
[correspondingFieldMetadataItem.name]: {
eq: parseFloat(recordFilter.value),
} as FloatFilter,
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${filterType} filter`,

View File

@ -0,0 +1,22 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { v4 } from 'uuid';
export const createAnyFieldRecordFilterBaseProperties = ({
filterValue,
fieldMetadataItem,
}: {
filterValue: string;
fieldMetadataItem: FieldMetadataItem;
}): Pick<
RecordFilter,
'id' | 'value' | 'displayValue' | 'label' | 'fieldMetadataId'
> => {
return {
id: v4(),
value: filterValue,
displayValue: '',
label: '',
fieldMetadataId: fieldMetadataItem.id,
};
};

View File

@ -0,0 +1,25 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
export const filterSelectOptionsOfFieldMetadataItem = ({
fieldMetadataItem,
filterValue,
}: {
fieldMetadataItem: FieldMetadataItem;
filterValue: string;
}) => {
const selectOptions = fieldMetadataItem.options;
const foundCorrespondingSelectOptions = selectOptions?.filter(
(selectOption) =>
selectOption.value
.toLocaleLowerCase()
.includes(filterValue.toLocaleLowerCase()) ||
selectOption.label
.toLocaleLowerCase()
.includes(filterValue.toLocaleLowerCase()),
);
return {
foundCorrespondingSelectOptions,
};
};

View File

@ -0,0 +1,236 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { turnRecordFilterIntoRecordGqlOperationFilter } from '@/object-record/record-filter/utils/compute-record-gql-operation-filter/turnRecordFilterIntoGqlOperationFilter';
import { createAnyFieldRecordFilterBaseProperties } from '@/object-record/record-filter/utils/createAnyFieldRecordFilterBaseProperties';
import { filterSelectOptionsOfFieldMetadataItem } from '@/object-record/record-filter/utils/filterSelectOptionsOfFieldMetadataItem';
import { CURRENCIES } from '@/settings/data-model/constants/Currencies';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isNonEmptyArray } from '~/utils/isNonEmptyArray';
export const turnAnyFieldFilterIntoRecordGqlFilter = ({
filterValue,
objectMetadataItem,
}: {
filterValue: string;
objectMetadataItem: ObjectMetadataItem;
}) => {
const fieldMetadataItems = objectMetadataItem.fields;
const anyFieldRecordFilters: RecordFilter[] = [];
const isFilterValueANumber = z.coerce.number().safeParse(filterValue).success;
for (const fieldMetadataItem of fieldMetadataItems) {
switch (fieldMetadataItem.type) {
case FieldMetadataType.TEXT: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem,
}),
operand: RecordFilterOperand.Contains,
type: 'TEXT',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.ADDRESS: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem,
}),
operand: RecordFilterOperand.Contains,
type: 'ADDRESS',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.LINKS: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem,
}),
operand: RecordFilterOperand.Contains,
type: 'LINKS',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.FULL_NAME: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem,
}),
operand: RecordFilterOperand.Contains,
type: 'FULL_NAME',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.ARRAY: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem,
}),
operand: RecordFilterOperand.Contains,
type: 'ARRAY',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.EMAILS: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem,
}),
operand: RecordFilterOperand.Contains,
type: 'EMAILS',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.PHONES: {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem,
}),
operand: RecordFilterOperand.Contains,
type: 'PHONES',
} satisfies RecordFilter);
break;
}
case FieldMetadataType.NUMBER: {
if (isFilterValueANumber) {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem,
}),
operand: RecordFilterOperand.Is,
type: 'NUMBER',
} satisfies RecordFilter);
}
break;
}
case FieldMetadataType.CURRENCY: {
if (isFilterValueANumber) {
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue,
fieldMetadataItem,
}),
operand: RecordFilterOperand.Is,
type: 'CURRENCY',
subFieldName: 'amountMicros',
} satisfies RecordFilter);
}
if (isNonEmptyString(filterValue)) {
const foundCorrespondingCurrencies = CURRENCIES.filter(
(currency) =>
currency.label.includes(filterValue) ||
currency.value.includes(filterValue),
);
if (isNonEmptyArray(foundCorrespondingCurrencies)) {
const arrayOfCurrenciesStringified = JSON.stringify(
foundCorrespondingCurrencies.map((currency) => currency.value),
);
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
filterValue: arrayOfCurrenciesStringified,
fieldMetadataItem,
}),
operand: RecordFilterOperand.Is,
type: 'CURRENCY',
subFieldName: 'currencyCode',
} satisfies RecordFilter);
}
}
break;
}
case FieldMetadataType.SELECT: {
if (isNonEmptyString(filterValue)) {
const { foundCorrespondingSelectOptions } =
filterSelectOptionsOfFieldMetadataItem({
fieldMetadataItem,
filterValue,
});
if (isNonEmptyArray(foundCorrespondingSelectOptions)) {
const arrayOfSelectValues = JSON.stringify(
foundCorrespondingSelectOptions.map(
(selectOption) => selectOption.value,
),
);
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
fieldMetadataItem,
filterValue: arrayOfSelectValues,
}),
operand: RecordFilterOperand.Is,
type: 'SELECT',
} satisfies RecordFilter);
}
}
break;
}
case FieldMetadataType.MULTI_SELECT: {
if (isNonEmptyString(filterValue)) {
const { foundCorrespondingSelectOptions } =
filterSelectOptionsOfFieldMetadataItem({
fieldMetadataItem,
filterValue,
});
if (isNonEmptyArray(foundCorrespondingSelectOptions)) {
const arrayOfSelectValues = JSON.stringify(
foundCorrespondingSelectOptions.map(
(selectOption) => selectOption.value,
),
);
anyFieldRecordFilters.push({
...createAnyFieldRecordFilterBaseProperties({
fieldMetadataItem,
filterValue: arrayOfSelectValues,
}),
operand: RecordFilterOperand.Contains,
type: 'MULTI_SELECT',
} satisfies RecordFilter);
}
}
break;
}
}
}
const baseRecordGqlOperationFilters = anyFieldRecordFilters
.map((recordFilter) =>
turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies: {},
fieldMetadataItems: objectMetadataItem.fields,
recordFilter,
}),
)
.filter(isDefined);
const recordGqlOperationFilter: RecordGqlOperationFilter = {
or: baseRecordGqlOperationFilters,
};
if (baseRecordGqlOperationFilters.length === 0) {
return { recordGqlOperationFilter: {} };
}
return {
recordGqlOperationFilter,
};
};

View File

@ -1,3 +1,4 @@
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
@ -40,6 +41,10 @@ export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect =
contextStoreFiltersComponentState,
);
const contextStoreAnyFieldFilterValue = useRecoilComponentValueV2(
contextStoreAnyFieldFilterValueComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const { totalCount } = useFindManyRecords({
@ -52,6 +57,7 @@ export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect =
contextStoreFilters,
objectMetadataItem,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
),
limit: 1,
skip: contextStoreTargetedRecordsRule.mode === 'selection',

View File

@ -1,7 +1,9 @@
import { useEffect } from 'react';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
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 { hasUserSelectedAllRowsComponentState } from '@/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState';
@ -74,5 +76,22 @@ export const RecordIndexFiltersToContextStoreEffect = () => {
};
}, [recordIndexFilters, setContextStoreFilters]);
const setContextStoreAnyFieldFilterValue = useSetRecoilComponentStateV2(
contextStoreAnyFieldFilterValueComponentState,
);
const anyFieldFilterValue = useRecoilComponentValueV2(
anyFieldFilterValueComponentState,
recordIndexId,
);
useEffect(() => {
setContextStoreAnyFieldFilterValue(anyFieldFilterValue);
return () => {
setContextStoreAnyFieldFilterValue('');
};
}, [anyFieldFilterValue, setContextStoreAnyFieldFilterValue]);
return <></>;
};

View File

@ -2,6 +2,7 @@ import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
@ -72,6 +73,10 @@ export const useRecordIndexLazyFetchRecords = ({
contextStoreFiltersComponentState,
);
const contextStoreAnyFieldFilterValue = useRecoilComponentValueV2(
contextStoreAnyFieldFilterValueComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const findManyRecordsParams = useFindManyRecordIndexTableParams(
@ -83,6 +88,7 @@ export const useRecordIndexLazyFetchRecords = ({
contextStoreFilters,
objectMetadataItem,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
);
const finalColumns = [

View File

@ -2,9 +2,11 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { combineFilters } from '@/object-record/record-filter/utils/combineFilters';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { turnAnyFieldFilterIntoRecordGqlFilter } from '@/object-record/record-filter/utils/turnAnyFieldFilterIntoRecordGqlFilter';
import { useCurrentRecordGroupDefinition } from '@/object-record/record-group/hooks/useCurrentRecordGroupDefinition';
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
@ -37,18 +39,28 @@ export const useFindManyRecordIndexTableParams = (
const { filterValueDependencies } = useFilterValueDependencies();
const stateFilter = computeRecordGqlOperationFilter({
const currentFilters = computeRecordGqlOperationFilter({
fields: objectMetadataItem?.fields ?? [],
filterValueDependencies,
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
});
const anyFieldFilterValue = useRecoilComponentValueV2(
anyFieldFilterValueComponentState,
);
const { recordGqlOperationFilter: anyFieldFilter } =
turnAnyFieldFilterIntoRecordGqlFilter({
objectMetadataItem,
filterValue: anyFieldFilterValue,
});
const orderBy = turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts);
return {
objectNameSingular,
filter: combineFilters([stateFilter, recordGroupFilter]),
filter: combineFilters([currentFilters, recordGroupFilter, anyFieldFilter]),
orderBy,
// If we have a current record group definition, we only want to fetch 8 records by page
...(currentRecordGroupDefinition ? { limit: 8 } : {}),

View File

@ -15,7 +15,9 @@ import { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hook
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { combineFilters } from '@/object-record/record-filter/utils/combineFilters';
import { turnAnyFieldFilterIntoRecordGqlFilter } from '@/object-record/record-filter/utils/turnAnyFieldFilterIntoRecordGqlFilter';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-shared/utils';
@ -64,6 +66,16 @@ export const useLoadRecordIndexBoardColumn = ({
fields: objectMetadataItem.fields,
});
const anyFieldFilterValue = useRecoilComponentValueV2(
anyFieldFilterValueComponentState,
);
const { recordGqlOperationFilter: anyFieldFilter } =
turnAnyFieldFilterIntoRecordGqlFilter({
objectMetadataItem,
filterValue: anyFieldFilterValue,
});
const orderBy = turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts);
const recordGqlFields = useRecordBoardRecordGqlFields({
@ -78,6 +90,7 @@ export const useLoadRecordIndexBoardColumn = ({
: { is: 'NULL' };
const combinedFilters = combineFilters([
anyFieldFilter,
requestFilters,
{
[kanbanFieldMetadataItem.name]: recordIndexKanbanFieldMetadataFilterValue,

View File

@ -4,8 +4,10 @@ import { buildRecordGqlFieldsAggregateForView } from '@/object-record/record-boa
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { turnAnyFieldFilterIntoRecordGqlFilter } from '@/object-record/record-filter/utils/turnAnyFieldFilterIntoRecordGqlFilter';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { UserContext } from '@/users/contexts/UserContext';
@ -54,10 +56,20 @@ export const useAggregateRecordsForHeader = ({
recordIndexKanbanAggregateOperation,
});
const anyFieldFilterValue = useRecoilComponentValueV2(
anyFieldFilterValueComponentState,
);
const { recordGqlOperationFilter: anyFieldFilter } =
turnAnyFieldFilterIntoRecordGqlFilter({
objectMetadataItem,
filterValue: anyFieldFilterValue,
});
const { data } = useAggregateRecords({
objectNameSingular: objectMetadataItem.nameSingular,
recordGqlFieldsAggregate,
filter: { ...requestFilters, ...additionalFilters },
filter: { ...requestFilters, ...additionalFilters, ...anyFieldFilter },
});
const { value, labelWithFieldName } = computeAggregateValueAndLabel({

View File

@ -2,8 +2,10 @@ import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { turnAnyFieldFilterIntoRecordGqlFilter } from '@/object-record/record-filter/utils/turnAnyFieldFilterIntoRecordGqlFilter';
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
@ -85,10 +87,20 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
}
: {};
const anyFieldFilterValue = useRecoilComponentValueV2(
anyFieldFilterValueComponentState,
);
const { recordGqlOperationFilter: anyFieldFilter } =
turnAnyFieldFilterIntoRecordGqlFilter({
objectMetadataItem,
filterValue: anyFieldFilterValue,
});
const { data, loading } = useAggregateRecords({
objectNameSingular: objectMetadataItem.nameSingular,
recordGqlFieldsAggregate,
filter: { ...requestFilters, ...recordGroupFilter },
filter: { ...requestFilters, ...recordGroupFilter, ...anyFieldFilter },
skip: !isDefined(aggregateOperationForViewField),
});

View File

@ -1,26 +1,29 @@
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId';
import { viewAnyFieldSearchValueComponentState } from '@/views/states/viewAnyFieldSearchValueComponentState';
import { useLingui } from '@lingui/react/macro';
import { IconFilter } from 'twenty-ui/display';
export const AnyFieldSearchChip = () => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const [viewAnyFieldSearchValue, setViewAnyFieldSearchValue] =
useRecoilComponentStateV2(viewAnyFieldSearchValueComponentState);
const [anyFieldFilterValue, setAnyFieldFilterValue] =
useRecoilComponentStateV2(anyFieldFilterValueComponentState);
const handleRemoveClick = () => {
closeDropdown();
setViewAnyFieldSearchValue('');
setAnyFieldFilterValue('');
};
return (
<SortOrFilterChip
testId={ADVANCED_FILTER_DROPDOWN_ID}
labelKey={'Any field :'}
labelValue={viewAnyFieldSearchValue}
labelKey={t`Any field :`}
labelValue={anyFieldFilterValue}
Icon={IconFilter}
onRemove={handleRemoveClick}
type="filter"

View File

@ -22,6 +22,7 @@ import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAr
import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AnyFieldSearchDropdownButton } from '@/views/components/AnyFieldSearchDropdownButton';
@ -29,7 +30,6 @@ import { ANY_FIELD_SEARCH_DROPDOWN_ID } from '@/views/constants/AnyFieldSearchDr
import { useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups } from '@/views/hooks/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups';
import { useAreViewFilterGroupsDifferentFromRecordFilterGroups } from '@/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups';
import { isViewBarExpandedComponentState } from '@/views/states/isViewBarExpandedComponentState';
import { viewAnyFieldSearchValueComponentState } from '@/views/states/viewAnyFieldSearchValueComponentState';
import { t } from '@lingui/core/macro';
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
@ -123,8 +123,8 @@ export const ViewBarDetails = ({
currentRecordSortsComponentState,
);
const viewAnyFieldSearchValue = useRecoilComponentValueV2(
viewAnyFieldSearchValueComponentState,
const anyFieldFilterValue = useRecoilComponentValueV2(
anyFieldFilterValueComponentState,
);
const { objectNameSingular } = useObjectNameSingularFromPlural({
@ -189,7 +189,7 @@ export const ViewBarDetails = ({
);
const shouldShowAnyFieldSearchChip =
isNonEmptyString(viewAnyFieldSearchValue) || isAnyFieldSearchDropdownOpen;
isNonEmptyString(anyFieldFilterValue) || isAnyFieldSearchDropdownOpen;
const shouldExpandViewBar =
shouldShowAnyFieldSearchChip ||

View File

@ -2,8 +2,10 @@ import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { turnAnyFieldFilterIntoRecordGqlFilter } from '@/object-record/record-filter/utils/turnAnyFieldFilterIntoRecordGqlFilter';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetViewGroupsFilters } from '@/views/hooks/useGetViewGroupsFilters';
@ -30,11 +32,21 @@ export const useGetRecordIndexTotalCount = () => {
fields: objectMetadataItem.fields,
});
const anyFieldFilterValue = useRecoilComponentValueV2(
anyFieldFilterValueComponentState,
);
const { recordGqlOperationFilter: anyFieldFilter } =
turnAnyFieldFilterIntoRecordGqlFilter({
objectMetadataItem,
filterValue: anyFieldFilterValue,
});
const { data, loading } = useAggregateRecords<{
id: { COUNT: number };
}>({
objectNameSingular: objectMetadataItem.nameSingular,
filter,
filter: { ...filter, ...anyFieldFilter },
recordGqlFieldsAggregate: {
id: [AggregateOperations.COUNT],
},

View File

@ -1,15 +1,19 @@
import { objectFilterDropdownAnyFieldSearchIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownAnyFieldSearchIsSelectedComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { viewAnyFieldSearchValueComponentState } from '@/views/states/viewAnyFieldSearchValueComponentState';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
export const useOpenAnyFieldSearchFilterFromViewBar = () => {
const setViewAnyFieldSearchValueComponentState = useSetRecoilComponentStateV2(
viewAnyFieldSearchValueComponentState,
const setAnyFieldFilterValue = useSetRecoilComponentStateV2(
anyFieldFilterValueComponentState,
);
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const setObjectFilterDropdownAnyFieldSearchIsSelectedComponentState =
useSetRecoilComponentStateV2(
objectFilterDropdownAnyFieldSearchIsSelectedComponentState,
@ -19,14 +23,29 @@ export const useOpenAnyFieldSearchFilterFromViewBar = () => {
objectFilterDropdownSearchInputComponentState,
);
const translatedLabel = t`Search any field`;
const openAnyFieldSearchFilterFromViewBar = () => {
const userHasAlreadyEnteredSearchInputForObjectDropdownSearch =
isNonEmptyString(objectFilterDropdownSearchInput);
if (userHasAlreadyEnteredSearchInputForObjectDropdownSearch) {
const userInputIsMatchingAListMenuItem =
objectMetadataItem.fields.some((fieldMetadataItem) =>
fieldMetadataItem.label
.toLocaleLowerCase()
.includes(objectFilterDropdownSearchInput.toLocaleLowerCase()),
) ||
translatedLabel
.toLocaleLowerCase()
.includes(objectFilterDropdownSearchInput.toLocaleLowerCase());
if (
userHasAlreadyEnteredSearchInputForObjectDropdownSearch &&
!userInputIsMatchingAListMenuItem
) {
const filterValue = objectFilterDropdownSearchInput;
setViewAnyFieldSearchValueComponentState(filterValue);
setAnyFieldFilterValue(filterValue);
}
setObjectFilterDropdownAnyFieldSearchIsSelectedComponentState(true);

View File

@ -1,9 +0,0 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
export const viewAnyFieldSearchValueComponentState =
createComponentStateV2<string>({
key: 'viewAnyFieldSearchValueComponentState',
defaultValue: '',
componentInstanceContext: ViewComponentInstanceContext,
});

View File

@ -14,6 +14,7 @@ import {
} from '~/testing/mock-data/people';
import { mockedWorkspaceMemberData } from '~/testing/mock-data/users';
import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator';
import { RecordShowPage } from '../RecordShowPage';
const personRecord = allMockPersonRecords[0];
@ -62,7 +63,7 @@ export type Story = StoryObj<typeof RecordShowPage>;
export const Default: Story = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
decorators: [PageDecorator],
decorators: [PageDecorator, ContextStoreDecorator],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);