Modèles pour les données, blocs pour la vue
Le contenu structuré vit dans les modèles de l'espace de travail et atteint un bloc via fields.relation - jamais sous forme de repeater d'enregistrements dans les props du bloc.
cmssy sépare le contenu d'une page en deux couches ayant deux propriétaires différents.
- Les données sont CMS-first. Le contenu structuré et réutilisable - témoignages, entrées de FAQ, membres de l'équipe, bureaux - vit dans les modèles de l'espace de travail et leurs enregistrements. Les éditeurs, ou un client IA via MCP, créent le modèle, ajoutent des enregistrements, les réordonnent et les traduisent, sans déploiement.
- La vue est code-first. Un bloc est un composant React typé dans votre dépôt. Son schéma
propscontient ce qui relève de la vue : textes, variantes, options de mise en page - et des références aux données viafields.relation.
Le pont est fields.relation. Le schéma du bloc déclare quel modèle il affiche, et le SDK remet au composant des enregistrements entièrement résolus au moment du rendu. Le bloc part avec le déploiement ; les enregistrements changent dès qu'un éditeur enregistre.
L'anti-motif : des enregistrements dans un repeater
Un fields.repeater contenant des éléments { quote, author, role } paraît plus rapide, et pendant une semaine environ il l'est. Ensuite il enferme les données dans une instance de bloc sur une seule page. Impossible de les réutiliser ailleurs, de les interroger, de les trier ou de les référencer - et chaque copie dérive de son côté.
Le critère est la propriété, pas la forme. Réservez le repeater à une structure locale à la vue : une liste de points qui n'existe nulle part ailleurs. Dès que les éléments sont du contenu doté d'une identité - des choses que vous ajouteriez, traduiriez ou réutiliseriez indépendamment de cette page - ce sont des enregistrements d'un modèle.
fields.relation
fields.relation({
label: "Testimonials",
model: "testimonial", // le slug du modèle (requis)
mode: "all", // lier tous les enregistrements ; omettre pour choix éditeur
sort: "order_asc", // <fieldKey>_asc / <fieldKey>_desc
limit: 12, // plafond de la collection (50 par défaut)
multiple: true, // mode choix : en sélectionner plusieurs
});Il y a deux modes, et choisir entre eux revient à se demander qui décide de ce qui apparaît :
mode: "all"- le champ est lié à la liste d'enregistrements du modèle. Rien à sélectionner dans l'éditeur ;sortetlimitfaçonnent la liste. Pour les sections « tout afficher » : FAQ, logos, témoignages.- choix (par défaut) - l'éditeur sélectionne un enregistrement, ou plusieurs avec
multiple: true. Quand c'est la page qui décide : une étude de cas mise en avant, les trois offres d'une page tarifs.
Ce que reçoit le composant
La valeur stockée est un identifiant d'enregistrement, ou un tableau d'identifiants. Mais le SDK résout les relations côté serveur, avant le rendu du composant : votre composant ne voit donc jamais d'identifiant :
mode: "all"oumultiple→CmssyModelRecord[]- choix unique →
CmssyModelRecordouundefined
Les champs d'un enregistrement vivent sous record.data, une map fieldKey → valeur typée Record<string, unknown>. Restreignez chaque valeur avant l'affichage et utilisez record.id comme clé React.
Un choix unique vaut undefined quand la référence pend dans le vide - l'enregistrement a été supprimé. Aucun drapeau required ne peut l'exclure : le composant doit donc s'afficher correctement sans lui.
Comment fonctionne la résolution
La résolution est une passe groupée par page, avant l'exécution de tout loader serveur. Les identifiants choisis passent par public.model.recordsByIds ; les champs mode: "all" par public.model.records, dédoublonnés par modèle, tri et limite. Les deux portent la locale de la page.
Une relation ne casse jamais un rendu. Un échec de récupération ou un identifiant orphelin dégrade vers une liste vide ou une valeur absente, et journalise [cmssy] relation resolution failed côté serveur. Le canevas de l'éditeur s'affiche sans la résolution serveur : la valeur y a donc aussi la forme dégradée - une raison de plus pour que le composant ne présuppose pas la présence des données.
De bout en bout : les témoignages
Un modèle, un bloc, aucun loader.
1. Le modèle, CMS-first. Dans l'admin - ou via MCP avec create_model - créez testimonial avec les champs quote (textarea), author (text), role (text), order (number). Ajoutez des enregistrements. C'est de la saisie, pas un déploiement.
2. Le bloc, code-first. Le schéma déclare la relation ; le composant affiche les enregistrements qui arrivent :
// blocks/testimonials/block.ts
import { defineBlock, fields } from "@cmssy/react";
import Testimonials from "./Testimonials";
export const testimonialsProps = {
heading: fields.text({ label: "Heading" }),
items: fields.relation({
label: "Testimonials",
model: "testimonial",
mode: "all",
sort: "order_asc",
limit: 12,
}),
};
export const testimonialsBlock = defineBlock({
type: "testimonials",
label: "Testimonials",
component: Testimonials,
props: testimonialsProps,
});Ajouter un témoignage est désormais de la saisie. Pas de pull request, pas de déploiement, pas de développeur. C'est tout l'intérêt de la séparation.
Étapes suivantes
- Schéma de bloc & types de champs - tous les types, y compris
relation. - API de diffusion GraphQL - interroger les enregistrements vous-même quand une relation ne suffit pas.
- Loaders serveur - pour les données que les relations n'expriment pas.