feat: add UI conformance and launch context

This commit is contained in:
2026-08-18 21:32:25 +02:00
parent 7685a103e8
commit 8a925782ab
35 changed files with 1125 additions and 150 deletions
@@ -0,0 +1,55 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode
} from "react";
import type { ActiveObjectReference } from "../types";
type ActiveObjectContextValue = {
activeObject: ActiveObjectReference | null;
register: (reference: ActiveObjectReference) => () => void;
};
const fallbackContext: ActiveObjectContextValue = {
activeObject: null,
register: () => () => undefined
};
const ActiveObjectContext = createContext<ActiveObjectContextValue>(fallbackContext);
export function PlatformActiveObjectProvider({ children }: { children: ReactNode }) {
const nextId = useRef(1);
const [registrations, setRegistrations] = useState<Array<{
id: number;
reference: ActiveObjectReference;
}>>([]);
const activeObject = registrations[registrations.length - 1]?.reference ?? null;
const register = useCallback((reference: ActiveObjectReference) => {
const id = nextId.current;
nextId.current += 1;
setRegistrations((current) => [...current, { id, reference }]);
return () => setRegistrations((current) => current.filter((item) => item.id !== id));
}, []);
const value = useMemo(() => ({ activeObject, register }), [activeObject, register]);
return (
<ActiveObjectContext.Provider value={value}>
{children}
</ActiveObjectContext.Provider>
);
}
export function useActiveObject(): ActiveObjectReference | null {
return useContext(ActiveObjectContext).activeObject;
}
export function useRegisterActiveObject(reference: ActiveObjectReference | null): void {
const { register } = useContext(ActiveObjectContext);
useEffect(() => reference ? register(reference) : undefined, [reference, register]);
}
+126
View File
@@ -0,0 +1,126 @@
import type {
ActiveObjectReference,
AuthInfo,
EffectiveViewProjection,
QuickAccessLaunchContext,
QuickAccessResult
} from "../types";
import type { TemporalDataSelection } from "./temporal";
export const QUICK_ACCESS_LAUNCH_CONTEXT_VERSION = "1" as const;
export const QUICK_ACCESS_LAUNCH_STATE_KEY = "govoplanQuickAccessLaunch";
export const QUICK_ACCESS_RESULT_EVENT = "govoplan:quick-access-result";
export type QuickAccessLaunchContextInput = {
pathname: string;
search?: string;
hash?: string;
historyIndex?: number | null;
auth: AuthInfo;
activeObject?: ActiveObjectReference | null;
temporalContext: TemporalDataSelection;
viewContext?: EffectiveViewProjection | null;
};
export function createQuickAccessLaunchContext({
pathname,
search = "",
hash = "",
historyIndex = null,
auth,
activeObject = null,
temporalContext,
viewContext = null
}: QuickAccessLaunchContextInput): QuickAccessLaunchContext {
const tenant = auth.active_tenant ?? auth.tenant;
const principal = auth.principal;
return {
contractVersion: QUICK_ACCESS_LAUNCH_CONTEXT_VERSION,
origin: {
pathname: normalizePathname(pathname),
search: normalizeSearch(search),
hash: normalizeHash(hash),
historyIndex: Number.isInteger(historyIndex) ? historyIndex ?? null : null
},
tenantId: tenant.id,
accountId: auth.user.account_id,
activeObject: activeObject?.tenantId === tenant.id ? activeObject : null,
actingContext: principal?.acting_assignment_id || principal?.acting_for_account_id
? {
assignmentId: principal.acting_assignment_id ?? null,
actingForAccountId: principal.acting_for_account_id ?? null
}
: null,
temporalContext: {
validityMode: temporalContext.validityMode,
validAt: temporalContext.validAt ?? null,
recordedAt: temporalContext.recordedAt ?? null
},
viewContext: viewContext?.activeViewId
? {
viewId: viewContext.activeViewId,
revisionId: viewContext.activeRevisionId,
name: viewContext.activeViewName
}
: null
};
}
export function quickAccessLaunchState(
context: QuickAccessLaunchContext
): Record<string, QuickAccessLaunchContext> {
return { [QUICK_ACCESS_LAUNCH_STATE_KEY]: context };
}
export function quickAccessLaunchContextFromState(
value: unknown
): QuickAccessLaunchContext | null {
if (!isRecord(value)) return null;
const candidate = value[QUICK_ACCESS_LAUNCH_STATE_KEY];
if (!isRecord(candidate) || candidate.contractVersion !== QUICK_ACCESS_LAUNCH_CONTEXT_VERSION) {
return null;
}
if (!isRecord(candidate.origin) || typeof candidate.origin.pathname !== "string") {
return null;
}
if (
typeof candidate.tenantId !== "string"
|| typeof candidate.accountId !== "string"
|| !candidate.origin.pathname.startsWith("/")
) {
return null;
}
return candidate as QuickAccessLaunchContext;
}
export function quickAccessReturnPath(context: QuickAccessLaunchContext): string {
return `${context.origin.pathname}${context.origin.search}${context.origin.hash}`;
}
export function dispatchQuickAccessResult(
toolId: string,
launchContext: QuickAccessLaunchContext,
result: QuickAccessResult
): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(QUICK_ACCESS_RESULT_EVENT, {
detail: { toolId, launchContext, result }
}));
}
function normalizePathname(value: string): string {
return value.startsWith("/") ? value : "/";
}
function normalizeSearch(value: string): string {
return !value ? "" : value.startsWith("?") ? value : "";
}
function normalizeHash(value: string): string {
return !value ? "" : value.startsWith("#") ? value : "";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}