diff --git a/docs/model-custom-attributes.md b/docs/model-custom-attributes.md index 531a6666..0e0e6b76 100644 --- a/docs/model-custom-attributes.md +++ b/docs/model-custom-attributes.md @@ -54,12 +54,12 @@ Example of a valid JSON Schema: "minLength": 3, "x-i18n": { "title": { - "fr": "Fonction", - "en": "Job title" + "fr-Fr": "Fonction", + "en-US": "Job title" }, "description": { - "fr": "Le nom de la fonction de l'utilisateur", - "en": "The job name of the user" + "fr-Fr": "Le nom de la fonction de l'utilisateur", + "en-US": "The job name of the user" } } }, @@ -70,11 +70,11 @@ Example of a valid JSON Schema: "description": "Whether the user is elected", "x-i18n": { "title": { - "fr": "Est élu", - "en": "Is elected" + "fr-Fr": "Est élu", + "en-US": "Is elected" }, "description": { - "fr": "Indique si l'utilisateur est élu" + "fr-Fr": "Indique si l'utilisateur est élu", } } } diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 75c014ee..45a3f8ed 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -5105,15 +5105,33 @@ "application/json": { "schema": { "type": "object", - "description": "Field slugs mapped to their verbose labels", + "description": "Field slugs mapped to their label metadata. Built-in fields have an empty object and are localized client-side. Custom attribute fields expose their schema title and optional per-language translations.", "additionalProperties": { - "type": "string", - "description": "Verbose label for the field" + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Default label (custom fields only)." + }, + "i18n": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Label translations by language code, from the schema 'x-i18n' entry (custom fields only)." + } + } }, "example": { - "name": "Name", - "job_title": "Job title", - "is_elected": "Is elected" + "name": {}, + "recipient_name": {}, + "job_title": { + "title": "Job title", + "i18n": { + "en": "Job title", + "fr": "Fonction" + } + } } } } diff --git a/src/backend/core/api/viewsets/placeholder.py b/src/backend/core/api/viewsets/placeholder.py index 8efbb447..8e351c94 100644 --- a/src/backend/core/api/viewsets/placeholder.py +++ b/src/backend/core/api/viewsets/placeholder.py @@ -11,6 +11,10 @@ from rest_framework.views import APIView from core import enums, models +# Built-in placeholders, always available. They carry no label: the frontend +# localizes them client-side from its "placeholders" i18next namespace. +BUILTIN_PLACEHOLDER_FIELDS = ("name", "recipient_name", "user_name") + @extend_schema(tags=["placeholders"]) class PlaceholderView(APIView): @@ -32,40 +36,65 @@ class PlaceholderView(APIView): responses={ 200: { "type": "object", - "description": "Field slugs mapped to their verbose labels", + "description": ( + "Field slugs mapped to their label metadata. Built-in " + "fields have an empty object and are localized client-side. " + "Custom attribute fields expose their schema title and " + "optional per-language translations." + ), "additionalProperties": { - "type": "string", - "description": "Verbose label for the field", + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Default label (custom fields only).", + }, + "i18n": { + "type": "object", + "additionalProperties": {"type": "string"}, + "description": ( + "Label translations by language code, from the " + "schema 'x-i18n' entry (custom fields only)." + ), + }, + }, }, "example": { - "name": "Name", - "job_title": "Job title", - "is_elected": "Is elected", + "name": {}, + "recipient_name": {}, + "job_title": { + "title": "Job title", + "i18n": {"en": "Job title", "fr": "Fonction"}, + }, }, }, }, ) def get(self, request): - """Get the structure of available fields.""" - current_language = settings.LANGUAGE_CODE.split("-")[0] - fields = { - "name": "Name", - "recipient_name": "Recipient name", - } - # Add user custom attributes fields from schema + """Get the structure of available fields. + + Built-in fields are returned as empty objects and localized + client-side. Custom attribute fields carry their schema title and, + when defined, the ``x-i18n`` title translations so the frontend can + pick the right language. + """ + fields = {field_name: {} for field_name in BUILTIN_PLACEHOLDER_FIELDS} + + # Add user custom attributes fields from schema. Only string fields are + # exposed: a placeholder is substituted as text, so non-string types + # (e.g. boolean, integer) are not meaningful here. schema = settings.SCHEMA_CUSTOM_ATTRIBUTES_USER schema_properties = schema.get("properties", {}) for field_name, field_schema in schema_properties.items(): - # Check if there's internationalization - i18n_data = field_schema.get("x-i18n", {}) - if "title" in i18n_data: - label = i18n_data["title"].get( - current_language, i18n_data["title"].get("en", field_name) - ) - else: - # No internationalization, use schema title - label = field_schema.get("title", field_name) - fields[field_name] = label + if field_schema.get("type") != "string": + continue + field = {"title": field_schema.get("title", field_name)} + x_i18n = field_schema.get("x-i18n") + if isinstance(x_i18n, dict): + i18n_titles = x_i18n.get("title") + if isinstance(i18n_titles, dict) and i18n_titles: + field["i18n"] = i18n_titles + fields[field_name] = field return Response(fields) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index b2f8eeef..51bfdb2b 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -3185,7 +3185,8 @@ class MessageTemplate(BaseModel): Args: mailbox: Mailbox object — provides `name` via its contact - user: User object — fallback for `name` and source of custom attributes + user: User object — source of `user_name`, fallback for `name`, + and source of custom attributes message: Message object — provides `recipient_name` from TO recipients Returns: @@ -3196,7 +3197,8 @@ class MessageTemplate(BaseModel): mailbox.contact.name if mailbox and mailbox.contact else (getattr(user, "full_name", None) if user else "") - ) + ) or "" + context["user_name"] = (getattr(user, "full_name", None) or "") if user else "" schema = settings.SCHEMA_CUSTOM_ATTRIBUTES_USER schema_properties = schema.get("properties", {}) diff --git a/src/backend/core/tests/api/test_placeholder.py b/src/backend/core/tests/api/test_placeholder.py index 1015b7bd..07727469 100644 --- a/src/backend/core/tests/api/test_placeholder.py +++ b/src/backend/core/tests/api/test_placeholder.py @@ -9,6 +9,45 @@ from rest_framework.test import APIClient from core.factories import UserFactory +# Custom attributes schema providing x-i18n labels, shared by the i18n tests. +SCHEMA_WITH_I18N = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/suitenumerique/messages/schemas/custom-fields/user", + "type": "object", + "title": "User custom fields", + "additionalProperties": False, + "properties": { + "job_title": { + "type": "string", + "title": "Job title", + "default": "", + "description": "The job name of the user", + "minLength": 3, + "x-i18n": { + "title": {"fr": "Fonction", "en": "Job title"}, + "description": { + "fr": "Le nom de la fonction de l'utilisateur", + "en": "The job name of the user", + }, + }, + }, + "is_elected": { + "type": "boolean", + "title": "Is elected", + "default": False, + "description": "Whether the user is elected", + "x-i18n": { + "title": {"fr": "Est élu", "en": "Is elected"}, + "description": { + "fr": "Indique si l'utilisateur est élu", + "en": "Indicates if the user is elected", + }, + }, + }, + }, + "required": [], +} + @pytest.fixture(name="user") def fixture_user(): @@ -64,62 +103,36 @@ class TestPlaceholderView: } ) def test_get_fields_structure(self, api_client): - """Test that the endpoint returns field structure with slugs and labels.""" + """Built-in fields are empty; custom fields expose their schema title.""" url = reverse("placeholders") response = api_client.get(url) assert response.status_code == status.HTTP_200_OK data = response.json() - assert data["name"] == "Name" - assert data["job_title"] == "Job title" - assert data["is_elected"] == "Is elected" + # Built-in fields carry no label (localized client-side). + assert data["name"] == {} + assert data["recipient_name"] == {} + assert data["user_name"] == {} + # Custom fields without x-i18n expose their schema title only. + assert data["job_title"] == {"title": "Job title"} + # Non-string custom fields (e.g. the boolean "is_elected") are excluded. + assert "is_elected" not in data - @override_settings( - SCHEMA_CUSTOM_ATTRIBUTES_USER={ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/suitenumerique/messages/schemas/custom-fields/user", - "type": "object", - "title": "User custom fields", - "additionalProperties": False, - "properties": { - "job_title": { - "type": "string", - "title": "Job title", - "default": "", - "description": "The job name of the user", - "minLength": 3, - "x-i18n": { - "title": {"fr": "Fonction", "en": "Job title"}, - "description": { - "fr": "Le nom de la fonction de l'utilisateur", - "en": "The job name of the user", - }, - }, - }, - "is_elected": { - "type": "boolean", - "title": "Is elected", - "default": False, - "description": "Whether the user is elected", - "x-i18n": { - "title": {"fr": "Est élu", "en": "Is elected"}, - "description": { - "fr": "Indique si l'utilisateur est élu", - "en": "Indicates if the user is elected", - }, - }, - }, - }, - "required": [], - } - ) - def test_i18n_schema_uses_default_language(self, api_client): - """Test that x-i18n schema labels always use the default language.""" + @override_settings(SCHEMA_CUSTOM_ATTRIBUTES_USER=SCHEMA_WITH_I18N) + def test_returns_x_i18n_translations_for_custom_fields(self, api_client): + """Custom fields expose their x-i18n title translations for the frontend.""" url = reverse("placeholders") - # Accept-Language header is ignored; backend always uses LANGUAGE_CODE - response = api_client.get(url, HTTP_ACCEPT_LANGUAGE="fr-fr") + response = api_client.get(url) assert response.status_code == status.HTTP_200_OK data = response.json() - assert data["job_title"] == "Job title" - assert data["is_elected"] == "Is elected" - assert data["name"] == "Name" + # Built-in fields remain unlabeled regardless of the schema. + assert data["name"] == {} + assert data["recipient_name"] == {} + assert data["user_name"] == {} + # String custom fields ship every available translation; frontend picks one. + assert data["job_title"] == { + "title": "Job title", + "i18n": {"fr": "Fonction", "en": "Job title"}, + } + # Non-string custom fields (e.g. the boolean "is_elected") are excluded. + assert "is_elected" not in data diff --git a/src/backend/core/tests/api/test_placeholder_resolve.py b/src/backend/core/tests/api/test_placeholder_resolve.py index 8eaa8573..2e247783 100644 --- a/src/backend/core/tests/api/test_placeholder_resolve.py +++ b/src/backend/core/tests/api/test_placeholder_resolve.py @@ -126,6 +126,8 @@ class TestResolvePlaceholder: response = client.get(resolve_url(draft.id)) assert response.status_code == status.HTTP_200_OK assert response.data["name"] == "Mairie de Brigny" + # user_name resolves to the authenticated user, distinct from the mailbox. + assert response.data["user_name"] == "John Doe" assert response.data["job_title"] == "Adjointe" @patch( diff --git a/src/backend/core/tests/models/test_message_template.py b/src/backend/core/tests/models/test_message_template.py index e909edf8..c4f22f1c 100644 --- a/src/backend/core/tests/models/test_message_template.py +++ b/src/backend/core/tests/models/test_message_template.py @@ -41,6 +41,25 @@ class TestResolveplaceholderValues: result = models.MessageTemplate.resolve_placeholder_values() assert result["name"] == "" + def test_resolve_placeholder_user_name_from_user(self): + """user_name resolves to the user's full_name, independent of the mailbox.""" + mailbox = factories.MailboxFactory() + contact = factories.ContactFactory(name="Mairie de Brigny", mailbox=mailbox) + mailbox.contact = contact + mailbox.save() + user = factories.UserFactory(full_name="John Doe") + + result = models.MessageTemplate.resolve_placeholder_values( + mailbox=mailbox, user=user + ) + assert result["name"] == "Mairie de Brigny" + assert result["user_name"] == "John Doe" + + def test_resolve_placeholder_user_name_empty_when_no_user(self): + """When no user is provided, user_name should be empty.""" + result = models.MessageTemplate.resolve_placeholder_values() + assert result["user_name"] == "" + @patch( "django.conf.settings.SCHEMA_CUSTOM_ATTRIBUTES_USER", { diff --git a/src/frontend/eslint.config.mjs b/src/frontend/eslint.config.mjs index 3f87c439..e33561cf 100644 --- a/src/frontend/eslint.config.mjs +++ b/src/frontend/eslint.config.mjs @@ -1,4 +1,5 @@ import { defineConfig, globalIgnores } from "eslint/config"; +import i18next from "eslint-plugin-i18next"; import reactHooks from "eslint-plugin-react-hooks"; import tseslint from "typescript-eslint"; @@ -7,6 +8,7 @@ const eslintConfig = defineConfig([ { ignores: ["src/features/api/gen/**/*.ts", 'public/pdf.worker.min.mjs'] }, ...tseslint.configs.recommended, reactHooks.configs.flat.recommended, + i18next.configs["flat/recommended"], { rules: { "no-console": ["error", { allow: ["error", "warn"] }], @@ -15,6 +17,10 @@ const eslintConfig = defineConfig([ "react-hooks/exhaustive-deps": "off", "react-hooks/refs": "off", "react-hooks/set-state-in-effect": "warn", + // Guard against hardcoded user-facing strings. `warn` keeps it + // non-blocking for now; default `jsx-text-only` mode flags visible JSX + // text while leaving technical attributes (className, type…) alone. + "i18next/no-literal-string": "warn", }, }, ]); diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 6fa9ce85..881a83d5 100755 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -230,6 +230,7 @@ "Awaiting response": "Awaiting response", "Back": "Back", "Back to your inbox": "Back to your inbox", + "bcc": "bcc", "BCC: ": "BCC: ", "Blind copy: ": "Blind copy: ", "Calendar invite": "Calendar invite", @@ -239,6 +240,7 @@ "Cancelled": "Cancelled", "Cannot add attachment(s). Total size would be more than {{maxSize}}.": "Cannot add attachment(s). Total size would be more than {{maxSize}}.", "Cannot add image. File size exceeds the {{maxSize}} limit.": "Cannot add image. File size exceeds the {{maxSize}} limit.", + "cc": "cc", "CC: ": "CC: ", "Check DNS again": "Check DNS again", "Checking DNS records...": "Checking DNS records...", @@ -490,6 +492,7 @@ "Loading users": "Loading users", "Loading variables...": "Loading variables...", "Loading…": "Loading…", + "logo": "logo", "Logout": "Logout", "Mailbox {{mailbox}} has been deleted successfully.": "Mailbox {{mailbox}} has been deleted successfully.", "Mailbox count": "Mailbox count", @@ -513,6 +516,7 @@ "Message sent successfully": "Message sent successfully", "Message templates": "Message templates", "Messages": "Messages", + "Messages Logo": "Messages Logo", "Messaging": "Messaging", "mime.archive": "Archive", "mime.audio": "Audio", @@ -562,7 +566,6 @@ "No message could be starred.": "No message could be starred.", "No message template": "No message template", "No results": "No results", - "No signatures": "No signatures", "No subject": "No subject", "No summary available.": "No summary available.", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index ae76c1e6..99178c14 100755 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -300,6 +300,7 @@ "Awaiting response": "En attente de réponse", "Back": "Précédent", "Back to your inbox": "Retour à votre messagerie", + "bcc": "cci", "BCC: ": "CCI : ", "Blind copy: ": "Copie cachée : ", "Calendar invite": "Invitation calendrier", @@ -309,6 +310,7 @@ "Cancelled": "Annulé", "Cannot add attachment(s). Total size would be more than {{maxSize}}.": "Impossible d'ajouter ces pièces jointes. La taille totale dépasserait la limite autorisée de {{maxSize}}.", "Cannot add image. File size exceeds the {{maxSize}} limit.": "Impossible d'ajouter l'image. La taille du fichier dépasse la limite de {{maxSize}}.", + "cc": "cc", "CC: ": "Copie : ", "Check DNS again": "Revérifier les DNS", "Checking DNS records...": "Vérification des enregistrements DNS...", @@ -569,6 +571,7 @@ "Loading users": "Chargement des utilisateurs", "Loading variables...": "Chargement des variables...", "Loading…": "Chargement…", + "logo": "logo", "Logout": "Déconnexion", "Mailbox {{mailbox}} has been deleted successfully.": "La boîte aux lettres {{mailbox}} a été supprimée avec succès.", "Mailbox count": "Nombre de BAL", @@ -592,6 +595,7 @@ "Message sent successfully": "Message envoyé avec succès", "Message templates": "Modèles de message", "Messages": "Messages", + "Messages Logo": "Logo Messages", "Messaging": "Messages", "mime.archive": "Archive", "mime.audio": "Audio", @@ -642,7 +646,6 @@ "No message could be starred.": "Aucun message n'a pu être suivi.", "No message template": "Aucun modèle de message", "No results": "Aucun résultat", - "No signatures": "Aucune signature", "No subject": "Aucun objet", "No summary available.": "Aucun résumé disponible.", diff --git a/src/frontend/public/locales/placeholders/br-FR.json b/src/frontend/public/locales/placeholders/br-FR.json new file mode 100644 index 00000000..3aaa9e46 --- /dev/null +++ b/src/frontend/public/locales/placeholders/br-FR.json @@ -0,0 +1,5 @@ +{ + "name": "Anv ar c'haser", + "recipient_name": "Anv an degemerer", + "user_name": "Anv an implijer" +} diff --git a/src/frontend/public/locales/placeholders/de-DE.json b/src/frontend/public/locales/placeholders/de-DE.json new file mode 100644 index 00000000..c07b6d5c --- /dev/null +++ b/src/frontend/public/locales/placeholders/de-DE.json @@ -0,0 +1,5 @@ +{ + "name": "Absendername", + "recipient_name": "Name des Empfängers", + "user_name": "Benutzername" +} diff --git a/src/frontend/public/locales/placeholders/en-US.json b/src/frontend/public/locales/placeholders/en-US.json new file mode 100644 index 00000000..3c104637 --- /dev/null +++ b/src/frontend/public/locales/placeholders/en-US.json @@ -0,0 +1,5 @@ +{ + "name": "Sender name", + "recipient_name": "Recipient name", + "user_name": "User name" +} diff --git a/src/frontend/public/locales/placeholders/es-ES.json b/src/frontend/public/locales/placeholders/es-ES.json new file mode 100644 index 00000000..ef77d70c --- /dev/null +++ b/src/frontend/public/locales/placeholders/es-ES.json @@ -0,0 +1,5 @@ +{ + "name": "Nombre del remitente", + "recipient_name": "Nombre del destinatario", + "user_name": "Nombre del usuario" +} diff --git a/src/frontend/public/locales/placeholders/fr-FR.json b/src/frontend/public/locales/placeholders/fr-FR.json new file mode 100644 index 00000000..0e9ba883 --- /dev/null +++ b/src/frontend/public/locales/placeholders/fr-FR.json @@ -0,0 +1,5 @@ +{ + "name": "Nom d'expéditeur", + "recipient_name": "Nom du destinataire", + "user_name": "Nom de l'utilisateur" +} diff --git a/src/frontend/public/locales/placeholders/it-IT.json b/src/frontend/public/locales/placeholders/it-IT.json new file mode 100644 index 00000000..89112743 --- /dev/null +++ b/src/frontend/public/locales/placeholders/it-IT.json @@ -0,0 +1,5 @@ +{ + "name": "Nome del mittente", + "recipient_name": "Nome del destinatario", + "user_name": "Nome dell'utente" +} diff --git a/src/frontend/public/locales/placeholders/nl-NL.json b/src/frontend/public/locales/placeholders/nl-NL.json new file mode 100644 index 00000000..9c7fd3c4 --- /dev/null +++ b/src/frontend/public/locales/placeholders/nl-NL.json @@ -0,0 +1,5 @@ +{ + "name": "Naam afzender", + "recipient_name": "Naam van de ontvanger", + "user_name": "Gebruikersnaam" +} diff --git a/src/frontend/public/locales/placeholders/pt-PT.json b/src/frontend/public/locales/placeholders/pt-PT.json new file mode 100644 index 00000000..c836c82f --- /dev/null +++ b/src/frontend/public/locales/placeholders/pt-PT.json @@ -0,0 +1,5 @@ +{ + "name": "Nome do remetente", + "recipient_name": "Nome do destinatário", + "user_name": "Nome do utilizador" +} diff --git a/src/frontend/public/locales/placeholders/ru-RU.json b/src/frontend/public/locales/placeholders/ru-RU.json new file mode 100644 index 00000000..d5d94b2c --- /dev/null +++ b/src/frontend/public/locales/placeholders/ru-RU.json @@ -0,0 +1,5 @@ +{ + "name": "Имя отправителя", + "recipient_name": "Имя получателя", + "user_name": "Имя пользователя" +} diff --git a/src/frontend/public/locales/placeholders/uk-UA.json b/src/frontend/public/locales/placeholders/uk-UA.json new file mode 100644 index 00000000..2a7218b4 --- /dev/null +++ b/src/frontend/public/locales/placeholders/uk-UA.json @@ -0,0 +1,5 @@ +{ + "name": "Ім'я відправника", + "recipient_name": "Ім'я одержувача", + "user_name": "Ім'я користувача" +} diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index 5b1a9ed3..9e1fed85 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -138,6 +138,7 @@ export * from "./patched_message_template_request_metadata"; export * from "./patched_thread_access_request"; export * from "./patched_thread_event_request"; export * from "./placeholders_retrieve200"; +export * from "./placeholders_retrieve200_i18n"; export * from "./read_message_template"; export * from "./read_message_template_metadata"; export * from "./regenerated_api_key_response"; diff --git a/src/frontend/src/features/api/gen/models/placeholders_retrieve200.ts b/src/frontend/src/features/api/gen/models/placeholders_retrieve200.ts index bb8f0498..208ea290 100644 --- a/src/frontend/src/features/api/gen/models/placeholders_retrieve200.ts +++ b/src/frontend/src/features/api/gen/models/placeholders_retrieve200.ts @@ -5,8 +5,16 @@ * This is the messages API schema. * OpenAPI spec version: 1.0.0 (v1.0) */ +import type { PlaceholdersRetrieve200I18n } from "./placeholders_retrieve200_i18n"; /** - * Field slugs mapped to their verbose labels + * Field slugs mapped to their label metadata. Built-in fields have an empty object and are localized client-side. Custom attribute fields expose their schema title and optional per-language translations. */ -export type PlaceholdersRetrieve200 = { [key: string]: string }; +export type PlaceholdersRetrieve200 = { + [key: string]: { + /** Default label (custom fields only). */ + title?: string; + /** Label translations by language code, from the schema 'x-i18n' entry (custom fields only). */ + i18n?: PlaceholdersRetrieve200I18n; + }; +}; diff --git a/src/frontend/src/features/api/gen/models/placeholders_retrieve200_i18n.ts b/src/frontend/src/features/api/gen/models/placeholders_retrieve200_i18n.ts new file mode 100644 index 00000000..5b0d8946 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/placeholders_retrieve200_i18n.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Label translations by language code, from the schema 'x-i18n' entry (custom fields only). + */ +export type PlaceholdersRetrieve200I18n = { [key: string]: string }; diff --git a/src/frontend/src/features/blocknote/__tests__/block-factories.ts b/src/frontend/src/features/blocknote/__tests__/block-factories.ts index 609186bf..9e37f50b 100644 --- a/src/frontend/src/features/blocknote/__tests__/block-factories.ts +++ b/src/frontend/src/features/blocknote/__tests__/block-factories.ts @@ -22,10 +22,16 @@ export function link(href: string, text: string): AnyInlineContent { } as unknown as AnyInlineContent; } -export function templateVariable(value: string, label?: string): AnyInlineContent { +export function templateVariable( + value: string, + label?: string, + styles: Record = {}, +): AnyInlineContent { + const display = label ?? value; return { type: 'template-variable', - props: { value, label: label ?? value }, + props: { value, label: display }, + content: [styledText(display, styles)], } as unknown as AnyInlineContent; } diff --git a/src/frontend/src/features/blocknote/email-exporter/index.test.tsx b/src/frontend/src/features/blocknote/email-exporter/index.test.tsx index bcaa6bf7..2e2bbfe7 100644 --- a/src/frontend/src/features/blocknote/email-exporter/index.test.tsx +++ b/src/frontend/src/features/blocknote/email-exporter/index.test.tsx @@ -122,6 +122,30 @@ describe('EmailExporter', () => { ]); expect(html).toContain('color:#0b6e99'); }); + + it('inlines size and weight so headings survive bn-default-styles', () => { + const html = exportBlocks([heading('Title', 1)]); + expect(html).toContain('font-size:3em'); + expect(html).toContain('font-weight:700'); + }); + + // Guard: pins the full HEADING_LEVEL_STYLES scale against BlockNote's own + // `--level` values (node_modules/@blocknote/core/dist/style.css). Email + // clients load no stylesheet, so these sizes MUST be inlined; if a BlockNote + // upgrade changes the scale, our hardcoded copy diverges silently — this + // table makes that divergence fail loudly. + it.each([ + [1, '3em'], + [2, '2em'], + [3, '1.3em'], + [4, '1em'], + [5, '0.9em'], + [6, '0.8em'], + ])('level %i inlines font-size %s and weight 700', (level, fontSize) => { + const html = exportBlocks([heading('Title', level)]); + expect(html).toContain(`font-size:${fontSize}`); + expect(html).toContain('font-weight:700'); + }); }); // ----------------------------------------------------------------------- @@ -181,20 +205,6 @@ describe('EmailExporter', () => { expect(html).toContain('text-decoration-line:underline line-through'); }); - it('renders named textColor via COLORS', () => { - const html = exportBlocks([ - paragraph([styledText('Purple', { textColor: 'purple' })]), - ]); - expect(html).toContain('color:#6940a5'); - }); - - it('renders named backgroundColor via COLORS', () => { - const html = exportBlocks([ - paragraph([styledText('Green bg', { backgroundColor: 'green' })]), - ]); - expect(html).toContain('background-color:#ddedea'); - }); - it('passes through non-named color values', () => { const html = exportBlocks([ paragraph([styledText('Custom', { textColor: '#ff00ff' })]), @@ -212,6 +222,52 @@ describe('EmailExporter', () => { }); }); + // ----------------------------------------------------------------------- + // 4b. COLORS palette guard + // + // Pins the full COLORS map against BlockNote's own values + // (node_modules/@blocknote/core/dist/style.css). Like the heading scale, + // these are an inline copy of a non-public BlockNote constant: email clients + // load no stylesheet, so the hex values MUST be inlined. If a BlockNote + // upgrade shifts the palette, our copy diverges silently — these tables make + // that fail loudly. + // ----------------------------------------------------------------------- + describe('COLORS palette', () => { + it.each([ + ['gray', '#9b9a97'], + ['brown', '#64473a'], + ['red', '#e03e3e'], + ['orange', '#d9730d'], + ['yellow', '#dfab01'], + ['green', '#4d6461'], + ['blue', '#0b6e99'], + ['purple', '#6940a5'], + ['pink', '#ad1a72'], + ])('maps textColor "%s" to %s', (name, hex) => { + const html = exportBlocks([ + paragraph([styledText('Text', { textColor: name })]), + ]); + expect(html).toContain(`color:${hex}`); + }); + + it.each([ + ['gray', '#ebeced'], + ['brown', '#e9e5e3'], + ['red', '#fbe4e4'], + ['orange', '#f6e9d9'], + ['yellow', '#fbf3db'], + ['green', '#ddedea'], + ['blue', '#ddebf1'], + ['purple', '#eae4f2'], + ['pink', '#f4dfeb'], + ])('maps backgroundColor "%s" to %s', (name, hex) => { + const html = exportBlocks([ + paragraph([styledText('Text', { backgroundColor: name })]), + ]); + expect(html).toContain(`background-color:${hex}`); + }); + }); + // ----------------------------------------------------------------------- // 5. Links // ----------------------------------------------------------------------- @@ -236,6 +292,25 @@ describe('EmailExporter', () => { expect(html).toContain('font-weight:bold'); expect(html).toContain('href="https://example.com"'); }); + + it('defaults the color to link blue when the text has no color', () => { + const html = exportBlocks([ + paragraph([link('https://example.com', 'Click here')]), + ]); + expect(html).toContain('color:#0b6e99'); + }); + + it('mirrors the text color onto the so the underline matches', () => { + const coloredLink: AnyInlineContent = { + type: 'link', + href: 'https://example.com', + content: [styledText('Red link', { textColor: 'red' })], + } as unknown as AnyInlineContent; + const html = exportBlocks([paragraph([coloredLink])]); + // The itself carries the red color (not the default blue). + expect(html).toMatch(/]*color:#e03e3e/); + expect(html).not.toContain('color:#0b6e99'); + }); }); // ----------------------------------------------------------------------- @@ -451,8 +526,8 @@ describe('EmailExporter', () => { // ----------------------------------------------------------------------- // Inline template-variable — InlineTemplateVariable has no toExternalHTML; - // EmailExporter handles it explicitly at index.tsx:156-158. These tests - // pin the current behavior (literal `{var}` output). + // EmailExporter handles it explicitly. The `{value}` token is stored as the + // node's styled content, so its own marks must be rendered too. // ----------------------------------------------------------------------- describe('inline template-variable', () => { it('renders a standalone template-variable as a placeholder span', () => { @@ -463,6 +538,14 @@ describe('EmailExporter', () => { expect(html).toContain('{first_name}'); }); + it('applies the styles carried by the template-variable token', () => { + const html = exportBlocks([ + paragraph([templateVariable('first_name', 'First name', { bold: true })]), + ]); + expect(html).toContain('{first_name}'); + expect(html).toContain('font-weight:bold'); + }); + it('preserves order and styles around an inline template-variable', () => { const html = exportBlocks([ paragraph([ @@ -886,7 +969,7 @@ describe('EmailExporter', () => { const html = exportBlocks([ heading('Important', 2, { textColor: 'red' }), ]); - expect(html).toMatchInlineSnapshot(`"

