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
@@ -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;
}
}
}