Add the support of Empty and Non-Empty filter (#5773)

This commit is contained in:
Pacifique LINJANJA
2024-06-20 18:18:12 +02:00
committed by GitHub
parent 9e08445bff
commit 9228667a57
11 changed files with 478 additions and 56 deletions

View File

@ -9,6 +9,11 @@ export type UUIDFilter = {
is?: IsFilter;
};
export type RelationFilter = {
is?: IsFilter;
in?: UUIDFilterValue[];
};
export type BooleanFilter = {
eq?: boolean;
is?: IsFilter;

View File

@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil';
import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { MultipleFiltersDropdownFilterOnFilterChangedEffect } from './MultipleFiltersDropdownFilterOnFilterChangedEffect';
import { ObjectFilterDropdownDateInput } from './ObjectFilterDropdownDateInput';
@ -36,6 +37,11 @@ export const MultipleFiltersDropdownContent = ({
const selectedOperandInDropdown = useRecoilValue(
selectedOperandInDropdownState,
);
const isEmptyOperand =
selectedOperandInDropdown &&
[ViewFilterOperand.IsEmpty, ViewFilterOperand.IsNotEmpty].includes(
selectedOperandInDropdown,
);
return (
<>
@ -43,6 +49,8 @@ export const MultipleFiltersDropdownContent = ({
<ObjectFilterDropdownFilterSelect />
) : isObjectFilterDropdownOperandSelectUnfolded ? (
<ObjectFilterDropdownOperandSelect />
) : isEmptyOperand ? (
<ObjectFilterDropdownOperandButton />
) : (
selectedOperandInDropdown && (
<>

View File

@ -2,6 +2,7 @@ import { useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown';
import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
@ -34,10 +35,27 @@ export const ObjectFilterDropdownOperandSelect = () => {
filterDefinitionUsedInDropdown?.type,
);
const handleOperangeChange = (newOperand: ViewFilterOperand) => {
const handleOperandChange = (newOperand: ViewFilterOperand) => {
const isEmptyOperand = [
ViewFilterOperand.IsEmpty,
ViewFilterOperand.IsNotEmpty,
].includes(newOperand);
setSelectedOperandInDropdown(newOperand);
setIsObjectFilterDropdownOperandSelectUnfolded(false);
if (isEmptyOperand) {
selectFilter?.({
id: v4(),
fieldMetadataId: filterDefinitionUsedInDropdown?.fieldMetadataId ?? '',
displayValue: '',
operand: newOperand,
value: '',
definition: filterDefinitionUsedInDropdown as FilterDefinition,
});
return;
}
if (
isDefined(filterDefinitionUsedInDropdown) &&
isDefined(selectedFilter)
@ -63,7 +81,7 @@ export const ObjectFilterDropdownOperandSelect = () => {
<MenuItem
key={`select-filter-operand-${index}`}
onClick={() => {
handleOperangeChange(filterOperand);
handleOperandChange(filterOperand);
}}
text={getOperandLabel(filterOperand)}
/>

View File

@ -4,20 +4,34 @@ import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { getOperandsForFilterType } from '../getOperandsForFilterType';
describe('getOperandsForFilterType', () => {
const emptyOperands = [
ViewFilterOperand.IsEmpty,
ViewFilterOperand.IsNotEmpty,
];
const containsOperands = [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
];
const numberOperands = [
ViewFilterOperand.GreaterThan,
ViewFilterOperand.LessThan,
];
const relationOperand = [ViewFilterOperand.Is, ViewFilterOperand.IsNot];
const testCases = [
['TEXT', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]],
['EMAIL', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]],
[
'FULL_NAME',
[ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain],
],
['ADDRESS', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]],
['LINK', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]],
['LINKS', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]],
['CURRENCY', [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]],
['NUMBER', [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]],
['DATE_TIME', [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]],
['RELATION', [ViewFilterOperand.Is, ViewFilterOperand.IsNot]],
['TEXT', [...containsOperands, ...emptyOperands]],
['EMAIL', [...containsOperands, ...emptyOperands]],
['FULL_NAME', [...containsOperands, ...emptyOperands]],
['ADDRESS', [...containsOperands, ...emptyOperands]],
['LINK', [...containsOperands, ...emptyOperands]],
['LINKS', [...containsOperands, ...emptyOperands]],
['CURRENCY', [...numberOperands, ...emptyOperands]],
['NUMBER', [...numberOperands, ...emptyOperands]],
['DATE_TIME', [...numberOperands, ...emptyOperands]],
['RELATION', [...relationOperand, ...emptyOperands]],
[undefined, []],
[null, []],
['UNKNOWN_TYPE', []],

View File

@ -18,6 +18,10 @@ export const getOperandLabel = (
return 'Is not';
case ViewFilterOperand.IsNotNull:
return 'Is not null';
case ViewFilterOperand.IsEmpty:
return 'Is empty';
case ViewFilterOperand.IsNotEmpty:
return 'Is not empty';
default:
return '';
}
@ -35,6 +39,10 @@ export const getOperandLabelShort = (
return ': Not';
case ViewFilterOperand.IsNotNull:
return ': NotNull';
case ViewFilterOperand.IsNotEmpty:
return ': NotEmpty';
case ViewFilterOperand.IsEmpty:
return ': Empty';
case ViewFilterOperand.GreaterThan:
return '\u00A0> ';
case ViewFilterOperand.LessThan:

View File

@ -5,6 +5,13 @@ import { FilterType } from '../types/FilterType';
export const getOperandsForFilterType = (
filterType: FilterType | null | undefined,
): ViewFilterOperand[] => {
const emptyOperands = [
ViewFilterOperand.IsEmpty,
ViewFilterOperand.IsNotEmpty,
];
const relationOperands = [ViewFilterOperand.Is, ViewFilterOperand.IsNot];
switch (filterType) {
case 'TEXT':
case 'EMAIL':
@ -12,17 +19,25 @@ export const getOperandsForFilterType = (
case 'ADDRESS':
case 'PHONE':
case 'LINK':
return [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain];
case 'LINKS':
return [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain];
return [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,
...emptyOperands,
];
case 'CURRENCY':
case 'NUMBER':
case 'DATE_TIME':
case 'DATE':
return [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan];
return [
ViewFilterOperand.GreaterThan,
ViewFilterOperand.LessThan,
...emptyOperands,
];
case 'RELATION':
return [...relationOperands, ...emptyOperands];
case 'SELECT':
return [ViewFilterOperand.Is, ViewFilterOperand.IsNot];
return [...relationOperands];
default:
return [];
}

View File

@ -6,10 +6,12 @@ import {
DateFilter,
FloatFilter,
RecordGqlOperationFilter,
RelationFilter,
StringFilter,
URLFilter,
UUIDFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { FilterType } from '@/object-record/object-filter-dropdown/types/FilterType';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { Field } from '~/generated/graphql';
@ -24,6 +26,200 @@ export type ObjectDropdownFilter = Omit<Filter, 'definition'> & {
};
};
const applyEmptyFilters = (
operand: ViewFilterOperand,
correspondingField: Pick<Field, 'id' | 'name'>,
objectRecordFilters: RecordGqlOperationFilter[],
filterType: FilterType,
) => {
let emptyRecordFilter: RecordGqlOperationFilter = {};
switch (filterType) {
case 'TEXT':
case 'EMAIL':
case 'PHONE':
emptyRecordFilter = {
or: [
{ [correspondingField.name]: { ilike: '' } as StringFilter },
{ [correspondingField.name]: { is: 'NULL' } as StringFilter },
],
};
break;
case 'CURRENCY':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
amountMicros: { is: 'NULL' },
} as CurrencyFilter,
},
],
};
break;
case 'FULL_NAME': {
const fullNameFilters = generateILikeFiltersForCompositeFields(
'',
correspondingField.name,
['firstName', 'lastName'],
true,
);
emptyRecordFilter = {
and: fullNameFilters,
};
break;
}
case 'LINK':
emptyRecordFilter = {
or: [
{ [correspondingField.name]: { url: { ilike: '' } } as URLFilter },
{
[correspondingField.name]: { url: { is: 'NULL' } } as URLFilter,
},
],
};
break;
case 'LINKS': {
const linksFilters = generateILikeFiltersForCompositeFields(
'',
correspondingField.name,
['primaryLinkLabel', 'primaryLinkUrl'],
true,
);
emptyRecordFilter = {
and: linksFilters,
};
break;
}
case 'ADDRESS':
emptyRecordFilter = {
and: [
{
or: [
{
[correspondingField.name]: {
addressStreet1: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressStreet1: { is: 'NULL' },
} as AddressFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
addressStreet2: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressStreet2: { is: 'NULL' },
} as AddressFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
addressCity: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressCity: { is: 'NULL' },
} as AddressFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
addressState: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressState: { is: 'NULL' },
} as AddressFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
addressCountry: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressCountry: { is: 'NULL' },
} as AddressFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
addressPostcode: { ilike: '' },
} as AddressFilter,
},
{
[correspondingField.name]: {
addressPostcode: { is: 'NULL' },
} as AddressFilter,
},
],
},
],
};
break;
case 'NUMBER':
emptyRecordFilter = {
[correspondingField.name]: { is: 'NULL' } as FloatFilter,
};
break;
case 'DATE_TIME':
emptyRecordFilter = {
[correspondingField.name]: { is: 'NULL' } as DateFilter,
};
break;
case 'SELECT':
emptyRecordFilter = {
[correspondingField.name]: { is: 'NULL' } as UUIDFilter,
};
break;
case 'RELATION':
emptyRecordFilter = {
[correspondingField.name + 'Id']: { is: 'NULL' } as RelationFilter,
};
break;
default:
throw new Error(`Unsupported empty filter type ${filterType}`);
}
switch (operand) {
case ViewFilterOperand.IsEmpty:
objectRecordFilters.push(emptyRecordFilter);
break;
case ViewFilterOperand.IsNotEmpty:
objectRecordFilters.push({
not: emptyRecordFilter,
});
break;
default:
throw new Error(`Unknown operand ${operand} for ${filterType} filter`);
}
};
export const turnObjectDropdownFilterIntoQueryFilter = (
rawUIFilters: ObjectDropdownFilter[],
fields: Pick<Field, 'id' | 'name'>[],
@ -35,12 +231,19 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
(field) => field.id === rawUIFilter.fieldMetadataId,
);
const isEmptyOperand = [
ViewFilterOperand.IsEmpty,
ViewFilterOperand.IsNotEmpty,
].includes(rawUIFilter.operand);
if (!correspondingField) {
continue;
}
if (!isDefined(rawUIFilter.value) || rawUIFilter.value === '') {
continue;
if (!isEmptyOperand) {
if (!isDefined(rawUIFilter.value) || rawUIFilter.value === '') {
continue;
}
}
switch (rawUIFilter.definition.type) {
@ -64,6 +267,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
},
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
@ -86,6 +298,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
} as DateFilter,
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
@ -108,6 +329,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
} as FloatFilter,
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
@ -115,39 +345,57 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}
break;
case 'RELATION': {
try {
JSON.parse(rawUIFilter.value);
} catch (e) {
throw new Error(
`Cannot parse filter value for RELATION filter : "${rawUIFilter.value}"`,
);
}
if (!isEmptyOperand) {
try {
JSON.parse(rawUIFilter.value);
} catch (e) {
throw new Error(
`Cannot parse filter value for RELATION filter : "${rawUIFilter.value}"`,
);
}
const parsedRecordIds = JSON.parse(rawUIFilter.value) as string[];
const parsedRecordIds = JSON.parse(rawUIFilter.value) as string[];
if (parsedRecordIds.length > 0) {
switch (rawUIFilter.operand) {
case ViewFilterOperand.Is:
objectRecordFilters.push({
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as UUIDFilter,
});
break;
case ViewFilterOperand.IsNot:
if (parsedRecordIds.length > 0) {
if (parsedRecordIds.length > 0) {
switch (rawUIFilter.operand) {
case ViewFilterOperand.Is:
objectRecordFilters.push({
not: {
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as UUIDFilter,
},
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as RelationFilter,
});
}
break;
case ViewFilterOperand.IsNot:
if (parsedRecordIds.length > 0) {
objectRecordFilters.push({
not: {
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as RelationFilter,
},
});
}
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
}
} else {
switch (rawUIFilter.operand) {
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
`Unknown empty operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
}
@ -169,6 +417,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
} as CurrencyFilter,
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
@ -197,6 +454,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
},
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
@ -224,6 +490,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}),
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
@ -231,7 +506,6 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}
break;
}
case 'FULL_NAME': {
const fullNameFilters = generateILikeFiltersForCompositeFields(
rawUIFilter.value,
@ -253,6 +527,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}),
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
@ -286,6 +569,27 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressState: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressCountry: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingField.name]: {
addressPostcode: {
ilike: `%${rawUIFilter.value}%`,
},
} as AddressFilter,
},
],
});
break;
@ -322,6 +626,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
],
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
@ -329,6 +642,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}
break;
case 'SELECT': {
if (isEmptyOperand) {
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
}
const stringifiedSelectValues = rawUIFilter.value;
let parsedOptionValues: string[] = [];

View File

@ -49,7 +49,7 @@ describe('generateCsv', () => {
},
];
const csv = generateCsv({ columns, rows });
expect(csv).toEqual(`id,Foo,Empty,Nested Foo,Nested Nested,Relation
expect(csv).toEqual(`Id,Foo,Empty,Nested Foo,Nested Nested,Relation
1,some field,,foo,nested,a relation`);
});
});

View File

@ -6,4 +6,6 @@ export enum ViewFilterOperand {
GreaterThan = 'greaterThan',
Contains = 'contains',
DoesNotContain = 'doesNotContain',
IsEmpty = 'isEmpty',
IsNotEmpty = 'isNotEmpty',
}

View File

@ -4,7 +4,31 @@ export const generateILikeFiltersForCompositeFields = (
filterString: string,
baseFieldName: string,
subFields: string[],
emptyCheck = false,
) => {
if (emptyCheck) {
return subFields.map((subField) => {
return {
or: [
{
[baseFieldName]: {
[subField]: {
is: 'NULL',
},
},
},
{
[baseFieldName]: {
[subField]: {
ilike: '',
},
},
},
],
};
});
}
return filterString
.split(' ')
.reduce((previousValue: RecordGqlOperationFilter[], currentValue) => {

View File

@ -199,14 +199,20 @@ export class QueryRunnerArgsFactory {
if (!fieldMetadata) {
return value;
}
switch (fieldMetadata.type) {
case 'NUMBER':
return Object.fromEntries(
Object.entries(value).map(([filterKey, filterValue]) => [
filterKey,
Number(filterValue),
]),
);
case 'NUMBER': {
if (value?.is === 'NULL') {
return value;
} else {
return Object.fromEntries(
Object.entries(value).map(([filterKey, filterValue]) => [
filterKey,
Number(filterValue),
]),
);
}
}
default:
return value;
}