Important

"`); + expect(html).toMatchInlineSnapshot(`"

Important

"`); }); it('renders an image with caption and center alignment', () => { diff --git a/src/frontend/src/features/blocknote/email-exporter/index.tsx b/src/frontend/src/features/blocknote/email-exporter/index.tsx index c7245fc1..5f6af728 100644 --- a/src/frontend/src/features/blocknote/email-exporter/index.tsx +++ b/src/frontend/src/features/blocknote/email-exporter/index.tsx @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import type { Block, InlineContent, StyledText } from '@blocknote/core'; import { Text, Heading, Img, Link, Hr, Row, Column } from '@react-email/components'; import MailHelper from '@/features/utils/mail-helper'; +import { TEMPLATE_VARIABLE_TYPE } from '../inline-template-variable'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyBlock = Block; @@ -24,6 +25,21 @@ const COLORS: Record = { pink: { text: '#ad1a72', background: '#f4dfeb' }, }; +// BlockNote renders heading sizes via CSS variables on `data-level` attributes, +// not on the

-

tags — and its `.bn-default-styles` rule forces +// `font-size: inherit` on those tags. A heading exported as a bare tag is thus +// flattened to body size wherever that stylesheet applies (e.g. a signature +// preview injected inside the editor). We inline BlockNote's own scale so the +// heading keeps its size in previews and email clients alike. +const HEADING_LEVEL_STYLES: Record = { + 1: { fontSize: '3em', fontWeight: 700 }, + 2: { fontSize: '2em', fontWeight: 700 }, + 3: { fontSize: '1.3em', fontWeight: 700 }, + 4: { fontSize: '1em', fontWeight: 700 }, + 5: { fontSize: '0.9em', fontWeight: 700 }, + 6: { fontSize: '0.8em', fontWeight: 700 }, +}; + // --------------------------------------------------------------------------- // Style utilities // --------------------------------------------------------------------------- @@ -147,15 +163,25 @@ function renderInlineContent(content: AnyInlineContent[]): React.ReactNode[] { if (ic.type === 'link') { // BlockNote Link: { type: "link", href: string, content: StyledText[] } const link = ic as { type: 'link'; href: string; content: AnyStyledText[] }; + // Mirror the link text's own color onto the so the underline + // matches the text instead of staying the default link blue. + const textColor = link.content + .map((st) => st.styles?.textColor as string | undefined) + .find((color) => color && color !== 'default'); + const linkColor = textColor ? (COLORS[textColor]?.text || textColor) : '#0b6e99'; return ( - + {link.content.map((st, j) => renderStyledText(st, j))} ); } - if (ic.type === 'template-variable') { - const variable = ic as unknown as { props: Record }; - return {`{${variable.props.value}}`}; + if (ic.type === TEMPLATE_VARIABLE_TYPE) { + const variable = ic as unknown as { props: Record; content?: AnyStyledText[] }; + // The editor shows the human label, but the export keeps the canonical + // `{value}` token — carrying the styles applied to it. + const styles = variable.content?.[0]?.styles ?? {}; + const token = { type: 'text', text: `{${variable.props.value}}`, styles } as AnyStyledText; + return {renderStyledText(token, 0)}; } return null; }); @@ -287,8 +313,11 @@ function renderBlock( case 'heading': { const level = Math.min(Math.max((props.level as number) || 1, 1), 6); const as = `h${level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + // `margin: 0` matches BlockNote (spacing comes from block padding) and + // avoids the browser's large default heading margins in email clients. + // Block-level styles (color/alignment) come last so they can override. return ( - + {renderInlineContent(content || [])} ); diff --git a/src/frontend/src/features/blocknote/hooks/use-base64-composer.tsx b/src/frontend/src/features/blocknote/hooks/use-base64-composer.tsx index f0a9d8c4..1d45dd51 100644 --- a/src/frontend/src/features/blocknote/hooks/use-base64-composer.tsx +++ b/src/frontend/src/features/blocknote/hooks/use-base64-composer.tsx @@ -10,7 +10,7 @@ import { useImageObjectUrls } from '@/features/blocknote/image-block/use-image-o import { EmailExporter } from '@/features/blocknote/email-exporter'; import { useConfig } from '@/features/providers/config'; import MailHelper from '@/features/utils/mail-helper'; -import { createBlockNoteDictionary, createNonImageFileBlockers } from '@/features/blocknote/utils'; +import { backfillTemplateVariableContent, createBlockNoteDictionary, createNonImageFileBlockers } from '@/features/blocknote/utils'; import { handle } from '@/features/utils/errors'; const emailExporter = new EmailExporter(); @@ -81,6 +81,11 @@ export const useBase64Composer = < return DEFAULT_CONTENT; } + // Restore the styled content of legacy `template-variable` tokens stored + // before the inline spec switched from `content: "none"` to "styled", + // otherwise they render as empty blue chips. + blocks = backfillTemplateVariableContent(blocks); + // Traverse blocks tree to transform image data URLs to Object URLs let imageIndex = 0; const processImageBlocks = (blocks: Record[]) => { diff --git a/src/frontend/src/features/blocknote/inline-template-variable/_index.scss b/src/frontend/src/features/blocknote/inline-template-variable/_index.scss index 3f9d05a5..4fef5fe7 100644 --- a/src/frontend/src/features/blocknote/inline-template-variable/_index.scss +++ b/src/frontend/src/features/blocknote/inline-template-variable/_index.scss @@ -8,7 +8,6 @@ color: var(--c--contextuals--content--semantic--brand--primary); font-size: var(--c--globals--font--sizes--xs); border: 1px solid var(--c--contextuals--border--semantic--brand--secondary); - user-select: none; font-family: monospace; } diff --git a/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.test.ts b/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.test.ts new file mode 100644 index 00000000..b2575abf --- /dev/null +++ b/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.test.ts @@ -0,0 +1,233 @@ +/** + * Battle-test for the template-variable editing behavior. + * + * The token stores its `{value}` label as styled text (BlockNote inline content + * `"styled"`), so without this extension Backspace/Delete would chew it one + * character at a time and typing inside would break the token. The logic is + * pure ProseMirror, so we exercise it against a real schema/state — no TipTap + * editor, no DOM, no React — by driving the extension through its public + * surface (the keyboard shortcuts and the ProseMirror plugin it registers). + */ +import { describe, it, expect } from 'vitest'; +import { Schema, type Slice } from '@tiptap/pm/model'; +import { EditorState, type Plugin, type Transaction } from '@tiptap/pm/state'; +import type { EditorView } from '@tiptap/pm/view'; +import type { Editor } from '@tiptap/core'; +import { TemplateVariableEditingBehavior } from './editing-behavior'; + +// A minimal schema reproducing the production shape that matters here: an inline +// `template-variable` node holding editable text, surrounded by plain text. +const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { + group: 'block', + content: 'inline*', + toDOM: () => ['p', 0], + parseDOM: [{ tag: 'p' }], + }, + 'template-variable': { + group: 'inline', + inline: true, + content: 'text*', + toDOM: () => ['span', 0], + parseDOM: [{ tag: 'span' }], + }, + text: { group: 'inline' }, + }, +}); + +type Built = { state: EditorState; tokenPos: number; tokenSize: number }; + +/** Builds `

