c938fbf4d6
**Stacked on #20527** https://github.com/user-attachments/assets/48995655-401a-4c35-8094-e88da8408bdd ## Summary Surfaces the one-hop relation traversal added in #20527 through the existing **composite sub-field dropdown pattern**. Clicking a MANY_TO_ONE relation field in the "+ Filter" picker now opens the same second-level dropdown that composite fields (FULL_NAME, ADDRESS, CURRENCY, etc.) already use — populated with the target object's filterable fields. Picking one (e.g. `Company → Name`) builds a filter that serializes to the nested GraphQL filter the backend now accepts: `{ company: { name: { ilike: "%X%" } } }`. No new components. The whole feature reuses `AdvancedFilterSubFieldSelectMenu` + the existing `subFieldNameUsedInDropdownComponentState` + the existing `MenuItem hasSubMenu` indicator. Only the conditions that gate the sub-menu (and the sub-menu's content for relations) were broadened. ## What landed | File | Change | |---|---| | `ObjectFilterDropdownFilterSelectMenuItem` | Sub-menu chevron now shows on MANY_TO_ONE relations (`isManyToOneRelationField` util). | | `AdvancedFilterFieldSelectMenu` | Relation clicks open the sub-menu alongside composite clicks. | | `AdvancedFilterSubFieldSelectMenu` | New branch: when the sub-menu type is `'RELATION'`, render the target object's filterable fields via `useFilterableFieldMetadataItems(targetObjectMetadataId)`. Composite logic untouched. | | `objectFilterDropdownSubMenuFieldType` state | Widened to accept a `'RELATION'` sentinel. Role-permissions sub-field menu narrows it back out (it doesn't traverse relations). | | `useSelectFieldUsedInAdvancedFilterDropdown` | New optional `targetFieldMetadataItem` arg. When present, the stored RecordFilter's `type` is the target field's type so the operand picker and value input render the target's operands (`'TEXT'` operators when filtering `company.name`, etc.). | | `turnRecordFilterIntoGqlOperationFilter` (shared) | When the filter targets a `RELATION` field with a `subFieldName`, synthesize a field-metadata for the target, recurse to build the inner filter, then wrap it under the relation field's name → `{ relationName: { targetFieldName: { ...operator } } }`. | `RecordFilter.subFieldName` stays narrowly typed as `CompositeFieldSubFieldName` so the wide downstream consumers (`shouldShowFilterTextInput`, composite handlers in the serializer, etc.) don't change. The relation target field's name is stored through a narrowly-scoped cast at the dropdown's storage point — the serializer checks `filter.type === 'RELATION'` before interpreting it as a target field name, so the cast can't be mis-read by composite-only code paths. ## Test plan - [ ] Open a table view on People, click "+ Filter", click "Company" → sub-menu opens with Company's filterable fields - [ ] Pick "Name" → operand picker shows TEXT operators (Contains, Equals, …) - [ ] Type "Airbnb" → filter applies, table shows people whose company name contains "Airbnb" - [ ] Verify network tab: the GraphQL filter variable is `{ company: { name: { ilike: "%Airbnb%" } } }` - [ ] Same flow with a composite target field (e.g. `Company → annualRecurringRevenue → amountMicros`) — should work end-to-end (backend supports composite-within-relation; #20527 has an integration test covering this) - [ ] Composite fields (FULL_NAME, ADDRESS) still open their normal sub-menu and filter correctly — no regression - [ ] Role-permissions field-select sub-field menu is unaffected (it bails out early on the RELATION sentinel) ## Out of scope - ONE_TO_MANY traversal (no backend support yet) - Aggregates (`people.count > 5`) - Persisting relation-traversal filters into a saved view (ViewFilter has no `relationPath` column yet; that's a separate slice) - REST API DSL changes - AI Tools 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
881 lines
30 KiB
TypeScript
881 lines
30 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
|
|
import { t } from '@lingui/core/macro';
|
|
import {
|
|
ViewFilterGroupLogicalOperator,
|
|
ViewFilterOperand,
|
|
ViewSortDirection,
|
|
} from 'twenty-shared/types';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { IsNull, Repository } from 'typeorm';
|
|
import { v4 } from 'uuid';
|
|
|
|
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
|
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
|
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
|
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
|
|
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
|
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
|
|
import { splitEntitiesByRemovalStrategy } from 'src/engine/metadata-modules/flat-entity/utils/split-entities-by-removal-strategy.util';
|
|
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
|
import { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/is-flat-page-layout-widget-configuration-of-type.util';
|
|
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
|
|
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
|
|
import { fromViewFieldOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-overrides-to-universal-overrides.util';
|
|
import { type FlatViewFilterGroupMaps } from 'src/engine/metadata-modules/flat-view-filter-group/types/flat-view-filter-group-maps.type';
|
|
import { type FlatViewFilterGroup } from 'src/engine/metadata-modules/flat-view-filter-group/types/flat-view-filter-group.type';
|
|
import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type';
|
|
import { type FlatViewSort } from 'src/engine/metadata-modules/flat-view-sort/types/flat-view-sort.type';
|
|
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
|
|
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
|
import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util';
|
|
import { sanitizeOverridableEntityInput } from 'src/engine/metadata-modules/utils/sanitize-overridable-entity-input.util';
|
|
import { type UpsertViewWidgetViewFieldInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-field.input';
|
|
import { type UpsertViewWidgetViewFilterGroupInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter-group.input';
|
|
import { type UpsertViewWidgetViewFilterInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input';
|
|
import { type UpsertViewWidgetViewSortInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-sort.input';
|
|
import { type UpsertViewWidgetInput } from 'src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget.input';
|
|
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
|
|
import {
|
|
ViewException,
|
|
ViewExceptionCode,
|
|
} from 'src/engine/metadata-modules/view/exceptions/view.exception';
|
|
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
|
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
|
|
|
const EMPTY_FIELD_OPS = {
|
|
fieldsToCreate: [] as FlatViewField[],
|
|
fieldsToUpdate: [] as FlatViewField[],
|
|
};
|
|
|
|
const EMPTY_FILTER_GROUP_OPS = {
|
|
filterGroupsToCreate: [] as FlatViewFilterGroup[],
|
|
filterGroupsToUpdate: [] as FlatViewFilterGroup[],
|
|
filterGroupsToRemove: [] as FlatViewFilterGroup[],
|
|
};
|
|
|
|
const EMPTY_FILTER_OPS = {
|
|
filtersToCreate: [] as FlatViewFilter[],
|
|
filtersToUpdate: [] as FlatViewFilter[],
|
|
filtersToRemove: [] as FlatViewFilter[],
|
|
};
|
|
|
|
const EMPTY_SORT_OPS = {
|
|
sortsToCreate: [] as FlatViewSort[],
|
|
sortsToUpdate: [] as FlatViewSort[],
|
|
sortsToRemove: [] as FlatViewSort[],
|
|
};
|
|
|
|
@Injectable()
|
|
export class ViewWidgetUpsertService {
|
|
constructor(
|
|
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
|
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
|
private readonly applicationService: ApplicationService,
|
|
@InjectRepository(ViewEntity)
|
|
private readonly viewRepository: Repository<ViewEntity>,
|
|
) {}
|
|
|
|
async upsertViewWidget({
|
|
input,
|
|
workspaceId,
|
|
}: {
|
|
input: UpsertViewWidgetInput;
|
|
workspaceId: string;
|
|
}): Promise<ViewEntity> {
|
|
const { widgetId } = input;
|
|
|
|
const { workspaceCustomFlatApplication } =
|
|
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
|
{ workspaceId },
|
|
);
|
|
|
|
const {
|
|
flatPageLayoutWidgetMaps,
|
|
flatFieldMetadataMaps,
|
|
flatViewFieldMaps,
|
|
flatViewFilterMaps,
|
|
flatViewFilterGroupMaps,
|
|
flatViewSortMaps,
|
|
flatViewMaps,
|
|
} =
|
|
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
|
{
|
|
workspaceId,
|
|
flatMapsKeys: [
|
|
'flatPageLayoutWidgetMaps',
|
|
'flatFieldMetadataMaps',
|
|
'flatViewFieldMaps',
|
|
'flatViewFilterMaps',
|
|
'flatViewFilterGroupMaps',
|
|
'flatViewSortMaps',
|
|
'flatViewMaps',
|
|
],
|
|
},
|
|
);
|
|
|
|
const widget = findFlatEntityByIdInFlatEntityMaps({
|
|
flatEntityId: widgetId,
|
|
flatEntityMaps: flatPageLayoutWidgetMaps,
|
|
});
|
|
|
|
if (
|
|
!isDefined(widget) ||
|
|
!isFlatPageLayoutWidgetConfigurationOfType(
|
|
widget,
|
|
WidgetConfigurationType.RECORD_TABLE,
|
|
)
|
|
) {
|
|
throw new ViewException(
|
|
t`Record table widget not found`,
|
|
ViewExceptionCode.VIEW_WIDGET_NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
const viewId = widget.configuration.viewId;
|
|
|
|
if (!isDefined(viewId)) {
|
|
throw new ViewException(
|
|
t`Record table widget has no associated view`,
|
|
ViewExceptionCode.VIEW_WIDGET_NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
const flatView = findFlatEntityByIdInFlatEntityMaps({
|
|
flatEntityId: viewId,
|
|
flatEntityMaps: flatViewMaps,
|
|
});
|
|
|
|
if (!isDefined(flatView)) {
|
|
throw new ViewException(
|
|
t`View not found for widget`,
|
|
ViewExceptionCode.VIEW_NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
const upsertContext = {
|
|
viewId,
|
|
workspaceId,
|
|
applicationId: workspaceCustomFlatApplication.id,
|
|
applicationUniversalIdentifier:
|
|
workspaceCustomFlatApplication.universalIdentifier,
|
|
now: new Date().toISOString(),
|
|
};
|
|
|
|
if (
|
|
!isDefined(input.viewFields) &&
|
|
!isDefined(input.viewFilterGroups) &&
|
|
!isDefined(input.viewFilters) &&
|
|
!isDefined(input.viewSorts)
|
|
) {
|
|
const view = await this.viewRepository.findOne({
|
|
where: {
|
|
id: upsertContext.viewId,
|
|
workspaceId: upsertContext.workspaceId,
|
|
deletedAt: IsNull(),
|
|
},
|
|
});
|
|
|
|
if (!isDefined(view)) {
|
|
throw new ViewException(
|
|
t`View not found`,
|
|
ViewExceptionCode.VIEW_NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
return view;
|
|
}
|
|
|
|
const existingViewFields = Object.values(
|
|
flatViewFieldMaps.byUniversalIdentifier,
|
|
)
|
|
.filter(isDefined)
|
|
.filter((field) => field.isActive && field.viewId === viewId);
|
|
|
|
const existingViewFilters = Object.values(
|
|
flatViewFilterMaps.byUniversalIdentifier,
|
|
)
|
|
.filter(isDefined)
|
|
.filter((filter) => filter.viewId === viewId);
|
|
|
|
const existingViewFilterGroups = Object.values(
|
|
flatViewFilterGroupMaps.byUniversalIdentifier,
|
|
)
|
|
.filter(isDefined)
|
|
.filter((group) => group.viewId === viewId);
|
|
|
|
const existingViewSorts = Object.values(
|
|
flatViewSortMaps.byUniversalIdentifier,
|
|
)
|
|
.filter(isDefined)
|
|
.filter((sort) => sort.viewId === viewId);
|
|
|
|
const viewFieldOperations = isDefined(input.viewFields)
|
|
? this.computeViewFieldOperations({
|
|
inputFields: input.viewFields,
|
|
existingViewFields,
|
|
...upsertContext,
|
|
flatFieldMetadataMaps,
|
|
flatViewMaps,
|
|
})
|
|
: EMPTY_FIELD_OPS;
|
|
|
|
const viewFilterGroupOperations = isDefined(input.viewFilterGroups)
|
|
? this.computeViewFilterGroupOperations({
|
|
inputFilterGroups: input.viewFilterGroups,
|
|
existingViewFilterGroups,
|
|
...upsertContext,
|
|
flatViewMaps,
|
|
flatViewFilterGroupMaps,
|
|
})
|
|
: EMPTY_FILTER_GROUP_OPS;
|
|
|
|
const optimisticFilterGroupMaps =
|
|
viewFilterGroupOperations.filterGroupsToCreate.reduce(
|
|
(maps, group) =>
|
|
addFlatEntityToFlatEntityMapsOrThrow({
|
|
flatEntity: group,
|
|
flatEntityMaps: maps,
|
|
}),
|
|
flatViewFilterGroupMaps,
|
|
);
|
|
|
|
const viewFilterOperations = isDefined(input.viewFilters)
|
|
? this.computeViewFilterOperations({
|
|
inputFilters: input.viewFilters,
|
|
existingViewFilters,
|
|
...upsertContext,
|
|
flatFieldMetadataMaps,
|
|
flatViewMaps,
|
|
flatViewFilterGroupMaps: optimisticFilterGroupMaps,
|
|
})
|
|
: EMPTY_FILTER_OPS;
|
|
|
|
const viewSortOperations = isDefined(input.viewSorts)
|
|
? this.computeViewSortOperations({
|
|
inputSorts: input.viewSorts,
|
|
existingViewSorts,
|
|
...upsertContext,
|
|
flatFieldMetadataMaps,
|
|
flatViewMaps,
|
|
})
|
|
: EMPTY_SORT_OPS;
|
|
|
|
const {
|
|
toHardDelete: filterGroupsToDelete,
|
|
toDeactivate: filterGroupsToDeactivate,
|
|
} = splitEntitiesByRemovalStrategy({
|
|
entitiesToRemove: viewFilterGroupOperations.filterGroupsToRemove,
|
|
workspaceCustomApplicationUniversalIdentifier:
|
|
upsertContext.applicationUniversalIdentifier,
|
|
now: upsertContext.now,
|
|
});
|
|
|
|
const { toHardDelete: filtersToDelete, toDeactivate: filtersToDeactivate } =
|
|
splitEntitiesByRemovalStrategy({
|
|
entitiesToRemove: viewFilterOperations.filtersToRemove,
|
|
workspaceCustomApplicationUniversalIdentifier:
|
|
upsertContext.applicationUniversalIdentifier,
|
|
now: upsertContext.now,
|
|
});
|
|
|
|
const { toHardDelete: sortsToDelete, toDeactivate: sortsToDeactivate } =
|
|
splitEntitiesByRemovalStrategy({
|
|
entitiesToRemove: viewSortOperations.sortsToRemove,
|
|
workspaceCustomApplicationUniversalIdentifier:
|
|
upsertContext.applicationUniversalIdentifier,
|
|
now: upsertContext.now,
|
|
});
|
|
|
|
const validateAndBuildResult =
|
|
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
|
{
|
|
allFlatEntityOperationByMetadataName: {
|
|
viewField: {
|
|
flatEntityToCreate: viewFieldOperations.fieldsToCreate,
|
|
flatEntityToDelete: [],
|
|
flatEntityToUpdate: viewFieldOperations.fieldsToUpdate,
|
|
},
|
|
viewFilterGroup: {
|
|
flatEntityToCreate:
|
|
viewFilterGroupOperations.filterGroupsToCreate,
|
|
flatEntityToDelete: filterGroupsToDelete,
|
|
flatEntityToUpdate: [
|
|
...viewFilterGroupOperations.filterGroupsToUpdate,
|
|
...filterGroupsToDeactivate,
|
|
],
|
|
},
|
|
viewFilter: {
|
|
flatEntityToCreate: viewFilterOperations.filtersToCreate,
|
|
flatEntityToDelete: filtersToDelete,
|
|
flatEntityToUpdate: [
|
|
...viewFilterOperations.filtersToUpdate,
|
|
...filtersToDeactivate,
|
|
],
|
|
},
|
|
viewSort: {
|
|
flatEntityToCreate: viewSortOperations.sortsToCreate,
|
|
flatEntityToDelete: sortsToDelete,
|
|
flatEntityToUpdate: [
|
|
...viewSortOperations.sortsToUpdate,
|
|
...sortsToDeactivate,
|
|
],
|
|
},
|
|
},
|
|
workspaceId: upsertContext.workspaceId,
|
|
isSystemBuild: false,
|
|
applicationUniversalIdentifier:
|
|
upsertContext.applicationUniversalIdentifier,
|
|
},
|
|
);
|
|
|
|
if (validateAndBuildResult.status === 'fail') {
|
|
throw new WorkspaceMigrationBuilderException(
|
|
validateAndBuildResult,
|
|
'Multiple validation errors occurred while upserting view widget',
|
|
);
|
|
}
|
|
|
|
const view = await this.viewRepository.findOne({
|
|
where: {
|
|
id: upsertContext.viewId,
|
|
workspaceId: upsertContext.workspaceId,
|
|
deletedAt: IsNull(),
|
|
},
|
|
});
|
|
|
|
if (!isDefined(view)) {
|
|
throw new ViewException(
|
|
t`View not found after upsert`,
|
|
ViewExceptionCode.VIEW_NOT_FOUND,
|
|
);
|
|
}
|
|
|
|
return view;
|
|
}
|
|
|
|
private computeViewFieldOperations({
|
|
inputFields,
|
|
existingViewFields,
|
|
viewId,
|
|
workspaceId,
|
|
applicationId,
|
|
applicationUniversalIdentifier,
|
|
now,
|
|
flatFieldMetadataMaps,
|
|
flatViewMaps,
|
|
}: {
|
|
inputFields: UpsertViewWidgetViewFieldInput[];
|
|
existingViewFields: FlatViewField[];
|
|
viewId: string;
|
|
workspaceId: string;
|
|
applicationId: string;
|
|
applicationUniversalIdentifier: string;
|
|
now: string;
|
|
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
|
flatViewMaps: FlatViewMaps;
|
|
}): {
|
|
fieldsToCreate: FlatViewField[];
|
|
fieldsToUpdate: FlatViewField[];
|
|
} {
|
|
const fieldsToCreate: FlatViewField[] = [];
|
|
const fieldsToUpdate: FlatViewField[] = [];
|
|
|
|
for (const inputField of inputFields) {
|
|
const existingFieldByViewFieldId = isDefined(inputField.viewFieldId)
|
|
? existingViewFields.find((f) => f.id === inputField.viewFieldId)
|
|
: undefined;
|
|
|
|
const existingFieldByFieldMetadataId = isDefined(
|
|
inputField.fieldMetadataId,
|
|
)
|
|
? existingViewFields.find(
|
|
(f) => f.fieldMetadataId === inputField.fieldMetadataId,
|
|
)
|
|
: undefined;
|
|
|
|
const existingField =
|
|
existingFieldByViewFieldId ?? existingFieldByFieldMetadataId;
|
|
|
|
if (isDefined(existingField)) {
|
|
const resolvedIsVisible = isDefined(existingField.overrides?.isVisible)
|
|
? existingField.overrides.isVisible
|
|
: existingField.isVisible;
|
|
const resolvedPosition = isDefined(existingField.overrides?.position)
|
|
? existingField.overrides.position
|
|
: existingField.position;
|
|
const resolvedSize = isDefined(existingField.overrides?.size)
|
|
? existingField.overrides.size
|
|
: existingField.size;
|
|
|
|
const hasChanged =
|
|
resolvedIsVisible !== inputField.isVisible ||
|
|
resolvedPosition !== inputField.position ||
|
|
(isDefined(inputField.size) && resolvedSize !== inputField.size);
|
|
|
|
if (!hasChanged) {
|
|
continue;
|
|
}
|
|
|
|
const shouldOverride = isCallerOverridingEntity({
|
|
callerApplicationUniversalIdentifier: applicationUniversalIdentifier,
|
|
entityApplicationUniversalIdentifier:
|
|
existingField.applicationUniversalIdentifier,
|
|
workspaceCustomApplicationUniversalIdentifier:
|
|
applicationUniversalIdentifier,
|
|
});
|
|
|
|
const { overrides, updatedEditableProperties: sanitizedFieldProps } =
|
|
sanitizeOverridableEntityInput({
|
|
metadataName: 'viewField',
|
|
existingFlatEntity: existingField,
|
|
updatedEditableProperties: {
|
|
isVisible: inputField.isVisible,
|
|
position: inputField.position,
|
|
...(isDefined(inputField.size) ? { size: inputField.size } : {}),
|
|
},
|
|
shouldOverride,
|
|
});
|
|
|
|
const updatedField: FlatViewField = {
|
|
...existingField,
|
|
...sanitizedFieldProps,
|
|
overrides,
|
|
updatedAt: now,
|
|
};
|
|
|
|
if (isDefined(overrides)) {
|
|
updatedField.universalOverrides =
|
|
fromViewFieldOverridesToUniversalOverrides({
|
|
overrides,
|
|
viewFieldGroupUniversalIdentifierById: {},
|
|
});
|
|
} else {
|
|
updatedField.universalOverrides = null;
|
|
}
|
|
|
|
fieldsToUpdate.push(updatedField);
|
|
continue;
|
|
}
|
|
|
|
if (!isDefined(inputField.fieldMetadataId)) {
|
|
continue;
|
|
}
|
|
|
|
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
|
|
flatEntityId: inputField.fieldMetadataId,
|
|
flatEntityMaps: flatFieldMetadataMaps,
|
|
});
|
|
|
|
if (!isDefined(fieldMetadata)) {
|
|
continue;
|
|
}
|
|
|
|
const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } =
|
|
resolveEntityRelationUniversalIdentifiers({
|
|
metadataName: 'viewField',
|
|
foreignKeyValues: {
|
|
fieldMetadataId: inputField.fieldMetadataId,
|
|
viewId,
|
|
},
|
|
flatEntityMaps: {
|
|
flatFieldMetadataMaps,
|
|
flatViewMaps,
|
|
},
|
|
});
|
|
|
|
fieldsToCreate.push({
|
|
id: v4(),
|
|
workspaceId,
|
|
applicationId,
|
|
universalIdentifier: v4(),
|
|
applicationUniversalIdentifier,
|
|
fieldMetadataId: inputField.fieldMetadataId,
|
|
fieldMetadataUniversalIdentifier,
|
|
viewId,
|
|
viewUniversalIdentifier,
|
|
viewFieldGroupId: null,
|
|
viewFieldGroupUniversalIdentifier: null,
|
|
isVisible: inputField.isVisible,
|
|
size: inputField.size ?? DEFAULT_VIEW_FIELD_SIZE,
|
|
position: inputField.position,
|
|
aggregateOperation: null,
|
|
overrides: null,
|
|
universalOverrides: null,
|
|
isActive: true,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
deletedAt: null,
|
|
});
|
|
}
|
|
|
|
return { fieldsToCreate, fieldsToUpdate };
|
|
}
|
|
|
|
private computeViewFilterGroupOperations({
|
|
inputFilterGroups,
|
|
existingViewFilterGroups,
|
|
viewId,
|
|
workspaceId,
|
|
applicationId,
|
|
applicationUniversalIdentifier,
|
|
now,
|
|
flatViewMaps,
|
|
flatViewFilterGroupMaps,
|
|
}: {
|
|
inputFilterGroups: UpsertViewWidgetViewFilterGroupInput[];
|
|
existingViewFilterGroups: FlatViewFilterGroup[];
|
|
viewId: string;
|
|
workspaceId: string;
|
|
applicationId: string;
|
|
applicationUniversalIdentifier: string;
|
|
now: string;
|
|
flatViewMaps: FlatViewMaps;
|
|
flatViewFilterGroupMaps: FlatViewFilterGroupMaps;
|
|
}): {
|
|
filterGroupsToCreate: FlatViewFilterGroup[];
|
|
filterGroupsToUpdate: FlatViewFilterGroup[];
|
|
filterGroupsToRemove: FlatViewFilterGroup[];
|
|
} {
|
|
const filterGroupsToCreate: FlatViewFilterGroup[] = [];
|
|
const filterGroupsToUpdate: FlatViewFilterGroup[] = [];
|
|
const inputFilterGroupIds = new Set(
|
|
inputFilterGroups.map((g) => g.id).filter(isDefined),
|
|
);
|
|
|
|
for (const inputGroup of inputFilterGroups) {
|
|
const existingGroup = isDefined(inputGroup.id)
|
|
? existingViewFilterGroups.find((g) => g.id === inputGroup.id)
|
|
: undefined;
|
|
|
|
if (!isDefined(existingGroup)) {
|
|
const filterGroupId = inputGroup.id ?? v4();
|
|
|
|
const {
|
|
viewUniversalIdentifier,
|
|
parentViewFilterGroupUniversalIdentifier,
|
|
} = resolveEntityRelationUniversalIdentifiers({
|
|
metadataName: 'viewFilterGroup',
|
|
foreignKeyValues: {
|
|
viewId,
|
|
parentViewFilterGroupId: inputGroup.parentViewFilterGroupId,
|
|
},
|
|
flatEntityMaps: { flatViewMaps, flatViewFilterGroupMaps },
|
|
});
|
|
|
|
filterGroupsToCreate.push({
|
|
id: filterGroupId,
|
|
workspaceId,
|
|
applicationId,
|
|
universalIdentifier: filterGroupId,
|
|
applicationUniversalIdentifier,
|
|
viewId,
|
|
viewUniversalIdentifier,
|
|
logicalOperator:
|
|
inputGroup.logicalOperator ?? ViewFilterGroupLogicalOperator.AND,
|
|
parentViewFilterGroupId: inputGroup.parentViewFilterGroupId ?? null,
|
|
parentViewFilterGroupUniversalIdentifier,
|
|
positionInViewFilterGroup:
|
|
inputGroup.positionInViewFilterGroup ?? null,
|
|
viewFilterIds: [],
|
|
viewFilterUniversalIdentifiers: [],
|
|
childViewFilterGroupIds: [],
|
|
childViewFilterGroupUniversalIdentifiers: [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
deletedAt: null,
|
|
});
|
|
} else {
|
|
const hasChanged =
|
|
existingGroup.logicalOperator !== inputGroup.logicalOperator ||
|
|
existingGroup.positionInViewFilterGroup !==
|
|
inputGroup.positionInViewFilterGroup ||
|
|
existingGroup.parentViewFilterGroupId !==
|
|
inputGroup.parentViewFilterGroupId;
|
|
|
|
if (hasChanged) {
|
|
const resolvedParentId =
|
|
inputGroup.parentViewFilterGroupId ??
|
|
existingGroup.parentViewFilterGroupId;
|
|
|
|
const { parentViewFilterGroupUniversalIdentifier } =
|
|
resolveEntityRelationUniversalIdentifiers({
|
|
metadataName: 'viewFilterGroup',
|
|
foreignKeyValues: {
|
|
parentViewFilterGroupId: resolvedParentId,
|
|
},
|
|
flatEntityMaps: { flatViewFilterGroupMaps },
|
|
});
|
|
|
|
filterGroupsToUpdate.push({
|
|
...existingGroup,
|
|
logicalOperator:
|
|
inputGroup.logicalOperator ?? existingGroup.logicalOperator,
|
|
positionInViewFilterGroup:
|
|
inputGroup.positionInViewFilterGroup ??
|
|
existingGroup.positionInViewFilterGroup,
|
|
parentViewFilterGroupId: resolvedParentId,
|
|
parentViewFilterGroupUniversalIdentifier,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const filterGroupsToRemove = existingViewFilterGroups.filter(
|
|
(g) => !inputFilterGroupIds.has(g.id),
|
|
);
|
|
|
|
return { filterGroupsToCreate, filterGroupsToUpdate, filterGroupsToRemove };
|
|
}
|
|
|
|
private computeViewFilterOperations({
|
|
inputFilters,
|
|
existingViewFilters,
|
|
viewId,
|
|
workspaceId,
|
|
applicationId,
|
|
applicationUniversalIdentifier,
|
|
now,
|
|
flatFieldMetadataMaps,
|
|
flatViewMaps,
|
|
flatViewFilterGroupMaps,
|
|
}: {
|
|
inputFilters: UpsertViewWidgetViewFilterInput[];
|
|
existingViewFilters: FlatViewFilter[];
|
|
viewId: string;
|
|
workspaceId: string;
|
|
applicationId: string;
|
|
applicationUniversalIdentifier: string;
|
|
now: string;
|
|
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
|
flatViewMaps: FlatViewMaps;
|
|
flatViewFilterGroupMaps: FlatViewFilterGroupMaps;
|
|
}): {
|
|
filtersToCreate: FlatViewFilter[];
|
|
filtersToUpdate: FlatViewFilter[];
|
|
filtersToRemove: FlatViewFilter[];
|
|
} {
|
|
const filtersToCreate: FlatViewFilter[] = [];
|
|
const filtersToUpdate: FlatViewFilter[] = [];
|
|
const inputFilterIds = new Set(
|
|
inputFilters.map((f) => f.id).filter(isDefined),
|
|
);
|
|
|
|
for (const inputFilter of inputFilters) {
|
|
const existingFilter = isDefined(inputFilter.id)
|
|
? existingViewFilters.find((f) => f.id === inputFilter.id)
|
|
: undefined;
|
|
|
|
if (!isDefined(existingFilter)) {
|
|
const filterId = inputFilter.id ?? v4();
|
|
|
|
const {
|
|
fieldMetadataUniversalIdentifier,
|
|
viewUniversalIdentifier,
|
|
viewFilterGroupUniversalIdentifier,
|
|
relationTargetFieldMetadataUniversalIdentifier,
|
|
} = resolveEntityRelationUniversalIdentifiers({
|
|
metadataName: 'viewFilter',
|
|
foreignKeyValues: {
|
|
fieldMetadataId: inputFilter.fieldMetadataId,
|
|
viewId,
|
|
viewFilterGroupId: inputFilter.viewFilterGroupId,
|
|
relationTargetFieldMetadataId:
|
|
inputFilter.relationTargetFieldMetadataId,
|
|
},
|
|
flatEntityMaps: {
|
|
flatFieldMetadataMaps,
|
|
flatViewMaps,
|
|
flatViewFilterGroupMaps,
|
|
},
|
|
});
|
|
|
|
filtersToCreate.push({
|
|
id: filterId,
|
|
workspaceId,
|
|
applicationId,
|
|
universalIdentifier: v4(),
|
|
applicationUniversalIdentifier,
|
|
fieldMetadataId: inputFilter.fieldMetadataId,
|
|
fieldMetadataUniversalIdentifier,
|
|
viewId,
|
|
viewUniversalIdentifier,
|
|
operand: inputFilter.operand ?? ViewFilterOperand.CONTAINS,
|
|
value: inputFilter.value,
|
|
viewFilterGroupId: inputFilter.viewFilterGroupId ?? null,
|
|
viewFilterGroupUniversalIdentifier,
|
|
positionInViewFilterGroup:
|
|
inputFilter.positionInViewFilterGroup ?? null,
|
|
subFieldName: inputFilter.subFieldName ?? null,
|
|
relationTargetFieldMetadataId:
|
|
inputFilter.relationTargetFieldMetadataId ?? null,
|
|
relationTargetFieldMetadataUniversalIdentifier,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
deletedAt: null,
|
|
});
|
|
} else {
|
|
const hasChanged =
|
|
existingFilter.fieldMetadataId !== inputFilter.fieldMetadataId ||
|
|
existingFilter.operand !== inputFilter.operand ||
|
|
JSON.stringify(existingFilter.value) !==
|
|
JSON.stringify(inputFilter.value) ||
|
|
existingFilter.viewFilterGroupId !== inputFilter.viewFilterGroupId ||
|
|
existingFilter.positionInViewFilterGroup !==
|
|
inputFilter.positionInViewFilterGroup ||
|
|
existingFilter.subFieldName !== inputFilter.subFieldName ||
|
|
existingFilter.relationTargetFieldMetadataId !==
|
|
(inputFilter.relationTargetFieldMetadataId ?? null);
|
|
|
|
if (hasChanged) {
|
|
const {
|
|
fieldMetadataUniversalIdentifier,
|
|
viewFilterGroupUniversalIdentifier,
|
|
relationTargetFieldMetadataUniversalIdentifier,
|
|
} = resolveEntityRelationUniversalIdentifiers({
|
|
metadataName: 'viewFilter',
|
|
foreignKeyValues: {
|
|
fieldMetadataId: inputFilter.fieldMetadataId,
|
|
viewFilterGroupId: inputFilter.viewFilterGroupId,
|
|
relationTargetFieldMetadataId:
|
|
inputFilter.relationTargetFieldMetadataId,
|
|
},
|
|
flatEntityMaps: {
|
|
flatFieldMetadataMaps,
|
|
flatViewFilterGroupMaps,
|
|
},
|
|
});
|
|
|
|
filtersToUpdate.push({
|
|
...existingFilter,
|
|
fieldMetadataId: inputFilter.fieldMetadataId,
|
|
fieldMetadataUniversalIdentifier,
|
|
operand: inputFilter.operand ?? existingFilter.operand,
|
|
value: inputFilter.value,
|
|
viewFilterGroupId:
|
|
inputFilter.viewFilterGroupId ?? existingFilter.viewFilterGroupId,
|
|
viewFilterGroupUniversalIdentifier,
|
|
positionInViewFilterGroup:
|
|
inputFilter.positionInViewFilterGroup ??
|
|
existingFilter.positionInViewFilterGroup,
|
|
subFieldName:
|
|
inputFilter.subFieldName ?? existingFilter.subFieldName,
|
|
relationTargetFieldMetadataId:
|
|
inputFilter.relationTargetFieldMetadataId ?? null,
|
|
relationTargetFieldMetadataUniversalIdentifier,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const filtersToRemove = existingViewFilters.filter(
|
|
(f) => !inputFilterIds.has(f.id),
|
|
);
|
|
|
|
return { filtersToCreate, filtersToUpdate, filtersToRemove };
|
|
}
|
|
|
|
private computeViewSortOperations({
|
|
inputSorts,
|
|
existingViewSorts,
|
|
viewId,
|
|
workspaceId,
|
|
applicationId,
|
|
applicationUniversalIdentifier,
|
|
now,
|
|
flatFieldMetadataMaps,
|
|
flatViewMaps,
|
|
}: {
|
|
inputSorts: UpsertViewWidgetViewSortInput[];
|
|
existingViewSorts: FlatViewSort[];
|
|
viewId: string;
|
|
workspaceId: string;
|
|
applicationId: string;
|
|
applicationUniversalIdentifier: string;
|
|
now: string;
|
|
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
|
flatViewMaps: FlatViewMaps;
|
|
}): {
|
|
sortsToCreate: FlatViewSort[];
|
|
sortsToUpdate: FlatViewSort[];
|
|
sortsToRemove: FlatViewSort[];
|
|
} {
|
|
const sortsToCreate: FlatViewSort[] = [];
|
|
const sortsToUpdate: FlatViewSort[] = [];
|
|
const inputSortIds = new Set(inputSorts.map((s) => s.id).filter(isDefined));
|
|
|
|
for (const inputSort of inputSorts) {
|
|
const existingSort = isDefined(inputSort.id)
|
|
? existingViewSorts.find((s) => s.id === inputSort.id)
|
|
: undefined;
|
|
|
|
if (!isDefined(existingSort)) {
|
|
const sortId = inputSort.id ?? v4();
|
|
|
|
const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } =
|
|
resolveEntityRelationUniversalIdentifiers({
|
|
metadataName: 'viewSort',
|
|
foreignKeyValues: {
|
|
fieldMetadataId: inputSort.fieldMetadataId,
|
|
viewId,
|
|
},
|
|
flatEntityMaps: {
|
|
flatFieldMetadataMaps,
|
|
flatViewMaps,
|
|
},
|
|
});
|
|
|
|
sortsToCreate.push({
|
|
id: sortId,
|
|
workspaceId,
|
|
applicationId,
|
|
universalIdentifier: v4(),
|
|
applicationUniversalIdentifier,
|
|
fieldMetadataId: inputSort.fieldMetadataId,
|
|
fieldMetadataUniversalIdentifier,
|
|
viewId,
|
|
viewUniversalIdentifier,
|
|
direction: inputSort.direction ?? ViewSortDirection.ASC,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
deletedAt: null,
|
|
});
|
|
} else {
|
|
const hasChanged =
|
|
existingSort.fieldMetadataId !== inputSort.fieldMetadataId ||
|
|
existingSort.direction !== inputSort.direction;
|
|
|
|
if (hasChanged) {
|
|
const { fieldMetadataUniversalIdentifier } =
|
|
resolveEntityRelationUniversalIdentifiers({
|
|
metadataName: 'viewSort',
|
|
foreignKeyValues: {
|
|
fieldMetadataId: inputSort.fieldMetadataId,
|
|
},
|
|
flatEntityMaps: {
|
|
flatFieldMetadataMaps,
|
|
},
|
|
});
|
|
|
|
sortsToUpdate.push({
|
|
...existingSort,
|
|
fieldMetadataId: inputSort.fieldMetadataId,
|
|
fieldMetadataUniversalIdentifier,
|
|
direction: inputSort.direction ?? existingSort.direction,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const sortsToRemove = existingViewSorts.filter(
|
|
(s) => !inputSortIds.has(s.id),
|
|
);
|
|
|
|
return { sortsToCreate, sortsToUpdate, sortsToRemove };
|
|
}
|
|
}
|