Share RICH_TEXT_V2 field value override between REST and GraphQL APIs (#10912)
Fixes issue #10606. This PR makes `RICH_TEXT_V2` field behavior in REST API matche the current behavior in GraphQL API: Currently both `markdown` and `blocknote` fields must be included in the request, one of them can be `null`. The field with a `null` value will be filled by the converted value of the other field. In other words, this works: ``` curl http://localhost:3000/rest/notes \ --request POST \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzQxODA1MzQyLCJleHAiOjQ4OTU0MDUzNDEsImp0aSI6ImZlMzU0NTBkLTlhMDMtNGE2ZS04ODVjLTBlNTU3M2Y3YTE0NiJ9.6_g8cwoSE7ZCX1Zzsw44gZIyBdLKNsnDqMOmm1bKik0' \ --data '{ "position": 1, "title": "a", "bodyV2": { "markdown": "test4\n\ntest3\n\n# test1\n", "blocknote": null }, "createdBy": { "source": "EMAIL" } }' ``` And this does not work: ``` curl http://localhost:3000/rest/notes \ --request POST \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzQxODA1MzQyLCJleHAiOjQ4OTU0MDUzNDEsImp0aSI6ImZlMzU0NTBkLTlhMDMtNGE2ZS04ODVjLTBlNTU3M2Y3YTE0NiJ9.6_g8cwoSE7ZCX1Zzsw44gZIyBdLKNsnDqMOmm1bKik0' \ --data '{ "position": 1, "title": "", "body": "", "bodyV2": { "markdown": "test4\n\ntest3\n\n# test1\n" }, "createdBy": { "source": "EMAIL" } }' ``` --- It would be nice not to require the null value, maybe let's make that a separate PR?
This commit is contained in:
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { RecordInputTransformerService } from './services/record-input-transformer.service';
|
||||
|
||||
@Module({
|
||||
providers: [RecordInputTransformerService],
|
||||
exports: [RecordInputTransformerService],
|
||||
})
|
||||
export class RecordTransformerModule {}
|
||||
@ -0,0 +1,235 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ServerBlockNoteEditor } from '@blocknote/server-util';
|
||||
|
||||
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
||||
|
||||
import {
|
||||
RichTextV2Metadata,
|
||||
richTextV2ValueSchema,
|
||||
} from 'src/engine/metadata-modules/field-metadata/composite-types/rich-text-v2.composite-type';
|
||||
import { lowercaseDomain } from 'src/engine/api/graphql/workspace-query-runner/utils/query-runner-links.util';
|
||||
import { LinkMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
|
||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
||||
|
||||
@Injectable()
|
||||
export class RecordInputTransformerService {
|
||||
async process({
|
||||
recordInput,
|
||||
objectMetadataMapItem,
|
||||
}: {
|
||||
recordInput: Record<string, any>;
|
||||
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
|
||||
}): Promise<Record<string, any>> {
|
||||
if (!recordInput) {
|
||||
return recordInput;
|
||||
}
|
||||
|
||||
const fieldMetadataByFieldName = objectMetadataMapItem.fields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.name] = field;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, FieldMetadataInterface>,
|
||||
);
|
||||
|
||||
let transformedEntries = {};
|
||||
|
||||
for (const [key, value] of Object.entries(recordInput)) {
|
||||
const fieldMetadata = fieldMetadataByFieldName[key];
|
||||
|
||||
if (!fieldMetadata) {
|
||||
transformedEntries = { ...transformedEntries, [key]: value };
|
||||
continue;
|
||||
}
|
||||
|
||||
const transformedValue = this.parseSubFields(
|
||||
fieldMetadata.type,
|
||||
await this.transformFieldValue(
|
||||
fieldMetadata.type,
|
||||
this.stringifySubFields(fieldMetadata.type, value),
|
||||
),
|
||||
);
|
||||
|
||||
transformedEntries = { ...transformedEntries, [key]: transformedValue };
|
||||
}
|
||||
|
||||
return transformedEntries;
|
||||
}
|
||||
|
||||
async transformFieldValue(
|
||||
fieldType: FieldMetadataType,
|
||||
value: any,
|
||||
): Promise<any> {
|
||||
if (!isDefined(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
switch (fieldType) {
|
||||
case FieldMetadataType.UUID:
|
||||
return value || null;
|
||||
case FieldMetadataType.NUMBER:
|
||||
return value === null ? null : Number(value);
|
||||
case FieldMetadataType.RICH_TEXT:
|
||||
throw new Error(
|
||||
'Rich text is not supported, please use RICH_TEXT_V2 instead',
|
||||
);
|
||||
case FieldMetadataType.RICH_TEXT_V2:
|
||||
return this.transformRichTextV2Value(value);
|
||||
case FieldMetadataType.LINKS:
|
||||
return this.transformLinksValue(value);
|
||||
case FieldMetadataType.EMAILS:
|
||||
return this.transformEmailsValue(value);
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private async transformRichTextV2Value(
|
||||
richTextValue: any,
|
||||
): Promise<RichTextV2Metadata> {
|
||||
const parsedValue = richTextV2ValueSchema.parse(richTextValue);
|
||||
|
||||
const serverBlockNoteEditor = ServerBlockNoteEditor.create();
|
||||
|
||||
const convertedMarkdown = parsedValue.blocknote
|
||||
? await serverBlockNoteEditor.blocksToMarkdownLossy(
|
||||
JSON.parse(parsedValue.blocknote),
|
||||
)
|
||||
: null;
|
||||
|
||||
const convertedBlocknote = parsedValue.markdown
|
||||
? JSON.stringify(
|
||||
await serverBlockNoteEditor.tryParseMarkdownToBlocks(
|
||||
parsedValue.markdown,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
markdown: parsedValue.markdown || convertedMarkdown,
|
||||
blocknote: parsedValue.blocknote || convertedBlocknote,
|
||||
};
|
||||
}
|
||||
|
||||
private transformLinksValue(value: any): any {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const newPrimaryLinkUrl = lowercaseDomain(value?.primaryLinkUrl);
|
||||
|
||||
let secondaryLinks = value?.secondaryLinks;
|
||||
|
||||
if (secondaryLinks) {
|
||||
try {
|
||||
const secondaryLinksArray = JSON.parse(secondaryLinks);
|
||||
|
||||
secondaryLinks = JSON.stringify(
|
||||
secondaryLinksArray.map((link: LinkMetadata) => {
|
||||
return {
|
||||
...link,
|
||||
url: lowercaseDomain(link.url),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...value,
|
||||
primaryLinkUrl: newPrimaryLinkUrl,
|
||||
secondaryLinks,
|
||||
};
|
||||
}
|
||||
|
||||
private transformEmailsValue(value: any): any {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let additionalEmails = value?.additionalEmails;
|
||||
const primaryEmail = value?.primaryEmail
|
||||
? value.primaryEmail.toLowerCase()
|
||||
: '';
|
||||
|
||||
if (additionalEmails) {
|
||||
try {
|
||||
const emailArray = JSON.parse(additionalEmails) as string[];
|
||||
|
||||
additionalEmails = JSON.stringify(
|
||||
emailArray.map((email) => email.toLowerCase()),
|
||||
);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
primaryEmail,
|
||||
additionalEmails,
|
||||
};
|
||||
}
|
||||
|
||||
private stringifySubFields(fieldMetadataType: FieldMetadataType, value: any) {
|
||||
const compositeType = compositeTypeDefinitions.get(fieldMetadataType);
|
||||
|
||||
if (!compositeType) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.entries(value).reduce(
|
||||
(acc, [subFieldName, subFieldValue]) => {
|
||||
const subFieldType = compositeType.properties.find(
|
||||
(property) => property.name === subFieldName,
|
||||
)?.type;
|
||||
|
||||
if (subFieldType === FieldMetadataType.RAW_JSON) {
|
||||
return {
|
||||
...acc,
|
||||
[subFieldName]: subFieldValue
|
||||
? JSON.stringify(subFieldValue)
|
||||
: subFieldValue,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...acc, [subFieldName]: subFieldValue };
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
private parseSubFields(fieldMetadataType: FieldMetadataType, value: any) {
|
||||
const compositeType = compositeTypeDefinitions.get(fieldMetadataType);
|
||||
|
||||
if (!compositeType) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.entries(value).reduce(
|
||||
(acc, [subFieldName, subFieldValue]: [string, any]) => {
|
||||
const subFieldType = compositeType.properties.find(
|
||||
(property) => property.name === subFieldName,
|
||||
)?.type;
|
||||
|
||||
if (subFieldType === FieldMetadataType.RAW_JSON) {
|
||||
return {
|
||||
...acc,
|
||||
[subFieldName]: subFieldValue
|
||||
? JSON.parse(subFieldValue)
|
||||
: subFieldValue,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...acc, [subFieldName]: subFieldValue };
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user