feat: Add nesting of tags in a tree
- levels are joined by forward slashes in the keywords - behaviour is opt-in for now - long paths are shortened if there is not enough display room Closes #687.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
|
||||
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
|
||||
|
||||
// Work
|
||||
// Clients
|
||||
// Acme
|
||||
// Personal
|
||||
const KEYWORDS: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/personal", "Personal"),
|
||||
];
|
||||
|
||||
describe("formatKeyword with nesting on", () => {
|
||||
it("joins the display name of every level", () => {
|
||||
expect(formatKeyword("work/clients/acme", KEYWORDS, true)).toBe("Work/Clients/Acme");
|
||||
});
|
||||
|
||||
it("returns the plain display name for a tag with one level", () => {
|
||||
expect(formatKeyword("work", KEYWORDS, true)).toBe("Work");
|
||||
});
|
||||
|
||||
it("falls back to the raw level for one this client does not know", () => {
|
||||
expect(formatKeyword("work/archive/2026", KEYWORDS, true)).toBe("Work/archive/2026");
|
||||
expect(formatKeyword("unknown", [], true)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatKeyword with nesting off", () => {
|
||||
it("names a tag by its own label, leaving a slash in the id uninterpreted", () => {
|
||||
// The setting says a slash means nothing, so an id that happens to contain
|
||||
// one - from before it was turned off, or from another client - is a single
|
||||
// opaque token rather than a hierarchy.
|
||||
expect(formatKeyword("work/clients/acme", KEYWORDS, false)).toBe("Acme");
|
||||
expect(formatKeyword("work", KEYWORDS, false)).toBe("Work");
|
||||
});
|
||||
|
||||
it("falls back to the whole id when the tag has no definition", () => {
|
||||
expect(formatKeyword("work/archive/2026", KEYWORDS, false)).toBe("work/archive/2026");
|
||||
});
|
||||
|
||||
it("offers no shortening, leaving the markup to clip", () => {
|
||||
expect(keywordRenderings(formatKeywordLabels("work/clients/acme", KEYWORDS, false)))
|
||||
.toEqual(["Acme"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keywordRenderings", () => {
|
||||
it("shortens by one intermediate level at a time, outermost first", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme", "EU", "Sales"])).toEqual([
|
||||
"Work/Clients/Acme/EU/Sales",
|
||||
"Work/../Acme/EU/Sales",
|
||||
"Work/.../EU/Sales",
|
||||
"Work/.../Sales",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses to a single ... as soon as the run covers more than one level", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme", "Sales"])).toEqual([
|
||||
"Work/Clients/Acme/Sales",
|
||||
"Work/../Acme/Sales",
|
||||
"Work/.../Sales",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses .. for a lone intermediate level, never ...", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme"])).toEqual([
|
||||
"Work/Clients/Acme",
|
||||
"Work/../Acme",
|
||||
]);
|
||||
});
|
||||
|
||||
it("has nothing to shorten without an intermediate level", () => {
|
||||
expect(keywordRenderings(["Work", "Acme"])).toEqual(["Work/Acme"]);
|
||||
expect(keywordRenderings(["Work"])).toEqual(["Work"]);
|
||||
});
|
||||
|
||||
it("drops a rendering that would not come out shorter", () => {
|
||||
// "../" costs as much as the level it replaces, so shortening buys nothing.
|
||||
expect(keywordRenderings(["a", "it", "b"])).toEqual(["a/it/b"]);
|
||||
expect(keywordRenderings(["a", "x", "b"])).toEqual(["a/x/b"]);
|
||||
});
|
||||
});
|
||||
|
||||
// How the components use the two together: resolve a tag to its display names,
|
||||
// then hand the ladder to `useShortenedText` to pick a rung.
|
||||
describe("keywordRenderings over formatKeywordLabels", () => {
|
||||
it("shortens a display name by the same ladder as an id", () => {
|
||||
const deep: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/clients/acme/eu", "Europe"),
|
||||
];
|
||||
|
||||
expect(keywordRenderings(formatKeywordLabels("work/clients/acme/eu", deep, true))).toEqual([
|
||||
"Work/Clients/Acme/Europe",
|
||||
"Work/../Acme/Europe",
|
||||
"Work/.../Europe",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a slash inside one display name as part of that name, not a level", () => {
|
||||
const slashed: KeywordDefinition[] = [kw("work", "Work"), kw("work/acme-r-d", "Acme/R&D")];
|
||||
|
||||
// Two levels, so there is no intermediate level to shorten.
|
||||
expect(keywordRenderings(formatKeywordLabels("work/acme-r-d", slashed, true))).toEqual([
|
||||
"Work/Acme/R&D",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
MAX_KEYWORD_ID_LENGTH,
|
||||
buildKeywordTree,
|
||||
composeKeywordId,
|
||||
getParentKeywordId,
|
||||
hasChildKeywords,
|
||||
isKeywordDescendant,
|
||||
keywordLevels,
|
||||
normalizeKeywordLevel,
|
||||
} from "@/lib/keyword-nesting";
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
|
||||
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
|
||||
|
||||
// Work
|
||||
// Clients
|
||||
// Acme
|
||||
// Personal
|
||||
const KEYWORDS: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/personal", "Personal"),
|
||||
];
|
||||
|
||||
describe("normalizeKeywordLevel", () => {
|
||||
it("lowercases and folds unsupported characters into single dashes", () => {
|
||||
expect(normalizeKeywordLevel("My Custom Tag!")).toBe("my-custom-tag");
|
||||
expect(normalizeKeywordLevel(" Spaced Out ")).toBe("spaced-out");
|
||||
expect(normalizeKeywordLevel("--Trimmed--")).toBe("trimmed");
|
||||
});
|
||||
|
||||
it("treats a slash as part of the name, not as a level", () => {
|
||||
expect(normalizeKeywordLevel("Acme/R&D")).toBe("acme-r-d");
|
||||
});
|
||||
|
||||
it("returns an empty string when nothing usable is left", () => {
|
||||
expect(normalizeKeywordLevel(" ")).toBe("");
|
||||
expect(normalizeKeywordLevel("!!!")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeKeywordId", () => {
|
||||
it("returns a bare slug at the top level", () => {
|
||||
expect(composeKeywordId(null, "Work")).toBe("work");
|
||||
expect(composeKeywordId("", "Work")).toBe("work");
|
||||
});
|
||||
|
||||
it("appends the slug below the parent", () => {
|
||||
expect(composeKeywordId("work/clients", "Acme")).toBe("work/clients/acme");
|
||||
});
|
||||
|
||||
it("never produces a trailing separator for an unusable name", () => {
|
||||
expect(composeKeywordId("work", "!!!")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("keywordLevels", () => {
|
||||
it("splits an id into its levels", () => {
|
||||
expect(keywordLevels("work/clients/acme")).toEqual(["work", "clients", "acme"]);
|
||||
expect(keywordLevels("work")).toEqual(["work"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getParentKeywordId", () => {
|
||||
it("drops the last level", () => {
|
||||
expect(getParentKeywordId("work/clients/acme")).toBe("work/clients");
|
||||
});
|
||||
|
||||
it("returns null for a top-level tag", () => {
|
||||
expect(getParentKeywordId("work")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isKeywordDescendant", () => {
|
||||
it("matches anything below the ancestor", () => {
|
||||
expect(isKeywordDescendant("work/clients/acme", "work")).toBe(true);
|
||||
expect(isKeywordDescendant("work/clients", "work")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the ancestor itself or a shared name prefix", () => {
|
||||
expect(isKeywordDescendant("work", "work")).toBe(false);
|
||||
expect(isKeywordDescendant("workshop/tools", "work")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasChildKeywords", () => {
|
||||
it("reports whether any defined tag sits below the given one", () => {
|
||||
expect(hasChildKeywords("work", KEYWORDS)).toBe(true);
|
||||
expect(hasChildKeywords("work/clients", KEYWORDS)).toBe(true);
|
||||
expect(hasChildKeywords("work/clients/acme", KEYWORDS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAX_KEYWORD_ID_LENGTH", () => {
|
||||
it("leaves room for the `$label:` prefix within the 255-character keyword limit", () => {
|
||||
expect(MAX_KEYWORD_ID_LENGTH).toBe(248);
|
||||
expect("$label:".length + MAX_KEYWORD_ID_LENGTH).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildKeywordTree", () => {
|
||||
it("nests each tag under its parent and records the depth", () => {
|
||||
const [work] = buildKeywordTree(KEYWORDS);
|
||||
|
||||
expect(work.id).toBe("work");
|
||||
expect(work.depth).toBe(0);
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/clients", "work/personal"]);
|
||||
|
||||
const clients = work.children[0];
|
||||
expect(clients.depth).toBe(1);
|
||||
expect(clients.children.map((c) => c.id)).toEqual(["work/clients/acme"]);
|
||||
expect(clients.children[0].depth).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the manual order within a level", () => {
|
||||
const reordered = [KEYWORDS[0], KEYWORDS[3], KEYWORDS[1], KEYWORDS[2]];
|
||||
const [work] = buildKeywordTree(reordered);
|
||||
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/personal", "work/clients"]);
|
||||
});
|
||||
|
||||
it("keeps a tag whose parent is not defined at the root", () => {
|
||||
const orphan = buildKeywordTree([kw("work/clients/acme", "Acme")]);
|
||||
|
||||
expect(orphan).toHaveLength(1);
|
||||
expect(orphan[0].id).toBe("work/clients/acme");
|
||||
expect(orphan[0].depth).toBe(0);
|
||||
});
|
||||
|
||||
it("returns every tag as a root when no id describes a hierarchy", () => {
|
||||
const flat = buildKeywordTree([kw("red", "Red"), kw("blue", "Blue")]);
|
||||
|
||||
expect(flat.map((n) => n.id)).toEqual(["red", "blue"]);
|
||||
expect(flat.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user