feat: enable Rich Text as a creatable field type (#18634)

## Summary

- Removes `RICH_TEXT` from the excluded/hidden field types in the
settings UI so users can create rich text fields on any object (not just
Note/Task)
- Creates a generic `RichTextFieldEditor` component that uses standard
`useUpdateOneRecord` for persistence, decoupled from the
Note/Task-specific `ActivityRichTextEditor`
- Updates the inline `RichTextFieldInput` and side panel to route to the
appropriate editor based on object type (activity editor for Note/Task,
generic editor for everything else)

## Details

### Tier 1 — Settings UI unlock
- Removed `RICH_TEXT` from `excludedFieldTypes` in
`SettingsObjectNewFieldSelect.tsx`
- Removed `RICH_TEXT` from `SettingsExcludedFieldType` type union
- Added `RICH_TEXT` to `previewableTypes` in
`SettingsDataModelFieldSettingsFormCard`

### Tier 2 — Generic inline editing
- New `RichTextFieldEditor` — a generic BlockNote editor that works for
any object using `useUpdateOneRecord` (no activity-specific coupling)
- `RichTextFieldInput` now branches: `ActivityRichTextEditor` for
Note/Task, `RichTextFieldEditor` for all other objects
- Generalized side panel state (`viewableRichTextComponentState`) from
`activityId`/`activityObjectNameSingular` to
`recordId`/`objectNameSingular`/`fieldName`
- `useOpenRichTextInSidePanel` now accepts an optional `fieldName`
parameter

### Tier 3 — Verification
- Search: only `markdown` subfield is indexed (correct behavior)
- Filters: `RichTextFilter` GraphQL input type already exists
- Import/export: `markdown` subfield is already marked `isImportable:
true`
This commit is contained in:
Charles Bochet
2026-03-14 10:57:27 +01:00
committed by GitHub
parent 3a9247d9d1
commit 602db4ffea
18 changed files with 578 additions and 372 deletions
@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { isNonEmptyString } from '@sniptt/guards';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useReplaceBlockEditorContent = (
editor: typeof BLOCK_SCHEMA.BlockNoteEditor,
fieldName: string,
) => {
const store = useStore();
const replaceBlockEditorContent = useCallback(
(recordId: string) => {
const record = store.get(recordStoreFamilyState.atomFamily(recordId));
const fieldValue = record?.[fieldName] as
| { blocknote?: string | null }
| undefined;
const content = isNonEmptyString(fieldValue?.blocknote)
? JSON.parse(fieldValue.blocknote)
: [{ type: 'paragraph', content: '' }];
if (!isDeeplyEqual(editor.document, content)) {
editor.replaceBlocks(editor.document, content);
}
},
[store, editor, fieldName],
);
return { replaceBlockEditorContent };
};
@@ -74,4 +74,19 @@ describe('getFirstNonEmptyLineOfRichText', () => {
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('First non-empty line');
});
it('should handle non-array string content (e.g. heading blocks)', () => {
const input = [{ content: 'Hello heading' }] as unknown as PartialBlock[];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('Hello heading');
});
it('should skip empty non-array string content', () => {
const input = [
{ content: ' ' },
{ content: [{ text: 'Fallback text', type: 'text', styles: {} }] },
] as unknown as PartialBlock[];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('Fallback text');
});
});
@@ -9,19 +9,25 @@ export const getFirstNonEmptyLineOfRichText = (
}
for (const block of blocks) {
if (!isUndefinedOrNull(block.content)) {
const contentArray = block.content as Array<
{ text: string } | { link: string }
>;
if (contentArray.length > 0) {
for (const content of contentArray) {
if ('link' in content) {
return content.link;
const contentArray = Array.isArray(block.content)
? (block.content as Array<{ text: string } | { link: string }>)
: [block.content as { text: string } | { link: string } | string];
for (const content of contentArray) {
if (typeof content === 'string') {
const value = content.trim();
if (value !== '') {
return value;
}
if ('text' in content) {
const value = content.text.trim();
if (value !== '') {
return value;
}
continue;
}
if ('link' in content) {
return content.link;
}
if ('text' in content) {
const value = content.text.trim();
if (value !== '') {
return value;
}
}
}