Skip to content

Feat/front/signature #311

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
.message-editor {
&--error {
.message-editor {
.composer-field {
&.c__field--error {
.composer-field-input {
border-color: var(--c--theme--colors--error);
}
}
&--success {
.message-editor {
&.c__field--success {
.composer-field-input {
border-color: var(--c--theme--colors--success);
}
}
&--disabled {
pointer-events: none;
.message-editor-input {
.composer-field-input {
cursor: not-allowed;
border-color: var(--c--theme--colors--greyscale-200);
}
}
}
.message-editor-input {
.composer-field-input {
border-radius: var(--c--components--forms-input--border-radius);
border-width: var(--c--components--forms-input--border-width);
border-color: var(--c--components--forms-input--border-color);
Expand Down
27 changes: 27 additions & 0 deletions src/frontend/src/features/blocknote/blocknote-view-field/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core";
import { BlockNoteView } from "@blocknote/mantine";
import { Field, FieldProps } from "@openfun/cunningham-react";
import clsx from "clsx";
import { PropsWithChildren } from "react";

type BlockNoteViewFieldProps<BSchema extends BlockSchema, ISchema extends InlineContentSchema, SSchema extends StyleSchema> = PropsWithChildren<FieldProps & {
composerProps: Parameters<typeof BlockNoteView<BSchema, ISchema, SSchema>>[0];
disabled?: boolean;
}>
export const BlockNoteViewField = <BSchema extends BlockSchema, ISchema extends InlineContentSchema, SSchema extends StyleSchema>({ composerProps, disabled = false, children, ...fieldProps }: BlockNoteViewFieldProps<BSchema, ISchema, SSchema>) => {
return (
<Field {...fieldProps} className={clsx(fieldProps?.className, "composer-field", { 'composer-field--disabled': disabled })}>
<BlockNoteView
theme="light"
sideMenu={false}
slashMenu={false}
formattingToolbar={false}
{...composerProps}
className={clsx(composerProps.className, "composer-field-input")}
editable={!disabled}
>
{children}
</BlockNoteView>
</Field>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createReactInlineContentSpec } from "@blocknote/react";
import React, { useMemo } from "react";
import { useBlockNoteEditor, useComponentsContext } from "@blocknote/react";
import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
import { PlaceholdersRetrieve200 } from "@/features/api/gen";
import { SignatureComposerBlockSchema, SignatureComposerInlineContentSchema, SignatureComposerStyleSchema } from "@/features/layouts/components/admin/modal-compose-signature/signature-composer";

type TemplateVariableSelectorProps = {
variables: PlaceholdersRetrieve200;
isLoading: boolean;
}

export const TemplateVariableSelector = ({ variables, isLoading }: TemplateVariableSelectorProps) => {
const editor = useBlockNoteEditor<SignatureComposerBlockSchema, SignatureComposerInlineContentSchema, SignatureComposerStyleSchema>();
const Components = useComponentsContext()!;
const variableItems = useMemo(() => {
if (!variables) return [];
return Object.entries(variables).map(([value, label]) => ({
text: label,
icon: null,
isSelected: false,
onClick: () => {
editor.insertInlineContent([{ type: "template-variable", props: { label, value } }, " "]);
}
}));
}, [variables]);

if (isLoading) {
return (
<Components.FormattingToolbar.Button icon={<Spinner size="sm" />} isDisabled={true} label="Loading variables..." mainTooltip="Loading variables..." />
);
}

if (!variables) {
return null;
}

return (
<Components.FormattingToolbar.Select
key={"templateVariableSelector"}
items={[
{
text: "Variables",
isSelected: true,
isDisabled: true,
icon: <Icon name="space_bar" size={IconSize.SMALL} />,
onClick: () => {}
},
...variableItems,
]}
/>
);
}


export const InlineTemplateVariable = createReactInlineContentSpec(
{
type: "template-variable",
content: "none",
propSchema: {
value: { default: "" },
label: { default: "" },
},
},
{
render: ({ inlineContent: { props } }) => {
return (
// TODO : Find a way to display variable name
// and (de)serialize this inline content during export and parsing
<span data-inline-type="template-variable">
{`{${props.value}}`}
</span>
);
},
}
);




132 changes: 132 additions & 0 deletions src/frontend/src/features/blocknote/signature-block/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { createReactBlockSpec, useBlockNoteEditor, useComponentsContext, useEditorContentOrSelectionChange } from "@blocknote/react";
import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
import { useEffect, useState } from "react";
import { Props } from "@blocknote/core";
import { ReadOnlyMessageTemplate, useMessageTemplatesRenderRetrieve } from "@/features/api/gen";
import { MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema, PartialMessageComposerBlockSchema } from "@/features/forms/components/message-composer";


type SignatureTemplateSelectorProps = {
mailboxId?: string;
templates?: ReadOnlyMessageTemplate[];
isLoading?: boolean;
}

/**
* A BlockNote toolbar selector which allows the user to select a signature template from
* all active signatures for a given mailbox.
*/
export const SignatureTemplateSelector = ({ mailboxId, templates = [], isLoading }: SignatureTemplateSelectorProps) => {
const editor = useBlockNoteEditor<MessageComposerBlockSchema, MessageComposerInlineContentSchema, MessageComposerStyleSchema>();
const Components = useComponentsContext()!;

// Tracks whether the text & background are both blue.
const [isSelected, setIsSelected] = useState<string>();

// Updates state on content or selection change.
useEditorContentOrSelectionChange(() => {
const signatureBlock = editor.getBlock('signature');
if (signatureBlock) {
setIsSelected((signatureBlock.props as BlockSignatureConfigProps).templateId);
}
}, editor);

useEffect(() => {
if(!isSelected) {
const signatureBlock = editor.getBlock('signature');
if (signatureBlock) {
setIsSelected((signatureBlock.props as BlockSignatureConfigProps).templateId);
}
}
}, []);

if (isLoading) {
return <Spinner size="sm" />;
}

if (templates.length === 0) {
return null;
}

return (
<Components.FormattingToolbar.Select
key={"templateVariableSelector"}
items={[
{
text: "Signatures",
isSelected: !isSelected,
isDisabled: true,
icon: <Icon name="content_copy" size={IconSize.SMALL} />,
onClick: () => {}
},
...templates.map((template) => ({
text: `Signature : ${template.name}`,
isSelected: isSelected === template.id,
icon: <Icon name="content_copy" size={IconSize.SMALL} />,
onClick: () => {
const signatureBlock = editor.getBlock('signature');
if (signatureBlock) {
editor.replaceBlocks(
["signature"],
[{ id: "signature", type: "signature", props: { templateId: template.id, mailboxId: mailboxId } }] as unknown as PartialMessageComposerBlockSchema[]
);
} else {
editor.insertBlocks(
[{ id: "signature", type: "signature", props: { templateId: template.id, mailboxId: mailboxId } }] as unknown as PartialMessageComposerBlockSchema[],
editor.document[0].id,
"after"
);
}

}
})),
]}
/>
);
}

/**
* A BlockNote custom block which displays a signature template.
*/
export const BlockSignature = createReactBlockSpec(
{
type: "signature",
content: "none",
isSelectable: false,
isFileBlock: false,
propSchema: {
templateId: { default: "" },
mailboxId: { default: "" },
username: { default: "" },
}
},
{
render: ({ block : { props }}) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { data: { data: preview = null } = {}, isLoading } = useMessageTemplatesRenderRetrieve(props.templateId, {
request: {
params: {
mailbox_id: props.mailboxId
}
}
});

if (isLoading) {
return <Spinner size="sm" />;
}

if (!preview?.html_body) {
return null;
}

return (
<div dangerouslySetInnerHTML={{ __html: preview.html_body }} />
)
},
toExternalHTML: ({ block : { props }}) => {
// This will be parsed by the backend to insert the signature in the message body
return <p>{`<SignatureID>${props.templateId}</SignatureID>`}</p>
}
}
)
export type BlockSignatureConfigProps = Props<typeof BlockSignature['config']["propSchema"]>;
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { BasicTextStyleButton, BlockTypeSelect, CreateLinkButton, FormattingToolbar } from "@blocknote/react";

const MessageEditorToolbar = () => {
type ToolbarProps = {
children?: React.ReactNode;
}
export const Toolbar = ({ children }: ToolbarProps) => {
return (
<FormattingToolbar>
<BlockTypeSelect key={"blockTypeSelect"} />
Expand All @@ -20,13 +23,8 @@ const MessageEditorToolbar = () => {
basicTextStyle={"strike"}
key={"strikeStyleButton"}
/>
<BasicTextStyleButton
key={"codeStyleButton"}
basicTextStyle={"code"}
/>
<CreateLinkButton key={"createLinkButton"} />
{children}
</FormattingToolbar>
)
}

export default MessageEditorToolbar;
Loading