fix(web): review round 1: optimistic edits, friendly errors, preferences, Gelasio
- Tags and custom fields update the cached entry optimistically and build from the latest cache; one entry's PATCHes run in order (mutation scope). - The review textarea follows server changes (Restore) unless edited. - friendlyError() maps validation reasons and 413 to plain copy everywhere. - Rating scale from me/preferences; /login and /register use auth/status; Attachments lists from the API. - Gelasio vendored (official OFL variable TTFs); minors from the review. - Docs: DESIGN (AA on orange, faint labels), DECISIONS, TODO, ARCHITECTURE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d204775235
commit
5fa59310f3
+33
-5
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type ChangeEvent, type EntryPatch } from "./api.ts";
|
||||
import { api, type ChangeEvent, type EntryPatch, type EntryView } from "./api.ts";
|
||||
import { applyEntryPatch } from "./edits.ts";
|
||||
|
||||
/** Pending Changes-inbox events (first 200; the badge shows "200+" beyond that). */
|
||||
export function usePendingChanges() {
|
||||
@@ -31,16 +32,43 @@ export function useDebounced<T>(value: T, ms = 220): T {
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Shared save path for every detail edit: PATCH, then refresh the entry and the list. */
|
||||
/**
|
||||
* Shared save path for every detail edit. The cached entry is updated optimistically (rolled back on
|
||||
* error) and PATCHes for one entry run one at a time (mutation scope), in the order they were made.
|
||||
* Use `edit(cur => patch)` when the next value depends on the current one (tags, custom fields): it
|
||||
* builds from the latest cache, so quick successive edits compose.
|
||||
*/
|
||||
export function useEntryPatch(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
const key = ["entry", id];
|
||||
const mutationKey = ["entry-patch", id];
|
||||
const m = useMutation({
|
||||
mutationKey,
|
||||
scope: { id: `entry-patch:${id}` },
|
||||
mutationFn: (patch: EntryPatch) => api.patchEntry(id, patch),
|
||||
onMutate: async (patch) => {
|
||||
await qc.cancelQueries({ queryKey: key });
|
||||
const prev = qc.getQueryData<EntryView>(key);
|
||||
if (prev) qc.setQueryData<EntryView>(key, applyEntryPatch<EntryView>(prev, patch));
|
||||
return { prev };
|
||||
},
|
||||
onError: (_e, _patch, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
|
||||
void qc.invalidateQueries({ queryKey: key });
|
||||
},
|
||||
onSuccess: (entry) => {
|
||||
qc.setQueryData(["entry", id], entry);
|
||||
// Only the last queued edit writes the server's answer, so earlier answers don't flash old values.
|
||||
if (qc.isMutating({ mutationKey }) <= 1) qc.setQueryData(key, entry);
|
||||
},
|
||||
onSettled: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
void qc.invalidateQueries({ queryKey: ["history", id] });
|
||||
},
|
||||
});
|
||||
const edit = (build: (cur: EntryView) => EntryPatch | null) => {
|
||||
const cur = qc.getQueryData<EntryView>(key);
|
||||
const patch = cur ? build(cur) : null;
|
||||
if (patch) m.mutate(patch);
|
||||
};
|
||||
return { ...m, edit };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user