feat: harden shared platform contracts
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user