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:
+57
-26
@@ -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}
|
||||
>
|
||||
|
||||
+1
-6
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-3
@@ -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...',
|
||||
},
|
||||
};
|
||||
|
||||
+1
@@ -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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user