@cosmicdrift/kumiko-bundled-features 0.97.1 → 0.100.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/file-foundation/feature.ts +28 -141
- package/src/tags/__tests__/feature.test.ts +46 -13
- package/src/tags/__tests__/tags.integration.test.ts +66 -4
- package/src/tags/constants.ts +17 -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 +15 -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-filter.test.tsx +124 -0
- package/src/tags/web/__tests__/tag-section.test.tsx +51 -77
- package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
- package/src/tags/web/client-plugin.tsx +20 -1
- package/src/tags/web/entity-tags.tsx +47 -0
- package/src/tags/web/i18n.ts +39 -8
- package/src/tags/web/index.ts +14 -1
- package/src/tags/web/tag-chip.tsx +63 -0
- package/src/tags/web/tag-filter.tsx +100 -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/web/tags-cell.tsx +46 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
- package/src/tags/handlers/rename-tag.write.ts +0 -26
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
TAGS_COLUMN_RENDERER_NAME,
|
|
6
|
+
TAGS_FEATURE_NAME,
|
|
7
|
+
TAGS_FILTER_EXTENSION_NAME,
|
|
8
|
+
TAGS_SCREEN_ID,
|
|
9
|
+
TAGS_SECTION_EXTENSION_NAME,
|
|
10
|
+
} from "../constants";
|
|
5
11
|
import { defaultTranslations } from "./i18n";
|
|
12
|
+
import { TagFilter } from "./tag-filter";
|
|
13
|
+
import { TagManager } from "./tag-manager";
|
|
6
14
|
import { TagSection } from "./tag-section";
|
|
15
|
+
import { TagsCell } from "./tags-cell";
|
|
7
16
|
|
|
8
17
|
export function tagsClient(): ClientFeatureDefinition {
|
|
9
18
|
return {
|
|
10
19
|
name: TAGS_FEATURE_NAME,
|
|
11
20
|
extensionSectionComponents: {
|
|
12
21
|
[TAGS_SECTION_EXTENSION_NAME]: TagSection,
|
|
22
|
+
// Header-slot tag filter for any entityList toolbar.
|
|
23
|
+
[TAGS_FILTER_EXTENSION_NAME]: TagFilter,
|
|
24
|
+
},
|
|
25
|
+
// Inline tag chips on any entityList row, via a labeled virtual column.
|
|
26
|
+
columnRenderers: {
|
|
27
|
+
[TAGS_COLUMN_RENDERER_NAME]: TagsCell,
|
|
28
|
+
},
|
|
29
|
+
// Standalone Tags management screen (custom screen → TagManager).
|
|
30
|
+
components: {
|
|
31
|
+
[TAGS_SCREEN_ID]: TagManager,
|
|
13
32
|
},
|
|
14
33
|
translations: defaultTranslations,
|
|
15
34
|
};
|
|
@@ -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.clear": "Filter zurücksetzen",
|
|
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.clear": "Clear",
|
|
28
59
|
},
|
|
29
60
|
};
|
package/src/tags/web/index.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
TAGS_COLUMN_RENDERER_NAME,
|
|
4
|
+
TAGS_FILTER_EXTENSION_NAME,
|
|
5
|
+
TAGS_SCREEN_ID,
|
|
6
|
+
TAGS_SECTION_EXTENSION_NAME,
|
|
7
|
+
TagsHandlers,
|
|
8
|
+
TagsQueries,
|
|
9
|
+
} from "../constants";
|
|
3
10
|
export { tagsClient } from "./client-plugin";
|
|
11
|
+
export { EntityTags } from "./entity-tags";
|
|
12
|
+
export { contrastText, TagChip } from "./tag-chip";
|
|
13
|
+
export { TagFilter } from "./tag-filter";
|
|
14
|
+
export { TagManager } from "./tag-manager";
|
|
15
|
+
export { TagPicker } from "./tag-picker";
|
|
4
16
|
export { TagSection } from "./tag-section";
|
|
17
|
+
export { TagsCell } from "./tags-cell";
|
|
@@ -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,100 @@
|
|
|
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`); the renderer passes it the
|
|
4
|
+
// list's screenId, and picking tags resolves the matching row ids and narrows
|
|
5
|
+
// the list via an id-set URL filter (`<screenId>.f.id=…`, applied as
|
|
6
|
+
// `{ field: "id", op: "in" }`). No host schema change: any list gets
|
|
7
|
+
// tag-filtering by mounting this in its header.
|
|
8
|
+
//
|
|
9
|
+
// The active selection is shown inline as colored chips next to the button, with
|
|
10
|
+
// a clear affordance — so the filter is visible, not hidden behind a count.
|
|
11
|
+
//
|
|
12
|
+
// ponytail: resolves ids from the assignment list (first 500 rows) + an id-IN
|
|
13
|
+
// URL filter — fine for typical tag volumes. For huge assignment sets add a
|
|
14
|
+
// server-side `entitiesByTag` query + a server-side join filter instead.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
useListUrlState,
|
|
18
|
+
usePrimitives,
|
|
19
|
+
useQuery,
|
|
20
|
+
useTranslation,
|
|
21
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
22
|
+
import { type ReactNode, useState } from "react";
|
|
23
|
+
import { TagsQueries } from "../constants";
|
|
24
|
+
import { TagChip } from "./tag-chip";
|
|
25
|
+
import { TagPicker } from "./tag-picker";
|
|
26
|
+
|
|
27
|
+
// Tags chosen but zero matching entities → filter to a value that matches no row
|
|
28
|
+
// (so the list shows empty), instead of clearing the filter (which shows all).
|
|
29
|
+
const NO_MATCH = "__tags_no_match__";
|
|
30
|
+
|
|
31
|
+
type AssignmentRow = {
|
|
32
|
+
readonly tagId: string;
|
|
33
|
+
readonly entityType: string;
|
|
34
|
+
readonly entityId: string;
|
|
35
|
+
};
|
|
36
|
+
type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
|
|
37
|
+
|
|
38
|
+
export function TagFilter({
|
|
39
|
+
entityName,
|
|
40
|
+
screenId,
|
|
41
|
+
}: {
|
|
42
|
+
readonly entityName: string;
|
|
43
|
+
readonly entityId?: string | null;
|
|
44
|
+
readonly screenId?: string;
|
|
45
|
+
}): ReactNode {
|
|
46
|
+
const { Button } = usePrimitives();
|
|
47
|
+
const t = useTranslation();
|
|
48
|
+
const [open, setOpen] = useState(false);
|
|
49
|
+
const [selected, setSelected] = useState<readonly string[]>([]);
|
|
50
|
+
const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {});
|
|
51
|
+
const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {
|
|
52
|
+
limit: 500,
|
|
53
|
+
});
|
|
54
|
+
// Unconditional (Rules of Hooks). The renderer always passes a screenId in the
|
|
55
|
+
// header slot; the fallback namespace is inert if it ever runs standalone.
|
|
56
|
+
const urlState = useListUrlState(screenId ?? "__tag-filter__");
|
|
57
|
+
|
|
58
|
+
const applyFilter = (tagIds: readonly string[]): void => {
|
|
59
|
+
setSelected(tagIds);
|
|
60
|
+
if (tagIds.length === 0) {
|
|
61
|
+
urlState.setFilter("id", []);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const wanted = new Set(tagIds);
|
|
65
|
+
const ids = [
|
|
66
|
+
...new Set(
|
|
67
|
+
(assignments.data?.rows ?? [])
|
|
68
|
+
.filter((a) => a.entityType === entityName && wanted.has(a.tagId))
|
|
69
|
+
.map((a) => a.entityId),
|
|
70
|
+
),
|
|
71
|
+
];
|
|
72
|
+
urlState.setFilter("id", ids.length > 0 ? ids : [NO_MATCH]);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const byId = new Map((catalog.data?.rows ?? []).map((tg) => [tg.id, tg]));
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
79
|
+
<Button variant="secondary" onClick={() => setOpen(true)} testId="tag-filter-open">
|
|
80
|
+
{t("tags.filter.label")}
|
|
81
|
+
</Button>
|
|
82
|
+
{selected.map((id) => {
|
|
83
|
+
const tag = byId.get(id);
|
|
84
|
+
return <TagChip key={id} name={tag?.name ?? id} color={tag?.color} />;
|
|
85
|
+
})}
|
|
86
|
+
{selected.length > 0 && (
|
|
87
|
+
<Button variant="secondary" onClick={() => applyFilter([])} testId="tag-filter-clear">
|
|
88
|
+
{t("tags.filter.clear")}
|
|
89
|
+
</Button>
|
|
90
|
+
)}
|
|
91
|
+
<TagPicker
|
|
92
|
+
entityType={entityName}
|
|
93
|
+
value={selected}
|
|
94
|
+
onChange={applyFilter}
|
|
95
|
+
open={open}
|
|
96
|
+
onOpenChange={setOpen}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|