Problem
When editing a class, property, or individual in the details panel, the editor displays a value row for each existing annotation value, plus a trailing empty row that lets the user add another value. For some annotations that's correct (skos:altLabel, rdfs:comment, skos:example, …) — they're genuinely multi-valued. For others, it's not.
Concretely: skos:prefLabel is constrained by SKOS itself to at most one value per language tag (S14 of the SKOS Reference). When the user opens an entity that already has skos:prefLabel "Foo"@en, the editor shows that value plus a fresh empty Preferred Label row, inviting a second entry. If the user fills it in (especially with @en again), the result violates the SKOS cardinality constraint.
The editor should respect each annotation's cardinality and:
- Not propose an additional empty row for cardinality-1 (or cardinality-1-per-language) annotations once they're filled.
- Optionally, validate or block direct entry of a duplicate for cardinality-1-per-language annotations.
Where the code is
The trailing-empty row is appended unconditionally in ensureTrailingEmpty, defined three times (one per detail panel):
components/editor/ClassDetailPanel.tsx:49
components/editor/PropertyDetailPanel.tsx:42
components/editor/IndividualDetailPanel.tsx:40
Each panel calls ensureTrailingEmpty for label arrays, comment arrays, definition arrays, and per-property annotation value arrays. There's no awareness of which annotation property the array belongs to.
The annotation property catalog is lib/ontology/annotationProperties.ts (KnownAnnotationProperty). It currently carries iri, curie, displayLabel, vocabulary — no cardinality information.
Proposed plan
1. Extend KnownAnnotationProperty with cardinality metadata
// lib/ontology/annotationProperties.ts
export type AnnotationCardinality =
| "single" // at most one value, period (e.g., dcterms:created)
| "single-per-lang" // at most one per language tag (e.g., skos:prefLabel)
| "multiple"; // unbounded (e.g., skos:altLabel, rdfs:comment)
export interface KnownAnnotationProperty {
iri: string;
curie: string;
displayLabel: string;
vocabulary: string;
cardinality: AnnotationCardinality;
}
Populate the existing entries with the SKOS / DC / RDFS / OWL conventions:
| IRI |
Cardinality |
skos:prefLabel |
single-per-lang (S14) |
skos:notation |
single |
skos:definition |
single-per-lang (convention) |
skos:altLabel, skos:hiddenLabel, skos:example, skos:scopeNote, skos:editorialNote, skos:historyNote, skos:changeNote |
multiple |
rdfs:label |
single-per-lang (convention) |
rdfs:comment |
multiple |
rdfs:seeAlso, rdfs:isDefinedBy |
multiple (these go through the relationship section, but listed for completeness) |
dcterms:created, dcterms:modified, dcterms:identifier, dcterms:license, dcterms:title |
single (DC convention; not strictly enforced by DC but expected in practice) |
dcterms:contributor, dcterms:creator, dcterms:subject, dcterms:type |
multiple |
dc:* (DC Elements 1.1) |
multiple (legacy DC is genuinely unbounded) |
A small helper getAnnotationCardinality(iri): AnnotationCardinality returns "multiple" for IRIs not in the catalog (the safe default — unknown annotations should not be artificially restricted).
2. Make ensureTrailingEmpty cardinality-aware
Rewrite as ensureTrailingPlaceholder(values, cardinality):
function ensureTrailingPlaceholder(
values: LocalizedString[],
cardinality: AnnotationCardinality,
): LocalizedString[] {
const langs = values.filter((v) => v.value.trim()).map((v) => v.lang);
switch (cardinality) {
case "single":
// No placeholder if any non-empty value already exists.
if (langs.length > 0) return values;
return [{ value: "", lang: "en" }];
case "single-per-lang":
// Append a placeholder only if the trailing row isn't already empty
// and the user has at least one language not yet covered. The simplest
// useful rule: always allow ONE empty placeholder, but its language
// must default to a language not already filled (or an empty string,
// forcing the user to pick a fresh language).
// Implementation note: pick a default lang that's NOT in `langs`,
// or empty string if all common langs are taken.
...
case "multiple":
// Existing behavior: always one trailing empty.
if (values.length === 0 || values[values.length - 1].value.trim() !== "") {
return [...values, { value: "", lang: "en" }];
}
return values;
}
}
Each call site passes the property's cardinality. For editLabels (always rdfs:label or context-specific to the panel), the call site has the IRI in scope and can look up cardinality.
3. Remove the trailing placeholder dynamically
If the user fills out the placeholder row of a single-per-lang annotation, the editor should not append a second placeholder until the user explicitly clicks an "add another" affordance (which itself should be hidden when no further values are valid).
For single annotations, no placeholder is offered at all once a value exists. The edit affordance for clearing/replacing the existing value is the existing per-row controls.
4. Centralize the helper
ensureTrailingEmpty is currently duplicated across all three panels. As part of this work, consolidate it into a shared utility (e.g., lib/ontology/annotationCardinality.ts) so the rule lives in one place.
5. Tests
__tests__/lib/ontology/annotationCardinality.test.ts (new) — unit tests for the cardinality lookup and ensureTrailingPlaceholder for each cardinality category, including the single-per-lang case where one English value exists and the placeholder defaults to a non-English language (or empty).
- Update
ClassDetailPanel.test.tsx, PropertyDetailPanel.test.tsx, and IndividualDetailPanel.test.tsx to assert:
- Opening an entity with one
skos:prefLabel value shows the value but no extra empty prefLabel row.
- Opening an entity with one
skos:altLabel value shows the value plus a trailing empty row (multi-valued, current behavior preserved).
- For
single-per-lang, after the user fills the placeholder with @en (when @en already exists), validation surfaces the conflict.
Done criteria
Open questions
- Backend validation. The frontend can prevent the user from creating a duplicate, but should the linter (
ontokit-api's linter.py) also flag existing data that violates SKOS cardinality? Probably yes (separate ticket, but cross-link).
- Language defaulting. When
single-per-lang shows a placeholder, what language should it default to? Options: empty (forces the user to pick), the first language not already covered, or the last language used in the project. UX call.
- Error surfacing. If the user tries to enter a duplicate (e.g., two
skos:prefLabel @en), do we block the input, mark the row red, or let it through and let the linter catch it later? Interim: surface inline, let the user submit, let the linter confirm.
Out of scope
- Restructuring the per-panel "Annotations" section UI. The trailing-empty pattern stays; only its conditional appearance is changed.
- Schema-driven cardinality (i.e., reading cardinality constraints declared in the project's own ontology, like
owl:maxCardinality). Important but a much larger feature; this issue covers the well-known annotation vocabularies only.
Related
Problem
When editing a class, property, or individual in the details panel, the editor displays a value row for each existing annotation value, plus a trailing empty row that lets the user add another value. For some annotations that's correct (
skos:altLabel,rdfs:comment,skos:example, …) — they're genuinely multi-valued. For others, it's not.Concretely:
skos:prefLabelis constrained by SKOS itself to at most one value per language tag (S14 of the SKOS Reference). When the user opens an entity that already hasskos:prefLabel "Foo"@en, the editor shows that value plus a fresh emptyPreferred Labelrow, inviting a second entry. If the user fills it in (especially with@enagain), the result violates the SKOS cardinality constraint.The editor should respect each annotation's cardinality and:
Where the code is
The trailing-empty row is appended unconditionally in
ensureTrailingEmpty, defined three times (one per detail panel):components/editor/ClassDetailPanel.tsx:49components/editor/PropertyDetailPanel.tsx:42components/editor/IndividualDetailPanel.tsx:40Each panel calls
ensureTrailingEmptyfor label arrays, comment arrays, definition arrays, and per-property annotation value arrays. There's no awareness of which annotation property the array belongs to.The annotation property catalog is
lib/ontology/annotationProperties.ts(KnownAnnotationProperty). It currently carriesiri,curie,displayLabel,vocabulary— no cardinality information.Proposed plan
1. Extend
KnownAnnotationPropertywith cardinality metadataPopulate the existing entries with the SKOS / DC / RDFS / OWL conventions:
skos:prefLabelsingle-per-lang(S14)skos:notationsingleskos:definitionsingle-per-lang(convention)skos:altLabel,skos:hiddenLabel,skos:example,skos:scopeNote,skos:editorialNote,skos:historyNote,skos:changeNotemultiplerdfs:labelsingle-per-lang(convention)rdfs:commentmultiplerdfs:seeAlso,rdfs:isDefinedBymultiple(these go through the relationship section, but listed for completeness)dcterms:created,dcterms:modified,dcterms:identifier,dcterms:license,dcterms:titlesingle(DC convention; not strictly enforced by DC but expected in practice)dcterms:contributor,dcterms:creator,dcterms:subject,dcterms:typemultipledc:*(DC Elements 1.1)multiple(legacy DC is genuinely unbounded)A small helper
getAnnotationCardinality(iri): AnnotationCardinalityreturns"multiple"for IRIs not in the catalog (the safe default — unknown annotations should not be artificially restricted).2. Make
ensureTrailingEmptycardinality-awareRewrite as
ensureTrailingPlaceholder(values, cardinality):Each call site passes the property's cardinality. For
editLabels(alwaysrdfs:labelor context-specific to the panel), the call site has the IRI in scope and can look up cardinality.3. Remove the trailing placeholder dynamically
If the user fills out the placeholder row of a
single-per-langannotation, the editor should not append a second placeholder until the user explicitly clicks an "add another" affordance (which itself should be hidden when no further values are valid).For
singleannotations, no placeholder is offered at all once a value exists. The edit affordance for clearing/replacing the existing value is the existing per-row controls.4. Centralize the helper
ensureTrailingEmptyis currently duplicated across all three panels. As part of this work, consolidate it into a shared utility (e.g.,lib/ontology/annotationCardinality.ts) so the rule lives in one place.5. Tests
__tests__/lib/ontology/annotationCardinality.test.ts(new) — unit tests for the cardinality lookup andensureTrailingPlaceholderfor each cardinality category, including thesingle-per-langcase where one English value exists and the placeholder defaults to a non-English language (or empty).ClassDetailPanel.test.tsx,PropertyDetailPanel.test.tsx, andIndividualDetailPanel.test.tsxto assert:skos:prefLabelvalue shows the value but no extra emptyprefLabelrow.skos:altLabelvalue shows the value plus a trailing empty row (multi-valued, current behavior preserved).single-per-lang, after the user fills the placeholder with@en(when@enalready exists), validation surfaces the conflict.Done criteria
skos:prefLabel,rdfs:label,skos:definition, and othersingle-per-langannotations no longer get an extra empty row when a value for every common language is already present.singleannotations (skos:notation,dcterms:created, …) get no placeholder when filled.multipleannotations behave as today (trailing empty row).ensureTrailingEmptydeduplicated into one shared helper.Open questions
ontokit-api'slinter.py) also flag existing data that violates SKOS cardinality? Probably yes (separate ticket, but cross-link).single-per-langshows a placeholder, what language should it default to? Options: empty (forces the user to pick), the first language not already covered, or the last language used in the project. UX call.skos:prefLabel @en), do we block the input, mark the row red, or let it through and let the linter catch it later? Interim: surface inline, let the user submit, let the linter confirm.Out of scope
owl:maxCardinality). Important but a much larger feature; this issue covers the well-known annotation vocabularies only.Related
react-hooks/set-state-in-effectcleanup; the cardinality-awareensureTrailingPlaceholdercan be implemented as a pure function called from event handlers, helping reduce one of the reset-in-effect occurrences.