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
59 changes: 59 additions & 0 deletions __tests__/components/editor/LanguagePicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,4 +249,63 @@ describe("LanguagePicker", () => {

expect(screen.queryByText(/Use custom code/)).toBeNull();
});

it("hides languages listed in excludeCodes", async () => {
render(<LanguagePicker value="" onChange={vi.fn()} excludeCodes={["fr", "de"]} />);
fireEvent.click(screen.getByLabelText("Language tag"));

await waitFor(() => {
expect(screen.getByText("English")).toBeDefined();
});

expect(screen.queryByText("French")).toBeNull();
expect(screen.queryByText("German")).toBeNull();
});

it("keeps the row's own language visible even when it is excluded", async () => {
render(<LanguagePicker value="fr" onChange={vi.fn()} excludeCodes={["en", "fr"]} />);
fireEvent.click(screen.getByLabelText("Language tag"));

await waitFor(() => {
expect(screen.getByText("French")).toBeDefined();
});

expect(screen.queryByText("English")).toBeNull();
});

it("matches excludeCodes case-insensitively", async () => {
render(<LanguagePicker value="" onChange={vi.fn()} excludeCodes={["FR"]} />);
fireEvent.click(screen.getByLabelText("Language tag"));

await waitFor(() => {
expect(screen.getByText("English")).toBeDefined();
});

expect(screen.queryByText("French")).toBeNull();
});

it("does not offer an excluded code through the custom-code escape hatch", async () => {
const user = userEvent.setup();
render(<LanguagePicker value="" onChange={vi.fn()} excludeCodes={["grc"]} />);
fireEvent.click(screen.getByLabelText("Language tag"));

await waitFor(() => {
expect(screen.getByPlaceholderText("Search languages...")).toBeDefined();
});

await user.type(screen.getByPlaceholderText("Search languages..."), "grc");

expect(screen.queryByText(/Use custom code/)).toBeNull();
});

it("shows the full list when excludeCodes is omitted", async () => {
render(<LanguagePicker value="en" onChange={vi.fn()} />);
fireEvent.click(screen.getByLabelText("Language tag"));

await waitFor(() => {
expect(screen.getByText("French")).toBeDefined();
});

expect(screen.getByText("German")).toBeDefined();
});
});
163 changes: 163 additions & 0 deletions __tests__/lib/ontology/annotationCardinality.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, it, expect } from "vitest";
import { ensureTrailingPlaceholder, usedLanguages } 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("defaults the first row to @en when nothing is filled yet", () => {
expect(ensureTrailingPlaceholder([], "single-per-lang")).toEqual([{ value: "", lang: "en" }]);
});

it("does NOT offer a second @en row when @en is already filled (SKOS S14)", () => {
const result = ensureTrailingPlaceholder([{ value: "Foo", lang: "en" }], "single-per-lang");
expect(result).toHaveLength(2);
expect(result[1]).toEqual({ value: "", lang: "" });
});

it("leaves the placeholder language blank so the filtered picker forces a choice", () => {
const result = ensureTrailingPlaceholder(
[{ value: "Foo", lang: "en" }, { value: "Bar", lang: "pt" }],
"single-per-lang",
);
expect(result).toHaveLength(3);
expect(result[2]).toEqual({ value: "", lang: "" });
});

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("still offers a row when many 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: "" });
});
});

// ── usedLanguages ─────────────────────────────────────────────────────────

describe("usedLanguages", () => {
it("lists the languages already carrying a value for single-per-lang", () => {
const values = [
{ value: "Foo", lang: "en" },
{ value: "Bar", lang: "PT" },
{ value: "", lang: "es" },
];
expect(usedLanguages(values, "single-per-lang")).toEqual(["en", "pt"]);
});

it("ignores rows whose value is only whitespace", () => {
expect(usedLanguages([{ value: " ", lang: "en" }], "single-per-lang")).toEqual([]);
});

it("drops empty language tags", () => {
const values = [{ value: "Foo", lang: "" }, { value: "Bar", lang: "de" }];
expect(usedLanguages(values, "single-per-lang")).toEqual(["de"]);
});

it("excludes nothing for multiple — the picker stays unfiltered", () => {
const values = [{ value: "Foo", lang: "en" }, { value: "Bar", lang: "fr" }];
expect(usedLanguages(values, "multiple")).toEqual([]);
});

it("excludes nothing for single", () => {
expect(usedLanguages([{ value: "Foo", lang: "en" }], "single")).toEqual([]);
});
});

// ── 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");
});
});
24 changes: 13 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, usedLanguages } 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 @@ -666,6 +664,7 @@ export function ClassDetailPanel({
className="flex-1 rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-sm focus:border-primary-500 focus:outline-hidden focus:ring-1 focus:ring-primary-500 dark:border-slate-600 dark:bg-slate-700 dark:text-white"
/>
<LanguagePicker
excludeCodes={usedLanguages(editLabels, "single-per-lang")}
value={label.lang}
onChange={(code) => {
updateLabel(index, "lang", code);
Expand Down Expand Up @@ -718,6 +717,7 @@ export function ClassDetailPanel({
<AnnotationRow
key={vIdx}
propertyIri={DEFINITION_IRI}
excludeLangs={usedLanguages(defValues, getAnnotationCardinality(DEFINITION_IRI))}
value={val.value}
lang={val.lang}
onValueChange={(v) => updateAnnotationValue(DEFINITION_IRI, vIdx, "value", v)}
Expand Down Expand Up @@ -829,6 +829,7 @@ export function ClassDetailPanel({
<AnnotationRow
key={vIdx}
propertyIri={annotation.property_iri}
excludeLangs={usedLanguages(annotation.values, getAnnotationCardinality(annotation.property_iri))}
value={val.value}
lang={val.lang}
onValueChange={(v) => updateAnnotationValue(annotation.property_iri, vIdx, "value", v)}
Expand Down Expand Up @@ -869,16 +870,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
Loading
Loading