Skip to content

Editor proposes a duplicate row for cardinality-1 annotations (skos:prefLabel, etc.) #203

Description

@JohnRDOrazio

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:

  1. Not propose an additional empty row for cardinality-1 (or cardinality-1-per-language) annotations once they're filled.
  2. 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

  • skos:prefLabel, rdfs:label, skos:definition, and other single-per-lang annotations no longer get an extra empty row when a value for every common language is already present.
  • single annotations (skos:notation, dcterms:created, …) get no placeholder when filled.
  • multiple annotations behave as today (trailing empty row).
  • ensureTrailingEmpty deduplicated into one shared helper.
  • Tests cover each cardinality category and the SKOS S14 case explicitly.

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

Activity

  1. added this to the v0.5.0 milestone on Apr 28, 2026
  2. JohnRDOrazio commented on Aug 18, 2026

    @JohnRDOrazio
    MemberAuthor

    Answering the two open questions in this issue from the review of PR #273:

    "Backend validation — should the linter also flag existing data that violates SKOS cardinality?"

    It already does, for most of it. ontokit-api's linter.py has a label-per-language rule (_check_label_per_language) that flags multiple differing values for the same predicate + language tag, covering rdfs:label and skos:prefLabel and deliberately exempting skos:altLabel / skos:hiddenLabel as genuinely multi-valued. It normalizes language tags to lowercase, matching what the frontend helper now does.

    The one gap is skos:definition, which this issue's table marks single-per-lang but the backend rule doesn't cover — so the editor would restrict something the health check permits. Filed ontokit-api#216 to close it, and added both to the v0.5.0 tracker (#214).

    "Language defaulting — what language should a single-per-lang placeholder default to?"

    Resolved as: none of the three options as originally framed. Rather than guessing a default, the placeholder carries a blank tag and the language picker on that annotation's rows excludes the languages that already have a value. A duplicate becomes unreachable rather than merely unsuggested, and no new "common languages" list is needed — the picker filters the existing FREQUENT_LANGUAGES / ALL_LANGUAGES from lib/i18n/languageCodes.ts.

    Final semantics:

    Cardinality Trailing row Language picker
    single none once a value is filled —
    single-per-lang yes, blank tag (en only when nothing is filled yet) excludes already-used languages
    multiple yes, @en unfiltered

    That also settles the third open question ("error surfacing"): there's nothing to surface, because the conflicting choice can't be made in the first place. The linter remains the backstop for data that arrives from imports or external edits.

    PR #273 has been updated accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions