[Dashboards] Rich text editor frontend (#16437)

closes https://github.com/twentyhq/core-team-issues/issues/1894
This commit is contained in:
nitin
2025-12-12 23:06:59 +05:30
committed by GitHub
parent afcca283c4
commit 44f0cfdd9e
42 changed files with 2054 additions and 143 deletions
@@ -18,33 +18,27 @@ import { ActivityRichTextEditorChangeOnActivityIdEffect } from '@/activities/com
import { type Attachment } from '@/activities/files/types/Attachment';
import { type Note } from '@/activities/types/Note';
import { type Task } from '@/activities/types/Task';
import { filterAttachmentsToRestore } from '@/activities/utils/filterAttachmentsToRestore';
import { getActivityAttachmentIdsAndNameToUpdate } from '@/activities/utils/getActivityAttachmentIdsAndNameToUpdate';
import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete';
import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore';
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
import { isTitleCellInEditModeComponentState } from '@/object-record/record-title-cell/states/isTitleCellInEditModeComponentState';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig';
import { useAttachmentSync } from '@/ui/input/editor/hooks/useAttachmentSync';
import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote';
import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import type { PartialBlock } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import '@blocknote/react/style.css';
import { isArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type ActivityRichTextEditorProps = {
@@ -72,14 +66,6 @@ export const ActivityRichTextEditor = ({
(field) => field.name === 'bodyV2',
);
const { deleteManyRecords: deleteAttachments } = useDeleteManyRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { restoreManyRecords: restoreAttachments } = useRestoreManyRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
@@ -102,18 +88,8 @@ export const ActivityRichTextEditor = ({
},
});
const { fetchAllRecords: findSoftDeletedAttachments } =
useLazyFetchAllRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
filter: {
deletedAt: {
is: 'NOT_NULL',
},
},
});
const { updateOneRecord: updateOneAttachment } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { syncAttachments } = useAttachmentSync(attachments);
const { upsertActivity } = useUpsertActivity({
activityObjectNameSingular: activityObjectNameSingular,
});
@@ -155,37 +131,13 @@ export const ActivityRichTextEditor = ({
});
};
const prepareBody = (newStringifiedBody: string) => {
if (!newStringifiedBody) return newStringifiedBody;
const body = JSON.parse(newStringifiedBody);
const bodyWithSignedPayload = body.map((block: any) => {
if (block.type !== 'image' || !block.props.url) {
return block;
}
const imageProps = block.props;
const imageUrl = new URL(imageProps.url);
return {
...block,
props: {
...imageProps,
url: `${imageUrl.toString()}`,
},
};
});
return JSON.stringify(bodyWithSignedPayload);
};
const handlePersistBody = useCallback(
(activityBody: string) => {
if (!canCreateActivity) {
setCanCreateActivity(true);
}
persistBodyDebounced(prepareBody(activityBody));
persistBodyDebounced(prepareBodyWithSignedUrls(activityBody));
},
[persistBodyDebounced, setCanCreateActivity, canCreateActivity],
);
@@ -225,60 +177,17 @@ export const ActivityRichTextEditor = ({
handlePersistBody(newStringifiedBody);
const attachmentIdsToDelete = getActivityAttachmentIdsToDelete(
await syncAttachments(
newStringifiedBody,
attachments,
oldActivity?.bodyV2.blocknote,
);
if (attachmentIdsToDelete.length > 0) {
await deleteAttachments({
recordIdsToDelete: attachmentIdsToDelete,
});
}
const attachmentPathsToRestore = getActivityAttachmentPathsToRestore(
newStringifiedBody,
attachments,
);
if (attachmentPathsToRestore.length > 0) {
const softDeletedAttachments =
(await findSoftDeletedAttachments()) as Attachment[];
const attachmentIdsToRestore = filterAttachmentsToRestore(
attachmentPathsToRestore,
softDeletedAttachments,
);
await restoreAttachments({
idsToRestore: attachmentIdsToRestore,
});
}
const attachmentsToUpdate = getActivityAttachmentIdsAndNameToUpdate(
newStringifiedBody,
attachments,
);
if (attachmentsToUpdate.length > 0) {
for (const attachmentToUpdate of attachmentsToUpdate) {
if (!attachmentToUpdate.id) continue;
await updateOneAttachment({
idToUpdate: attachmentToUpdate.id,
updateOneRecordInput: { name: attachmentToUpdate.name },
});
}
}
},
[
attachments,
activityId,
cache,
objectMetadataItemActivity,
handlePersistBody,
deleteAttachments,
restoreAttachments,
findSoftDeletedAttachments,
updateOneAttachment,
syncAttachments,
],
);
@@ -291,35 +200,14 @@ export const ActivityRichTextEditor = ({
};
const initialBody = useMemo(() => {
const blocknote = activity?.bodyV2?.blocknote;
if (
isDefined(activity) &&
isNonEmptyString(blocknote) &&
blocknote !== '{}'
) {
let parsedBody: PartialBlock[] | undefined = undefined;
// TODO: Remove this once we have removed the old rich text
try {
parsedBody = JSON.parse(blocknote);
} catch {
// eslint-disable-next-line no-console
console.warn(
`Failed to parse body for activity ${activityId}, for rich text version 'v2'`,
);
// eslint-disable-next-line no-console
console.warn(blocknote);
}
if (isArray(parsedBody) && parsedBody.length === 0) {
return undefined;
}
return parsedBody;
if (!isDefined(activity)) {
return undefined;
}
return undefined;
return parseInitialBlocknote(
activity?.bodyV2?.blocknote,
`Failed to parse body for activity ${activityId}, for rich text version 'v2'`,
);
}, [activity, activityId]);
const handleEditorBuiltInUploadFile = async (file: File) => {
@@ -431,10 +319,7 @@ export const ActivityRichTextEditor = ({
type: FocusComponentType.ACTIVITY_RICH_TEXT_EDITOR,
},
focusId: activityId,
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
enableGlobalHotkeysWithModifiers: true,
},
globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG,
});
},
[recordTitleCellId, activityId, editor, pushFocusItemToFocusStack],
@@ -157,7 +157,6 @@ export const usePageLayoutHeaderInfo = ({
widgetInEditMode: undefined,
};
}
default:
return null;
}
@@ -22,11 +22,7 @@ export const CommandMenuPageLayoutIframeSettings = () => {
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
if (!isDefined(widgetInEditMode)) {
throw new Error('Widget ID must be present while editing the widget');
}
const widgetConfiguration = widgetInEditMode.configuration;
const widgetConfiguration = widgetInEditMode?.configuration;
const configUrl =
widgetConfiguration && 'url' in widgetConfiguration
@@ -38,6 +34,10 @@ export const CommandMenuPageLayoutIframeSettings = () => {
);
const [urlError, setUrlError] = useState('');
if (!isDefined(widgetInEditMode)) {
return null;
}
const validateUrl = (urlString: string): boolean => {
const trimmedUrl = urlString.trim();
@@ -1,23 +1,31 @@
import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuList } from '@/command-menu/components/CommandMenuList';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useCompanyDefaultChartConfig } from '@/page-layout/hooks/useCompanyDefaultChartConfig';
import { useCreatePageLayoutGraphWidget } from '@/page-layout/hooks/useCreatePageLayoutGraphWidget';
import { useCreatePageLayoutIframeWidget } from '@/page-layout/hooks/useCreatePageLayoutIframeWidget';
import { useCreatePageLayoutStandaloneRichTextWidget } from '@/page-layout/hooks/useCreatePageLayoutStandaloneRichTextWidget';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconChartPie, IconFrame } from 'twenty-ui/display';
import {
IconAlignBoxLeftTop,
IconChartPie,
IconFrame,
} from 'twenty-ui/display';
import { GraphType } from '~/generated-metadata/graphql';
export const CommandMenuPageLayoutWidgetTypeSelect = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { closeCommandMenu } = useCommandMenu();
const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu();
const { buildBarChartFieldSelection } = useCompanyDefaultChartConfig();
@@ -28,6 +36,9 @@ export const CommandMenuPageLayoutWidgetTypeSelect = () => {
const { createPageLayoutIframeWidget } =
useCreatePageLayoutIframeWidget(pageLayoutId);
const { createPageLayoutStandaloneRichTextWidget } =
useCreatePageLayoutStandaloneRichTextWidget(pageLayoutId);
const [pageLayoutEditingWidgetId, setPageLayoutEditingWidgetId] =
useRecoilComponentState(
pageLayoutEditingWidgetIdComponentState,
@@ -64,8 +75,23 @@ export const CommandMenuPageLayoutWidgetTypeSelect = () => {
});
};
const handleNavigateToRichTextSettings = () => {
if (!isDefined(pageLayoutEditingWidgetId)) {
const newWidget = createPageLayoutStandaloneRichTextWidget({
blocknote: '',
markdown: null,
});
setPageLayoutEditingWidgetId(newWidget.id);
}
closeCommandMenu();
};
return (
<CommandMenuList commandGroups={[]} selectableItemIds={['chart', 'iframe']}>
<CommandMenuList
commandGroups={[]}
selectableItemIds={['chart', 'iframe', 'rich-text']}
>
<CommandGroup heading={t`Widget type`}>
<SelectableListItem
itemId="chart"
@@ -89,6 +115,18 @@ export const CommandMenuPageLayoutWidgetTypeSelect = () => {
onClick={handleNavigateToIframeSettings}
/>
</SelectableListItem>
<SelectableListItem
itemId="rich-text"
onEnter={handleNavigateToRichTextSettings}
>
<CommandMenuItem
Icon={IconAlignBoxLeftTop}
label={t`Rich Text`}
id="rich-text"
onClick={handleNavigateToRichTextSettings}
/>
</SelectableListItem>
</CommandGroup>
</CommandMenuList>
);
@@ -6,4 +6,8 @@ export const WIDGET_SIZES: Partial<Record<WidgetType, WidgetSizeConfig>> = {
default: { w: 6, h: 6 },
minimum: { w: 4, h: 5 },
},
[WidgetType.STANDALONE_RICH_TEXT]: {
default: { w: 4, h: 4 },
minimum: { w: 1, h: 1 },
},
};
@@ -0,0 +1,129 @@
import { WIDGET_SIZES } from '@/page-layout/constants/WidgetSizes';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { createDefaultStandaloneRichTextWidget } from '@/page-layout/utils/createDefaultStandaloneRichTextWidget';
import { getDefaultWidgetPosition } from '@/page-layout/utils/getDefaultWidgetPosition';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { getUpdatedTabLayouts } from '@/page-layout/utils/getUpdatedTabLayouts';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import {
type PageLayoutWidget,
type RichTextV2Body,
WidgetType,
} from '~/generated/graphql';
export const useCreatePageLayoutStandaloneRichTextWidget = (
pageLayoutIdFromProps?: string,
) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
PageLayoutComponentInstanceContext,
pageLayoutIdFromProps,
);
const activeTabIdState = useRecoilComponentCallbackState(
activeTabIdComponentState,
getTabListInstanceIdFromPageLayoutId(pageLayoutId),
);
const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState(
pageLayoutCurrentLayoutsComponentState,
pageLayoutId,
);
const pageLayoutDraggedAreaState = useRecoilComponentCallbackState(
pageLayoutDraggedAreaComponentState,
pageLayoutId,
);
const pageLayoutDraftState = useRecoilComponentCallbackState(
pageLayoutDraftComponentState,
pageLayoutId,
);
const createPageLayoutStandaloneRichTextWidget = useRecoilCallback(
({ snapshot, set }) =>
(body: RichTextV2Body): PageLayoutWidget => {
const activeTabId = snapshot.getLoadable(activeTabIdState).getValue();
if (!isDefined(activeTabId)) {
throw new Error(
'A tab must be selected to create a new rich text widget',
);
}
const allTabLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const pageLayoutDraggedArea = snapshot
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
const widgetId = uuidv4();
const richTextSize = WIDGET_SIZES[WidgetType.STANDALONE_RICH_TEXT]!;
const defaultRichTextSize = richTextSize.default;
const minimumSize = richTextSize.minimum;
const position = getDefaultWidgetPosition(
pageLayoutDraggedArea,
defaultRichTextSize,
minimumSize,
);
const newWidget = createDefaultStandaloneRichTextWidget(
widgetId,
activeTabId,
body,
{
row: position.y,
column: position.x,
rowSpan: position.h,
columnSpan: position.w,
},
);
const newLayout = {
i: widgetId,
x: position.x,
y: position.y,
w: position.w,
h: position.h,
minW: minimumSize.w,
minH: minimumSize.h,
};
const updatedLayouts = getUpdatedTabLayouts(
allTabLayouts,
activeTabId,
newLayout,
);
set(pageLayoutCurrentLayoutsState, updatedLayouts);
set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: addWidgetToTab(prev.tabs, activeTabId, newWidget),
}));
set(pageLayoutDraggedAreaState, null);
return newWidget;
},
[
activeTabIdState,
pageLayoutCurrentLayoutsState,
pageLayoutDraftState,
pageLayoutDraggedAreaState,
],
);
return { createPageLayoutStandaloneRichTextWidget };
};
@@ -2,6 +2,7 @@ import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layo
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
@@ -26,6 +27,11 @@ export const useEndPageLayoutDragSelection = (
pageLayoutId,
);
const pageLayoutEditingWidgetIdState = useRecoilComponentCallbackState(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu();
const endPageLayoutDragSelection = useRecoilCallback(
@@ -42,6 +48,7 @@ export const useEndPageLayoutDragSelection = (
if (isDefined(draggedBounds)) {
set(pageLayoutDraggedAreaState, draggedBounds);
set(pageLayoutEditingWidgetIdState, null);
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutWidgetTypeSelect,
@@ -54,6 +61,7 @@ export const useEndPageLayoutDragSelection = (
[
navigatePageLayoutCommandMenu,
pageLayoutDraggedAreaState,
pageLayoutEditingWidgetIdState,
pageLayoutSelectedCellsState,
],
);
@@ -1,3 +1,4 @@
import { WIDGET_SIZES } from '@/page-layout/constants/WidgetSizes';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import {
@@ -86,4 +87,54 @@ describe('convertPageLayoutToTabLayouts', () => {
},
});
});
it('should apply STANDALONE_RICH_TEXT minimum size constraints', () => {
const pageLayout: PageLayout = {
id: 'page-layout-1',
name: 'Page Layout 1',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-1',
tabs: [
{
id: 'tab-1',
title: 'Tab 1',
position: 0,
pageLayoutId: 'page-layout-1',
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'rich-text-widget',
pageLayoutTabId: 'tab-1',
title: 'Rich Text',
type: WidgetType.STANDALONE_RICH_TEXT,
configuration: {
body: { blocknote: '[]' },
},
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
objectMetadataId: null,
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
deletedAt: null,
},
],
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
deletedAt: null,
},
],
createdAt: '2025-01-01T00:00:00.000Z',
updatedAt: '2025-01-01T00:00:00.000Z',
deletedAt: null,
};
const result = convertPageLayoutToTabLayouts(pageLayout);
const richTextMinSize =
WIDGET_SIZES[WidgetType.STANDALONE_RICH_TEXT]!.minimum;
expect(result['tab-1'].desktop[0]).toMatchObject({
i: 'rich-text-widget',
minW: richTextMinSize.w,
minH: richTextMinSize.h,
});
});
});
@@ -0,0 +1,45 @@
import { WidgetType } from '~/generated/graphql';
import { createDefaultStandaloneRichTextWidget } from '../createDefaultStandaloneRichTextWidget';
describe('createDefaultStandaloneRichTextWidget', () => {
it('should create a standalone rich text widget with correct structure', () => {
const widget = createDefaultStandaloneRichTextWidget(
'widget-1',
'tab-1',
{ blocknote: '[{"type":"paragraph","content":"Test"}]' },
{ row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
);
expect(widget).toMatchObject({
__typename: 'PageLayoutWidget',
id: 'widget-1',
pageLayoutTabId: 'tab-1',
type: WidgetType.STANDALONE_RICH_TEXT,
title: 'Untitled Rich Text',
configuration: {
body: { blocknote: '[{"type":"paragraph","content":"Test"}]' },
},
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
});
});
it('should use provided objectMetadataId or default to null', () => {
const withObjectId = createDefaultStandaloneRichTextWidget(
'w1',
't1',
{ blocknote: '[]' },
{ row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
'object-1',
);
const withoutObjectId = createDefaultStandaloneRichTextWidget(
'w2',
't1',
{ blocknote: '[]' },
{ row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
);
expect(withObjectId.objectMetadataId).toBe('object-1');
expect(withoutObjectId.objectMetadataId).toBeNull();
});
});
@@ -39,6 +39,13 @@ export const convertPageLayoutToTabLayouts = (
minH = iframeMinimumSize.h;
}
if (widget.type === WidgetType.STANDALONE_RICH_TEXT) {
const richTextMinimumSize =
WIDGET_SIZES[WidgetType.STANDALONE_RICH_TEXT]!.minimum;
minW = richTextMinimumSize.w;
minH = richTextMinimumSize.h;
}
return {
i: widget.id,
x: widget.gridPosition.column,
@@ -0,0 +1,30 @@
import {
type GridPosition,
type PageLayoutWidget,
type RichTextV2Body,
WidgetType,
} from '~/generated/graphql';
export const createDefaultStandaloneRichTextWidget = (
id: string,
pageLayoutTabId: string,
body: RichTextV2Body,
gridPosition: GridPosition,
objectMetadataId?: string | null,
): PageLayoutWidget => {
return {
__typename: 'PageLayoutWidget',
id,
pageLayoutTabId,
title: 'Untitled Rich Text',
type: WidgetType.STANDALONE_RICH_TEXT,
configuration: {
body,
},
gridPosition,
objectMetadataId: objectMetadataId ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
};
@@ -7,6 +7,7 @@ import { FileWidget } from '@/page-layout/widgets/files/components/FileWidget';
import { GraphWidgetRenderer } from '@/page-layout/widgets/graph/components/GraphWidgetRenderer';
import { IframeWidget } from '@/page-layout/widgets/iframe/components/IframeWidget';
import { NoteWidget } from '@/page-layout/widgets/notes/components/NoteWidget';
import { StandaloneRichTextWidget } from '@/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidget';
import { TaskWidget } from '@/page-layout/widgets/tasks/components/TaskWidget';
import { TimelineWidget } from '@/page-layout/widgets/timeline/components/TimelineWidget';
import { WorkflowRunWidget } from '@/page-layout/widgets/workflow/components/WorkflowRunWidget';
@@ -61,6 +62,9 @@ export const WidgetContentRenderer = ({
case WidgetType.WORKFLOW_RUN:
return <WorkflowRunWidget />;
case WidgetType.STANDALONE_RICH_TEXT:
return <StandaloneRichTextWidget widget={widget} />;
default:
return null;
}
@@ -21,6 +21,7 @@ import { useSetRecoilComponentFamilyState } from '@/ui/utilities/state/component
import { useTheme } from '@emotion/react';
import { type MouseEvent } from 'react';
import { IconLock } from 'twenty-ui/display';
import { WidgetType } from '~/generated/graphql';
type WidgetRendererProps = {
widget: PageLayoutWidget;
@@ -60,7 +61,13 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const showHeader = layoutMode !== 'canvas' && !isInPinnedTab;
// TODO: when we have more widgets without headers, we should use a more generic approach to hide the header
// each widget type could have metadata (e.g., hasHeader: boolean or headerMode: 'always' | 'editOnly' | 'never')
const isRichTextWidget = widget.type === WidgetType.STANDALONE_RICH_TEXT;
const hideRichTextHeader = isRichTextWidget && !isPageLayoutInEditMode;
const showHeader =
layoutMode !== 'canvas' && !isInPinnedTab && !hideRichTextHeader;
const handleClick = () => {
handleEditWidget({
@@ -95,6 +102,7 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
return (
<WidgetCard
headerLess={!showHeader}
variant={variant}
isEditable={isPageLayoutInEditMode}
onClick={isPageLayoutInEditMode ? handleClick : undefined}
@@ -0,0 +1,99 @@
import { type Block } from '@blocknote/core';
import {
autoUpdate,
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react';
import { useRef } from 'react';
import { createPortal } from 'react-dom';
import { DashboardColorSelectionMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardColorSelectionMenu';
import { COLOR_PICKER_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/ColorPickerFloatingConfig';
import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor';
import { extractColorFromProps } from '@/page-layout/widgets/standalone-rich-text/utils/extractColorFromProps';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
type DashboardBlockColorPickerProps = {
block: Block;
anchorElement: HTMLElement | null;
onClose: () => void;
onColorSelect: (
textColor: BlockNoteColor | undefined,
backgroundColor: string | undefined,
) => void;
};
const COLOR_PICKER_CLICK_OUTSIDE_ID = 'color-picker-click-outside';
export const DashboardBlockColorPicker = ({
block,
anchorElement,
onClose,
onColorSelect,
}: DashboardBlockColorPickerProps) => {
const menuRef = useRef<HTMLDivElement>(null);
const currentTextColor = extractColorFromProps(block.props, 'text');
const currentBackgroundColor = extractColorFromProps(
block.props,
'background',
);
const { refs, floatingStyles } = useFloating({
placement: 'right-start',
whileElementsMounted: autoUpdate,
elements: {
reference: anchorElement,
},
middleware: [
offset(COLOR_PICKER_FLOATING_CONFIG.offsetFromMenuItem),
flip(),
shift({
padding: COLOR_PICKER_FLOATING_CONFIG.boundaryPadding,
}),
],
});
useListenClickOutside({
refs: [menuRef],
excludedClickOutsideIds: [COLOR_PICKER_CLICK_OUTSIDE_ID],
callback: onClose,
listenerId: 'dashboard-block-color-picker',
});
const handleTextColorSelect = (color: BlockNoteColor) => {
onColorSelect(color, undefined);
};
const handleBackgroundColorSelect = (color: BlockNoteColor) => {
onColorSelect(undefined, color);
};
return (
<>
{createPortal(
<OverlayContainer
ref={(node) => {
refs.setFloating(node);
(menuRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;
}}
style={floatingStyles}
className="bn-ui-container"
data-click-outside-id={COLOR_PICKER_CLICK_OUTSIDE_ID}
>
<DashboardColorSelectionMenu
currentTextColor={currentTextColor}
currentBackgroundColor={currentBackgroundColor}
onTextColorSelect={handleTextColorSelect}
onBackgroundColorSelect={handleBackgroundColorSelect}
/>
</OverlayContainer>,
document.body,
)}
</>
);
};
@@ -0,0 +1,174 @@
import { type Block } from '@blocknote/core';
import styled from '@emotion/styled';
import {
autoUpdate,
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react';
import { useLingui } from '@lingui/react/macro';
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { IconColorSwatch, IconPlus, IconTrash } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { DashboardBlockColorPicker } from '@/page-layout/widgets/standalone-rich-text/components/DashboardBlockColorPicker';
import { DRAG_HANDLE_MENU_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/DragHandleMenuFloatingConfig';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from 'twenty-shared/utils';
type DashboardBlockDragHandleMenuProps = {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
block: Block;
anchorElement: HTMLElement | null;
boundaryElement?: HTMLElement | null;
onClose: () => void;
};
const DRAG_HANDLE_MENU_CLICK_OUTSIDE_ID = 'drag-handle-menu-click-outside';
const StyledColorMenuItem = styled.div`
position: relative;
`;
export const DashboardBlockDragHandleMenu = ({
editor,
block,
anchorElement,
boundaryElement,
onClose,
}: DashboardBlockDragHandleMenuProps) => {
const { t } = useLingui();
const menuRef = useRef<HTMLDivElement>(null);
const [showColorPicker, setShowColorPicker] = useState(false);
const [colorMenuItemElement, setColorMenuItemElement] =
useState<HTMLDivElement | null>(null);
const { refs, floatingStyles } = useFloating({
placement: 'right-start',
whileElementsMounted: autoUpdate,
elements: {
reference: anchorElement,
},
middleware: [
offset(DRAG_HANDLE_MENU_FLOATING_CONFIG.offsetFromAnchor),
flip({
boundary: boundaryElement ?? undefined,
}),
shift({
boundary: boundaryElement ?? undefined,
}),
],
});
useListenClickOutside({
refs: [menuRef],
excludedClickOutsideIds: [DRAG_HANDLE_MENU_CLICK_OUTSIDE_ID],
callback: () => {
if (!showColorPicker) {
onClose();
}
},
listenerId: 'portaled-drag-handle-menu',
});
const handleAddBlock = () => {
const insertedBlocks = editor.insertBlocks(
[{ type: 'paragraph' }],
block,
'after',
);
const insertedBlock = insertedBlocks[0];
if (isDefined(insertedBlock)) {
editor.setTextCursorPosition(insertedBlock);
}
editor.openSuggestionMenu('/');
onClose();
};
const handleDelete = () => {
editor.removeBlocks([block]);
onClose();
};
const handleColorClick = () => {
setShowColorPicker(true);
};
const handleColorPickerClose = () => {
setShowColorPicker(false);
};
const handleColorSelect = (
textColor: string | undefined,
backgroundColor: string | undefined,
) => {
editor.updateBlock(block, {
props: {
...(isDefined(textColor) && { textColor }),
...(isDefined(backgroundColor) && { backgroundColor }),
},
});
setShowColorPicker(false);
onClose();
};
return (
<>
{createPortal(
<OverlayContainer
ref={(node) => {
refs.setFloating(node);
(menuRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;
}}
style={floatingStyles}
className="bn-ui-container"
data-click-outside-id={DRAG_HANDLE_MENU_CLICK_OUTSIDE_ID}
>
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconPlus}
onClick={handleAddBlock}
accent="default"
text={t`Add Block`}
/>
<StyledColorMenuItem ref={setColorMenuItemElement}>
<MenuItem
LeftIcon={IconColorSwatch}
onClick={handleColorClick}
accent="default"
text={t`Change Color`}
/>
</StyledColorMenuItem>
<MenuItem
LeftIcon={IconTrash}
onClick={handleDelete}
accent="danger"
text={t`Delete`}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>,
document.body,
)}
{showColorPicker && isDefined(colorMenuItemElement) && (
<DashboardBlockColorPicker
anchorElement={colorMenuItemElement}
block={block}
onClose={handleColorPickerClose}
onColorSelect={handleColorSelect}
/>
)}
</>
);
};
@@ -0,0 +1,77 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor';
const StyledColorIcon = styled.div<{
textColorValue: string;
backgroundColorValue: string;
}>`
background-color: ${({ backgroundColorValue }) => backgroundColorValue};
border-radius: ${({ theme }) => theme.border.radius.xs};
color: ${({ textColorValue }) => textColorValue};
font-size: 12px;
font-weight: ${({ theme }) => theme.font.weight.medium};
height: 16px;
line-height: 16px;
pointer-events: none;
text-align: center;
width: 16px;
`;
type DashboardColorIconProps = {
textColor?: BlockNoteColor;
backgroundColor?: BlockNoteColor;
};
export const DashboardColorIcon = ({
textColor,
backgroundColor,
}: DashboardColorIconProps) => {
const theme = useTheme();
const getThemeColorForTextColor = (color: BlockNoteColor): string => {
if (color === 'default') {
return 'inherit';
}
return theme.color[color] ?? 'inherit';
};
const getThemeColorForBackgroundColor = (color: BlockNoteColor): string => {
if (color === 'default') {
return 'transparent';
}
const backgroundColorMap: Record<
Exclude<BlockNoteColor, 'default'>,
string
> = {
gray: theme.color.gray3,
brown: theme.color.brown3,
red: theme.color.red3,
orange: theme.color.orange3,
yellow: theme.color.yellow3,
green: theme.color.green3,
blue: theme.color.blue3,
purple: theme.color.purple3,
pink: theme.color.pink3,
};
return backgroundColorMap[color];
};
return (
<StyledColorIcon
textColorValue={
textColor ? getThemeColorForTextColor(textColor) : 'inherit'
}
backgroundColorValue={
backgroundColor
? getThemeColorForBackgroundColor(backgroundColor)
: 'transparent'
}
>
A
</StyledColorIcon>
);
};
@@ -0,0 +1,103 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { IconCheck } from 'twenty-ui/display';
import { DashboardColorIcon } from '@/page-layout/widgets/standalone-rich-text/components/DashboardColorIcon';
import { BLOCKNOTE_COLOR_DISPLAY_NAMES } from '@/page-layout/widgets/standalone-rich-text/constants/BlockNoteColorDisplayNames';
import { BLOCKNOTE_COLORS } from '@/page-layout/widgets/standalone-rich-text/constants/BlockNoteColors';
import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useTheme } from '@emotion/react';
const StyledColorMenuItem = styled.div`
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.sm};
cursor: pointer;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
min-height: ${({ theme }) => theme.spacing(6)};
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;
const StyledColorName = styled.span`
color: ${({ theme }) => theme.font.color.primary};
flex: 1;
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledCheckIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
height: ${({ theme }) => theme.spacing(4)};
justify-content: center;
width: ${({ theme }) => theme.spacing(4)};
`;
type DashboardColorSelectionMenuProps = {
currentTextColor: string;
currentBackgroundColor: string;
onTextColorSelect: (color: BlockNoteColor) => void;
onBackgroundColorSelect: (color: BlockNoteColor) => void;
};
export const DashboardColorSelectionMenu = ({
currentTextColor,
currentBackgroundColor,
onTextColorSelect,
onBackgroundColorSelect,
}: DashboardColorSelectionMenuProps) => {
const { t } = useLingui();
const theme = useTheme();
return (
<DropdownContent>
<DropdownMenuItemsContainer hasMaxHeight>
<DropdownMenuSectionLabel label={t`Text Colors`} />
{BLOCKNOTE_COLORS.map((colorName) => (
<StyledColorMenuItem
key={`text-${colorName}`}
onClick={() => onTextColorSelect(colorName)}
>
<DashboardColorIcon textColor={colorName} />
<StyledColorName>
{BLOCKNOTE_COLOR_DISPLAY_NAMES[colorName]}
</StyledColorName>
{currentTextColor === colorName && (
<StyledCheckIcon>
<IconCheck size={theme.icon.size.sm} />
</StyledCheckIcon>
)}
</StyledColorMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuSectionLabel label={t`Background Colors`} />
{BLOCKNOTE_COLORS.map((colorName) => (
<StyledColorMenuItem
key={`bg-${colorName}`}
onClick={() => onBackgroundColorSelect(colorName)}
>
<DashboardColorIcon backgroundColor={colorName} />
<StyledColorName>
{BLOCKNOTE_COLOR_DISPLAY_NAMES[colorName]}
</StyledColorName>
{currentBackgroundColor === colorName && (
<StyledCheckIcon>
<IconCheck size={theme.icon.size.sm} />
</StyledCheckIcon>
)}
</StyledColorMenuItem>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,151 @@
import { useBlockNoteEditor, useUIPluginState } from '@blocknote/react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { autoUpdate, useFloating } from '@floating-ui/react';
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { IconGripVertical } from 'twenty-ui/display';
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { DashboardBlockDragHandleMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu';
import { isDefined } from 'twenty-shared/utils';
type DashboardEditorSideMenuProps = {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
boundaryElement?: HTMLElement | null;
};
const StyledSideMenuContainer = styled.div`
display: flex;
`;
const StyledDragHandleContainerWrapper = styled.div`
width: 20px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
`;
const StyledDragHandleContainer = styled.div`
align-items: center;
cursor: grab;
display: flex;
height: 24px;
justify-content: center;
width: 18px;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.light};
&:hover {
background: ${({ theme }) => theme.background.transparent.secondary};
backdrop-filter: ${({ theme }) => theme.blur.medium};
color: ${({ theme }) => theme.font.color.primary};
box-shadow: ${({ theme }) => theme.boxShadow.light},
${({ theme }) => theme.boxShadow.strong};
}
&:active {
cursor: grabbing;
}
`;
const StyledDivToCreateGap = styled.div`
width: ${({ theme }) => theme.spacing(2)};
`;
export const DashboardEditorSideMenu = ({
editor,
boundaryElement,
}: DashboardEditorSideMenuProps) => {
const blockNoteEditor = useBlockNoteEditor();
const theme = useTheme();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [dragHandleElement, setDragHandleElement] =
useState<HTMLDivElement | null>(null);
const state = useUIPluginState(
blockNoteEditor.sideMenu.onUpdate.bind(blockNoteEditor.sideMenu),
);
const virtualReference = isDefined(state?.referencePos)
? { getBoundingClientRect: () => state.referencePos }
: null;
const { refs, floatingStyles } = useFloating({
placement: 'left-start',
whileElementsMounted: autoUpdate,
elements: {
reference: virtualReference,
},
});
if (!state?.show || !isDefined(virtualReference)) {
return null;
}
const handleClick = () => {
blockNoteEditor.sideMenu.freezeMenu();
setIsMenuOpen(true);
};
const handleDragStart = (event: React.DragEvent) => {
blockNoteEditor.sideMenu.blockDragStart(
{
dataTransfer: event.dataTransfer,
clientY: event.clientY,
},
state.block,
);
};
const handleDragEnd = () => {
blockNoteEditor.sideMenu.blockDragEnd();
};
const handleCloseMenu = () => {
setIsMenuOpen(false);
blockNoteEditor.sideMenu.unfreezeMenu();
};
return (
<>
{createPortal(
<StyledSideMenuContainer
ref={refs.setFloating}
style={floatingStyles}
className="bn-ui-container"
>
<StyledDragHandleContainerWrapper>
<StyledDragHandleContainer
ref={setDragHandleElement}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onClick={handleClick}
>
<IconGripVertical
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
color={theme.font.color.light}
/>
</StyledDragHandleContainer>
</StyledDragHandleContainerWrapper>
<StyledDivToCreateGap />
</StyledSideMenuContainer>,
document.body,
)}
{isMenuOpen && (
<DashboardBlockDragHandleMenu
editor={editor}
block={state.block}
anchorElement={dragHandleElement}
boundaryElement={boundaryElement}
onClose={handleCloseMenu}
/>
)}
</>
);
};
@@ -0,0 +1,190 @@
import { type DefaultProps } from '@blocknote/core';
import {
BasicTextStyleButton,
BlockTypeSelect,
CreateLinkButton,
FormattingToolbar,
NestBlockButton,
TextAlignButton,
UnnestBlockButton,
useBlockNoteEditor,
useEditorContentOrSelectionChange,
useUIElementPositioning,
useUIPluginState,
} from '@blocknote/react';
import { useTheme } from '@emotion/react';
import { flip, FloatingPortal, offset, shift } from '@floating-ui/react';
import { useMemo, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { DashboardFormattingToolbarColorButton } from '@/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbarColorButton';
import { FORMATTING_TOOLBAR_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/FormattingToolbarFloatingConfig';
import styled from '@emotion/styled';
const StyledToolbarContainer = styled.div`
& .bn-formatting-toolbar .mantine-Button-root {
height: 24px;
min-height: 24px;
}
`;
const textAlignmentToPlacement = (
textAlignment: DefaultProps['textAlignment'],
) => {
switch (textAlignment) {
case 'left':
return 'top-start';
case 'center':
return 'top';
case 'right':
return 'top-end';
default:
return 'top-start';
}
};
type DashboardFormattingToolbarProps = {
boundaryElement?: HTMLElement | null;
};
// This is a copy of the BlockNote's FormattingToolbarController component with customizations.
export const DashboardFormattingToolbar = ({
boundaryElement,
}: DashboardFormattingToolbarProps) => {
// eslint-disable-next-line @nx/workspace-no-state-useref
const toolbarContainerRef = useRef<HTMLDivElement | null>(null);
const editor = useBlockNoteEditor();
const theme = useTheme();
const colorScheme = theme.name === 'light' ? 'light' : 'dark';
const [placement, setPlacement] = useState<'top-start' | 'top' | 'top-end'>(
() => {
const block = editor.getTextCursorPosition().block;
if (!('textAlignment' in block.props)) {
return 'top-start';
}
return textAlignmentToPlacement(
block.props.textAlignment as DefaultProps['textAlignment'],
);
},
);
useEditorContentOrSelectionChange(() => {
const block = editor.getTextCursorPosition().block;
if (!('textAlignment' in block.props)) {
setPlacement('top-start');
} else {
setPlacement(
textAlignmentToPlacement(
block.props.textAlignment as DefaultProps['textAlignment'],
),
);
}
}, editor);
const state = useUIPluginState(
editor.formattingToolbar.onUpdate.bind(editor.formattingToolbar),
);
const isNodeSelection =
editor.prosemirrorView?.state.selection.toJSON().type === 'node';
const shouldShow = (state?.show && !isNodeSelection) || false;
const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning(
shouldShow,
state?.referencePos || null,
3000,
{
placement,
middleware: [
offset(FORMATTING_TOOLBAR_FLOATING_CONFIG.offsetFromSelection),
shift({
boundary: boundaryElement ?? undefined,
padding: FORMATTING_TOOLBAR_FLOATING_CONFIG.boundaryPadding,
}),
flip({
boundary: boundaryElement ?? undefined,
}),
],
onOpenChange: (open) => {
if (!open) {
editor.formattingToolbar.closeMenu();
editor.focus();
}
},
},
);
const combinedRef = useMemo(
() => (node: HTMLDivElement | null) => {
toolbarContainerRef.current = node;
if (typeof ref === 'function') {
ref(node);
}
},
[ref],
);
if (!isMounted || !isDefined(state)) {
return null;
}
if (!shouldShow && isDefined(toolbarContainerRef.current)) {
return (
<FloatingPortal>
<StyledToolbarContainer
className="bn-container bn-mantine bn-ui-container"
data-color-scheme={colorScheme}
data-mantine-color-scheme={colorScheme}
ref={combinedRef}
style={style}
dangerouslySetInnerHTML={{
__html: toolbarContainerRef.current.innerHTML,
}}
/>
</FloatingPortal>
);
}
return (
<FloatingPortal>
<StyledToolbarContainer
className="bn-container bn-mantine bn-ui-container"
data-color-scheme={colorScheme}
data-mantine-color-scheme={colorScheme}
ref={combinedRef}
style={style}
// eslint-disable-next-line react/jsx-props-no-spreading
{...getFloatingProps()}
>
<FormattingToolbar>
<BlockTypeSelect key="blockTypeSelect" />
<BasicTextStyleButton basicTextStyle="bold" key="boldStyleButton" />
<BasicTextStyleButton
basicTextStyle="italic"
key="italicStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="underline"
key="underlineStyleButton"
/>
<BasicTextStyleButton
basicTextStyle="strike"
key="strikeStyleButton"
/>
<TextAlignButton textAlignment="left" key="textAlignLeftButton" />
<TextAlignButton textAlignment="center" key="textAlignCenterButton" />
<TextAlignButton textAlignment="right" key="textAlignRightButton" />
<DashboardFormattingToolbarColorButton key="colorStyleButton" />
<NestBlockButton key="nestBlockButton" />
<UnnestBlockButton key="unnestBlockButton" />
<CreateLinkButton key="createLinkButton" />
</FormattingToolbar>
</StyledToolbarContainer>
</FloatingPortal>
);
};
@@ -0,0 +1,152 @@
import {
useBlockNoteEditor,
useEditorContentOrSelectionChange,
} from '@blocknote/react';
import styled from '@emotion/styled';
import {
autoUpdate,
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react';
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { DashboardColorIcon } from '@/page-layout/widgets/standalone-rich-text/components/DashboardColorIcon';
import { DashboardColorSelectionMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardColorSelectionMenu';
import { COLOR_DROPDOWN_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/ColorDropdownFloatingConfig';
import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor';
import { extractColorFromProps } from '@/page-layout/widgets/standalone-rich-text/utils/extractColorFromProps';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from 'twenty-shared/utils';
const StyledColorButton = styled.button`
align-items: center;
background: transparent;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
cursor: pointer;
display: flex;
height: 24px;
justify-content: center;
padding: ${({ theme }) => theme.spacing(1)};
width: 24px;
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
`;
const COLOR_BUTTON_CLICK_OUTSIDE_ID = 'color-button-click-outside';
export const DashboardFormattingToolbarColorButton = () => {
const editor = useBlockNoteEditor();
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [currentTextColor, setCurrentTextColor] =
useState<BlockNoteColor>('default');
const [currentBackgroundColor, setCurrentBackgroundColor] =
useState<BlockNoteColor>('default');
useEditorContentOrSelectionChange(() => {
const activeStyles = editor.getActiveStyles();
setCurrentTextColor(extractColorFromProps(activeStyles, 'text'));
setCurrentBackgroundColor(
extractColorFromProps(activeStyles, 'background'),
);
}, editor);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
offset(COLOR_DROPDOWN_FLOATING_CONFIG.offsetFromButton),
flip(),
shift({ padding: COLOR_DROPDOWN_FLOATING_CONFIG.boundaryPadding }),
],
});
useListenClickOutside({
refs: [menuRef],
excludedClickOutsideIds: [COLOR_BUTTON_CLICK_OUTSIDE_ID],
callback: () => setIsOpen(false),
listenerId: 'custom-color-style-button',
});
const handleButtonClick = () => {
if (isDefined(buttonRef.current)) {
refs.setReference(buttonRef.current);
}
setIsOpen(!isOpen);
};
const applyTextColor = (color: string) => {
if (color === 'default') {
editor.removeStyles({ textColor: color });
} else {
editor.addStyles({ textColor: color });
}
setTimeout(() => editor.focus());
};
const applyBackgroundColor = (color: string) => {
if (color === 'default') {
editor.removeStyles({ backgroundColor: color });
} else {
editor.addStyles({ backgroundColor: color });
}
setTimeout(() => editor.focus());
};
const handleTextColorSelect = (color: BlockNoteColor) => {
applyTextColor(color);
setIsOpen(false);
};
const handleBackgroundColorSelect = (color: BlockNoteColor) => {
applyBackgroundColor(color);
setIsOpen(false);
};
return (
<>
<StyledColorButton
ref={buttonRef}
onClick={handleButtonClick}
data-click-outside-id={COLOR_BUTTON_CLICK_OUTSIDE_ID}
>
<DashboardColorIcon
textColor={currentTextColor}
backgroundColor={currentBackgroundColor}
/>
</StyledColorButton>
{isOpen &&
createPortal(
<OverlayContainer
ref={(node) => {
refs.setFloating(node);
(
menuRef as React.MutableRefObject<HTMLDivElement | null>
).current = node;
}}
style={floatingStyles}
className="bn-ui-container"
data-click-outside-id={COLOR_BUTTON_CLICK_OUTSIDE_ID}
>
<DashboardColorSelectionMenu
currentTextColor={currentTextColor}
currentBackgroundColor={currentBackgroundColor}
onTextColorSelect={handleTextColorSelect}
onBackgroundColorSelect={handleBackgroundColorSelect}
/>
</OverlayContainer>,
document.body,
)}
</>
);
};
@@ -0,0 +1,198 @@
import { filterSuggestionItems } from '@blocknote/core';
import { BlockNoteView } from '@blocknote/mantine';
import { SuggestionMenuController } from '@blocknote/react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type ClipboardEvent } from 'react';
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { getSlashMenu } from '@/activities/blocks/utils/getSlashMenu';
import { DashboardEditorSideMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu';
import { DashboardFormattingToolbar } from '@/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbar';
import {
CustomSlashMenu,
type SuggestionItem,
} from '@/ui/input/editor/components/CustomSlashMenu';
interface DashboardsBlockEditorProps {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
onFocus?: () => void;
onBlur?: () => void;
onPaste?: (event: ClipboardEvent) => void;
onChange?: () => void;
readonly?: boolean;
boundaryElement?: HTMLElement | null;
}
// TODO: Refactor these BlockNote CSS overrides - some may be dead code now that we have custom components
// (DashboardBlockDragHandleMenu, DashboardEditorSideMenu, DashboardColorSelectionMenu).
// Test removing each selector and move necessary styles to appropriate components.
// eslint-disable-next-line @nx/workspace-no-hardcoded-colors
const StyledEditor = styled.div`
width: 100%;
& .editor {
background: transparent;
font-size: 13px;
color: ${({ theme }) => theme.font.color.primary};
}
& .editor [class^='_inlineContent']:before {
color: ${({ theme }) => theme.font.color.tertiary};
font-style: normal !important;
}
& .editor .bn-inline-content:has(> .ProseMirror-trailingBreak):before {
font-style: normal;
}
& .mantine-ActionIcon-icon {
height: 20px;
width: 20px;
background: transparent;
}
& .bn-container .bn-drag-handle {
width: 20px;
height: 20px;
}
& .bn-block-outer {
line-height: 1.4;
}
& .bn-block-content[data-content-type='checkListItem'] > div > div {
display: flex;
align-items: center;
}
& .bn-drag-handle-menu {
background: ${({ theme }) => theme.background.transparent.secondary};
backdrop-filter: ${({ theme }) => theme.blur.medium};
box-shadow:
0px 2px 4px rgba(0, 0, 0, 0.04),
2px 4px 16px rgba(0, 0, 0, 0.12);
min-width: 160px;
min-height: 96px;
padding: 4px;
border-radius: 8px;
border: 1px solid ${({ theme }) => theme.border.color.medium};
}
& .bn-editor {
padding-inline: 0px;
}
& .bn-inline-content {
width: 100%;
}
& .bn-container .bn-suggestion-menu-item:hover {
background-color: blue;
}
& .bn-suggestion-menu {
padding: 4px;
border-radius: 8px;
border: 1px solid ${({ theme }) => theme.border.color.medium};
background: ${({ theme }) => theme.background.transparent.secondary};
backdrop-filter: ${({ theme }) => theme.blur.medium};
}
& .mantine-Menu-item {
background-color: transparent;
min-width: 152px;
min-height: 32px;
font-style: normal;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
color: ${({ theme }) => theme.font.color.secondary};
}
& .mantine-ActionIcon-root:hover {
box-shadow:
0px 0px 4px rgba(0, 0, 0, 0.08),
0px 2px 4px rgba(0, 0, 0, 0.04);
background: ${({ theme }) => theme.background.transparent.primary};
backdrop-filter: blur(20px);
border: 1px solid ${({ theme }) => theme.border.color.light};
}
& .bn-side-menu .mantine-UnstyledButton-root:not(.mantine-Menu-item) svg {
height: 16px;
width: 16px;
}
& .bn-mantine .bn-side-menu > [draggable='true'] {
margin-bottom: 5px;
}
& .bn-color-picker-dropdown {
margin-left: 8px;
}
& .bn-inline-content a {
color: ${({ theme }) => theme.color.blue};
}
& .bn-inline-content code {
font-family: monospace;
color: ${({ theme }) => theme.font.color.danger};
padding: 2px 4px;
border-radius: 4px;
border: 1px solid ${({ theme }) => theme.font.color.extraLight};
font-size: 0.9rem;
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
export const DashboardsBlockEditor = ({
editor,
onFocus,
onBlur,
onChange,
onPaste,
readonly,
boundaryElement,
}: DashboardsBlockEditorProps) => {
const theme = useTheme();
const blockNoteTheme = theme.name === 'light' ? 'light' : 'dark';
const handleFocus = () => {
onFocus?.();
};
const handleBlur = () => {
onBlur?.();
};
const handleChange = () => {
onChange?.();
};
const handlePaste = (event: ClipboardEvent) => {
onPaste?.(event);
};
return (
<StyledEditor>
<BlockNoteView
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
onChange={handleChange}
editor={editor}
theme={blockNoteTheme}
slashMenu={false}
sideMenu={false}
formattingToolbar={false}
editable={!readonly}
>
<DashboardFormattingToolbar boundaryElement={boundaryElement} />
<DashboardEditorSideMenu
editor={editor}
boundaryElement={boundaryElement}
/>
<SuggestionMenuController
triggerCharacter="/"
getItems={async (query) => {
const items = getSlashMenu(editor);
return filterSuggestionItems<SuggestionItem>(items, query);
}}
suggestionMenuComponent={CustomSlashMenu}
/>
</BlockNoteView>
</StyledEditor>
);
};
@@ -0,0 +1,187 @@
import { useCallback, useMemo, useRef } from 'react';
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { type Attachment } from '@/activities/files/types/Attachment';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { DashboardsBlockEditor } from '@/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor';
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig';
import { useAttachmentSync } from '@/ui/input/editor/hooks/useAttachmentSync';
import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote';
import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import '@blocknote/react/style.css';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import {
PageLayoutType,
type StandaloneRichTextConfiguration,
} from '~/generated/graphql';
const StyledContainer = styled.div<{ isPageLayoutInEditMode?: boolean }>`
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
padding-left: ${({ theme, isPageLayoutInEditMode }) =>
isPageLayoutInEditMode ? theme.spacing(5) : 0};
`;
type StandaloneRichTextWidgetProps = {
widget: PageLayoutWidget;
};
export const StandaloneRichTextWidget = ({
widget,
}: StandaloneRichTextWidgetProps) => {
const containerElementRef = useRef<HTMLDivElement>(null);
const isPageLayoutInEditMode = useRecoilComponentValue(
isPageLayoutInEditModeComponentState,
);
const editingWidgetId = useRecoilComponentValue(
pageLayoutEditingWidgetIdComponentState,
);
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget();
const { targetRecordIdentifier, layoutType } = useLayoutRenderingContext();
const { uploadAttachmentFile } = useUploadAttachmentFile();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const isDashboard = layoutType === PageLayoutType.DASHBOARD;
const dashboardId = isDashboard ? targetRecordIdentifier?.id : undefined;
const configuration = widget.configuration as
| StandaloneRichTextConfiguration
| undefined;
const currentBody = configuration?.body?.blocknote ?? '';
const { records: attachments } = useFindManyRecords<Attachment>({
objectNameSingular: CoreObjectNameSingular.Attachment,
filter: isDefined(dashboardId)
? { dashboardId: { eq: dashboardId } }
: undefined,
skip: !isDefined(dashboardId),
});
const { syncAttachments } = useAttachmentSync(attachments);
const handleUploadAttachment = async (file: File) => {
if (!isDefined(dashboardId)) return { attachmentAbsoluteURL: '' };
return await uploadAttachmentFile(file, {
id: dashboardId,
targetObjectNameSingular: CoreObjectNameSingular.Dashboard,
});
};
const handleEditorBuiltInUploadFile = async (file: File) => {
const { attachmentAbsoluteURL } = await handleUploadAttachment(file);
return attachmentAbsoluteURL;
};
const initialContent = useMemo(() => {
if (isDefined(configuration) && 'body' in configuration) {
return parseInitialBlocknote(configuration.body?.blocknote);
}
return undefined;
}, [configuration]);
const editor = useCreateBlockNote({
initialContent,
domAttributes: { editor: { class: 'editor' } },
schema: BLOCK_SCHEMA,
uploadFile: handleEditorBuiltInUploadFile,
sideMenuDetection: 'editor',
});
const handlePersistBody = useDebouncedCallback((blocknote: string) => {
updatePageLayoutWidget(widget.id, {
configuration: {
body: {
blocknote,
markdown: null,
},
},
});
}, 300);
const handleAttachmentSync = useDebouncedCallback(
async (newStringifiedBody: string, previousBody: string) => {
await syncAttachments(newStringifiedBody, previousBody);
},
500,
);
const handleEditorChange = () => {
const newStringifiedBody = JSON.stringify(editor.document) ?? '';
const preparedBody = prepareBodyWithSignedUrls(newStringifiedBody);
handlePersistBody(preparedBody);
handleAttachmentSync(newStringifiedBody, currentBody);
};
const isThisWidgetBeingEdited = editingWidgetId === widget.id;
const isEditable = isPageLayoutInEditMode && isThisWidgetBeingEdited;
const handleBlockEditorFocus = useCallback(() => {
pushFocusItemToFocusStack({
component: {
instanceId: widget.id,
type: FocusComponentType.STANDALONE_RICH_TEXT_WIDGET,
},
focusId: widget.id,
globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG,
});
}, [pushFocusItemToFocusStack, widget.id]);
const handleBlockEditorBlur = useCallback(() => {
removeFocusItemFromFocusStackById({
focusId: widget.id,
});
}, [removeFocusItemFromFocusStackById, widget.id]);
if (!isDefined(dashboardId)) {
return null;
}
return (
<StyledContainer
ref={containerElementRef}
isPageLayoutInEditMode={isPageLayoutInEditMode}
>
<ScrollWrapper
componentInstanceId={`scroll-wrapper-rich-text-widget-${widget.id}`}
>
<DashboardsBlockEditor
onFocus={handleBlockEditorFocus}
onBlur={handleBlockEditorBlur}
onChange={handleEditorChange}
editor={editor}
readonly={!isEditable}
boundaryElement={containerElementRef.current}
/>
</ScrollWrapper>
</StyledContainer>
);
};
@@ -0,0 +1,14 @@
import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor';
export const BLOCKNOTE_COLOR_DISPLAY_NAMES: Record<BlockNoteColor, string> = {
default: 'Default',
gray: 'Gray',
brown: 'Brown',
red: 'Red',
orange: 'Orange',
yellow: 'Yellow',
green: 'Green',
blue: 'Blue',
purple: 'Purple',
pink: 'Pink',
};
@@ -0,0 +1,12 @@
export const BLOCKNOTE_COLORS = [
'default',
'gray',
'brown',
'red',
'orange',
'yellow',
'green',
'blue',
'purple',
'pink',
] as const;
@@ -0,0 +1,4 @@
export const COLOR_DROPDOWN_FLOATING_CONFIG = {
offsetFromButton: 8,
boundaryPadding: 8,
};
@@ -0,0 +1,4 @@
export const COLOR_PICKER_FLOATING_CONFIG = {
offsetFromMenuItem: 4,
boundaryPadding: 8,
};
@@ -0,0 +1,3 @@
export const DRAG_HANDLE_MENU_FLOATING_CONFIG = {
offsetFromAnchor: 4,
};
@@ -0,0 +1,4 @@
export const FORMATTING_TOOLBAR_FLOATING_CONFIG = {
offsetFromSelection: 10,
boundaryPadding: 8,
};
@@ -0,0 +1,3 @@
import { type BLOCKNOTE_COLORS } from '@/page-layout/widgets/standalone-rich-text/constants/BlockNoteColors';
export type BlockNoteColor = (typeof BLOCKNOTE_COLORS)[number];
@@ -0,0 +1,65 @@
import { extractColorFromProps } from '../extractColorFromProps';
describe('extractColorFromProps', () => {
describe('text color extraction', () => {
it('should return textColor when present and is a string', () => {
const props = { textColor: 'red' };
expect(extractColorFromProps(props, 'text')).toBe('red');
});
it('should return default when textColor is not a string', () => {
const props = { textColor: 123 };
expect(extractColorFromProps(props, 'text')).toBe('default');
});
it('should return default when textColor is missing', () => {
const props = {};
expect(extractColorFromProps(props, 'text')).toBe('default');
});
it('should return default when textColor is null', () => {
const props = { textColor: null };
expect(extractColorFromProps(props, 'text')).toBe('default');
});
it('should return default when textColor is undefined', () => {
const props = { textColor: undefined };
expect(extractColorFromProps(props, 'text')).toBe('default');
});
});
describe('background color extraction', () => {
it('should return backgroundColor when present and is a string', () => {
const props = { backgroundColor: 'blue' };
expect(extractColorFromProps(props, 'background')).toBe('blue');
});
it('should return default when backgroundColor is not a string', () => {
const props = { backgroundColor: { value: 'blue' } };
expect(extractColorFromProps(props, 'background')).toBe('default');
});
it('should return default when backgroundColor is missing', () => {
const props = {};
expect(extractColorFromProps(props, 'background')).toBe('default');
});
it('should return default when backgroundColor is null', () => {
const props = { backgroundColor: null };
expect(extractColorFromProps(props, 'background')).toBe('default');
});
});
describe('edge cases', () => {
it('should not confuse textColor and backgroundColor', () => {
const props = { textColor: 'red', backgroundColor: 'blue' };
expect(extractColorFromProps(props, 'text')).toBe('red');
expect(extractColorFromProps(props, 'background')).toBe('blue');
});
it('should handle empty string as valid color', () => {
const props = { textColor: '' };
expect(extractColorFromProps(props, 'text')).toBe('');
});
});
});
@@ -0,0 +1,12 @@
import { type BlockNoteColor } from '@/page-layout/widgets/standalone-rich-text/types/BlockNoteColor';
import { isString } from '@sniptt/guards';
export const extractColorFromProps = (
props: Record<string, unknown>,
colorType: 'text' | 'background',
): BlockNoteColor => {
const propertyName = colorType === 'text' ? 'textColor' : 'backgroundColor';
return isString(props[propertyName])
? (props[propertyName] as BlockNoteColor)
: 'default';
};
@@ -10,6 +10,7 @@ const StyledWidgetCard = styled.div<{
isEditing: boolean;
isDragging: boolean;
isResizing: boolean;
headerLess?: boolean;
}>`
box-sizing: border-box;
display: flex;
@@ -26,13 +27,14 @@ const StyledWidgetCard = styled.div<{
isDragging,
isResizing,
onClick,
headerLess,
}) => {
if (variant === 'dashboard' && !isEditable) {
return css`
background: ${theme.background.secondary};
border: 1px solid ${theme.border.color.light};
border-radius: ${theme.border.radius.md};
padding: ${theme.spacing(2)};
padding: ${headerLess ? 0 : theme.spacing(2)};
gap: ${theme.spacing(2)};
`;
}
@@ -42,7 +44,7 @@ const StyledWidgetCard = styled.div<{
background: ${theme.background.secondary};
border: 1px solid ${theme.border.color.light};
border-radius: ${theme.border.radius.md};
padding: ${theme.spacing(2)};
padding: ${headerLess ? 0 : theme.spacing(2)};
gap: ${theme.spacing(2)};
${!isDragging &&
@@ -6,7 +6,8 @@ const StyledWidgetCardContent = styled.div<{ variant: WidgetCardVariant }>`
align-items: center;
display: flex;
height: 100%;
width: 100%;
flex: 1;
overflow: hidden;
justify-content: center;
box-sizing: border-box;
@@ -0,0 +1,4 @@
export const BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG = {
enableGlobalHotkeysConflictingWithKeyboard: false,
enableGlobalHotkeysWithModifiers: true,
};
@@ -0,0 +1,89 @@
import { type Attachment } from '@/activities/files/types/Attachment';
import { filterAttachmentsToRestore } from '@/activities/utils/filterAttachmentsToRestore';
import { getActivityAttachmentIdsAndNameToUpdate } from '@/activities/utils/getActivityAttachmentIdsAndNameToUpdate';
import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete';
import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
export const useAttachmentSync = (attachments: Attachment[]) => {
const { deleteManyRecords: deleteAttachments } = useDeleteManyRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { restoreManyRecords: restoreAttachments } = useRestoreManyRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { fetchAllRecords: findSoftDeletedAttachments } =
useLazyFetchAllRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
filter: {
deletedAt: {
is: 'NOT_NULL',
},
},
});
const { updateOneRecord: updateOneAttachment } = useUpdateOneRecord({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const syncAttachments = async (
newBody: string,
previousBody?: string | null,
) => {
if (!newBody) return;
const previousBodyOrEmptyArray = previousBody?.trim() ? previousBody : '[]';
const attachmentIdsToDelete = getActivityAttachmentIdsToDelete(
newBody,
attachments,
previousBodyOrEmptyArray,
);
if (attachmentIdsToDelete.length > 0) {
await deleteAttachments({
recordIdsToDelete: attachmentIdsToDelete,
});
}
const attachmentPathsToRestore = getActivityAttachmentPathsToRestore(
newBody,
attachments,
);
if (attachmentPathsToRestore.length > 0) {
const softDeletedAttachments =
(await findSoftDeletedAttachments()) as Attachment[];
const attachmentIdsToRestore = filterAttachmentsToRestore(
attachmentPathsToRestore,
softDeletedAttachments ?? [],
);
await restoreAttachments({
idsToRestore: attachmentIdsToRestore,
});
}
const attachmentsToUpdate = getActivityAttachmentIdsAndNameToUpdate(
newBody,
attachments,
);
for (const attachmentToUpdate of attachmentsToUpdate) {
if (!attachmentToUpdate.id || !attachmentToUpdate.name) continue;
await updateOneAttachment({
idToUpdate: attachmentToUpdate.id,
updateOneRecordInput: { name: attachmentToUpdate.name },
});
}
};
return { syncAttachments };
};
@@ -0,0 +1,47 @@
import { parseInitialBlocknote } from '../parseInitialBlocknote';
describe('parseInitialBlocknote', () => {
it('should parse valid JSON array string', () => {
const input = JSON.stringify([{ type: 'paragraph', content: 'test' }]);
const result = parseInitialBlocknote(input);
expect(result).toEqual([{ type: 'paragraph', content: 'test' }]);
});
it('should return undefined for empty string', () => {
expect(parseInitialBlocknote('')).toBeUndefined();
});
it('should return undefined for null', () => {
expect(parseInitialBlocknote(null)).toBeUndefined();
});
it('should return undefined for undefined', () => {
expect(parseInitialBlocknote(undefined)).toBeUndefined();
});
it('should return undefined for empty object string "{}"', () => {
expect(parseInitialBlocknote('{}')).toBeUndefined();
});
it('should return undefined for invalid JSON', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
expect(parseInitialBlocknote('invalid json')).toBeUndefined();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should return undefined for empty array', () => {
expect(parseInitialBlocknote('[]')).toBeUndefined();
});
it('should return undefined for non-array JSON', () => {
expect(parseInitialBlocknote('{"key": "value"}')).toBeUndefined();
});
it('should use custom log context when parsing fails', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
parseInitialBlocknote('invalid', 'Custom context');
expect(consoleSpy).toHaveBeenCalledWith('Custom context');
consoleSpy.mockRestore();
});
});
@@ -0,0 +1,46 @@
import { prepareBodyWithSignedUrls } from '../prepareBodyWithSignedUrls';
describe('prepareBodyWithSignedUrls', () => {
it('should return empty string as-is', () => {
expect(prepareBodyWithSignedUrls('')).toBe('');
});
it('should parse and re-stringify blocks', () => {
const input = JSON.stringify([{ type: 'paragraph', content: 'text' }]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result).toEqual([{ type: 'paragraph', content: 'text' }]);
});
it('should pass through non-image blocks unchanged', () => {
const blocks = [
{ type: 'paragraph', content: 'text' },
{ type: 'heading', content: 'title' },
{ type: 'bulletListItem', content: 'item' },
];
const result = JSON.parse(
prepareBodyWithSignedUrls(JSON.stringify(blocks)),
);
expect(result).toEqual(blocks);
});
it('should skip image blocks without props', () => {
const input = JSON.stringify([{ type: 'image' }]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result).toEqual([{ type: 'image' }]);
});
it('should skip image blocks without url in props', () => {
const input = JSON.stringify([{ type: 'image', props: { alt: 'test' } }]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result).toEqual([{ type: 'image', props: { alt: 'test' } }]);
});
it('should process image blocks with valid URLs', () => {
const input = JSON.stringify([
{ type: 'image', props: { url: 'https://example.com/image.png' } },
]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result[0].type).toBe('image');
expect(result[0].props.url).toContain('example.com');
});
});
@@ -0,0 +1,29 @@
import type { PartialBlock } from '@blocknote/core';
import { isArray, isNonEmptyString } from '@sniptt/guards';
export const parseInitialBlocknote = (
blocknote?: string | null,
logContext?: string,
): PartialBlock[] | undefined => {
if (isNonEmptyString(blocknote) && blocknote !== '{}') {
let parsedBody: PartialBlock[] | undefined = undefined;
// TODO: Remove this once we have removed the old rich text
try {
parsedBody = JSON.parse(blocknote);
} catch {
// eslint-disable-next-line no-console
console.warn(logContext ?? `Failed to parse blocknote body`);
// eslint-disable-next-line no-console
console.warn(blocknote);
}
if (!isArray(parsedBody) || parsedBody.length === 0) {
return undefined;
}
return parsedBody;
}
return undefined;
};
@@ -0,0 +1,30 @@
import type { PartialBlock } from '@blocknote/core';
// TODO: This function is extracted but its not doing what it is supposed to do. It is not signing the urls. It is just parsing the image urls.
// tracking issue - https://github.com/twentyhq/twenty/issues/8351
export const prepareBodyWithSignedUrls = (
newStringifiedBody: string,
): string => {
if (!newStringifiedBody) return newStringifiedBody;
const body: PartialBlock[] = JSON.parse(newStringifiedBody);
const bodyWithSignedPayload = body.map((block) => {
if (block.type !== 'image' || !block.props?.url) {
return block;
}
const imageUrl = block.props.url;
const parsedImageUrl = new URL(imageUrl);
return {
...block,
props: {
...block.props,
url: parsedImageUrl.toString(),
},
};
});
return JSON.stringify(bodyWithSignedPayload);
};
@@ -12,6 +12,7 @@ export enum FocusComponentType {
FORM_FIELD_INPUT = 'form-field-input',
RECORD_BOARD_CARD = 'record-board-card',
ACTIVITY_RICH_TEXT_EDITOR = 'activity-rich-text-editor',
STANDALONE_RICH_TEXT_WIDGET = 'standalone-rich-text-widget',
KEYBOARD_SHORTCUT_MENU = 'keyboard-shortcut-menu',
DIALOG = 'dialog',
}
@@ -3,6 +3,7 @@ export {
IconNumber123 as Icon123,
IconAlertCircle,
IconAlertTriangle,
IconAlignBoxLeftTop,
IconAlignCenter,
IconAlignLeft,
IconAlignRight,
+1
View File
@@ -68,6 +68,7 @@ export {
Icon123,
IconAlertCircle,
IconAlertTriangle,
IconAlignBoxLeftTop,
IconAlignCenter,
IconAlignLeft,
IconAlignRight,