RICH_TEXT_V2 backend (#9848)

- Add RICH_TEXT_V2 composite type to backend.
- Add `bodyV2` field to tasks and notes.
- Minimum required frontend changes to avoid errors when creating a note

[Testing
instructions](https://github.com/twentyhq/twenty/pull/9690#issuecomment-2602378218)

---------

Co-authored-by: ad-elias <elias@autodiligence.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
eliasylonen
2025-01-28 14:05:06 +01:00
committed by GitHub
parent 6f72f1af33
commit b63ae14318
34 changed files with 517 additions and 102 deletions

View File

@ -1,5 +1,7 @@
import { QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity';
import { TaskWorkspaceEntity } from 'src/modules/task/standard-objects/task.workspace-entity';
@ -11,20 +13,32 @@ type RichTextBody = RichTextBlock[];
export class ActivityQueryResultGetterHandler
implements QueryResultGetterHandlerInterface
{
constructor(private readonly fileService: FileService) {}
constructor(
private readonly fileService: FileService,
private readonly featureFlagService: FeatureFlagService,
) {}
async handle(
activity: TaskWorkspaceEntity | NoteWorkspaceEntity,
workspaceId: string,
): Promise<TaskWorkspaceEntity | NoteWorkspaceEntity> {
if (!activity.id || !activity.body) {
const isRichTextV2Enabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsRichTextV2Enabled,
workspaceId,
);
const blocknoteJson = isRichTextV2Enabled
? activity.bodyV2?.blocknote
: activity.body;
if (!activity.id || !blocknoteJson) {
return activity;
}
const body: RichTextBody = JSON.parse(activity.body);
const blocknote: RichTextBody = JSON.parse(blocknoteJson);
const bodyWithSignedPayload = await Promise.all(
body.map(async (block: RichTextBlock) => {
const blocknoteWithSignedPayload = await Promise.all(
blocknote.map(async (block: RichTextBlock) => {
if (block.type !== 'image' || !block.props.url) {
return block;
}
@ -49,9 +63,19 @@ export class ActivityQueryResultGetterHandler
}),
);
if (isRichTextV2Enabled) {
return {
...activity,
bodyV2: {
blocknote: JSON.stringify(blocknoteWithSignedPayload),
markdown: activity.bodyV2?.markdown ?? null,
},
};
}
return {
...activity,
body: JSON.stringify(bodyWithSignedPayload),
body: JSON.stringify(blocknoteWithSignedPayload),
};
}
}

View File

@ -16,6 +16,7 @@ import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/works
import { PersonQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler';
import { WorkspaceMemberQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler';
import { CompositeInputTypeDefinitionFactory } from 'src/engine/api/graphql/workspace-schema-builder/factories/composite-input-type-definition.factory';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util';
@ -31,7 +32,10 @@ export class QueryResultGettersFactory {
);
private handlers: Map<string, QueryResultGetterHandlerInterface>;
constructor(private readonly fileService: FileService) {
constructor(
private readonly fileService: FileService,
private readonly featureFlagService: FeatureFlagService,
) {
this.initializeHandlers();
}
@ -43,8 +47,20 @@ export class QueryResultGettersFactory {
'workspaceMember',
new WorkspaceMemberQueryResultGetterHandler(this.fileService),
],
['note', new ActivityQueryResultGetterHandler(this.fileService)],
['task', new ActivityQueryResultGetterHandler(this.fileService)],
[
'note',
new ActivityQueryResultGetterHandler(
this.fileService,
this.featureFlagService,
),
],
[
'task',
new ActivityQueryResultGetterHandler(
this.fileService,
this.featureFlagService,
),
],
]);
}

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { ServerBlockNoteEditor } from '@blocknote/server-util';
import { FieldMetadataType } from 'twenty-shared';
import {
@ -15,9 +16,15 @@ import {
FindOneResolverArgs,
ResolverArgs,
ResolverArgsType,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
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 { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { RecordPositionFactory } from './record-position.factory';
@ -77,6 +84,41 @@ export class QueryRunnerArgsFactory {
) ?? [],
),
} satisfies CreateManyResolverArgs;
case ResolverArgsType.UpdateOne:
return {
...args,
id: (args as UpdateOneResolverArgs).id,
data: await this.overrideDataByFieldMetadata(
(args as UpdateOneResolverArgs).data,
options,
fieldMetadataMapByNameByName,
{
argIndex: 0,
shouldBackfillPosition,
},
),
} satisfies UpdateOneResolverArgs;
case ResolverArgsType.UpdateMany:
return {
...args,
filter: await this.overrideFilterByFieldMetadata(
(args as UpdateManyResolverArgs).filter,
fieldMetadataMapByNameByName,
),
data: await Promise.all(
(args as UpdateManyResolverArgs).data?.map((arg, index) =>
this.overrideDataByFieldMetadata(
arg,
options,
fieldMetadataMapByNameByName,
{
argIndex: index,
shouldBackfillPosition,
},
),
) ?? [],
),
} satisfies UpdateManyResolverArgs;
case ResolverArgsType.FindOne:
return {
...args,
@ -130,47 +172,73 @@ export class QueryRunnerArgsFactory {
options: WorkspaceQueryRunnerOptions,
fieldMetadataMapByNameByName: Record<string, FieldMetadataInterface>,
argPositionBackfillInput: ArgPositionBackfillInput,
) {
): Promise<Partial<ObjectRecord>> {
if (!data) {
return;
return Promise.resolve({});
}
let isFieldPositionPresent = false;
const createArgPromiseByArgKey = Object.entries(data).map(
async ([key, value]) => {
const fieldMetadata = fieldMetadataMapByNameByName[key];
const createArgByArgKeyPromises: Promise<[string, any]>[] = Object.entries(
data,
).map(async ([key, value]): Promise<[string, any]> => {
const fieldMetadata = fieldMetadataMapByNameByName[key];
if (!fieldMetadata) {
return [key, await Promise.resolve(value)];
if (!fieldMetadata) {
return [key, value];
}
switch (fieldMetadata.type) {
case FieldMetadataType.POSITION: {
isFieldPositionPresent = true;
const newValue = await this.recordPositionFactory.create(
value,
{
isCustom: options.objectMetadataItemWithFieldMaps.isCustom,
nameSingular:
options.objectMetadataItemWithFieldMaps.nameSingular,
},
options.authContext.workspace.id,
argPositionBackfillInput.argIndex,
);
return [key, newValue];
}
case FieldMetadataType.NUMBER:
return [key, Number(value)] as const;
case FieldMetadataType.RICH_TEXT_V2: {
const richTextV2Value = richTextV2ValueSchema.parse(value);
switch (fieldMetadata.type) {
case FieldMetadataType.POSITION:
isFieldPositionPresent = true;
const serverBlockNoteEditor = ServerBlockNoteEditor.create();
return [
key,
await this.recordPositionFactory.create(
value,
{
isCustom: options.objectMetadataItemWithFieldMaps.isCustom,
nameSingular:
options.objectMetadataItemWithFieldMaps.nameSingular,
},
options.authContext.workspace.id,
argPositionBackfillInput.argIndex,
),
];
case FieldMetadataType.NUMBER:
return [key, Number(value)];
default:
return [key, await Promise.resolve(value)];
const convertedMarkdown = richTextV2Value.blocknote
? await serverBlockNoteEditor.blocksToMarkdownLossy(
JSON.parse(richTextV2Value.blocknote),
)
: null;
const convertedBlocknote = richTextV2Value.markdown
? JSON.stringify(
await serverBlockNoteEditor.tryParseMarkdownToBlocks(
richTextV2Value.markdown,
),
)
: null;
const valueInBothFormats: RichTextV2Metadata = {
markdown: richTextV2Value.markdown || convertedMarkdown,
blocknote: richTextV2Value.blocknote || convertedBlocknote,
};
return [key, valueInBothFormats];
}
},
);
default:
return [key, value];
}
});
const newArgEntries = await Promise.all(createArgPromiseByArgKey);
const newArgEntries = await Promise.all(createArgByArgKeyPromises);
if (
!isFieldPositionPresent &&

View File

@ -0,0 +1,16 @@
import { GraphQLInputObjectType, GraphQLString } from 'graphql';
const richTextV2LeafFilter = new GraphQLInputObjectType({
name: 'RichTextV2LeafFilter',
fields: {
ilike: { type: GraphQLString },
},
});
export const RichTextV2FilterType = new GraphQLInputObjectType({
name: 'RichTextV2Filter',
fields: {
blocknote: { type: richTextV2LeafFilter },
markdown: { type: richTextV2LeafFilter },
},
});

View File

@ -29,6 +29,7 @@ import {
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input';
import { IDFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/id-filter.input-type';
import { MultiSelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type';
import { RichTextV2FilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/rich-text.input-type';
import { SelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type';
import {
BigFloatScalarType,
@ -116,6 +117,7 @@ export class TypeMapperService {
[FieldMetadataType.POSITION, FloatFilterType],
[FieldMetadataType.RAW_JSON, RawJsonFilterType],
[FieldMetadataType.RICH_TEXT, StringFilterType],
[FieldMetadataType.RICH_TEXT_V2, RichTextV2FilterType],
[FieldMetadataType.ARRAY, ArrayFilterType],
[FieldMetadataType.MULTI_SELECT, MultiSelectFilterType],
[FieldMetadataType.SELECT, SelectFilterType],