Automatically Apply Values on Filtered Views (#11717)
Issue : When I create a task in the "Assigned to me" task view, it will disappear from the view because the Assignee field isn't automatically populated. Solution : We created a "buildRecordInputFromFilters" funciron that will convert filtered into their corresponding values for the input. Fixes https://github.com/twentyhq/core-team-issues/issues/708 --------- Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
This commit is contained in:
@ -1,4 +1,5 @@
|
||||
import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
|
||||
import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
|
||||
|
||||
export type RecordFilter = {
|
||||
@ -14,3 +15,6 @@ export type RecordFilter = {
|
||||
label: string;
|
||||
subFieldName?: string | null | undefined;
|
||||
};
|
||||
|
||||
export type RecordFilterToRecordInputOperand<T extends FilterableFieldType> =
|
||||
(typeof FILTER_OPERANDS_MAP)[T][number];
|
||||
|
||||
@ -7,17 +7,116 @@ export type GetRecordFilterOperandsParams = {
|
||||
subFieldName?: string | null | undefined;
|
||||
};
|
||||
|
||||
const emptyOperands = [
|
||||
RecordFilterOperand.IsEmpty,
|
||||
RecordFilterOperand.IsNotEmpty,
|
||||
] as const;
|
||||
|
||||
const relationOperands = [
|
||||
RecordFilterOperand.Is,
|
||||
RecordFilterOperand.IsNot,
|
||||
] as const;
|
||||
|
||||
type FilterOperandMap = {
|
||||
[K in FilterableFieldType]: readonly RecordFilterOperand[];
|
||||
};
|
||||
|
||||
export const FILTER_OPERANDS_MAP = {
|
||||
TEXT: [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
],
|
||||
EMAILS: [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
],
|
||||
FULL_NAME: [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
],
|
||||
ADDRESS: [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
],
|
||||
LINKS: [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
],
|
||||
PHONES: [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
],
|
||||
CURRENCY: [
|
||||
RecordFilterOperand.GreaterThan,
|
||||
RecordFilterOperand.LessThan,
|
||||
...emptyOperands,
|
||||
],
|
||||
NUMBER: [
|
||||
RecordFilterOperand.GreaterThan,
|
||||
RecordFilterOperand.LessThan,
|
||||
...emptyOperands,
|
||||
],
|
||||
RAW_JSON: [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
],
|
||||
DATE_TIME: [
|
||||
RecordFilterOperand.Is,
|
||||
RecordFilterOperand.IsRelative,
|
||||
RecordFilterOperand.IsInPast,
|
||||
RecordFilterOperand.IsInFuture,
|
||||
RecordFilterOperand.IsToday,
|
||||
RecordFilterOperand.IsBefore,
|
||||
RecordFilterOperand.IsAfter,
|
||||
...emptyOperands,
|
||||
],
|
||||
DATE: [
|
||||
RecordFilterOperand.Is,
|
||||
RecordFilterOperand.IsRelative,
|
||||
RecordFilterOperand.IsInPast,
|
||||
RecordFilterOperand.IsInFuture,
|
||||
RecordFilterOperand.IsToday,
|
||||
RecordFilterOperand.IsBefore,
|
||||
RecordFilterOperand.IsAfter,
|
||||
...emptyOperands,
|
||||
],
|
||||
RATING: [
|
||||
RecordFilterOperand.Is,
|
||||
RecordFilterOperand.GreaterThan,
|
||||
RecordFilterOperand.LessThan,
|
||||
...emptyOperands,
|
||||
],
|
||||
RELATION: [...relationOperands, ...emptyOperands],
|
||||
MULTI_SELECT: [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
],
|
||||
SELECT: [RecordFilterOperand.Is, RecordFilterOperand.IsNot, ...emptyOperands],
|
||||
ACTOR: [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
],
|
||||
ARRAY: [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
],
|
||||
BOOLEAN: [RecordFilterOperand.Is],
|
||||
} as const satisfies FilterOperandMap;
|
||||
|
||||
export const getRecordFilterOperands = ({
|
||||
filterType,
|
||||
subFieldName,
|
||||
}: GetRecordFilterOperandsParams): RecordFilterOperand[] => {
|
||||
const emptyOperands = [
|
||||
RecordFilterOperand.IsEmpty,
|
||||
RecordFilterOperand.IsNotEmpty,
|
||||
];
|
||||
|
||||
const relationOperands = [RecordFilterOperand.Is, RecordFilterOperand.IsNot];
|
||||
|
||||
}: GetRecordFilterOperandsParams) => {
|
||||
switch (filterType) {
|
||||
case 'TEXT':
|
||||
case 'EMAILS':
|
||||
@ -25,57 +124,23 @@ export const getRecordFilterOperands = ({
|
||||
case 'ADDRESS':
|
||||
case 'LINKS':
|
||||
case 'PHONES':
|
||||
return [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
];
|
||||
return FILTER_OPERANDS_MAP.TEXT;
|
||||
case 'CURRENCY':
|
||||
case 'NUMBER':
|
||||
return [
|
||||
RecordFilterOperand.GreaterThan,
|
||||
RecordFilterOperand.LessThan,
|
||||
...emptyOperands,
|
||||
];
|
||||
return FILTER_OPERANDS_MAP.NUMBER;
|
||||
case 'RAW_JSON':
|
||||
return [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
];
|
||||
return FILTER_OPERANDS_MAP.RAW_JSON;
|
||||
case 'DATE_TIME':
|
||||
case 'DATE':
|
||||
return [
|
||||
RecordFilterOperand.Is,
|
||||
RecordFilterOperand.IsRelative,
|
||||
RecordFilterOperand.IsInPast,
|
||||
RecordFilterOperand.IsInFuture,
|
||||
RecordFilterOperand.IsToday,
|
||||
RecordFilterOperand.IsBefore,
|
||||
RecordFilterOperand.IsAfter,
|
||||
...emptyOperands,
|
||||
];
|
||||
return FILTER_OPERANDS_MAP.DATE_TIME;
|
||||
case 'RATING':
|
||||
return [
|
||||
RecordFilterOperand.Is,
|
||||
RecordFilterOperand.GreaterThan,
|
||||
RecordFilterOperand.LessThan,
|
||||
...emptyOperands,
|
||||
];
|
||||
return FILTER_OPERANDS_MAP.RATING;
|
||||
case 'RELATION':
|
||||
return [...relationOperands, ...emptyOperands];
|
||||
return FILTER_OPERANDS_MAP.RELATION;
|
||||
case 'MULTI_SELECT':
|
||||
return [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
];
|
||||
return FILTER_OPERANDS_MAP.MULTI_SELECT;
|
||||
case 'SELECT':
|
||||
return [
|
||||
RecordFilterOperand.Is,
|
||||
RecordFilterOperand.IsNot,
|
||||
...emptyOperands,
|
||||
];
|
||||
return FILTER_OPERANDS_MAP.SELECT;
|
||||
case 'ACTOR': {
|
||||
if (isFilterOnActorSourceSubField(subFieldName)) {
|
||||
return [
|
||||
@ -85,20 +150,12 @@ export const getRecordFilterOperands = ({
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
];
|
||||
return FILTER_OPERANDS_MAP.ACTOR;
|
||||
}
|
||||
case 'ARRAY':
|
||||
return [
|
||||
RecordFilterOperand.Contains,
|
||||
RecordFilterOperand.DoesNotContain,
|
||||
...emptyOperands,
|
||||
];
|
||||
return FILTER_OPERANDS_MAP.ARRAY;
|
||||
case 'BOOLEAN':
|
||||
return [RecordFilterOperand.Is];
|
||||
return FILTER_OPERANDS_MAP.BOOLEAN;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { buildValueFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter';
|
||||
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useBuildRecordInputFromFilters = ({
|
||||
objectMetadataItem,
|
||||
}: {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
}) => {
|
||||
// we might need to build a recoil callback for better performance
|
||||
const currentRecordFilters = useRecoilComponentValueV2(
|
||||
currentRecordFiltersComponentState,
|
||||
);
|
||||
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
|
||||
const buildRecordInputFromFilters = (): Partial<ObjectRecord> => {
|
||||
const recordInput: Partial<ObjectRecord> = {};
|
||||
|
||||
currentRecordFilters.forEach((filter) => {
|
||||
const fieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === filter.fieldMetadataId,
|
||||
);
|
||||
|
||||
if (!isDefined(fieldMetadataItem)) {
|
||||
return;
|
||||
}
|
||||
if (fieldMetadataItem.type === 'RELATION') {
|
||||
const value = buildValueFromFilter({
|
||||
filter,
|
||||
options: fieldMetadataItem.options ?? undefined,
|
||||
relationType: fieldMetadataItem.relationDefinition?.direction,
|
||||
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
|
||||
label: filter.label,
|
||||
});
|
||||
if (!isDefined(value)) {
|
||||
return;
|
||||
}
|
||||
recordInput[`${fieldMetadataItem.name}Id`] = value;
|
||||
} else {
|
||||
recordInput[fieldMetadataItem.name] = buildValueFromFilter({
|
||||
filter,
|
||||
options: fieldMetadataItem.options ?? undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return recordInput;
|
||||
};
|
||||
|
||||
return { buildRecordInputFromFilters };
|
||||
};
|
||||
@ -2,6 +2,7 @@ import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordIn
|
||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
|
||||
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
|
||||
import { useBuildRecordInputFromFilters } from '@/object-record/record-table/hooks/useBuildRecordInputFromFilters';
|
||||
import { useRecordTitleCell } from '@/object-record/record-title-cell/hooks/useRecordTitleCell';
|
||||
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
|
||||
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
@ -27,17 +28,25 @@ export const useCreateNewIndexRecord = ({
|
||||
|
||||
const { openRecordTitleCell } = useRecordTitleCell();
|
||||
|
||||
const { buildRecordInputFromFilters } = useBuildRecordInputFromFilters({
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
const createNewIndexRecord = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
async (recordInput?: Partial<ObjectRecord>) => {
|
||||
const recordId = v4();
|
||||
const recordInputFromFilters = buildRecordInputFromFilters();
|
||||
|
||||
const recordIndexOpenRecordIn = snapshot
|
||||
.getLoadable(recordIndexOpenRecordInState)
|
||||
.getValue();
|
||||
|
||||
await createOneRecord({ id: recordId, ...recordInput });
|
||||
|
||||
await createOneRecord({
|
||||
id: recordId,
|
||||
...recordInputFromFilters,
|
||||
...recordInput,
|
||||
});
|
||||
if (recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL) {
|
||||
openRecordInCommandMenu({
|
||||
recordId,
|
||||
@ -58,6 +67,7 @@ export const useCreateNewIndexRecord = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
buildRecordInputFromFilters,
|
||||
createOneRecord,
|
||||
navigate,
|
||||
objectMetadataItem.labelIdentifierFieldMetadataId,
|
||||
|
||||
@ -0,0 +1,500 @@
|
||||
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
|
||||
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
|
||||
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
|
||||
import { RelationDefinitionType } from '~/generated-metadata/graphql';
|
||||
import { buildValueFromFilter } from './buildRecordInputFromFilter';
|
||||
|
||||
// TODO: fix the dates, and test the not supported types
|
||||
const mockDate = new Date('2024-03-20T12:00:00Z');
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(mockDate);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('buildValueFromFilter', () => {
|
||||
const createTestFilter = (
|
||||
operand: ViewFilterOperand,
|
||||
value: string,
|
||||
type: FilterableFieldType,
|
||||
): RecordFilter => ({
|
||||
id: 'test-id',
|
||||
fieldMetadataId: 'test-field-id',
|
||||
value,
|
||||
displayValue: value,
|
||||
type,
|
||||
operand,
|
||||
label: 'Test Label',
|
||||
});
|
||||
|
||||
describe('TEXT field type', () => {
|
||||
const testCases = [
|
||||
{
|
||||
operand: ViewFilterOperand.Contains,
|
||||
value: 'test',
|
||||
expected: 'test',
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.DoesNotContain,
|
||||
value: 'test',
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsNotEmpty,
|
||||
value: 'test',
|
||||
expected: 'test',
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsEmpty,
|
||||
value: 'test',
|
||||
expected: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should handle $operand with value "$value"',
|
||||
({ operand, value, expected }) => {
|
||||
const filter = createTestFilter(operand, value, 'TEXT');
|
||||
expect(buildValueFromFilter({ filter })).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('DATE_TIME field type', () => {
|
||||
const testCases = [
|
||||
{
|
||||
operand: ViewFilterOperand.Is,
|
||||
value: '2024-03-20T12:00:00Z',
|
||||
expected: mockDate,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsAfter,
|
||||
value: '2024-03-20T12:00:00Z',
|
||||
expected: mockDate,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsBefore,
|
||||
value: '2024-03-20T12:00:00Z',
|
||||
expected: mockDate,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsInPast,
|
||||
value: '2024-03-20T12:00:00Z',
|
||||
expected: mockDate,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsInFuture,
|
||||
value: '2024-03-20T12:00:00Z',
|
||||
expected: mockDate,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsToday,
|
||||
value: '',
|
||||
expected: mockDate,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsRelative,
|
||||
value: '',
|
||||
expected: mockDate,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsEmpty,
|
||||
value: '',
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsNotEmpty,
|
||||
value: '2024-03-20T12:00:00Z',
|
||||
expected: mockDate,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should handle $operand with value "$value"',
|
||||
({ operand, value, expected }) => {
|
||||
const filter = createTestFilter(operand, value, 'DATE_TIME');
|
||||
const result = buildValueFromFilter({ filter });
|
||||
if (expected instanceof Date) {
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result).toEqual(expected);
|
||||
} else {
|
||||
expect(result).toBe(expected);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('NUMBER field type', () => {
|
||||
const testCases = [
|
||||
{
|
||||
operand: ViewFilterOperand.GreaterThan,
|
||||
value: '5',
|
||||
expected: 6,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.LessThan,
|
||||
value: '5',
|
||||
expected: 4,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsNotEmpty,
|
||||
value: '5',
|
||||
expected: 5,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsEmpty,
|
||||
value: '5',
|
||||
expected: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should handle $operand with value "$value"',
|
||||
({ operand, value, expected }) => {
|
||||
const filter = createTestFilter(operand, value, 'NUMBER');
|
||||
expect(buildValueFromFilter({ filter })).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('BOOLEAN field type', () => {
|
||||
const testCases = [
|
||||
{
|
||||
operand: ViewFilterOperand.Is,
|
||||
value: 'true',
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.Is,
|
||||
value: 'false',
|
||||
expected: false,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should handle $operand with value "$value"',
|
||||
({ operand, value, expected }) => {
|
||||
const filter = createTestFilter(operand, value, 'BOOLEAN');
|
||||
expect(buildValueFromFilter({ filter })).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('ARRAY field type', () => {
|
||||
const testCases = [
|
||||
{
|
||||
operand: ViewFilterOperand.Contains,
|
||||
value: 'test',
|
||||
expected: 'test',
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.DoesNotContain,
|
||||
value: 'test',
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsNotEmpty,
|
||||
value: 'test',
|
||||
expected: 'test',
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsEmpty,
|
||||
value: 'test',
|
||||
expected: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should handle $operand with value "$value"',
|
||||
({ operand, value, expected }) => {
|
||||
const filter = createTestFilter(operand, value, 'ARRAY');
|
||||
expect(buildValueFromFilter({ filter })).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('RELATION field type', () => {
|
||||
const mockCurrentWorkspaceMember = {
|
||||
id: 'current-workspace-member-id',
|
||||
name: { firstName: 'John', lastName: 'Doe' },
|
||||
locale: 'en',
|
||||
colorScheme: 'Light' as ColorScheme,
|
||||
avatarUrl: '',
|
||||
dateFormat: null,
|
||||
timeFormat: null,
|
||||
timeZone: null,
|
||||
};
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
operand: ViewFilterOperand.Is,
|
||||
value: JSON.stringify({
|
||||
isCurrentWorkspaceMemberSelected: false,
|
||||
selectedRecordIds: ['record-1'],
|
||||
}),
|
||||
relationType: RelationDefinitionType.MANY_TO_ONE,
|
||||
label: 'belongs to one',
|
||||
expected: 'record-1',
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.Is,
|
||||
value: JSON.stringify({
|
||||
isCurrentWorkspaceMemberSelected: true,
|
||||
selectedRecordIds: ['record-1'],
|
||||
}),
|
||||
relationType: RelationDefinitionType.MANY_TO_ONE,
|
||||
label: 'Assignee',
|
||||
expected: 'current-workspace-member-id',
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.Is,
|
||||
value: JSON.stringify({
|
||||
isCurrentWorkspaceMemberSelected: false,
|
||||
selectedRecordIds: ['record-1', 'record-2'],
|
||||
}),
|
||||
relationType: RelationDefinitionType.MANY_TO_MANY,
|
||||
label: 'hasmany',
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsNot,
|
||||
value: JSON.stringify({
|
||||
isCurrentWorkspaceMemberSelected: false,
|
||||
selectedRecordIds: ['record-1'],
|
||||
}),
|
||||
relationType: RelationDefinitionType.MANY_TO_ONE,
|
||||
label: 'Assignee',
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsEmpty,
|
||||
value: JSON.stringify({
|
||||
isCurrentWorkspaceMemberSelected: false,
|
||||
selectedRecordIds: ['record-1'],
|
||||
}),
|
||||
relationType: RelationDefinitionType.MANY_TO_ONE,
|
||||
label: 'Assignee',
|
||||
expected: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should handle $operand with value "$value" for $relationType relation',
|
||||
({ operand, value, relationType, label, expected }) => {
|
||||
const filter = createTestFilter(operand, value, 'RELATION');
|
||||
expect(
|
||||
buildValueFromFilter({
|
||||
filter,
|
||||
relationType,
|
||||
currentWorkspaceMember: mockCurrentWorkspaceMember,
|
||||
label,
|
||||
}),
|
||||
).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Composite field types', () => {
|
||||
const compositeTypes: FilterableFieldType[] = ['ACTOR', 'FULL_NAME'];
|
||||
|
||||
it.each(compositeTypes)(
|
||||
'should return undefined for composite type %s',
|
||||
(type) => {
|
||||
const filter = createTestFilter(ViewFilterOperand.Is, 'test', type);
|
||||
expect(buildValueFromFilter({ filter })).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('RAW_JSON field type', () => {
|
||||
it('should return undefined', () => {
|
||||
const filter = createTestFilter(ViewFilterOperand.Is, 'test', 'RAW_JSON');
|
||||
expect(buildValueFromFilter({ filter })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RATING field type', () => {
|
||||
const mockOptions = [
|
||||
{
|
||||
label: 'Rating 1',
|
||||
value: 'RATING_1',
|
||||
id: '1',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
label: 'Rating 2',
|
||||
value: 'RATING_2',
|
||||
id: '2',
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
label: 'Rating 3',
|
||||
value: 'RATING_3',
|
||||
id: '3',
|
||||
position: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
operand: ViewFilterOperand.Is,
|
||||
value: 'Rating 1',
|
||||
expected: 'RATING_1',
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsNotEmpty,
|
||||
value: 'Rating 2',
|
||||
expected: 'RATING_2',
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsEmpty,
|
||||
value: 'Rating 1',
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.GreaterThan,
|
||||
value: 'Rating 1',
|
||||
expected: 'RATING_2',
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.LessThan,
|
||||
value: 'Rating 2',
|
||||
expected: 'RATING_1',
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should handle $operand with value "$value"',
|
||||
({ operand, value, expected }) => {
|
||||
const filter = createTestFilter(operand, value, 'RATING');
|
||||
expect(
|
||||
buildValueFromFilter({
|
||||
filter,
|
||||
options: mockOptions as FieldMetadataItemOption[],
|
||||
}),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should return undefined when option is not found', () => {
|
||||
const filter = createTestFilter(
|
||||
ViewFilterOperand.Is,
|
||||
'Rating 4',
|
||||
'RATING',
|
||||
);
|
||||
expect(
|
||||
buildValueFromFilter({
|
||||
filter,
|
||||
options: mockOptions as FieldMetadataItemOption[],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SELECT field type', () => {
|
||||
const mockOptions = [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: 'OPTION_1',
|
||||
color: 'red',
|
||||
id: '1',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: 'OPTION_2',
|
||||
color: 'blue',
|
||||
id: '2',
|
||||
position: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
operand: ViewFilterOperand.Is,
|
||||
value: JSON.stringify(['OPTION_1']),
|
||||
expected: 'OPTION_1',
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsNot,
|
||||
value: JSON.stringify(['OPTION_1']),
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsEmpty,
|
||||
value: JSON.stringify(['OPTION_1']),
|
||||
expected: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should handle $operand with value "$value"',
|
||||
({ operand, value, expected }) => {
|
||||
const filter = createTestFilter(operand, value, 'SELECT');
|
||||
expect(
|
||||
buildValueFromFilter({
|
||||
filter,
|
||||
options: mockOptions as FieldMetadataItemOption[],
|
||||
}),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle invalid JSON', () => {
|
||||
const filter = createTestFilter(
|
||||
ViewFilterOperand.Is,
|
||||
'invalid-json',
|
||||
'SELECT',
|
||||
);
|
||||
expect(
|
||||
buildValueFromFilter({
|
||||
filter,
|
||||
options: mockOptions as FieldMetadataItemOption[],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MULTI_SELECT field type', () => {
|
||||
const testCases = [
|
||||
{
|
||||
operand: ViewFilterOperand.Contains,
|
||||
value: JSON.stringify(['OPTION_1', 'OPTION_2']),
|
||||
expected: ['OPTION_1', 'OPTION_2'],
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.DoesNotContain,
|
||||
value: JSON.stringify(['OPTION_1']),
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
operand: ViewFilterOperand.IsEmpty,
|
||||
value: JSON.stringify(['OPTION_1']),
|
||||
expected: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should handle $operand with value "$value"',
|
||||
({ operand, value, expected }) => {
|
||||
const filter = createTestFilter(operand, value, 'MULTI_SELECT');
|
||||
expect(buildValueFromFilter({ filter })).toEqual(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle invalid JSON', () => {
|
||||
const filter = createTestFilter(
|
||||
ViewFilterOperand.Contains,
|
||||
'invalid-json',
|
||||
'MULTI_SELECT',
|
||||
);
|
||||
expect(buildValueFromFilter({ filter })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,299 @@
|
||||
import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField';
|
||||
|
||||
import {
|
||||
RecordFilter,
|
||||
RecordFilterToRecordInputOperand,
|
||||
} from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import { RelationDefinitionType } from '~/generated-metadata/graphql';
|
||||
import { parseJson } from '~/utils/parseJson';
|
||||
|
||||
export const buildValueFromFilter = ({
|
||||
filter,
|
||||
options,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
}: {
|
||||
filter: RecordFilter;
|
||||
options?: FieldMetadataItemOption[];
|
||||
relationType?: RelationDefinitionType;
|
||||
currentWorkspaceMember?: CurrentWorkspaceMember;
|
||||
label?: string;
|
||||
}) => {
|
||||
if (isCompositeField(filter.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter.type === 'RAW_JSON') {
|
||||
return;
|
||||
}
|
||||
|
||||
const operands = FILTER_OPERANDS_MAP[filter.type];
|
||||
if (!operands.some((operand) => operand === filter.operand)) {
|
||||
throw new Error('Operand not supported for this field type');
|
||||
}
|
||||
|
||||
switch (filter.type) {
|
||||
case 'TEXT': {
|
||||
return computeValueFromFilterText(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['TEXT'][number],
|
||||
filter.value,
|
||||
);
|
||||
}
|
||||
case 'RATING':
|
||||
return computeValueFromFilterRating(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['RATING'][number],
|
||||
filter.value,
|
||||
options,
|
||||
);
|
||||
case 'DATE_TIME':
|
||||
case 'DATE':
|
||||
return computeValueFromFilterDate(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['DATE_TIME'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'NUMBER':
|
||||
return computeValueFromFilterNumber(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['NUMBER'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'BOOLEAN':
|
||||
return computeValueFromFilterBoolean(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['BOOLEAN'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'ARRAY':
|
||||
return computeValueFromFilterArray(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['ARRAY'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'SELECT':
|
||||
return computeValueFromFilterSelect(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['SELECT'][number],
|
||||
filter.value,
|
||||
options,
|
||||
);
|
||||
case 'MULTI_SELECT':
|
||||
return computeValueFromFilterMultiSelect(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['MULTI_SELECT'][number],
|
||||
filter.value,
|
||||
);
|
||||
case 'RELATION': {
|
||||
return computeValueFromFilterRelation(
|
||||
filter.operand as (typeof FILTER_OPERANDS_MAP)['RELATION'][number],
|
||||
filter.value,
|
||||
relationType,
|
||||
currentWorkspaceMember,
|
||||
label,
|
||||
);
|
||||
}
|
||||
default:
|
||||
assertUnreachable(filter.type);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterText = (
|
||||
operand: RecordFilterToRecordInputOperand<'TEXT'>,
|
||||
value: string,
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.Contains:
|
||||
return value;
|
||||
case ViewFilterOperand.IsNotEmpty:
|
||||
return value;
|
||||
case ViewFilterOperand.IsEmpty:
|
||||
case ViewFilterOperand.DoesNotContain:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterDate = (
|
||||
operand: RecordFilterToRecordInputOperand<'DATE_TIME'>,
|
||||
value: string,
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.Is:
|
||||
case ViewFilterOperand.IsAfter:
|
||||
case ViewFilterOperand.IsBefore:
|
||||
return new Date(value);
|
||||
case ViewFilterOperand.IsToday:
|
||||
case ViewFilterOperand.IsNotEmpty:
|
||||
case ViewFilterOperand.IsInPast:
|
||||
case ViewFilterOperand.IsInFuture:
|
||||
case ViewFilterOperand.IsRelative:
|
||||
return new Date();
|
||||
case ViewFilterOperand.IsEmpty:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterNumber = (
|
||||
operand: RecordFilterToRecordInputOperand<'NUMBER'>,
|
||||
value: string,
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.GreaterThan:
|
||||
return Number(value) + 1;
|
||||
case ViewFilterOperand.LessThan:
|
||||
return Number(value) - 1;
|
||||
case ViewFilterOperand.IsNotEmpty:
|
||||
return Number(value);
|
||||
case ViewFilterOperand.IsEmpty:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterBoolean = (
|
||||
operand: RecordFilterToRecordInputOperand<'BOOLEAN'>,
|
||||
value: string,
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.Is:
|
||||
return value === 'true';
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterArray = (
|
||||
operand: RecordFilterToRecordInputOperand<'ARRAY'>,
|
||||
value: string,
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.Contains:
|
||||
case ViewFilterOperand.IsNotEmpty:
|
||||
return value;
|
||||
case ViewFilterOperand.DoesNotContain:
|
||||
case ViewFilterOperand.IsEmpty:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterRating = (
|
||||
operand: RecordFilterToRecordInputOperand<'RATING'>,
|
||||
value: string,
|
||||
options?: FieldMetadataItemOption[],
|
||||
) => {
|
||||
const option = options?.find((option) => option.label === value);
|
||||
if (!option) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.Is:
|
||||
case ViewFilterOperand.IsNotEmpty:
|
||||
return option.value;
|
||||
case ViewFilterOperand.GreaterThan: {
|
||||
const plusOne = options?.find(
|
||||
(opt) => opt.position === option.position + 1,
|
||||
)?.value;
|
||||
return plusOne ? plusOne : option.value;
|
||||
}
|
||||
case ViewFilterOperand.LessThan: {
|
||||
const minusOne = options?.find(
|
||||
(opt) => opt.position === option.position - 1,
|
||||
)?.value;
|
||||
return minusOne ? minusOne : option.value;
|
||||
}
|
||||
case ViewFilterOperand.IsEmpty:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterSelect = (
|
||||
operand: RecordFilterToRecordInputOperand<'SELECT'>,
|
||||
value: string,
|
||||
options?: FieldMetadataItemOption[],
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.Is:
|
||||
case ViewFilterOperand.IsNotEmpty:
|
||||
try {
|
||||
const valueParsed = parseJson<string[]>(value)?.[0];
|
||||
const option = options?.find((option) => option.value === valueParsed);
|
||||
if (!option) {
|
||||
return undefined;
|
||||
}
|
||||
return option.value;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
case ViewFilterOperand.IsNot:
|
||||
case ViewFilterOperand.IsEmpty:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterMultiSelect = (
|
||||
operand: RecordFilterToRecordInputOperand<'MULTI_SELECT'>,
|
||||
value: string,
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.Contains:
|
||||
case ViewFilterOperand.IsNotEmpty:
|
||||
try {
|
||||
const parsedValue = parseJson<string[]>(value);
|
||||
return parsedValue ? parsedValue : undefined;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
case ViewFilterOperand.DoesNotContain:
|
||||
case ViewFilterOperand.IsEmpty:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
|
||||
const computeValueFromFilterRelation = (
|
||||
operand: RecordFilterToRecordInputOperand<'RELATION'>,
|
||||
value: string,
|
||||
relationType?: RelationDefinitionType,
|
||||
currentWorkspaceMember?: CurrentWorkspaceMember,
|
||||
label?: string,
|
||||
) => {
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.Is: {
|
||||
const parsedValue = parseJson<{
|
||||
isCurrentWorkspaceMemberSelected: boolean;
|
||||
selectedRecordIds: string[];
|
||||
}>(value);
|
||||
if (
|
||||
relationType === RelationDefinitionType.MANY_TO_ONE ||
|
||||
relationType === RelationDefinitionType.ONE_TO_ONE
|
||||
) {
|
||||
if (label === 'Assignee') {
|
||||
return parsedValue?.isCurrentWorkspaceMemberSelected
|
||||
? currentWorkspaceMember?.id
|
||||
: undefined;
|
||||
} else {
|
||||
return parsedValue?.selectedRecordIds?.[0];
|
||||
}
|
||||
}
|
||||
return undefined; //todo
|
||||
}
|
||||
case ViewFilterOperand.IsNot:
|
||||
case ViewFilterOperand.IsNotEmpty: // todo
|
||||
case ViewFilterOperand.IsEmpty:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(operand);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user