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

@ -8,6 +8,7 @@
"@aws-sdk/credential-providers": "^3.363.0",
"@blocknote/mantine": "^0.22.0",
"@blocknote/react": "^0.22.0",
"@blocknote/server-util": "0.17.1",
"@codesandbox/sandpack-react": "^2.13.5",
"@dagrejs/dagre": "^1.1.2",
"@emotion/react": "^11.11.1",

View File

@ -541,6 +541,7 @@ export enum FieldMetadataType {
RAW_JSON = 'RAW_JSON',
RELATION = 'RELATION',
RICH_TEXT = 'RICH_TEXT',
RICH_TEXT_V2 = 'RICH_TEXT_V2',
SELECT = 'SELECT',
TEXT = 'TEXT',
TS_VECTOR = 'TS_VECTOR',

View File

@ -396,6 +396,7 @@ export enum FeatureFlagKey {
IsMicrosoftSyncEnabled = 'IsMicrosoftSyncEnabled',
IsNewRelationEnabled = 'IsNewRelationEnabled',
IsPostgreSQLIntegrationEnabled = 'IsPostgreSQLIntegrationEnabled',
IsRichTextV2Enabled = 'IsRichTextV2Enabled',
IsStripeIntegrationEnabled = 'IsStripeIntegrationEnabled',
IsUniqueIndexesEnabled = 'IsUniqueIndexesEnabled',
IsWorkflowEnabled = 'IsWorkflowEnabled'
@ -473,6 +474,7 @@ export enum FieldMetadataType {
RAW_JSON = 'RAW_JSON',
RELATION = 'RELATION',
RICH_TEXT = 'RICH_TEXT',
RICH_TEXT_V2 = 'RICH_TEXT_V2',
SELECT = 'SELECT',
TEXT = 'TEXT',
TS_VECTOR = 'TS_VECTOR',

View File

@ -203,6 +203,7 @@ export type FieldMetadata =
| FieldPhoneMetadata
| FieldRatingMetadata
| FieldRelationMetadata
| FieldRichTextMetadata
| FieldSelectMetadata
| FieldMultiSelectMetadata
| FieldTextMetadata

View File

@ -3,5 +3,5 @@ import { PickLiteral } from '~/types/PickLiteral';
export type SettingsExcludedFieldType = PickLiteral<
FieldType,
'POSITION' | 'TS_VECTOR'
'POSITION' | 'TS_VECTOR' | 'RICH_TEXT_V2'
>;

View File

@ -23,5 +23,6 @@ export const DEFAULT_ICONS_BY_FIELD_TYPE: Record<FieldMetadataType, string> = {
[FieldMetadataType.NUMERIC]: 'IconUsers',
[FieldMetadataType.POSITION]: 'IconUsers',
[FieldMetadataType.RICH_TEXT]: 'IconUsers',
[FieldMetadataType.RICH_TEXT_V2]: 'IconUsers',
[FieldMetadataType.TS_VECTOR]: 'IconUsers',
};

View File

@ -75,6 +75,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IsRichTextV2Enabled,
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IsNewRelationEnabled,
workspaceId: workspaceId,

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],

View File

@ -155,5 +155,13 @@ export const mapFieldMetadataToGraphqlQuery = (
additionalPhones
}
`;
} else if (fieldType === FieldMetadataType.RICH_TEXT_V2) {
return `
${field.name}
{
blocknote
markdown
}
`;
}
};

View File

@ -13,5 +13,6 @@ export enum FeatureFlagKey {
IsJsonFilterEnabled = 'IS_JSON_FILTER_ENABLED',
IsLocalizationEnabled = 'IS_LOCALIZATION_ENABLED',
IsBillingPlansEnabled = 'IS_BILLING_PLANS_ENABLED',
IsRichTextV2Enabled = 'IS_RICH_TEXT_V2_ENABLED',
IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',
}

View File

@ -266,6 +266,19 @@ const getSchemaComponentsProperties = ({
type: 'object',
};
break;
case FieldMetadataType.RICH_TEXT_V2:
itemProperty = {
type: 'object',
properties: {
blocknote: {
type: 'string',
},
markdown: {
type: 'string',
},
},
};
break;
default:
itemProperty = getFieldProperties(field.type);
break;

View File

@ -9,6 +9,7 @@ import { emailsCompositeType } from 'src/engine/metadata-modules/field-metadata/
import { fullNameCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
import { linksCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { phonesCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type';
import { richTextV2CompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/rich-text-v2.composite-type';
export const compositeTypeDefinitions = new Map<
FieldMetadataType,
@ -21,4 +22,5 @@ export const compositeTypeDefinitions = new Map<
[FieldMetadataType.ACTOR, actorCompositeType],
[FieldMetadataType.EMAILS, emailsCompositeType],
[FieldMetadataType.PHONES, phonesCompositeType],
[FieldMetadataType.RICH_TEXT_V2, richTextV2CompositeType],
]);

View File

@ -0,0 +1,29 @@
import { FieldMetadataType } from 'twenty-shared';
import { z } from 'zod';
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
export const richTextV2CompositeType: CompositeType = {
type: FieldMetadataType.RICH_TEXT_V2,
properties: [
{
name: 'blocknote',
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
},
{
name: 'markdown',
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
},
],
};
export const richTextV2ValueSchema = z.object({
blocknote: z.string().nullable(),
markdown: z.string().nullable(),
});
export type RichTextV2Metadata = z.infer<typeof richTextV2ValueSchema>;

View File

@ -35,11 +35,22 @@ export class FieldMetadataDefaultValueRawJson {
value: object | null;
}
export class FieldMetadataDefaultValueRichTextV2 {
@ValidateIf((object, value) => value !== null)
@IsQuotedString()
blocknote: string | null;
@ValidateIf((object, value) => value !== null)
@IsQuotedString()
markdown: string | null;
}
export class FieldMetadataDefaultValueRichText {
@ValidateIf((_object, value) => value !== null)
@IsString()
value: string | null;
}
export class FieldMetadataDefaultValueNumber {
@ValidateIf((object, value) => value !== null)
@IsNumber()

View File

@ -0,0 +1,23 @@
import { FieldMetadataType } from 'twenty-shared';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
describe('computeCompositeColumnName', () => {
it('should compute composite column name for rich text v2 field', () => {
const fieldMetadata = {
name: 'bodyV2',
type: FieldMetadataType.RICH_TEXT_V2,
};
const property = {
name: 'markdown',
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
};
expect(computeCompositeColumnName(fieldMetadata, property)).toEqual(
'bodyV2Markdown',
);
});
});

View File

@ -47,6 +47,11 @@ export function generateDefaultValue(
primaryPhoneCallingCode: "''",
additionalPhones: null,
};
case FieldMetadataType.RICH_TEXT_V2:
return {
blocknote: "''",
markdown: "''",
};
default:
return null;
}

View File

@ -9,7 +9,8 @@ export const isCompositeFieldMetadataType = (
| FieldMetadataType.LINKS
| FieldMetadataType.ACTOR
| FieldMetadataType.EMAILS
| FieldMetadataType.PHONES => {
| FieldMetadataType.PHONES
| FieldMetadataType.RICH_TEXT_V2 => {
return [
FieldMetadataType.CURRENCY,
FieldMetadataType.FULL_NAME,
@ -18,5 +19,6 @@ export const isCompositeFieldMetadataType = (
FieldMetadataType.ACTOR,
FieldMetadataType.EMAILS,
FieldMetadataType.PHONES,
FieldMetadataType.RICH_TEXT_V2,
].includes(type);
};

View File

@ -21,6 +21,7 @@ import {
FieldMetadataDefaultValueNumber,
FieldMetadataDefaultValuePhones,
FieldMetadataDefaultValueRawJson,
FieldMetadataDefaultValueRichTextV2,
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueStringArray,
FieldMetadataDefaultValueUuidFunction,
@ -47,6 +48,7 @@ export const defaultValueValidatorsMap = {
[FieldMetadataType.SELECT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataDefaultValueStringArray],
[FieldMetadataType.ADDRESS]: [FieldMetadataDefaultValueAddress],
[FieldMetadataType.RICH_TEXT_V2]: [FieldMetadataDefaultValueRichTextV2],
[FieldMetadataType.RICH_TEXT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson],
[FieldMetadataType.LINKS]: [FieldMetadataDefaultValueLinks],

View File

@ -26,7 +26,8 @@ export type CompositeFieldMetadataType =
| FieldMetadataType.FULL_NAME
| FieldMetadataType.LINKS
| FieldMetadataType.EMAILS
| FieldMetadataType.PHONES;
| FieldMetadataType.PHONES
| FieldMetadataType.RICH_TEXT_V2;
@Injectable()
export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<CompositeFieldMetadataType> {

View File

@ -94,6 +94,10 @@ export class WorkspaceMigrationFactory {
FieldMetadataType.TS_VECTOR,
{ factory: this.tsVectorColumnActionFactory },
],
[
FieldMetadataType.RICH_TEXT_V2,
{ factory: this.compositeColumnActionFactory },
],
]);
}

View File

@ -36,6 +36,8 @@ export const getSubfieldsForAggregateOperation = (
'primaryPhoneCountryCode',
'primaryPhoneCallingCode',
];
case FieldMetadataType.RICH_TEXT_V2:
return ['blocknote', 'markdown'];
default:
throw new Error(`Unsupported composite field type: ${fieldType}`);
}

View File

@ -288,6 +288,7 @@ export const NOTE_STANDARD_FIELD_IDS = {
position: '20202020-368d-4dc2-943f-ed8a49c7fdfb',
title: '20202020-faeb-4c76-8ba6-ccbb0b4a965f',
body: '20202020-e63d-4e70-95be-a78cd9abe7ef',
bodyV2: '20202020-a7bb-4d94-be51-8f25181502c8',
createdBy: '20202020-0d79-4e21-ab77-5a394eff97be',
noteTargets: '20202020-1f25-43fe-8b00-af212fdde823',
attachments: '20202020-4986-4c92-bf19-39934b149b16',
@ -355,6 +356,7 @@ export const TASK_STANDARD_FIELD_IDS = {
position: '20202020-7d47-4690-8a98-98b9a0c05dd8',
title: '20202020-b386-4cb7-aa5a-08d4a4d92680',
body: '20202020-ce13-43f4-8821-69388fe1fd26',
bodyV2: '20202020-4aa0-4ae8-898d-7df0afd47ab1',
dueAt: '20202020-fd99-40da-951b-4cb9a352fce3',
status: '20202020-70bc-48f9-89c5-6aa730b151e0',
createdBy: '20202020-1a04-48ab-a567-576965ae5387',

View File

@ -13,58 +13,6 @@ const nameFullNameField = {
const jobTitleTextField = { name: 'jobTitle', type: FieldMetadataType.TEXT };
const emailsEmailsField = { name: 'emails', type: FieldMetadataType.EMAILS };
jest.mock(
'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util',
() => ({
computeColumnName: jest.fn((name) => {
if (name === 'name') {
return 'name';
}
if (name === 'jobTitle') {
return 'jobTitle';
}
if (name === 'emailsPrimaryEmail') {
return 'emailsPrimaryEmail';
}
if (name === 'emailsAdditionalEmails') {
return 'emailsAdditionalEmails';
}
if (name === 'nameFirstName') {
return 'nameFirstName';
}
if (name === 'nameLastName') {
return 'nameLastName';
}
}),
computeCompositeColumnName: jest.fn((field, property) => {
if (
field.name === emailsEmailsField.name &&
property.name === 'primaryEmail'
) {
return 'emailsPrimaryEmail';
}
if (
field.name === emailsEmailsField.name &&
property.name === 'additionalEmails'
) {
return 'emailsAdditionalEmails';
}
if (
field.name === nameFullNameField.name &&
property.name === 'firstName'
) {
return 'nameFirstName';
}
if (
field.name === nameFullNameField.name &&
property.name === 'lastName'
) {
return 'nameLastName';
}
}),
}),
);
describe('getTsVectorColumnExpressionFromFields', () => {
it('should generate correct expression for simple text field', () => {
const fields = [nameTextField] as FieldTypeAndNameMetadata[];
@ -95,4 +43,24 @@ describe('getTsVectorColumnExpressionFromFields', () => {
expect(result.trim()).toBe(expected);
});
it('should handle rich text fields', () => {
const fields = [
{ name: 'body', type: FieldMetadataType.RICH_TEXT },
] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
expect(result).toBe("to_tsvector('simple', COALESCE(\"body\", ''))");
});
it('should handle rich text v2 fields', () => {
const fields = [
{ name: 'bodyV2', type: FieldMetadataType.RICH_TEXT_V2 },
] as FieldTypeAndNameMetadata[];
const result = getTsVectorColumnExpressionFromFields(fields);
expect(result).toBe(
"to_tsvector('simple', COALESCE(\"bodyV2Markdown\", ''))",
);
});
});

View File

@ -14,6 +14,7 @@ import {
isSearchableFieldType,
SearchableFieldType,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-searchable-field.util';
import { isSearchableSubfield } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/is-searchable-subfield.util';
export type FieldTypeAndNameMetadata = {
name: string;
@ -55,7 +56,9 @@ const getColumnExpressionsFromField = (
}
return compositeType.properties
.filter((property) => property.type === FieldMetadataType.TEXT)
.filter((property) =>
isSearchableSubfield(compositeType.type, property.type, property.name),
)
.map((property) => {
const columnName = computeCompositeColumnName(
fieldMetadataTypeAndName,

View File

@ -7,6 +7,7 @@ const SEARCHABLE_FIELD_TYPES = [
FieldMetadataType.ADDRESS,
FieldMetadataType.LINKS,
FieldMetadataType.RICH_TEXT,
FieldMetadataType.RICH_TEXT_V2,
] as const;
export type SearchableFieldType = (typeof SEARCHABLE_FIELD_TYPES)[number];

View File

@ -0,0 +1,18 @@
import { FieldMetadataType } from 'twenty-shared';
export const isSearchableSubfield = (
compositeFieldMetadataType: FieldMetadataType,
subFieldMetadataType: FieldMetadataType,
subFieldName: string,
) => {
if (subFieldMetadataType !== FieldMetadataType.TEXT) {
return false;
}
switch (compositeFieldMetadataType) {
case FieldMetadataType.RICH_TEXT_V2:
return ['markdown'].includes(subFieldName);
default:
return true;
}
};

View File

@ -3,11 +3,13 @@ import { FieldMetadataType } from 'twenty-shared';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { RichTextV2Metadata } from 'src/engine/metadata-modules/field-metadata/composite-types/rich-text-v2.composite-type';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import {
RelationMetadataType,
@ -17,6 +19,7 @@ import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
@ -33,11 +36,9 @@ import { NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/not
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
const TITLE_FIELD_NAME = 'title';
const BODY_FIELD_NAME = 'body';
export const SEARCH_FIELDS_FOR_NOTES: FieldTypeAndNameMetadata[] = [
{ name: TITLE_FIELD_NAME, type: FieldMetadataType.TEXT },
{ name: BODY_FIELD_NAME, type: FieldMetadataType.RICH_TEXT },
];
@WorkspaceEntity({
@ -81,6 +82,19 @@ export class NoteWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
body: string | null;
@WorkspaceField({
standardId: NOTE_STANDARD_FIELD_IDS.bodyV2,
type: FieldMetadataType.RICH_TEXT_V2,
label: 'Body',
description: 'Note body',
icon: 'IconFilePencil',
})
@WorkspaceIsNullable()
@WorkspaceGate({
featureFlag: FeatureFlagKey.IsRichTextV2Enabled,
})
bodyV2: RichTextV2Metadata | null;
@WorkspaceField({
standardId: NOTE_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,

View File

@ -8,6 +8,7 @@ import {
ActorMetadata,
FieldActorSource,
} from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { RichTextV2Metadata } from 'src/engine/metadata-modules/field-metadata/composite-types/rich-text-v2.composite-type';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import {
RelationMetadataType,
@ -33,13 +34,13 @@ import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/f
import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
const TITLE_FIELD_NAME = 'title';
const BODY_FIELD_NAME = 'body';
export const SEARCH_FIELDS_FOR_TASK: FieldTypeAndNameMetadata[] = [
{ name: TITLE_FIELD_NAME, type: FieldMetadataType.TEXT },
{ name: BODY_FIELD_NAME, type: FieldMetadataType.RICH_TEXT },
];
@WorkspaceEntity({
@ -83,6 +84,19 @@ export class TaskWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
body: string | null;
@WorkspaceField({
standardId: TASK_STANDARD_FIELD_IDS.bodyV2,
type: FieldMetadataType.RICH_TEXT_V2,
label: 'Body',
description: 'Task body',
icon: 'IconFilePencil',
})
@WorkspaceIsNullable()
@WorkspaceGate({
featureFlag: FeatureFlagKey.IsRichTextV2Enabled,
})
bodyV2: RichTextV2Metadata | null;
@WorkspaceField({
standardId: TASK_STANDARD_FIELD_IDS.dueAt,
type: FieldMetadataType.DATE_TIME,

View File

@ -18,6 +18,7 @@ export enum FieldMetadataType {
POSITION = 'POSITION',
ADDRESS = 'ADDRESS',
RAW_JSON = 'RAW_JSON',
RICH_TEXT_V2 = 'RICH_TEXT_V2',
RICH_TEXT = 'RICH_TEXT',
ACTOR = 'ACTOR',
ARRAY = 'ARRAY',

160
yarn.lock
View File

@ -3415,6 +3415,55 @@ __metadata:
languageName: node
linkType: hard
"@blocknote/core@npm:^0.17.1":
version: 0.17.1
resolution: "@blocknote/core@npm:0.17.1"
dependencies:
"@emoji-mart/data": "npm:^1.2.1"
"@tiptap/core": "npm:^2.7.1"
"@tiptap/extension-bold": "npm:^2.7.1"
"@tiptap/extension-code": "npm:^2.7.1"
"@tiptap/extension-collaboration": "npm:^2.7.1"
"@tiptap/extension-collaboration-cursor": "npm:^2.7.1"
"@tiptap/extension-dropcursor": "npm:^2.7.1"
"@tiptap/extension-gapcursor": "npm:^2.7.1"
"@tiptap/extension-hard-break": "npm:^2.7.1"
"@tiptap/extension-history": "npm:^2.7.1"
"@tiptap/extension-horizontal-rule": "npm:^2.7.1"
"@tiptap/extension-italic": "npm:^2.7.1"
"@tiptap/extension-link": "npm:^2.7.1"
"@tiptap/extension-paragraph": "npm:^2.7.1"
"@tiptap/extension-strike": "npm:^2.7.1"
"@tiptap/extension-table-cell": "npm:^2.7.1"
"@tiptap/extension-table-header": "npm:^2.7.1"
"@tiptap/extension-table-row": "npm:^2.7.1"
"@tiptap/extension-text": "npm:^2.7.1"
"@tiptap/extension-underline": "npm:^2.7.1"
"@tiptap/pm": "npm:^2.7.1"
emoji-mart: "npm:^5.6.0"
hast-util-from-dom: "npm:^4.2.0"
prosemirror-model: "npm:^1.21.0"
prosemirror-state: "npm:^1.4.3"
prosemirror-tables: "npm:^1.3.7"
prosemirror-transform: "npm:^1.9.0"
prosemirror-view: "npm:^1.33.7"
rehype-format: "npm:^5.0.0"
rehype-parse: "npm:^8.0.4"
rehype-remark: "npm:^9.1.2"
rehype-stringify: "npm:^9.0.3"
remark-gfm: "npm:^3.0.1"
remark-parse: "npm:^10.0.1"
remark-rehype: "npm:^10.1.0"
remark-stringify: "npm:^10.0.2"
unified: "npm:^10.1.2"
uuid: "npm:^8.3.2"
y-prosemirror: "npm:1.2.12"
y-protocols: "npm:^1.0.6"
yjs: "npm:^13.6.15"
checksum: 10c0/0acd1a099832d8e271983924f19e59aa056ead278a8bac8ab7a64d6c7d40a787a27143bdee3d6b6f3dfa26c7723207d98d7124ffef8ff9c4cfdf3034140716ab
languageName: node
linkType: hard
"@blocknote/core@npm:^0.22.0":
version: 0.22.0
resolution: "@blocknote/core@npm:0.22.0"
@ -3483,6 +3532,25 @@ __metadata:
languageName: node
linkType: hard
"@blocknote/react@npm:^0.17.1":
version: 0.17.1
resolution: "@blocknote/react@npm:0.17.1"
dependencies:
"@blocknote/core": "npm:^0.17.1"
"@floating-ui/react": "npm:^0.26.4"
"@tiptap/core": "npm:^2.7.1"
"@tiptap/react": "npm:^2.7.1"
lodash.merge: "npm:^4.6.2"
react: "npm:^18"
react-dom: "npm:^18"
react-icons: "npm:^5.2.1"
peerDependencies:
react: ^18
react-dom: ^18
checksum: 10c0/4914dce225f60905b3dfe59805d7cf1f0c0c6d87295a09204333b913f6449cc90c2947baeb65c1f06106beaea8b50ac5c2785782cb2528f7e913b2877427c3e4
languageName: node
linkType: hard
"@blocknote/react@npm:^0.22.0":
version: 0.22.0
resolution: "@blocknote/react@npm:0.22.0"
@ -3500,6 +3568,27 @@ __metadata:
languageName: node
linkType: hard
"@blocknote/server-util@npm:0.17.1":
version: 0.17.1
resolution: "@blocknote/server-util@npm:0.17.1"
dependencies:
"@blocknote/core": "npm:^0.17.1"
"@blocknote/react": "npm:^0.17.1"
"@tiptap/core": "npm:^2.7.1"
"@tiptap/pm": "npm:^2.7.1"
jsdom: "npm:^21.1.0"
react: "npm:^18"
react-dom: "npm:^18"
y-prosemirror: "npm:1.2.12"
y-protocols: "npm:^1.0.6"
yjs: "npm:^13.6.15"
peerDependencies:
react: ^18
react-dom: ^18
checksum: 10c0/7d400dbf19562f8827bc524f87d673d711fba95a50fb299e0eb638f01c2dc87fd840a132b33dae60c0944637208f18a632f72f7664cb03b8ce81f5be7f8e59f0
languageName: node
linkType: hard
"@blocknote/xl-docx-exporter@npm:^0.22.0":
version: 0.22.0
resolution: "@blocknote/xl-docx-exporter@npm:0.22.0"
@ -15688,6 +15777,16 @@ __metadata:
languageName: node
linkType: hard
"@tiptap/extension-dropcursor@npm:^2.7.1":
version: 2.11.0
resolution: "@tiptap/extension-dropcursor@npm:2.11.0"
peerDependencies:
"@tiptap/core": ^2.7.0
"@tiptap/pm": ^2.7.0
checksum: 10c0/12ace987deec4bd02f52ee7a8f837bd71d560bca1ce670d43c6a715526a336aa5431ed044cba44babd45f7f0ed79002d16f03430ce72899a4a9713679e924717
languageName: node
linkType: hard
"@tiptap/extension-floating-menu@npm:^2.10.4":
version: 2.10.4
resolution: "@tiptap/extension-floating-menu@npm:2.10.4"
@ -32934,6 +33033,45 @@ __metadata:
languageName: node
linkType: hard
"jsdom@npm:^21.1.0":
version: 21.1.2
resolution: "jsdom@npm:21.1.2"
dependencies:
abab: "npm:^2.0.6"
acorn: "npm:^8.8.2"
acorn-globals: "npm:^7.0.0"
cssstyle: "npm:^3.0.0"
data-urls: "npm:^4.0.0"
decimal.js: "npm:^10.4.3"
domexception: "npm:^4.0.0"
escodegen: "npm:^2.0.0"
form-data: "npm:^4.0.0"
html-encoding-sniffer: "npm:^3.0.0"
http-proxy-agent: "npm:^5.0.0"
https-proxy-agent: "npm:^5.0.1"
is-potential-custom-element-name: "npm:^1.0.1"
nwsapi: "npm:^2.2.4"
parse5: "npm:^7.1.2"
rrweb-cssom: "npm:^0.6.0"
saxes: "npm:^6.0.0"
symbol-tree: "npm:^3.2.4"
tough-cookie: "npm:^4.1.2"
w3c-xmlserializer: "npm:^4.0.0"
webidl-conversions: "npm:^7.0.0"
whatwg-encoding: "npm:^2.0.0"
whatwg-mimetype: "npm:^3.0.0"
whatwg-url: "npm:^12.0.1"
ws: "npm:^8.13.0"
xml-name-validator: "npm:^4.0.0"
peerDependencies:
canvas: ^2.5.0
peerDependenciesMeta:
canvas:
optional: true
checksum: 10c0/905012680891fa0c92b8c18acfa35fc0b3e4b15f778ee3494aec1aca3274875160d2be35917d666b8eacd0b3121f483bd95fbe35e14790a004b805b1cf01818a
languageName: node
linkType: hard
"jsdom@npm:~22.1.0":
version: 22.1.0
resolution: "jsdom@npm:22.1.0"
@ -40128,7 +40266,7 @@ __metadata:
languageName: node
linkType: hard
"prosemirror-tables@npm:^1.6.1":
"prosemirror-tables@npm:^1.3.7, prosemirror-tables@npm:^1.6.1":
version: 1.6.2
resolution: "prosemirror-tables@npm:1.6.2"
dependencies:
@ -40164,7 +40302,7 @@ __metadata:
languageName: node
linkType: hard
"prosemirror-transform@npm:^1.10.2":
"prosemirror-transform@npm:^1.10.2, prosemirror-transform@npm:^1.9.0":
version: 1.10.2
resolution: "prosemirror-transform@npm:1.10.2"
dependencies:
@ -40649,7 +40787,7 @@ __metadata:
languageName: node
linkType: hard
"react-dom@npm:^18.2.0":
"react-dom@npm:^18, react-dom@npm:^18.2.0":
version: 18.3.1
resolution: "react-dom@npm:18.3.1"
dependencies:
@ -45818,6 +45956,7 @@ __metadata:
"@babel/preset-typescript": "npm:^7.24.6"
"@blocknote/mantine": "npm:^0.22.0"
"@blocknote/react": "npm:^0.22.0"
"@blocknote/server-util": "npm:0.17.1"
"@codesandbox/sandpack-react": "npm:^2.13.5"
"@crxjs/vite-plugin": "npm:^1.0.14"
"@dagrejs/dagre": "npm:^1.1.2"
@ -48799,6 +48938,21 @@ __metadata:
languageName: node
linkType: hard
"y-prosemirror@npm:1.2.12":
version: 1.2.12
resolution: "y-prosemirror@npm:1.2.12"
dependencies:
lib0: "npm:^0.2.42"
peerDependencies:
prosemirror-model: ^1.7.1
prosemirror-state: ^1.2.3
prosemirror-view: ^1.9.10
y-protocols: ^1.0.1
yjs: ^13.5.38
checksum: 10c0/c460aa9104c71806112a17b52449221343095c774bc929a3bcfaa6d752ce9af1a5a8359c974625c70de8bf48e10b2aa8702f12ca2027f85c6097d1621969beeb
languageName: node
linkType: hard
"y-prosemirror@npm:1.2.13":
version: 1.2.13
resolution: "y-prosemirror@npm:1.2.13"