Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #63.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeConflict, describeCounts, describeUndo, totalChanges, type ChangeSet } from './history'
|
||||
|
||||
function changeSet(over: Partial<ChangeSet> = {}): ChangeSet {
|
||||
return {
|
||||
id: 1,
|
||||
gardenId: 1,
|
||||
actorId: 1,
|
||||
actorName: 'Steve',
|
||||
source: 'ui',
|
||||
summary: 'Did a thing',
|
||||
counts: [],
|
||||
createdAt: '2026-07-21T00:00:00Z',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('describeCounts', () => {
|
||||
it('pluralizes per entity and names the operation', () => {
|
||||
const cs = changeSet({
|
||||
counts: [
|
||||
{ entityType: 'planting', op: 'create', n: 3 },
|
||||
{ entityType: 'object', op: 'update', n: 1 },
|
||||
],
|
||||
})
|
||||
expect(describeCounts(cs)).toBe('3 plantings added, 1 bed changed')
|
||||
})
|
||||
|
||||
it('says nothing rather than "0 changes"', () => {
|
||||
expect(describeCounts(changeSet())).toBe('')
|
||||
expect(describeCounts(changeSet({ counts: [{ entityType: 'object', op: 'create', n: 0 }] }))).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeConflict', () => {
|
||||
it('names the entity when it has a name', () => {
|
||||
expect(describeConflict({ entityType: 'object', entityId: 4, reason: 'changed', name: 'North Bed' })).toBe(
|
||||
'“North Bed” was edited since, so it was left alone',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the entity kind when it has no name', () => {
|
||||
expect(describeConflict({ entityType: 'planting', entityId: 9, reason: 'missing' })).toBe(
|
||||
'that planting no longer exists',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeUndo', () => {
|
||||
const target = changeSet({ counts: [{ entityType: 'object', op: 'update', n: 3 }] })
|
||||
|
||||
it('is a plain success when nothing conflicted', () => {
|
||||
const out = describeUndo(target, {
|
||||
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 3 }] }),
|
||||
conflicts: [],
|
||||
})
|
||||
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
|
||||
})
|
||||
|
||||
// The server answers 200 with a null change set when every revision was
|
||||
// already a no-op — reachable by undoing a creation whose object is gone.
|
||||
// Claiming "Undone." there reports work that didn't happen.
|
||||
it("doesn't claim to have undone a no-op", () => {
|
||||
const out = describeUndo(target, { changeSet: null, conflicts: [] })
|
||||
expect(out.tone).toBe('ok')
|
||||
expect(out.message).toBe('Nothing left to undo — this was already reversed.')
|
||||
})
|
||||
|
||||
// The case the issue was specific about: a partial revert really did change
|
||||
// things, so reporting a bare failure would be a lie about what applied.
|
||||
it('says how much applied and what was skipped', () => {
|
||||
const out = describeUndo(target, {
|
||||
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 2 }] }),
|
||||
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
|
||||
})
|
||||
expect(out.tone).toBe('partial')
|
||||
expect(out.message).toBe('2 of 3 changes undone — “North Bed” was edited since, so it was left alone.')
|
||||
})
|
||||
|
||||
it('is an error when nothing applied at all', () => {
|
||||
const out = describeUndo(target, {
|
||||
changeSet: null,
|
||||
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
|
||||
})
|
||||
expect(out.tone).toBe('error')
|
||||
expect(out.message).toBe('Nothing was undone — “North Bed” was edited since, so it was left alone.')
|
||||
})
|
||||
|
||||
it('lists every conflict, not just the first', () => {
|
||||
const out = describeUndo(target, {
|
||||
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 1 }] }),
|
||||
conflicts: [
|
||||
{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' },
|
||||
{ entityType: 'planting', entityId: 8, reason: 'missing' },
|
||||
],
|
||||
})
|
||||
expect(out.message).toContain('“North Bed”')
|
||||
expect(out.message).toContain('that planting no longer exists')
|
||||
})
|
||||
})
|
||||
|
||||
describe('totalChanges', () => {
|
||||
it('sums every tally', () => {
|
||||
expect(
|
||||
totalChanges(
|
||||
changeSet({
|
||||
counts: [
|
||||
{ entityType: 'planting', op: 'create', n: 12 },
|
||||
{ entityType: 'object', op: 'update', n: 1 },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe(13)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
// Change history and undo (#49): zod-validated shapes for /gardens/:id/history
|
||||
// and /change-sets/:id/revert, plus the hooks the editor's History tab uses.
|
||||
//
|
||||
// A revert is a real write that can partly succeed, so its mutation is unusual:
|
||||
// a 409 is not a plain failure. It carries the change set that DID apply
|
||||
// alongside the entities it deliberately left alone, and the caller is expected
|
||||
// to report both.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { ApiError, api, errorMessage } from './api'
|
||||
import { gardenFullKey } from './objects'
|
||||
|
||||
export const changeSourceSchema = z.enum(['ui', 'agent', 'api'])
|
||||
export type ChangeSource = z.infer<typeof changeSourceSchema>
|
||||
|
||||
export const changeCountSchema = z.object({
|
||||
entityType: z.enum(['garden', 'object', 'planting']),
|
||||
op: z.enum(['create', 'update', 'delete']),
|
||||
// COUNT(*) can only ever be a non-negative integer; constraining it means bad
|
||||
// data fails loudly rather than quietly hiding an Undo button (totalChanges
|
||||
// gates it) or rendering "1.5 beds changed".
|
||||
n: z.number().int().nonnegative(),
|
||||
})
|
||||
export type ChangeCount = z.infer<typeof changeCountSchema>
|
||||
|
||||
export const changeSetSchema = z.object({
|
||||
id: z.number(),
|
||||
gardenId: z.number(),
|
||||
actorId: z.number(),
|
||||
actorName: z.string().default(''),
|
||||
source: changeSourceSchema,
|
||||
summary: z.string(),
|
||||
agentRunId: z.string().optional(),
|
||||
// Set when this change set reverts another.
|
||||
revertsId: z.number().optional(),
|
||||
// Set when another change set reverted this one.
|
||||
revertedById: z.number().optional(),
|
||||
counts: z.array(changeCountSchema).default([]),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
export type ChangeSet = z.infer<typeof changeSetSchema>
|
||||
|
||||
export const revertConflictSchema = z.object({
|
||||
entityType: z.enum(['garden', 'object', 'planting']),
|
||||
entityId: z.number(),
|
||||
reason: z.enum(['changed', 'missing', 'exists', 'unsupported']),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
export type RevertConflict = z.infer<typeof revertConflictSchema>
|
||||
|
||||
const historyPageSchema = z.object({
|
||||
changeSets: z.array(changeSetSchema),
|
||||
hasMore: z.boolean(),
|
||||
})
|
||||
|
||||
const revertResultSchema = z.object({
|
||||
changeSet: changeSetSchema.nullable(),
|
||||
conflicts: z.array(revertConflictSchema).default([]),
|
||||
})
|
||||
export type RevertResult = z.infer<typeof revertResultSchema>
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export function historyKey(gardenId: number) {
|
||||
return ['gardens', gardenId, 'history'] as const
|
||||
}
|
||||
|
||||
/** A garden's change sets, newest first, paged on demand. */
|
||||
export function useGardenHistory(gardenId: number, enabled = true) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: historyKey(gardenId),
|
||||
enabled,
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }) =>
|
||||
historyPageSchema.parse(
|
||||
await api.get(`/gardens/${gardenId}/history`, { params: { limit: PAGE_SIZE, offset: pageParam } }),
|
||||
),
|
||||
// Offset paging, not cursor: history is append-only at the FRONT, so a new
|
||||
// entry arriving mid-scroll shifts the window by one. That's a duplicate row
|
||||
// at worst, and the alternative (a cursor on created_at) buys little for a
|
||||
// list you scroll a page or two of.
|
||||
getNextPageParam: (last, pages) => (last.hasMore ? pages.length * PAGE_SIZE : undefined),
|
||||
})
|
||||
}
|
||||
|
||||
/** The conflicts carried by a 409 from a revert, or null if this isn't one. */
|
||||
export function revertConflicts(err: unknown): RevertResult | null {
|
||||
if (!(err instanceof ApiError) || err.status !== 409) return null
|
||||
const parsed = revertResultSchema.safeParse(err.body)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo a change set. Invalidates the editor payload so the canvas redraws, and
|
||||
* the history list so the new entry (and the "reverted" mark on its target)
|
||||
* appear — on failure too, because a 409 means part of it applied.
|
||||
*/
|
||||
export function useRevertChangeSet(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) })
|
||||
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
|
||||
}
|
||||
return useMutation({
|
||||
mutationFn: async (changeSetId: number): Promise<RevertResult> =>
|
||||
revertResultSchema.parse(await api.post(`/change-sets/${changeSetId}/revert`, undefined)),
|
||||
onSuccess: refresh,
|
||||
onError: (err) => {
|
||||
if (revertConflicts(err)) refresh() // a partial revert still changed things
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** How many rows a change set touched, for "3 changes" in the list. */
|
||||
export function totalChanges(cs: ChangeSet): number {
|
||||
return cs.counts.reduce((sum, c) => sum + c.n, 0)
|
||||
}
|
||||
|
||||
const ENTITY_NOUNS: Record<ChangeCount['entityType'], [string, string]> = {
|
||||
garden: ['garden setting', 'garden settings'],
|
||||
object: ['bed', 'beds'],
|
||||
planting: ['planting', 'plantings'],
|
||||
}
|
||||
|
||||
const OP_VERBS: Record<ChangeCount['op'], string> = {
|
||||
create: 'added',
|
||||
update: 'changed',
|
||||
delete: 'removed',
|
||||
}
|
||||
|
||||
/**
|
||||
* The change set's counts as a human phrase: "3 plantings added, 1 bed changed".
|
||||
* Returns '' when there's nothing to say, so callers can skip the line entirely
|
||||
* rather than render "0 changes".
|
||||
*/
|
||||
export function describeCounts(cs: ChangeSet): string {
|
||||
return cs.counts
|
||||
.filter((c) => c.n > 0)
|
||||
.map((c) => {
|
||||
const [one, many] = ENTITY_NOUNS[c.entityType]
|
||||
return `${c.n} ${c.n === 1 ? one : many} ${OP_VERBS[c.op]}`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
/** What a revert left alone, phrased for the person who asked for the undo. */
|
||||
export function describeConflict(c: RevertConflict): string {
|
||||
const noun = c.name ? `“${c.name}”` : `that ${ENTITY_NOUNS[c.entityType][0]}`
|
||||
switch (c.reason) {
|
||||
case 'changed':
|
||||
return `${noun} was edited since, so it was left alone`
|
||||
case 'missing':
|
||||
return `${noun} no longer exists`
|
||||
case 'exists':
|
||||
return `${noun} exists again, so it wasn't restored over the top`
|
||||
case 'unsupported':
|
||||
return `${noun} can't be undone automatically`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one undo implementation, shared by the history list and (per #49) the
|
||||
* agent turn's inline Undo in #57 — so an undo behaves identically wherever it
|
||||
* is offered, including how it explains a partial result.
|
||||
*
|
||||
* Outcomes are keyed by change set id, so a single instance serves a whole list.
|
||||
*/
|
||||
export function useUndo(gardenId: number) {
|
||||
const revert = useRevertChangeSet(gardenId)
|
||||
const [outcomes, setOutcomes] = useState<Record<number, UndoOutcome>>({})
|
||||
|
||||
const undo = (cs: ChangeSet) => {
|
||||
setOutcomes((prev) => ({ ...prev, [cs.id]: { tone: 'pending', message: 'Undoing…' } }))
|
||||
revert.mutate(cs.id, {
|
||||
onSuccess: (result) => {
|
||||
setOutcomes((prev) => ({ ...prev, [cs.id]: describeUndo(cs, result) }))
|
||||
},
|
||||
onError: (err) => {
|
||||
const result = revertConflicts(err)
|
||||
setOutcomes((prev) => ({
|
||||
...prev,
|
||||
[cs.id]: result
|
||||
? describeUndo(cs, result)
|
||||
: { tone: 'error', message: errorMessage(err, "That couldn't be undone.") },
|
||||
}))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
undo,
|
||||
// Per change set rather than a single global flag, so one row's pending
|
||||
// state can't disable the others.
|
||||
outcomeFor: (id: number): UndoOutcome | undefined => outcomes[id],
|
||||
}
|
||||
}
|
||||
|
||||
export interface UndoOutcome {
|
||||
tone: 'pending' | 'ok' | 'partial' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What actually happened, said in full. A revert can partly succeed, and "2 of 3
|
||||
* changes undone — “north bed” was edited since and was left alone" is the only
|
||||
* useful thing to report: a bare failure would be a lie about the two that did
|
||||
* apply, and a bare success would hide the one that didn't.
|
||||
*/
|
||||
export function describeUndo(target: ChangeSet, result: RevertResult): UndoOutcome {
|
||||
const skipped = result.conflicts.map(describeConflict).join('; ')
|
||||
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
|
||||
if (result.conflicts.length === 0) {
|
||||
// The server answers 200 with a null change set when every revision was
|
||||
// already a no-op — reachable by undoing a creation whose object is gone.
|
||||
// Claiming "Undone." there would be reporting work that didn't happen.
|
||||
if (applied === 0) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
|
||||
return { tone: 'ok', message: 'Undone.' }
|
||||
}
|
||||
if (applied === 0) {
|
||||
return { tone: 'error', message: `Nothing was undone — ${skipped}.` }
|
||||
}
|
||||
const total = totalChanges(target)
|
||||
return { tone: 'partial', message: `${applied} of ${total} changes undone — ${skipped}.` }
|
||||
}
|
||||
@@ -69,7 +69,11 @@ export function toEditorObject(o: ServerObject): EditorObject {
|
||||
}
|
||||
}
|
||||
|
||||
const fullKey = (gardenId: number) => ['garden-full', gardenId] as const
|
||||
// Exported so anything that changes a garden's contents from outside this file
|
||||
// — undo, in particular — can invalidate the editor payload and make the canvas
|
||||
// redraw.
|
||||
export const gardenFullKey = (gardenId: number) => ['garden-full', gardenId] as const
|
||||
const fullKey = gardenFullKey
|
||||
|
||||
export function gardenFullQueryOptions(gardenId: number) {
|
||||
return queryOptions({
|
||||
|
||||
Reference in New Issue
Block a user