feat: add temporal context and contextual help
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import {
|
||||
DEFAULT_TEMPORAL_DATA_SELECTION,
|
||||
dispatchTemporalContextChanged,
|
||||
normalizeTemporalDataSelection,
|
||||
setActiveTemporalDataSelection,
|
||||
temporalDataSelectionIsDefault,
|
||||
temporalDataSelectionKey,
|
||||
type TemporalDataSelection
|
||||
} from "./temporal";
|
||||
|
||||
|
||||
const TEMPORAL_STORAGE_PREFIX = "govoplan.temporal-data";
|
||||
|
||||
type TemporalContextValue = {
|
||||
selection: TemporalDataSelection;
|
||||
applySelection: (selection: TemporalDataSelection) => void;
|
||||
isDefault: boolean;
|
||||
selectionKey: string;
|
||||
};
|
||||
|
||||
const TemporalContext = createContext<TemporalContextValue>({
|
||||
selection: DEFAULT_TEMPORAL_DATA_SELECTION,
|
||||
applySelection: () => undefined,
|
||||
isDefault: true,
|
||||
selectionKey: temporalDataSelectionKey(DEFAULT_TEMPORAL_DATA_SELECTION)
|
||||
});
|
||||
|
||||
export function PlatformTemporalProvider({
|
||||
scopeKey,
|
||||
children
|
||||
}: {
|
||||
scopeKey: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [selection, setSelection] = useState<TemporalDataSelection>(() =>
|
||||
loadSelection(scopeKey)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadSelection(scopeKey);
|
||||
setActiveTemporalDataSelection(stored);
|
||||
setSelection(stored);
|
||||
dispatchTemporalContextChanged(stored, scopeKey);
|
||||
return () => {
|
||||
setActiveTemporalDataSelection(DEFAULT_TEMPORAL_DATA_SELECTION);
|
||||
};
|
||||
}, [scopeKey]);
|
||||
|
||||
const applySelection = useCallback((next: TemporalDataSelection) => {
|
||||
const normalized = normalizeTemporalDataSelection(next);
|
||||
setActiveTemporalDataSelection(normalized);
|
||||
storeSelection(scopeKey, normalized);
|
||||
setSelection(normalized);
|
||||
dispatchTemporalContextChanged(normalized, scopeKey);
|
||||
}, [scopeKey]);
|
||||
|
||||
const value = useMemo<TemporalContextValue>(() => ({
|
||||
selection,
|
||||
applySelection,
|
||||
isDefault: temporalDataSelectionIsDefault(selection),
|
||||
selectionKey: temporalDataSelectionKey(selection)
|
||||
}), [applySelection, selection]);
|
||||
|
||||
return (
|
||||
<TemporalContext.Provider value={value}>
|
||||
{children}
|
||||
</TemporalContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTemporalDataContext(): TemporalContextValue {
|
||||
return useContext(TemporalContext);
|
||||
}
|
||||
|
||||
function storageKey(scopeKey: string): string {
|
||||
return `${TEMPORAL_STORAGE_PREFIX}.${scopeKey}`;
|
||||
}
|
||||
|
||||
function loadSelection(scopeKey: string): TemporalDataSelection {
|
||||
if (typeof sessionStorage === "undefined") {
|
||||
return DEFAULT_TEMPORAL_DATA_SELECTION;
|
||||
}
|
||||
const stored = sessionStorage.getItem(storageKey(scopeKey));
|
||||
if (!stored) return DEFAULT_TEMPORAL_DATA_SELECTION;
|
||||
try {
|
||||
return normalizeTemporalDataSelection(JSON.parse(stored));
|
||||
} catch {
|
||||
sessionStorage.removeItem(storageKey(scopeKey));
|
||||
return DEFAULT_TEMPORAL_DATA_SELECTION;
|
||||
}
|
||||
}
|
||||
|
||||
function storeSelection(
|
||||
scopeKey: string,
|
||||
selection: TemporalDataSelection
|
||||
): void {
|
||||
if (typeof sessionStorage === "undefined") return;
|
||||
if (temporalDataSelectionIsDefault(selection)) {
|
||||
sessionStorage.removeItem(storageKey(scopeKey));
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(storageKey(scopeKey), JSON.stringify(selection));
|
||||
}
|
||||
@@ -194,6 +194,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
routes: routesWithServerMetadata(module, info),
|
||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||
viewSurfaces: mergeViewSurfaces(module, info),
|
||||
helpContexts: info.help_contexts ?? module.helpContexts,
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
...runtimeUiCapabilitiesForModule(module, info)
|
||||
@@ -218,6 +219,7 @@ function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
|
||||
dependencies: [],
|
||||
optional_dependencies: [],
|
||||
enabled: true,
|
||||
help_contexts: info.help_contexts,
|
||||
runtime_ui_capabilities: [],
|
||||
nav: [],
|
||||
frontend: {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
export type TemporalValidityMode = "current" | "at" | "all";
|
||||
|
||||
export type TemporalDataSelection = {
|
||||
validityMode: TemporalValidityMode;
|
||||
validAt: string | null;
|
||||
recordedAt: string | null;
|
||||
};
|
||||
|
||||
export type TemporalContextChangedEventDetail = {
|
||||
selection: TemporalDataSelection;
|
||||
scopeKey: string;
|
||||
};
|
||||
|
||||
export const PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT =
|
||||
"govoplan:temporal-context-changed";
|
||||
|
||||
export const DEFAULT_TEMPORAL_DATA_SELECTION: TemporalDataSelection = {
|
||||
validityMode: "current",
|
||||
validAt: null,
|
||||
recordedAt: null
|
||||
};
|
||||
|
||||
let activeSelection = DEFAULT_TEMPORAL_DATA_SELECTION;
|
||||
|
||||
export function normalizeTemporalDataSelection(
|
||||
value: Partial<TemporalDataSelection> | null | undefined
|
||||
): TemporalDataSelection {
|
||||
const validityMode = ["current", "at", "all"].includes(
|
||||
String(value?.validityMode)
|
||||
)
|
||||
? value?.validityMode as TemporalValidityMode
|
||||
: "current";
|
||||
const validAt = validityMode === "at"
|
||||
? normalizeTimestamp(value?.validAt)
|
||||
: null;
|
||||
if (validityMode === "at" && !validAt) {
|
||||
throw new Error("i18n:govoplan-core.select_valid_date_time");
|
||||
}
|
||||
return {
|
||||
validityMode,
|
||||
validAt,
|
||||
recordedAt: normalizeTimestamp(value?.recordedAt)
|
||||
};
|
||||
}
|
||||
|
||||
export function temporalDataSelectionIsDefault(
|
||||
value: TemporalDataSelection
|
||||
): boolean {
|
||||
return value.validityMode === "current" && !value.recordedAt;
|
||||
}
|
||||
|
||||
export function temporalDataSelectionKey(value: TemporalDataSelection): string {
|
||||
return [value.validityMode, value.validAt ?? "", value.recordedAt ?? ""].join(":");
|
||||
}
|
||||
|
||||
export function setActiveTemporalDataSelection(
|
||||
value: TemporalDataSelection
|
||||
): void {
|
||||
activeSelection = normalizeTemporalDataSelection(value);
|
||||
}
|
||||
|
||||
export function activeTemporalDataSelection(): TemporalDataSelection {
|
||||
return activeSelection;
|
||||
}
|
||||
|
||||
export function temporalRequestHeaders(): Record<string, string> {
|
||||
const selection = activeTemporalDataSelection();
|
||||
if (temporalDataSelectionIsDefault(selection)) return {};
|
||||
const headers: Record<string, string> = {
|
||||
"X-Govoplan-Validity-Mode": selection.validityMode
|
||||
};
|
||||
if (selection.validAt) headers["X-Govoplan-Valid-At"] = selection.validAt;
|
||||
if (selection.recordedAt) {
|
||||
headers["X-Govoplan-Recorded-At"] = selection.recordedAt;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function dispatchTemporalContextChanged(
|
||||
selection: TemporalDataSelection,
|
||||
scopeKey: string
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<TemporalContextChangedEventDetail>(
|
||||
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
|
||||
{ detail: { selection, scopeKey } }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: string | null | undefined): string | null {
|
||||
const clean = String(value || "").trim();
|
||||
if (!clean) return null;
|
||||
const instant = new Date(clean);
|
||||
if (!Number.isFinite(instant.getTime())) return null;
|
||||
return instant.toISOString();
|
||||
}
|
||||
Reference in New Issue
Block a user