Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions __tests__/lib/ontology/annotationCardinality.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect } from "vitest";
import { ensureTrailingPlaceholder } from "@/lib/ontology/annotationCardinality";
import { getAnnotationCardinality } from "@/lib/ontology/annotationProperties";

// ── ensureTrailingPlaceholder — "multiple" ────────────────────────────────

describe('ensureTrailingPlaceholder — "multiple"', () => {
it("appends empty row to empty array", () => {
expect(ensureTrailingPlaceholder([], "multiple")).toEqual([{ value: "", lang: "en" }]);
});

it("appends empty row when last item has a value", () => {
const result = ensureTrailingPlaceholder([{ value: "hello", lang: "en" }], "multiple");
expect(result).toHaveLength(2);
expect(result[1]).toEqual({ value: "", lang: "en" });
});

it("does not append when last item is already empty", () => {
const input = [{ value: "hello", lang: "en" }, { value: "", lang: "en" }];
expect(ensureTrailingPlaceholder(input, "multiple")).toHaveLength(2);
});

it("does not append when last item is whitespace-only", () => {
expect(ensureTrailingPlaceholder([{ value: " ", lang: "en" }], "multiple")).toHaveLength(1);
});
});

// ── ensureTrailingPlaceholder — "single-per-lang" ────────────────────────

describe('ensureTrailingPlaceholder — "single-per-lang"', () => {
it("adds an @en placeholder for an empty array", () => {
const result = ensureTrailingPlaceholder([], "single-per-lang");
expect(result).toEqual([{ value: "", lang: "en" }]);
});

it("does NOT add a second @en placeholder when @en is already filled (SKOS S14)", () => {
const input = [{ value: "Foo", lang: "en" }];
const result = ensureTrailingPlaceholder(input, "single-per-lang");
// The placeholder lang must differ from "en"
expect(result).toHaveLength(2);
expect(result[1].value).toBe("");
expect(result[1].lang).not.toBe("en");
});

it("adds a placeholder with the next uncovered language when @en is filled", () => {
const result = ensureTrailingPlaceholder([{ value: "Foo", lang: "en" }], "single-per-lang");
expect(result[1].lang).toBe("pt");
});

it("keeps an existing trailing empty placeholder as-is", () => {
const input = [{ value: "Foo", lang: "en" }, { value: "", lang: "pt" }];
expect(ensureTrailingPlaceholder(input, "single-per-lang")).toStrictEqual(input);
});

it("adds a blank-lang placeholder when all common languages are already filled", () => {
const input = [
{ value: "A", lang: "en" },
{ value: "B", lang: "pt" },
{ value: "C", lang: "es" },
{ value: "D", lang: "fr" },
{ value: "E", lang: "de" },
{ value: "F", lang: "it" },
];
const result = ensureTrailingPlaceholder(input, "single-per-lang");
expect(result).toHaveLength(7);
expect(result[6]).toEqual({ value: "", lang: "" });
});

it("skips covered languages and finds the next available one", () => {
const input = [{ value: "Foo", lang: "en" }, { value: "Bar", lang: "pt" }];
const result = ensureTrailingPlaceholder(input, "single-per-lang");
expect(result).toHaveLength(3);
expect(result[2].lang).toBe("es");
});
});

// ── ensureTrailingPlaceholder — "single" ─────────────────────────────────

describe('ensureTrailingPlaceholder — "single"', () => {
it("returns one empty placeholder for an empty array", () => {
expect(ensureTrailingPlaceholder([], "single")).toEqual([{ value: "", lang: "en" }]);
});

it("returns the filled value with no trailing empty once a value exists", () => {
const input = [{ value: "2024-01-01", lang: "en" }];
expect(ensureTrailingPlaceholder(input, "single")).toStrictEqual(input);
});

it("strips any trailing empty when a value is present", () => {
const input = [{ value: "ABC", lang: "en" }, { value: "", lang: "en" }];
const result = ensureTrailingPlaceholder(input, "single");
expect(result).toEqual([{ value: "ABC", lang: "en" }]);
});

it("keeps the empty placeholder while the user is still typing (value is non-empty)", () => {
const input = [{ value: "2024", lang: "en" }];
expect(ensureTrailingPlaceholder(input, "single")).toStrictEqual(input);
});
});

