@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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// TagsCell — a reusable columnRenderer that shows an entity's tags as colored
|
|
3
|
+
// chips inline in a list row. Drop it into ANY entityList by declaring a labeled
|
|
4
|
+
// virtual column (no host-schema change), after mounting tagsClient():
|
|
5
|
+
// columns: [
|
|
6
|
+
// "title",
|
|
7
|
+
// { field: "tags", label: "Tags",
|
|
8
|
+
// renderer: { react: { __component: TAGS_COLUMN_RENDERER_NAME } } },
|
|
9
|
+
// ]
|
|
10
|
+
// It reads the row's id (entity ids are unique, so entityType is unnecessary)
|
|
11
|
+
// and renders a TagChip per assigned tag; renders nothing when the row has none.
|
|
12
|
+
//
|
|
13
|
+
// ponytail: bulk-loads the assignment list (first 500) + the catalog once and
|
|
14
|
+
// filters per row — kumiko's useQuery dedupes the identical (type, payload)
|
|
15
|
+
// calls, so the whole column shares two queries, not two-per-row. For huge
|
|
16
|
+
// assignment sets add a server-side entitiesByTag query + join filter.
|
|
17
|
+
|
|
18
|
+
import { type ColumnRendererProps, useQuery } from "@cosmicdrift/kumiko-renderer";
|
|
19
|
+
import type { ReactNode } from "react";
|
|
20
|
+
import { TagsQueries } from "../constants";
|
|
21
|
+
import { TagChip } from "./tag-chip";
|
|
22
|
+
|
|
23
|
+
type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
|
|
24
|
+
type AssignmentRow = { readonly tagId: string; readonly entityId: string };
|
|
25
|
+
|
|
26
|
+
export function TagsCell({ row }: ColumnRendererProps): ReactNode {
|
|
27
|
+
const entityId = String(row["id"] ?? "");
|
|
28
|
+
const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {});
|
|
29
|
+
const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {
|
|
30
|
+
limit: 500,
|
|
31
|
+
});
|
|
32
|
+
if (entityId === "") return null;
|
|
33
|
+
const byId = new Map((catalog.data?.rows ?? []).map((t) => [t.id, t]));
|
|
34
|
+
const tagIds = (assignments.data?.rows ?? [])
|
|
35
|
+
.filter((a) => a.entityId === entityId)
|
|
36
|
+
.map((a) => a.tagId);
|
|
37
|
+
if (tagIds.length === 0) return null;
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex flex-wrap gap-1" data-testid="tags-cell">
|
|
40
|
+
{tagIds.map((id) => {
|
|
41
|
+
const tag = byId.get(id);
|
|
42
|
+
return <TagChip key={id} name={tag?.name ?? id} color={tag?.color} />;
|
|
43
|
+
})}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -80,7 +80,6 @@ beforeAll(async () => {
|
|
|
80
80
|
createUserDataRightsFeature(),
|
|
81
81
|
createUserDataRightsDefaultsFeature(),
|
|
82
82
|
],
|
|
83
|
-
files: { storageProvider: provider },
|
|
84
83
|
extraContext: ({ registry }) => ({
|
|
85
84
|
configResolver: resolver,
|
|
86
85
|
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
@@ -90,7 +90,6 @@ beforeAll(async () => {
|
|
|
90
90
|
createUserDataRightsFeature(),
|
|
91
91
|
createUserDataRightsDefaultsFeature(),
|
|
92
92
|
],
|
|
93
|
-
files: { storageProvider: flakyProvider },
|
|
94
93
|
extraContext: ({ registry }) => ({
|
|
95
94
|
configResolver: resolver,
|
|
96
95
|
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// #608 trap-closed regression: uploads and erasure resolve the file provider
|
|
2
|
+
// through ONE source (file-foundation), end-to-end through the real server.
|
|
3
|
+
//
|
|
4
|
+
// The stack mounts file-foundation + a provider plugin but does NOT inject a
|
|
5
|
+
// resolver (no `files:` option) — so buildServer must build the upload-route
|
|
6
|
+
// resolver itself from the mounted features, exactly as production does. A file
|
|
7
|
+
// uploaded through POST /api/files must land in the SAME store the GDPR forget
|
|
8
|
+
// pipeline deletes from. Before the unification the upload route wrote through a
|
|
9
|
+
// separately-wired provider while erasure deleted through file-foundation — so a
|
|
10
|
+
// regression to a static `storageProvider` makes the `provider.exists(...)`
|
|
11
|
+
// assertion below go red.
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import { type SessionUser, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
|
+
import {
|
|
19
|
+
createInMemoryFileProvider,
|
|
20
|
+
type FileStorageProvider,
|
|
21
|
+
fileRefsTable,
|
|
22
|
+
type InMemoryFileProvider,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
24
|
+
import {
|
|
25
|
+
setupTestStack,
|
|
26
|
+
type TestStack,
|
|
27
|
+
unsafeCreateEntityTable,
|
|
28
|
+
unsafePushTables,
|
|
29
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
30
|
+
import {
|
|
31
|
+
buildMultipartBody,
|
|
32
|
+
patchFileInstanceofForBunTest,
|
|
33
|
+
resetTestTables,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
35
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
36
|
+
import { createConfigFeature } from "../../config";
|
|
37
|
+
import { createConfigAccessorFactory } from "../../config/feature";
|
|
38
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
39
|
+
import { configValuesTable } from "../../config/table";
|
|
40
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
41
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
42
|
+
import { createFilesFeature } from "../../files";
|
|
43
|
+
import { createSessionsFeature } from "../../sessions";
|
|
44
|
+
import { createUserFeature, userEntity, userTable } from "../../user";
|
|
45
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
46
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
47
|
+
import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
|
|
48
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
49
|
+
import {
|
|
50
|
+
createForgetSeeders,
|
|
51
|
+
createTestFileProviderFeature,
|
|
52
|
+
type ForgetSeeders,
|
|
53
|
+
nowInstant,
|
|
54
|
+
READ_TENANT_MEMBERSHIPS_DDL,
|
|
55
|
+
TENANT_SYSTEM,
|
|
56
|
+
} from "./forget-test-helpers";
|
|
57
|
+
|
|
58
|
+
const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider";
|
|
59
|
+
|
|
60
|
+
let stack: TestStack;
|
|
61
|
+
let db: DbConnection;
|
|
62
|
+
let provider: InMemoryFileProvider;
|
|
63
|
+
let seed: ForgetSeeders;
|
|
64
|
+
let buildStorageProvider: (tenantId: string) => Promise<FileStorageProvider>;
|
|
65
|
+
|
|
66
|
+
function uuid(suffix: number): string {
|
|
67
|
+
return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function uploadAs(user: SessionUser, fileName: string, bytes: Uint8Array): Promise<Response> {
|
|
71
|
+
const token = await stack.jwt.sign(user);
|
|
72
|
+
const fd = new FormData();
|
|
73
|
+
fd.append("file", new File([Buffer.from(bytes)], fileName, { type: "application/pdf" }));
|
|
74
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
75
|
+
return stack.app.request("/api/files", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
78
|
+
body,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
patchFileInstanceofForBunTest();
|
|
84
|
+
provider = createInMemoryFileProvider();
|
|
85
|
+
// Select the test plugin app-wide via a config app-override (no admin write).
|
|
86
|
+
const appOverrides = new Map<string, string>([[FILE_PROVIDER_CONFIG_KEY, "test"]]);
|
|
87
|
+
const resolver = createConfigResolver({ appOverrides });
|
|
88
|
+
stack = await setupTestStack({
|
|
89
|
+
features: [
|
|
90
|
+
createConfigFeature(),
|
|
91
|
+
createUserFeature(),
|
|
92
|
+
createFilesFeature(),
|
|
93
|
+
fileFoundationFeature,
|
|
94
|
+
createTestFileProviderFeature(provider, "test"),
|
|
95
|
+
createDataRetentionFeature(),
|
|
96
|
+
createComplianceProfilesFeature(),
|
|
97
|
+
createSessionsFeature(),
|
|
98
|
+
createUserDataRightsFeature(),
|
|
99
|
+
createUserDataRightsDefaultsFeature(),
|
|
100
|
+
],
|
|
101
|
+
// No `files:` option on purpose — buildServer must resolve the upload
|
|
102
|
+
// provider through the mounted file-foundation, like production.
|
|
103
|
+
extraContext: ({ registry }) => ({
|
|
104
|
+
configResolver: resolver,
|
|
105
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
db = stack.db;
|
|
109
|
+
seed = createForgetSeeders(db, provider);
|
|
110
|
+
buildStorageProvider = makeTenantStorageProviderResolver({
|
|
111
|
+
registry: stack.registry,
|
|
112
|
+
configResolver: resolver,
|
|
113
|
+
secrets: undefined,
|
|
114
|
+
db,
|
|
115
|
+
userId: SYSTEM_USER_ID,
|
|
116
|
+
handlerName: "test-608-e2e",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await unsafeCreateEntityTable(db, userEntity);
|
|
120
|
+
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
121
|
+
await unsafePushTables(db, { fileRefsTable, configValuesTable });
|
|
122
|
+
await createEventsTable(db);
|
|
123
|
+
await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterAll(async () => {
|
|
127
|
+
await stack.cleanup();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
beforeEach(async () => {
|
|
131
|
+
provider.clear();
|
|
132
|
+
await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("#608 unified file-storage :: route upload + GDPR erasure hit one store", () => {
|
|
136
|
+
test("a file uploaded via /api/files lands in the file-foundation store and forget erases it", async () => {
|
|
137
|
+
const userId = uuid(1);
|
|
138
|
+
// The uploader is also the forget subject (DeletionRequested + grace passed).
|
|
139
|
+
await seed.seedForgetUser(userId);
|
|
140
|
+
await seed.seedMembership(userId, TENANT_SYSTEM);
|
|
141
|
+
const uploader: SessionUser = { id: userId, tenantId: TENANT_SYSTEM, roles: ["Member"] };
|
|
142
|
+
|
|
143
|
+
const res = await uploadAs(uploader, "secret.pdf", new Uint8Array([10, 20, 30, 40]));
|
|
144
|
+
expect(res.status).toBe(201);
|
|
145
|
+
const { storageKey } = (await res.json()) as { storageKey: string };
|
|
146
|
+
|
|
147
|
+
// The upload route resolved its provider through file-foundation — the bytes
|
|
148
|
+
// are in `provider`. A static/separate upload provider would miss here.
|
|
149
|
+
expect(await provider.exists(storageKey)).toBe(true);
|
|
150
|
+
|
|
151
|
+
// Erasure resolves through the SAME source and deletes the very same bytes —
|
|
152
|
+
// no false "done", no orphaned binary.
|
|
153
|
+
const result = await runForgetCleanup({
|
|
154
|
+
db,
|
|
155
|
+
registry: stack.registry,
|
|
156
|
+
now: nowInstant(),
|
|
157
|
+
buildStorageProvider,
|
|
158
|
+
});
|
|
159
|
+
expect(result.processedUserIds).toContain(userId);
|
|
160
|
+
expect(await provider.exists(storageKey)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { DEFAULT_TAG_ACCESS } from "../constants";
|
|
3
|
-
import { tagExecutor } from "../executor";
|
|
4
|
-
import { type RenameTagPayload, renameTagPayloadSchema } from "../schemas";
|
|
5
|
-
|
|
6
|
-
// rename-tag — renames a tag in the tenant's catalog. Optimistic-locked: the
|
|
7
|
-
// client sends the `version` it read (mirrors tenant:update). The executor
|
|
8
|
-
// merges shallowly so only `name` changes — `color` is preserved. A stale
|
|
9
|
-
// version returns version_conflict; the UI refetches and retries.
|
|
10
|
-
export function createRenameTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
|
|
11
|
-
return {
|
|
12
|
-
name: "rename-tag",
|
|
13
|
-
schema: renameTagPayloadSchema,
|
|
14
|
-
access,
|
|
15
|
-
handler: async (event, ctx) => {
|
|
16
|
-
const payload = event.payload as RenameTagPayload; // @cast-boundary engine-payload
|
|
17
|
-
return tagExecutor.update(
|
|
18
|
-
{ id: payload.id, version: payload.version, changes: { name: payload.name } },
|
|
19
|
-
event.user,
|
|
20
|
-
ctx.db,
|
|
21
|
-
);
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export const renameTagHandler: WriteHandlerDef = createRenameTagHandler();
|