Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51fd08a019 | ||
|
|
5024f54976 | ||
|
|
cb5235e5be | ||
|
|
bb5c2bd00c | ||
|
|
a2673da164 | ||
|
|
08682fb3f5 | ||
|
|
53d4e92dda | ||
|
|
53392f9a16 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-partners",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -16,8 +16,13 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"seed": "tsx src/scripts/seed.ts",
|
||||
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
|
||||
"purge": "tsx src/scripts/purge-soft-deleted.ts",
|
||||
"purge:prod": "ENV_FILE=.env.prod tsx src/scripts/purge-soft-deleted.ts",
|
||||
"import:dryrun": "tsx src/scripts/import-from-tft.ts",
|
||||
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
|
||||
"import:dryrun:prod": "ENV_FILE=.env.prod tsx src/scripts/import-from-tft.ts",
|
||||
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
|
||||
"import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-client-sdk": "2.4.0",
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ export const APP_DESCRIPTION = '';
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447b3965';
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db';
|
||||
export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
|
||||
export const PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
|
||||
export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
|
||||
export const ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '379b11d5-44d5-476b-ba7d-31d5f515c9b4';
|
||||
export const ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER = '7a34da39-a8e1-44c7-88b4-91ceaa064920';
|
||||
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
|
||||
@@ -35,7 +35,7 @@ export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1
|
||||
// Partner views + nav (harmonization)
|
||||
export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = 'b57a84ed-d7c1-420d-b0eb-348db0dac612';
|
||||
export const VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '13cca6a7-b9f1-4103-b011-ea2e39430899';
|
||||
export const PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
|
||||
export const PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
|
||||
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
|
||||
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
|
||||
export const PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
|
||||
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID = 'd448f6be-533f-4c43-a70f-55dc361dcdd7';
|
||||
export const PARTNER_CONTENTS_ON_COMPANY_FIELD_ID = '941e7707-1b3d-47d4-b13a-45c6628c1b3d';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'customerCompany',
|
||||
label: 'Customer Company',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'customerCompanyId',
|
||||
},
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID = 'f17547be-76aa-4231-bd1a-3f57bb1ae323';
|
||||
export const PARTNER_CONTENTS_ON_PERSON_FIELD_ID = 'fae42f4e-b054-4267-a761-81a6792f7c12';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'customerPerson',
|
||||
label: 'Customer Person',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'customerPersonId',
|
||||
},
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_CONTENT_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
|
||||
export const PARTNER_CONTENTS_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partner',
|
||||
label: 'Partner',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'partnerId',
|
||||
},
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_CONTENTS_ON_COMPANY_FIELD_ID, PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID } from './partner-content-customer-company.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
|
||||
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerContents',
|
||||
label: 'Partner Content',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, PARTNER_CONTENT_PARTNER_FIELD_ID } from './partner-content-partner.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerContents',
|
||||
label: 'Partner Content',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_CONTENTS_ON_PERSON_FIELD_ID, PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID } from './partner-content-customer-person.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
|
||||
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerContents',
|
||||
label: 'Partner Content',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_QUOTE_OPPORTUNITY_FIELD_ID = '36684f3e-f01a-4270-8f34-78966747dd64';
|
||||
export const PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID = '4857f339-a051-4cdb-bd8a-6219b933d1ce';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'opportunity',
|
||||
label: 'Opportunity',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'opportunityId',
|
||||
},
|
||||
});
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_QUOTE_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
|
||||
export const PARTNER_QUOTES_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partner',
|
||||
label: 'Partner',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'partnerId',
|
||||
},
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID, PARTNER_QUOTE_OPPORTUNITY_FIELD_ID } from './partner-quote-opportunity.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
|
||||
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerQuotes',
|
||||
label: 'Partner Quotes',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_QUOTES_ON_PARTNER_FIELD_ID, PARTNER_QUOTE_PARTNER_FIELD_ID } from './partner-quote-partner.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerQuotes',
|
||||
label: 'Partner Quotes',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
+5
-5
@@ -1,15 +1,15 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
|
||||
universalIdentifier: PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
icon: 'IconFileDollar',
|
||||
icon: 'IconQuote',
|
||||
position: 3,
|
||||
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
|
||||
viewUniversalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
viewUniversalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
+30
-16
@@ -1,15 +1,15 @@
|
||||
import { FieldType, defineObject } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'partnerQuote',
|
||||
namePlural: 'partnerQuotes',
|
||||
labelSingular: 'Partner Quote',
|
||||
labelPlural: 'Partner Quotes',
|
||||
description: 'A quote a partner submitted for a customer deal',
|
||||
icon: 'IconFileDollar',
|
||||
universalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'partnerContent',
|
||||
namePlural: 'partnerContents',
|
||||
labelSingular: 'Partner Content',
|
||||
labelPlural: 'Partner Content',
|
||||
description: 'Marketing content involving a partner: quotes, case studies, logos',
|
||||
icon: 'IconQuote',
|
||||
isSearchable: true,
|
||||
labelIdentifierFieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
|
||||
fields: [
|
||||
@@ -21,6 +21,20 @@ export default defineObject({
|
||||
icon: 'IconTag',
|
||||
defaultValue: "''",
|
||||
},
|
||||
{
|
||||
universalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692',
|
||||
type: FieldType.MULTI_SELECT,
|
||||
name: 'contentType',
|
||||
label: 'Content Type',
|
||||
icon: 'IconCategory',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: '07f85e5b-0d70-416b-9884-256e469ed532', value: 'CUSTOMER_QUOTE', label: 'Customer quote', position: 0, color: 'blue' },
|
||||
{ id: '108b2358-d04d-4fdc-83df-a5978d39f66f', value: 'CASE_STUDY', label: 'Case study', position: 1, color: 'green' },
|
||||
{ id: 'eb45f371-f93c-4c45-9c8a-f29e1a58b7e4', value: 'PARTNER_QUOTE', label: 'Partner quote', position: 2, color: 'orange' },
|
||||
{ id: '3356c8a0-41cd-47c3-a293-5862138abc1a', value: 'LOGO', label: 'Logo', position: 3, color: 'purple' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a',
|
||||
type: FieldType.SELECT,
|
||||
@@ -45,21 +59,21 @@ export default defineObject({
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391',
|
||||
type: FieldType.CURRENCY,
|
||||
name: 'amount',
|
||||
label: 'Amount',
|
||||
icon: 'IconCoin',
|
||||
universalIdentifier: 'da7e9094-e2c3-47d3-924f-a1d4d3c717ed',
|
||||
type: FieldType.LINKS,
|
||||
name: 'interview',
|
||||
label: 'Interview',
|
||||
icon: 'IconMicrophone',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159',
|
||||
type: FieldType.FILES,
|
||||
name: 'quoteFile',
|
||||
label: 'Quote File',
|
||||
name: 'documents',
|
||||
label: 'Documents',
|
||||
icon: 'IconPaperclip',
|
||||
isNullable: true,
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
universalSettings: { maxNumberOfValues: 10 },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -23,7 +23,7 @@
|
||||
// tsx src/scripts/import-from-tft.ts
|
||||
|
||||
import { config } from 'dotenv';
|
||||
config({ path: '.env.local' });
|
||||
config({ path: process.env.ENV_FILE ?? '.env.local' });
|
||||
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
@@ -76,6 +76,17 @@ const PARTNER_STAGE_TO_VALIDATION: Record<string, string> = {
|
||||
REJECTED: 'REJECTED',
|
||||
};
|
||||
|
||||
// TFT person.partnerTimezone -> our Partner.region (MULTI_SELECT). TFT's value is a
|
||||
// coarse timezone band, not a geography, so each maps to every region it plausibly
|
||||
// covers. OTHER carries no signal -> no region. Region stays empty if unmapped.
|
||||
const TIMEZONE_TO_REGION: Record<string, string[]> = {
|
||||
AMERICAS: ['US', 'LATAM'],
|
||||
EMEA: ['EUROPE', 'MENA', 'AFRICA'],
|
||||
WEST_ASIA: ['MENA', 'APAC'],
|
||||
EAST_ASIA_OCEANIA: ['APAC'],
|
||||
OTHER: [],
|
||||
};
|
||||
|
||||
// Local SELECT option sets, for preflight coverage checks (a TFT value not in
|
||||
// these would fail a real write). Keep in sync with src/objects + src/fields.
|
||||
const LOCAL_OPTIONS: Record<string, Set<string>> = {
|
||||
@@ -101,6 +112,30 @@ const edges = (result: any, key: string): any[] =>
|
||||
const uniq = (values: (string | undefined | null)[]): string[] =>
|
||||
[...new Set(values.filter((v): v is string => !!v))];
|
||||
|
||||
// Normalize a domain for dedup. Twenty's company domain is a unique key but is
|
||||
// stored with an https:// prefix, while TFT values vary, so compare on a canonical
|
||||
// form (no protocol, no www, no trailing slash, lowercased).
|
||||
const normDomain = (d?: string | null): string | undefined =>
|
||||
d
|
||||
? d.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '') || undefined
|
||||
: undefined;
|
||||
|
||||
// Distinct non-empty values across rows, for the preflight report. Flattens array
|
||||
// fields (e.g. partnerScope, typeCustom) and stringifies, so scalar and
|
||||
// multi-select fields share one path. Derived from the already-fetched source
|
||||
// rows — no per-loop bookkeeping needed.
|
||||
const distinct = <TRow>(rows: TRow[], pick: (row: TRow) => unknown): string[] =>
|
||||
[
|
||||
...new Set(
|
||||
rows.flatMap((row) => {
|
||||
const value = pick(row);
|
||||
return Array.isArray(value) ? value : value != null ? [value] : [];
|
||||
}),
|
||||
),
|
||||
]
|
||||
.map(String)
|
||||
.sort();
|
||||
|
||||
async function main() {
|
||||
console.log(`[import] mode: ${APPLY ? 'APPLY (writing to local)' : 'DRY-RUN (no writes)'}`);
|
||||
const local = new CoreApiClient({
|
||||
@@ -114,26 +149,10 @@ async function main() {
|
||||
if (APPLY && writes++ > 0) await new Promise((r) => setTimeout(r, 750));
|
||||
};
|
||||
|
||||
// Distinct TFT values seen, for the preflight coverage report.
|
||||
const seen: Record<string, Set<string>> = {
|
||||
partnerStage: new Set(),
|
||||
partnerTier: new Set(),
|
||||
partnerScope: new Set(),
|
||||
typeOfTeam: new Set(),
|
||||
oppStage: new Set(),
|
||||
hostingType: new Set(),
|
||||
subscriptionType: new Set(),
|
||||
subscriptionFrequency: new Set(),
|
||||
quoteStatus: new Set(),
|
||||
typeCustom: new Set(),
|
||||
};
|
||||
const note = (bucket: string, value?: string | null) => {
|
||||
if (value) seen[bucket].add(value);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. Read all three TFT datasets (raw fetch; not rate-limited by local).
|
||||
// ---------------------------------------------------------------------
|
||||
console.log('[import] fetching TFT people...');
|
||||
const tftPeople = edges(
|
||||
await tftQuery(`query {
|
||||
people(first: 500, filter: { partnerStage: { in: ["APPLICATION","POTENTIAL_PARTNER","PARTNER","FORMER_PARTNER","REJECTED"] } }) {
|
||||
@@ -143,7 +162,7 @@ async function main() {
|
||||
emails { primaryEmail }
|
||||
city jobTitle
|
||||
linkedinLink { primaryLinkUrl }
|
||||
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerIsAvailable partnerSkills
|
||||
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerTimezone partnerIsAvailable partnerSkills
|
||||
partnerBudgetMinimum { amountMicros currencyCode }
|
||||
partnerBudgetAverage { amountMicros currencyCode }
|
||||
company { id name domainName { primaryLinkUrl } }
|
||||
@@ -152,7 +171,8 @@ async function main() {
|
||||
}`),
|
||||
'people',
|
||||
);
|
||||
const tftOpps = edges(
|
||||
console.log('[import] fetching TFT opportunities...');
|
||||
const tftOppsAll = edges(
|
||||
await tftQuery(`query {
|
||||
opportunities(first: 500) {
|
||||
edges { node {
|
||||
@@ -166,16 +186,32 @@ async function main() {
|
||||
}`),
|
||||
'opportunities',
|
||||
);
|
||||
// Only import opportunities linked to a partner. The rest is TFT's general sales
|
||||
// pipeline (mostly LOST/IDENTIFIED) — noise for a partners app. Every partner
|
||||
// stage (INTRODUCED/WORKING) only ever appears on partner-linked opps anyway.
|
||||
const tftOpps = tftOppsAll.filter((o: any) => o.partner?.id);
|
||||
console.log(`[import] opportunities: ${tftOpps.length} partner-linked of ${tftOppsAll.length} total (skipping ${tftOppsAll.length - tftOpps.length} unlinked)`);
|
||||
console.log('[import] fetching TFT partner content...');
|
||||
const tftContent = edges(
|
||||
await tftQuery(`query {
|
||||
customerContents(first: 500) {
|
||||
edges { node { id name status approvalDate typeCustom partnerPerson { id } } }
|
||||
edges { node {
|
||||
id name status approvalDate typeCustom
|
||||
interview { primaryLinkUrl }
|
||||
partnerPerson { id }
|
||||
customerCompany { id name domainName { primaryLinkUrl } }
|
||||
customerPerson { id }
|
||||
} }
|
||||
}
|
||||
}`),
|
||||
'customerContents',
|
||||
);
|
||||
const quotes = tftContent.filter((c: any) => Array.isArray(c.typeCustom) && c.typeCustom.includes('PARTNER_QUOTE'));
|
||||
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${quotes.length} partner quotes`);
|
||||
// Import all content TYPES (quotes, case studies, logos) but only records that
|
||||
// involve a partner. Customer-only content (no partnerPerson) is noise for the
|
||||
// partners app; a partner-linked case study/quote should show on the partner.
|
||||
const contentRecords = tftContent.filter((c: any) => c.partnerPerson?.id);
|
||||
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${tftContent.length} content records`);
|
||||
console.log(`[import] partner content: ${contentRecords.length} partner-linked of ${tftContent.length} total (skipping ${tftContent.length - contentRecords.length} customer-only)`);
|
||||
|
||||
const personSlug = (p: any): string =>
|
||||
slugify([p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner') || p.id;
|
||||
@@ -183,6 +219,7 @@ async function main() {
|
||||
// ---------------------------------------------------------------------
|
||||
// 2. Batched existence lookups against local (one `in` query per object).
|
||||
// ---------------------------------------------------------------------
|
||||
console.log('[import] checking existing records in target workspace...');
|
||||
const partnerSlugs = uniq(tftPeople.map(personSlug));
|
||||
const partnerIdBySlug = new Map<string, string>(
|
||||
partnerSlugs.length
|
||||
@@ -195,17 +232,33 @@ async function main() {
|
||||
: [],
|
||||
);
|
||||
|
||||
const companyNames = uniq([...tftPeople.map((p: any) => p.company?.name), ...tftOpps.map((o: any) => o.company?.name)]);
|
||||
const companyIdByName = new Map<string, string>(
|
||||
companyNames.length
|
||||
? edges(
|
||||
await local.query({
|
||||
companies: { __args: { filter: { name: { in: companyNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
|
||||
} as any),
|
||||
'companies',
|
||||
).map((n: any) => [n.name, n.id])
|
||||
: [],
|
||||
);
|
||||
// Fetch existing companies and index by BOTH name and normalized domain. Twenty
|
||||
// enforces uniqueness on domain, so dedup must be domain-aware: the same company
|
||||
// can arrive under different names (e.g. "Acme" vs "acme.com") across TFT people,
|
||||
// opps and content, and creating a second one collides on the domain constraint.
|
||||
// Page through ALL companies (not just the first 500): these dedupe maps must
|
||||
// be complete, or upsertCompany would create domain-colliding duplicates for
|
||||
// companies that live beyond the first page in a larger workspace.
|
||||
const existingCompanies: any[] = [];
|
||||
let companiesCursor: string | undefined;
|
||||
for (;;) {
|
||||
const page: any = await local.query({
|
||||
companies: {
|
||||
__args: { filter: {}, first: 200, ...(companiesCursor ? { after: companiesCursor } : {}) },
|
||||
edges: { node: { id: true, name: true, domainName: { primaryLinkUrl: true } } },
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
},
|
||||
} as any);
|
||||
existingCompanies.push(...edges(page, 'companies'));
|
||||
if (!page?.companies?.pageInfo?.hasNextPage) break;
|
||||
companiesCursor = page.companies.pageInfo.endCursor;
|
||||
}
|
||||
const companyIdByName = new Map<string, string>(existingCompanies.map((n: any) => [n.name, n.id]));
|
||||
const companyIdByDomain = new Map<string, string>();
|
||||
for (const c of existingCompanies) {
|
||||
const nd = normDomain(c.domainName?.primaryLinkUrl);
|
||||
if (nd && !companyIdByDomain.has(nd)) companyIdByDomain.set(nd, c.id);
|
||||
}
|
||||
|
||||
const oppTftIds = uniq(tftOpps.filter((o: any) => o.name).map((o: any) => o.id));
|
||||
const oppIdByTftId = new Map<string, string>(
|
||||
@@ -219,14 +272,18 @@ async function main() {
|
||||
: [],
|
||||
);
|
||||
|
||||
const quoteNames = uniq(quotes.map((q: any) => q.name || 'Partner quote'));
|
||||
const quoteIdByName = new Map<string, string>(
|
||||
quoteNames.length
|
||||
// Unnamed content upserts by name, so a constant fallback would make every
|
||||
// unnamed record collide on one key (collapsing them on re-run). Key the
|
||||
// fallback on the TFT id so each unnamed record stays distinct.
|
||||
const contentName = (c: any): string => c.name || `Partner content ${c.id}`;
|
||||
const contentNames = uniq(contentRecords.map(contentName));
|
||||
const contentIdByName = new Map<string, string>(
|
||||
contentNames.length
|
||||
? edges(
|
||||
await local.query({
|
||||
partnerQuotes: { __args: { filter: { name: { in: quoteNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
|
||||
partnerContents: { __args: { filter: { name: { in: contentNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
|
||||
} as any),
|
||||
'partnerQuotes',
|
||||
'partnerContents',
|
||||
).map((n: any) => [n.name, n.id])
|
||||
: [],
|
||||
);
|
||||
@@ -238,43 +295,102 @@ async function main() {
|
||||
const upsertCompany = async (name?: string, domain?: string): Promise<string | undefined> => {
|
||||
if (!name) return undefined;
|
||||
if (companyIdByName.has(name)) return companyIdByName.get(name);
|
||||
const nd = normDomain(domain);
|
||||
// Same company under a different name but same domain — reuse it.
|
||||
if (nd && companyIdByDomain.has(nd)) {
|
||||
const existingId = companyIdByDomain.get(nd) as string;
|
||||
companyIdByName.set(name, existingId);
|
||||
return existingId;
|
||||
}
|
||||
let id = `dry:company:${name}`;
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
const created: any = await local.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
|
||||
id: true,
|
||||
},
|
||||
} as any);
|
||||
id = created.createCompany.id;
|
||||
try {
|
||||
const created: any = await local.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
|
||||
id: true,
|
||||
},
|
||||
} as any);
|
||||
id = created.createCompany.id;
|
||||
} catch (err) {
|
||||
// Fallback: createCompany failed, almost certainly because the domain
|
||||
// already exists (Twenty enforces a unique domain) on a company we
|
||||
// didn't index. Re-find it and reuse instead of failing the import.
|
||||
// `ilike` is a substring match — and the stored value carries an
|
||||
// https:// prefix so we can't `eq` the normalized form — so it can
|
||||
// return the wrong company ("acme.com" also matches "notacme.com" or
|
||||
// "acme.com.br"). Confirm an exact normalized-domain match before reuse.
|
||||
if (!nd) throw err;
|
||||
await pace();
|
||||
const candidates = edges(
|
||||
await local.query({
|
||||
companies: { __args: { filter: { domainName: { primaryLinkUrl: { ilike: `%${nd}%` } } }, first: 20 }, edges: { node: { id: true, domainName: { primaryLinkUrl: true } } } },
|
||||
} as any),
|
||||
'companies',
|
||||
);
|
||||
const match = candidates.find((c: any) => normDomain(c.domainName?.primaryLinkUrl) === nd);
|
||||
if (!match?.id) throw err;
|
||||
id = match.id;
|
||||
console.log(`[import] company "${name}" reused existing by domain ${nd}`);
|
||||
}
|
||||
}
|
||||
companyIdByName.set(name, id);
|
||||
if (nd) companyIdByDomain.set(nd, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const budgetCurrency = (amount: any) =>
|
||||
amount?.amountMicros != null ? { amountMicros: amount.amountMicros, currencyCode: amount.currencyCode ?? 'USD' } : undefined;
|
||||
|
||||
// Generic create/update dispatch shared by partners, opportunities and content.
|
||||
// Owns pacing + APPLY-gating + the create-vs-update branch, so each loop below
|
||||
// only builds its `data`. genql keys a mutation by the object name, so the
|
||||
// create<Object>/update<Object> names are derived from one argument. Companies
|
||||
// keep their own upsert (above) because of the domain-collision fallback.
|
||||
// Returns the row id: the real id on APPLY, a synthetic dry id otherwise so the
|
||||
// relation mapping in dry-run still resolves to a stable placeholder.
|
||||
const upsert = async (
|
||||
object: 'Partner' | 'Opportunity' | 'PartnerContent',
|
||||
existingId: string | undefined,
|
||||
data: Record<string, unknown>,
|
||||
dryKey: string,
|
||||
): Promise<string> => {
|
||||
if (!APPLY) return existingId ?? `dry:${object}:${dryKey}`;
|
||||
await pace();
|
||||
if (existingId) {
|
||||
await local.mutation({ [`update${object}`]: { __args: { id: existingId, data }, id: true } } as any);
|
||||
return existingId;
|
||||
}
|
||||
const created: any = await local.mutation({ [`create${object}`]: { __args: { data }, id: true } } as any);
|
||||
return created[`create${object}`].id;
|
||||
};
|
||||
|
||||
// -- Partners (upsert by slug) --
|
||||
console.log(`[import] upserting ${tftPeople.length} partners...`);
|
||||
const localPartnerIdByTftPersonId = new Map<string, string>();
|
||||
let partnersCreated = 0;
|
||||
let partnersUpdated = 0;
|
||||
let partnersDone = 0;
|
||||
for (const p of tftPeople) {
|
||||
note('partnerStage', p.partnerStage);
|
||||
note('partnerTier', p.partnerTier);
|
||||
(Array.isArray(p.partnerScope) ? p.partnerScope : []).forEach((s: string) => note('partnerScope', s));
|
||||
note('typeOfTeam', p.partnerTypeOfTeam);
|
||||
|
||||
const slug = personSlug(p);
|
||||
const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl);
|
||||
// Timezone band -> geographic region(s). Unmapped/OTHER -> no region.
|
||||
const region = TIMEZONE_TO_REGION[p.partnerTimezone] ?? [];
|
||||
// A partner scoped for hosting is, by definition, a self-host expert.
|
||||
const scope = Array.isArray(p.partnerScope) ? p.partnerScope : [];
|
||||
const deploymentExpertise = scope.includes('HOSTING_ENVIRONMENT') ? ['SELF_HOST'] : [];
|
||||
const data: Record<string, unknown> = {
|
||||
name: [p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner',
|
||||
slug,
|
||||
validationStage: PARTNER_STAGE_TO_VALIDATION[p.partnerStage] ?? 'APPLICATION',
|
||||
availability: p.partnerIsAvailable ? 'AVAILABLE' : 'UNAVAILABLE',
|
||||
// TFT has no language data; default everyone to English.
|
||||
languagesSpoken: ['ENGLISH'],
|
||||
...(p.partnerTier ? { partnerTier: p.partnerTier } : {}),
|
||||
...(Array.isArray(p.partnerScope) && p.partnerScope.length ? { partnerScope: p.partnerScope } : {}),
|
||||
...(scope.length ? { partnerScope: scope } : {}),
|
||||
...(region.length ? { region } : {}),
|
||||
...(deploymentExpertise.length ? { deploymentExpertise } : {}),
|
||||
...(p.partnerTypeOfTeam ? { typeOfTeam: p.partnerTypeOfTeam } : {}),
|
||||
...(Array.isArray(p.partnerSkills) && p.partnerSkills.length ? { skills: p.partnerSkills } : {}),
|
||||
...(p.city ? { city: p.city } : {}),
|
||||
@@ -284,39 +400,29 @@ async function main() {
|
||||
...(companyId && APPLY ? { companyId } : {}),
|
||||
};
|
||||
|
||||
let partnerId = partnerIdBySlug.get(slug);
|
||||
if (partnerId) {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ updatePartner: { __args: { id: partnerId, data }, id: true } } as any);
|
||||
}
|
||||
const existingId = partnerIdBySlug.get(slug);
|
||||
const partnerId = await upsert('Partner', existingId, data, slug);
|
||||
if (existingId) {
|
||||
partnersUpdated++;
|
||||
} else {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
const created: any = await local.mutation({ createPartner: { __args: { data }, id: true } } as any);
|
||||
partnerId = created.createPartner.id;
|
||||
} else {
|
||||
partnerId = `dry:partner:${slug}`;
|
||||
}
|
||||
partnerIdBySlug.set(slug, partnerId as string);
|
||||
partnerIdBySlug.set(slug, partnerId);
|
||||
partnersCreated++;
|
||||
}
|
||||
localPartnerIdByTftPersonId.set(p.id, partnerId as string);
|
||||
localPartnerIdByTftPersonId.set(p.id, partnerId);
|
||||
partnersDone++;
|
||||
if (partnersDone % 10 === 0 || partnersDone === tftPeople.length)
|
||||
console.log(`[import] partners ${partnersDone}/${tftPeople.length} (created=${partnersCreated} updated=${partnersUpdated})`);
|
||||
}
|
||||
console.log(`[import] partners created=${partnersCreated} updated=${partnersUpdated}`);
|
||||
console.log(`[import] partners done: created=${partnersCreated} updated=${partnersUpdated}`);
|
||||
|
||||
// -- Opportunities (upsert by tftOpportunityId) --
|
||||
console.log(`[import] upserting ${tftOpps.length} opportunities...`);
|
||||
let oppsCreated = 0;
|
||||
let oppsUpdated = 0;
|
||||
let oppsPartnerLinked = 0;
|
||||
let oppsDone = 0;
|
||||
for (const o of tftOpps) {
|
||||
if (!o.name) continue;
|
||||
note('oppStage', o.stage);
|
||||
note('hostingType', o.hostingType);
|
||||
note('subscriptionType', o.subscriptionType);
|
||||
note('subscriptionFrequency', o.subscriptionFrequence);
|
||||
|
||||
const companyId = await upsertCompany(o.company?.name, o.company?.domainName?.primaryLinkUrl);
|
||||
const partnerId = o.partner?.id ? localPartnerIdByTftPersonId.get(o.partner.id) : undefined;
|
||||
if (partnerId) oppsPartnerLinked++;
|
||||
@@ -338,58 +444,44 @@ async function main() {
|
||||
};
|
||||
|
||||
const existingId = oppIdByTftId.get(o.id);
|
||||
if (existingId) {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ updateOpportunity: { __args: { id: existingId, data }, id: true } } as any);
|
||||
}
|
||||
oppsUpdated++;
|
||||
} else {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ createOpportunity: { __args: { data }, id: true } } as any);
|
||||
}
|
||||
oppsCreated++;
|
||||
}
|
||||
await upsert('Opportunity', existingId, data, o.id);
|
||||
if (existingId) oppsUpdated++;
|
||||
else oppsCreated++;
|
||||
oppsDone++;
|
||||
if (oppsDone % 20 === 0 || oppsDone === tftOpps.length)
|
||||
console.log(`[import] opportunities ${oppsDone}/${tftOpps.length} (created=${oppsCreated} updated=${oppsUpdated})`);
|
||||
}
|
||||
console.log(`[import] opportunities created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
|
||||
console.log(`[import] opportunities done: created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
|
||||
|
||||
// -- Partner quotes (upsert by name) --
|
||||
let quotesCreated = 0;
|
||||
let quotesUpdated = 0;
|
||||
tftContent.forEach((c: any) => (Array.isArray(c.typeCustom) ? c.typeCustom : []).forEach((t: string) => note('typeCustom', t)));
|
||||
for (const q of quotes) {
|
||||
note('quoteStatus', q.status);
|
||||
const name = q.name || 'Partner quote';
|
||||
const partnerId = q.partnerPerson?.id ? localPartnerIdByTftPersonId.get(q.partnerPerson.id) : undefined;
|
||||
// -- Partner content (upsert by name) --
|
||||
console.log(`[import] upserting ${contentRecords.length} content records...`);
|
||||
let contentCreated = 0;
|
||||
let contentUpdated = 0;
|
||||
for (const c of contentRecords) {
|
||||
const name = contentName(c);
|
||||
const partnerId = c.partnerPerson?.id ? localPartnerIdByTftPersonId.get(c.partnerPerson.id) : undefined;
|
||||
const customerCompanyId = await upsertCompany(c.customerCompany?.name, c.customerCompany?.domainName?.primaryLinkUrl);
|
||||
const data: Record<string, unknown> = {
|
||||
name,
|
||||
...(q.status ? { status: q.status } : {}),
|
||||
...(q.approvalDate ? { approvalDate: q.approvalDate } : {}),
|
||||
...(Array.isArray(c.typeCustom) && c.typeCustom.length ? { contentType: c.typeCustom } : {}),
|
||||
...(c.status ? { status: c.status } : {}),
|
||||
...(c.approvalDate ? { approvalDate: c.approvalDate } : {}),
|
||||
...(c.interview?.primaryLinkUrl ? { interview: { primaryLinkUrl: c.interview.primaryLinkUrl } } : {}),
|
||||
...(partnerId && APPLY ? { partnerId } : {}),
|
||||
...(customerCompanyId && APPLY ? { customerCompanyId } : {}),
|
||||
};
|
||||
const existingId = quoteIdByName.get(name);
|
||||
if (existingId) {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ updatePartnerQuote: { __args: { id: existingId, data }, id: true } } as any);
|
||||
}
|
||||
quotesUpdated++;
|
||||
} else {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
|
||||
}
|
||||
quotesCreated++;
|
||||
}
|
||||
const existingId = contentIdByName.get(name);
|
||||
await upsert('PartnerContent', existingId, data, name);
|
||||
if (existingId) contentUpdated++;
|
||||
else contentCreated++;
|
||||
}
|
||||
console.log(`[import] partner quotes created=${quotesCreated} updated=${quotesUpdated}`);
|
||||
console.log(`[import] partner content created=${contentCreated} updated=${contentUpdated}`);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. Preflight: distinct TFT values vs local option coverage.
|
||||
// 4. Preflight: distinct TFT values vs local option coverage. Derived
|
||||
// directly from the fetched source rows (no per-loop bookkeeping).
|
||||
// ---------------------------------------------------------------------
|
||||
const report = (label: string, bucket: string, optionKey?: string) => {
|
||||
const values = [...seen[bucket]].sort();
|
||||
const report = (label: string, values: string[], optionKey?: string) => {
|
||||
const allowed = optionKey ? LOCAL_OPTIONS[optionKey] : undefined;
|
||||
const uncovered = allowed ? values.filter((v) => !allowed.has(v)) : [];
|
||||
console.log(
|
||||
@@ -397,21 +489,27 @@ async function main() {
|
||||
(uncovered.length ? ` ⚠️ NOT IN LOCAL OPTIONS: ${uncovered.join(', ')}` : ''),
|
||||
);
|
||||
};
|
||||
const partnerStages = distinct(tftPeople, (p: any) => p.partnerStage);
|
||||
const oppStages = distinct(tftOpps, (o: any) => o.stage);
|
||||
const timezones = distinct(tftPeople, (p: any) => p.partnerTimezone);
|
||||
console.log('--- preflight: distinct TFT values seen ---');
|
||||
report('partnerStage (-> validationStage map)', 'partnerStage');
|
||||
report('partnerTier', 'partnerTier', 'partnerTier');
|
||||
report('partnerScope', 'partnerScope', 'partnerScope');
|
||||
report('typeOfTeam', 'typeOfTeam', 'typeOfTeam');
|
||||
report('opp stage (-> matchStatus map)', 'oppStage');
|
||||
report('hostingType', 'hostingType', 'hostingType');
|
||||
report('subscriptionType', 'subscriptionType', 'subscriptionType');
|
||||
report('subscriptionFrequency', 'subscriptionFrequency', 'subscriptionFrequency');
|
||||
report('quote status', 'quoteStatus', 'quoteStatus');
|
||||
report('customerContent typeCustom', 'typeCustom');
|
||||
const unmappedStages = [...seen.partnerStage].filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
|
||||
const unmappedOpps = [...seen.oppStage].filter((s) => !(s in STAGE_TO_MATCH_STATUS));
|
||||
report('partnerStage (-> validationStage map)', partnerStages);
|
||||
report('partnerTier', distinct(tftPeople, (p: any) => p.partnerTier), 'partnerTier');
|
||||
report('partnerScope', distinct(tftPeople, (p: any) => p.partnerScope), 'partnerScope');
|
||||
report('typeOfTeam', distinct(tftPeople, (p: any) => p.partnerTypeOfTeam), 'typeOfTeam');
|
||||
report('partnerTimezone (-> region map)', timezones);
|
||||
report('opp stage (-> matchStatus map)', oppStages);
|
||||
report('hostingType', distinct(tftOpps, (o: any) => o.hostingType), 'hostingType');
|
||||
report('subscriptionType', distinct(tftOpps, (o: any) => o.subscriptionType), 'subscriptionType');
|
||||
report('subscriptionFrequency', distinct(tftOpps, (o: any) => o.subscriptionFrequence), 'subscriptionFrequency');
|
||||
report('quote status', distinct(contentRecords, (c: any) => c.status), 'quoteStatus');
|
||||
report('customerContent typeCustom', distinct(contentRecords, (c: any) => c.typeCustom));
|
||||
const unmappedStages = partnerStages.filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
|
||||
const unmappedOpps = oppStages.filter((s) => !(s in STAGE_TO_MATCH_STATUS));
|
||||
const unmappedTz = timezones.filter((t) => !(t in TIMEZONE_TO_REGION));
|
||||
if (unmappedStages.length) console.log(`[preflight] ⚠️ partnerStage not mapped: ${unmappedStages.join(', ')}`);
|
||||
if (unmappedOpps.length) console.log(`[preflight] ⚠️ opp stage not mapped: ${unmappedOpps.join(', ')}`);
|
||||
if (unmappedTz.length) console.log(`[preflight] ⚠️ partnerTimezone not mapped: ${unmappedTz.join(', ')}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Hard-destroy soft-deleted records that block re-imports.
|
||||
//
|
||||
// Twenty SOFT-deletes (sets deletedAt); the row stays in the DB and keeps holding
|
||||
// unique constraints (e.g. company domain, partner slug). But normal queries —
|
||||
// including the import's existence checks — exclude soft-deleted rows. So after a
|
||||
// UI "delete" or a partial import that got rolled back, re-running the import hits
|
||||
// "A duplicate entry was detected" on records it cannot see. This purges those
|
||||
// ghosts permanently so idempotent upserts work again.
|
||||
//
|
||||
// Only touches soft-deleted rows (deletedAt IS NOT NULL); active/default data is
|
||||
// left untouched. One bulk destroy per object, so it is not rate-limited.
|
||||
//
|
||||
// yarn purge # against .env.local
|
||||
// yarn purge:prod # against .env.prod
|
||||
//
|
||||
import { config } from 'dotenv';
|
||||
config({ path: process.env.ENV_FILE ?? '.env.local' });
|
||||
|
||||
const requireEnv = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing ${name} env var`);
|
||||
return value;
|
||||
};
|
||||
|
||||
// Objects the import writes to. partners + partnerContents are app custom objects;
|
||||
// companies + opportunities are standard but populated by the import.
|
||||
const OBJECTS = ['companies', 'partners', 'opportunities', 'partnerContents'] as const;
|
||||
|
||||
const gql = async (url: string, key: string, query: string): Promise<any> => {
|
||||
const response = await fetch(`${url.replace(/\/$/, '')}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
const json: any = await response.json();
|
||||
if (json.errors?.length) throw new Error(JSON.stringify(json.errors));
|
||||
return json.data;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const url = requireEnv('TWENTY_PARTNERS_API_URL');
|
||||
const key = requireEnv('TWENTY_PARTNERS_API_KEY');
|
||||
console.log(`[purge] target: ${url} — destroying soft-deleted rows only`);
|
||||
|
||||
for (const obj of OBJECTS) {
|
||||
// destroy<Object>s is the bulk variant; capitalise + already-plural names.
|
||||
const mutationName = `destroy${obj.charAt(0).toUpperCase()}${obj.slice(1)}`;
|
||||
const data = await gql(url, key, `mutation { ${mutationName}(filter: { deletedAt: { is: NOT_NULL } }) { id } }`);
|
||||
const destroyed = data[mutationName]?.length ?? 0;
|
||||
console.log(`[purge] ${obj}: destroyed ${destroyed} soft-deleted`);
|
||||
}
|
||||
|
||||
console.log('[purge] done');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
// tsx src/scripts/seed.ts
|
||||
|
||||
import { config } from 'dotenv';
|
||||
config({ path: '.env.local' });
|
||||
config({ path: process.env.ENV_FILE ?? '.env.local' });
|
||||
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
@@ -89,13 +89,13 @@ const OPPORTUNITIES: Opp[] = [
|
||||
{ name: 'Sunrise — vendor onboarding', companyName: 'Sunrise Logistics', matchStatus: 'LOST', numberOfSeats: 10, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'MONTHLY' },
|
||||
];
|
||||
|
||||
type Quote = { name: string; status: string; partnerSlug: string; oppName: string };
|
||||
type Quote = { name: string; status: string; partnerSlug: string; contentType: string[] };
|
||||
const QUOTES: Quote[] = [
|
||||
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — APAC fleet CRM' },
|
||||
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', oppName: 'Helix Bio — clinical trials CRM' },
|
||||
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', oppName: 'Acme RE — CRM rollout' },
|
||||
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — LATAM expansion' },
|
||||
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', oppName: 'Helix Bio — self-host evaluation' },
|
||||
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', contentType: ['CASE_STUDY'] },
|
||||
];
|
||||
|
||||
const nodes = (r: any, key: string): any[] => (r?.[key]?.edges ?? []).map((e: any) => e.node);
|
||||
@@ -177,12 +177,12 @@ async function main() {
|
||||
// -- Partner quotes (upsert by name) --
|
||||
let quoteCount = 0;
|
||||
for (const q of QUOTES) {
|
||||
const data = { name: q.name, status: q.status, partnerId: partnerIdBySlug.get(q.partnerSlug), opportunityId: oppIdByName.get(q.oppName) };
|
||||
const existing = nodes(await client.query({ partnerQuotes: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerQuotes');
|
||||
const data = { name: q.name, status: q.status, contentType: q.contentType, partnerId: partnerIdBySlug.get(q.partnerSlug) };
|
||||
const existing = nodes(await client.query({ partnerContents: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerContents');
|
||||
if (existing[0]?.id) {
|
||||
await client.mutation({ updatePartnerQuote: { __args: { id: existing[0].id, data }, id: true } } as any);
|
||||
await client.mutation({ updatePartnerContent: { __args: { id: existing[0].id, data }, id: true } } as any);
|
||||
} else {
|
||||
await client.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
|
||||
await client.mutation({ createPartnerContent: { __args: { data }, id: true } } as any);
|
||||
}
|
||||
quoteCount++;
|
||||
}
|
||||
|
||||
+9
-9
@@ -1,21 +1,21 @@
|
||||
import { ViewType, defineView } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Index view for partner quotes.
|
||||
// Index view for partner content.
|
||||
export default defineView({
|
||||
universalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Partner quotes',
|
||||
icon: 'IconFileDollar',
|
||||
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
universalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Partner content',
|
||||
icon: 'IconQuote',
|
||||
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: ViewType.TABLE,
|
||||
fields: [
|
||||
{ universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df', fieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', position: 0, isVisible: true },
|
||||
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 1, isVisible: true },
|
||||
{ universalIdentifier: '62bb1536-539c-4fb0-95bf-440c5c5da89f', fieldMetadataUniversalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391', position: 2, isVisible: true },
|
||||
{ universalIdentifier: 'a9bf3eaa-ec27-4a0a-8df2-e18c8f4239a7', fieldMetadataUniversalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692', position: 1, isVisible: true },
|
||||
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 2, isVisible: true },
|
||||
{ universalIdentifier: 'fbd1f953-1dd2-4d0f-a239-148a0688fbff', fieldMetadataUniversalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005', position: 3, isVisible: true },
|
||||
],
|
||||
});
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Field types with similar names can use entirely different operands — `SELECT` and `MULTI_SELECT` being a common case.
|
||||
|
||||
+5
-4
@@ -148,10 +148,11 @@ const buildFieldMetadataItemFromMarketplaceField = (
|
||||
field: ObjectFieldManifest,
|
||||
): FieldMetadataItem => {
|
||||
const now = new Date().toISOString();
|
||||
const universalIdentifier = field.universalIdentifier ?? uuidv4();
|
||||
|
||||
return {
|
||||
id: field.universalIdentifier ?? uuidv4(),
|
||||
universalIdentifier: field.universalIdentifier ?? uuidv4(),
|
||||
id: universalIdentifier,
|
||||
universalIdentifier,
|
||||
name: field.name,
|
||||
label: field.label,
|
||||
type: (field.type as FieldMetadataType) ?? FieldMetadataType.TEXT,
|
||||
@@ -172,7 +173,7 @@ const buildFieldMetadataItemFromMarketplaceField = (
|
||||
};
|
||||
};
|
||||
|
||||
const buildobjectMetadataItemsFromMarketplaceApp = (
|
||||
const buildObjectMetadataItemsFromMarketplaceApp = (
|
||||
defaultRole: RoleManifest,
|
||||
objectUniversalIdToIdMap: Record<string, string>,
|
||||
marketplaceAppObjects: ObjectManifest[],
|
||||
@@ -292,7 +293,7 @@ const MarketplaceRoleEffect = ({
|
||||
fieldUniversalIdToIdMap,
|
||||
),
|
||||
objectMetadataItemsFromMarketplaceApp:
|
||||
buildobjectMetadataItemsFromMarketplaceApp(
|
||||
buildObjectMetadataItemsFromMarketplaceApp(
|
||||
defaultRole,
|
||||
objectUniversalIdToIdMap,
|
||||
marketplaceAppObjects,
|
||||
|
||||
+7
@@ -1,10 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { DropChannelStandardObjectsCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798000050000-drop-channel-standard-objects.command';
|
||||
import { BackfillRelationJoinColumnIndexesCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100000000-backfill-relation-join-column-indexes.command';
|
||||
import { GateDefaultCommandMenuItemsByPermissionFlagCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100010000-gate-default-command-menu-items-by-permission-flag.command';
|
||||
import { RestoreChannelAssociationScalarFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100020000-restore-channel-association-scalar-field-metadata.command';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
|
||||
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
|
||||
@@ -12,8 +16,10 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
@Module({
|
||||
imports: [
|
||||
ApplicationModule,
|
||||
TypeOrmModule.forFeature([FieldMetadataEntity]),
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceIteratorModule,
|
||||
WorkspaceMetadataVersionModule,
|
||||
WorkspaceMigrationModule,
|
||||
WorkspaceSchemaManagerModule,
|
||||
],
|
||||
@@ -21,6 +27,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
DropChannelStandardObjectsCommand,
|
||||
BackfillRelationJoinColumnIndexesCommand,
|
||||
GateDefaultCommandMenuItemsByPermissionFlagCommand,
|
||||
RestoreChannelAssociationScalarFieldMetadataCommand,
|
||||
],
|
||||
})
|
||||
export class V2_8_UpgradeVersionCommandModule {}
|
||||
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
import { Command } from 'nest-commander';
|
||||
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
|
||||
import { getMetadataSerializedRelationNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-serialized-relation-names.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { getWorkspaceSchemaContextForMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/get-workspace-schema-context-for-migration.util';
|
||||
|
||||
// 2-8 dropped calendarChannel/messageChannel/messageFolder standard objects, which
|
||||
// cascade-deleted the relation field metadata that owned the join columns on the
|
||||
// surviving association objects. The physical columns + their data survived, but the
|
||||
// replacement standard scalar field metadata was never created for existing workspaces,
|
||||
// so TwentyORM builds the association entity without the join field and every calendar /
|
||||
// message import crashes with `Property "<channel>Id" was not found`.
|
||||
// This re-registers the scalar field metadata over the existing physical columns.
|
||||
type ScalarFieldToRestore = {
|
||||
objectUniversalIdentifier: string;
|
||||
fieldUniversalIdentifier: string;
|
||||
fieldName: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
isNullable: boolean;
|
||||
};
|
||||
|
||||
const SCALAR_FIELDS_TO_RESTORE: ScalarFieldToRestore[] = [
|
||||
{
|
||||
objectUniversalIdentifier: '20202020-491b-4aaa-9825-afd1bae6ae00',
|
||||
fieldUniversalIdentifier: '20202020-93ee-4da4-8d58-0282c4a9cb7d',
|
||||
fieldName: 'calendarChannelId',
|
||||
label: 'Channel ID',
|
||||
description: 'Channel ID',
|
||||
icon: 'IconCalendar',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: '20202020-ad1e-4127-bccb-d83ae04d2ccb',
|
||||
fieldUniversalIdentifier: '20202020-b658-408f-bd46-3bd2d15d7e52',
|
||||
fieldName: 'messageChannelId',
|
||||
label: 'Message Channel Id',
|
||||
description: 'Message Channel Id',
|
||||
icon: 'IconHash',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: '20202020-a1b0-40b0-8ab0-5b6c7d8e9f0a',
|
||||
fieldUniversalIdentifier: 'b3369d31-3856-4a7a-b007-ee353918127c',
|
||||
fieldName: 'messageFolderId',
|
||||
label: 'Message Folder',
|
||||
description: 'Message Folder',
|
||||
icon: 'IconFolder',
|
||||
isNullable: false,
|
||||
},
|
||||
];
|
||||
|
||||
@RegisteredWorkspaceCommand('2.8.0', 1798100020000)
|
||||
@Command({
|
||||
name: 'upgrade:2-8:restore-channel-association-scalar-field-metadata',
|
||||
description:
|
||||
'Re-register the calendarChannelId/messageChannelId/messageFolderId scalar field metadata on the surviving association objects, over their already-existing physical columns, for workspaces where 2-8 drop-channel-standard-objects cascade-removed them',
|
||||
})
|
||||
export class RestoreChannelAssociationScalarFieldMetadataCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
|
||||
@InjectRepository(FieldMetadataEntity)
|
||||
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
dataSource,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
if (!isDefined(dataSource)) {
|
||||
this.logger.log(`No data source for workspace ${workspaceId}, skipping`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatObjectMetadataMaps',
|
||||
'flatFieldMetadataMaps',
|
||||
]);
|
||||
|
||||
// Only fields whose object still exists and whose scalar metadata is absent.
|
||||
// For already-healthy workspaces this is empty, so no schema query runs below.
|
||||
const candidates = SCALAR_FIELDS_TO_RESTORE.flatMap((fieldToRestore) => {
|
||||
const flatObjectMetadata =
|
||||
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
universalIdentifier: fieldToRestore.objectUniversalIdentifier,
|
||||
});
|
||||
|
||||
if (!isDefined(flatObjectMetadata)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingFlatFieldMetadata =
|
||||
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
universalIdentifier: fieldToRestore.fieldUniversalIdentifier,
|
||||
});
|
||||
|
||||
if (isDefined(existingFlatFieldMetadata)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { schemaName, tableName } = getWorkspaceSchemaContextForMigration({
|
||||
workspaceId,
|
||||
objectMetadata: flatObjectMetadata,
|
||||
});
|
||||
|
||||
return [{ fieldToRestore, flatObjectMetadata, schemaName, tableName }];
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
this.logger.log(
|
||||
`No channel association scalar field metadata to restore for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const existingColumnKeys = await this.getExistingColumnKeys({
|
||||
dataSource,
|
||||
tableColumnPairs: candidates.map(({ tableName, fieldToRestore }) => ({
|
||||
tableName,
|
||||
columnName: fieldToRestore.fieldName,
|
||||
})),
|
||||
schemaName: candidates[0].schemaName,
|
||||
});
|
||||
|
||||
const fieldMetadataRowsToInsert: QueryDeepPartialEntity<FieldMetadataEntity>[] =
|
||||
[];
|
||||
|
||||
for (const {
|
||||
fieldToRestore,
|
||||
flatObjectMetadata,
|
||||
tableName,
|
||||
} of candidates) {
|
||||
if (!existingColumnKeys.has(`${tableName}.${fieldToRestore.fieldName}`)) {
|
||||
this.logger.warn(
|
||||
`Physical column ${tableName}.${fieldToRestore.fieldName} missing for workspace ${workspaceId} - skipping metadata-only restore (needs schema repair)`,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
fieldMetadataRowsToInsert.push({
|
||||
id: uuidv4(),
|
||||
universalIdentifier: fieldToRestore.fieldUniversalIdentifier,
|
||||
objectMetadataId: flatObjectMetadata.id,
|
||||
workspaceId,
|
||||
type: FieldMetadataType.UUID,
|
||||
name: fieldToRestore.fieldName,
|
||||
label: fieldToRestore.label,
|
||||
description: fieldToRestore.description,
|
||||
icon: fieldToRestore.icon,
|
||||
isCustom: false,
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
isNullable: fieldToRestore.isNullable,
|
||||
isUIReadOnly: true,
|
||||
isLabelSyncedWithName: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (fieldMetadataRowsToInsert.length === 0) {
|
||||
this.logger.log(
|
||||
`No channel association scalar field metadata to restore for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Restoring ${fieldMetadataRowsToInsert.length} scalar field metadata row(s) for workspace ${workspaceId}: ${fieldMetadataRowsToInsert.map((row) => row.name).join(', ')}`,
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { twentyStandardFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const fieldMetadataRowsWithApplication = fieldMetadataRowsToInsert.map(
|
||||
(row) => ({
|
||||
...row,
|
||||
applicationId: twentyStandardFlatApplication.id,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.fieldMetadataRepository.insert(fieldMetadataRowsWithApplication);
|
||||
|
||||
// Derive the affected cache keys from the fieldMetadata metadata name using
|
||||
// the same utility set the migration runner uses, so the flush scope stays
|
||||
// in sync with the canonical dependency graph automatically.
|
||||
const fieldMetadataRelatedNames = [
|
||||
'fieldMetadata',
|
||||
...getMetadataRelatedMetadataNames('fieldMetadata'),
|
||||
...getMetadataSerializedRelationNames('fieldMetadata'),
|
||||
] as const;
|
||||
const cacheKeysToFlush = [
|
||||
...new Set(fieldMetadataRelatedNames.map(getMetadataFlatEntityMapsKey)),
|
||||
];
|
||||
|
||||
await this.workspaceCacheService.flush(workspaceId, [
|
||||
...cacheKeysToFlush,
|
||||
'ORMEntityMetadatas',
|
||||
'graphQLResolverNameMap',
|
||||
]);
|
||||
|
||||
await this.workspaceMetadataVersionService.incrementMetadataVersion(
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Restored ${fieldMetadataRowsWithApplication.length} scalar field metadata row(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Single information_schema lookup for all candidate columns in the workspace
|
||||
// schema. Returns a set of `tableName.columnName` keys that physically exist.
|
||||
private async getExistingColumnKeys({
|
||||
dataSource,
|
||||
schemaName,
|
||||
tableColumnPairs,
|
||||
}: {
|
||||
dataSource: NonNullable<RunOnWorkspaceArgs['dataSource']>;
|
||||
schemaName: string;
|
||||
tableColumnPairs: { tableName: string; columnName: string }[];
|
||||
}): Promise<Set<string>> {
|
||||
const tableNames = [
|
||||
...new Set(tableColumnPairs.map(({ tableName }) => tableName)),
|
||||
];
|
||||
const columnNames = [
|
||||
...new Set(tableColumnPairs.map(({ columnName }) => columnName)),
|
||||
];
|
||||
|
||||
// The workspace data source blocks raw query(); use a query runner like sibling
|
||||
// upgrade commands do for direct schema access.
|
||||
const queryRunner = dataSource.createQueryRunner();
|
||||
|
||||
try {
|
||||
await queryRunner.connect();
|
||||
|
||||
const rows: { table_name: string; column_name: string }[] =
|
||||
await queryRunner.query(
|
||||
`SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1
|
||||
AND table_name = ANY($2)
|
||||
AND column_name = ANY($3)`,
|
||||
[schemaName, tableNames, columnNames],
|
||||
);
|
||||
|
||||
return new Set(rows.map((row) => `${row.table_name}.${row.column_name}`));
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -694,10 +694,10 @@ msgstr "Toegewysde"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Geassosieerde Gebruikers-ID"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Ten minste een verhouding is vereis"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr ""
|
||||
msgid "Associated User Id"
|
||||
msgstr "معرف المستخدم المرتبط"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "مطلوب على الأقل علاقة واحدة"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Assignat"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Id d'usuari associat"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Cal almenys una relació"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Přiřazený"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Přidružené uživatelské ID"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Je vyžadován alespoň jeden vztah"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Opgaveansvarlig"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Tilknyttet Bruger-ID"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Der kræves mindst én relation"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Zuständiger"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Zugehörige Benutzer-ID"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Mindestens eine Beziehung ist erforderlich"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr ""
|
||||
msgid "Associated User Id"
|
||||
msgstr "Συσχετισμένο Αναγνωριστικό Χρήστη"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Τουλάχιστον μία σχέση είναι απαραίτητη"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -689,10 +689,10 @@ msgstr "Assignee"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Associated User Id"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "At least one relation is require"
|
||||
msgid "At least one relation is required"
|
||||
msgstr "At least one relation is required"
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Asignado"
|
||||
msgid "Associated User Id"
|
||||
msgstr "ID de usuario asociado"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Se requiere al menos una relación"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Tehtävään määrätty"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Liitetyn käyttäjän tunnus"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Ainakin yksi suhde on pakollinen"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Attributaire"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Identifiant de l'utilisateur associé"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Au moins une relation est requise"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -694,10 +694,10 @@ msgstr "המוקצה"
|
||||
msgid "Associated User Id"
|
||||
msgstr "מזהה המשתמש המקושר"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "נדרש לפחות קשר אחד"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Felelős"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Kapcsolódó Felhasználó Azonosítója"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Legalább egy kapcsolat szükséges"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Assegnatario"
|
||||
msgid "Associated User Id"
|
||||
msgstr "ID utente associato"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "È richiesta almeno una relazione"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "担当者"
|
||||
msgid "Associated User Id"
|
||||
msgstr "関連ユーザーID"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "少なくとも1つの関連が必要です"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "담당자"
|
||||
msgid "Associated User Id"
|
||||
msgstr "연결된 사용자 ID"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "하나 이상의 관계가 필요합니다."
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Toegewezene"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Geassocieerde gebruiker-ID"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Er is minstens één relatie vereist"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Oppgavemottaker"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Tilknyttet Bruker-ID"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Minst ett forhold er nødvendig"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Przydzielony"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Identyfikator powiązanego użytkownika"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Wymagane jest przynajmniej jedno powiązanie"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -689,9 +689,9 @@ msgstr ""
|
||||
msgid "Associated User Id"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr ""
|
||||
msgid "Associated User Id"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Pelo menos uma relação é necessária"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Responsável"
|
||||
msgid "Associated User Id"
|
||||
msgstr "ID de Utilizador Associado"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "É necessário pelo menos uma relação"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Responsabil"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Id utilizator asociat"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Este necesar cel puțin o relație"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Исполнитель"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Идентификатор связанного пользователя"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Требуется как минимум одна связь"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Извршилац"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Идентификациони број повезаног корисника"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Потребно је барем једно повезивање"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr ""
|
||||
msgid "Associated User Id"
|
||||
msgstr "Associerat användar-ID"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Minst en relation krävs"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Atanan kişi"
|
||||
msgid "Associated User Id"
|
||||
msgstr "İlişkilendirilmiş Kullanıcı Id"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "En az bir ilişki gerekli"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr "Виконавець"
|
||||
msgid "Associated User Id"
|
||||
msgstr "Пов’язаний ідентифікатор користувача"
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Потрібно щонайменше одне відношення"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr ""
|
||||
msgid "Associated User Id"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "Cần ít nhất một quan hệ"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr ""
|
||||
msgid "Associated User Id"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "至少需要一个关系"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
@@ -694,10 +694,10 @@ msgstr ""
|
||||
msgid "Associated User Id"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: K9efby
|
||||
#. js-lingui-id: IlaQdj
|
||||
#: src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-relation-creation-payload.util.ts
|
||||
msgid "At least one relation is require"
|
||||
msgstr "至少需要一個關係"
|
||||
msgid "At least one relation is required"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: UY1vmE
|
||||
#: src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-flat-object-metadata.util.ts
|
||||
|
||||
+1
-1
@@ -930,7 +930,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
universalProperty: undefined,
|
||||
},
|
||||
pageLayoutTabId: {
|
||||
toCompare: true,
|
||||
toCompare: false,
|
||||
toStringify: false,
|
||||
universalProperty: 'pageLayoutTabUniversalIdentifier',
|
||||
isOverridable: true,
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
|
||||
"message": "Morph relation creation payloads are empty",
|
||||
"userFriendlyMessage": {
|
||||
"id": Any<String>,
|
||||
"message": "At least one relation is require",
|
||||
"message": "At least one relation is required",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ export const validateMorphRelationCreationPayload = async ({
|
||||
{
|
||||
code: FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
|
||||
message: 'Morph relation creation payloads are empty',
|
||||
userFriendlyMessage: msg`At least one relation is require`,
|
||||
userFriendlyMessage: msg`At least one relation is required`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
-10
@@ -46,16 +46,6 @@ export class WorkspaceFlatIndexMapCacheService extends WorkspaceCacheProvider<
|
||||
},
|
||||
withDeleted: true,
|
||||
relationLoadStrategy: 'join',
|
||||
select: {
|
||||
indexFieldMetadatas: {
|
||||
id: true,
|
||||
indexMetadataId: true,
|
||||
fieldMetadataId: true,
|
||||
order: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
relations: ['indexFieldMetadatas'],
|
||||
}),
|
||||
this.applicationRepository.find({
|
||||
|
||||
+6
-1
@@ -10,9 +10,10 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
|
||||
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
|
||||
import type { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Entity({ name: 'indexFieldMetadata', schema: 'core' })
|
||||
export class IndexFieldMetadataEntity implements Required<IndexFieldMetadataEntity> {
|
||||
@@ -60,6 +61,10 @@ export class IndexFieldMetadataEntity implements Required<IndexFieldMetadataEnti
|
||||
// Null for scalar/relation fields. Set to the composite sub-property name
|
||||
// (e.g. 'addressCity') when the index targets a single column of a
|
||||
// composite-type parent.
|
||||
@WasIntroducedInUpgrade({
|
||||
upgradeCommandName:
|
||||
'2.8.0_AddSubFieldNameToIndexFieldMetadataFastInstanceCommand_1798200000000',
|
||||
})
|
||||
@Column({ type: 'text', nullable: true })
|
||||
subFieldName: string | null;
|
||||
|
||||
|
||||
+12
-1
@@ -283,11 +283,22 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
|
||||
const hasPermissionFromSettingPermissions = isDefined(
|
||||
rolePermissionFlags.find(
|
||||
(rolePermissionFlag) =>
|
||||
rolePermissionFlag.permissionFlag.universalIdentifier ===
|
||||
this.getRolePermissionFlagUniversalIdentifier(rolePermissionFlag) ===
|
||||
permissionFlagUniversalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
return hasPermissionFromRole || hasPermissionFromSettingPermissions;
|
||||
}
|
||||
|
||||
private getRolePermissionFlagUniversalIdentifier(
|
||||
rolePermissionFlag: RolePermissionFlagEntity,
|
||||
): string {
|
||||
// The `permissionFlag` relation is stripped during upgrades until the 2.6.0
|
||||
// cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
|
||||
return (
|
||||
rolePermissionFlag.permissionFlag?.universalIdentifier ??
|
||||
SystemPermissionFlag[rolePermissionFlag.flag]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
|
||||
|
||||
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
|
||||
import { FlatPageLayoutWidgetTypeValidatorService } from 'src/engine/metadata-modules/flat-page-layout-widget/services/flat-page-layout-widget-type-validator.service';
|
||||
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
|
||||
import { type UniversalFlatPageLayoutTab } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-tab.type';
|
||||
import { FlatPageLayoutWidgetValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-page-layout-widget-validator.service';
|
||||
|
||||
const CANVAS_TAB_UI = '00000000-0000-0000-0000-000000000aa1';
|
||||
const GRID_TAB_UI = '00000000-0000-0000-0000-000000000aa2';
|
||||
const WIDGET_A_UI = '00000000-0000-0000-0000-000000000111';
|
||||
const WIDGET_B_UI = '00000000-0000-0000-0000-000000000222';
|
||||
|
||||
const CANVAS_CAP_ERROR = /CANVAS layout tab can only contain one widget/;
|
||||
|
||||
const tab = (
|
||||
layoutMode: PageLayoutTabLayoutMode,
|
||||
universalIdentifier: string,
|
||||
): UniversalFlatPageLayoutTab =>
|
||||
({
|
||||
universalIdentifier,
|
||||
layoutMode,
|
||||
}) as unknown as UniversalFlatPageLayoutTab;
|
||||
|
||||
const widget = (
|
||||
universalIdentifier: string,
|
||||
pageLayoutTabUniversalIdentifier: string,
|
||||
options?: {
|
||||
universalOverrides?: {
|
||||
pageLayoutTabUniversalIdentifier?: string;
|
||||
} | null;
|
||||
},
|
||||
): FlatPageLayoutWidget =>
|
||||
({
|
||||
universalIdentifier,
|
||||
pageLayoutTabUniversalIdentifier,
|
||||
universalOverrides: options?.universalOverrides ?? null,
|
||||
title: 'widget',
|
||||
type: 'FRONT_COMPONENT',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
|
||||
position: { layoutMode: PageLayoutTabLayoutMode.CANVAS },
|
||||
}) as unknown as FlatPageLayoutWidget;
|
||||
|
||||
const mapsFrom = (entities: { universalIdentifier: string }[]): any => {
|
||||
const maps = createEmptyFlatEntityMaps() as any;
|
||||
|
||||
for (const entity of entities) {
|
||||
maps.byUniversalIdentifier[entity.universalIdentifier] = entity;
|
||||
}
|
||||
|
||||
return maps;
|
||||
};
|
||||
|
||||
const buildCreateArgs = ({
|
||||
incoming,
|
||||
tabs,
|
||||
existingWidgets,
|
||||
}: {
|
||||
incoming: FlatPageLayoutWidget;
|
||||
tabs: UniversalFlatPageLayoutTab[];
|
||||
existingWidgets: FlatPageLayoutWidget[];
|
||||
}) =>
|
||||
({
|
||||
flatEntityToValidate: incoming,
|
||||
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
|
||||
flatPageLayoutTabMaps: mapsFrom(tabs),
|
||||
flatPageLayoutWidgetMaps: mapsFrom(existingWidgets),
|
||||
},
|
||||
additionalCacheDataMaps: { featureFlagsMap: {} },
|
||||
workspaceId: 'workspace-id',
|
||||
buildOptions: {} as never,
|
||||
}) as any;
|
||||
|
||||
const buildUpdateArgs = ({
|
||||
universalIdentifier,
|
||||
update,
|
||||
tabs,
|
||||
existingWidgets,
|
||||
}: {
|
||||
universalIdentifier: string;
|
||||
update: Record<string, unknown>;
|
||||
tabs: UniversalFlatPageLayoutTab[];
|
||||
existingWidgets: FlatPageLayoutWidget[];
|
||||
}) =>
|
||||
({
|
||||
universalIdentifier,
|
||||
flatEntityUpdate: update,
|
||||
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
|
||||
flatPageLayoutTabMaps: mapsFrom(tabs),
|
||||
flatPageLayoutWidgetMaps: mapsFrom(existingWidgets),
|
||||
},
|
||||
additionalCacheDataMaps: { featureFlagsMap: {} },
|
||||
workspaceId: 'workspace-id',
|
||||
buildOptions: {} as never,
|
||||
}) as any;
|
||||
|
||||
const errorsMatching = (errors: { message: string }[], matcher: RegExp) =>
|
||||
errors.filter((error) => matcher.test(error.message));
|
||||
|
||||
describe('FlatPageLayoutWidgetValidatorService — CANVAS widget cap', () => {
|
||||
let service: FlatPageLayoutWidgetValidatorService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
FlatPageLayoutWidgetValidatorService,
|
||||
{
|
||||
provide: FlatPageLayoutWidgetTypeValidatorService,
|
||||
useValue: {
|
||||
validateFlatPageLayoutWidgetTypeSpecificitiesForCreation: () => [],
|
||||
validateFlatPageLayoutWidgetTypeSpecificitiesForUpdate: () => [],
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(FlatPageLayoutWidgetValidatorService);
|
||||
});
|
||||
|
||||
it('rejects a second widget on a CANVAS tab', async () => {
|
||||
const canvasTab = tab(PageLayoutTabLayoutMode.CANVAS, CANVAS_TAB_UI);
|
||||
|
||||
const result = await service.validateFlatPageLayoutWidgetCreation(
|
||||
buildCreateArgs({
|
||||
incoming: widget(WIDGET_B_UI, CANVAS_TAB_UI),
|
||||
tabs: [canvasTab],
|
||||
existingWidgets: [widget(WIDGET_A_UI, CANVAS_TAB_UI)],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errorsMatching(result.errors, CANVAS_CAP_ERROR)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('counts widgets assigned to a CANVAS tab through overrides', async () => {
|
||||
const canvasTab = tab(PageLayoutTabLayoutMode.CANVAS, CANVAS_TAB_UI);
|
||||
const gridTab = tab(PageLayoutTabLayoutMode.GRID, GRID_TAB_UI);
|
||||
|
||||
const result = await service.validateFlatPageLayoutWidgetCreation(
|
||||
buildCreateArgs({
|
||||
incoming: widget(WIDGET_B_UI, CANVAS_TAB_UI),
|
||||
tabs: [canvasTab, gridTab],
|
||||
existingWidgets: [
|
||||
widget(WIDGET_A_UI, GRID_TAB_UI, {
|
||||
universalOverrides: {
|
||||
pageLayoutTabUniversalIdentifier: CANVAS_TAB_UI,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errorsMatching(result.errors, CANVAS_CAP_ERROR)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects moving a widget into an occupied CANVAS tab', async () => {
|
||||
const canvasTab = tab(PageLayoutTabLayoutMode.CANVAS, CANVAS_TAB_UI);
|
||||
const gridTab = tab(PageLayoutTabLayoutMode.GRID, GRID_TAB_UI);
|
||||
|
||||
const result = await service.validateFlatPageLayoutWidgetUpdate(
|
||||
buildUpdateArgs({
|
||||
universalIdentifier: WIDGET_B_UI,
|
||||
update: {
|
||||
universalOverrides: {
|
||||
pageLayoutTabUniversalIdentifier: CANVAS_TAB_UI,
|
||||
},
|
||||
},
|
||||
tabs: [canvasTab, gridTab],
|
||||
existingWidgets: [
|
||||
widget(WIDGET_A_UI, CANVAS_TAB_UI),
|
||||
widget(WIDGET_B_UI, GRID_TAB_UI),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errorsMatching(result.errors, CANVAS_CAP_ERROR)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not apply the cap to non-CANVAS tabs', async () => {
|
||||
const gridTab = tab(PageLayoutTabLayoutMode.GRID, GRID_TAB_UI);
|
||||
|
||||
const result = await service.validateFlatPageLayoutWidgetCreation(
|
||||
buildCreateArgs({
|
||||
incoming: widget(WIDGET_B_UI, GRID_TAB_UI),
|
||||
tabs: [gridTab],
|
||||
existingWidgets: [widget(WIDGET_A_UI, GRID_TAB_UI)],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errorsMatching(result.errors, CANVAS_CAP_ERROR)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
-81
@@ -13,12 +13,10 @@ import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules
|
||||
import { FlatPageLayoutWidgetTypeValidatorService } from 'src/engine/metadata-modules/flat-page-layout-widget/services/flat-page-layout-widget-type-validator.service';
|
||||
import { PageLayoutTabExceptionCode } from 'src/engine/metadata-modules/page-layout-tab/exceptions/page-layout-tab.exception';
|
||||
import { PageLayoutWidgetExceptionCode } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
|
||||
import { type MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type';
|
||||
import { validatePageLayoutWidgetGridPosition } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-page-layout-widget-grid-position.util';
|
||||
import { validatePageLayoutWidgetVerticalListPosition } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-page-layout-widget-vertical-list-position.util';
|
||||
import { validateWidgetGridPosition } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-widget-grid-position.util';
|
||||
import { type UniversalFlatPageLayoutTab } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-tab.type';
|
||||
import { type UniversalFlatPageLayoutWidget } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-widget.type';
|
||||
import {
|
||||
FailedFlatEntityValidation,
|
||||
FlatEntityValidationError,
|
||||
@@ -85,23 +83,6 @@ export class FlatPageLayoutWidgetValidatorService {
|
||||
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutTabMaps,
|
||||
});
|
||||
|
||||
const effectivePageLayoutTab = findFlatEntityByUniversalIdentifier({
|
||||
universalIdentifier: this.getEffectivePageLayoutTabUniversalIdentifier(
|
||||
updatedFlatPageLayoutWidget,
|
||||
),
|
||||
flatEntityMaps:
|
||||
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutTabMaps,
|
||||
});
|
||||
|
||||
const canvasTabErrors = this.validateCanvasTabWidgetCap({
|
||||
pageLayoutTab: effectivePageLayoutTab,
|
||||
flatPageLayoutWidgetMaps:
|
||||
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutWidgetMaps,
|
||||
excludeWidgetUniversalIdentifier: universalIdentifier,
|
||||
});
|
||||
|
||||
validationResult.errors.push(...canvasTabErrors);
|
||||
|
||||
const gridPositionErrors = this.validateGridPosition({
|
||||
gridPosition: updatedFlatPageLayoutWidget.gridPosition,
|
||||
widgetTitle: updatedFlatPageLayoutWidget.title,
|
||||
@@ -222,16 +203,6 @@ export class FlatPageLayoutWidgetValidatorService {
|
||||
});
|
||||
}
|
||||
|
||||
const canvasTabErrors = this.validateCanvasTabWidgetCap({
|
||||
pageLayoutTab: referencedPageLayoutTab,
|
||||
flatPageLayoutWidgetMaps:
|
||||
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutWidgetMaps,
|
||||
excludeWidgetUniversalIdentifier:
|
||||
flatPageLayoutWidgetToValidate.universalIdentifier,
|
||||
});
|
||||
|
||||
validationResult.errors.push(...canvasTabErrors);
|
||||
|
||||
const gridPositionErrors = this.validateGridPosition({
|
||||
gridPosition: flatPageLayoutWidgetToValidate.gridPosition,
|
||||
widgetTitle: flatPageLayoutWidgetToValidate.title,
|
||||
@@ -264,58 +235,6 @@ export class FlatPageLayoutWidgetValidatorService {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
private validateCanvasTabWidgetCap({
|
||||
pageLayoutTab,
|
||||
flatPageLayoutWidgetMaps,
|
||||
excludeWidgetUniversalIdentifier,
|
||||
}: {
|
||||
pageLayoutTab: UniversalFlatPageLayoutTab | undefined;
|
||||
flatPageLayoutWidgetMaps: MetadataUniversalFlatEntityMaps<'pageLayoutWidget'>;
|
||||
excludeWidgetUniversalIdentifier?: string;
|
||||
}): FlatEntityValidationError[] {
|
||||
if (
|
||||
!isDefined(pageLayoutTab) ||
|
||||
pageLayoutTab.layoutMode !== PageLayoutTabLayoutMode.CANVAS
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const existingWidgetsOnTab = Object.values(
|
||||
flatPageLayoutWidgetMaps.byUniversalIdentifier,
|
||||
).filter(
|
||||
(widget) =>
|
||||
isDefined(widget) &&
|
||||
!isDefined(widget.deletedAt) &&
|
||||
this.getEffectivePageLayoutTabUniversalIdentifier(widget) ===
|
||||
pageLayoutTab.universalIdentifier &&
|
||||
widget.universalIdentifier !== excludeWidgetUniversalIdentifier,
|
||||
);
|
||||
|
||||
if (existingWidgetsOnTab.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
code: PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
|
||||
message: t`A CANVAS layout tab can only contain one widget`,
|
||||
userFriendlyMessage: msg`A CANVAS layout tab can only contain one widget`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private getEffectivePageLayoutTabUniversalIdentifier(
|
||||
widget: Pick<
|
||||
UniversalFlatPageLayoutWidget,
|
||||
'pageLayoutTabUniversalIdentifier' | 'universalOverrides'
|
||||
>,
|
||||
): string {
|
||||
return (
|
||||
widget.universalOverrides?.pageLayoutTabUniversalIdentifier ??
|
||||
widget.pageLayoutTabUniversalIdentifier
|
||||
);
|
||||
}
|
||||
|
||||
private validateGridPosition({
|
||||
gridPosition,
|
||||
widgetTitle,
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`failing createOne FieldMetadataService morph relation fields v2 Morh relation collision tests suite it should fail to create a already existing morph relation with samefield 1`] = `
|
||||
exports[`failing createOne FieldMetadataService morph relation fields v2 Morph relation collision tests suite it should fail to create a already existing morph relation with same field 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "METADATA_VALIDATION_FAILED",
|
||||
@@ -91,7 +91,7 @@ exports[`failing createOne FieldMetadataService morph relation fields v2 Morh re
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`failing createOne FieldMetadataService morph relation fields v2 Morh relation collision tests suite it should fail to create a field with name than result in existing morph relation field join column 1`] = `
|
||||
exports[`failing createOne FieldMetadataService morph relation fields v2 Morph relation collision tests suite it should fail to create a field with name than result in existing morph relation field join column 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "METADATA_VALIDATION_FAILED",
|
||||
@@ -612,7 +612,7 @@ exports[`failing createOne FieldMetadataService morph relation fields v2 it shou
|
||||
{
|
||||
"code": "FIELD_METADATA_RELATION_MALFORMED",
|
||||
"message": "Morph relation creation payloads are empty",
|
||||
"userFriendlyMessage": "At least one relation is require",
|
||||
"userFriendlyMessage": "At least one relation is required",
|
||||
},
|
||||
],
|
||||
"flatEntityMinimalInformation": {
|
||||
@@ -628,7 +628,7 @@ exports[`failing createOne FieldMetadataService morph relation fields v2 it shou
|
||||
"fieldMetadata": 1,
|
||||
"totalErrors": 1,
|
||||
},
|
||||
"userFriendlyMessage": "At least one relation is require",
|
||||
"userFriendlyMessage": "At least one relation is required",
|
||||
},
|
||||
"message": "Multiple validation errors occurred while creating field",
|
||||
"name": "GraphQLError",
|
||||
|
||||
+2
-2
@@ -446,7 +446,7 @@ describe('failing createOne FieldMetadataService morph relation fields v2', () =
|
||||
},
|
||||
);
|
||||
|
||||
describe('Morh relation collision tests suite', () => {
|
||||
describe('Morph relation collision tests suite', () => {
|
||||
beforeAll(async () => {
|
||||
const morphRelationCreateFieldInput: Omit<
|
||||
CreateFieldInput,
|
||||
@@ -502,7 +502,7 @@ describe('failing createOne FieldMetadataService morph relation fields v2', () =
|
||||
expect(firstError.extensions.code).not.toBe('INTERNAL_SERVER_ERROR');
|
||||
});
|
||||
|
||||
it('it should fail to create a already existing morph relation with samefield', async () => {
|
||||
it('it should fail to create a already existing morph relation with same field', async () => {
|
||||
const morphRelationCreateFieldInput: Omit<
|
||||
CreateFieldInput,
|
||||
'workspaceId'
|
||||
|
||||
+1
-37
@@ -1,4 +1,4 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`Page layout widget creation should fail AGGREGATE_CHART widget configuration validation failures when AGGREGATE_CHART configuration has invalid UUID 1`] = `
|
||||
{
|
||||
@@ -44,42 +44,6 @@ exports[`Page layout widget creation should fail BAR_CHART widget configuration
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Page layout widget creation should fail CANVAS tab widget cap rejects a second widget on a CANVAS tab 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "METADATA_VALIDATION_FAILED",
|
||||
"errors": {
|
||||
"pageLayoutWidget": [
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "INVALID_PAGE_LAYOUT_WIDGET_DATA",
|
||||
"message": "A CANVAS layout tab can only contain one widget",
|
||||
"userFriendlyMessage": "A CANVAS layout tab can only contain one widget",
|
||||
},
|
||||
],
|
||||
"flatEntityMinimalInformation": {
|
||||
"pageLayoutTabUniversalIdentifier": Any<String>,
|
||||
"universalIdentifier": Any<String>,
|
||||
},
|
||||
"metadataName": "pageLayoutWidget",
|
||||
"status": "fail",
|
||||
"type": "create",
|
||||
},
|
||||
],
|
||||
},
|
||||
"message": "Validation failed for 1 pageLayoutWidget",
|
||||
"summary": {
|
||||
"pageLayoutWidget": 1,
|
||||
"totalErrors": 1,
|
||||
},
|
||||
"userFriendlyMessage": "A CANVAS layout tab can only contain one widget",
|
||||
},
|
||||
"message": "Multiple validation errors occurred while creating page layout widget",
|
||||
"name": "GraphQLError",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Page layout widget creation should fail Edge case configuration validation failures when configuration has missing configurationType 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
|
||||
-51
@@ -16,7 +16,6 @@ import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-l
|
||||
import { createOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/create-one-page-layout-widget.util';
|
||||
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
|
||||
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
|
||||
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
|
||||
|
||||
import { type CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/create-page-layout-widget.input';
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
@@ -258,56 +257,6 @@ describe('Page layout widget creation should fail', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CANVAS tab widget cap', () => {
|
||||
let canvasTabId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { data } = await createOnePageLayoutTab({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
title: 'CANVAS Tab For Cap Test',
|
||||
pageLayoutId: testPageLayoutId,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
},
|
||||
});
|
||||
|
||||
canvasTabId = data.createPageLayoutTab.id;
|
||||
|
||||
await createOnePageLayoutWidget({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
title: 'First widget on CANVAS tab',
|
||||
pageLayoutTabId: canvasTabId,
|
||||
type: WidgetType.IFRAME,
|
||||
configuration: TEST_IFRAME_CONFIG,
|
||||
gridPosition: DEFAULT_GRID_POSITION,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyOnePageLayoutTab({
|
||||
expectToFail: false,
|
||||
input: { id: canvasTabId },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a second widget on a CANVAS tab', async () => {
|
||||
const { errors } = await createOnePageLayoutWidget({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
title: 'Second widget should be rejected',
|
||||
pageLayoutTabId: canvasTabId,
|
||||
type: WidgetType.IFRAME,
|
||||
configuration: TEST_IFRAME_CONFIG,
|
||||
gridPosition: DEFAULT_GRID_POSITION,
|
||||
},
|
||||
});
|
||||
|
||||
expectOneNotInternalServerErrorSnapshot({ errors });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge case configuration validation failures', () => {
|
||||
it('when configuration is null', async () => {
|
||||
const { errors } = await createOnePageLayoutWidget({
|
||||
|
||||
+1
-173
@@ -11,11 +11,7 @@ import {
|
||||
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
|
||||
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
|
||||
import { updateOnePageLayoutWithTabsAndWidgets } from 'test/integration/metadata/suites/page-layout/utils/update-one-page-layout-with-tabs-and-widgets.util';
|
||||
import {
|
||||
AggregateOperations,
|
||||
FieldMetadataType,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-shared/types';
|
||||
import { AggregateOperations, FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
@@ -62,174 +58,6 @@ describe('Page layout with tabs update should fail', () => {
|
||||
expectOneNotInternalServerErrorSnapshot({ errors });
|
||||
});
|
||||
|
||||
it('when moving a widget into an occupied CANVAS tab', async () => {
|
||||
const { data: layoutData } = await createOnePageLayout({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
name: 'Move Into Occupied Canvas Layout',
|
||||
type: PageLayoutType.RECORD_PAGE,
|
||||
},
|
||||
});
|
||||
|
||||
const testPageLayoutId = layoutData.createPageLayout.id;
|
||||
const sourceTabId = v4();
|
||||
const canvasTabId = v4();
|
||||
const widgetToMoveId = v4();
|
||||
const canvasWidgetId = v4();
|
||||
|
||||
try {
|
||||
await updateOnePageLayoutWithTabsAndWidgets({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
id: testPageLayoutId,
|
||||
name: 'Move Into Occupied Canvas Layout',
|
||||
type: PageLayoutType.RECORD_PAGE,
|
||||
objectMetadataId: null,
|
||||
tabs: [
|
||||
{
|
||||
id: sourceTabId,
|
||||
title: 'Source Tab',
|
||||
position: 0,
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
widgets: [
|
||||
{
|
||||
id: widgetToMoveId,
|
||||
pageLayoutTabId: sourceTabId,
|
||||
title: 'Widget To Move',
|
||||
type: WidgetType.IFRAME,
|
||||
objectMetadataId: null,
|
||||
gridPosition: {
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 1,
|
||||
columnSpan: 1,
|
||||
},
|
||||
position: {
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
index: 0,
|
||||
},
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.IFRAME,
|
||||
url: 'https://example.com/source',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: canvasTabId,
|
||||
title: 'Canvas Tab',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
id: canvasWidgetId,
|
||||
pageLayoutTabId: canvasTabId,
|
||||
title: 'Canvas Widget',
|
||||
type: WidgetType.IFRAME,
|
||||
objectMetadataId: null,
|
||||
gridPosition: {
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 1,
|
||||
columnSpan: 1,
|
||||
},
|
||||
position: {
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
},
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.IFRAME,
|
||||
url: 'https://example.com/canvas',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { errors } = await updateOnePageLayoutWithTabsAndWidgets({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
id: testPageLayoutId,
|
||||
name: 'Move Into Occupied Canvas Layout',
|
||||
type: PageLayoutType.RECORD_PAGE,
|
||||
objectMetadataId: null,
|
||||
tabs: [
|
||||
{
|
||||
id: sourceTabId,
|
||||
title: 'Source Tab',
|
||||
position: 0,
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
widgets: [],
|
||||
},
|
||||
{
|
||||
id: canvasTabId,
|
||||
title: 'Canvas Tab',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
id: canvasWidgetId,
|
||||
pageLayoutTabId: canvasTabId,
|
||||
title: 'Canvas Widget',
|
||||
type: WidgetType.IFRAME,
|
||||
objectMetadataId: null,
|
||||
gridPosition: {
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 1,
|
||||
columnSpan: 1,
|
||||
},
|
||||
position: {
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
},
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.IFRAME,
|
||||
url: 'https://example.com/canvas',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: widgetToMoveId,
|
||||
pageLayoutTabId: canvasTabId,
|
||||
title: 'Widget To Move',
|
||||
type: WidgetType.IFRAME,
|
||||
objectMetadataId: null,
|
||||
gridPosition: {
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 1,
|
||||
columnSpan: 1,
|
||||
},
|
||||
position: {
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
},
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.IFRAME,
|
||||
url: 'https://example.com/source',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
const [firstError] = errors ?? [];
|
||||
|
||||
expect(firstError?.extensions.code).toBe('BAD_USER_INPUT');
|
||||
expect(firstError?.message).toContain(
|
||||
'A CANVAS layout tab can only contain one widget',
|
||||
);
|
||||
} finally {
|
||||
await destroyOnePageLayout({
|
||||
expectToFail: false,
|
||||
input: { id: testPageLayoutId },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('chart filter validation failures', () => {
|
||||
let testFieldMetadataIds: TestFieldMetadataIds;
|
||||
let testPageLayoutId: string | undefined;
|
||||
|
||||
+1
-110
@@ -8,10 +8,7 @@ import {
|
||||
type EachTestingContext,
|
||||
eachTestingContextFilter,
|
||||
} from 'twenty-shared/testing';
|
||||
import {
|
||||
AggregateOperations,
|
||||
PageLayoutTabLayoutMode,
|
||||
} from 'twenty-shared/types';
|
||||
import { AggregateOperations } from 'twenty-shared/types';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
@@ -232,110 +229,4 @@ describe('Page layout with tabs update should succeed', () => {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should move a widget to another tab when saving layout tabs and widgets', async () => {
|
||||
const widgetId = v4();
|
||||
|
||||
await updateOnePageLayoutWithTabsAndWidgets({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
id: testPageLayoutId,
|
||||
name: 'Layout Before Widget Move',
|
||||
type: PageLayoutType.RECORD_PAGE,
|
||||
objectMetadataId: null,
|
||||
tabs: [
|
||||
{
|
||||
id: testTabId1,
|
||||
title: 'Source Tab',
|
||||
position: 0,
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
widgets: [
|
||||
{
|
||||
id: widgetId,
|
||||
pageLayoutTabId: testTabId1,
|
||||
title: 'Iframe Widget',
|
||||
type: WidgetType.IFRAME,
|
||||
objectMetadataId: null,
|
||||
gridPosition: {
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 1,
|
||||
columnSpan: 1,
|
||||
},
|
||||
position: {
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
index: 0,
|
||||
},
|
||||
configuration: MOCK_IFRAME_CONFIGURATION,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: testTabId2,
|
||||
title: 'Destination Tab',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
widgets: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = await updateOnePageLayoutWithTabsAndWidgets({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
id: testPageLayoutId,
|
||||
name: 'Layout After Widget Move',
|
||||
type: PageLayoutType.RECORD_PAGE,
|
||||
objectMetadataId: null,
|
||||
tabs: [
|
||||
{
|
||||
id: testTabId1,
|
||||
title: 'Source Tab',
|
||||
position: 0,
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
widgets: [],
|
||||
},
|
||||
{
|
||||
id: testTabId2,
|
||||
title: 'Destination Tab',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
widgets: [
|
||||
{
|
||||
id: widgetId,
|
||||
pageLayoutTabId: testTabId2,
|
||||
title: 'Iframe Widget',
|
||||
type: WidgetType.IFRAME,
|
||||
objectMetadataId: null,
|
||||
gridPosition: {
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 1,
|
||||
columnSpan: 1,
|
||||
},
|
||||
position: {
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
index: 0,
|
||||
},
|
||||
configuration: MOCK_IFRAME_CONFIGURATION,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const updatedTabs = data.updatePageLayoutWithTabsAndWidgets.tabs ?? [];
|
||||
const sourceTab = updatedTabs.find((tab) => tab.id === testTabId1);
|
||||
const destinationTab = updatedTabs.find((tab) => tab.id === testTabId2);
|
||||
|
||||
expect(sourceTab?.widgets).toHaveLength(0);
|
||||
expect(destinationTab?.widgets).toHaveLength(1);
|
||||
expect(destinationTab?.widgets?.[0]).toMatchObject({
|
||||
id: widgetId,
|
||||
pageLayoutTabId: testTabId2,
|
||||
title: 'Iframe Widget',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"nx": "NX_DEFAULT_PROJECT=twenty-website node ../../node_modules/nx/bin/nx.js",
|
||||
"generate:releases": "node ./scripts/generate-release-notes-manifest.mjs",
|
||||
"dev": "npx next dev --port 3002",
|
||||
"build": "npx next build",
|
||||
"start": "npx next start --port 3002",
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
"projectType": "application",
|
||||
"tags": ["scope:website"],
|
||||
"targets": {
|
||||
"generate-release-manifest": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"{projectRoot}/src/content/releases/**/*",
|
||||
"{projectRoot}/public/images/releases/**/*",
|
||||
"{projectRoot}/scripts/generate-release-notes-manifest.mjs"
|
||||
],
|
||||
"outputs": ["{projectRoot}/src/lib/releases/generated-release-notes.ts"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "node scripts/generate-release-notes-manifest.mjs"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
@@ -14,11 +28,12 @@
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx next build"
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
"dependsOn": ["generate-release-manifest", "^build"]
|
||||
},
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": false,
|
||||
"dependsOn": ["generate-release-manifest"],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx next dev --port 3002"
|
||||
@@ -71,13 +86,24 @@
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"check-boundaries",
|
||||
"check-section-shape",
|
||||
"check-lottie-frames",
|
||||
"^build",
|
||||
"twenty-oxlint-rules:build"
|
||||
]
|
||||
],
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx run twenty-website:lint --configuration=fix' && false))"
|
||||
},
|
||||
"configurations": {
|
||||
"fix": {
|
||||
"command": "npx oxlint -c .oxlintrc.json . && npx oxfmt ."
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint:diff-with-main": {
|
||||
"dependsOn": [
|
||||
@@ -86,7 +112,9 @@
|
||||
"check-lottie-frames"
|
||||
]
|
||||
},
|
||||
"typecheck": {},
|
||||
"typecheck": {
|
||||
"dependsOn": ["generate-release-manifest"]
|
||||
},
|
||||
"test": {},
|
||||
"fmt": {
|
||||
"options": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user