Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@toolctrl/view-models",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm run typecheck",
|
||||
"test": "vitest run src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSelectTenantVM, toTenantCard, type SelectTenantInput } from "./select-tenant.vm.js";
|
||||
|
||||
function tenant(overrides: Partial<SelectTenantInput> & { id: string; name: string; slug: string }): SelectTenantInput {
|
||||
return { ...overrides };
|
||||
}
|
||||
|
||||
describe("buildSelectTenantVM", () => {
|
||||
it("returns an empty state when the user has no tenants", () => {
|
||||
expect(buildSelectTenantVM({ tenants: [] })).toEqual({ status: "empty" });
|
||||
});
|
||||
|
||||
it("maps tenants to cards, derives initials, and assigns the role tag + tone", () => {
|
||||
const vm = buildSelectTenantVM({
|
||||
tenants: [tenant({ id: "t1", name: "Berger Bad", slug: "berger-bad", role: "owner", subtitle: "t.berger@berger-bad.de" })],
|
||||
});
|
||||
expect(vm.status).toBe("ready");
|
||||
const card = vm.data!.tenants[0];
|
||||
expect(card.avatarInitials).toBe("BB");
|
||||
expect(card.role).toBe("owner");
|
||||
expect(card.roleLabelKey).toBe("account.selectTenant.role.owner");
|
||||
expect(card.roleTone).toBe("red-subtle");
|
||||
expect(card.subtitle).toBe("t.berger@berger-bad.de");
|
||||
expect(vm.data!.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the image and a neutral avatar color when a logo/icon is present", () => {
|
||||
const card = toTenantCard({ id: "t1", name: "Acme", slug: "acme", image: "https://x/y.png" });
|
||||
expect(card.avatarImage).toBe("https://x/y.png");
|
||||
expect(card.avatarColor).toBe("neutral");
|
||||
});
|
||||
|
||||
it("derives a deterministic avatar color from the id when no image is present", () => {
|
||||
const a = toTenantCard({ id: "t1", name: "Acme", slug: "acme" });
|
||||
const b = toTenantCard({ id: "t1", name: "Acme", slug: "acme" });
|
||||
expect(a.avatarColor).toBe(b.avatarColor);
|
||||
expect(a.avatarColor).not.toBe("neutral");
|
||||
expect(a.roleLabelKey).toBeUndefined();
|
||||
expect(a.roleTone).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flags and sorts the current/last-used tenant to the front", () => {
|
||||
const vm = buildSelectTenantVM({
|
||||
tenants: [
|
||||
tenant({ id: "t1", name: "Zeta", slug: "zeta" }),
|
||||
tenant({ id: "t2", name: "Alpha", slug: "alpha" }),
|
||||
tenant({ id: "t3", name: "Mu", slug: "mu" }),
|
||||
],
|
||||
currentTenantId: "t3",
|
||||
});
|
||||
expect(vm.data!.tenants[0].id).toBe("t3");
|
||||
expect(vm.data!.tenants[0].isCurrent).toBe(true);
|
||||
// restliche alphabetisch
|
||||
expect(vm.data!.tenants.slice(1).map((c) => c.name)).toEqual(["Alpha", "Zeta"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { VMState } from "../index.js";
|
||||
|
||||
/**
|
||||
* Tenant-/Profil-Auswahl View-Model (D-D0a, Flow "Login Tenantauswahl (Admin)", M013-Konvention).
|
||||
*
|
||||
* Reine Funktion: ein Loader holt die Tenants des Users (getMyTenants) + dessen Rolle je Tenant
|
||||
* (getTenantUserType) und baut dieses VM; die UI (Davids Subframe-Karten) rendert nur. Kein React,
|
||||
* kein Prisma, keine App-Imports (DoD A.3). Rollen-Labels werden als i18n-Key ausgegeben (kein
|
||||
* vorformatierter Klartext, DoD i18n); die Tag-Tone-Zuordnung ist deterministisch.
|
||||
*/
|
||||
|
||||
export type TenantRole = "owner" | "admin" | "member";
|
||||
|
||||
/** Tag_Label-Tone-Literale (Teilmenge), die die UI direkt durchreichen kann. */
|
||||
export type RoleTone = "red-subtle" | "blue-subtle" | "green-subtle" | "neutral-subtle";
|
||||
|
||||
export type AvatarColor = "neutral" | "blue" | "green" | "red" | "yellow" | "purple";
|
||||
|
||||
export interface SelectTenantInput {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
/** Bild-Quelle (Tenant-Icon/Logo); fehlt -> Initialen-Avatar. */
|
||||
readonly image?: string | null;
|
||||
/** Rolle des aktuellen Users in diesem Tenant; fehlt -> kein Tag. */
|
||||
readonly role?: TenantRole | null;
|
||||
/** Optionaler Untertitel als DATEN (z.B. User-Email). */
|
||||
readonly subtitle?: string | null;
|
||||
}
|
||||
|
||||
export interface BuildSelectTenantInput {
|
||||
readonly tenants: readonly SelectTenantInput[];
|
||||
/** Tenant, der als "zuletzt genutzt" markiert wird (User.defaultTenantId). */
|
||||
readonly currentTenantId?: string | null;
|
||||
}
|
||||
|
||||
export interface TenantCardData {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly slug: string;
|
||||
/** Bild-URL, wenn vorhanden; sonst undefined -> Initialen nutzen. */
|
||||
readonly avatarImage?: string;
|
||||
/** Bis zu zwei Großbuchstaben aus dem Tenant-Namen. */
|
||||
readonly avatarInitials: string;
|
||||
readonly avatarColor: AvatarColor;
|
||||
readonly role?: TenantRole;
|
||||
/** i18n-Key des Rollen-Labels (z.B. account.selectTenant.role.owner). */
|
||||
readonly roleLabelKey?: string;
|
||||
readonly roleTone?: RoleTone;
|
||||
/** "zuletzt genutzt"-Markierung. */
|
||||
readonly isCurrent: boolean;
|
||||
readonly subtitle?: string;
|
||||
}
|
||||
|
||||
export interface SelectTenantData {
|
||||
readonly tenants: readonly TenantCardData[];
|
||||
readonly totalCount: number;
|
||||
}
|
||||
|
||||
const ROLE_TONE: Record<TenantRole, RoleTone> = {
|
||||
owner: "red-subtle",
|
||||
admin: "blue-subtle",
|
||||
member: "green-subtle",
|
||||
};
|
||||
|
||||
const AVATAR_PALETTE: readonly AvatarColor[] = ["blue", "green", "red", "yellow", "purple"];
|
||||
|
||||
/** Deterministische, stabile Farbe aus einem String (kein Math.random — testbar). */
|
||||
function colorFor(seed: string): AvatarColor {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
|
||||
}
|
||||
|
||||
function initialsFor(name: string): string {
|
||||
const words = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return "?";
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
export function toTenantCard(input: SelectTenantInput, currentTenantId?: string | null): TenantCardData {
|
||||
const role = input.role ?? undefined;
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
avatarImage: input.image ?? undefined,
|
||||
avatarInitials: initialsFor(input.name),
|
||||
avatarColor: input.image ? "neutral" : colorFor(input.id),
|
||||
role,
|
||||
roleLabelKey: role ? `account.selectTenant.role.${role}` : undefined,
|
||||
roleTone: role ? ROLE_TONE[role] : undefined,
|
||||
isCurrent: !!currentTenantId && input.id === currentTenantId,
|
||||
subtitle: input.subtitle ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Auswahl-VM. Leere Liste -> "empty" (Empty-State in der UI, DoD 23).
|
||||
* Der aktuelle/zuletzt genutzte Tenant wird nach vorne sortiert.
|
||||
*/
|
||||
export function buildSelectTenantVM(input: BuildSelectTenantInput): VMState<SelectTenantData> {
|
||||
if (!input.tenants || input.tenants.length === 0) {
|
||||
return { status: "empty" };
|
||||
}
|
||||
const cards = input.tenants.map((t) => toTenantCard(t, input.currentTenantId));
|
||||
const sorted = [...cards].sort((a, b) => {
|
||||
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return {
|
||||
status: "ready",
|
||||
data: { tenants: sorted, totalCount: sorted.length },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSessionListVM, type SessionDeviceInput } from "./session-list.vm.js";
|
||||
|
||||
const NOW = Date.UTC(2026, 5, 18, 12, 0, 0);
|
||||
|
||||
function session(overrides: Partial<SessionDeviceInput> & { id: string }): SessionDeviceInput {
|
||||
return {
|
||||
lastSeenAt: new Date(NOW),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildSessionListVM", () => {
|
||||
it("returns an empty state when there are no sessions", () => {
|
||||
expect(buildSessionListVM({ sessions: [], currentSessionId: "s1", nowMs: NOW })).toEqual({ status: "empty" });
|
||||
});
|
||||
|
||||
it("flags the current device, forbids revoking it, and counts the others", () => {
|
||||
const vm = buildSessionListVM({
|
||||
sessions: [
|
||||
session({ id: "s1", userAgent: "Firefox", approxLocation: "Germany" }),
|
||||
session({ id: "s2", deviceLabel: "iPhone", trustedAt: new Date(NOW) }),
|
||||
],
|
||||
currentSessionId: "s1",
|
||||
nowMs: NOW,
|
||||
});
|
||||
expect(vm.status).toBe("ready");
|
||||
if (vm.status !== "ready") return;
|
||||
const current = vm.data.devices.find((d) => d.id === "s1")!;
|
||||
const other = vm.data.devices.find((d) => d.id === "s2")!;
|
||||
expect(current.isCurrent).toBe(true);
|
||||
expect(current.canRevoke).toBe(false);
|
||||
expect(current.deviceLabel).toBe("Firefox");
|
||||
expect(other.isCurrent).toBe(false);
|
||||
expect(other.canRevoke).toBe(true);
|
||||
expect(other.trusted).toBe(true);
|
||||
expect(vm.data.otherActiveCount).toBe(1);
|
||||
expect(vm.data.canRevokeOthers).toBe(true);
|
||||
});
|
||||
|
||||
it("orders the current device first", () => {
|
||||
const vm = buildSessionListVM({
|
||||
sessions: [session({ id: "s2" }), session({ id: "s1" })],
|
||||
currentSessionId: "s1",
|
||||
nowMs: NOW,
|
||||
});
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.devices[0].id).toBe("s1");
|
||||
});
|
||||
|
||||
it("falls back to an i18n key when no device label or user agent is present", () => {
|
||||
const vm = buildSessionListVM({ sessions: [session({ id: "s1" })], currentSessionId: null, nowMs: NOW });
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.devices[0].deviceLabel).toBeUndefined();
|
||||
expect(vm.data.devices[0].deviceLabelFallbackKey).toBe("settings.security.sessions.unknownDevice");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[30 * 1000, "justNow", undefined],
|
||||
[5 * 60 * 1000, "minutes", 5],
|
||||
[3 * 60 * 60 * 1000, "hours", 3],
|
||||
[2 * 24 * 60 * 60 * 1000, "days", 2],
|
||||
])("buckets last-seen %ims ago as %s", (ago, bucket, count) => {
|
||||
const vm = buildSessionListVM({
|
||||
sessions: [session({ id: "s1", lastSeenAt: new Date(NOW - (ago as number)) })],
|
||||
currentSessionId: "s1",
|
||||
nowMs: NOW,
|
||||
});
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.devices[0].lastSeenBucket).toBe(bucket);
|
||||
expect(vm.data.devices[0].lastSeenCount).toBe(count);
|
||||
});
|
||||
|
||||
it("has no revocable others when only the current device is active", () => {
|
||||
const vm = buildSessionListVM({ sessions: [session({ id: "s1" })], currentSessionId: "s1", nowMs: NOW });
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.otherActiveCount).toBe(0);
|
||||
expect(vm.data.canRevokeOthers).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { VMState } from "../index.js";
|
||||
|
||||
/**
|
||||
* Session-Geraeteliste View-Model (R-P2a S6, M013-Konvention).
|
||||
*
|
||||
* Reine Funktion: ein Loader holt die aktiven UserSession-Zeilen (sessionStore.listActiveUserSessions)
|
||||
* und baut dieses VM; die UI (Settings > Sicherheit, Davids D-P2) rendert nur. Kein React, kein Prisma,
|
||||
* keine App-Imports (DoD A.3). Last-Seen wird als i18n-Bucket + Count ausgegeben (kein vorformatierter
|
||||
* Klartext, DoD i18n); `nowMs` ist Eingabe, damit die Funktion deterministisch testbar bleibt.
|
||||
*/
|
||||
|
||||
export interface SessionDeviceInput {
|
||||
readonly id: string;
|
||||
readonly lastSeenAt: string | Date;
|
||||
// AD-22: keine Klartext-IP — die Herkunft kommt als Land (approxLocation).
|
||||
readonly userAgent?: string | null;
|
||||
readonly deviceLabel?: string | null;
|
||||
readonly approxLocation?: string | null;
|
||||
readonly trustedAt?: string | Date | null;
|
||||
}
|
||||
|
||||
export type LastSeenBucket = "justNow" | "minutes" | "hours" | "days";
|
||||
|
||||
export interface SessionDeviceData {
|
||||
readonly id: string;
|
||||
readonly isCurrent: boolean;
|
||||
readonly trusted: boolean;
|
||||
/** Roh-Label (deviceLabel oder userAgent) als DATEN; i18n-Fallback, wenn beides fehlt. */
|
||||
readonly deviceLabel?: string;
|
||||
readonly deviceLabelFallbackKey?: string;
|
||||
readonly approxLocation?: string;
|
||||
readonly lastSeenIso: string;
|
||||
readonly lastSeenBucket: LastSeenBucket;
|
||||
/** Zaehlwert fuer minutes/hours/days (fuer "vor N Minuten"); bei justNow undefined. */
|
||||
readonly lastSeenCount?: number;
|
||||
/** Aktuelle Session beendet man ueber normales Abmelden, nicht ueber die Geraeteliste. */
|
||||
readonly canRevoke: boolean;
|
||||
}
|
||||
|
||||
export interface SessionListData {
|
||||
readonly devices: readonly SessionDeviceData[];
|
||||
readonly currentDeviceId: string | null;
|
||||
/** Anzahl anderer aktiver Geraete (fuer "ueberall abmelden (N)"). */
|
||||
readonly otherActiveCount: number;
|
||||
readonly canRevokeOthers: boolean;
|
||||
}
|
||||
|
||||
export type SessionListVM = VMState<SessionListData>;
|
||||
|
||||
export interface BuildSessionListVMInput {
|
||||
readonly sessions: readonly SessionDeviceInput[];
|
||||
readonly currentSessionId: string | null;
|
||||
readonly nowMs: number;
|
||||
}
|
||||
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
const HOUR_MS = 60 * MINUTE_MS;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
function toMs(value: string | Date): number {
|
||||
return value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
}
|
||||
|
||||
function relativeLastSeen(lastSeenMs: number, nowMs: number): { bucket: LastSeenBucket; count?: number } {
|
||||
const diff = Math.max(0, nowMs - lastSeenMs);
|
||||
if (diff < MINUTE_MS) {
|
||||
return { bucket: "justNow" };
|
||||
}
|
||||
if (diff < HOUR_MS) {
|
||||
return { bucket: "minutes", count: Math.floor(diff / MINUTE_MS) };
|
||||
}
|
||||
if (diff < DAY_MS) {
|
||||
return { bucket: "hours", count: Math.floor(diff / HOUR_MS) };
|
||||
}
|
||||
return { bucket: "days", count: Math.floor(diff / DAY_MS) };
|
||||
}
|
||||
|
||||
function toDevice(session: SessionDeviceInput, currentSessionId: string | null, nowMs: number): SessionDeviceData {
|
||||
const isCurrent = session.id === currentSessionId;
|
||||
const rawLabel = session.deviceLabel?.trim() || session.userAgent?.trim() || undefined;
|
||||
const lastSeenMs = toMs(session.lastSeenAt);
|
||||
const relative = relativeLastSeen(lastSeenMs, nowMs);
|
||||
return {
|
||||
id: session.id,
|
||||
isCurrent,
|
||||
trusted: Boolean(session.trustedAt),
|
||||
deviceLabel: rawLabel,
|
||||
deviceLabelFallbackKey: rawLabel ? undefined : "settings.security.sessions.unknownDevice",
|
||||
approxLocation: session.approxLocation?.trim() || undefined,
|
||||
lastSeenIso: new Date(lastSeenMs).toISOString(),
|
||||
lastSeenBucket: relative.bucket,
|
||||
lastSeenCount: relative.count,
|
||||
canRevoke: !isCurrent,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSessionListVM(input: BuildSessionListVMInput): SessionListVM {
|
||||
if (input.sessions.length === 0) {
|
||||
return { status: "empty" };
|
||||
}
|
||||
const devices = input.sessions
|
||||
.map((session) => toDevice(session, input.currentSessionId, input.nowMs))
|
||||
// Aktuelles Geraet zuerst, der Rest nach zuletzt gesehen (Eingabe ist i.d.R. schon desc sortiert).
|
||||
.sort((a, b) => (a.isCurrent === b.isCurrent ? 0 : a.isCurrent ? -1 : 1));
|
||||
const otherActiveCount = devices.filter((device) => !device.isCurrent).length;
|
||||
return {
|
||||
status: "ready",
|
||||
data: {
|
||||
devices,
|
||||
currentDeviceId: input.currentSessionId,
|
||||
otherActiveCount,
|
||||
canRevokeOthers: otherActiveCount > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { VMState } from "../index.js";
|
||||
|
||||
/**
|
||||
* Files browser view-model (AK-P4a §9). Pure function: the loader builds it once
|
||||
* server-side, the UI only renders. No React, no Prisma, no app imports
|
||||
* (DoD A.3). All texts are i18n keys.
|
||||
*
|
||||
* URLs (`thumbnailUrl`, `detailHref`) ALWAYS point at the auth-proxy route
|
||||
* (AD-23) or internal routes, NEVER at a raw S3 URL — the loader builds them and
|
||||
* passes them in here so the VM stays routing-agnostic + pure.
|
||||
*/
|
||||
export type FilePreviewStatus = "pending" | "generating" | "ready" | "failed" | "not_supported";
|
||||
export type FileScanStatus = "pending" | "clean" | "infected" | "skipped";
|
||||
export type FileViewMode = "table" | "grid";
|
||||
|
||||
export interface FileBrowserRow {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly mimeType: string;
|
||||
readonly sizeBytes: number;
|
||||
readonly createdAtIso: string;
|
||||
readonly tags: readonly string[];
|
||||
readonly source: string;
|
||||
readonly previewStatus: FilePreviewStatus;
|
||||
readonly scanStatus: FileScanStatus;
|
||||
/** Proxy-route URL of the thumbnail (or null while not yet `ready`). */
|
||||
readonly thumbnailUrl: string | null;
|
||||
/** Internal detail route (e.g. `/acme/files/<id>`). */
|
||||
readonly detailHref: string;
|
||||
/** Label of the linked target entity (i18n key) or null. */
|
||||
readonly linkedEntityLabelKey: string | null;
|
||||
}
|
||||
|
||||
export interface FileBrowserFilterState {
|
||||
readonly entityType: string | null;
|
||||
readonly mimeGroup: string | null;
|
||||
readonly tags: readonly string[];
|
||||
readonly assignedToUserId: string | null;
|
||||
readonly includeArchived: boolean;
|
||||
}
|
||||
|
||||
export interface FileBrowserData {
|
||||
readonly viewMode: FileViewMode;
|
||||
readonly rows: readonly FileBrowserRow[];
|
||||
readonly filter: FileBrowserFilterState;
|
||||
readonly canWrite: boolean;
|
||||
readonly canDelete: boolean;
|
||||
/** Cursor of the next page (null = last page). */
|
||||
readonly nextCursor: string | null;
|
||||
readonly emptyHeadlineKey: string;
|
||||
readonly emptyBodyKey: string;
|
||||
}
|
||||
|
||||
export type FileBrowserVM = VMState<FileBrowserData>;
|
||||
|
||||
/**
|
||||
* Normalized prop-model the UI implementation renders (M015 Subframe-adapter
|
||||
* contract). The ui-app adapter maps {@link FileBrowserData} -> this model once,
|
||||
* then the ui-subframe / ui-mock renderers stay dumb (hot-swap drill, M017).
|
||||
*/
|
||||
export interface FileBrowserModelRow {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly mimeType: string;
|
||||
readonly sizeBytes: number;
|
||||
readonly detailHref: string;
|
||||
readonly thumbnailUrl: string | null;
|
||||
}
|
||||
|
||||
export interface FileBrowserModel {
|
||||
readonly titleKey: string;
|
||||
readonly emptyHeadlineKey: string;
|
||||
readonly emptyBodyKey: string;
|
||||
readonly canWrite: boolean;
|
||||
readonly rows: readonly FileBrowserModelRow[];
|
||||
readonly rowCount: number;
|
||||
}
|
||||
|
||||
export interface BuildFileBrowserVMInput {
|
||||
readonly viewMode?: FileViewMode;
|
||||
readonly rows: readonly FileBrowserRow[];
|
||||
readonly filter: FileBrowserFilterState;
|
||||
readonly canWrite: boolean;
|
||||
readonly canDelete: boolean;
|
||||
readonly nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export function buildFileBrowserVM(input: BuildFileBrowserVMInput): FileBrowserVM {
|
||||
const viewMode: FileViewMode = input.viewMode ?? "table";
|
||||
if (input.rows.length === 0) {
|
||||
// Empty stays a `ready` state with a plain-text empty state (DoD 23) so the
|
||||
// filter/toggle bar and upload CTA remain visible.
|
||||
return {
|
||||
status: "ready",
|
||||
data: {
|
||||
viewMode,
|
||||
rows: [],
|
||||
filter: input.filter,
|
||||
canWrite: input.canWrite,
|
||||
canDelete: input.canDelete,
|
||||
nextCursor: null,
|
||||
emptyHeadlineKey: "files.browser.empty.headline",
|
||||
emptyBodyKey: "files.browser.empty.body",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ready",
|
||||
data: {
|
||||
viewMode,
|
||||
rows: input.rows,
|
||||
filter: input.filter,
|
||||
canWrite: input.canWrite,
|
||||
canDelete: input.canDelete,
|
||||
nextCursor: input.nextCursor ?? null,
|
||||
emptyHeadlineKey: "files.browser.empty.headline",
|
||||
emptyBodyKey: "files.browser.empty.body",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { VMState } from "../index.js";
|
||||
import type { FilePreviewStatus, FileScanStatus } from "./file-browser.vm.js";
|
||||
|
||||
/**
|
||||
* File detail view-model (AK-P4a §9). Pure function, i18n keys only.
|
||||
* Preview/download URLs point at the auth-proxy route (AD-23).
|
||||
*/
|
||||
export interface FileLinkedEntity {
|
||||
readonly entityType: string;
|
||||
readonly entityId: string;
|
||||
readonly linkRole: string | null;
|
||||
/** Internal route to the target entity (loader-built) or null. */
|
||||
readonly href: string | null;
|
||||
}
|
||||
|
||||
export interface FileDetailData {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly mimeType: string;
|
||||
readonly sizeBytes: number;
|
||||
readonly createdAtIso: string;
|
||||
readonly tags: readonly string[];
|
||||
readonly notes: string | null;
|
||||
readonly source: string;
|
||||
readonly previewStatus: FilePreviewStatus;
|
||||
readonly scanStatus: FileScanStatus;
|
||||
/** Proxy URL of the preview (or null). */
|
||||
readonly previewUrl: string | null;
|
||||
/** Proxy URL of the original for download. */
|
||||
readonly downloadUrl: string;
|
||||
readonly assignedToUserId: string | null;
|
||||
readonly linkedEntities: readonly FileLinkedEntity[];
|
||||
/** Plain-text hint key when previewStatus=not_supported/failed. */
|
||||
readonly previewFallbackKey: string | null;
|
||||
/** Plain-text key when scanStatus=infected (download blocked) or pending. */
|
||||
readonly scanNoticeKey: string | null;
|
||||
readonly canWrite: boolean;
|
||||
readonly canDelete: boolean;
|
||||
readonly canAdmin: boolean;
|
||||
/** Regenerating the preview only makes sense when it failed. */
|
||||
readonly canRegeneratePreview: boolean;
|
||||
}
|
||||
|
||||
export type FileDetailVM = VMState<FileDetailData>;
|
||||
|
||||
export interface BuildFileDetailVMInput {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly mimeType: string;
|
||||
readonly sizeBytes: number;
|
||||
readonly createdAtIso: string;
|
||||
readonly tags: readonly string[];
|
||||
readonly notes: string | null;
|
||||
readonly source: string;
|
||||
readonly previewStatus: FilePreviewStatus;
|
||||
readonly scanStatus: FileScanStatus;
|
||||
readonly previewUrl: string | null;
|
||||
readonly downloadUrl: string;
|
||||
readonly assignedToUserId: string | null;
|
||||
readonly linkedEntities: readonly FileLinkedEntity[];
|
||||
readonly canWrite: boolean;
|
||||
readonly canDelete: boolean;
|
||||
readonly canAdmin: boolean;
|
||||
}
|
||||
|
||||
function previewFallbackKey(status: FilePreviewStatus): string | null {
|
||||
if (status === "not_supported") return "files.detail.preview.notSupported";
|
||||
if (status === "failed") return "files.detail.preview.failed";
|
||||
if (status === "pending" || status === "generating") return "files.detail.preview.pending";
|
||||
return null;
|
||||
}
|
||||
|
||||
function scanNoticeKey(status: FileScanStatus): string | null {
|
||||
if (status === "infected") return "files.detail.scan.infected";
|
||||
if (status === "pending") return "files.detail.scan.pending";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildFileDetailVM(input: BuildFileDetailVMInput): FileDetailVM {
|
||||
return {
|
||||
status: "ready",
|
||||
data: {
|
||||
id: input.id,
|
||||
displayName: input.displayName,
|
||||
mimeType: input.mimeType,
|
||||
sizeBytes: input.sizeBytes,
|
||||
createdAtIso: input.createdAtIso,
|
||||
tags: input.tags,
|
||||
notes: input.notes,
|
||||
source: input.source,
|
||||
previewStatus: input.previewStatus,
|
||||
scanStatus: input.scanStatus,
|
||||
// An infected file never exposes a preview URL (fail-closed).
|
||||
previewUrl: input.scanStatus === "infected" ? null : input.previewUrl,
|
||||
downloadUrl: input.downloadUrl,
|
||||
assignedToUserId: input.assignedToUserId,
|
||||
linkedEntities: input.linkedEntities,
|
||||
previewFallbackKey: previewFallbackKey(input.previewStatus),
|
||||
scanNoticeKey: scanNoticeKey(input.scanStatus),
|
||||
canWrite: input.canWrite,
|
||||
canDelete: input.canDelete,
|
||||
canAdmin: input.canAdmin,
|
||||
canRegeneratePreview: input.canWrite && input.previewStatus === "failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { VMState } from "../index.js";
|
||||
|
||||
/**
|
||||
* Upload view view-model (AK-P4a §9). Pure function, i18n keys only.
|
||||
* Describes the multi-file queue with per-file progress + plain-text errors
|
||||
* (HEIC hint, too large, MIME blocked, network abort).
|
||||
*/
|
||||
export type FileUploadItemStatus = "queued" | "uploading" | "processing" | "done" | "error";
|
||||
|
||||
export interface FileUploadQueueItem {
|
||||
readonly clientId: string;
|
||||
readonly filename: string;
|
||||
readonly sizeBytes: number;
|
||||
readonly status: FileUploadItemStatus;
|
||||
/** 0..100 */
|
||||
readonly progressPercent: number;
|
||||
/** i18n key of the error message (only when status=error). */
|
||||
readonly errorKey: string | null;
|
||||
/** Plain-text hint key (e.g. HEIC will be converted to JPG). */
|
||||
readonly noticeKey: string | null;
|
||||
readonly canRetry: boolean;
|
||||
}
|
||||
|
||||
export interface FileUploadData {
|
||||
readonly canWrite: boolean;
|
||||
readonly chunkSizeBytes: number;
|
||||
readonly maxFileSizeBytes: number;
|
||||
readonly maxParallelUploads: number;
|
||||
readonly items: readonly FileUploadQueueItem[];
|
||||
readonly dropzoneHeadlineKey: string;
|
||||
readonly dropzoneBodyKey: string;
|
||||
readonly heicNoticeKey: string;
|
||||
}
|
||||
|
||||
export type FileUploadVM = VMState<FileUploadData>;
|
||||
|
||||
export const FILE_UPLOAD_CHUNK_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
export const FILE_UPLOAD_MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024; // 500 MB
|
||||
export const FILE_UPLOAD_MAX_PARALLEL = 3;
|
||||
|
||||
export interface BuildFileUploadVMInput {
|
||||
readonly canWrite: boolean;
|
||||
readonly items?: readonly FileUploadQueueItem[];
|
||||
readonly chunkSizeBytes?: number;
|
||||
readonly maxFileSizeBytes?: number;
|
||||
readonly maxParallelUploads?: number;
|
||||
}
|
||||
|
||||
export function buildFileUploadVM(input: BuildFileUploadVMInput): FileUploadVM {
|
||||
return {
|
||||
status: "ready",
|
||||
data: {
|
||||
canWrite: input.canWrite,
|
||||
chunkSizeBytes: input.chunkSizeBytes ?? FILE_UPLOAD_CHUNK_SIZE_BYTES,
|
||||
maxFileSizeBytes: input.maxFileSizeBytes ?? FILE_UPLOAD_MAX_FILE_SIZE_BYTES,
|
||||
maxParallelUploads: input.maxParallelUploads ?? FILE_UPLOAD_MAX_PARALLEL,
|
||||
items: input.items ?? [],
|
||||
dropzoneHeadlineKey: "files.upload.dropzone.headline",
|
||||
dropzoneBodyKey: "files.upload.dropzone.body",
|
||||
heicNoticeKey: "files.upload.notice.heic",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildFileBrowserVM,
|
||||
buildFileDetailVM,
|
||||
buildFileUploadVM,
|
||||
FILE_UPLOAD_CHUNK_SIZE_BYTES,
|
||||
FILE_UPLOAD_MAX_FILE_SIZE_BYTES,
|
||||
type BuildFileDetailVMInput,
|
||||
type FileBrowserFilterState,
|
||||
type FileBrowserRow,
|
||||
} from "../index.js";
|
||||
|
||||
const I18N_KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
||||
|
||||
const filter: FileBrowserFilterState = {
|
||||
entityType: null,
|
||||
mimeGroup: null,
|
||||
tags: [],
|
||||
assignedToUserId: null,
|
||||
includeArchived: false,
|
||||
};
|
||||
|
||||
describe("buildFileBrowserVM", () => {
|
||||
it("returns a ready empty-state with i18n keys when there are no rows", () => {
|
||||
const vm = buildFileBrowserVM({ rows: [], filter, canWrite: true, canDelete: false });
|
||||
expect(vm.status).toBe("ready");
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.rows).toHaveLength(0);
|
||||
expect(vm.data.viewMode).toBe("table");
|
||||
expect(vm.data.emptyHeadlineKey).toMatch(I18N_KEY);
|
||||
expect(vm.data.emptyBodyKey).toMatch(I18N_KEY);
|
||||
});
|
||||
|
||||
it("passes rows through and honours the view mode + cursor", () => {
|
||||
const row: FileBrowserRow = {
|
||||
id: "f1",
|
||||
displayName: "foto.heic",
|
||||
mimeType: "image/heic",
|
||||
sizeBytes: 1234,
|
||||
createdAtIso: "2026-06-19T08:00:00.000Z",
|
||||
tags: ["baustelle"],
|
||||
source: "manual_upload",
|
||||
previewStatus: "ready",
|
||||
scanStatus: "clean",
|
||||
thumbnailUrl: "/acme/media/f1/thumb",
|
||||
detailHref: "/acme/files/f1",
|
||||
linkedEntityLabelKey: null,
|
||||
};
|
||||
const vm = buildFileBrowserVM({ viewMode: "grid", rows: [row], filter, canWrite: true, canDelete: true, nextCursor: "cur2" });
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.viewMode).toBe("grid");
|
||||
expect(vm.data.rows[0]?.thumbnailUrl).toBe("/acme/media/f1/thumb");
|
||||
expect(vm.data.nextCursor).toBe("cur2");
|
||||
// Proxy/intern, nie rohe S3-URL.
|
||||
expect(vm.data.rows[0]?.thumbnailUrl?.startsWith("http")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFileDetailVM", () => {
|
||||
const base: BuildFileDetailVMInput = {
|
||||
id: "f1",
|
||||
displayName: "vertrag.pdf",
|
||||
mimeType: "application/pdf",
|
||||
sizeBytes: 9999,
|
||||
createdAtIso: "2026-06-19T08:00:00.000Z",
|
||||
tags: [],
|
||||
notes: null,
|
||||
source: "manual_upload",
|
||||
previewStatus: "ready",
|
||||
scanStatus: "clean",
|
||||
previewUrl: "/acme/media/f1/preview",
|
||||
downloadUrl: "/acme/media/f1/original",
|
||||
assignedToUserId: null,
|
||||
linkedEntities: [],
|
||||
canWrite: true,
|
||||
canDelete: false,
|
||||
canAdmin: false,
|
||||
};
|
||||
|
||||
it("withholds the preview URL when the file is infected (fail-closed)", () => {
|
||||
const vm = buildFileDetailVM({ ...base, scanStatus: "infected", previewUrl: "/acme/media/f1/preview" });
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.previewUrl).toBeNull();
|
||||
expect(vm.data.scanNoticeKey).toMatch(I18N_KEY);
|
||||
});
|
||||
|
||||
it("offers regenerate only for writers on a failed preview", () => {
|
||||
expect(buildFileDetailVM({ ...base, previewStatus: "failed" }).status).toBe("ready");
|
||||
const failedWriter = buildFileDetailVM({ ...base, previewStatus: "failed", canWrite: true });
|
||||
const failedReader = buildFileDetailVM({ ...base, previewStatus: "failed", canWrite: false });
|
||||
if (failedWriter.status !== "ready" || failedReader.status !== "ready") throw new Error("expected ready");
|
||||
expect(failedWriter.data.canRegeneratePreview).toBe(true);
|
||||
expect(failedReader.data.canRegeneratePreview).toBe(false);
|
||||
expect(failedWriter.data.previewFallbackKey).toMatch(I18N_KEY);
|
||||
});
|
||||
|
||||
it("emits a not-supported fallback key", () => {
|
||||
const vm = buildFileDetailVM({ ...base, previewStatus: "not_supported", previewUrl: null });
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.previewFallbackKey).toBe("files.detail.preview.notSupported");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFileUploadVM", () => {
|
||||
it("exposes the chunk/size/parallel defaults and i18n keys", () => {
|
||||
const vm = buildFileUploadVM({ canWrite: true });
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.chunkSizeBytes).toBe(FILE_UPLOAD_CHUNK_SIZE_BYTES);
|
||||
expect(vm.data.maxFileSizeBytes).toBe(FILE_UPLOAD_MAX_FILE_SIZE_BYTES);
|
||||
expect(vm.data.maxParallelUploads).toBe(3);
|
||||
expect(vm.data.heicNoticeKey).toMatch(I18N_KEY);
|
||||
expect(vm.data.dropzoneHeadlineKey).toMatch(I18N_KEY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { assertI18nLabel, empty, ready, type ActionCallbacks, type SidebarRegistryItem, type VMState } from "./index.js";
|
||||
|
||||
describe("@toolctrl/view-models", () => {
|
||||
test("exports serializable VM state helpers", () => {
|
||||
expect(ready({ id: "1" })).toEqual({ status: "ready", data: { id: "1" } } satisfies VMState<{ id: string }>);
|
||||
expect(empty()).toEqual({ status: "empty" });
|
||||
});
|
||||
|
||||
test("keeps action callbacks generic and UI-free", async () => {
|
||||
const calls: string[] = [];
|
||||
const callbacks: ActionCallbacks<{ id: string }> = {
|
||||
onUpdate: async (item) => {
|
||||
calls.push(item.id);
|
||||
},
|
||||
};
|
||||
await callbacks.onUpdate?.({ id: "item_1" });
|
||||
expect(calls).toEqual(["item_1"]);
|
||||
});
|
||||
|
||||
test("requires i18n-key labels for shell registries", () => {
|
||||
const item: SidebarRegistryItem = { id: "home", href: "/", labelKey: "shell.nav.home", permission: "resource.view" };
|
||||
expect(assertI18nLabel(item)).toBe(item);
|
||||
expect(() => assertI18nLabel({ labelKey: "Home" })).toThrow(/i18n keys/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
export type VMStatus = "idle" | "loading" | "ready" | "empty" | "error";
|
||||
|
||||
export type VMState<T> =
|
||||
| { status: "idle"; data?: undefined; error?: undefined }
|
||||
| { status: "loading"; data?: T; error?: undefined }
|
||||
| { status: "ready"; data: T; error?: undefined }
|
||||
| { status: "empty"; data?: undefined; error?: undefined }
|
||||
| { status: "error"; data?: T; error: { messageKey: string; code?: string } };
|
||||
|
||||
export type ActionCallbacks<T> = {
|
||||
readonly onCreate?: (draft?: Partial<T>) => void | Promise<void>;
|
||||
readonly onUpdate?: (item: T) => void | Promise<void>;
|
||||
readonly onDelete?: (item: T) => void | Promise<void>;
|
||||
readonly onRefresh?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type I18nLabel = {
|
||||
readonly labelKey: string;
|
||||
readonly descriptionKey?: string;
|
||||
};
|
||||
|
||||
export type RegistryGate = {
|
||||
readonly permission?: string;
|
||||
readonly flag?: string;
|
||||
};
|
||||
|
||||
export type SidebarRegistryItem = I18nLabel &
|
||||
RegistryGate & {
|
||||
readonly id: string;
|
||||
readonly href: string;
|
||||
readonly icon?: string;
|
||||
readonly children?: readonly SidebarRegistryItem[];
|
||||
};
|
||||
|
||||
export type BreadcrumbRegistryItem = I18nLabel &
|
||||
RegistryGate & {
|
||||
readonly id: string;
|
||||
readonly href?: string;
|
||||
};
|
||||
|
||||
export type MobileRegistryItem = I18nLabel &
|
||||
RegistryGate & {
|
||||
readonly id: string;
|
||||
readonly href: string;
|
||||
readonly priority?: number;
|
||||
};
|
||||
|
||||
export type PortalRegistryItem = I18nLabel &
|
||||
RegistryGate & {
|
||||
readonly id: string;
|
||||
readonly slot: string;
|
||||
readonly order?: number;
|
||||
};
|
||||
|
||||
export type ShellRegistries = {
|
||||
readonly sidebar?: readonly SidebarRegistryItem[];
|
||||
readonly breadcrumbs?: readonly BreadcrumbRegistryItem[];
|
||||
readonly mobile?: readonly MobileRegistryItem[];
|
||||
readonly portals?: readonly PortalRegistryItem[];
|
||||
};
|
||||
|
||||
// --- Subframe-Adapter contract (M015) ---
|
||||
/** Swappable UI implementation: renders a serializable component model to HTML. */
|
||||
export type UiRenderer<Model> = (model: Model) => string;
|
||||
|
||||
export type DataTableColumn = I18nLabel & { readonly key: string; readonly align?: "left" | "right" | "center" };
|
||||
export type DataTableRow = { readonly id: string; readonly cells: Readonly<Record<string, string>> };
|
||||
/** Domain View-Model a loader builds for a tabular list. */
|
||||
export type DataTableVM = {
|
||||
readonly caption?: I18nLabel;
|
||||
readonly columns: readonly DataTableColumn[];
|
||||
readonly rows: readonly DataTableRow[];
|
||||
readonly emptyLabelKey?: string;
|
||||
};
|
||||
/** Normalized component prop-model the UI implementation renders. */
|
||||
export type DataTableModel = {
|
||||
readonly captionKey?: string;
|
||||
readonly columns: readonly { readonly key: string; readonly labelKey: string; readonly align: "left" | "right" | "center" }[];
|
||||
readonly rows: readonly DataTableRow[];
|
||||
readonly emptyLabelKey: string;
|
||||
readonly rowCount: number;
|
||||
};
|
||||
|
||||
export function ready<T>(data: T): VMState<T> {
|
||||
return { status: "ready", data };
|
||||
}
|
||||
|
||||
export function empty<T>(): VMState<T> {
|
||||
return { status: "empty" };
|
||||
}
|
||||
|
||||
export function assertI18nLabel(label: I18nLabel): I18nLabel {
|
||||
if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(label.labelKey)) {
|
||||
throw new Error(`Registry labels must use i18n keys: ${label.labelKey}`);
|
||||
}
|
||||
if (label.descriptionKey && !/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(label.descriptionKey)) {
|
||||
throw new Error(`Registry descriptions must use i18n keys: ${label.descriptionKey}`);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
// Domain View-Models (M013-Konvention: src/<domain>/*.vm.ts)
|
||||
export * from "./walking-skeleton/walking-skeleton.vm.js";
|
||||
export * from "./auth/session-list.vm.js";
|
||||
export * from "./auth/select-tenant.vm.js";
|
||||
export * from "./files/file-browser.vm.js";
|
||||
export * from "./files/file-detail.vm.js";
|
||||
export * from "./files/file-upload.vm.js";
|
||||
export * from "./tasks/task-list.vm.js";
|
||||
export * from "./tasks/task-detail.vm.js";
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { VMState } from "../index.js";
|
||||
import {
|
||||
taskEntityLabelKey,
|
||||
taskPriorityKey,
|
||||
taskSourceKey,
|
||||
taskStatusKey,
|
||||
type TaskAssignee,
|
||||
type TaskLinkedEntity,
|
||||
type TaskPriority,
|
||||
type TaskSource,
|
||||
type TaskStatus,
|
||||
} from "./task-list.vm.js";
|
||||
|
||||
/**
|
||||
* Task detail view-model (AK-P4b §6). Pure function: the loader builds it once
|
||||
* server-side. Carries the display fields plus the edit-form picker state
|
||||
* (assignee person↔group, entity link, status/priority). Markdown description is
|
||||
* passed raw; the UI renders it. All static texts are i18n keys; resolved names
|
||||
* are data.
|
||||
*/
|
||||
export interface TaskPickerOption {
|
||||
readonly id: string;
|
||||
/** Resolved display label (data). */
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface TaskWatcherView {
|
||||
readonly userId: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface TaskDetailData {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
/** Raw markdown description; UI renders it (null when unset). */
|
||||
readonly description: string | null;
|
||||
readonly status: TaskStatus;
|
||||
readonly statusKey: string;
|
||||
readonly priority: TaskPriority;
|
||||
readonly priorityKey: string;
|
||||
readonly deadlineIso: string | null;
|
||||
readonly isOverdue: boolean;
|
||||
readonly assignee: TaskAssignee | null;
|
||||
readonly linkedEntity: TaskLinkedEntity | null;
|
||||
readonly source: TaskSource;
|
||||
readonly sourceKey: string;
|
||||
readonly sourceHref: string | null;
|
||||
readonly watchers: readonly TaskWatcherView[];
|
||||
readonly createdAtIso: string;
|
||||
readonly completedAtIso: string | null;
|
||||
// Edit-form picker option lists (assignee person↔group). Empty when the user
|
||||
// may not write.
|
||||
readonly assignableUsers: readonly TaskPickerOption[];
|
||||
readonly assignableGroups: readonly TaskPickerOption[];
|
||||
readonly canWrite: boolean;
|
||||
readonly canDelete: boolean;
|
||||
/** Back link to the task list (breadcrumb / cancel target). */
|
||||
readonly listHref: string;
|
||||
}
|
||||
|
||||
export type TaskDetailVM = VMState<TaskDetailData>;
|
||||
|
||||
export interface TaskDetailSource {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly description: string | null;
|
||||
readonly status: TaskStatus;
|
||||
readonly priority: TaskPriority;
|
||||
readonly deadlineIso: string | null;
|
||||
readonly isOverdue: boolean;
|
||||
readonly assignee: TaskAssignee | null;
|
||||
readonly linkedEntity: TaskLinkedEntity | null;
|
||||
readonly source: TaskSource;
|
||||
readonly sourceHref: string | null;
|
||||
readonly watchers: readonly TaskWatcherView[];
|
||||
readonly createdAtIso: string;
|
||||
readonly completedAtIso: string | null;
|
||||
}
|
||||
|
||||
export interface BuildTaskDetailVMInput {
|
||||
readonly task: TaskDetailSource;
|
||||
readonly assignableUsers?: readonly TaskPickerOption[];
|
||||
readonly assignableGroups?: readonly TaskPickerOption[];
|
||||
readonly canWrite: boolean;
|
||||
readonly canDelete: boolean;
|
||||
readonly listHref: string;
|
||||
}
|
||||
|
||||
export function buildTaskDetailVM(input: BuildTaskDetailVMInput): TaskDetailVM {
|
||||
const { task } = input;
|
||||
return {
|
||||
status: "ready",
|
||||
data: {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: task.status,
|
||||
statusKey: taskStatusKey(task.status),
|
||||
priority: task.priority,
|
||||
priorityKey: taskPriorityKey(task.priority),
|
||||
deadlineIso: task.deadlineIso,
|
||||
isOverdue: task.isOverdue,
|
||||
assignee: task.assignee,
|
||||
linkedEntity: task.linkedEntity ? { ...task.linkedEntity, labelKey: taskEntityLabelKey(task.linkedEntity.entityType) } : null,
|
||||
source: task.source,
|
||||
sourceKey: taskSourceKey(task.source),
|
||||
sourceHref: task.sourceHref,
|
||||
watchers: task.watchers,
|
||||
createdAtIso: task.createdAtIso,
|
||||
completedAtIso: task.completedAtIso,
|
||||
assignableUsers: input.canWrite ? (input.assignableUsers ?? []) : [],
|
||||
assignableGroups: input.canWrite ? (input.assignableGroups ?? []) : [],
|
||||
canWrite: input.canWrite,
|
||||
canDelete: input.canDelete,
|
||||
listHref: input.listHref,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { VMState } from "../index.js";
|
||||
|
||||
/**
|
||||
* Task list view-model (AK-P4b §6). Pure function: the loader builds it once
|
||||
* server-side, the UI only renders. No React, no Prisma, no app imports
|
||||
* (DoD A.3). All static texts are i18n keys; resolved names (assignee, entity
|
||||
* label) are data passed in by the loader.
|
||||
*
|
||||
* This VM backs the REUSABLE task list (DoD 8): the standalone `/tasks` browser
|
||||
* and the embedded "Tasks tab" in every entity detail view (P12/P14/P16) render
|
||||
* the same component from this shape. `detailHref`/`createHref`/`sourceHref` are
|
||||
* always internal routes built by the loader, so the VM stays routing-agnostic.
|
||||
*/
|
||||
export type TaskStatus = "open" | "in_progress" | "blocked" | "done" | "cancelled";
|
||||
export type TaskPriority = "low" | "normal" | "high" | "urgent";
|
||||
export type TaskSource = "manual" | "chat" | "form_submission" | "automation" | "api";
|
||||
export type TaskListPreset = "mine" | "created" | "all";
|
||||
|
||||
export interface TaskAssignee {
|
||||
readonly kind: "user" | "group";
|
||||
/** Resolved display name (data), built by the loader. */
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface TaskLinkedEntity {
|
||||
readonly entityType: string;
|
||||
readonly entityId: string;
|
||||
/** i18n key for the entity-type label (e.g. `tasks.entity.contact`). */
|
||||
readonly labelKey: string;
|
||||
}
|
||||
|
||||
export interface TaskListRow {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly status: TaskStatus;
|
||||
/** i18n key for the status label (e.g. `tasks.status.open`). */
|
||||
readonly statusKey: string;
|
||||
readonly priority: TaskPriority;
|
||||
/** i18n key for the priority label (e.g. `tasks.priority.high`). */
|
||||
readonly priorityKey: string;
|
||||
readonly deadlineIso: string | null;
|
||||
/** Loader-computed: deadline is in the past and the task is not terminal. */
|
||||
readonly isOverdue: boolean;
|
||||
readonly assignee: TaskAssignee | null;
|
||||
readonly linkedEntity: TaskLinkedEntity | null;
|
||||
readonly source: TaskSource;
|
||||
/** i18n key for the source marker (e.g. `tasks.source.chat`). */
|
||||
readonly sourceKey: string;
|
||||
/** Internal link back to the task's origin (chat/form/automation) or null. */
|
||||
readonly sourceHref: string | null;
|
||||
/** Internal detail route (e.g. `/acme/tasks/<id>`). */
|
||||
readonly detailHref: string;
|
||||
}
|
||||
|
||||
export interface TaskListFilterState {
|
||||
readonly preset: TaskListPreset;
|
||||
readonly status: TaskStatus | null;
|
||||
readonly priority: TaskPriority | null;
|
||||
readonly assignedToUserId: string | null;
|
||||
readonly includeArchived: boolean;
|
||||
}
|
||||
|
||||
/** Optional embedding context: when set, the list is the "Tasks tab" of an
|
||||
* entity and the create CTA pre-fills the link to that entity. */
|
||||
export interface TaskListEmbedContext {
|
||||
readonly linkedEntityType: string;
|
||||
readonly linkedEntityId: string;
|
||||
}
|
||||
|
||||
export interface TaskListData {
|
||||
readonly rows: readonly TaskListRow[];
|
||||
readonly filter: TaskListFilterState;
|
||||
readonly canWrite: boolean;
|
||||
readonly canDelete: boolean;
|
||||
/** Internal route for the "new task" CTA (pre-filled when embedded). */
|
||||
readonly createHref: string;
|
||||
readonly embed: TaskListEmbedContext | null;
|
||||
readonly nextCursor: string | null;
|
||||
readonly emptyHeadlineKey: string;
|
||||
readonly emptyBodyKey: string;
|
||||
}
|
||||
|
||||
export type TaskListVM = VMState<TaskListData>;
|
||||
|
||||
/**
|
||||
* Normalized prop-model the UI implementation renders (M015 Subframe-adapter
|
||||
* contract). The ui-app adapter maps {@link TaskListData} -> this model once,
|
||||
* then the ui-subframe / ui-mock renderers stay dumb (hot-swap drill, M017).
|
||||
*/
|
||||
export interface TaskListModelRow {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly statusKey: string;
|
||||
readonly priorityKey: string;
|
||||
readonly deadlineIso: string | null;
|
||||
readonly isOverdue: boolean;
|
||||
readonly assigneeLabel: string | null;
|
||||
readonly detailHref: string;
|
||||
}
|
||||
|
||||
export interface TaskListModel {
|
||||
readonly titleKey: string;
|
||||
readonly emptyHeadlineKey: string;
|
||||
readonly emptyBodyKey: string;
|
||||
readonly canWrite: boolean;
|
||||
readonly createHref: string;
|
||||
readonly rows: readonly TaskListModelRow[];
|
||||
readonly rowCount: number;
|
||||
}
|
||||
|
||||
export interface BuildTaskListVMInput {
|
||||
readonly rows: readonly TaskListRow[];
|
||||
readonly filter: TaskListFilterState;
|
||||
readonly canWrite: boolean;
|
||||
readonly canDelete: boolean;
|
||||
readonly createHref: string;
|
||||
readonly embed?: TaskListEmbedContext | null;
|
||||
readonly nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export function buildTaskListVM(input: BuildTaskListVMInput): TaskListVM {
|
||||
// Empty stays a `ready` state with a plain-text empty state (DoD 23) so the
|
||||
// filter bar + "new task" CTA remain visible.
|
||||
return {
|
||||
status: "ready",
|
||||
data: {
|
||||
rows: input.rows,
|
||||
filter: input.filter,
|
||||
canWrite: input.canWrite,
|
||||
canDelete: input.canDelete,
|
||||
createHref: input.createHref,
|
||||
embed: input.embed ?? null,
|
||||
nextCursor: input.rows.length === 0 ? null : (input.nextCursor ?? null),
|
||||
emptyHeadlineKey: "tasks.list.empty.headline",
|
||||
emptyBodyKey: "tasks.list.empty.body",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Map the domain data to the normalized hot-swap model (used by the ui-app adapter). */
|
||||
export function toTaskListModel(data: TaskListData): TaskListModel {
|
||||
return {
|
||||
titleKey: "tasks.list.title",
|
||||
emptyHeadlineKey: data.emptyHeadlineKey,
|
||||
emptyBodyKey: data.emptyBodyKey,
|
||||
canWrite: data.canWrite,
|
||||
createHref: data.createHref,
|
||||
rows: data.rows.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
statusKey: row.statusKey,
|
||||
priorityKey: row.priorityKey,
|
||||
deadlineIso: row.deadlineIso,
|
||||
isOverdue: row.isOverdue,
|
||||
assigneeLabel: row.assignee?.label ?? null,
|
||||
detailHref: row.detailHref,
|
||||
})),
|
||||
rowCount: data.rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Pure helpers reused by loaders to keep i18n keys consistent ----------
|
||||
|
||||
export function taskStatusKey(status: TaskStatus): string {
|
||||
return `tasks.status.${status}`;
|
||||
}
|
||||
|
||||
export function taskPriorityKey(priority: TaskPriority): string {
|
||||
return `tasks.priority.${priority}`;
|
||||
}
|
||||
|
||||
export function taskSourceKey(source: TaskSource): string {
|
||||
return `tasks.source.${source}`;
|
||||
}
|
||||
|
||||
export function taskEntityLabelKey(entityType: string): string {
|
||||
return `tasks.entity.${entityType}`;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildTaskDetailVM,
|
||||
buildTaskListVM,
|
||||
taskEntityLabelKey,
|
||||
taskPriorityKey,
|
||||
taskSourceKey,
|
||||
taskStatusKey,
|
||||
toTaskListModel,
|
||||
type BuildTaskDetailVMInput,
|
||||
type TaskListFilterState,
|
||||
type TaskListRow,
|
||||
} from "../index.js";
|
||||
|
||||
const I18N_KEY = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
|
||||
|
||||
const filter: TaskListFilterState = {
|
||||
preset: "all",
|
||||
status: null,
|
||||
priority: null,
|
||||
assignedToUserId: null,
|
||||
includeArchived: false,
|
||||
};
|
||||
|
||||
const row: TaskListRow = {
|
||||
id: "t1",
|
||||
title: "Angebot nachfassen",
|
||||
status: "open",
|
||||
statusKey: taskStatusKey("open"),
|
||||
priority: "high",
|
||||
priorityKey: taskPriorityKey("high"),
|
||||
deadlineIso: "2026-06-25T08:00:00.000Z",
|
||||
isOverdue: false,
|
||||
assignee: { kind: "user", label: "Akeshan" },
|
||||
linkedEntity: { entityType: "deal", entityId: "d9", labelKey: taskEntityLabelKey("deal") },
|
||||
source: "manual",
|
||||
sourceKey: taskSourceKey("manual"),
|
||||
sourceHref: null,
|
||||
detailHref: "/acme/tasks/t1",
|
||||
};
|
||||
|
||||
describe("buildTaskListVM", () => {
|
||||
it("returns a ready empty-state with i18n keys and no cursor when there are no rows", () => {
|
||||
const vm = buildTaskListVM({ rows: [], filter, canWrite: true, canDelete: false, createHref: "/acme/tasks/new", nextCursor: "x" });
|
||||
expect(vm.status).toBe("ready");
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.rows).toHaveLength(0);
|
||||
expect(vm.data.nextCursor).toBeNull();
|
||||
expect(vm.data.emptyHeadlineKey).toMatch(I18N_KEY);
|
||||
expect(vm.data.emptyBodyKey).toMatch(I18N_KEY);
|
||||
expect(vm.data.createHref).toBe("/acme/tasks/new");
|
||||
});
|
||||
|
||||
it("passes rows + embed context + cursor through; hrefs stay internal", () => {
|
||||
const vm = buildTaskListVM({
|
||||
rows: [row],
|
||||
filter: { ...filter, preset: "mine" },
|
||||
canWrite: true,
|
||||
canDelete: true,
|
||||
createHref: "/acme/tasks/new?deal=d9",
|
||||
embed: { linkedEntityType: "deal", linkedEntityId: "d9" },
|
||||
nextCursor: "cur2",
|
||||
});
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.rows[0]?.detailHref.startsWith("http")).toBe(false);
|
||||
expect(vm.data.embed?.linkedEntityId).toBe("d9");
|
||||
expect(vm.data.nextCursor).toBe("cur2");
|
||||
expect(vm.data.filter.preset).toBe("mine");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toTaskListModel", () => {
|
||||
it("flattens rows to the hot-swap model (assignee label, status key)", () => {
|
||||
const vm = buildTaskListVM({ rows: [row], filter, canWrite: false, canDelete: false, createHref: "/acme/tasks/new" });
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
const model = toTaskListModel(vm.data);
|
||||
expect(model.rowCount).toBe(1);
|
||||
expect(model.titleKey).toMatch(I18N_KEY);
|
||||
expect(model.rows[0]?.assigneeLabel).toBe("Akeshan");
|
||||
expect(model.rows[0]?.statusKey).toBe("tasks.status.open");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTaskDetailVM", () => {
|
||||
const base: BuildTaskDetailVMInput = {
|
||||
task: {
|
||||
id: "t1",
|
||||
title: "Angebot nachfassen",
|
||||
description: "**Markdown** Beschreibung",
|
||||
status: "open",
|
||||
priority: "normal",
|
||||
deadlineIso: null,
|
||||
isOverdue: false,
|
||||
assignee: { kind: "group", label: "Vertrieb" },
|
||||
linkedEntity: { entityType: "contact", entityId: "c1", labelKey: "" },
|
||||
source: "chat",
|
||||
sourceHref: "/acme/inbox/msg-7",
|
||||
watchers: [{ userId: "u2", label: "Raffael" }],
|
||||
createdAtIso: "2026-06-20T08:00:00.000Z",
|
||||
completedAtIso: null,
|
||||
},
|
||||
assignableUsers: [{ id: "u1", label: "Akeshan" }],
|
||||
assignableGroups: [{ id: "g1", label: "Vertrieb" }],
|
||||
canWrite: true,
|
||||
canDelete: false,
|
||||
listHref: "/acme/tasks",
|
||||
};
|
||||
|
||||
it("derives i18n keys for status/priority/source/entity and keeps the source link", () => {
|
||||
const vm = buildTaskDetailVM(base);
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.statusKey).toBe("tasks.status.open");
|
||||
expect(vm.data.priorityKey).toBe("tasks.priority.normal");
|
||||
expect(vm.data.sourceKey).toBe("tasks.source.chat");
|
||||
expect(vm.data.linkedEntity?.labelKey).toBe("tasks.entity.contact");
|
||||
expect(vm.data.sourceHref).toBe("/acme/inbox/msg-7");
|
||||
expect(vm.data.watchers[0]?.label).toBe("Raffael");
|
||||
});
|
||||
|
||||
it("hides picker option lists from non-writers", () => {
|
||||
const vm = buildTaskDetailVM({ ...base, canWrite: false });
|
||||
if (vm.status !== "ready") throw new Error("expected ready");
|
||||
expect(vm.data.assignableUsers).toHaveLength(0);
|
||||
expect(vm.data.assignableGroups).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWalkingSkeletonVM } from "./walking-skeleton.vm.js";
|
||||
|
||||
describe("buildWalkingSkeletonVM", () => {
|
||||
it("builds a ready VM with active content when the module is active", () => {
|
||||
const vm = buildWalkingSkeletonVM({ tenantId: "t1", moduleKey: "walking-skeleton", connectorState: "active" });
|
||||
|
||||
expect(vm.status).toBe("ready");
|
||||
expect(vm.data).toMatchObject({
|
||||
tenantId: "t1",
|
||||
moduleKey: "walking-skeleton",
|
||||
connectorState: "active",
|
||||
headlineKey: "walkingSkeleton.active.headline",
|
||||
bodyKey: "walkingSkeleton.active.body",
|
||||
});
|
||||
expect(vm.data?.ctaKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds a ready VM with locked content + upsell CTA when the module is locked", () => {
|
||||
const vm = buildWalkingSkeletonVM({ tenantId: "t1", moduleKey: "walking-skeleton", connectorState: "locked" });
|
||||
|
||||
expect(vm.status).toBe("ready");
|
||||
expect(vm.data?.connectorState).toBe("locked");
|
||||
expect(vm.data?.headlineKey).toBe("walkingSkeleton.locked.headline");
|
||||
expect(vm.data?.ctaKey).toBe("walkingSkeleton.locked.cta");
|
||||
});
|
||||
|
||||
it("builds an empty VM when the module is hidden", () => {
|
||||
const vm = buildWalkingSkeletonVM({ tenantId: "t1", moduleKey: "walking-skeleton", connectorState: "hidden" });
|
||||
|
||||
expect(vm.status).toBe("empty");
|
||||
expect(vm.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses only i18n keys, never raw display strings", () => {
|
||||
const vm = buildWalkingSkeletonVM({ tenantId: "t1", moduleKey: "walking-skeleton", connectorState: "active" });
|
||||
const keyPattern = /^[a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+)+$/;
|
||||
|
||||
expect(vm.data?.headlineKey).toMatch(keyPattern);
|
||||
expect(vm.data?.bodyKey).toMatch(keyPattern);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { VMState } from "../index.js";
|
||||
|
||||
/**
|
||||
* Walking-Skeleton View-Model (R-P1c) — die *.vm.ts-Konvention exemplarisch.
|
||||
*
|
||||
* Reine Funktion: der Loader baut sie einmal serverseitig, die UI rendert nur. Kein React,
|
||||
* kein Prisma, keine App-Imports (DoD A.3). Der Konnektor-Zustand (aus `tenantHasModule()` über
|
||||
* `resolveConnectorNavState`) bestimmt, welcher VM-Status/Inhalt entsteht.
|
||||
*/
|
||||
export type WalkingSkeletonConnectorState = "active" | "locked" | "hidden";
|
||||
|
||||
export interface WalkingSkeletonData {
|
||||
readonly tenantId: string;
|
||||
readonly moduleKey: string;
|
||||
readonly connectorState: WalkingSkeletonConnectorState;
|
||||
/** i18n-Key der Überschrift (kein Klartext — DoD i18n). */
|
||||
readonly headlineKey: string;
|
||||
/** i18n-Key des Fließtexts. */
|
||||
readonly bodyKey: string;
|
||||
/** i18n-Key des Upsell-CTA (nur im gesperrten Zustand). */
|
||||
readonly ctaKey?: string;
|
||||
}
|
||||
|
||||
export type WalkingSkeletonVM = VMState<WalkingSkeletonData>;
|
||||
|
||||
export interface BuildWalkingSkeletonVMInput {
|
||||
readonly tenantId: string;
|
||||
readonly moduleKey: string;
|
||||
readonly connectorState: WalkingSkeletonConnectorState;
|
||||
}
|
||||
|
||||
export function buildWalkingSkeletonVM(input: BuildWalkingSkeletonVMInput): WalkingSkeletonVM {
|
||||
if (input.connectorState === "active") {
|
||||
return {
|
||||
status: "ready",
|
||||
data: {
|
||||
tenantId: input.tenantId,
|
||||
moduleKey: input.moduleKey,
|
||||
connectorState: "active",
|
||||
headlineKey: "walkingSkeleton.active.headline",
|
||||
bodyKey: "walkingSkeleton.active.body",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (input.connectorState === "locked") {
|
||||
return {
|
||||
status: "ready",
|
||||
data: {
|
||||
tenantId: input.tenantId,
|
||||
moduleKey: input.moduleKey,
|
||||
connectorState: "locked",
|
||||
headlineKey: "walkingSkeleton.locked.headline",
|
||||
bodyKey: "walkingSkeleton.locked.body",
|
||||
ctaKey: "walkingSkeleton.locked.cta",
|
||||
},
|
||||
};
|
||||
}
|
||||
// hidden — die Route sollte für diesen Tenant gar nicht erreichbar sein; defensiv leerer Zustand.
|
||||
return { status: "empty" };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user