feat: add UI conformance and launch context
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user