// ── getAnnotationCardinality ──────────────────────────────────────────────

describe("getAnnotationCardinality", () => {
it("returns single-per-lang for skos:prefLabel", () => {
expect(getAnnotationCardinality("http://www.w3.org/2004/02/skos/core#prefLabel")).toBe("single-per-lang");
});

it("returns single-per-lang for skos:definition", () => {
expect(getAnnotationCardinality("http://www.w3.org/2004/02/skos/core#definition")).toBe("single-per-lang");
});

it("returns single for skos:notation", () => {
expect(getAnnotationCardinality("http://www.w3.org/2004/02/skos/core#notation")).toBe("single");
});

it("returns single for dcterms:created", () => {
expect(getAnnotationCardinality("http://purl.org/dc/terms/created")).toBe("single");
});

it("returns single for dcterms:modified", () => {
expect(getAnnotationCardinality("http://purl.org/dc/terms/modified")).toBe("single");
});

it("returns multiple for skos:altLabel", () => {
expect(getAnnotationCardinality("http://www.w3.org/2004/02/skos/core#altLabel")).toBe("multiple");
});

it("returns multiple for rdfs:comment (COMMENT_IRI)", () => {
expect(getAnnotationCardinality("http://www.w3.org/2000/01/rdf-schema#comment")).toBe("multiple");
});

it("returns single-per-lang for rdfs:label (LABEL_IRI)", () => {
expect(getAnnotationCardinality("http://www.w3.org/2000/01/rdf-schema#label")).toBe("single-per-lang");
});

it("defaults to multiple for unknown IRIs", () => {
expect(getAnnotationCardinality("http://example.org/custom#prop")).toBe("multiple");
});
});
21 changes: 10 additions & 11 deletions components/editor/ClassDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,18 @@ import { ParentClassPicker } from "@/components/editor/ParentClassPicker";
import { AnnotationRow } from "@/components/editor/standard/AnnotationRow";
import { InlineAnnotationAdder } from "@/components/editor/standard/InlineAnnotationAdder";
import { RelationshipSection, type RelationshipGroup, type RelationshipTarget } from "@/components/editor/standard/RelationshipSection";
import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, RELATIONSHIP_PROPERTY_IRIS, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties";
import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, RELATIONSHIP_PROPERTY_IRIS, SEE_ALSO_IRI, getAnnotationPropertyInfo, getAnnotationCardinality } from "@/lib/ontology/annotationProperties";
import { ensureTrailingPlaceholder } from "@/lib/ontology/annotationCardinality";
import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar";
import { CrossReferencesPanel } from "@/components/editor/CrossReferencesPanel";
import { SimilarConceptsPanel } from "@/components/editor/SimilarConceptsPanel";
import { EntityHistoryTab } from "@/components/editor/EntityHistoryTab";
import { useAutoSave } from "@/lib/hooks/useAutoSave";
import { useToast } from "@/lib/context/ToastContext";

/** Ensure an array of localized strings always ends with an empty placeholder row */
/** @deprecated Use ensureTrailingPlaceholder from lib/ontology/annotationCardinality with an explicit cardinality. */
export function ensureTrailingEmpty(arr: LocalizedString[]): LocalizedString[] {
if (arr.length === 0 || arr[arr.length - 1].value.trim() !== "") {
return [...arr, { value: "", lang: "en" }];
}
return arr;
return ensureTrailingPlaceholder(arr, "multiple");
}

