[Dashboards] Rich text editor frontend (#16437)
closes https://github.com/twentyhq/core-team-issues/issues/1894
This commit is contained in:
+16
-131
@@ -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],
|
||||
|
||||
-1
@@ -157,7 +157,6 @@ export const usePageLayoutHeaderInfo = ({
|
||||
widgetInEditMode: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
+5
-5
@@ -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();
|
||||
|
||||
|
||||
+40
-2
@@ -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 },
|
||||
},
|
||||
};
|
||||
|
||||
+129
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
+51
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+45
@@ -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,
|
||||
|
||||
+30
@@ -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,
|
||||
};
|
||||
};
|
||||
+4
@@ -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}
|
||||
|
||||
+99
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+174
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+77
@@ -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>
|
||||
);
|
||||
};
|
||||
+103
@@ -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>
|
||||
);
|
||||
};
|
||||
+151
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+190
@@ -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>
|
||||
);
|
||||
};
|
||||
+152
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+198
@@ -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>
|
||||
);
|
||||
};
|
||||
+187
@@ -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>
|
||||
);
|
||||
};
|
||||
+14
@@ -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',
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
export const BLOCKNOTE_COLORS = [
|
||||
'default',
|
||||
'gray',
|
||||
'brown',
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
'green',
|
||||
'blue',
|
||||
'purple',
|
||||
'pink',
|
||||
] as const;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const COLOR_DROPDOWN_FLOATING_CONFIG = {
|
||||
offsetFromButton: 8,
|
||||
boundaryPadding: 8,
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const COLOR_PICKER_FLOATING_CONFIG = {
|
||||
offsetFromMenuItem: 4,
|
||||
boundaryPadding: 8,
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export const DRAG_HANDLE_MENU_FLOATING_CONFIG = {
|
||||
offsetFromAnchor: 4,
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const FORMATTING_TOOLBAR_FLOATING_CONFIG = {
|
||||
offsetFromSelection: 10,
|
||||
boundaryPadding: 8,
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { type BLOCKNOTE_COLORS } from '@/page-layout/widgets/standalone-rich-text/constants/BlockNoteColors';
|
||||
|
||||
export type BlockNoteColor = (typeof BLOCKNOTE_COLORS)[number];
|
||||
+65
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
+12
@@ -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';
|
||||
};
|
||||
+4
-2
@@ -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 &&
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
|
||||
+4
@@ -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 };
|
||||
};
|
||||
+47
@@ -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();
|
||||
});
|
||||
});
|
||||
+46
@@ -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,
|
||||
|
||||
@@ -68,6 +68,7 @@ export {
|
||||
Icon123,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconAlignBoxLeftTop,
|
||||
IconAlignCenter,
|
||||
IconAlignLeft,
|
||||
IconAlignRight,
|
||||
|
||||
Reference in New Issue
Block a user