Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add filter on array and jsonb field types #7839

Merged
merged 9 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import { renderHook } from '@testing-library/react';
import { Nullable } from 'twenty-ui';

import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { WorkspaceActivationStatus } from '~/generated/graphql';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';

const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
onInitializeRecoilSnapshot: ({ set }) => {
set(currentWorkspaceState, {
id: '1',
featureFlags: [],
allowImpersonation: false,
activationStatus: WorkspaceActivationStatus.Active,
metadataVersion: 1,
});
},
});

describe('useColumnDefinitionsFromFieldMetadata', () => {
it('should return empty definitions if no object is passed', () => {
const { result } = renderHook(
(objectMetadataItem?: Nullable<ObjectMetadataItem>) => {
return useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
},
{
wrapper: Wrapper,
},
);

expect(Array.isArray(result.current.columnDefinitions)).toBe(true);
Expand All @@ -32,6 +51,7 @@ describe('useColumnDefinitionsFromFieldMetadata', () => {
},
{
initialProps: companyObjectMetadata,
wrapper: Wrapper,
},
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns';

import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { formatFieldMetadataItemAsColumnDefinition } from '../utils/formatFieldMetadataItemAsColumnDefinition';
import { formatFieldMetadataItemsAsFilterDefinitions } from '../utils/formatFieldMetadataItemsAsFilterDefinitions';
import { formatFieldMetadataItemsAsSortDefinitions } from '../utils/formatFieldMetadataItemsAsSortDefinitions';
Expand All @@ -23,8 +24,13 @@ export const useColumnDefinitionsFromFieldMetadata = (
[objectMetadataItem],
);

const isArrayAndJsonFilterEnabled = useIsFeatureEnabled(
'IS_ARRAY_AND_JSON_FILTER_ENABLED',
);

const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({
fields: activeFieldMetadataItems,
isArrayAndJsonFilterEnabled,
});

const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { ObjectMetadataItem } from '../types/ObjectMetadataItem';

export const formatFieldMetadataItemsAsFilterDefinitions = ({
fields,
isArrayAndJsonFilterEnabled,
}: {
fields: Array<ObjectMetadataItem['fields'][0]>;
}): FilterDefinition[] =>
fields.reduce((acc, field) => {
isArrayAndJsonFilterEnabled: boolean;
}): FilterDefinition[] => {
return fields.reduce((acc, field) => {
if (
field.type === FieldMetadataType.Relation &&
field.relationDefinition?.direction !==
Expand All @@ -37,13 +39,17 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
FieldMetadataType.Rating,
FieldMetadataType.Actor,
FieldMetadataType.Phones,
...(isArrayAndJsonFilterEnabled
? [FieldMetadataType.Array, FieldMetadataType.RawJson]
: []),
].includes(field.type)
) {
return acc;
}

return [...acc, formatFieldMetadataItemAsFilterDefinition({ field })];
}, [] as FilterDefinition[]);
};

export const formatFieldMetadataItemAsFilterDefinition = ({
field,
Expand Down Expand Up @@ -92,6 +98,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
return 'ACTOR';
case FieldMetadataType.Array:
return 'ARRAY';
case FieldMetadataType.RawJson:
return 'RAW_JSON';
default:
return 'TEXT';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ export type PhonesFilter = {
primaryPhoneCountryCode?: StringFilter;
};

export type ArrayFilter = {
contains?: string[];
not_contains?: string[];
is?: IsFilter;
};

export type RawJsonFilter = {
like?: string;
is?: IsFilter;
};

export type LeafFilter =
| UUIDFilter
| StringFilter
Expand All @@ -117,6 +128,8 @@ export type LeafFilter =
| LinksFilter
| ActorFilter
| PhonesFilter
| ArrayFilter
| RawJsonFilter
| undefined;

export type AndObjectRecordFilter = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const ObjectFilterDropdownFilterInput = ({
'ADDRESS',
'ACTOR',
'ARRAY',
'RAW_JSON',
'PHONES',
].includes(filterDefinitionUsedInDropdown.type) &&
!isActorSourceCompositeFilter(filterDefinitionUsedInDropdown) && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ export type FilterableFieldType = PickLiteral<
| 'MULTI_SELECT'
| 'ACTOR'
| 'ARRAY'
| 'RAW_JSON'
>;
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export const getOperandsForFilterDefinition = (
case 'FULL_NAME':
case 'ADDRESS':
case 'LINKS':
case 'ARRAY':
case 'PHONES':
return [
ViewFilterOperand.Contains,
Expand All @@ -32,6 +31,12 @@ export const getOperandsForFilterDefinition = (
ViewFilterOperand.LessThan,
...emptyOperands,
];
case 'RAW_JSON':
return [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
...emptyOperands,
];
case 'DATE_TIME':
case 'DATE':
return [
Expand Down Expand Up @@ -70,6 +75,12 @@ export const getOperandsForFilterDefinition = (
...emptyOperands,
];
}
case 'ARRAY':
return [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
...emptyOperands,
];
default:
return [];
}
Comment on lines 84 to 86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Default case returns an empty array. Consider throwing an error or logging a warning for unexpected filter types

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
ActorFilter,
AddressFilter,
ArrayFilter,
CurrencyFilter,
DateFilter,
EmailsFilter,
FloatFilter,
RawJsonFilter,
RecordGqlOperationFilter,
RelationFilter,
StringFilter,
Expand Down Expand Up @@ -290,6 +292,24 @@ export const applyEmptyFilters = (
],
};
break;
case 'ARRAY':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: { is: 'NULL' } as ArrayFilter,
},
],
};
break;
case 'RAW_JSON':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: { is: 'NULL' } as RawJsonFilter,
},
],
};
break;
Comment on lines +304 to +312
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: The RAW_JSON filter only checks for NULL. Consider adding a check for empty object {}

case 'EMAILS':
emptyRecordFilter = {
or: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ArrayFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';

export const isMatchingArrayFilter = ({
arrayFilter,
value,
}: {
arrayFilter: ArrayFilter;
value: string[];
}) => {
if (value === null || !Array.isArray(value)) {
return false;
}

switch (true) {
case arrayFilter.contains !== undefined: {
return arrayFilter.contains.every((item) => value.includes(item));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: This implementation assumes value is a string and uses includes, which may not work correctly if value is actually an array. Consider using Array.isArray(value) && value.includes(item) for more robust checking.

}
case arrayFilter.not_contains !== undefined: {
return !arrayFilter.not_contains.some((item) => value.includes(item));
}
case arrayFilter.is !== undefined: {
if (arrayFilter.is === 'NULL') {
return value === null;
} else {
return value !== null;
}
}
default: {
throw new Error(
`Unexpected value for array filter: ${JSON.stringify(arrayFilter)}`,
);
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { RawJsonFilter } from '../../graphql/types/RecordGqlOperationFilter';

export const isMatchingRawJsonFilter = ({
rawJsonFilter,
value,
}: {
rawJsonFilter: RawJsonFilter;
value: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: value is typed as string, but could be null

}) => {
switch (true) {
case rawJsonFilter.like !== undefined: {
const regexPattern = rawJsonFilter.like.replace(/%/g, '.*');
const regexCaseInsensitive = new RegExp(`^${regexPattern}$`, 'i');

const stringValue = JSON.stringify(value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: JSON.stringify may fail if value is not JSON-serializable


return regexCaseInsensitive.test(stringValue);
}
case rawJsonFilter.is !== undefined: {
if (rawJsonFilter.is === 'NULL') {
return value === null;
} else {
return value !== null;
}
Comment on lines +20 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: consider using strict equality (===) for null checks

}
default: {
throw new Error(
`Unexpected value for string filter : ${JSON.stringify(rawJsonFilter)}`,
);
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ActorFilter,
AddressFilter,
AndObjectRecordFilter,
ArrayFilter,
BooleanFilter,
CurrencyFilter,
DateFilter,
Expand All @@ -16,14 +17,17 @@ import {
NotObjectRecordFilter,
OrObjectRecordFilter,
PhonesFilter,
RawJsonFilter,
RecordGqlOperationFilter,
StringFilter,
UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { isMatchingArrayFilter } from '@/object-record/record-filter/utils/isMatchingArrayFilter';
import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isMatchingBooleanFilter';
import { isMatchingCurrencyFilter } from '@/object-record/record-filter/utils/isMatchingCurrencyFilter';
import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter';
import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter';
import { isMatchingRawJsonFilter } from '@/object-record/record-filter/utils/isMatchingRawJsonFilter';
import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter';
import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter';
import { FieldMetadataType } from '~/generated-metadata/graphql';
Expand Down Expand Up @@ -165,6 +169,18 @@ export const isRecordMatchingFilter = ({
value: record[filterKey],
});
}
case FieldMetadataType.Array: {
return isMatchingArrayFilter({
arrayFilter: filterValue as ArrayFilter,
value: record[filterKey],
});
}
case FieldMetadataType.RawJson: {
return isMatchingRawJsonFilter({
rawJsonFilter: filterValue as RawJsonFilter,
value: record[filterKey],
});
}
case FieldMetadataType.FullName: {
const fullNameFilter = filterValue as FullNameFilter;

Expand Down Expand Up @@ -302,6 +318,7 @@ export const isRecordMatchingFilter = ({
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,
);
}

default: {
throw new Error(
`Not implemented yet for field type "${objectMetadataField.type}"`,
Expand Down
Loading
Loading