Compare commits

...
Author SHA1 Message Date
Félix Malfait 69ee0bd6b0 refactor: address review feedback on onboarding view seeding
- Move the view seeding into prefillCreatedWorkspaceRecords (after the
  record transaction), alongside the other workspace prefill, instead of
  a separate activateWorkspace step.
- Seed the example companies' account owner deterministically from the
  workspace creator (order by creation) with an explicit select alias.
- In the advanced-filter record picker, only fall back to the source
  field when there is no relation-target leaf; a set-but-unresolvable
  leaf no longer loads records from the wrong object.
- Drop the redundant comment on the relation-target field menu.
2026-06-01 23:18:12 +02:00
Félix Malfait 9c693002d8 feat: seed private onboarding kanban with nested account-owner filter
Seed a private "My Pipeline" kanban for each new workspace's first user:
opportunities grouped by stage, filtered by the nested advanced rule
"company.accountOwner = me". Example companies seeded at activation now
set accountOwner to that user so the board isn't empty. This introduces
new users to kanban views, nested relation filters and private views.

To make that filter user-authorable too, a many-to-one relation can now
be selected as an advanced-filter leaf. It filters by the relation's
foreign key (company.accountOwnerId = X) -- a single hop the backend
already resolves -- and the record picker resolves its options from the
leaf relation's target object.
2026-06-01 18:16:58 +02:00
9 changed files with 476 additions and 12 deletions
@@ -62,17 +62,9 @@ export const AdvancedFilterRelationTargetFieldSelectMenu = ({
? sourceFieldMetadataItem.relation.targetObjectMetadata.id
: null;
const { filterableFieldMetadataItems: allTargetFields } =
const { filterableFieldMetadataItems: relationTargetFields } =
useFilterableFieldMetadataItems(targetObjectMetadataId ?? '');
// The backend supports a single hop only. Exclude many-to-one relations
// from the target list so the user can't compose multi-hop traversals
// (e.g. Person → Company → ParentCompany) that the dispatcher would
// collapse back to a filter-by-id on the intermediate relation.
const relationTargetFields = allTargetFields.filter(
(field) => !isManyToOneRelationField(field),
);
if (
!isDefined(sourceFieldMetadataItem) ||
!isManyToOneRelationField(sourceFieldMetadataItem)
@@ -1,11 +1,14 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { getRelationObjectMetadataNameSingular } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { ObjectFilterDropdownRecordPinnedItems } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordPinnedItems';
import { CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID } from '@/object-record/object-filter-dropdown/constants/CurrentWorkspaceMemberSelectableItemId';
import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue';
import { useObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useObjectFilterDropdownFilterValue';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown';
@@ -66,6 +69,13 @@ export const ObjectFilterDropdownRecordSelect = ({
currentRecordFiltersComponentState,
);
const relationTargetFieldMetadataIdUsedInDropdown =
useAtomComponentStateValue(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const { isCurrentWorkspaceMemberSelected } = jsonRelationFilterValueSchema
.catch({
isCurrentWorkspaceMemberSelected: false,
@@ -79,8 +89,32 @@ export const ObjectFilterDropdownRecordSelect = ({
throw new Error('fieldMetadataItemUsedInFilterDropdown is not defined');
}
// For a nested relation filter (e.g. company → accountOwner) the records to
// choose from belong to the leaf relation's target object (WorkspaceMember),
// not the source relation's object (Company). Only a direct relation filter
// (no leaf) falls back to the source field; a leaf that is set but cannot be
// resolved must not load records from the source object.
const relationTargetFieldMetadataItem = isDefined(
relationTargetFieldMetadataIdUsedInDropdown,
)
? getFieldMetadataItemById({
fieldMetadataId: relationTargetFieldMetadataIdUsedInDropdown,
objectMetadataItems,
}).fieldMetadataItem
: undefined;
const effectiveFieldMetadataItem = isDefined(
relationTargetFieldMetadataIdUsedInDropdown,
)
? relationTargetFieldMetadataItem
: fieldMetadataItemUsedInFilterDropdown;
if (!isDefined(effectiveFieldMetadataItem)) {
throw new Error('effectiveFieldMetadataItem is not defined');
}
const objectNameSingular = getRelationObjectMetadataNameSingular({
field: fieldMetadataItemUsedInFilterDropdown,
field: effectiveFieldMetadataItem,
});
if (!isDefined(objectNameSingular)) {
@@ -30,6 +30,7 @@ import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/co
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
import { SeedOnboardingPrivateViewService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/seed-onboarding-private-view.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
@@ -101,6 +102,7 @@ describe('WorkspaceService', () => {
PreInstalledAppsService,
SdkClientGenerationService,
PrefillLogicFunctionService,
SeedOnboardingPrivateViewService,
WorkspaceMigrationValidateBuildAndRunService,
UpgradeMigrationService,
UpgradeSequenceReaderService,
@@ -65,6 +65,7 @@ import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
import { SeedOnboardingPrivateViewService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/seed-onboarding-private-view.service';
import { prefillCompanies } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-companies.util';
import { prefillDashboards } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-dashboards.util';
import { prefillOpportunities } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-opportunities.util';
@@ -122,6 +123,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
private readonly userWorkspaceService: UserWorkspaceService,
private readonly twentyConfigService: TwentyConfigService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly seedOnboardingPrivateViewService: SeedOnboardingPrivateViewService,
private readonly permissionsService: PermissionsService,
private readonly dnsManagerService: DnsManagerService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
@@ -366,9 +368,16 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
await this.userWorkspaceService.createWorkspaceMember(workspace.id, user);
const creatorUserWorkspace =
await this.userWorkspaceRepository.findOneByOrFail({
userId: user.id,
workspaceId: workspace.id,
});
await this.prefillCreatedWorkspaceRecords({
workspaceId: workspace.id,
schemaName: getWorkspaceSchemaName(workspace.id),
createdByUserWorkspaceId: creatorUserWorkspace.id,
});
await this.activateAndInitializeUpgradeState({
@@ -785,9 +794,11 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
private async prefillCreatedWorkspaceRecords({
workspaceId,
schemaName,
createdByUserWorkspaceId,
}: {
workspaceId: string;
schemaName: string;
createdByUserWorkspaceId: string;
}): Promise<void> {
const {
flatObjectMetadataMaps,
@@ -851,6 +862,23 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
await queryRunner.release();
}
// Seed a private onboarding kanban for the workspace creator alongside the
// other workspace records. Best-effort: a failure must never block
// activation. Runs after the record transaction because views are metadata
// created through their own migrations.
try {
await this.seedOnboardingPrivateViewService.seedForWorkspace({
workspaceId,
createdByUserWorkspaceId,
});
} catch (error) {
this.logger.error(
`failed to seed onboarding private view for workspace ${workspaceId}`,
error,
);
this.exceptionHandlerService.captureExceptions([error as Error]);
}
try {
await prefillWorkflowCommandMenuItems({
workspaceId,
@@ -0,0 +1,178 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
AggregateOperations,
ViewFilterOperand,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { SeedOnboardingPrivateViewService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/seed-onboarding-private-view.service';
const opportunityStandardObject = STANDARD_OBJECTS.opportunity;
const companyStandardObject = STANDARD_OBJECTS.company;
const FIELD_METADATA_IDS = {
stage: 'stage-field-id',
name: 'name-field-id',
amount: 'amount-field-id',
createdBy: 'created-by-field-id',
closeDate: 'close-date-field-id',
company: 'company-field-id',
pointOfContact: 'point-of-contact-field-id',
accountOwner: 'account-owner-field-id',
};
const OPPORTUNITY_OBJECT_METADATA_ID = 'opportunity-object-id';
const SEEDED_VIEW_ID = 'seeded-view-id';
const WORKSPACE_ID = 'workspace-id';
const CREATED_BY_USER_WORKSPACE_ID = 'user-workspace-id';
const flatObjectMetadataMaps = {
byUniversalIdentifier: {
[opportunityStandardObject.universalIdentifier]: {
id: OPPORTUNITY_OBJECT_METADATA_ID,
},
},
};
const flatFieldMetadataMaps = {
byUniversalIdentifier: {
[opportunityStandardObject.fields.stage.universalIdentifier]: {
id: FIELD_METADATA_IDS.stage,
},
[opportunityStandardObject.fields.name.universalIdentifier]: {
id: FIELD_METADATA_IDS.name,
},
[opportunityStandardObject.fields.amount.universalIdentifier]: {
id: FIELD_METADATA_IDS.amount,
},
[opportunityStandardObject.fields.createdBy.universalIdentifier]: {
id: FIELD_METADATA_IDS.createdBy,
},
[opportunityStandardObject.fields.closeDate.universalIdentifier]: {
id: FIELD_METADATA_IDS.closeDate,
},
[opportunityStandardObject.fields.company.universalIdentifier]: {
id: FIELD_METADATA_IDS.company,
},
[opportunityStandardObject.fields.pointOfContact.universalIdentifier]: {
id: FIELD_METADATA_IDS.pointOfContact,
},
[companyStandardObject.fields.accountOwner.universalIdentifier]: {
id: FIELD_METADATA_IDS.accountOwner,
},
},
};
describe('SeedOnboardingPrivateViewService', () => {
let service: SeedOnboardingPrivateViewService;
let viewService: jest.Mocked<ViewService>;
let viewFieldService: jest.Mocked<ViewFieldService>;
let viewFilterService: jest.Mocked<ViewFilterService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SeedOnboardingPrivateViewService,
{
provide: ViewService,
useValue: {
createOne: jest.fn().mockResolvedValue({ id: SEEDED_VIEW_ID }),
},
},
{
provide: ViewFieldService,
useValue: { createMany: jest.fn().mockResolvedValue([]) },
},
{
provide: ViewFilterService,
useValue: { createOne: jest.fn().mockResolvedValue({}) },
},
{
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
useValue: {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
},
},
],
}).compile();
service = module.get(SeedOnboardingPrivateViewService);
viewService = module.get(ViewService);
viewFieldService = module.get(ViewFieldService);
viewFilterService = module.get(ViewFilterService);
});
it('creates a private kanban view grouped by stage for the workspace creator', async () => {
await service.seedForWorkspace({
workspaceId: WORKSPACE_ID,
createdByUserWorkspaceId: CREATED_BY_USER_WORKSPACE_ID,
});
expect(viewService.createOne).toHaveBeenCalledWith({
workspaceId: WORKSPACE_ID,
createdByUserWorkspaceId: CREATED_BY_USER_WORKSPACE_ID,
createViewInput: expect.objectContaining({
name: 'My Pipeline',
objectMetadataId: OPPORTUNITY_OBJECT_METADATA_ID,
type: ViewType.KANBAN,
mainGroupByFieldMetadataId: FIELD_METADATA_IDS.stage,
kanbanAggregateOperation: AggregateOperations.SUM,
kanbanAggregateOperationFieldMetadataId: FIELD_METADATA_IDS.amount,
visibility: ViewVisibility.UNLISTED,
}),
});
});
it('seeds the standard kanban card fields on the new view', async () => {
await service.seedForWorkspace({
workspaceId: WORKSPACE_ID,
createdByUserWorkspaceId: CREATED_BY_USER_WORKSPACE_ID,
});
expect(viewFieldService.createMany).toHaveBeenCalledWith({
workspaceId: WORKSPACE_ID,
createViewFieldInputs: [
FIELD_METADATA_IDS.name,
FIELD_METADATA_IDS.amount,
FIELD_METADATA_IDS.createdBy,
FIELD_METADATA_IDS.closeDate,
FIELD_METADATA_IDS.company,
FIELD_METADATA_IDS.pointOfContact,
].map((fieldMetadataId, position) => ({
fieldMetadataId,
viewId: SEEDED_VIEW_ID,
position,
isVisible: true,
size: 150,
})),
});
});
it('seeds the nested company.accountOwner = me advanced filter', async () => {
await service.seedForWorkspace({
workspaceId: WORKSPACE_ID,
createdByUserWorkspaceId: CREATED_BY_USER_WORKSPACE_ID,
});
expect(viewFilterService.createOne).toHaveBeenCalledWith({
workspaceId: WORKSPACE_ID,
createViewFilterInput: {
viewId: SEEDED_VIEW_ID,
fieldMetadataId: FIELD_METADATA_IDS.company,
relationTargetFieldMetadataId: FIELD_METADATA_IDS.accountOwner,
operand: ViewFilterOperand.IS,
value: { isCurrentWorkspaceMemberSelected: true, selectedRecordIds: [] },
},
});
});
});
@@ -0,0 +1,135 @@
import { Injectable } from '@nestjs/common';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
AggregateOperations,
ViewFilterOperand,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
const SEEDED_VIEW_CARD_FIELD_SIZE = 150;
// Seeds a private "My Pipeline" kanban for the workspace creator: opportunities
// grouped by stage, filtered to the accounts they own
// (company.accountOwner = me). It introduces new users to kanban views, nested
// relation filters and private (per-user) views through a single example view.
@Injectable()
export class SeedOnboardingPrivateViewService {
constructor(
private readonly viewService: ViewService,
private readonly viewFieldService: ViewFieldService,
private readonly viewFilterService: ViewFilterService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async seedForWorkspace({
workspaceId,
createdByUserWorkspaceId,
}: {
workspaceId: string;
createdByUserWorkspaceId: string;
}): Promise<void> {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const opportunityStandardObject = STANDARD_OBJECTS.opportunity;
const companyStandardObject = STANDARD_OBJECTS.company;
const opportunityObjectMetadataId =
findFlatEntityByUniversalIdentifierOrThrow({
universalIdentifier: opportunityStandardObject.universalIdentifier,
flatEntityMaps: flatObjectMetadataMaps,
}).id;
const resolveFieldMetadataId = (universalIdentifier: string) =>
findFlatEntityByUniversalIdentifierOrThrow({
universalIdentifier,
flatEntityMaps: flatFieldMetadataMaps,
}).id;
const stageFieldMetadataId = resolveFieldMetadataId(
opportunityStandardObject.fields.stage.universalIdentifier,
);
const nameFieldMetadataId = resolveFieldMetadataId(
opportunityStandardObject.fields.name.universalIdentifier,
);
const amountFieldMetadataId = resolveFieldMetadataId(
opportunityStandardObject.fields.amount.universalIdentifier,
);
const createdByFieldMetadataId = resolveFieldMetadataId(
opportunityStandardObject.fields.createdBy.universalIdentifier,
);
const closeDateFieldMetadataId = resolveFieldMetadataId(
opportunityStandardObject.fields.closeDate.universalIdentifier,
);
const companyFieldMetadataId = resolveFieldMetadataId(
opportunityStandardObject.fields.company.universalIdentifier,
);
const pointOfContactFieldMetadataId = resolveFieldMetadataId(
opportunityStandardObject.fields.pointOfContact.universalIdentifier,
);
const accountOwnerFieldMetadataId = resolveFieldMetadataId(
companyStandardObject.fields.accountOwner.universalIdentifier,
);
const view = await this.viewService.createOne({
workspaceId,
createdByUserWorkspaceId,
createViewInput: {
name: 'My Pipeline',
objectMetadataId: opportunityObjectMetadataId,
type: ViewType.KANBAN,
icon: 'IconLayoutKanban',
position: 3,
mainGroupByFieldMetadataId: stageFieldMetadataId,
kanbanAggregateOperation: AggregateOperations.SUM,
kanbanAggregateOperationFieldMetadataId: amountFieldMetadataId,
visibility: ViewVisibility.UNLISTED,
},
});
// Card fields mirror the standard "By Stage" kanban so the seeded board
// looks complete (createOne does not generate view fields).
await this.viewFieldService.createMany({
workspaceId,
createViewFieldInputs: [
nameFieldMetadataId,
amountFieldMetadataId,
createdByFieldMetadataId,
closeDateFieldMetadataId,
companyFieldMetadataId,
pointOfContactFieldMetadataId,
].map((fieldMetadataId, position) => ({
fieldMetadataId,
viewId: view.id,
position,
isVisible: true,
size: SEEDED_VIEW_CARD_FIELD_SIZE,
})),
});
// Nested relation filter: opportunity.company → company.accountOwner = me.
await this.viewFilterService.createOne({
workspaceId,
createViewFilterInput: {
viewId: view.id,
fieldMetadataId: companyFieldMetadataId,
relationTargetFieldMetadataId: accountOwnerFieldMetadataId,
operand: ViewFilterOperand.IS,
value: { isCurrentWorkspaceMemberSelected: true, selectedRecordIds: [] },
},
});
}
}
@@ -5,8 +5,12 @@ import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-sto
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FrontComponentModule } from 'src/engine/metadata-modules/front-component/front-component.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { PrefillFrontComponentService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service';
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
import { SeedOnboardingPrivateViewService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/seed-onboarding-private-view.service';
@Module({
imports: [
@@ -15,8 +19,19 @@ import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standa
FileStorageModule,
ApplicationModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ViewModule,
ViewFieldModule,
ViewFilterModule,
],
providers: [
PrefillLogicFunctionService,
PrefillFrontComponentService,
SeedOnboardingPrivateViewService,
],
exports: [
PrefillLogicFunctionService,
PrefillFrontComponentService,
SeedOnboardingPrivateViewService,
],
providers: [PrefillLogicFunctionService, PrefillFrontComponentService],
exports: [PrefillLogicFunctionService, PrefillFrontComponentService],
})
export class StandardObjectsPrefillModule {}
@@ -11,6 +11,18 @@ export const prefillCompanies = async (
entityManager: EntityManager,
schemaName: string,
) => {
// The workspace creator is the first member created at activation time;
// order by creation so the seeded account owner is deterministically them.
const workspaceCreator = await entityManager
.createQueryBuilder()
.select('id', 'id')
.from(`${schemaName}.workspaceMember`, 'workspaceMember')
.orderBy('"createdAt"', 'ASC')
.limit(1)
.getRawOne<{ id: string }>();
const accountOwnerId = workspaceCreator?.id ?? null;
await entityManager
.createQueryBuilder()
.insert()
@@ -26,6 +38,7 @@ export const prefillCompanies = async (
'addressAddressCountry',
'employees',
'position',
'accountOwnerId',
'createdBySource',
'createdByWorkspaceMemberId',
'createdByName',
@@ -47,6 +60,7 @@ export const prefillCompanies = async (
addressAddressCountry: 'United States',
employees: 5000,
position: 1,
accountOwnerId,
createdBySource: FieldActorSource.SYSTEM,
createdByWorkspaceMemberId: null,
createdByName: 'System',
@@ -66,6 +80,7 @@ export const prefillCompanies = async (
addressAddressCountry: 'United States',
employees: 1100,
position: 2,
accountOwnerId,
createdBySource: FieldActorSource.SYSTEM,
createdByWorkspaceMemberId: null,
createdByName: 'System',
@@ -85,6 +100,7 @@ export const prefillCompanies = async (
addressAddressCountry: 'Ireland',
employees: 8000,
position: 3,
accountOwnerId,
createdBySource: FieldActorSource.SYSTEM,
createdByWorkspaceMemberId: null,
createdByName: 'System',
@@ -104,6 +120,7 @@ export const prefillCompanies = async (
addressAddressCountry: 'United States',
employees: 800,
position: 4,
accountOwnerId,
createdBySource: FieldActorSource.SYSTEM,
createdByWorkspaceMemberId: null,
createdByName: 'System',
@@ -123,6 +140,7 @@ export const prefillCompanies = async (
addressAddressCountry: 'United States',
employees: 400,
position: 5,
accountOwnerId,
createdBySource: FieldActorSource.SYSTEM,
createdByWorkspaceMemberId: null,
createdByName: 'System',
@@ -49,6 +49,12 @@ const fields = [
type: FieldMetadataType.RELATION,
label: 'Company',
},
{
id: 'f-relation-account-owner',
name: 'accountOwner',
type: FieldMetadataType.RELATION,
label: 'Account Owner',
},
{
id: 'f-bool',
name: 'isActive',
@@ -1038,5 +1044,61 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => {
expect(result).toHaveProperty('companyId.in');
});
// A relation target field compiles to a single-hop foreign-key compare on
// the already-joined table (company.accountOwnerId), never a second
// relation block — the backend rejects deeper `company.accountOwner.<field>`
// traversals.
it('should resolve a relation target field to its foreign key', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: {
...makeFilter(
'f-relation',
RecordFilterOperand.IS,
'["550e8400-e29b-41d4-a716-446655440000"]',
'RELATION',
),
relationTargetFieldMetadataId: 'f-relation-account-owner',
} as RecordFilter,
fieldMetadataItemById,
});
expect(result).toEqual({
company: {
accountOwnerId: { in: ['550e8400-e29b-41d4-a716-446655440000'] },
},
});
});
// The seeded "My Pipeline" onboarding view (company.accountOwner = me)
// resolves the current workspace member into that same FK compare.
it('should resolve a relation target field set to the current workspace member', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies: {
...filterValueDependencies,
currentWorkspaceMemberId: '11111111-1111-4111-8111-111111111111',
},
recordFilter: {
...makeFilter(
'f-relation',
RecordFilterOperand.IS,
JSON.stringify({
isCurrentWorkspaceMemberSelected: true,
selectedRecordIds: [],
}),
'RELATION',
),
relationTargetFieldMetadataId: 'f-relation-account-owner',
} as RecordFilter,
fieldMetadataItemById,
});
expect(result).toEqual({
company: {
accountOwnerId: { in: ['11111111-1111-4111-8111-111111111111'] },
},
});
});
});
});