feat: harden shared platform contracts
This commit is contained in:
@@ -10,6 +10,8 @@ const stackSource = readFileSync("src/components/dialogStack.ts", "utf8");
|
||||
assert(source.includes("ref={panelRef}"), "Dialog wires its panel ref to the focus boundary");
|
||||
assert(source.includes("tabIndex={-1}"), "Dialog exposes a programmatically focusable fallback");
|
||||
assert(source.includes("return registerDialog({"), "Dialog registers with the shared modal stack");
|
||||
assert(source.includes("createPortal(dialog, document.body)"), "Dialog supports opt-in body portals without replacing the shared stack");
|
||||
assert(source.includes("panelStyle"), "Dialog accepts bounded custom panel positioning");
|
||||
assert(!source.includes('window.addEventListener("keydown"'), "Dialog instances do not install competing keyboard listeners");
|
||||
assert(stackSource.includes('window.addEventListener("keydown", handleWindowKeyDown)'), "the modal stack owns one keyboard listener");
|
||||
assert(stackSource.includes("handleTopDialogKeyDown(event, currentActiveElement())"), "the keyboard listener delegates only to the topmost dialog");
|
||||
|
||||
376
webui/src/api/concurrency.ts
Normal file
376
webui/src/api/concurrency.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { isApiError } from "./client";
|
||||
|
||||
export type RevisionConflictDetail = {
|
||||
code: "revision_conflict";
|
||||
resource: {
|
||||
type: string;
|
||||
id: string;
|
||||
};
|
||||
current_revision: number;
|
||||
submitted_base_revision: number;
|
||||
retryable: boolean;
|
||||
refresh_path?: string;
|
||||
current_etag?: string;
|
||||
};
|
||||
|
||||
export type MergeConflict = {
|
||||
path: string;
|
||||
kind: string;
|
||||
baseValue: unknown;
|
||||
localValue: unknown;
|
||||
currentValue: unknown;
|
||||
};
|
||||
|
||||
export type ThreeWayMergeResult<T = unknown> = {
|
||||
value: T;
|
||||
conflicts: MergeConflict[];
|
||||
appliedPaths: string[];
|
||||
};
|
||||
|
||||
export type ConflictChoice = "current" | "local";
|
||||
|
||||
type MissingValue = { readonly missing: true };
|
||||
const MISSING: MissingValue = Object.freeze({ missing: true });
|
||||
|
||||
export function revisionConflictFromError(error: unknown): RevisionConflictDetail | null {
|
||||
if (!isApiError(error, 409, 412)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(error.body) as { detail?: unknown };
|
||||
const detail = asRecord(parsed.detail);
|
||||
const resource = asRecord(detail.resource);
|
||||
if (
|
||||
detail.code !== "revision_conflict"
|
||||
|| typeof resource.type !== "string"
|
||||
|| typeof resource.id !== "string"
|
||||
|| !Number.isInteger(detail.current_revision)
|
||||
|| !Number.isInteger(detail.submitted_base_revision)
|
||||
) return null;
|
||||
return {
|
||||
code: "revision_conflict",
|
||||
resource: {
|
||||
type: resource.type,
|
||||
id: resource.id
|
||||
},
|
||||
current_revision: Number(detail.current_revision),
|
||||
submitted_base_revision: Number(detail.submitted_base_revision),
|
||||
retryable: detail.retryable !== false,
|
||||
refresh_path: typeof detail.refresh_path === "string" ? detail.refresh_path : undefined,
|
||||
current_etag: typeof detail.current_etag === "string" ? detail.current_etag : undefined
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function threeWayMerge<T>(
|
||||
base: T,
|
||||
local: T,
|
||||
current: T,
|
||||
options: {
|
||||
protectedPaths?: readonly string[];
|
||||
stableIdFields?: readonly string[];
|
||||
} = {}
|
||||
): ThreeWayMergeResult<T> {
|
||||
return mergeValue(
|
||||
cloneValue(base),
|
||||
cloneValue(local),
|
||||
cloneValue(current),
|
||||
"",
|
||||
options.protectedPaths ?? [],
|
||||
options.stableIdFields ?? ["id"]
|
||||
) as ThreeWayMergeResult<T>;
|
||||
}
|
||||
|
||||
export function applyConflictChoices<T>(
|
||||
merge: ThreeWayMergeResult<T>,
|
||||
choices: Readonly<Record<string, ConflictChoice>>
|
||||
): T {
|
||||
let result: unknown = cloneValue(merge.value);
|
||||
for (const conflict of merge.conflicts) {
|
||||
if ((choices[conflict.path] ?? "current") !== "local") continue;
|
||||
result = writePath(result, conflict.path, cloneValue(conflict.localValue));
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function mergeValue(
|
||||
base: unknown,
|
||||
local: unknown,
|
||||
current: unknown,
|
||||
path: string,
|
||||
protectedPaths: readonly string[],
|
||||
stableIdFields: readonly string[]
|
||||
): ThreeWayMergeResult {
|
||||
const localChanged = !deepEqual(local, base);
|
||||
const currentChanged = !deepEqual(current, base);
|
||||
if (!localChanged) return result(current);
|
||||
if (deepEqual(local, current)) return result(current);
|
||||
if (isProtected(path, protectedPaths)) {
|
||||
return conflictResult(path, "protected_path", base, local, current);
|
||||
}
|
||||
if (!currentChanged) return result(local, [], [path || "/"]);
|
||||
if (isRecord(base) && isRecord(local) && isRecord(current)) {
|
||||
return mergeRecord(base, local, current, path, protectedPaths, stableIdFields);
|
||||
}
|
||||
if (Array.isArray(base) && Array.isArray(local) && Array.isArray(current)) {
|
||||
return mergeList(base, local, current, path, protectedPaths, stableIdFields);
|
||||
}
|
||||
return conflictResult(path, "same_path_changed", base, local, current);
|
||||
}
|
||||
|
||||
function mergeRecord(
|
||||
base: Record<string, unknown>,
|
||||
local: Record<string, unknown>,
|
||||
current: Record<string, unknown>,
|
||||
path: string,
|
||||
protectedPaths: readonly string[],
|
||||
stableIdFields: readonly string[]
|
||||
): ThreeWayMergeResult<Record<string, unknown>> {
|
||||
const value: Record<string, unknown> = {};
|
||||
const conflicts: MergeConflict[] = [];
|
||||
const appliedPaths: string[] = [];
|
||||
const keys = [...new Set([...Object.keys(current), ...Object.keys(local), ...Object.keys(base)])];
|
||||
for (const key of keys) {
|
||||
const childPath = joinPath(path, key);
|
||||
const merged = mergePresence(
|
||||
hasOwn(base, key) ? base[key] : MISSING,
|
||||
hasOwn(local, key) ? local[key] : MISSING,
|
||||
hasOwn(current, key) ? current[key] : MISSING,
|
||||
childPath,
|
||||
protectedPaths,
|
||||
stableIdFields
|
||||
);
|
||||
conflicts.push(...merged.conflicts);
|
||||
appliedPaths.push(...merged.appliedPaths);
|
||||
if (!isMissing(merged.value)) value[key] = merged.value;
|
||||
}
|
||||
return result(value, conflicts, appliedPaths);
|
||||
}
|
||||
|
||||
function mergePresence(
|
||||
base: unknown | MissingValue,
|
||||
local: unknown | MissingValue,
|
||||
current: unknown | MissingValue,
|
||||
path: string,
|
||||
protectedPaths: readonly string[],
|
||||
stableIdFields: readonly string[]
|
||||
): ThreeWayMergeResult {
|
||||
if (isMissing(local) && isMissing(current)) return result(MISSING);
|
||||
if (isMissing(base)) {
|
||||
if (isMissing(local)) return result(current);
|
||||
if (isMissing(current)) {
|
||||
if (isProtected(path, protectedPaths)) {
|
||||
return conflictResult(path, "protected_path", undefined, local, undefined);
|
||||
}
|
||||
return result(local, [], [path]);
|
||||
}
|
||||
if (deepEqual(local, current)) return result(current);
|
||||
return conflictResult(path, "concurrent_add", undefined, local, current);
|
||||
}
|
||||
if (isMissing(local)) {
|
||||
if (deepEqual(current, base)) {
|
||||
if (isProtected(path, protectedPaths)) {
|
||||
return conflictResult(path, "protected_path", base, undefined, current);
|
||||
}
|
||||
return result(MISSING, [], [path]);
|
||||
}
|
||||
return conflictResult(path, "delete_vs_edit", base, undefined, current);
|
||||
}
|
||||
if (isMissing(current)) {
|
||||
if (deepEqual(local, base)) return result(MISSING);
|
||||
return conflictResult(path, "edit_vs_delete", base, local, undefined);
|
||||
}
|
||||
return mergeValue(base, local, current, path, protectedPaths, stableIdFields);
|
||||
}
|
||||
|
||||
function mergeList(
|
||||
base: unknown[],
|
||||
local: unknown[],
|
||||
current: unknown[],
|
||||
path: string,
|
||||
protectedPaths: readonly string[],
|
||||
stableIdFields: readonly string[]
|
||||
): ThreeWayMergeResult<unknown[]> {
|
||||
const identityField = stableIdentityField([base, local, current], stableIdFields);
|
||||
if (!identityField) {
|
||||
return conflictResult(path, "unkeyed_collection", base, local, current);
|
||||
}
|
||||
const byId = (items: unknown[]) => new Map(
|
||||
items.map((item) => [String((item as Record<string, unknown>)[identityField]), item])
|
||||
);
|
||||
const baseById = byId(base);
|
||||
const localById = byId(local);
|
||||
const currentById = byId(current);
|
||||
const baseOrder = [...baseById.keys()];
|
||||
const localOrder = [...localById.keys()];
|
||||
const currentOrder = [...currentById.keys()];
|
||||
const localReordered = !deepEqual(commonOrder(localOrder, baseOrder), commonOrder(baseOrder, localOrder));
|
||||
const currentReordered = !deepEqual(commonOrder(currentOrder, baseOrder), commonOrder(baseOrder, currentOrder));
|
||||
if (localReordered && currentReordered && !deepEqual(localOrder, currentOrder)) {
|
||||
return conflictResult(path, "collection_reorder", baseOrder, localOrder, currentOrder);
|
||||
}
|
||||
|
||||
const mergedById = new Map<string, unknown>();
|
||||
const conflicts: MergeConflict[] = [];
|
||||
const appliedPaths: string[] = [];
|
||||
const identities = [...new Set([...currentOrder, ...localOrder, ...baseOrder])];
|
||||
for (const identity of identities) {
|
||||
const merged = mergePresence(
|
||||
baseById.has(identity) ? baseById.get(identity) : MISSING,
|
||||
localById.has(identity) ? localById.get(identity) : MISSING,
|
||||
currentById.has(identity) ? currentById.get(identity) : MISSING,
|
||||
joinPath(path, `${identityField}=${identity}`),
|
||||
protectedPaths,
|
||||
stableIdFields
|
||||
);
|
||||
conflicts.push(...merged.conflicts);
|
||||
appliedPaths.push(...merged.appliedPaths);
|
||||
if (!isMissing(merged.value)) mergedById.set(identity, merged.value);
|
||||
}
|
||||
|
||||
const orderSource = localReordered && !currentReordered ? localOrder : currentOrder;
|
||||
const mergedOrder = orderSource.filter((identity) => mergedById.has(identity));
|
||||
for (const identity of identities) {
|
||||
if (mergedById.has(identity) && !mergedOrder.includes(identity)) mergedOrder.push(identity);
|
||||
}
|
||||
return result(
|
||||
mergedOrder.map((identity) => mergedById.get(identity)),
|
||||
conflicts,
|
||||
appliedPaths
|
||||
);
|
||||
}
|
||||
|
||||
function writePath(root: unknown, path: string, value: unknown): unknown {
|
||||
if (!path || path === "/") return value;
|
||||
const segments = path.slice(1).split("/").map(decodePointerSegment);
|
||||
const cloned = cloneValue(root);
|
||||
let target: unknown = cloned;
|
||||
for (let index = 0; index < segments.length - 1; index += 1) {
|
||||
target = descend(target, segments[index]);
|
||||
}
|
||||
const finalSegment = segments[segments.length - 1];
|
||||
if (Array.isArray(target)) {
|
||||
const itemIndex = arraySegmentIndex(target, finalSegment);
|
||||
if (itemIndex < 0) return cloned;
|
||||
if (value === undefined) target.splice(itemIndex, 1);
|
||||
else target[itemIndex] = value;
|
||||
} else if (isRecord(target)) {
|
||||
if (value === undefined) delete target[finalSegment];
|
||||
else target[finalSegment] = value;
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function descend(target: unknown, segment: string): unknown {
|
||||
if (Array.isArray(target)) {
|
||||
const index = arraySegmentIndex(target, segment);
|
||||
return index >= 0 ? target[index] : undefined;
|
||||
}
|
||||
return isRecord(target) ? target[segment] : undefined;
|
||||
}
|
||||
|
||||
function arraySegmentIndex(items: unknown[], segment: string): number {
|
||||
const separator = segment.indexOf("=");
|
||||
if (separator > 0) {
|
||||
const key = segment.slice(0, separator);
|
||||
const value = segment.slice(separator + 1);
|
||||
return items.findIndex((item) => isRecord(item) && String(item[key]) === value);
|
||||
}
|
||||
const index = Number(segment);
|
||||
return Number.isInteger(index) ? index : -1;
|
||||
}
|
||||
|
||||
function stableIdentityField(values: unknown[][], candidates: readonly string[]): string | null {
|
||||
const allItems = values.flat();
|
||||
if (allItems.length === 0 || !allItems.every(isRecord)) return null;
|
||||
for (const candidate of candidates) {
|
||||
const valid = values.every((items) => {
|
||||
const identities = items.map((item) => String((item as Record<string, unknown>)[candidate] ?? "").trim());
|
||||
return identities.every(Boolean) && new Set(identities).size === identities.length;
|
||||
});
|
||||
if (valid) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function commonOrder(left: string[], right: string[]): string[] {
|
||||
const rightSet = new Set(right);
|
||||
return left.filter((item) => rightSet.has(item));
|
||||
}
|
||||
|
||||
function isProtected(path: string, protectedPaths: readonly string[]): boolean {
|
||||
const normalized = path || "/";
|
||||
return protectedPaths.some((rawPrefix) => {
|
||||
const prefix = rawPrefix.replace(/\/+$/, "") || "/";
|
||||
return normalized === prefix || normalized.startsWith(`${prefix}/`);
|
||||
});
|
||||
}
|
||||
|
||||
function conflictResult<T>(
|
||||
path: string,
|
||||
kind: string,
|
||||
baseValue: unknown,
|
||||
localValue: unknown,
|
||||
currentValue: T
|
||||
): ThreeWayMergeResult<T> {
|
||||
return result(currentValue, [{
|
||||
path: path || "/",
|
||||
kind,
|
||||
baseValue,
|
||||
localValue,
|
||||
currentValue
|
||||
}]);
|
||||
}
|
||||
|
||||
function result<T>(
|
||||
value: T,
|
||||
conflicts: MergeConflict[] = [],
|
||||
appliedPaths: string[] = []
|
||||
): ThreeWayMergeResult<T> {
|
||||
return { value, conflicts, appliedPaths };
|
||||
}
|
||||
|
||||
function joinPath(parent: string, segment: string): string {
|
||||
const escaped = segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
||||
return parent ? `${parent}/${escaped}` : `/${escaped}`;
|
||||
}
|
||||
|
||||
function decodePointerSegment(segment: string): string {
|
||||
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
||||
}
|
||||
|
||||
function isMissing(value: unknown): value is MissingValue {
|
||||
return value === MISSING;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function cloneValue<T>(value: T): T {
|
||||
if (value === undefined || value === null || typeof value !== "object") return value;
|
||||
if (typeof structuredClone === "function") return structuredClone(value);
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function deepEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true;
|
||||
if (Array.isArray(left) && Array.isArray(right)) {
|
||||
return left.length === right.length && left.every((item, index) => deepEqual(item, right[index]));
|
||||
}
|
||||
if (isRecord(left) && isRecord(right)) {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
return leftKeys.length === rightKeys.length
|
||||
&& leftKeys.every((key) => hasOwn(right, key) && deepEqual(left[key], right[key]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasOwn(value: object, key: PropertyKey): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
203
webui/src/components/ConcurrencyConflictDialog.tsx
Normal file
203
webui/src/components/ConcurrencyConflictDialog.tsx
Normal file
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./types";
|
||||
|
||||
export * from "./api/client";
|
||||
export * from "./api/concurrency";
|
||||
export * from "./api/auth";
|
||||
export * from "./api/platform";
|
||||
export * from "./api/notificationSummary";
|
||||
@@ -55,6 +56,15 @@ export { default as Button } from "./components/Button";
|
||||
export type { ButtonProps } from "./components/Button";
|
||||
export { default as Card } from "./components/Card";
|
||||
export { default as ConfirmDialog } from "./components/ConfirmDialog";
|
||||
export {
|
||||
default as ConcurrencyConflictDialog,
|
||||
ConcurrencyConflictProvider,
|
||||
useConcurrencyConflictResolver
|
||||
} from "./components/ConcurrencyConflictDialog";
|
||||
export type {
|
||||
ConcurrencyResolution,
|
||||
ConcurrencyResolutionRequest
|
||||
} from "./components/ConcurrencyConflictDialog";
|
||||
export { default as ConnectionTree } from "./components/ConnectionTree";
|
||||
export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree";
|
||||
export { default as ColorPickerField } from "./components/ColorPickerField";
|
||||
|
||||
@@ -132,6 +132,14 @@
|
||||
.step-intro h2 { margin: 0; color: var(--text-strong); }
|
||||
.step-intro p { margin-top: 6px; color: var(--muted); }
|
||||
.button-row { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; }
|
||||
.concurrency-conflict-dialog { width: min(860px, calc(100vw - 32px)); }
|
||||
.concurrency-conflict-list { display: grid; gap: 12px; max-height: min(58vh, 620px); overflow: auto; }
|
||||
.concurrency-conflict-item { border: var(--border-line); border-radius: var(--radius-sm); padding: 12px; }
|
||||
.concurrency-conflict-heading { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; margin-bottom: 10px; }
|
||||
.concurrency-conflict-heading span { color: var(--muted); font-size: 12px; text-transform: capitalize; }
|
||||
.concurrency-conflict-values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
|
||||
.concurrency-conflict-values strong { display: block; margin-bottom: 4px; font-size: 12px; }
|
||||
.concurrency-conflict-values pre { min-height: 54px; max-height: 160px; margin: 0; padding: 8px; overflow: auto; background: var(--panel-soft); border: var(--border-line); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.alert { padding: 14px 16px; border-radius: var(--radius-sm); margin-bottom: 16px; }
|
||||
.alert.success { background: var(--success-bg); color: var(--success-text); }
|
||||
.alert.info { background: var(--info-bg); color: var(--info-text); }
|
||||
@@ -149,6 +157,7 @@
|
||||
.section-sidebar { display: none; }
|
||||
.wizard-card { grid-template-columns: 1fr; }
|
||||
.metric-grid, .dashboard-grid, .settings-grid { grid-template-columns: 1fr; }
|
||||
.concurrency-conflict-values { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user