@cosmicdrift/kumiko-bundled-features 0.97.0 → 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/tags/__tests__/feature.test.ts +46 -13
- package/src/tags/__tests__/tags.integration.test.ts +66 -4
- package/src/tags/constants.ts +11 -1
- package/src/tags/entity.ts +4 -0
- package/src/tags/feature.ts +18 -7
- package/src/tags/handlers/create-tag.write.ts +1 -1
- package/src/tags/handlers/delete-tag.write.ts +54 -0
- package/src/tags/handlers/update-tag.write.ts +31 -0
- package/src/tags/index.ts +14 -5
- package/src/tags/schemas.ts +25 -6
- package/src/tags/web/__tests__/entity-tags.test.tsx +107 -0
- package/src/tags/web/__tests__/tag-chip.test.ts +34 -0
- package/src/tags/web/__tests__/tag-section.test.tsx +51 -77
- package/src/tags/web/client-plugin.tsx +14 -1
- package/src/tags/web/entity-tags.tsx +47 -0
- package/src/tags/web/i18n.ts +39 -8
- package/src/tags/web/index.ts +12 -1
- package/src/tags/web/tag-chip.tsx +63 -0
- package/src/tags/web/tag-filter.tsx +88 -0
- package/src/tags/web/tag-manager.tsx +390 -0
- package/src/tags/web/tag-picker.tsx +51 -0
- package/src/tags/web/tag-section.tsx +51 -101
- package/src/tags/handlers/rename-tag.write.ts +0 -26
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { contrastText } from "../tag-chip";
|
|
3
|
+
|
|
4
|
+
// contrastText is the only non-trivial logic in TagChip: the YIQ pick must put
|
|
5
|
+
// white on dark labels and black on light ones, and reject non-hex input so the
|
|
6
|
+
// chip falls back to neutral instead of rendering an invalid CSS color.
|
|
7
|
+
describe("contrastText", () => {
|
|
8
|
+
test("white text on dark colors", () => {
|
|
9
|
+
expect(contrastText("#000000")).toBe("#ffffff");
|
|
10
|
+
expect(contrastText("#1e3a8a")).toBe("#ffffff"); // dark blue
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("black text on light colors", () => {
|
|
14
|
+
expect(contrastText("#ffffff")).toBe("#000000");
|
|
15
|
+
expect(contrastText("#fde68a")).toBe("#000000"); // light yellow
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("supports 3-digit shorthand hex", () => {
|
|
19
|
+
expect(contrastText("#fff")).toBe("#000000");
|
|
20
|
+
expect(contrastText("#000")).toBe("#ffffff");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("trims surrounding whitespace", () => {
|
|
24
|
+
expect(contrastText(" #ffffff ")).toBe("#000000");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns null for non-hex / malformed input", () => {
|
|
28
|
+
expect(contrastText("")).toBeNull();
|
|
29
|
+
expect(contrastText("rebeccapurple")).toBeNull();
|
|
30
|
+
expect(contrastText("#12")).toBeNull();
|
|
31
|
+
expect(contrastText("#1234")).toBeNull();
|
|
32
|
+
expect(contrastText("22cc88")).toBeNull(); // missing #
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -11,7 +11,7 @@ import { TagsHandlers, TagsQueries } from "../../constants";
|
|
|
11
11
|
import { defaultTranslations } from "../i18n";
|
|
12
12
|
import { TagSection, tagSelectionDelta } from "../tag-section";
|
|
13
13
|
|
|
14
|
-
type TagRow = { id: string; name: string };
|
|
14
|
+
type TagRow = { id: string; name: string; color?: string };
|
|
15
15
|
type AssignmentRow = { tagId: string; entityType: string; entityId: string };
|
|
16
16
|
|
|
17
17
|
let catalogRows: readonly TagRow[] = [];
|
|
@@ -44,6 +44,36 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
|
44
44
|
useQuery: useQuerySpy,
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
|
+
// The real picker is a Dialog wrapping TagManager (cmdk/Radix popovers) — its
|
|
48
|
+
// interaction is the picker's own test + e2e territory. Here we swap a headless
|
|
49
|
+
// stub that just exposes the onChange contract via two buttons, so we can pin
|
|
50
|
+
// what TagSection does with a new selection (the assign/remove diff) without
|
|
51
|
+
// driving a modal. Same contract as the real picker's onChange.
|
|
52
|
+
const StubPicker = ({
|
|
53
|
+
value,
|
|
54
|
+
onChange,
|
|
55
|
+
}: {
|
|
56
|
+
readonly value: readonly string[];
|
|
57
|
+
readonly onChange: (next: readonly string[]) => void;
|
|
58
|
+
readonly entityType: string;
|
|
59
|
+
readonly open: boolean;
|
|
60
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
61
|
+
}): ReactNode => (
|
|
62
|
+
<div data-testid="stub-picker">
|
|
63
|
+
<button type="button" data-testid="picker-add-t2" onClick={() => onChange([...value, "t2"])}>
|
|
64
|
+
add t2
|
|
65
|
+
</button>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
data-testid="picker-remove-t1"
|
|
69
|
+
onClick={() => onChange(value.filter((v) => v !== "t1"))}
|
|
70
|
+
>
|
|
71
|
+
remove t1
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
mock.module("../tag-picker", () => ({ TagPicker: StubPicker }));
|
|
76
|
+
|
|
47
77
|
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
48
78
|
return (
|
|
49
79
|
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
@@ -52,51 +82,8 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
|
52
82
|
);
|
|
53
83
|
}
|
|
54
84
|
|
|
55
|
-
// The
|
|
56
|
-
//
|
|
57
|
-
// wiring we swap in a headless stub that renders one toggle button per option
|
|
58
|
-
// and fires onChange with the toggled selection — same contract, no popover.
|
|
59
|
-
const StubInput: typeof defaultPrimitives.Input = (props) => {
|
|
60
|
-
if (props.kind === "combobox" && props.multiple === true) {
|
|
61
|
-
const value = props.value;
|
|
62
|
-
return (
|
|
63
|
-
<div data-testid="stub-combobox">
|
|
64
|
-
{props.options.map((o) => {
|
|
65
|
-
const selected = value.includes(o.value);
|
|
66
|
-
return (
|
|
67
|
-
<button
|
|
68
|
-
key={o.value}
|
|
69
|
-
type="button"
|
|
70
|
-
data-testid={`tag-opt-${o.value}`}
|
|
71
|
-
onClick={() =>
|
|
72
|
-
props.onChange(selected ? value.filter((v) => v !== o.value) : [...value, o.value])
|
|
73
|
-
}
|
|
74
|
-
>
|
|
75
|
-
{o.label}
|
|
76
|
-
</button>
|
|
77
|
-
);
|
|
78
|
-
})}
|
|
79
|
-
</div>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
return <input data-testid={`stub-${props.id}`} />;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
function StubComboboxWrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
86
|
-
return (
|
|
87
|
-
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
88
|
-
<PrimitivesProvider value={{ ...defaultPrimitives, Input: StubInput }}>
|
|
89
|
-
{children}
|
|
90
|
-
</PrimitivesProvider>
|
|
91
|
-
</LocaleProvider>
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// The combobox's assign/remove toggle drives onChange with the full new
|
|
96
|
-
// selection; the component diffs it against the current tags via this helper.
|
|
97
|
-
// Popover interaction itself (cmdk + Radix in jsdom) is covered by the
|
|
98
|
-
// combobox primitive's own tests + e2e — here we pin the diff that turns a
|
|
99
|
-
// selection into assign/remove calls.
|
|
85
|
+
// The picker's onChange drives the full new selection; the section diffs it
|
|
86
|
+
// against the current assignments via this helper to decide assign vs remove.
|
|
100
87
|
describe("tagSelectionDelta", () => {
|
|
101
88
|
test("addition only", () => {
|
|
102
89
|
expect(tagSelectionDelta(["a"], ["a", "b"])).toEqual({ added: ["b"], removed: [] });
|
|
@@ -113,9 +100,9 @@ describe("tagSelectionDelta", () => {
|
|
|
113
100
|
});
|
|
114
101
|
|
|
115
102
|
describe("TagSection", () => {
|
|
116
|
-
test("renders assigned tags as
|
|
103
|
+
test("renders assigned tags as colored chips + an Edit-tags button", () => {
|
|
117
104
|
catalogRows = [
|
|
118
|
-
{ id: "t1", name: "important" },
|
|
105
|
+
{ id: "t1", name: "important", color: "#ef4444" },
|
|
119
106
|
{ id: "t2", name: "project-x" },
|
|
120
107
|
];
|
|
121
108
|
assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "note-1" }];
|
|
@@ -126,41 +113,28 @@ describe("TagSection", () => {
|
|
|
126
113
|
</Wrapper>,
|
|
127
114
|
);
|
|
128
115
|
|
|
129
|
-
expect(screen.getByTestId("
|
|
130
|
-
// assigned → chip shown
|
|
116
|
+
expect(screen.getByTestId("tags-section")).toBeTruthy();
|
|
117
|
+
// assigned → chip shown; unassigned t2 is not rendered (it lives in the picker)
|
|
131
118
|
expect(screen.getByText("important")).toBeTruthy();
|
|
132
119
|
expect(screen.queryByText("project-x")).toBeNull();
|
|
120
|
+
expect(screen.getByTestId("tags-section-edit")).toBeTruthy();
|
|
133
121
|
});
|
|
134
122
|
|
|
135
|
-
test("
|
|
136
|
-
catalogRows = [];
|
|
123
|
+
test("no assigned tags → renders no chips, just the Edit-tags button", () => {
|
|
124
|
+
catalogRows = [{ id: "t1", name: "important" }];
|
|
137
125
|
assignmentRows = [];
|
|
138
|
-
dispatchSpy.mockClear();
|
|
139
126
|
|
|
140
127
|
render(
|
|
141
128
|
<Wrapper>
|
|
142
|
-
<TagSection entityName="note" entityId="note-
|
|
129
|
+
<TagSection entityName="note" entityId="note-1" />
|
|
143
130
|
</Wrapper>,
|
|
144
131
|
);
|
|
145
132
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
});
|
|
149
|
-
fireEvent.click(screen.getByTestId("tags-section-create"));
|
|
150
|
-
|
|
151
|
-
await waitFor(() =>
|
|
152
|
-
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.createTag, { name: "urgent" }),
|
|
153
|
-
);
|
|
154
|
-
await waitFor(() =>
|
|
155
|
-
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
|
|
156
|
-
tagId: "tag-new",
|
|
157
|
-
entityType: "note",
|
|
158
|
-
entityId: "note-9",
|
|
159
|
-
}),
|
|
160
|
-
);
|
|
133
|
+
expect(screen.queryByTestId("tag-chip")).toBeNull();
|
|
134
|
+
expect(screen.getByTestId("tags-section-edit")).toBeTruthy();
|
|
161
135
|
});
|
|
162
136
|
|
|
163
|
-
test("
|
|
137
|
+
test("picker selection dispatches assign for additions, remove for removals", async () => {
|
|
164
138
|
catalogRows = [
|
|
165
139
|
{ id: "t1", name: "important" },
|
|
166
140
|
{ id: "t2", name: "project-x" },
|
|
@@ -169,13 +143,13 @@ describe("TagSection", () => {
|
|
|
169
143
|
dispatchSpy.mockClear();
|
|
170
144
|
|
|
171
145
|
render(
|
|
172
|
-
<
|
|
146
|
+
<Wrapper>
|
|
173
147
|
<TagSection entityName="note" entityId="note-1" />
|
|
174
|
-
</
|
|
148
|
+
</Wrapper>,
|
|
175
149
|
);
|
|
176
150
|
|
|
177
|
-
//
|
|
178
|
-
fireEvent.click(screen.getByTestId("
|
|
151
|
+
// t1 assigned → picking [t1, t2] adds t2 → assign-tag
|
|
152
|
+
fireEvent.click(screen.getByTestId("picker-add-t2"));
|
|
179
153
|
await waitFor(() =>
|
|
180
154
|
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
|
|
181
155
|
tagId: "t2",
|
|
@@ -184,8 +158,8 @@ describe("TagSection", () => {
|
|
|
184
158
|
}),
|
|
185
159
|
);
|
|
186
160
|
|
|
187
|
-
// t1
|
|
188
|
-
fireEvent.click(screen.getByTestId("
|
|
161
|
+
// t1 assigned → picking [] removes t1 → remove-tag
|
|
162
|
+
fireEvent.click(screen.getByTestId("picker-remove-t1"));
|
|
189
163
|
await waitFor(() =>
|
|
190
164
|
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.removeTag, {
|
|
191
165
|
tagId: "t1",
|
|
@@ -195,7 +169,7 @@ describe("TagSection", () => {
|
|
|
195
169
|
);
|
|
196
170
|
});
|
|
197
171
|
|
|
198
|
-
test("create-mode (no entityId yet) shows the save-first hint instead of the
|
|
172
|
+
test("create-mode (no entityId yet) shows the save-first hint instead of the section", () => {
|
|
199
173
|
render(
|
|
200
174
|
<Wrapper>
|
|
201
175
|
<TagSection entityName="note" entityId={null} />
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
TAGS_FEATURE_NAME,
|
|
6
|
+
TAGS_FILTER_EXTENSION_NAME,
|
|
7
|
+
TAGS_SCREEN_ID,
|
|
8
|
+
TAGS_SECTION_EXTENSION_NAME,
|
|
9
|
+
} from "../constants";
|
|
5
10
|
import { defaultTranslations } from "./i18n";
|
|
11
|
+
import { TagFilter } from "./tag-filter";
|
|
12
|
+
import { TagManager } from "./tag-manager";
|
|
6
13
|
import { TagSection } from "./tag-section";
|
|
7
14
|
|
|
8
15
|
export function tagsClient(): ClientFeatureDefinition {
|
|
@@ -10,6 +17,12 @@ export function tagsClient(): ClientFeatureDefinition {
|
|
|
10
17
|
name: TAGS_FEATURE_NAME,
|
|
11
18
|
extensionSectionComponents: {
|
|
12
19
|
[TAGS_SECTION_EXTENSION_NAME]: TagSection,
|
|
20
|
+
// Header-slot tag filter for any entityList toolbar.
|
|
21
|
+
[TAGS_FILTER_EXTENSION_NAME]: TagFilter,
|
|
22
|
+
},
|
|
23
|
+
// Standalone Tags management screen (custom screen → TagManager).
|
|
24
|
+
components: {
|
|
25
|
+
[TAGS_SCREEN_ID]: TagManager,
|
|
13
26
|
},
|
|
14
27
|
translations: defaultTranslations,
|
|
15
28
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// EntityTags — read-only colored tag row for ANY entity. Drop it on a card or
|
|
3
|
+
// detail view: <EntityTags entityName="note" entityId={id} />. Loads the
|
|
4
|
+
// catalog (for name+color) and the entity's assignments, renders a TagChip per
|
|
5
|
+
// assigned tag. Renders nothing when the entity has no tags.
|
|
6
|
+
|
|
7
|
+
import { useQuery } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { TagsQueries } from "../constants";
|
|
10
|
+
import { TagChip } from "./tag-chip";
|
|
11
|
+
|
|
12
|
+
type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
|
|
13
|
+
type AssignmentRow = {
|
|
14
|
+
readonly tagId: string;
|
|
15
|
+
readonly entityType: string;
|
|
16
|
+
readonly entityId: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function EntityTags({
|
|
20
|
+
entityName,
|
|
21
|
+
entityId,
|
|
22
|
+
}: {
|
|
23
|
+
readonly entityName: string;
|
|
24
|
+
readonly entityId: string | null;
|
|
25
|
+
}): ReactNode {
|
|
26
|
+
const enabled = entityId !== null;
|
|
27
|
+
const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {}, { enabled });
|
|
28
|
+
const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(
|
|
29
|
+
TagsQueries.assignmentList,
|
|
30
|
+
{ filter: { field: "entityId", op: "eq", value: entityId } },
|
|
31
|
+
{ enabled },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (entityId === null) return null;
|
|
35
|
+
const byId = new Map((catalog.data?.rows ?? []).map((t) => [t.id, t]));
|
|
36
|
+
const assigned = (assignments.data?.rows ?? []).filter((r) => r.entityType === entityName);
|
|
37
|
+
if (assigned.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div data-testid="entity-tags" className="flex flex-wrap gap-1">
|
|
41
|
+
{assigned.map((a) => {
|
|
42
|
+
const tag = byId.get(a.tagId);
|
|
43
|
+
return <TagChip key={a.tagId} name={tag?.name ?? a.tagId} color={tag?.color} />;
|
|
44
|
+
})}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
package/src/tags/web/i18n.ts
CHANGED
|
@@ -9,21 +9,52 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
9
9
|
de: {
|
|
10
10
|
"tags.section.createMode": "Speichere zuerst den Eintrag, um Tags zu setzen.",
|
|
11
11
|
"tags.section.loading": "Lädt…",
|
|
12
|
-
"tags.section.label": "Tags",
|
|
13
|
-
"tags.section.placeholder": "Tags auswählen…",
|
|
14
12
|
"tags.section.empty": "Keine Tags gefunden.",
|
|
15
|
-
"tags.section.newLabel": "Neuer Tag",
|
|
16
|
-
"tags.section.create": "Tag anlegen & zuweisen",
|
|
17
13
|
"tags.section.working": "Speichert…",
|
|
14
|
+
"tags.section.none": "Keine Tags",
|
|
15
|
+
"tags.section.edit": "Tags bearbeiten",
|
|
16
|
+
"tags.manage.newLabel": "Neuer Tag",
|
|
17
|
+
"tags.manage.namePlaceholder": "Tag-Name",
|
|
18
|
+
"tags.manage.scopeLabel": "Geltungsbereich (Entity-Typ, leer = global)",
|
|
19
|
+
"tags.manage.scopePlaceholder": "z. B. note (leer = überall)",
|
|
20
|
+
"tags.manage.create": "Tag anlegen",
|
|
21
|
+
"tags.manage.edit": "Bearbeiten",
|
|
22
|
+
"tags.manage.save": "Speichern",
|
|
23
|
+
"tags.manage.cancel": "Abbrechen",
|
|
24
|
+
"tags.manage.delete": "Löschen",
|
|
25
|
+
"tags.manage.toggle": "Tag umschalten",
|
|
26
|
+
"tags.manage.usage": "{count}×",
|
|
27
|
+
"tags.manage.deleteConfirmTitle": "Tag „{name}“ löschen?",
|
|
28
|
+
"tags.manage.deleteConfirmDesc":
|
|
29
|
+
"Entfernt ihn von {count} Objekten. Das lässt sich nicht rückgängig machen.",
|
|
30
|
+
"tags.picker.title": "Tags",
|
|
31
|
+
"tags.picker.done": "Fertig",
|
|
32
|
+
"tags.filter.label": "Nach Tag filtern",
|
|
33
|
+
"tags.filter.active": "Tags: {count}",
|
|
18
34
|
},
|
|
19
35
|
en: {
|
|
20
36
|
"tags.section.createMode": "Save the entity first to add tags.",
|
|
21
37
|
"tags.section.loading": "Loading…",
|
|
22
|
-
"tags.section.label": "Tags",
|
|
23
|
-
"tags.section.placeholder": "Select tags…",
|
|
24
38
|
"tags.section.empty": "No tags found.",
|
|
25
|
-
"tags.section.newLabel": "New tag",
|
|
26
|
-
"tags.section.create": "Create & attach tag",
|
|
27
39
|
"tags.section.working": "Saving…",
|
|
40
|
+
"tags.section.none": "No tags",
|
|
41
|
+
"tags.section.edit": "Edit tags",
|
|
42
|
+
"tags.manage.newLabel": "New label",
|
|
43
|
+
"tags.manage.namePlaceholder": "Label name",
|
|
44
|
+
"tags.manage.scopeLabel": "Scope (entity type, empty = global)",
|
|
45
|
+
"tags.manage.scopePlaceholder": "e.g. note (empty = everywhere)",
|
|
46
|
+
"tags.manage.create": "Create label",
|
|
47
|
+
"tags.manage.edit": "Edit",
|
|
48
|
+
"tags.manage.save": "Save",
|
|
49
|
+
"tags.manage.cancel": "Cancel",
|
|
50
|
+
"tags.manage.delete": "Delete",
|
|
51
|
+
"tags.manage.toggle": "Toggle label",
|
|
52
|
+
"tags.manage.usage": "{count}×",
|
|
53
|
+
"tags.manage.deleteConfirmTitle": "Delete label “{name}”?",
|
|
54
|
+
"tags.manage.deleteConfirmDesc": "Removes it from {count} objects. This can't be undone.",
|
|
55
|
+
"tags.picker.title": "Tags",
|
|
56
|
+
"tags.picker.done": "Done",
|
|
57
|
+
"tags.filter.label": "Filter by tag",
|
|
58
|
+
"tags.filter.active": "Tags: {count}",
|
|
28
59
|
},
|
|
29
60
|
};
|
package/src/tags/web/index.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
TAGS_FILTER_EXTENSION_NAME,
|
|
4
|
+
TAGS_SCREEN_ID,
|
|
5
|
+
TAGS_SECTION_EXTENSION_NAME,
|
|
6
|
+
TagsHandlers,
|
|
7
|
+
TagsQueries,
|
|
8
|
+
} from "../constants";
|
|
3
9
|
export { tagsClient } from "./client-plugin";
|
|
10
|
+
export { EntityTags } from "./entity-tags";
|
|
11
|
+
export { contrastText, TagChip } from "./tag-chip";
|
|
12
|
+
export { TagFilter } from "./tag-filter";
|
|
13
|
+
export { TagManager } from "./tag-manager";
|
|
14
|
+
export { TagPicker } from "./tag-picker";
|
|
4
15
|
export { TagSection } from "./tag-section";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// TagChip — read-only colored label pill, GitLab-labels style. The tag's `color`
|
|
3
|
+
// (a hex like "#22cc88") drives the background; the text color is chosen for
|
|
4
|
+
// contrast (YIQ luminance) so light and dark labels both stay legible. A tag
|
|
5
|
+
// with no usable color falls back to a neutral chip. Self-contained inline
|
|
6
|
+
// styles (pattern from config-source-badge.tsx) so it renders the same in the
|
|
7
|
+
// picker, on cards and in screenshots without depending on Tailwind tokens.
|
|
8
|
+
|
|
9
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
10
|
+
|
|
11
|
+
// YIQ perceived brightness → black text on light backgrounds, white on dark.
|
|
12
|
+
// Returns null for anything that isn't a #rgb/#rrggbb hex so the caller can use
|
|
13
|
+
// its neutral fallback (color is a free-text UI hint, never validated).
|
|
14
|
+
export function contrastText(color: string): "#000000" | "#ffffff" | null {
|
|
15
|
+
const rgb = hexToRgb(color);
|
|
16
|
+
if (rgb === null) return null;
|
|
17
|
+
const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
|
|
18
|
+
return yiq >= 128 ? "#000000" : "#ffffff";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hexToRgb(color: string): { r: number; g: number; b: number } | null {
|
|
22
|
+
const match = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(color.trim());
|
|
23
|
+
if (match === null) return null;
|
|
24
|
+
const short = match[1] as string;
|
|
25
|
+
const hex =
|
|
26
|
+
short.length === 3
|
|
27
|
+
? `${short[0]}${short[0]}${short[1]}${short[1]}${short[2]}${short[2]}`
|
|
28
|
+
: short;
|
|
29
|
+
return {
|
|
30
|
+
r: Number.parseInt(hex.slice(0, 2), 16),
|
|
31
|
+
g: Number.parseInt(hex.slice(2, 4), 16),
|
|
32
|
+
b: Number.parseInt(hex.slice(4, 6), 16),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const BASE_STYLE: CSSProperties = {
|
|
37
|
+
display: "inline-flex",
|
|
38
|
+
alignItems: "center",
|
|
39
|
+
padding: "0 8px",
|
|
40
|
+
fontSize: "12px",
|
|
41
|
+
fontWeight: 500,
|
|
42
|
+
lineHeight: "20px",
|
|
43
|
+
borderRadius: "9999px",
|
|
44
|
+
whiteSpace: "nowrap",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const NEUTRAL = { backgroundColor: "#e5e7eb", color: "#374151" } as const;
|
|
48
|
+
|
|
49
|
+
export function TagChip({
|
|
50
|
+
name,
|
|
51
|
+
color,
|
|
52
|
+
}: {
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly color?: string | null;
|
|
55
|
+
}): ReactNode {
|
|
56
|
+
const fg = color != null && color !== "" ? contrastText(color) : null;
|
|
57
|
+
const palette = fg !== null && color != null ? { backgroundColor: color, color: fg } : NEUTRAL;
|
|
58
|
+
return (
|
|
59
|
+
<span data-testid="tag-chip" style={{ ...BASE_STYLE, ...palette }}>
|
|
60
|
+
{name}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// TagFilter — a drop-in tag filter for ANY entityList toolbar. Register it as
|
|
3
|
+
// the list's header slot (`screen.slots.header`) and mark `id` filterable is NOT
|
|
4
|
+
// needed — the renderer passes this control the list's screenId, and picking
|
|
5
|
+
// tags resolves the matching row ids and narrows the list via an id-set URL
|
|
6
|
+
// filter (`<screenId>.f.id=…`, applied as `{ field: "id", op: "in" }`). No host
|
|
7
|
+
// schema change: any list gets tag-filtering by mounting this in its header.
|
|
8
|
+
//
|
|
9
|
+
// ponytail: resolves ids from the assignment list (first 500 rows) + an id-IN
|
|
10
|
+
// URL filter — fine for typical tag volumes. For huge assignment sets add a
|
|
11
|
+
// server-side `entitiesByTag` query + a server-side join filter instead.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
useListUrlState,
|
|
15
|
+
usePrimitives,
|
|
16
|
+
useQuery,
|
|
17
|
+
useTranslation,
|
|
18
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
19
|
+
import { type ReactNode, useState } from "react";
|
|
20
|
+
import { TagsQueries } from "../constants";
|
|
21
|
+
import { TagPicker } from "./tag-picker";
|
|
22
|
+
|
|
23
|
+
// Tags chosen but zero matching entities → filter to a value that matches no row
|
|
24
|
+
// (so the list shows empty), instead of clearing the filter (which shows all).
|
|
25
|
+
const NO_MATCH = "__tags_no_match__";
|
|
26
|
+
|
|
27
|
+
type AssignmentRow = {
|
|
28
|
+
readonly tagId: string;
|
|
29
|
+
readonly entityType: string;
|
|
30
|
+
readonly entityId: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function TagFilter({
|
|
34
|
+
entityName,
|
|
35
|
+
screenId,
|
|
36
|
+
}: {
|
|
37
|
+
readonly entityName: string;
|
|
38
|
+
readonly entityId?: string | null;
|
|
39
|
+
readonly screenId?: string;
|
|
40
|
+
}): ReactNode {
|
|
41
|
+
const { Button } = usePrimitives();
|
|
42
|
+
const t = useTranslation();
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
const [selected, setSelected] = useState<readonly string[]>([]);
|
|
45
|
+
const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {
|
|
46
|
+
limit: 500,
|
|
47
|
+
});
|
|
48
|
+
// Unconditional (Rules of Hooks). The renderer always passes a screenId in the
|
|
49
|
+
// header slot; the fallback namespace is inert if it ever runs standalone.
|
|
50
|
+
const urlState = useListUrlState(screenId ?? "__tag-filter__");
|
|
51
|
+
|
|
52
|
+
const applyFilter = (tagIds: readonly string[]): void => {
|
|
53
|
+
setSelected(tagIds);
|
|
54
|
+
if (tagIds.length === 0) {
|
|
55
|
+
urlState.setFilter("id", []);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const wanted = new Set(tagIds);
|
|
59
|
+
const ids = [
|
|
60
|
+
...new Set(
|
|
61
|
+
(assignments.data?.rows ?? [])
|
|
62
|
+
.filter((a) => a.entityType === entityName && wanted.has(a.tagId))
|
|
63
|
+
.map((a) => a.entityId),
|
|
64
|
+
),
|
|
65
|
+
];
|
|
66
|
+
urlState.setFilter("id", ids.length > 0 ? ids : [NO_MATCH]);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const label =
|
|
70
|
+
selected.length > 0
|
|
71
|
+
? t("tags.filter.active", { count: selected.length })
|
|
72
|
+
: t("tags.filter.label");
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
<Button variant="secondary" onClick={() => setOpen(true)} testId="tag-filter-open">
|
|
77
|
+
{label}
|
|
78
|
+
</Button>
|
|
79
|
+
<TagPicker
|
|
80
|
+
entityType={entityName}
|
|
81
|
+
value={selected}
|
|
82
|
+
onChange={applyFilter}
|
|
83
|
+
open={open}
|
|
84
|
+
onOpenChange={setOpen}
|
|
85
|
+
/>
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|