Le registre des propriétés
Dix types de propriétés, déclarés une fois par espace de travail, validés à l'écriture — et ce que chacun permet de trier, filtrer ou grouper.
Les propriétés se déclarent par espace de travail, pas par liste. Un seul registre, partagé par toutes les listes de l'espace.
C'est pour cela qu'une carte garde ses valeurs quand elle passe d'une liste à l'autre : la propriété dont elle porte une valeur est le même objet avant et après. Un schéma par liste aurait fait de « déplacer cette carte » un « abandonner les champs que la destination n'a pas » — une perte de données déguisée en navigation.
Quelles propriétés une liste donnée affiche est une autre question — c'est
displayedFieldIds sur la liste, et c'est de la présentation. C'est aussi ce que
montre la fiche d'une carte : les propriétés de sa liste, puis celles des
listes où elle est rattachée — pas tout le registre. Une valeur renseignée hors
de ces listes n'est pas cachée, elle est rangée sous « Autres propriétés ». Et
« Ajouter une propriété » depuis une carte l'ajoute à sa liste. Côté API, chaque
liste de ItemDetail.collections porte sa sélection (fieldIds), et
POST /workspaces/{workspaceId}/collections/{collectionId}/fields y ajoute une
propriété sans remplacer les autres. Une liste qui n'a rien choisi
(displayedFieldIds vide) montre tout le registre.
Les dix types
| Type | Contient | Validé contre | Tri / groupement |
|---|---|---|---|
text | Une chaîne | — | oui |
number | Un nombre | — | oui |
select | Une option | config.options, si déclarées | oui |
date | Une date ISO | — | oui |
checkbox | Un booléen | — | oui |
url | Une URL absolue http, https ou mailto | le schéma d'URL | oui |
person | Un membre de cet espace de travail | la table membership | oui |
file | Un fichier envoyé dans cet espace de travail | la table file | oui |
multi_select | Plusieurs options | config.options, si déclarées | non |
multi_person | Plusieurs membres | la table membership | non |
L'API ne restreint pas les opérateurs de filtre par type — le même jeu
(eq, neq, lt, lte, gt, gte, contains, starts_with, in,
not_in, is_empty, is_not_empty) se compile contre n'importe quelle
propriété scalaire, et son sens découle du type. L'interface propose, pour
chaque type, un sous-ensemble plus restreint et raisonnable ; un client qui
pilote directement l'API n'y est pas tenu. Les types tableau sont l'exception,
et mieux vaut savoir pourquoi avant de concevoir autour d'eux.
Types tableau
multi_select et multi_person contiennent une liste. Ils se filtrent par
inclusion — eq et contains veulent tous deux dire « a cette valeur parmi
ses valeurs », parce que c'est ce que signifie filtrer sur une étiquette — et ils
ne peuvent servir ni au tri ni au groupement. Demander l'un ou l'autre
renvoie un 400 qui le dit.
C'est un refus, pas un manque. Postgres les trierait volontiers ; il les trierait selon l'ordre des documents d'un tableau JSON, ce qui ferait afficher à l'interface un contrôle de tri produisant un ordre qu'aucun utilisateur ne pourrait prévoir ni expliquer. Un contrôle qui ment est pire qu'un contrôle absent.
multi_person plutôt qu'une colonne
Une propriété « Assignés » est un multi_person. Elle n'a délibérément pas été
ajoutée comme colonne assignee_ids sur la carte, et la raison se généralise :
l'invariant d'un kanban est que la somme des colonnes vaut la vue. Une carte à deux assignés, groupée par assigné, apparaîtrait dans deux colonnes — le groupement par type tableau est donc refusé, ce qui veut dire qu'un type tableau n'a rien à faire en colonne promue que tout le reste suppose scalaire.
Une fois cet argument posé, il a aussi tranché le singulier : « Assigné » est
une propriété person déclarée, pas une colonne non plus.
Les trois qui dépendent d'une autre table
person, multi_person et file ne sont valides que si la valeur désigne
quelque chose qui existe — une appartenance à cet espace de travail, ou un
fichier de cet espace. C'est vérifié à l'écriture, pas à la lecture.
La validation se fait à l'écriture
Une valeur qui ne correspond pas à son type déclaré est refusée à l'écriture, avec un 400. Elle n'atteint jamais la base.
Cette barrière ne peut pas être déplacée à la lecture, et la raison mérite d'être
dite franchement : une valeur mal typée ne casse pas sa propre ligne. Elle casse
la requête entière de toute vue qui convertit cette propriété — un seul
nombre invalide dans une propriété number empêche toute une liste de se
charger, pour tout le monde. Le seul endroit où l'arrêter, c'est la porte.
Un PATCH sur une carte fusionne properties : modifier une propriété
n'efface pas les autres. Retirer une propriété d'une carte est une opération
explicite, pas l'effet de bord d'une omission.
Les options et leurs couleurs
Un select ou un multi_select peut déclarer des options — les valeurs
permises. Laissez-les vides pour du texte libre.
Il peut aussi déclarer optionColors : une table à part, indexée par la valeur
de l'option, puisée dans la même palette de huit jetons que les statuts. Une
seule palette, pour qu'une étiquette verte soit du même vert que le statut vert
d'à côté.
Que optionColors soit une table à part plutôt qu'un objet option enrichi a
deux conséquences :
- la valeur écrite sur une carte est la chaîne. C'est elle que compare la
validation et que cherche le filtre d'inclusion.
optionsdit ce qui est permis,optionColorscomment ça se présente ; - la couleur ne se compile nulle part — ni dans le tri, ni dans le filtre, ni dans la validité. Une entrée qui nomme une option disparue est ignorée ; une option sans entrée s'affiche en neutre. Rien n'est déduit à l'affichage.
Une option nouvelle écrite sans couleur en reçoit une d'après son libellé :
le même mot a toujours la même couleur, dans toutes les listes et tous les
espaces de travail. L'API la choisit une fois et l'enregistre dans
optionColors : elle se lit ensuite comme n'importe quelle autre couleur, et
vous pouvez la changer ou la rendre neutre après coup. Une option qui existe déjà
n'est jamais recolorée, et passer color: null à la création la garde neutre.
`config` est remplacé en bloc
options et optionColors vivent tous deux dans config, et une écriture
remplace l'objet entier. Envoyez les couleurs avec les options, sinon elles
disparaissent. C'est aussi pourquoi l'emplacement d'affichage d'une propriété
vit dans une tout autre colonne — sinon déplacer un champ dans la barre latérale
aurait effacé ses options sans rien dire.
Pour ajouter une option, ou changer la couleur d'une seule, sans renvoyer les
autres, utilisez POST /workspaces/{workspaceId}/fields/{fieldId}/options avec
{ value, color }. La fusion se fait côté serveur : deux personnes qui ajoutent
une étiquette au même moment gardent chacune la leur. Omettre color conserve la
couleur actuelle (ou, pour une option nouvelle, lui donne la couleur de son
libellé) ; null la rend neutre. Dans l'app, c'est ce que fait la saisie d'une
nouvelle valeur dans un select : le menu propose Créer « x », et chaque
option affiche un bouton de palette au survol.
Champs système
Certaines choses sont adressables dans une vue sans être des propriétés du
registre : sys:title, sys:status, sys:order, sys:created_at, etc.
L'API en publie la liste sous systemFields sur la liste, pour qu'aucun client
n'ait à tenir sa propre copie de ce qu'accepte le compilateur de vues.
Un piège connu
Un select se trie par ordre alphabétique, pas dans l'ordre de ses options.
Une vue triée « par priorité » renvoie critical, high, low, normal.
C'est connu, c'est documenté, et ce n'est pas un bug à corriger à la légère —
l'index de base de données sur lequel s'appuie le tri dépend de cet ordre. Si
vous avez besoin de l'ordre des priorités, encodez-le : 1 — Basse,
2 — Normale.
En ajouter une ne coûte rien structurellement
Créer une propriété, c'est une ligne dans un registre. Cela ne modifie aucune table, ne verrouille rien et ne réécrit aucune carte existante — les cartes sans valeur n'ont simplement pas de clé pour elle.
Trier ou filtrer dessus à grande échelle peut demander un index. L'API vous dira
lequel : POST …/index-hints indique ce dont une vue donnée bénéficierait.
Listes et cartes
Les listes, les cartes qu'elles contiennent, les deux identifiants de chaque carte, et la différence entre là où une carte vit et là où elle apparaît.
Vues enregistrées
Une vue est une donnée — arbre de filtres, tris et groupement — compilée en SQL à un seul endroit. Ce qu'elle sélectionne, et ce qu'elle change à l'affichage.