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:
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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 },
|
||||
},
|
||||
});
|
||||
@ -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],
|
||||
|
||||
@ -155,5 +155,13 @@ export const mapFieldMetadataToGraphqlQuery = (
|
||||
additionalPhones
|
||||
}
|
||||
`;
|
||||
} else if (fieldType === FieldMetadataType.RICH_TEXT_V2) {
|
||||
return `
|
||||
${field.name}
|
||||
{
|
||||
blocknote
|
||||
markdown
|
||||
}
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user