[GroupBy] Add views filters to groupBy query (#14762)
Closes https://github.com/twentyhq/core-team-issues/issues/1560 If viewId is defined in a groupBy query, we want to apply all filters of the view to the query. This required to move a lot of code from twenty-front to twenty-shared to convert the filters as stored in the db into graphql filters, applying the right combinations between filters etc., which was previously only done in the FE. This PR does not handle any field filters, it will be done in a later pr
This commit is contained in:
+1
-1
@@ -10,7 +10,6 @@ import { VIEW_BAR_FILTER_BOTTOM_MENU_ITEM_IDS } from '@/views/constants/ViewBarF
|
||||
import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId';
|
||||
|
||||
import { useSetRecordFilterUsedInAdvancedFilterDropdownRow } from '@/object-record/advanced-filter/hooks/useSetRecordFilterUsedInAdvancedFilterDropdownRow';
|
||||
import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator';
|
||||
import { useCreateEmptyRecordFilterFromFieldMetadataItem } from '@/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
|
||||
@@ -20,6 +19,7 @@ import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Pill } from 'twenty-ui/components';
|
||||
import { IconFilter } from 'twenty-ui/display';
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
|
||||
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
@@ -11,7 +10,7 @@ import { type View } from '@/views/types/View';
|
||||
import { type ViewFilter } from '@/views/types/ViewFilter';
|
||||
import { act } from 'react';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
type CoreViewFilter,
|
||||
ViewFilterOperand as CoreViewFilterOperand,
|
||||
|
||||
+1
-2
@@ -1,12 +1,11 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { type ViewFilter } from '@/views/types/ViewFilter';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
|
||||
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
|
||||
import { useApplyViewFiltersToCurrentRecordFilters } from '../useApplyViewFiltersToCurrentRecordFilters';
|
||||
|
||||
+1
-1
@@ -7,7 +7,6 @@ import { selectedOperandInDropdownComponentState } from '@/object-record/object-
|
||||
import { useInitializeFilterOnFieldMetadataItemFromViewBarFilterDropdown } from '../useInitializeFilterOnFieldMetadataItemFromViewBarFilterDropdown';
|
||||
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
|
||||
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
|
||||
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
|
||||
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
|
||||
@@ -19,6 +18,7 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId';
|
||||
import { getFilterTypeFromFieldType } from 'twenty-shared/utils';
|
||||
import { getMockPersonObjectMetadataItem } from '~/testing/mock-data/people';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
|
||||
|
||||
+1
-1
@@ -4,11 +4,11 @@ import { currentRecordFilterGroupsComponentState } from '@/object-record/record-
|
||||
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 { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useGetViewGroupsFilters } from '@/views/hooks/useGetViewGroupsFilters';
|
||||
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
|
||||
|
||||
export const useGetRecordIndexTotalCount = () => {
|
||||
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
|
||||
|
||||
@@ -15,9 +15,11 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery';
|
||||
import { type ViewFilter } from '@/views/types/ViewFilter';
|
||||
import { relationFilterValueSchemaObject } from '@/views/view-filter-value/validation-schemas/jsonRelationFilterValueSchema';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
isDefined,
|
||||
relationFilterValueSchemaObject,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
const filterQueryParamsSchema = z.object({
|
||||
viewId: z.string().optional(),
|
||||
|
||||
+5
-3
@@ -5,10 +5,12 @@ import { MAX_RECORDS_TO_DISPLAY } from '@/object-record/object-filter-dropdown/c
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { useRecordsForSelect } from '@/object-record/select/hooks/useRecordsForSelect';
|
||||
import { getRecordFilterLabelValue } from '@/views/utils/getRecordFilterLabelValue';
|
||||
import { arrayOfUuidOrVariableSchema } from '@/views/view-filter-value/validation-schemas/arrayOfUuidsOrVariablesSchema';
|
||||
import { jsonRelationFilterValueSchema } from '@/views/view-filter-value/validation-schemas/jsonRelationFilterValueSchema';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
arrayOfUuidOrVariableSchema,
|
||||
isDefined,
|
||||
jsonRelationFilterValueSchema,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
type ObjectFilterDropdownRecordSelectProps = {
|
||||
recordFilter: RecordFilter;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useGetViewGroupsFilters = (): RecordFilter[] => {
|
||||
const { currentView } = useGetCurrentViewOnly();
|
||||
|
||||
+1
-2
@@ -1,5 +1,4 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
|
||||
import { useUpsertObjectFilterDropdownCurrentFilter } from '@/object-record/object-filter-dropdown/hooks/useUpsertObjectFilterDropdownCurrentFilter';
|
||||
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
|
||||
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
|
||||
@@ -15,7 +14,7 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp
|
||||
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
|
||||
import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const useInitializeFilterOnFieldMetadataItemFromViewBarFilterDropdown =
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
|
||||
import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter';
|
||||
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
|
||||
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
|
||||
import { useVectorSearchFieldInRecordIndexContextOrThrow } from '@/views/hooks/useVectorSearchFieldInRecordIndexContextOrThrow';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
import { useVectorSearchFilterState } from './useVectorSearchFilterState';
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator';
|
||||
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
|
||||
import { mapRecordFilterGroupLogicalOperatorToViewFilterGroupLogicalOperator } from '@/views/utils/mapRecordFilterGroupLogicalOperatorToViewFilterGroupLogicalOperator';
|
||||
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
|
||||
|
||||
describe('mapRecordFilterGroupLogicalOperatorToViewFilterGroupLogicalOperator', () => {
|
||||
it('should map correctly for AND', () => {
|
||||
|
||||
+1
-1
@@ -1,5 +1,4 @@
|
||||
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
|
||||
import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator';
|
||||
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { type View } from '@/views/types/View';
|
||||
import { type ViewFilterGroup } from '@/views/types/ViewFilterGroup';
|
||||
@@ -7,6 +6,7 @@ import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLog
|
||||
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
|
||||
import { ViewType } from '@/views/types/ViewType';
|
||||
import { mapRecordFilterGroupToViewFilterGroup } from '@/views/utils/mapRecordFilterGroupToViewFilterGroup';
|
||||
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator';
|
||||
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
|
||||
import { mapViewFilterGroupLogicalOperatorToRecordFilterGroupLogicalOperator } from '@/views/utils/mapViewFilterGroupLogicalOperatorToRecordFilterGroupLogicalOperator';
|
||||
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
|
||||
|
||||
describe('mapViewFilterGroupLogicalOperatorToRecordFilterGroupLogicalOperator', () => {
|
||||
it('should map correctly for AND', () => {
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
|
||||
import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator';
|
||||
import { type ViewFilterGroup } from '@/views/types/ViewFilterGroup';
|
||||
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
|
||||
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
|
||||
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
|
||||
|
||||
describe('mapViewFilterGroupsToRecordFilterGroups', () => {
|
||||
it('should map empty array to empty array', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
|
||||
import { type ViewFilter } from '@/views/types/ViewFilter';
|
||||
import { convertViewFilterValueToString } from 'twenty-shared/utils';
|
||||
import { type CoreViewFilter } from '~/generated/graphql';
|
||||
import { convertViewFilterOperandFromCore } from '../utils/convertViewFilterOperandFromCore';
|
||||
|
||||
@@ -20,14 +21,8 @@ export const convertCoreViewFilterToViewFilter = (
|
||||
id: coreViewFilter.id,
|
||||
fieldMetadataId: coreViewFilter.fieldMetadataId,
|
||||
operand: convertViewFilterOperandFromCore(coreViewFilter.operand),
|
||||
value:
|
||||
typeof coreViewFilter.value === 'string'
|
||||
? coreViewFilter.value
|
||||
: JSON.stringify(coreViewFilter.value ?? ''),
|
||||
displayValue:
|
||||
typeof coreViewFilter.value === 'string'
|
||||
? coreViewFilter.value
|
||||
: JSON.stringify(coreViewFilter.value ?? ''),
|
||||
value: convertViewFilterValueToString(coreViewFilter.value),
|
||||
displayValue: convertViewFilterValueToString(coreViewFilter.value),
|
||||
viewFilterGroupId: coreViewFilter.viewFilterGroupId,
|
||||
positionInViewFilterGroup: coreViewFilter.positionInViewFilterGroup,
|
||||
subFieldName: coreViewFilter.subFieldName as CompositeFieldSubFieldName,
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
|
||||
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
|
||||
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { type RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
|
||||
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
|
||||
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
|
||||
import { type RecordFilterValueDependencies } from 'twenty-shared/types';
|
||||
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
|
||||
|
||||
export const getQueryVariablesFromFiltersAndSorts = ({
|
||||
recordFilterGroups,
|
||||
|
||||
@@ -2,9 +2,8 @@ import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetad
|
||||
import { getOperandLabelShort } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { isEmptinessOperand } from '@/object-record/record-filter/utils/isEmptinessOperand';
|
||||
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
|
||||
import { parseJson } from 'twenty-shared/utils';
|
||||
import { isEmptinessOperand, parseJson } from 'twenty-shared/utils';
|
||||
|
||||
export const getRecordFilterLabelValue = ({
|
||||
recordFilter,
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator';
|
||||
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
|
||||
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
|
||||
|
||||
export const mapRecordFilterGroupLogicalOperatorToViewFilterGroupLogicalOperator =
|
||||
({
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator';
|
||||
import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator';
|
||||
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
|
||||
|
||||
export const mapViewFilterGroupLogicalOperatorToRecordFilterGroupLogicalOperator =
|
||||
({
|
||||
|
||||
@@ -2,12 +2,11 @@ import { type RecordFilter } from '@/object-record/record-filter/types/RecordFil
|
||||
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
|
||||
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
|
||||
import { isSystemSearchVectorField } from '@/object-record/utils/isSystemSearchVectorField';
|
||||
import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
|
||||
import { convertViewFilterOperandFromCore } from '@/views/utils/convertViewFilterOperandFromCore';
|
||||
import { type ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
|
||||
import { type CoreViewFilter } from '~/generated/graphql';
|
||||
import { type ViewFilter } from '../types/ViewFilter';
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type jsonRelationFilterValueSchema } from '@/views/view-filter-value/validation-schemas/jsonRelationFilterValueSchema';
|
||||
import { type jsonRelationFilterValueSchema } from 'twenty-shared/utils';
|
||||
import { type z } from 'zod';
|
||||
|
||||
export type RelationFilterValue = z.infer<typeof jsonRelationFilterValueSchema>;
|
||||
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
import { type ViewFilter } from '@/views/types/ViewFilter';
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addWeeks,
|
||||
addYears,
|
||||
endOfDay,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
endOfYear,
|
||||
roundToNearestMinutes,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
subDays,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
subYears,
|
||||
} from 'date-fns';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
const variableDateViewFilterValueDirectionSchema = z.enum([
|
||||
'NEXT',
|
||||
'THIS',
|
||||
'PAST',
|
||||
]);
|
||||
|
||||
export type VariableDateViewFilterValueDirection = z.infer<
|
||||
typeof variableDateViewFilterValueDirectionSchema
|
||||
>;
|
||||
|
||||
const variableDateViewFilterValueAmountSchema = z
|
||||
.union([z.coerce.number().int().positive(), z.literal('undefined')])
|
||||
.transform((val) => (val === 'undefined' ? undefined : val));
|
||||
|
||||
export const variableDateViewFilterValueUnitSchema = z.enum([
|
||||
'DAY',
|
||||
'WEEK',
|
||||
'MONTH',
|
||||
'YEAR',
|
||||
]);
|
||||
|
||||
export type VariableDateViewFilterValueUnit = z.infer<
|
||||
typeof variableDateViewFilterValueUnitSchema
|
||||
>;
|
||||
|
||||
export const variableDateViewFilterValuePartsSchema = z
|
||||
.object({
|
||||
direction: variableDateViewFilterValueDirectionSchema,
|
||||
amount: variableDateViewFilterValueAmountSchema,
|
||||
unit: variableDateViewFilterValueUnitSchema,
|
||||
})
|
||||
.refine((data) => !(data.amount === undefined && data.direction !== 'THIS'), {
|
||||
error: "Amount cannot be 'undefined' unless direction is 'THIS'",
|
||||
});
|
||||
|
||||
const variableDateViewFilterValueSchema = z.string().transform((value) => {
|
||||
const [direction, amount, unit] = value.split('_');
|
||||
|
||||
return variableDateViewFilterValuePartsSchema.parse({
|
||||
direction,
|
||||
amount,
|
||||
unit,
|
||||
});
|
||||
});
|
||||
|
||||
const addUnit = (
|
||||
date: Date,
|
||||
amount: number,
|
||||
unit: VariableDateViewFilterValueUnit,
|
||||
) => {
|
||||
switch (unit) {
|
||||
case 'DAY':
|
||||
return addDays(date, amount);
|
||||
case 'WEEK':
|
||||
return addWeeks(date, amount);
|
||||
case 'MONTH':
|
||||
return addMonths(date, amount);
|
||||
case 'YEAR':
|
||||
return addYears(date, amount);
|
||||
}
|
||||
};
|
||||
|
||||
const subUnit = (
|
||||
date: Date,
|
||||
amount: number,
|
||||
unit: VariableDateViewFilterValueUnit,
|
||||
) => {
|
||||
switch (unit) {
|
||||
case 'DAY':
|
||||
return subDays(date, amount);
|
||||
case 'WEEK':
|
||||
return subWeeks(date, amount);
|
||||
case 'MONTH':
|
||||
return subMonths(date, amount);
|
||||
case 'YEAR':
|
||||
return subYears(date, amount);
|
||||
}
|
||||
};
|
||||
|
||||
const startOfUnit = (date: Date, unit: VariableDateViewFilterValueUnit) => {
|
||||
switch (unit) {
|
||||
case 'DAY':
|
||||
return startOfDay(date);
|
||||
case 'WEEK':
|
||||
return startOfWeek(date);
|
||||
case 'MONTH':
|
||||
return startOfMonth(date);
|
||||
case 'YEAR':
|
||||
return startOfYear(date);
|
||||
}
|
||||
};
|
||||
|
||||
const endOfUnit = (date: Date, unit: VariableDateViewFilterValueUnit) => {
|
||||
switch (unit) {
|
||||
case 'DAY':
|
||||
return endOfDay(date);
|
||||
case 'WEEK':
|
||||
return endOfWeek(date);
|
||||
case 'MONTH':
|
||||
return endOfMonth(date);
|
||||
case 'YEAR':
|
||||
return endOfYear(date);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveVariableDateViewFilterValueFromRelativeDate = (relativeDate: {
|
||||
direction: VariableDateViewFilterValueDirection;
|
||||
amount?: number;
|
||||
unit: VariableDateViewFilterValueUnit;
|
||||
}) => {
|
||||
const { direction, amount, unit } = relativeDate;
|
||||
const now = roundToNearestMinutes(new Date());
|
||||
|
||||
switch (direction) {
|
||||
case 'NEXT':
|
||||
if (amount === undefined) throw new Error('Amount is required');
|
||||
return {
|
||||
start: now,
|
||||
end: addUnit(now, amount, unit),
|
||||
...relativeDate,
|
||||
};
|
||||
case 'PAST':
|
||||
if (amount === undefined) throw new Error('Amount is required');
|
||||
return {
|
||||
start: subUnit(now, amount, unit),
|
||||
end: now,
|
||||
...relativeDate,
|
||||
};
|
||||
case 'THIS':
|
||||
return {
|
||||
start: startOfUnit(now, unit),
|
||||
end: endOfUnit(now, unit),
|
||||
...relativeDate,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const resolveVariableDateViewFilterValue = (value?: string | null) => {
|
||||
if (!value) return null;
|
||||
|
||||
const relativeDate = variableDateViewFilterValueSchema.parse(value);
|
||||
return resolveVariableDateViewFilterValueFromRelativeDate(relativeDate);
|
||||
};
|
||||
|
||||
export type ResolvedDateViewFilterValue<O extends ViewFilterOperand> =
|
||||
O extends ViewFilterOperand.IsRelative
|
||||
? ReturnType<typeof resolveVariableDateViewFilterValue>
|
||||
: Date | null;
|
||||
|
||||
type PartialViewFilter<O extends ViewFilterOperand> = Pick<
|
||||
ViewFilter,
|
||||
'value'
|
||||
> & { operand: O };
|
||||
|
||||
export const resolveDateViewFilterValue = <O extends ViewFilterOperand>(
|
||||
viewFilter: PartialViewFilter<O>,
|
||||
): ResolvedDateViewFilterValue<O> => {
|
||||
if (!viewFilter.value) return null;
|
||||
|
||||
if (viewFilter.operand === ViewFilterOperand.IsRelative) {
|
||||
return resolveVariableDateViewFilterValue(
|
||||
viewFilter.value,
|
||||
) as ResolvedDateViewFilterValue<O>;
|
||||
}
|
||||
return new Date(viewFilter.value) as ResolvedDateViewFilterValue<O>;
|
||||
};
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
import { arrayOfStringsOrVariablesSchema } from '../arrayOfStringsOrVariablesSchema';
|
||||
|
||||
describe('arrayOfStringsOrVariablesSchema', () => {
|
||||
describe('Empty value handling', () => {
|
||||
it('should return empty array for empty string', () => {
|
||||
const result = arrayOfStringsOrVariablesSchema.safeParse('');
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variable syntax validation', () => {
|
||||
it('should accept valid variable syntax', () => {
|
||||
const validVariables = [
|
||||
'{{variable}}',
|
||||
'{{user.id}}',
|
||||
'{{company.name}}',
|
||||
];
|
||||
|
||||
validVariables.forEach((variable) => {
|
||||
const result = arrayOfStringsOrVariablesSchema.safeParse(variable);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([variable]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON array handling', () => {
|
||||
it('should accept valid JSON array of strings', () => {
|
||||
const validArrays = [
|
||||
JSON.stringify(['value1', 'value2']),
|
||||
JSON.stringify(['{{variable1}}', '{{variable2}}']),
|
||||
JSON.stringify(['value1', '{{variable2}}']),
|
||||
];
|
||||
|
||||
validArrays.forEach((array) => {
|
||||
const result = arrayOfStringsOrVariablesSchema.safeParse(array);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(JSON.parse(array));
|
||||
}
|
||||
});
|
||||
});
|
||||
it('should reject JSON array with non-string values', () => {
|
||||
const invalidArrays = [
|
||||
JSON.stringify([1, 2, 3]),
|
||||
JSON.stringify([true, false]),
|
||||
JSON.stringify([null]),
|
||||
JSON.stringify([{}]),
|
||||
JSON.stringify([[]]),
|
||||
];
|
||||
|
||||
invalidArrays.forEach((array) => {
|
||||
const result = arrayOfStringsOrVariablesSchema.safeParse(array);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle whitespace in variable syntax', () => {
|
||||
const result =
|
||||
arrayOfStringsOrVariablesSchema.safeParse('{{ variable }}');
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(['{{ variable }}']);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle nested variables in JSON array', () => {
|
||||
const input = JSON.stringify(['{{outer.{{inner}}}}']);
|
||||
const result = arrayOfStringsOrVariablesSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(['{{outer.{{inner}}}}']);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty array in JSON', () => {
|
||||
const result = arrayOfStringsOrVariablesSchema.safeParse('[]');
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
import { arrayOfUuidOrVariableSchema } from '../arrayOfUuidsOrVariablesSchema';
|
||||
|
||||
describe('arrayOfUuidOrVariableSchema', () => {
|
||||
describe('UUID validation', () => {
|
||||
it('should accept valid UUIDs', () => {
|
||||
const validUuids = [
|
||||
'123e4567-e89b-12d3-a456-426614174000',
|
||||
'550e8400-e29b-41d4-a716-446655440000',
|
||||
];
|
||||
|
||||
validUuids.forEach((uuid) => {
|
||||
// Test as single value
|
||||
const singleResult = arrayOfUuidOrVariableSchema.safeParse(uuid);
|
||||
expect(singleResult.success).toBe(true);
|
||||
if (singleResult.success) {
|
||||
expect(singleResult.data).toEqual([uuid]);
|
||||
}
|
||||
|
||||
// Test as array
|
||||
const arrayResult = arrayOfUuidOrVariableSchema.safeParse([uuid]);
|
||||
expect(arrayResult.success).toBe(true);
|
||||
if (arrayResult.success) {
|
||||
expect(arrayResult.data).toEqual([uuid]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for invalid UUIDs', () => {
|
||||
const invalidUuids = [
|
||||
'invalid-uuid',
|
||||
'12345',
|
||||
'550e8400e29b41d4a716446655440000',
|
||||
'',
|
||||
'123e4567-e89b-12d3-a456-42661417400-',
|
||||
];
|
||||
|
||||
invalidUuids.forEach((uuid) => {
|
||||
// Test as single value
|
||||
const singleResult = arrayOfUuidOrVariableSchema.safeParse(uuid);
|
||||
expect(singleResult.success).toBe(true);
|
||||
if (singleResult.success) {
|
||||
expect(singleResult.data).toEqual([]);
|
||||
}
|
||||
|
||||
// Test as array
|
||||
const arrayResult = arrayOfUuidOrVariableSchema.safeParse([uuid]);
|
||||
expect(arrayResult.success).toBe(true);
|
||||
if (arrayResult.success) {
|
||||
expect(arrayResult.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variable syntax validation', () => {
|
||||
it('should accept valid variable syntax', () => {
|
||||
const validVariables = [
|
||||
'{{variable}}',
|
||||
'{{user.id}}',
|
||||
'{{company.name}}',
|
||||
];
|
||||
|
||||
validVariables.forEach((variable) => {
|
||||
// Test as single value
|
||||
const singleResult = arrayOfUuidOrVariableSchema.safeParse(variable);
|
||||
expect(singleResult.success).toBe(true);
|
||||
if (singleResult.success) {
|
||||
expect(singleResult.data).toEqual([variable]);
|
||||
}
|
||||
|
||||
// Test as array
|
||||
const arrayResult = arrayOfUuidOrVariableSchema.safeParse([variable]);
|
||||
expect(arrayResult.success).toBe(true);
|
||||
if (arrayResult.success) {
|
||||
expect(arrayResult.data).toEqual([variable]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for invalid variable syntax', () => {
|
||||
const invalidVariables = ['{{variable', 'variable}}', '{{}}', '{{', '}}'];
|
||||
|
||||
invalidVariables.forEach((variable) => {
|
||||
// Test as single value
|
||||
const singleResult = arrayOfUuidOrVariableSchema.safeParse(variable);
|
||||
expect(singleResult.success).toBe(true);
|
||||
if (singleResult.success) {
|
||||
expect(singleResult.data).toEqual([]);
|
||||
}
|
||||
|
||||
// Test as array
|
||||
const arrayResult = arrayOfUuidOrVariableSchema.safeParse([variable]);
|
||||
expect(arrayResult.success).toBe(true);
|
||||
if (arrayResult.success) {
|
||||
expect(arrayResult.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input type handling', () => {
|
||||
it('should handle string input with valid JSON', () => {
|
||||
const input = JSON.stringify(['123e4567-e89b-12d3-a456-426614174000']);
|
||||
const result = arrayOfUuidOrVariableSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(['123e4567-e89b-12d3-a456-426614174000']);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle string input with variables', () => {
|
||||
const input = '{{variable}}';
|
||||
const result = arrayOfUuidOrVariableSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(['{{variable}}']);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle array input directly', () => {
|
||||
const input = ['123e4567-e89b-12d3-a456-426614174000', '{{variable}}'];
|
||||
const result = arrayOfUuidOrVariableSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual(input);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle single value input', () => {
|
||||
const input = '20202020-0687-4c41-b707-ed1bfca972a7';
|
||||
const result = arrayOfUuidOrVariableSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([input]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should return empty array for invalid JSON string', () => {
|
||||
const input = 'invalid-json';
|
||||
const result = arrayOfUuidOrVariableSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty array for non-string, non-array input', () => {
|
||||
const inputs = [null, undefined, 123, true, {}];
|
||||
inputs.forEach((input) => {
|
||||
const result = arrayOfUuidOrVariableSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for array with invalid values', () => {
|
||||
const input = ['invalid-uuid', 'not-a-variable'];
|
||||
const result = arrayOfUuidOrVariableSchema.safeParse(input);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
import { isValidVariable } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const arrayOfStringsOrVariablesSchema = z
|
||||
.string()
|
||||
.transform((val) => {
|
||||
if (val === '') return [];
|
||||
if (isValidVariable(val) as boolean) {
|
||||
return [val];
|
||||
}
|
||||
return JSON.parse(val);
|
||||
})
|
||||
.refine(
|
||||
(parsed) =>
|
||||
Array.isArray(parsed) && parsed.every((item) => typeof item === 'string'),
|
||||
{
|
||||
error: 'Expected an array of strings',
|
||||
},
|
||||
);
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { isValidUuid, isValidVariable } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const arrayOfUuidOrVariableSchema = z
|
||||
.preprocess(
|
||||
(value) => {
|
||||
try {
|
||||
if (typeof value === 'string') {
|
||||
if (isValidVariable(value) as boolean) {
|
||||
return [value];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return [value];
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) ? value : [value];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
z.array(
|
||||
z.string().refine((val) => {
|
||||
return isValidUuid(val) || isValidVariable(val);
|
||||
}, 'Must be a valid UUID or a variable with {{ }} syntax'),
|
||||
),
|
||||
)
|
||||
.catch([]);
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const relationFilterValueSchemaObject = z.object({
|
||||
isCurrentWorkspaceMemberSelected: z.boolean().optional(),
|
||||
selectedRecordIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const jsonRelationFilterValueSchema = z
|
||||
.string()
|
||||
.transform((value, ctx) => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: (error as Error).message,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
})
|
||||
.pipe(relationFilterValueSchemaObject);
|
||||
Reference in New Issue
Block a user