mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
✨(frontend) translate template placeholder and add user_name builtin var
Since we disabled backend i18n, template placeholders were not translated according to the active frontend language. We revamp the logic to delegate translation of those variables to the frontend. Furthermore, we add a new builtin variable that allows to bind the username into template. Last but not least we add support of style to template variable inline elements and we render name instead of value into the editor.
This commit is contained in:
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Verbose label for the field"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Verbose label for the field",
|
||||
"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)
|
||||
|
||||
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Anv ar c'haser",
|
||||
"recipient_name": "Anv an degemerer",
|
||||
"user_name": "Anv an implijer"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Absendername",
|
||||
"recipient_name": "Name des Empfängers",
|
||||
"user_name": "Benutzername"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Sender name",
|
||||
"recipient_name": "Recipient name",
|
||||
"user_name": "User name"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Nombre del remitente",
|
||||
"recipient_name": "Nombre del destinatario",
|
||||
"user_name": "Nombre del usuario"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Nom d'expéditeur",
|
||||
"recipient_name": "Nom du destinataire",
|
||||
"user_name": "Nom de l'utilisateur"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Nome del mittente",
|
||||
"recipient_name": "Nome del destinatario",
|
||||
"user_name": "Nome dell'utente"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Naam afzender",
|
||||
"recipient_name": "Naam van de ontvanger",
|
||||
"user_name": "Gebruikersnaam"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Nome do remetente",
|
||||
"recipient_name": "Nome do destinatário",
|
||||
"user_name": "Nome do utilizador"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Имя отправителя",
|
||||
"recipient_name": "Имя получателя",
|
||||
"user_name": "Имя пользователя"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Ім'я відправника",
|
||||
"recipient_name": "Ім'я одержувача",
|
||||
"user_name": "Ім'я користувача"
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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<string, unknown> = {},
|
||||
): 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <a> 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 <a> 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 <a> itself carries the red color (not the default blue).
|
||||
expect(html).toMatch(/<a[^>]*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(`"<h2 style="color:#e03e3e">Important</h2>"`);
|
||||
expect(html).toMatchInlineSnapshot(`"<h2 style="margin:0;font-size:2em;font-weight:700;color:#e03e3e">Important</h2>"`);
|
||||
});
|
||||
|
||||
it('renders an image with caption and center alignment', () => {
|
||||
|
||||
@@ -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<any, any, any>;
|
||||
@@ -24,6 +25,21 @@ const COLORS: Record<string, { text: string; background: string }> = {
|
||||
pink: { text: '#ad1a72', background: '#f4dfeb' },
|
||||
};
|
||||
|
||||
// BlockNote renders heading sizes via CSS variables on `data-level` attributes,
|
||||
// not on the <h1>-<h6> 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<number, CSSProperties> = {
|
||||
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 <a> 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 key={i} href={link.href} style={{ color: '#0b6e99', textDecoration: 'underline' }}>
|
||||
<Link key={i} href={link.href} style={{ color: linkColor, textDecoration: 'underline' }}>
|
||||
{link.content.map((st, j) => renderStyledText(st, j))}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (ic.type === 'template-variable') {
|
||||
const variable = ic as unknown as { props: Record<string, string> };
|
||||
return <span key={i} data-inline-content-type="template-variable">{`{${variable.props.value}}`}</span>;
|
||||
if (ic.type === TEMPLATE_VARIABLE_TYPE) {
|
||||
const variable = ic as unknown as { props: Record<string, string>; 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 <span key={i} data-inline-content-type={TEMPLATE_VARIABLE_TYPE}>{renderStyledText(token, 0)}</span>;
|
||||
}
|
||||
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 (
|
||||
<Heading key={key} as={as} style={styleOrUndefined(style)}>
|
||||
<Heading key={key} as={as} style={{ margin: 0, ...HEADING_LEVEL_STYLES[level], ...style }}>
|
||||
{renderInlineContent(content || [])}
|
||||
</Heading>
|
||||
);
|
||||
|
||||
@@ -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<string, unknown>[]) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+233
@@ -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 `<p>{leading}[{token}]{trailing}</p>` 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<string, (props: { editor: Editor }) => 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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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: {} },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
<span data-inline-type="template-variable">
|
||||
{`{${props.value}}`}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
render: ({ contentRef }) => (
|
||||
<span data-inline-type={TEMPLATE_VARIABLE_TYPE} ref={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 = <S extends StyleSchema>(
|
||||
{ value, label }: PlaceholderVariable,
|
||||
styles: Styles<S> = {} as Styles<S>,
|
||||
) => [
|
||||
{
|
||||
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<BlockSchema, TemplateVariableInlineContentSchema, StyleSchema>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+108
@@ -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<string, string> = {}): 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+87
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
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<PlaceholderVariable[]>(() => {
|
||||
if (!placeholders) return [];
|
||||
return Object.entries(placeholders).map(([value, meta]) => ({
|
||||
value,
|
||||
label: resolvePlaceholderLabel(value, meta, t, language),
|
||||
}));
|
||||
}, [placeholders, t, language]);
|
||||
|
||||
return { variables, isLoading };
|
||||
};
|
||||
@@ -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<string, unknown> }[];
|
||||
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<string, unknown>,
|
||||
];
|
||||
|
||||
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<string, unknown>,
|
||||
];
|
||||
|
||||
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<string, unknown>,
|
||||
];
|
||||
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<string, unknown>,
|
||||
];
|
||||
|
||||
const result = backfillTemplateVariableContent(blocks);
|
||||
const child = (result[0].children as Record<string, unknown>[])[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<string, unknown>,
|
||||
image('https://example.com/a.png') as unknown as Record<string, unknown>,
|
||||
];
|
||||
|
||||
const result = backfillTemplateVariableContent(blocks);
|
||||
|
||||
expect(result[0].type).toBe('divider');
|
||||
expect(result[1].type).toBe('image');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>[],
|
||||
): Record<string, unknown>[] => {
|
||||
return blocks.map((block) => {
|
||||
const result = { ...block };
|
||||
|
||||
if (Array.isArray(result.content)) {
|
||||
result.content = result.content.map((ic: Record<string, unknown>) => {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>[]);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
+10
-14
@@ -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<Base64ComposerHandle, TemplateCompose
|
||||
defaultValue,
|
||||
blockNoteOptions,
|
||||
trailingBlock: false,
|
||||
extensions: [SmartTrailingBlock],
|
||||
extensions: [SmartTrailingBlock, TemplateVariableEditingBehavior],
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({ 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<TemplateComposerBlockSchema, TemplateComposerInlineContentSchema, TemplateComposerStyleSchema>) => {
|
||||
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<Base64ComposerHandle, TemplateCompose
|
||||
/>
|
||||
{canShowPlaceholdersMenu &&
|
||||
<TemplateVariableSelector
|
||||
variables={placeholders}
|
||||
variables={variables}
|
||||
isLoading={isLoadingPlaceholders}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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={<LeftPanel hasNoMailbox={hasNoMailbox} />}
|
||||
icon={<Link to="/"><img src={`/images/${theme}/app-logo-${variant}.svg`} alt="logo" height={40} /></Link>}
|
||||
icon={<Link to="/"><img src={`/images/${theme}/app-logo-${variant}.svg`} alt={t("logo")} height={40} /></Link>}
|
||||
hideLeftPanelOnDesktop={hasNoMailbox}
|
||||
isDragging={isDragging}
|
||||
>
|
||||
|
||||
@@ -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<Base64ComposerHandle, SignatureCompo
|
||||
defaultValue,
|
||||
trailingBlock: true,
|
||||
blockNoteOptions: { autofocus: "end", ...blockNoteOptions },
|
||||
extensions: [TemplateVariableEditingBehavior],
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({ 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<SignatureComposerBlockSchema, SignatureComposerInlineContentSchema, SignatureComposerStyleSchema>) => {
|
||||
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<Base64ComposerHandle, SignatureCompo
|
||||
>
|
||||
<Toolbar>
|
||||
{canShowPlaceholdersMenu &&
|
||||
<TemplateVariableSelector key="templateVariableSelector" variables={placeholders} isLoading={isLoadingPlaceholders} />
|
||||
<TemplateVariableSelector key="templateVariableSelector" variables={variables} isLoading={isLoadingPlaceholders} />
|
||||
}
|
||||
</Toolbar>
|
||||
{canShowPlaceholdersMenu &&
|
||||
|
||||
Reference in New Issue
Block a user