Feat/email composer improvements (#23188)

- Move composer to dedicated page
- Add test email option
- Auto saved as draft can be revisited from `objects/messageCampaigns`
later
- Campaign stats component



https://github.com/user-attachments/assets/9e523116-e79b-496d-9c9d-3887e0c9213f



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23188?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-07-28 16:43:00 +05:30
committed by GitHub
parent 30bbf4149a
commit 1e58c3073c
119 changed files with 5345 additions and 2606 deletions
@@ -1,8 +1,9 @@
import { AdvancedTextEditor } from '@/advanced-text-editor/components/AdvancedTextEditor';
import {
type AdvancedTextEditorContentType,
useAdvancedTextEditor,
} from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
ADVANCED_TEXT_EDITOR_PRESETS,
type AdvancedTextEditorPresetName,
} from '@/advanced-text-editor/constants/AdvancedTextEditorPresets';
import { useAdvancedTextEditor } from '@/advanced-text-editor/hooks/useAdvancedTextEditor';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { InputHint } from '@/ui/input/components/InputHint';
@@ -21,8 +22,20 @@ import { LightIconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledAdvancedTextFieldContainerWrapper = styled.div`
const StyledAdvancedTextFieldContainerWrapper = styled.div<{
hasFieldChrome: boolean;
}>`
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 0;
/* FormFieldInputContainer is shared by every form field and does not carry a
height, so the document chrome stretches its one child here instead. */
& > * {
flex-grow: ${({ hasFieldChrome }) => (hasFieldChrome ? 0 : 1)};
min-height: 0;
}
`;
const StyledAdvancedTextFieldFieldContainer = styled.div`
@@ -33,10 +46,17 @@ const StyledAdvancedTextFieldFieldContainer = styled.div`
position: relative;
`;
const StyledAdvancedTextFieldInnerContainer = styled.div`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
const StyledAdvancedTextFieldInnerContainer = styled.div<{
hasFieldChrome: boolean;
}>`
background-color: ${({ hasFieldChrome }) =>
hasFieldChrome ? themeCssVariables.background.transparent.lighter : 'none'};
border: ${({ hasFieldChrome }) =>
hasFieldChrome
? `1px solid ${themeCssVariables.border.color.medium}`
: 'none'};
border-radius: ${({ hasFieldChrome }) =>
hasFieldChrome ? themeCssVariables.border.radius.md : '0'};
box-sizing: border-box;
display: flex;
@@ -73,17 +93,17 @@ type FormAdvancedTextFieldInputProps = {
error?: string;
hint?: string;
defaultValue: string | undefined | null;
onChange: (value: string) => void;
onChange?: (value: string) => void;
readonly?: boolean;
placeholder?: string;
VariablePicker?: VariablePickerComponent;
onImageUpload?: (file: File) => Promise<string>;
onImageUploadError?: (error: Error, file: File) => void;
preset: AdvancedTextEditorPresetName;
// Escape hatch for surfaces that share a preset but need their own height.
minHeight?: number;
enableFullScreen?: boolean;
fullScreenBreadcrumbs?: BreadcrumbProps['links'];
minHeight: number;
maxWidth: number;
contentType?: AdvancedTextEditorContentType;
};
export const FormAdvancedTextFieldInput = ({
@@ -97,12 +117,21 @@ export const FormAdvancedTextFieldInput = ({
VariablePicker,
onImageUpload,
onImageUploadError,
enableFullScreen = true,
fullScreenBreadcrumbs,
preset,
minHeight,
maxWidth,
contentType = 'json',
enableFullScreen,
fullScreenBreadcrumbs,
}: FormAdvancedTextFieldInputProps) => {
const {
contentType,
chrome,
minHeight: presetMinHeight,
enableFullScreen: presetEnableFullScreen,
} = ADVANCED_TEXT_EDITOR_PRESETS[preset];
const editorMinHeight = minHeight ?? presetMinHeight;
const isFullScreenEnabled = enableFullScreen ?? presetEnableFullScreen;
const instanceId = useId();
const isMobile = useIsMobile();
const [isFullScreen, setIsFullScreen] = useState(false);
@@ -120,10 +149,10 @@ export const FormAdvancedTextFieldInput = ({
contentType,
onUpdate: (editor) => {
if (contentType === 'markdown' || contentType === 'html') {
onChange(editor.getHTML());
onChange?.(editor.getHTML());
} else {
const jsonContent = editor.getJSON();
onChange(JSON.stringify(jsonContent));
onChange?.(JSON.stringify(jsonContent));
}
},
onFocus: () => {
@@ -180,15 +209,14 @@ export const FormAdvancedTextFieldInput = ({
hasClosePageButton: !isMobile,
});
const fullScreenOverlay = enableFullScreen
const fullScreenOverlay = isFullScreenEnabled
? renderFullScreenModal(
<div data-globally-prevent-click-outside="true">
<StyledFullScreenEditorContainer>
<AdvancedTextEditor
editor={editor}
readonly={readonly}
minHeight={minHeight}
maxWidth={maxWidth}
minHeight={editorMinHeight}
/>
</StyledFullScreenEditorContainer>
</div>,
@@ -202,22 +230,25 @@ export const FormAdvancedTextFieldInput = ({
return (
<>
<StyledAdvancedTextFieldContainerWrapper>
<StyledAdvancedTextFieldContainerWrapper
hasFieldChrome={chrome === 'field'}
>
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledAdvancedTextFieldFieldContainer>
<StyledAdvancedTextFieldInnerContainer>
<StyledAdvancedTextFieldInnerContainer
hasFieldChrome={chrome === 'field'}
>
{!isFullScreen && (
<AdvancedTextEditor
editor={editor}
readonly={readonly}
minHeight={minHeight}
maxWidth={maxWidth}
minHeight={editorMinHeight}
/>
)}
{enableFullScreen && (
{isFullScreenEnabled && (
<StyledEditorActionButtonContainer
hasVariablePicker={isDefined(VariablePicker) && !readonly}
>
@@ -14,10 +14,6 @@ type FormRichTextFieldInputProps = {
VariablePicker?: VariablePickerComponent;
};
const RICH_TEXT_EDITOR_MIN_HEIGHT = 340;
const RICH_TEXT_EDITOR_MAX_WIDTH = 600;
const mapTipTapToBlockNote = (tiptapJson: string): string => {
try {
const json = JSON.parse(tiptapJson);
@@ -57,8 +53,7 @@ export const FormRichTextFieldInput = ({
onChange={handleChange}
readonly={readonly}
VariablePicker={VariablePicker}
minHeight={RICH_TEXT_EDITOR_MIN_HEIGHT}
maxWidth={RICH_TEXT_EDITOR_MAX_WIDTH}
preset="recordRichTextField"
/>
);
};
@@ -18,8 +18,7 @@ const DEFAULT_PROPS = {
defaultValue: '',
onChange: fn(),
readonly: false,
minHeight: 200,
maxWidth: 800,
preset: 'workflowEmailBody' as const,
};
const RICH_CONTENT_VALUE = JSON.stringify({
@@ -254,7 +253,6 @@ export const CustomSize: Story = {
...DEFAULT_PROPS,
label: 'Custom Size Field',
minHeight: 300,
maxWidth: 600,
placeholder: 'This field has custom dimensions...',
},
};
@@ -5,6 +5,7 @@ describe('canOpenObjectInSidePanel', () => {
expect(canOpenObjectInSidePanel('workflow')).toBe(false);
expect(canOpenObjectInSidePanel('workflowVersion')).toBe(false);
expect(canOpenObjectInSidePanel('dashboard')).toBe(false);
expect(canOpenObjectInSidePanel('messageCampaign')).toBe(false);
});
it('should return true for other objects', () => {
@@ -2,5 +2,6 @@ export const canOpenObjectInSidePanel = (objectNameSingular: string) =>
!(
objectNameSingular === 'workflow' ||
objectNameSingular === 'workflowVersion' ||
objectNameSingular === 'dashboard'
objectNameSingular === 'dashboard' ||
objectNameSingular === 'messageCampaign'
);