feat: harden shared platform contracts

This commit is contained in:
2026-07-30 14:26:36 +02:00
parent 6970bf7457
commit 9b88ae388b
24 changed files with 2034 additions and 57 deletions
@@ -0,0 +1,203 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactNode
} from "react";
import {
applyConflictChoices,
type ConflictChoice,
type ThreeWayMergeResult
} from "../api/concurrency";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import Button from "./Button";
import Dialog from "./Dialog";
import SegmentedControl from "./SegmentedControl";
export type ConcurrencyResolution<T = unknown> =
| { action: "apply"; value: T; resolvedPaths: string[] }
| { action: "reload" }
| { action: "cancel" };
export type ConcurrencyResolutionRequest<T = unknown> = {
resourceLabel: string;
merge: ThreeWayMergeResult<T>;
};
type Resolver = <T>(
request: ConcurrencyResolutionRequest<T>
) => Promise<ConcurrencyResolution<T>>;
type PendingResolution = {
request: ConcurrencyResolutionRequest;
resolve: (resolution: ConcurrencyResolution) => void;
};
const ConcurrencyResolverContext = createContext<Resolver | null>(null);
export function ConcurrencyConflictProvider({ children }: { children: ReactNode }) {
const [pending, setPending] = useState<PendingResolution | null>(null);
const pendingRef = useRef<PendingResolution | null>(null);
const requestResolution = useCallback<Resolver>((request) => {
pendingRef.current?.resolve({ action: "cancel" });
return new Promise((resolve) => {
const next: PendingResolution = {
request: request as ConcurrencyResolutionRequest,
resolve: resolve as (resolution: ConcurrencyResolution) => void
};
pendingRef.current = next;
setPending(next);
});
}, []);
const finish = useCallback((resolution: ConcurrencyResolution) => {
const active = pendingRef.current;
pendingRef.current = null;
setPending(null);
active?.resolve(resolution);
}, []);
return (
<ConcurrencyResolverContext.Provider value={requestResolution}>
{children}
<ConcurrencyConflictDialog
request={pending?.request ?? null}
onResolve={finish}
/>
</ConcurrencyResolverContext.Provider>
);
}
export function useConcurrencyConflictResolver(): Resolver {
const resolver = useContext(ConcurrencyResolverContext);
if (!resolver) {
throw new Error(
"useConcurrencyConflictResolver must be used inside ConcurrencyConflictProvider"
);
}
return resolver;
}
function ConcurrencyConflictDialog({
request,
onResolve
}: {
request: ConcurrencyResolutionRequest | null;
onResolve: (resolution: ConcurrencyResolution) => void;
}) {
const { translateText } = usePlatformLanguage();
const [choices, setChoices] = useState<Record<string, ConflictChoice>>({});
const requestKey = request
? request.merge.conflicts.map((conflict) => `${conflict.path}:${conflict.kind}`).join("|")
: "";
const effectiveChoices = useMemo(
() => Object.fromEntries(
(request?.merge.conflicts ?? []).map((conflict) => [
conflict.path,
choices[conflict.path] ?? "current"
])
),
[choices, request, requestKey]
);
function applyResolution() {
if (!request) return;
onResolve({
action: "apply",
value: applyConflictChoices(request.merge, effectiveChoices),
resolvedPaths: request.merge.conflicts.map((conflict) => conflict.path)
});
setChoices({});
}
function finish(action: "reload" | "cancel") {
onResolve({ action });
setChoices({});
}
return (
<Dialog
open={Boolean(request)}
title="Concurrent changes"
role="alertdialog"
className="concurrency-conflict-dialog"
footerClassName="button-row compact-actions"
onClose={() => finish("cancel")}
footer={
<>
<Button type="button" onClick={() => finish("cancel")}>
{translateText("i18n:govoplan-core.cancel.77dfd213")}
</Button>
<Button type="button" onClick={() => finish("reload")}>
{translateText("i18n:govoplan-core.reload.cce71553")}
</Button>
<Button type="button" variant="primary" onClick={applyResolution}>
Apply resolution
</Button>
</>
}
>
<p>
{request?.resourceLabel ?? "This item"} changed after you loaded it.
Review each overlapping change before saving.
</p>
<div className="concurrency-conflict-list">
{(request?.merge.conflicts ?? []).map((conflict) => (
<section className="concurrency-conflict-item" key={`${requestKey}:${conflict.path}`}>
<div className="concurrency-conflict-heading">
<code>{conflict.path}</code>
<span>{conflictKindLabel(conflict.kind)}</span>
</div>
<div className="concurrency-conflict-values">
<ConflictValue label="Current" value={conflict.currentValue} />
<ConflictValue label="Your change" value={conflict.localValue} />
</div>
<SegmentedControl
value={effectiveChoices[conflict.path] ?? "current"}
options={[
{ id: "current", label: "Keep current" },
{ id: "local", label: "Use my change" }
]}
onChange={(value) => setChoices((current) => ({
...current,
[conflict.path]: value as ConflictChoice
}))}
/>
</section>
))}
</div>
</Dialog>
);
}
function ConflictValue({ label, value }: { label: string; value: unknown }) {
return (
<div>
<strong>{label}</strong>
<pre>{previewValue(value)}</pre>
</div>
);
}
function previewValue(value: unknown): string {
if (value === undefined) return "(removed)";
if (typeof value === "string") return value.slice(0, 500);
try {
const serialized = JSON.stringify(value, null, 2);
return serialized.length > 1000
? `${serialized.slice(0, 1000)}\n...`
: serialized;
} catch {
return String(value);
}
}
function conflictKindLabel(kind: string): string {
return kind.replace(/_/g, " ");
}
export default ConcurrencyConflictDialog;
+16 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
import { useEffect, useId, useRef, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
import { shouldCloseDialogOnBackdrop } from "./dialogInteractions";
import {
@@ -26,6 +27,9 @@ export type DialogProps = {
titleClassName?: string;
bodyClassName?: string;
footerClassName?: string;
portal?: boolean;
panelStyle?: CSSProperties;
backdropStyle?: CSSProperties;
};
function joinClasses(...classes: Array<string | undefined | false>) {
@@ -49,7 +53,10 @@ export default function Dialog({
headerClassName = "",
titleClassName = "",
bodyClassName = "",
footerClassName = ""
footerClassName = "",
portal = false,
panelStyle,
backdropStyle
}: DialogProps) {
const titleId = useId();
const canClose = Boolean(onClose) && !closeDisabled;
@@ -107,9 +114,10 @@ export default function Dialog({
if (!open) return null;
return (
const dialog = (
<div
className={joinClasses("dialog-backdrop", backdropClassName)}
style={backdropStyle}
role="presentation"
onMouseDown={(event) => {
if (
@@ -122,6 +130,7 @@ export default function Dialog({
ref={panelRef}
tabIndex={-1}
className={joinClasses("dialog-panel", className)}
style={panelStyle}
role={role}
aria-modal="true"
data-dialog-stack-state="topmost"
@@ -147,4 +156,8 @@ export default function Dialog({
</section>
</div>
);
return portal && typeof document !== "undefined"
? createPortal(dialog, document.body)
: dialog;
}
+20 -15
View File
@@ -1,4 +1,4 @@
import type { HTMLAttributes, ReactNode } from "react";
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
export type PageScrollViewportProps = Omit<
HTMLAttributes<HTMLDivElement>,
@@ -7,17 +7,22 @@ export type PageScrollViewportProps = Omit<
children: ReactNode;
};
export default function PageScrollViewport({
children,
className = "",
...props
}: PageScrollViewportProps) {
return (
<div
{...props}
className={["page-scroll-viewport", className].filter(Boolean).join(" ")}
>
{children}
</div>
);
}
const PageScrollViewport = forwardRef<HTMLDivElement, PageScrollViewportProps>(
function PageScrollViewport({
children,
className = "",
...props
}, ref) {
return (
<div
{...props}
ref={ref}
className={["page-scroll-viewport", className].filter(Boolean).join(" ")}
>
{children}
</div>
);
}
);
export default PageScrollViewport;