Skip to content

Commit 75c50da

Browse files
committed
✨(front) add mail signatures
Allow to manage maildomain's signature from the administration. Then in the Message composer allow the user to select a signature then render a preview in the MessageEditor
1 parent 17527e1 commit 75c50da

File tree

25 files changed

+1060
-167
lines changed

25 files changed

+1060
-167
lines changed

src/frontend/src/features/forms/components/message-editor/_index.scss renamed to src/frontend/src/features/blocknote/blocknote-view-field/_index.scss

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
.message-editor {
2-
&--error {
3-
.message-editor {
1+
.composer-field {
2+
&.c__field--error {
3+
.composer-field-input {
44
border-color: var(--c--theme--colors--error);
55
}
66
}
7-
&--success {
8-
.message-editor {
7+
&.c__field--success {
8+
.composer-field-input {
99
border-color: var(--c--theme--colors--success);
1010
}
1111
}
1212
&--disabled {
1313
pointer-events: none;
14-
.message-editor-input {
14+
.composer-field-input {
1515
cursor: not-allowed;
1616
border-color: var(--c--theme--colors--greyscale-200);
1717
}
1818
}
1919
}
20-
.message-editor-input {
20+
.composer-field-input {
2121
border-radius: var(--c--components--forms-input--border-radius);
2222
border-width: var(--c--components--forms-input--border-width);
2323
border-color: var(--c--components--forms-input--border-color);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { BlockNoteView } from "@blocknote/mantine";
2+
import { Field, FieldProps } from "@openfun/cunningham-react";
3+
import clsx from "clsx";
4+
import { PropsWithChildren } from "react";
5+
6+
type BlockNoteViewFieldProps = PropsWithChildren<FieldProps & {
7+
composerProps: Parameters<typeof BlockNoteView>[0];
8+
disabled?: boolean;
9+
}>
10+
export const BlockNoteViewField = ({ composerProps, disabled = false, children, ...fieldProps }: BlockNoteViewFieldProps) => {
11+
return (
12+
<Field {...fieldProps} className={clsx(fieldProps?.className, "composer-field", { 'composer-field--disabled': disabled })}>
13+
<BlockNoteView
14+
theme="light"
15+
sideMenu={false}
16+
slashMenu={false}
17+
formattingToolbar={false}
18+
{...composerProps}
19+
className={clsx(composerProps.className, "composer-field-input")}
20+
editable={!disabled}
21+
>
22+
{children}
23+
</BlockNoteView>
24+
</Field>
25+
)
26+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { createReactInlineContentSpec } from "@blocknote/react";
2+
import React, { useMemo } from "react";
3+
4+
import "@blocknote/mantine/style.css";
5+
import {
6+
useBlockNoteEditor,
7+
useComponentsContext,
8+
} from "@blocknote/react";
9+
import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
10+
import { PlaceholdersRetrieve200 } from "@/features/api/gen";
11+
12+
type TemplateVariableSelectorProps = {
13+
variables: PlaceholdersRetrieve200;
14+
isLoading: boolean;
15+
}
16+
17+
export const TemplateVariableSelector = ({ variables, isLoading }: TemplateVariableSelectorProps) => {
18+
const editor = useBlockNoteEditor();
19+
const Components = useComponentsContext()!;
20+
const variableItems = useMemo(() => {
21+
if (!variables) return [];
22+
return Object.entries(variables).map(([value, label]) => ({
23+
text: label,
24+
icon: null,
25+
isSelected: false,
26+
onClick: () => {
27+
editor.insertInlineContent([{ type: "template-variable", props: { label, value } }, " "]);
28+
}
29+
}));
30+
}, [variables]);
31+
32+
if (isLoading) {
33+
return (
34+
<Components.FormattingToolbar.Button icon={<Spinner size="sm" />} isDisabled={true} label="Loading variables..." mainTooltip="Loading variables..." />
35+
);
36+
}
37+
38+
if (!variables) {
39+
return null;
40+
}
41+
42+
return (
43+
<Components.FormattingToolbar.Select
44+
key={"templateVariableSelector"}
45+
items={[
46+
{
47+
text: "Variables",
48+
isSelected: true,
49+
isDisabled: true,
50+
icon: <Icon name="space_bar" size={IconSize.SMALL} />,
51+
onClick: () => {}
52+
},
53+
...variableItems,
54+
]}
55+
/>
56+
);
57+
}
58+
59+
60+
export const InlineTemplateVariable = createReactInlineContentSpec(
61+
{
62+
type: "template-variable",
63+
content: "none",
64+
draggable: true,
65+
propSchema: {
66+
value: { default: "" },
67+
label: { default: "" },
68+
},
69+
},
70+
{
71+
render: ({ inlineContent: { props } }) => {
72+
return (
73+
// TODO : Find a way to display variable name
74+
// and (de)serialize this inline content during export and parsing
75+
<span data-inline-type="template-variable">
76+
{`{${props.value}}`}
77+
</span>
78+
);
79+
},
80+
}
81+
);
82+
83+
84+
85+
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { createReactBlockSpec, useBlockNoteEditor, useComponentsContext, useEditorContentOrSelectionChange } from "@blocknote/react";
2+
import { MessageTemplateKindChoices, useMessageTemplatesList, useMessageTemplatesRenderRetrieve } from "@/features/api/gen";
3+
import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
4+
import { useEffect, useState } from "react";
5+
6+
7+
type SignatureTemplateSelectorProps = {
8+
mailboxId: string;
9+
}
10+
11+
export const SignatureTemplateSelector = ({ mailboxId }: SignatureTemplateSelectorProps) => {
12+
const editor = useBlockNoteEditor();
13+
const Components = useComponentsContext()!;
14+
const { data: signatures, isLoading } = useMessageTemplatesList({
15+
query: {
16+
enabled: !!mailboxId,
17+
meta: { noGlobalError: true },
18+
},
19+
request: {
20+
params: {
21+
mailbox_id: mailboxId,
22+
kind: MessageTemplateKindChoices.signature,
23+
is_active: "true",
24+
}
25+
}
26+
});
27+
28+
// Tracks whether the text & background are both blue.
29+
const [isSelected, setIsSelected] = useState<string>();
30+
31+
// Updates state on content or selection change.
32+
useEditorContentOrSelectionChange(() => {
33+
const signatureBlock = editor.getBlock('signature');
34+
setIsSelected(signatureBlock?.props.templateId || null);
35+
}, editor);
36+
37+
// Doesn't render unless a at least one block with inline content is
38+
// selected. You can use a similar pattern of returning `null` to
39+
// conditionally render buttons based on the editor state.
40+
// const blocks = useSelectedBlocks();
41+
// if (blocks.filter((block) => block.content !== undefined)) {
42+
// return null;
43+
// }
44+
45+
useEffect(() => {
46+
if(!isSelected) {
47+
const signatureBlock = editor.getBlock('signature');
48+
if (signatureBlock) {
49+
setIsSelected(signatureBlock.props.templateId);
50+
}
51+
}
52+
}, []);
53+
54+
if (isLoading) {
55+
return <Spinner size="sm" />;
56+
}
57+
58+
if (!mailboxId || !signatures?.data) {
59+
return null;
60+
}
61+
62+
return (
63+
<Components.FormattingToolbar.Select
64+
key={"templateVariableSelector"}
65+
items={[
66+
{
67+
text: "Signatures",
68+
isSelected: !isSelected,
69+
isDisabled: true,
70+
icon: <Icon name="content_copy" size={IconSize.SMALL} />,
71+
onClick: () => {}
72+
},
73+
...signatures.data.map((signature) => ({
74+
text: `Signature : ${signature.name}`,
75+
isSelected: isSelected === signature.id,
76+
icon: <Icon name="content_copy" size={IconSize.SMALL} />,
77+
onClick: () => {
78+
const signatureBlock = editor.getBlock('signature');
79+
if (signatureBlock) {
80+
editor.replaceBlocks(
81+
["signature"],
82+
[{ id: "signature", type: "signature", props: { templateId: signature.id, mailboxId: mailboxId } }]);
83+
} else {
84+
editor.insertBlocks(
85+
[{ id: "signature", type: "signature", props: { templateId: signature.id, mailboxId: mailboxId } }],
86+
editor.document[0].id,
87+
"after"
88+
);
89+
}
90+
91+
}
92+
})),
93+
]}
94+
/>
95+
);
96+
}
97+
98+
export const BlockSignature = createReactBlockSpec(
99+
{
100+
type: "signature",
101+
content: "none",
102+
isSelectable: false,
103+
propSchema: {
104+
templateId: { default: "" },
105+
mailboxId: { default: "" },
106+
username: { default: "" },
107+
}
108+
},
109+
{
110+
render: ({ block : { props }}) => {
111+
// eslint-disable-next-line react-hooks/rules-of-hooks
112+
const { data: signature, isLoading } = useMessageTemplatesRenderRetrieve(props.templateId, {
113+
request: {
114+
params: {
115+
mailbox_id: props.mailboxId
116+
}
117+
}
118+
});
119+
120+
if (isLoading) {
121+
return <Spinner size="sm" />;
122+
}
123+
124+
return (
125+
<div dangerouslySetInnerHTML={{ __html: signature?.data.html_body || "" }} />
126+
)
127+
},
128+
toExternalHTML: ({ block : { props }}) => {
129+
return <p>{`<SignatureID>${props.templateId}</SignatureID>`}</p>
130+
}
131+
}
132+
)

src/frontend/src/features/forms/components/message-editor/toolbar.tsx renamed to src/frontend/src/features/blocknote/toolbar.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { BasicTextStyleButton, BlockTypeSelect, CreateLinkButton, FormattingToolbar } from "@blocknote/react";
22

3-
const MessageEditorToolbar = () => {
3+
type ToolbarProps = {
4+
children?: React.ReactNode;
5+
}
6+
export const Toolbar = ({ children }: ToolbarProps) => {
47
return (
58
<FormattingToolbar>
69
<BlockTypeSelect key={"blockTypeSelect"} />
@@ -20,13 +23,8 @@ const MessageEditorToolbar = () => {
2023
basicTextStyle={"strike"}
2124
key={"strikeStyleButton"}
2225
/>
23-
<BasicTextStyleButton
24-
key={"codeStyleButton"}
25-
basicTextStyle={"code"}
26-
/>
2726
<CreateLinkButton key={"createLinkButton"} />
27+
{children}
2828
</FormattingToolbar>
2929
)
3030
}
31-
32-
export default MessageEditorToolbar;

0 commit comments

Comments
 (0)