/** Minimal data from the tree node, used as fallback when the API has no data yet */
Expand Down Expand Up @@ -199,7 +197,7 @@ export function ClassDetailPanel({
} else {
regularAnnotations.push({
property_iri: a.property_iri,
values: ensureTrailingEmpty(a.values.map((v) => ({ ...v }))),
values: ensureTrailingPlaceholder(a.values.map((v) => ({ ...v })), getAnnotationCardinality(a.property_iri)),
});
}
}
Expand Down Expand Up @@ -426,7 +424,7 @@ export function ClassDetailPanel({
prev.map((a) => {
if (a.property_iri !== propertyIri) return a;
const updated = a.values.map((v, vi) => (vi === valueIdx ? { ...v, [field]: newVal } : v));
return { ...a, values: ensureTrailingEmpty(updated) };
return { ...a, values: ensureTrailingPlaceholder(updated, getAnnotationCardinality(propertyIri)) };
})
);
},
Expand All @@ -439,7 +437,7 @@ export function ClassDetailPanel({
prev.map((a) => {
if (a.property_iri !== propertyIri) return a;
const filtered = a.values.filter((_, vi) => vi !== valueIdx);
return { ...a, values: ensureTrailingEmpty(filtered) };
return { ...a, values: ensureTrailingPlaceholder(filtered, getAnnotationCardinality(propertyIri)) };
})
);
requestAnimationFrame(() => triggerSave());
Expand Down Expand Up @@ -869,16 +867,17 @@ export function ClassDetailPanel({
onAdd={(propertyIri, value, lang) => {
setEditAnnotations((prev) => {
const existing = prev.find((a) => a.property_iri === propertyIri);
const cardinality = getAnnotationCardinality(propertyIri);
if (existing) {
return prev.map((a) =>
a.property_iri === propertyIri
? { ...a, values: ensureTrailingEmpty([...a.values, { value, lang }]) }
? { ...a, values: ensureTrailingPlaceholder([...a.values, { value, lang }], cardinality) }
: a
);
}
return [
...prev,
{ property_iri: propertyIri, values: ensureTrailingEmpty([{ value, lang }]) },
{ property_iri: propertyIri, values: ensureTrailingPlaceholder([{ value, lang }], cardinality) },
];
});
requestAnimationFrame(() => triggerSave());
Expand Down
35 changes: 15 additions & 20 deletions components/editor/IndividualDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import { AnnotationRow } from "@/components/editor/standard/AnnotationRow";
import { InlineAnnotationAdder } from "@/components/editor/standard/InlineAnnotationAdder";
import { RelationshipSection, type RelationshipGroup, type RelationshipTarget } from "@/components/editor/standard/RelationshipSection";
import { PropertyAssertionSection } from "@/components/editor/standard/PropertyAssertionSection";
import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo } from "@/lib/ontology/annotationProperties";
import { LABEL_IRI, COMMENT_IRI, DEFINITION_IRI, SEE_ALSO_IRI, getAnnotationPropertyInfo, getAnnotationCardinality } from "@/lib/ontology/annotationProperties";
import { ensureTrailingPlaceholder } from "@/lib/ontology/annotationCardinality";
import { AutoSaveAffordanceBar } from "@/components/editor/AutoSaveAffordanceBar";
import { useEntityAutoSave } from "@/lib/hooks/useEntityAutoSave";
import { useToast } from "@/lib/context/ToastContext";
Expand All @@ -37,12 +38,6 @@ import {
import { type IndividualDraftEntry } from "@/lib/stores/draftStore";
import { useIriLabels } from "@/lib/hooks/useIriLabels";

function ensureTrailingEmpty(arr: LocalizedString[]): LocalizedString[] {
if (arr.length === 0 || arr[arr.length - 1].value.trim() !== "") {
return [...arr, { value: "", lang: "en" }];
}
return arr;
}

interface IndividualDetailPanelProps {
projectId: string;
Expand Down Expand Up @@ -198,9 +193,9 @@ export function IndividualDetailPanel({
});

const initEditState = useCallback((d: ParsedIndividualDetail) => {
setEditLabels(ensureTrailingEmpty(d.labels.map((l) => ({ ...l }))));
setEditComments(ensureTrailingEmpty(d.comments.map((c) => ({ ...c }))));
setEditDefinitions(ensureTrailingEmpty(d.definitions.map((def) => ({ ...def }))));
setEditLabels(ensureTrailingPlaceholder(d.labels.map((l) => ({ ...l })), "single-per-lang"));
setEditComments(ensureTrailingPlaceholder(d.comments.map((c) => ({ ...c })), "multiple"));
setEditDefinitions(ensureTrailingPlaceholder(d.definitions.map((def) => ({ ...def })), "single-per-lang"));
setEditTypeIris([...d.typeIris]);
setEditSameAsIris([...d.sameAsIris]);
setEditDifferentFromIris([...d.differentFromIris]);
Expand Down Expand Up @@ -229,7 +224,7 @@ export function IndividualDetailPanel({

const regularAnnotations = d.annotations
.filter((a) => a.property_iri !== DEFINITION_IRI)
.map((a) => ({ ...a, values: ensureTrailingEmpty(a.values.map((v) => ({ ...v }))) }));
.map((a) => ({ ...a, values: ensureTrailingPlaceholder(a.values.map((v) => ({ ...v })), getAnnotationCardinality(a.property_iri)) }));
setEditAnnotations(regularAnnotations);
}, []);

Expand Down Expand Up @@ -283,8 +278,8 @@ export function IndividualDetailPanel({
const d = restoredDraft as IndividualDraftEntry;
// eslint-disable-next-line react-hooks/set-state-in-effect -- restoring draft state from store; matches PropertyDetailPanel auto-enter pattern
setEditLabels(d.labels);
setEditComments(ensureTrailingEmpty(d.comments));
setEditDefinitions(ensureTrailingEmpty(d.definitions));
setEditComments(ensureTrailingPlaceholder(d.comments, "multiple"));
setEditDefinitions(ensureTrailingPlaceholder(d.definitions, "single-per-lang"));
setEditTypeIris(d.typeIris);
setEditSameAsIris(d.sameAsIris);
setEditDifferentFromIris(d.differentFromIris);
Expand All @@ -303,26 +298,26 @@ export function IndividualDetailPanel({

// ── Edit helpers ──
const updateLabel = useCallback((index: number, field: "value" | "lang", val: string) => {
setEditLabels((prev) => ensureTrailingEmpty(prev.map((l, i) => (i === index ? { ...l, [field]: val } : l))));
setEditLabels((prev) => ensureTrailingPlaceholder(prev.map((l, i) => (i === index ? { ...l, [field]: val } : l)), "single-per-lang"));
}, []);
const removeLabel = useCallback((index: number) => {
setEditLabels((prev) => prev.filter((_, i) => i !== index));
requestAnimationFrame(() => triggerSave());
}, [triggerSave]);

const updateComment = useCallback((index: number, field: "value" | "lang", val: string) => {
setEditComments((prev) => ensureTrailingEmpty(prev.map((c, i) => (i === index ? { ...c, [field]: val } : c))));
setEditComments((prev) => ensureTrailingPlaceholder(prev.map((c, i) => (i === index ? { ...c, [field]: val } : c)), "multiple"));
}, []);
const removeComment = useCallback((index: number) => {
setEditComments((prev) => ensureTrailingEmpty(prev.filter((_, i) => i !== index)));
setEditComments((prev) => ensureTrailingPlaceholder(prev.filter((_, i) => i !== index), "multiple"));
requestAnimationFrame(() => triggerSave());
}, [triggerSave]);

const updateDefinition = useCallback((index: number, field: "value" | "lang", val: string) => {
setEditDefinitions((prev) => ensureTrailingEmpty(prev.map((d, i) => (i === index ? { ...d, [field]: val } : d))));
setEditDefinitions((prev) => ensureTrailingPlaceholder(prev.map((d, i) => (i === index ? { ...d, [field]: val } : d)), "single-per-lang"));
}, []);
const removeDefinition = useCallback((index: number) => {
setEditDefinitions((prev) => ensureTrailingEmpty(prev.filter((_, i) => i !== index)));
setEditDefinitions((prev) => ensureTrailingPlaceholder(prev.filter((_, i) => i !== index), "single-per-lang"));
requestAnimationFrame(() => triggerSave());
}, [triggerSave]);

Expand All @@ -332,7 +327,7 @@ export function IndividualDetailPanel({
prev.map((a) => {
if (a.property_iri !== propertyIri) return a;
const updated = a.values.map((v, vi) => (vi === valueIdx ? { ...v, [field]: val } : v));
return { ...a, values: ensureTrailingEmpty(updated) };
return { ...a, values: ensureTrailingPlaceholder(updated, getAnnotationCardinality(propertyIri)) };
})
);
}, []
Expand All @@ -342,7 +337,7 @@ export function IndividualDetailPanel({
setEditAnnotations((prev) =>
prev.map((a) => {
if (a.property_iri !== propertyIri) return a;
return { ...a, values: ensureTrailingEmpty(a.values.filter((_, vi) => vi !== valueIdx)) };
return { ...a, values: ensureTrailingPlaceholder(a.values.filter((_, vi) => vi !== valueIdx), getAnnotationCardinality(propertyIri)) };
})
);
requestAnimationFrame(() => triggerSave());
Expand Down
Loading
Loading