{leading}[{token}]{trailing}

` and locates the token node. */ +const buildDoc = (leading: string, token: string, trailing: string): Built => { + const inline = []; + if (leading) inline.push(schema.text(leading)); + inline.push(schema.node('template-variable', null, [schema.text(token)])); + if (trailing) inline.push(schema.text(trailing)); + const doc = schema.node('doc', null, [schema.node('paragraph', null, inline)]); + const state = EditorState.create({ doc, schema }); + + let tokenPos = -1; + let tokenSize = 0; + doc.descendants((node, pos) => { + if (node.type.name === 'template-variable') { + tokenPos = pos; + tokenSize = node.nodeSize; + } + }); + return { state, tokenPos, tokenSize }; +}; + +// --- Reach into the extension through its public config surface ------------- +type ShortcutMap = Record boolean>; +type ExtConfig = { + addKeyboardShortcuts: () => ShortcutMap; + addProseMirrorPlugins: () => Plugin[]; +}; +const config = TemplateVariableEditingBehavior.config as unknown as ExtConfig; +const shortcuts = config.addKeyboardShortcuts(); +const [readonlyPlugin] = config.addProseMirrorPlugins(); + +// ProseMirror types `EditorProps` methods with a `this: Plugin` context, which +// trips TS when calling them as plain functions. Re-type just the handlers we +// drive, decoupled from that `this` binding. +type ReadonlyHandlers = { + handleTextInput: (view: EditorView, from: number, to: number, text: string) => boolean; + handlePaste: (view: EditorView, event: ClipboardEvent, slice: Slice) => boolean; + handleKeyDown: (view: EditorView, event: KeyboardEvent) => boolean; +}; +const handlers = readonlyPlugin.props as unknown as ReadonlyHandlers; + +/** + * Fakes the slice of the TipTap editor that `deleteTemplateVariable` reads: + * a collapsed (or not) selection at `cursorPos` plus a capturing dispatch. + */ +const makeEditor = (state: EditorState, cursorPos: number, empty = true) => { + let dispatched: Transaction | null = null; + const $from = state.doc.resolve(cursorPos); + const editor = { + state: { selection: { empty, $from }, tr: state.tr }, + view: { dispatch: (tr: Transaction) => { dispatched = tr; } }, + } as unknown as Editor; + return { + editor, + resultText: () => (dispatched ? state.apply(dispatched).doc.textContent : null), + }; +}; + +const press = ( + key: 'Backspace' | 'Delete', + b: Built, + pos: number, + empty = true, +) => { + const { editor, resultText } = makeEditor(b.state, pos, empty); + const handled = shortcuts[key]({ editor }); + return { handled, text: resultText() }; +}; + +describe('TemplateVariableEditingBehavior — atomic deletion', () => { + it('Backspace removes the whole token when the cursor is inside it', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, text } = press('Backspace', b, b.tokenPos + 2); + expect(handled).toBe(true); + expect(text).toBe('Hello world'); + }); + + it('Delete removes the whole token when the cursor is inside it', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, text } = press('Delete', b, b.tokenPos + 2); + expect(handled).toBe(true); + expect(text).toBe('Hello world'); + }); + + it('Backspace removes the token when the cursor sits right after it', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, text } = press('Backspace', b, b.tokenPos + b.tokenSize); + expect(handled).toBe(true); + expect(text).toBe('Hello world'); + }); + + it('Delete removes the token when the cursor sits right before it', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, text } = press('Delete', b, b.tokenPos); + expect(handled).toBe(true); + expect(text).toBe('Hello world'); + }); + + it('Backspace at the end of a token-terminated paragraph still removes it', () => { + const b = buildDoc('Hello ', '{name}', ''); + const { handled, text } = press('Backspace', b, b.tokenPos + b.tokenSize); + expect(handled).toBe(true); + expect(text).toBe('Hello '); + }); + + it('Delete after the token is a no-op (the next char is plain text)', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, text } = press('Delete', b, b.tokenPos + b.tokenSize); + expect(handled).toBe(false); + expect(text).toBeNull(); + }); + + it('Backspace before the token is a no-op (the previous char is plain text)', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const { handled, text } = press('Backspace', b, b.tokenPos); + expect(handled).toBe(false); + expect(text).toBeNull(); + }); + + it('Backspace at the very start of the paragraph is a no-op', () => { + const b = buildDoc('', '{name}', ' world'); + const { handled, text } = press('Backspace', b, b.tokenPos); + expect(handled).toBe(false); + expect(text).toBeNull(); + }); + + it('leaves a non-collapsed selection to the default handler', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + // Cursor inside the token, but the selection is a range, not a caret. + const { handled, text } = press('Backspace', b, b.tokenPos + 2, false); + expect(handled).toBe(false); + expect(text).toBeNull(); + }); +}); + +describe('TemplateVariableEditingBehavior — read-only token content', () => { + const b = buildDoc('Hello ', '{name}', ' world'); + const insidePos = b.tokenPos + 2; + const outsidePos = 2; // within the leading "Hello " text + + const viewWithDoc = { state: b.state } as unknown as EditorView; + const viewWithCursor = (pos: number) => + ({ state: { selection: { $from: b.state.doc.resolve(pos) } } }) as unknown as EditorView; + + it('blocks text input inside the token', () => { + expect(handlers.handleTextInput(viewWithDoc, insidePos, insidePos, 'x')).toBe(true); + }); + + it('allows text input outside the token', () => { + expect(handlers.handleTextInput(viewWithDoc, outsidePos, outsidePos, 'x')).toBe(false); + }); + + it('blocks paste inside the token', () => { + const noopClipboard = {} as unknown as ClipboardEvent; + const noopSlice = {} as unknown as Slice; + expect(handlers.handlePaste(viewWithCursor(insidePos), noopClipboard, noopSlice)).toBe(true); + }); + + it('allows paste outside the token', () => { + const noopClipboard = {} as unknown as ClipboardEvent; + const noopSlice = {} as unknown as Slice; + expect(handlers.handlePaste(viewWithCursor(outsidePos), noopClipboard, noopSlice)).toBe(false); + }); + + it('blocks Enter inside the token', () => { + const enter = { key: 'Enter' } as unknown as KeyboardEvent; + expect(handlers.handleKeyDown(viewWithCursor(insidePos), enter)).toBe(true); + }); + + it('allows Enter outside the token', () => { + const enter = { key: 'Enter' } as unknown as KeyboardEvent; + expect(handlers.handleKeyDown(viewWithCursor(outsidePos), enter)).toBe(false); + }); + + it('only intercepts Enter, not other keys, inside the token', () => { + const letter = { key: 'a' } as unknown as KeyboardEvent; + expect(handlers.handleKeyDown(viewWithCursor(insidePos), letter)).toBe(false); + }); +}); + +describe('TemplateVariableEditingBehavior — wiring', () => { + it('binds both Backspace and Delete', () => { + expect(typeof shortcuts.Backspace).toBe('function'); + expect(typeof shortcuts.Delete).toBe('function'); + }); + + it('registers a single ProseMirror plugin exposing the read-only handlers', () => { + expect(config.addProseMirrorPlugins()).toHaveLength(1); + expect(typeof readonlyPlugin.props.handleTextInput).toBe('function'); + expect(typeof readonlyPlugin.props.handlePaste).toBe('function'); + expect(typeof readonlyPlugin.props.handleKeyDown).toBe('function'); + }); +}); diff --git a/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.ts b/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.ts new file mode 100644 index 00000000..84a884bd --- /dev/null +++ b/src/frontend/src/features/blocknote/inline-template-variable/editing-behavior.ts @@ -0,0 +1,97 @@ +import { Editor, Extension } from "@tiptap/core"; +import { ResolvedPos } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { TEMPLATE_VARIABLE_TYPE } from "."; + +/** Returns true when the resolved position sits inside a template-variable node. */ +const isInsideTemplateVariable = ($pos: ResolvedPos): boolean => { + for (let depth = $pos.depth; depth > 0; depth--) { + if ($pos.node(depth).type.name === TEMPLATE_VARIABLE_TYPE) { + return true; + } + } + return false; +}; + +/** + * Deletes a whole template-variable token in a single keystroke. + * + * Because the token stores its `{value}` label as styled text, the default + * Backspace/Delete would otherwise erase it one character at a time. This + * restores atomic-token behavior: a single keystroke removes the entire + * variable when the (collapsed) cursor sits inside it or right next to it. + * + * @param editor - The TipTap editor instance. + * @param backward - True for Backspace (look behind), false for Delete (ahead). + * @returns True when a variable was deleted, so default handling is skipped. + */ +const deleteTemplateVariable = (editor: Editor, backward: boolean): boolean => { + const { state } = editor; + const { selection } = state; + if (!selection.empty) { + return false; + } + const { $from } = selection; + + // Cursor inside the token's text: remove the whole node. + for (let depth = $from.depth; depth > 0; depth--) { + if ($from.node(depth).type.name === TEMPLATE_VARIABLE_TYPE) { + editor.view.dispatch(state.tr.delete($from.before(depth), $from.after(depth))); + return true; + } + } + + // Cursor immediately before/after the token: remove it as a whole. + const adjacent = backward ? $from.nodeBefore : $from.nodeAfter; + if (adjacent?.type.name === TEMPLATE_VARIABLE_TYPE) { + const from = backward ? $from.pos - adjacent.nodeSize : $from.pos; + const to = backward ? $from.pos : $from.pos + adjacent.nodeSize; + editor.view.dispatch(state.tr.delete(from, to)); + return true; + } + + return false; +}; + +/** + * TipTap extension governing how template-variable tokens behave while editing: + * + * - Backspace/Delete remove the whole token at once (atomic deletion). + * - The token's inner `{value}` text is read-only: typing, pasting or pressing + * Enter while the cursor is inside it is blocked, so the token can be styled + * as a whole (via mark commands) but never edited into free text. + */ +export const TemplateVariableEditingBehavior = Extension.create({ + name: "templateVariableEditingBehavior", + + // Run before BlockNote's own Backspace/Delete handlers so the whole token is + // removed instead of letting the default per-character deletion kick in. + priority: 1000, + + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => deleteTemplateVariable(editor, true), + Delete: ({ editor }) => deleteTemplateVariable(editor, false), + }; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("templateVariableReadonlyContent"), + props: { + // Block any text inserted while the cursor sits within a token. Mark + // commands (bold, color…) dispatch transactions directly and are not + // routed through these handlers, so styling the token still works. + handleTextInput: (view, from) => + isInsideTemplateVariable(view.state.doc.resolve(from)), + handlePaste: (view) => + isInsideTemplateVariable(view.state.selection.$from), + handleKeyDown: (view, event) => + event.key === "Enter" && + isInsideTemplateVariable(view.state.selection.$from), + }, + }), + ]; + }, +}); diff --git a/src/frontend/src/features/blocknote/inline-template-variable/index.test.tsx b/src/frontend/src/features/blocknote/inline-template-variable/index.test.tsx new file mode 100644 index 00000000..35594c8d --- /dev/null +++ b/src/frontend/src/features/blocknote/inline-template-variable/index.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { buildTemplateVariableInsertion } from './index'; + +describe('buildTemplateVariableInsertion', () => { + it('seeds the token and trailing space with the provided styles', () => { + const result = buildTemplateVariableInsertion<{}>( + { value: 'name', label: 'Name' }, + { bold: true, textColor: 'red' }, + ); + + // The token displays the human label, while value stays in props. Both the + // token and the trailing space carry the styles, so the variable inherits + // the surrounding formatting and text typed right after it keeps those + // styles (the caret lands after the styled space). + expect(result).toEqual([ + { + type: 'template-variable', + props: { value: 'name', label: 'Name' }, + content: [{ type: 'text', text: 'Name', styles: { bold: true, textColor: 'red' } }], + }, + { type: 'text', text: ' ', styles: { bold: true, textColor: 'red' } }, + ]); + }); + + it('defaults to no styles when none are provided', () => { + const result = buildTemplateVariableInsertion({ value: 'x', label: 'X' }); + + expect(result).toEqual([ + { + type: 'template-variable', + props: { value: 'x', label: 'X' }, + content: [{ type: 'text', text: 'X', styles: {} }], + }, + { type: 'text', text: ' ', styles: {} }, + ]); + }); +}); diff --git a/src/frontend/src/features/blocknote/inline-template-variable/index.tsx b/src/frontend/src/features/blocknote/inline-template-variable/index.tsx index 53962322..7d9f6c77 100644 --- a/src/frontend/src/features/blocknote/inline-template-variable/index.tsx +++ b/src/frontend/src/features/blocknote/inline-template-variable/index.tsx @@ -1,39 +1,66 @@ import { createReactInlineContentSpec } from "@blocknote/react"; import React, { useMemo } from "react"; import { useBlockNoteEditor, useComponentsContext } from "@blocknote/react"; -import { BlockSchema, StyleSchema, defaultInlineContentSpecs, InlineContentSchemaFromSpecs } from "@blocknote/core"; +import { BlockSchema, StyleSchema, Styles, defaultInlineContentSpecs, InlineContentSchemaFromSpecs } from "@blocknote/core"; import { Icon, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit"; -import { PlaceholdersRetrieve200 } from "@/features/api/gen"; import { useTranslation } from "react-i18next"; +import { PlaceholderVariable } from "./use-placeholder-variables"; + +export const TEMPLATE_VARIABLE_TYPE = "template-variable" as const; export const InlineTemplateVariable = createReactInlineContentSpec( { - type: "template-variable", - content: "none", + type: TEMPLATE_VARIABLE_TYPE, + // "styled" (instead of "none") so the `{value}` token is stored as styled + // text. This lets the standard formatting toolbar (bold, italic, color…) + // apply marks that BlockNote persists in the block JSON — a "none" inline + // content drops them on serialization, losing every style at render time. + content: "styled", 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 - - {`{${props.value}}`} - - ); - }, + render: ({ contentRef }) => ( + + ), } ); +/** + * Builds the inline content inserted when picking a variable: the token itself + * followed by a trailing space. + * + * The token displays the human `label` (e.g. "Nom de l'expéditeur") as its + * styled content so it can be formatted, while `value`/`label` stay in props — + * `value` being the canonical slug used for resolution and email export. Both + * the token and the trailing space are seeded with the provided styles — + * typically the active styles at the cursor — so the variable inherits the + * surrounding formatting and the text typed right after it keeps those styles + * (the caret lands after the styled space and inherits its marks). + * + * @param variable - The picked variable (`value` slug and display `label`). + * @param styles - Styles to seed the token and trailing space with. + */ +export const buildTemplateVariableInsertion = ( + { value, label }: PlaceholderVariable, + styles: Styles = {} as Styles, +) => [ + { + type: TEMPLATE_VARIABLE_TYPE, + props: { value, label }, + content: [{ type: "text" as const, text: label, styles }], + }, + { type: "text" as const, text: " ", styles }, +]; + type TemplateVariableInlineContentSchema = InlineContentSchemaFromSpecs< - typeof defaultInlineContentSpecs & { 'template-variable': typeof InlineTemplateVariable } + typeof defaultInlineContentSpecs & { [TEMPLATE_VARIABLE_TYPE]: typeof InlineTemplateVariable } >; type TemplateVariableSelectorProps = { - variables: PlaceholdersRetrieve200; + variables: PlaceholderVariable[]; isLoading: boolean; } @@ -42,13 +69,12 @@ export const TemplateVariableSelector = ({ variables, isLoading }: TemplateVaria const editor = useBlockNoteEditor(); const Components = useComponentsContext()!; const variableItems = useMemo(() => { - if (!variables) return []; - return Object.entries(variables).map(([value, label]) => ({ + return variables.map(({ value, label }) => ({ text: label, icon: null, isSelected: false, onClick: () => { - editor.insertInlineContent([{ type: "template-variable", props: { label, value } }, " "]); + editor.insertInlineContent(buildTemplateVariableInsertion({ value, label }, editor.getActiveStyles())); } })); }, [editor, variables]); @@ -64,7 +90,7 @@ export const TemplateVariableSelector = ({ variables, isLoading }: TemplateVaria ); } - if (!variables) { + if (!variables.length) { return null; } diff --git a/src/frontend/src/features/blocknote/inline-template-variable/use-placeholder-variables.test.ts b/src/frontend/src/features/blocknote/inline-template-variable/use-placeholder-variables.test.ts new file mode 100644 index 00000000..c1fbf2d7 --- /dev/null +++ b/src/frontend/src/features/blocknote/inline-template-variable/use-placeholder-variables.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import type { TFunction } from 'i18next'; +import { resolvePlaceholderLabel } from './use-placeholder-variables'; + +// Minimal TFunction stub: looks the key up in `dict`, otherwise honours the +// `defaultValue` option (which the resolver always passes). Only the +// `(key, { defaultValue })` shape used by resolvePlaceholderLabel is exercised. +const makeT = (dict: Record = {}): TFunction => + ((key: string, opts?: { defaultValue?: string }) => + dict[key] ?? opts?.defaultValue ?? key) as unknown as TFunction; + +describe('resolvePlaceholderLabel', () => { + describe('built-in fields (empty meta, localized client-side)', () => { + it('translates the slug from the "placeholders" namespace', () => { + const t = makeT({ name: "Nom d'expéditeur" }); + expect(resolvePlaceholderLabel('name', {}, t, 'fr-FR')).toBe( + "Nom d'expéditeur", + ); + }); + + it('falls back to the slug when the namespace has no entry', () => { + expect(resolvePlaceholderLabel('name', {}, makeT(), 'fr-FR')).toBe('name'); + }); + }); + + describe('custom fields without translations', () => { + it('uses the schema title and never consults the i18next namespace', () => { + // The `t` map would return "FROM_NAMESPACE" — proving it is not used here. + const t = makeT({ job_title: 'FROM_NAMESPACE' }); + expect( + resolvePlaceholderLabel('job_title', { title: 'Job title' }, t, 'fr-FR'), + ).toBe('Job title'); + }); + }); + + describe('custom fields with x-i18n translations', () => { + const i18n = { 'fr-FR': 'Fonction régionale', fr: 'Fonction', en: 'Job title' }; + + it('prefers the exact regional language when present', () => { + expect( + resolvePlaceholderLabel('job_title', { i18n }, makeT(), 'fr-FR'), + ).toBe('Fonction régionale'); + }); + + it('falls back from the regional code to the base language', () => { + expect( + resolvePlaceholderLabel( + 'job_title', + { i18n: { fr: 'Fonction', en: 'Job title' } }, + makeT(), + 'fr-FR', + ), + ).toBe('Fonction'); + }); + + it('falls back to English when the active language is missing', () => { + expect( + resolvePlaceholderLabel( + 'job_title', + { i18n: { fr: 'Fonction', en: 'Job title' } }, + makeT(), + 'de-DE', + ), + ).toBe('Job title'); + }); + + it('prefers a translation over the title when both exist', () => { + expect( + resolvePlaceholderLabel( + 'job_title', + { title: 'Title', i18n: { en: 'Translated' } }, + makeT(), + 'en-US', + ), + ).toBe('Translated'); + }); + + it('falls back to the title when no translation matches', () => { + expect( + resolvePlaceholderLabel( + 'job_title', + { title: 'Default title', i18n: { fr: 'Fonction' } }, + makeT(), + 'de-DE', + ), + ).toBe('Default title'); + }); + + it('falls back to the slug when neither a matching translation nor a title exist', () => { + expect( + resolvePlaceholderLabel( + 'job_title', + { i18n: { fr: 'Fonction' } }, + makeT(), + 'de-DE', + ), + ).toBe('job_title'); + }); + + it('treats an empty i18n object as "no match" and uses the title', () => { + // An empty object is still truthy, so the resolver enters the i18n branch + // and must degrade gracefully to the title. + expect( + resolvePlaceholderLabel('job_title', { title: 'T', i18n: {} }, makeT(), 'fr'), + ).toBe('T'); + }); + }); +}); diff --git a/src/frontend/src/features/blocknote/inline-template-variable/use-placeholder-variables.ts b/src/frontend/src/features/blocknote/inline-template-variable/use-placeholder-variables.ts new file mode 100644 index 00000000..a43065d5 --- /dev/null +++ b/src/frontend/src/features/blocknote/inline-template-variable/use-placeholder-variables.ts @@ -0,0 +1,87 @@ +import { useMemo } from "react"; +import { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { usePlaceholdersRetrieve } from "@/features/api/gen"; + +/** + * Label metadata returned by the placeholders endpoint for a single field. + * + * Built-in fields come as an empty object and are localized client-side from + * the "placeholders" i18next namespace. Custom (instance-defined) attributes + * carry their schema `title` and, when available, the `x-i18n` translations. + */ +export type PlaceholderFieldMeta = { + title?: string; + i18n?: Record; +}; + +export type PlaceholderVariable = { + value: string; + label: string; +}; + +/** + * Resolve the display label of a placeholder field. + * + * @param value - The field slug (e.g. "name", "job_title"). + * @param meta - The label metadata returned by the backend. + * @param t - The translation function bound to the "placeholders" namespace. + * @param language - The active language code (may be regional, e.g. "fr-FR"). + * @returns The localized label, falling back to the slug. + */ +export const resolvePlaceholderLabel = ( + value: string, + meta: PlaceholderFieldMeta, + t: TFunction, + language: string, +): string => { + // Instance-defined attribute with translations: pick the active language. + if (meta?.i18n) { + // Fall back to the base language ("fr-FR" -> "fr") since the backend i18n + // map may only expose base codes. + const baseLanguage = language.split("-")[0]; + return ( + meta.i18n[language] ?? + meta.i18n[baseLanguage] ?? + meta.i18n.en ?? + meta.title ?? + value + ); + } + // Instance-defined attribute without translations: use its schema title. + if (meta?.title) { + return meta.title; + } + // Built-in placeholder: translated client-side from the "placeholders" namespace. + return t(value, { ns: "placeholders", defaultValue: value }); +}; + +/** + * Fetch the available template/signature variables with localized labels. + * + * @param enabled - Whether the underlying query should run. + * @returns The resolved variables and the loading state. + */ +export const usePlaceholderVariables = ( + enabled: boolean = true, +): { variables: PlaceholderVariable[]; isLoading: boolean } => { + const { t, i18n } = useTranslation(); + const { data: { data: placeholders } = {}, isLoading } = usePlaceholdersRetrieve({ + query: { + enabled, + refetchOnMount: true, + refetchOnWindowFocus: true, + }, + }); + + const language = i18n.language; + const variables = useMemo(() => { + if (!placeholders) return []; + return Object.entries(placeholders).map(([value, meta]) => ({ + value, + label: resolvePlaceholderLabel(value, meta, t, language), + })); + }, [placeholders, t, language]); + + return { variables, isLoading }; +}; diff --git a/src/frontend/src/features/blocknote/utils.test.ts b/src/frontend/src/features/blocknote/utils.test.ts index f08ab474..24dad99d 100644 --- a/src/frontend/src/features/blocknote/utils.test.ts +++ b/src/frontend/src/features/blocknote/utils.test.ts @@ -1,6 +1,7 @@ import type { Block } from '@blocknote/core'; -import { resolveTemplateVariables } from './utils'; +import { backfillTemplateVariableContent, resolveTemplateVariables } from './utils'; import { + AnyInlineContent, bulletListItem, divider, image, @@ -9,6 +10,17 @@ import { templateVariable, } from './__tests__/block-factories'; +// Legacy `template-variable` token as persisted before the inline spec +// switched to `content: "styled"`: props are filled but `content` is absent. +const legacyTemplateVariable = ( + value: string, + label?: string, +): AnyInlineContent => + ({ + type: 'template-variable', + props: label === undefined ? { value } : { value, label }, + }) as unknown as AnyInlineContent; + // resolveTemplateVariables is typed against the public Block schema; the // factories return loosely-typed blocks, so we cast at the boundary. const asBlocks = (blocks: unknown[]): Block[] => blocks as Block[]; @@ -116,6 +128,23 @@ describe('resolveTemplateVariables', () => { expect(content[1].styles).toEqual({}); }); + it('carries the styles applied to the variable onto the resolved text', () => { + const blocks = asBlocks([ + paragraph([ + templateVariable('name', 'Name', { bold: true, textColor: 'red' }), + ]), + ]); + + const resolved = resolveTemplateVariables(blocks, { name: 'Carol' }); + + const content = resolved[0].content as { type: string; text: string; styles: Record }[]; + expect(content[0]).toEqual({ + type: 'text', + text: 'Carol', + styles: { bold: true, textColor: 'red' }, + }); + }); + it('does not mutate the input blocks', () => { const blocks = asBlocks([ paragraph([ @@ -130,3 +159,69 @@ describe('resolveTemplateVariables', () => { expect(blocks).toEqual(snapshot); }); }); + +describe('backfillTemplateVariableContent', () => { + it('seeds the styled content of a legacy token from its label', () => { + const blocks = [ + paragraph([ + styledText('Hello '), + legacyTemplateVariable('name', 'Sender name'), + ]) as unknown as Record, + ]; + + const [block] = backfillTemplateVariableContent(blocks); + const content = block.content as { content: { type: string; text: string }[] }[]; + + expect(content[1].content).toEqual([ + { type: 'text', text: 'Sender name', styles: {} }, + ]); + }); + + it('falls back to the slug when the label is missing', () => { + const blocks = [ + paragraph([legacyTemplateVariable('user_name')]) as unknown as Record, + ]; + + const [block] = backfillTemplateVariableContent(blocks); + const content = block.content as { content: { text: string }[] }[]; + + expect(content[0].content[0].text).toBe('user_name'); + }); + + it('leaves already-populated tokens untouched', () => { + const blocks = [ + paragraph([templateVariable('name', 'Sender name')]) as unknown as Record, + ]; + const snapshot = JSON.parse(JSON.stringify(blocks)); + + const result = backfillTemplateVariableContent(blocks); + + expect(result[0].content).toEqual(snapshot[0].content); + }); + + it('recurses into children blocks', () => { + const blocks = [ + bulletListItem('Parent', {}, [ + bulletListItem([legacyTemplateVariable('nested', 'Nested')]), + ]) as unknown as Record, + ]; + + const result = backfillTemplateVariableContent(blocks); + const child = (result[0].children as Record[])[0]; + const childContent = child.content as { content: { text: string }[] }[]; + + expect(childContent[0].content[0].text).toBe('Nested'); + }); + + it('preserves blocks without a content array', () => { + const blocks = [ + divider() as unknown as Record, + image('https://example.com/a.png') as unknown as Record, + ]; + + const result = backfillTemplateVariableContent(blocks); + + expect(result[0].type).toBe('divider'); + expect(result[1].type).toBe('image'); + }); +}); diff --git a/src/frontend/src/features/blocknote/utils.ts b/src/frontend/src/features/blocknote/utils.ts index 06185f1a..a0b1724a 100644 --- a/src/frontend/src/features/blocknote/utils.ts +++ b/src/frontend/src/features/blocknote/utils.ts @@ -2,6 +2,7 @@ import * as locales from '@blocknote/core/locales'; import { Block } from '@blocknote/core'; import { TFunction } from 'i18next'; import { ALLOWED_IMAGE_MIME_TYPES } from '@/features/blocknote/image-block'; +import { TEMPLATE_VARIABLE_TYPE } from '@/features/blocknote/inline-template-variable'; /** * Builds the BlockNote i18n dictionary for the given locale. @@ -85,9 +86,12 @@ export const resolveTemplateVariables = ( resolvedBlock.content = block.content.flatMap( // eslint-disable-next-line @typescript-eslint/no-explicit-any (ic: any) => { - if (ic.type === 'template-variable') { + if (ic.type === TEMPLATE_VARIABLE_TYPE) { const value = resolvedValues[ic.props?.value] ?? `{${ic.props?.value}}`; - return { type: 'text' as const, text: value, styles: {} }; + // Carry over the styles applied to the token so the + // resolved text keeps its bold/italic/color formatting. + const styles = ic.content?.[0]?.styles ?? {}; + return { type: 'text' as const, text: value, styles }; } return ic; }, @@ -101,3 +105,44 @@ export const resolveTemplateVariables = ( return resolvedBlock; }); }; + +/** + * Backfills the styled `content` of legacy `template-variable` inline nodes. + * + * These tokens used to be stored with `content: "none"` (no styled content), + * the slug being rendered from `props.value`. The inline spec now uses + * `content: "styled"` and renders the token from its `content`, so a legacy + * node with an empty `content` shows up as an empty blue chip. We seed the + * missing content from the persisted `label` (falling back to the `value` + * slug) so old signatures and templates keep displaying their variable names. + * + * Operates on the raw JSON blocks (pre-`useCreateBlockNote`), hence the loose + * typing. Recurses into children blocks. + */ +export const backfillTemplateVariableContent = ( + blocks: Record[], +): Record[] => { + return blocks.map((block) => { + const result = { ...block }; + + if (Array.isArray(result.content)) { + result.content = result.content.map((ic: Record) => { + const isEmptyToken = + ic?.type === TEMPLATE_VARIABLE_TYPE && + (!Array.isArray(ic.content) || ic.content.length === 0); + if (!isEmptyToken) return ic; + + const props = (ic.props ?? {}) as Record; + const text = (props.label as string) || (props.value as string) || ''; + return { ...ic, content: [{ type: 'text', text, styles: {} }] }; + }); + } + + const children = result.children; + if (Array.isArray(children) && children.length > 0) { + result.children = backfillTemplateVariableContent(children as Record[]); + } + + return result; + }); +}; diff --git a/src/frontend/src/features/i18n/initI18n.ts b/src/frontend/src/features/i18n/initI18n.ts index ba75634c..ad09f7e6 100644 --- a/src/frontend/src/features/i18n/initI18n.ts +++ b/src/frontend/src/features/i18n/initI18n.ts @@ -11,10 +11,11 @@ i18n .init({ lng: getLanguage(), supportedLngs: LANGUAGES_ALLOWED, - // We register two namespaces + // Register namespaces // - common: for the common strings // - roles: for the roles strings as they cannot be extracted by i18next-cli as key are dynamic - ns: ["common", "roles"], + // - placeholders: for the built-in template/signature placeholders (dynamic keys, same as roles) + ns: ["common", "roles", "placeholders"], defaultNS: "common", // Use flat keys and avoid interpreting ':' or '.' in natural language keys keySeparator: false, diff --git a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/template-composer.tsx b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/template-composer.tsx index 571ccf69..a425304d 100644 --- a/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/template-composer.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-settings/modal-compose-template/template-composer.tsx @@ -1,12 +1,14 @@ import { BlockNoteViewField } from "@/features/blocknote/blocknote-view-field"; import { BlockNoteEditor, BlockNoteEditorOptions, BlockNoteSchema, defaultBlockSpecs, defaultInlineContentSpecs } from "@blocknote/core"; -import { InlineTemplateVariable, TemplateVariableSelector } from "@/features/blocknote/inline-template-variable"; +import { buildTemplateVariableInsertion, InlineTemplateVariable, TemplateVariableSelector } from "@/features/blocknote/inline-template-variable"; +import { TemplateVariableEditingBehavior } from "@/features/blocknote/inline-template-variable/editing-behavior"; +import { usePlaceholderVariables } from "@/features/blocknote/inline-template-variable/use-placeholder-variables"; import { FieldProps } from "@gouvfr-lasuite/cunningham-react"; import { forwardRef, useEffect, useImperativeHandle, useMemo } from "react"; import { useFormContext } from "react-hook-form"; import { Toolbar } from "@/features/blocknote/toolbar"; import { BlockSignature, BlockSignatureConfigProps, SignatureTemplateSelector } from "@/features/blocknote/signature-block"; -import { MessageTemplateTypeChoices, useMailboxesMessageTemplatesAvailableList, usePlaceholdersRetrieve } from "@/features/api/gen"; +import { MessageTemplateTypeChoices, useMailboxesMessageTemplatesAvailableList } from "@/features/api/gen"; import { useMailboxContext } from "@/features/providers/mailbox"; import { imageBlockSpec } from "@/features/blocknote/image-block"; import { SmartTrailingBlock } from "@/features/blocknote/smart-trailing-block"; @@ -51,24 +53,18 @@ export const TemplateComposer = forwardRef ({ exportContent }), [exportContent]); - const { data: { data: placeholders = {} } = {}, isLoading: isLoadingPlaceholders } = usePlaceholdersRetrieve({ - query: { - enabled: allowVariables, - refetchOnMount: true, - refetchOnWindowFocus: true, - } - }); - const canShowPlaceholdersMenu = allowVariables && !isLoadingPlaceholders && !!Object.keys(placeholders).length; + const { variables, isLoading: isLoadingPlaceholders } = usePlaceholderVariables(allowVariables); + const canShowPlaceholdersMenu = allowVariables && !isLoadingPlaceholders && variables.length > 0; const getPlaceholderMenuItems = (editor: BlockNoteEditor) => { - return Object.entries(placeholders).map(([value, label]) => ({ + return variables.map(({ value, label }) => ({ title: label, onItemClick: () => { - editor.insertInlineContent([{ type: "template-variable", props: { value, label } }, " "]); + editor.insertInlineContent(buildTemplateVariableInsertion({ value, label }, editor.getActiveStyles())); } })); }; @@ -161,7 +157,7 @@ export const TemplateComposer = forwardRef {canShowPlaceholdersMenu && } diff --git a/src/frontend/src/features/layouts/components/main/index.tsx b/src/frontend/src/features/layouts/components/main/index.tsx index be1e4074..77a51d55 100644 --- a/src/frontend/src/features/layouts/components/main/index.tsx +++ b/src/frontend/src/features/layouts/components/main/index.tsx @@ -12,6 +12,7 @@ import { useTheme } from "@/features/providers/theme"; import { LayoutProvider, useLayoutDragContext } from "@/features/layouts/components/layout-context"; import { AttachmentPreviewModal } from "@/features/layouts/components/thread-view/components/attachment-preview-modal"; import { Link } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; export const MainLayout = ({ children }: PropsWithChildren) => { return ( @@ -35,6 +36,7 @@ export const MainLayout = ({ children }: PropsWithChildren) => { } const MainLayoutContent = ({ children }: PropsWithChildren<{ simple?: boolean }>) => { + const { t } = useTranslation(); const { mailboxes, queryStates } = useMailboxContext(); const hasNoMailbox = queryStates.mailboxes.status === 'success' && mailboxes!.length === 0; const { theme, variant } = useTheme(); @@ -46,7 +48,7 @@ const MainLayoutContent = ({ children }: PropsWithChildren<{ simple?: boolean }> isLeftPanelOpen={isLeftPanelOpen} setIsLeftPanelOpen={setIsLeftPanelOpen} leftPanelContent={} - icon={logo} + icon={{t("logo")}} hideLeftPanelOnDesktop={hasNoMailbox} isDragging={isDragging} > diff --git a/src/frontend/src/features/signatures/components/signature-composer/index.tsx b/src/frontend/src/features/signatures/components/signature-composer/index.tsx index cf42bc90..eb554099 100644 --- a/src/frontend/src/features/signatures/components/signature-composer/index.tsx +++ b/src/frontend/src/features/signatures/components/signature-composer/index.tsx @@ -5,9 +5,10 @@ import { SuggestionMenuController } from "@blocknote/react"; import { FieldProps } from "@gouvfr-lasuite/cunningham-react"; import { forwardRef, useImperativeHandle } from "react"; import { useFormContext } from "react-hook-form"; -import { InlineTemplateVariable, TemplateVariableSelector } from "@/features/blocknote/inline-template-variable"; +import { buildTemplateVariableInsertion, InlineTemplateVariable, TemplateVariableSelector } from "@/features/blocknote/inline-template-variable"; +import { TemplateVariableEditingBehavior } from "@/features/blocknote/inline-template-variable/editing-behavior"; +import { usePlaceholderVariables } from "@/features/blocknote/inline-template-variable/use-placeholder-variables"; import { Toolbar } from "@/features/blocknote/toolbar"; -import { usePlaceholdersRetrieve } from "@/features/api/gen"; import { imageBlockSpec } from "@/features/blocknote/image-block"; import { useBase64Composer, Base64ComposerHandle } from "@/features/blocknote/hooks/use-base64-composer"; import { ColumnBlock, ColumnListBlock } from "@/features/blocknote/column-layout-block"; @@ -48,18 +49,19 @@ export const SignatureComposer = forwardRef ({ exportContent }), [exportContent]); - const { data: { data: placeholders = {} } = {}, isLoading: isLoadingPlaceholders } = usePlaceholdersRetrieve(); - const canShowPlaceholdersMenu = !isLoadingPlaceholders && !!Object.keys(placeholders).length; + const { variables, isLoading: isLoadingPlaceholders } = usePlaceholderVariables(); + const canShowPlaceholdersMenu = !isLoadingPlaceholders && variables.length > 0; const getPlaceholderMenuItems = (editor: BlockNoteEditor) => { - return Object.entries(placeholders).map(([value, label]) => ({ + return variables.map(({ value, label }) => ({ title: label, onItemClick: () => { - editor.insertInlineContent([{ type: "template-variable", props: { value, label } }, " "]); + editor.insertInlineContent(buildTemplateVariableInsertion({ value, label }, editor.getActiveStyles())); } })); }; @@ -78,7 +80,7 @@ export const SignatureComposer = forwardRef {canShowPlaceholdersMenu && - + } {canShowPlaceholdersMenu &&