mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 18:07:45 +00:00
feat(mobile): keep automation drafts across kills (#39683)
* feat(mobile): keep automation drafts across kills * fix(mobile): drop a created automation's draft on affirm and restore saved fields under typed ones * fix(mobile): stop saving an automation draft after it is dropped * fix(mobile): reopen the automation editor with in-memory edits while the store can't be read * refactor(mobile): one rule for which automation draft fields changed
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
validateAutomationDraft,
|
||||
type AutomationDraft
|
||||
} from './automation-draft'
|
||||
import { useAutomationEditorDraft } from './use-automation-editor-draft'
|
||||
import { automationErrorMessage } from './automation-error'
|
||||
import { automationStyles as styles } from './automation-styles'
|
||||
|
||||
@@ -36,6 +37,8 @@ export function AutomationEditor(props: {
|
||||
client: RpcClient
|
||||
current?: MobileAutomation
|
||||
initialDraft?: AutomationDraft
|
||||
/** Where unsaved edits are kept; they come back when this editor opens again, even after a kill. */
|
||||
draftKey: string
|
||||
onDraftChange: (draft: AutomationDraft) => void
|
||||
busy: boolean
|
||||
connected: boolean
|
||||
@@ -46,9 +49,9 @@ export function AutomationEditor(props: {
|
||||
}) {
|
||||
const t = useMobileTranslation()
|
||||
useMobileColorScheme()
|
||||
const [draft, setDraft] = useState(() => props.initialDraft ?? newAutomationDraft(props.current))
|
||||
const original = useRef(newAutomationDraft(props.current))
|
||||
const leave = useAutomationLeaveGuard(draft, original, props.leaveGuard)
|
||||
const form = useAutomationEditorDraft(props.draftKey, props.current, props.initialDraft)
|
||||
const { draft, setDraft, original } = form
|
||||
const leave = useAutomationLeaveGuard(draft, original, props.leaveGuard, form.discard)
|
||||
const selectTargetDraft = useAutomationTargetDrafts()
|
||||
const initialized = useRef(false)
|
||||
const [targets, setTargets] = useState<AutomationTargets | null>(null)
|
||||
@@ -96,7 +99,7 @@ export function AutomationEditor(props: {
|
||||
initial.workspaceMode = 'existing'
|
||||
}
|
||||
original.current = { ...initial }
|
||||
setDraft(props.initialDraft ?? initial)
|
||||
setDraft(props.initialDraft ?? form.withSavedEdits(initial))
|
||||
}
|
||||
})
|
||||
.catch((failure: unknown) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { AutomationButton } from './AutomationControls'
|
||||
import type { AutomationDraft } from './automation-draft'
|
||||
import { automationDraftChanged, type AutomationDraft } from './automation-draft'
|
||||
import { automationStyles as styles } from './automation-styles'
|
||||
|
||||
type EditorPosition = { step: number; editing: boolean; busy: boolean }
|
||||
@@ -20,25 +20,17 @@ export function leaveThrough(slot: AutomationLeaveSlot | undefined, leave: () =>
|
||||
}
|
||||
}
|
||||
|
||||
// The *Edited flags record how a field changed, not anything the user would lose.
|
||||
function draftChanged(original: AutomationDraft, draft: AutomationDraft): boolean {
|
||||
const before = new Map(Object.entries(original))
|
||||
return Object.entries(draft).some(
|
||||
([key, value]) =>
|
||||
key !== 'targetEdited' && key !== 'setupDecisionEdited' && value !== before.get(key)
|
||||
)
|
||||
}
|
||||
|
||||
/** Asks before a leave drops unsaved edits; fills `slot` while mounted so the tab and host switches ask too. */
|
||||
export function useAutomationLeaveGuard(
|
||||
draft: AutomationDraft,
|
||||
original: RefObject<AutomationDraft>,
|
||||
slot?: AutomationLeaveSlot
|
||||
slot: AutomationLeaveSlot | undefined,
|
||||
onDiscard: () => void
|
||||
) {
|
||||
const t = useMobileTranslation()
|
||||
const [pendingLeave, setPendingLeave] = useState<(() => void) | null>(null)
|
||||
const guard: AutomationLeaveGuard = (leave) => {
|
||||
if (draftChanged(original.current, draft)) {
|
||||
if (automationDraftChanged(original.current, draft)) {
|
||||
setPendingLeave(() => leave)
|
||||
} else {
|
||||
leave()
|
||||
@@ -64,7 +56,10 @@ export function useAutomationLeaveGuard(
|
||||
confirmLabel={t('automations.mobile.discard', 'Discard')}
|
||||
cancelLabel={t('automations.mobile.keepEditing', 'Keep editing')}
|
||||
destructive
|
||||
onConfirm={() => pendingLeave?.()}
|
||||
onConfirm={() => {
|
||||
onDiscard()
|
||||
pendingLeave?.()
|
||||
}}
|
||||
onCancel={() => setPendingLeave(null)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { act, type ReactTestInstance } from 'react-test-renderer'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { publishPageDeviceStore } from '../mobile-web-shell/bridge/page-device-store'
|
||||
import { AutomationField } from './AutomationControls'
|
||||
import { resetAutomationEditorDraftsForTests } from './automation-editor-draft-row'
|
||||
|
||||
export function registerAutomationDraftPersistenceTests({
|
||||
root,
|
||||
press,
|
||||
mount,
|
||||
unmount
|
||||
}: {
|
||||
root: () => ReactTestInstance
|
||||
press: (label: string) => Promise<void>
|
||||
mount: () => Promise<unknown>
|
||||
unmount: () => void
|
||||
}): void {
|
||||
it('brings unsaved edits back after the page reloads, and forgets them once discarded', async () => {
|
||||
// The shell's draft table outlives the page; nothing else does.
|
||||
const table = new Map<string, string>()
|
||||
publishPageDeviceStore({
|
||||
available: () => true,
|
||||
read: async (_table, keys) => ({
|
||||
status: 'ok',
|
||||
values: keys.map((key) => table.get(key) ?? null)
|
||||
}),
|
||||
write: async (_table, ops) => {
|
||||
for (const op of ops) {
|
||||
if (op.value === null) {
|
||||
table.delete(op.key)
|
||||
} else {
|
||||
table.set(op.key, op.value)
|
||||
}
|
||||
}
|
||||
return { written: true }
|
||||
}
|
||||
})
|
||||
const field = (label: string) =>
|
||||
root()
|
||||
.findAllByType(AutomationField)
|
||||
.find((node) => node.props.label === label)!
|
||||
async function reload(): Promise<void> {
|
||||
unmount()
|
||||
resetAutomationEditorDraftsForTests()
|
||||
await mount()
|
||||
await press('New automation')
|
||||
}
|
||||
try {
|
||||
await mount()
|
||||
await press('New automation')
|
||||
vi.useFakeTimers()
|
||||
await act(async () => {
|
||||
field('Name').props.onChange('Nightly digest')
|
||||
field('Instructions').props.onChange('Summarize merged PRs')
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
vi.useRealTimers()
|
||||
await reload()
|
||||
expect(field('Name').props.value).toBe('Nightly digest')
|
||||
expect(field('Instructions').props.value).toBe('Summarize merged PRs')
|
||||
|
||||
await press('Cancel')
|
||||
const prompt = root().find(
|
||||
(node) => String(node.type) === 'ConfirmModal' && node.props.visible === true
|
||||
)
|
||||
await act(async () => prompt.props.onConfirm())
|
||||
await reload()
|
||||
expect(field('Name').props.value).toBe('')
|
||||
expect(table.size).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
publishPageDeviceStore(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { registerAutomationPresentationTests } from './AutomationManager.presentation.spec'
|
||||
import { registerAutomationHostReselectionTests } from './AutomationManager.host-reselection.spec'
|
||||
import { registerAutomationRunHistoryTests } from './AutomationManager.run-history.spec'
|
||||
import { registerAutomationDraftPersistenceTests } from './AutomationManager.draft-persistence.spec'
|
||||
import { AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../src/shared/automation-runtime-capabilities'
|
||||
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
|
||||
import { AutomationEditor } from './AutomationEditor'
|
||||
@@ -8,6 +9,10 @@ import { automationUpdateParams } from './automation-draft'
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
automationEditorDraftKey,
|
||||
resetAutomationEditorDraftsForTests
|
||||
} from './automation-editor-draft-row'
|
||||
import { AutomationManager } from './AutomationManager'
|
||||
import { AutomationButton, AutomationChoice, AutomationField } from './AutomationControls'
|
||||
import { AutomationRunDetails } from './AutomationRunDetails'
|
||||
@@ -70,6 +75,8 @@ let tree: ReactTestRenderer | null = null
|
||||
afterEach(() => {
|
||||
act(() => tree?.unmount())
|
||||
tree = null
|
||||
// Unsaved edits outlive the editor for the page's life; each test is a fresh page.
|
||||
resetAutomationEditorDraftsForTests()
|
||||
})
|
||||
function setTree(value: ReactTestRenderer): void {
|
||||
tree = value
|
||||
@@ -204,6 +211,12 @@ async function mount(
|
||||
|
||||
describe('mobile automation navigation and setup', () => {
|
||||
registerAutomationRunHistoryTests({ root, setTree })
|
||||
registerAutomationDraftPersistenceTests({
|
||||
root,
|
||||
press,
|
||||
mount,
|
||||
unmount: () => act(() => tree?.unmount())
|
||||
})
|
||||
registerAutomationPresentationTests({ inventory, root, press, setTree, mount })
|
||||
registerAutomationHostReselectionTests({
|
||||
inventory,
|
||||
@@ -236,6 +249,7 @@ describe('mobile automation navigation and setup', () => {
|
||||
capabilities: [],
|
||||
onCancel: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
draftKey: automationEditorDraftKey(),
|
||||
onSave: save
|
||||
})
|
||||
)
|
||||
@@ -337,6 +351,7 @@ describe('mobile automation navigation and setup', () => {
|
||||
capabilities: [],
|
||||
onCancel: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
draftKey: automationEditorDraftKey(),
|
||||
onSave: vi.fn()
|
||||
})
|
||||
)
|
||||
@@ -414,6 +429,7 @@ describe('mobile automation navigation and setup', () => {
|
||||
capabilities: [],
|
||||
onCancel: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
draftKey: automationEditorDraftKey(),
|
||||
onSave: vi.fn()
|
||||
}
|
||||
await act(async () => {
|
||||
@@ -570,6 +586,7 @@ describe('mobile automation navigation and setup', () => {
|
||||
capabilities: [],
|
||||
onCancel: vi.fn(),
|
||||
onDraftChange: vi.fn(),
|
||||
draftKey: automationEditorDraftKey(),
|
||||
onSave
|
||||
}
|
||||
await act(async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { automationUpdateParams, type AutomationDraft } from './automation-draft'
|
||||
import type { AutomationTargets } from './automation-target-inventory'
|
||||
import { useAutomationCatalog } from './use-automation-catalog'
|
||||
import { automationEditorDraftKey, dropAutomationEditorDraft } from './automation-editor-draft-row'
|
||||
import { automationStyles as styles } from './automation-styles'
|
||||
|
||||
type AutomationView =
|
||||
@@ -55,7 +56,6 @@ export function AutomationManager(props: {
|
||||
const [view, setView] = useState<AutomationView>(startView)
|
||||
const [tab, setTab] = useState<'automations' | 'runs'>('automations')
|
||||
const [filter, setFilter] = useState<AutomationRunScope>(null)
|
||||
const filterId = filter?.automationId ?? ''
|
||||
const [outcome, setOutcome] = useState('all')
|
||||
const [deleting, setDeleting] = useState<MobileAutomationRow | null>(null)
|
||||
const createAttempt = useAutomationCreateAttempt(
|
||||
@@ -67,7 +67,8 @@ export function AutomationManager(props: {
|
||||
initialDraft ? { kind: 'editor', row: saved, initialDraft } : { kind: 'detail', row: saved }
|
||||
)
|
||||
},
|
||||
catalog.isCurrentSnapshot
|
||||
catalog.isCurrentSnapshot,
|
||||
() => dropAutomationEditorDraft(props.executionHost)
|
||||
)
|
||||
const setupConsent = useAutomationSetupConsent(props.client, props.executionHost)
|
||||
const disabled = !catalog.connected || catalog.busy
|
||||
@@ -107,9 +108,7 @@ export function AutomationManager(props: {
|
||||
setTab(next)
|
||||
setView({ kind: 'list' })
|
||||
}
|
||||
function allAutomations() {
|
||||
showList('automations')
|
||||
}
|
||||
const allAutomations = () => showList('automations')
|
||||
function allRuns(automationId = '') {
|
||||
selectFilter(automationId)
|
||||
showList('runs')
|
||||
@@ -240,6 +239,7 @@ export function AutomationManager(props: {
|
||||
return
|
||||
}
|
||||
const id = saved.automation.id
|
||||
dropAutomationEditorDraft(props.executionHost, id)
|
||||
catalog.setRows((rows) => [...rows.filter((row) => row.automation.id !== id), saved])
|
||||
setView({ kind: 'detail', row: saved })
|
||||
}
|
||||
@@ -286,6 +286,7 @@ export function AutomationManager(props: {
|
||||
client={props.client}
|
||||
current={view.row?.automation}
|
||||
initialDraft={view.initialDraft}
|
||||
draftKey={automationEditorDraftKey(props.executionHost, view.row?.automation.id)}
|
||||
onDraftChange={createAttempt.observeDraft}
|
||||
leaveGuard={props.leaveGuard}
|
||||
capabilities={catalog.capabilities}
|
||||
@@ -351,7 +352,7 @@ export function AutomationManager(props: {
|
||||
<View style={styles.stack}>
|
||||
<AutomationChoice
|
||||
label={t('automations.mobile.automation', 'Automation')}
|
||||
value={filterId}
|
||||
value={filter?.automationId ?? ''}
|
||||
options={[
|
||||
{ value: '', label: t('automations.mobile.allAutomations', 'All automations') },
|
||||
...catalog.rows.map((entry) => ({
|
||||
|
||||
@@ -59,6 +59,22 @@ export function newAutomationDraft(current?: MobileAutomation): AutomationDraft
|
||||
}
|
||||
}
|
||||
|
||||
/** The fields that differ from `original`; null when nothing the user would lose did (the *Edited flags only record how a field changed). */
|
||||
export function automationDraftEdits(
|
||||
original: AutomationDraft,
|
||||
draft: AutomationDraft
|
||||
): Partial<AutomationDraft> | null {
|
||||
const before = new Map(Object.entries(original))
|
||||
const edits = Object.entries(draft).filter(([key, value]) => value !== before.get(key))
|
||||
return edits.some(([key]) => key !== 'targetEdited' && key !== 'setupDecisionEdited')
|
||||
? Object.fromEntries(edits)
|
||||
: null
|
||||
}
|
||||
|
||||
export function automationDraftChanged(original: AutomationDraft, draft: AutomationDraft): boolean {
|
||||
return automationDraftEdits(original, draft) !== null
|
||||
}
|
||||
|
||||
// The host checks arguments for an agent or surface this build doesn't know.
|
||||
function assertKnownExtraAgentArgs(draft: AutomationDraft): void {
|
||||
const { agentId, surface } = draft
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
durableDraftRow,
|
||||
durableDraftRowKey,
|
||||
pageDraftRowBackend,
|
||||
resetDurableDraftRowsForTests,
|
||||
type DurableDraftRow
|
||||
} from '../drafts/durable-draft-rows'
|
||||
import type { AutomationDraft } from './automation-draft'
|
||||
|
||||
// Only the fields the user changed are kept, so a field edited elsewhere since then is not reverted.
|
||||
export type AutomationDraftEdits = Partial<AutomationDraft>
|
||||
|
||||
const editsSchema = z
|
||||
.object({
|
||||
targetEdited: z.boolean(),
|
||||
setupDecisionEdited: z.boolean(),
|
||||
name: z.string(),
|
||||
prompt: z.string(),
|
||||
agentId: z.string().nullable(),
|
||||
surface: z.string(),
|
||||
setupId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
workspaceMode: z.enum(['existing', 'new_per_run']),
|
||||
rrule: z.string(),
|
||||
timezone: z.string(),
|
||||
baseBranch: z.string(),
|
||||
setupDecision: z.enum(['inherit', 'run', 'skip']),
|
||||
reuseSession: z.boolean(),
|
||||
extraAgentArgs: z.string(),
|
||||
precheckCommand: z.string(),
|
||||
precheckTimeout: z.string(),
|
||||
grace: z.string()
|
||||
} satisfies Record<keyof AutomationDraft, z.ZodType>)
|
||||
.partial()
|
||||
|
||||
type Slot = {
|
||||
edits: AutomationDraftEdits | null
|
||||
// A drop before the row was read: the stored copy is what was dropped, so it never comes back.
|
||||
dropped: boolean
|
||||
// Dropped since the editor opened: that editor's later typing is not saved (its automation may exist now).
|
||||
closed: boolean
|
||||
// The open editor, told whenever a read (the first or a retried one) brings saved edits.
|
||||
onRestore: ((edits: AutomationDraftEdits) => void) | null
|
||||
row: DurableDraftRow
|
||||
}
|
||||
const slots = new Map<string, Slot>()
|
||||
|
||||
/** One row per authority (the paired host, or a server) and automation, `new` for a create. */
|
||||
export function automationEditorDraftKey(executionHost?: string, automationId?: string): string {
|
||||
return durableDraftRowKey(
|
||||
'automation',
|
||||
'',
|
||||
`${executionHost ?? 'paired'}:${automationId ?? 'new'}`
|
||||
)
|
||||
}
|
||||
|
||||
/** Lays saved edits over the form, except on fields the user has changed since it opened. */
|
||||
export function laySavedEdits(
|
||||
form: AutomationDraft,
|
||||
original: AutomationDraft,
|
||||
edits: AutomationDraftEdits
|
||||
): AutomationDraft {
|
||||
const before = new Map(Object.entries(original))
|
||||
const now = new Map(Object.entries(form))
|
||||
const untouched = Object.entries(edits).filter(([key]) => now.get(key) === before.get(key))
|
||||
return untouched.length ? { ...form, ...Object.fromEntries(untouched) } : form
|
||||
}
|
||||
|
||||
function slotFor(key: string): Slot {
|
||||
let slot = slots.get(key)
|
||||
if (!slot) {
|
||||
const created: Slot = {
|
||||
edits: null,
|
||||
dropped: false,
|
||||
closed: false,
|
||||
onRestore: null,
|
||||
row: durableDraftRow(key, {
|
||||
backend: () => pageDraftRowBackend(key),
|
||||
snapshot: () => created.edits,
|
||||
restore: (saved) => {
|
||||
const parsed = editsSchema.safeParse(saved)
|
||||
if (created.dropped || !parsed.success || Object.keys(parsed.data).length === 0) {
|
||||
return
|
||||
}
|
||||
// Fields typed before the read landed win over the saved copy.
|
||||
created.edits = { ...parsed.data, ...created.edits }
|
||||
created.onRestore?.(created.edits)
|
||||
}
|
||||
})
|
||||
}
|
||||
slot = created
|
||||
slots.set(key, slot)
|
||||
}
|
||||
return slot
|
||||
}
|
||||
|
||||
/** The page's copy: what was read, plus typing kept in memory while the store can't be read. */
|
||||
export function readAutomationEditorDraft(key: string): AutomationDraftEdits | null {
|
||||
return slotFor(key).edits
|
||||
}
|
||||
|
||||
/** Opens the row for an editor: reads it if it hasn't been, and hands saved edits to `onRestore`. */
|
||||
export function watchAutomationEditorDraft(
|
||||
key: string,
|
||||
onRestore: (edits: AutomationDraftEdits) => void
|
||||
): () => void {
|
||||
const slot = slotFor(key)
|
||||
slot.closed = false
|
||||
slot.onRestore = onRestore
|
||||
void slot.row.load()
|
||||
return () => {
|
||||
if (slot.onRestore === onRestore) {
|
||||
slot.onRestore = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAutomationEditorDraft(key: string, edits: AutomationDraftEdits | null): void {
|
||||
const slot = slotFor(key)
|
||||
if (slot.closed) {
|
||||
return
|
||||
}
|
||||
slot.edits = edits
|
||||
slot.row.save(edits ? 'deferred' : 'immediate')
|
||||
}
|
||||
|
||||
// Discarded edits, and edits the host now holds, must not come back.
|
||||
export function discardAutomationEditorDraft(key: string): void {
|
||||
const slot = slotFor(key)
|
||||
slot.dropped ||= slot.row.state() === 'pending'
|
||||
saveAutomationEditorDraft(key, null)
|
||||
slot.closed = true
|
||||
}
|
||||
|
||||
export function dropAutomationEditorDraft(executionHost?: string, automationId?: string): void {
|
||||
discardAutomationEditorDraft(automationEditorDraftKey(executionHost, automationId))
|
||||
}
|
||||
|
||||
export function resetAutomationEditorDraftsForTests(): void {
|
||||
slots.clear()
|
||||
resetDurableDraftRowsForTests()
|
||||
}
|
||||
@@ -25,7 +25,9 @@ export function useAutomationCreateAttempt(
|
||||
rows: readonly MobileAutomationRow[],
|
||||
creating: boolean,
|
||||
onReconcile: (row: MobileAutomationRow, initialDraft?: AutomationDraft) => void,
|
||||
isCurrentSnapshot: () => boolean
|
||||
isCurrentSnapshot: () => boolean,
|
||||
// The host holds the create, so the form's saved copy must not bring it back for a second one.
|
||||
onCreated: () => void
|
||||
) {
|
||||
const attempt = useRef<SubmittedAutomationCreate | null>(null)
|
||||
const latestDraft = useRef<AutomationDraft | null>(null)
|
||||
@@ -60,8 +62,9 @@ export function useAutomationCreateAttempt(
|
||||
}
|
||||
submitted.affirmed = true
|
||||
attempt.current = null
|
||||
onCreated()
|
||||
onReconcile(saved, Object.keys(changes).length ? { ...baseline, ...changes } : undefined)
|
||||
}, [rows, creating, onReconcile, isCurrentSnapshot])
|
||||
}, [rows, creating, onReconcile, isCurrentSnapshot, onCreated])
|
||||
return {
|
||||
observeDraft,
|
||||
reset: () => {
|
||||
@@ -140,10 +143,11 @@ export function useAutomationCreateAttempt(
|
||||
}
|
||||
try {
|
||||
submitted.replyId = automationCreateWrite.interpret(response).automation.id
|
||||
submitted.affirmed = true
|
||||
} catch {
|
||||
throw new Error(AUTOMATION_OUTCOME_UNKNOWN)
|
||||
}
|
||||
submitted.affirmed = true
|
||||
onCreated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { MobileAutomation } from './automation-reply-schema'
|
||||
import { automationDraftEdits, newAutomationDraft, type AutomationDraft } from './automation-draft'
|
||||
import {
|
||||
discardAutomationEditorDraft,
|
||||
laySavedEdits,
|
||||
readAutomationEditorDraft,
|
||||
saveAutomationEditorDraft,
|
||||
watchAutomationEditorDraft
|
||||
} from './automation-editor-draft-row'
|
||||
|
||||
/** The editor's form, with the user's unsaved edits kept in a draft row and laid back over it on reopen. */
|
||||
export function useAutomationEditorDraft(
|
||||
draftKey: string,
|
||||
current: MobileAutomation | undefined,
|
||||
initialDraft: AutomationDraft | undefined
|
||||
) {
|
||||
const [saved] = useState(() => (initialDraft ? null : readAutomationEditorDraft(draftKey)))
|
||||
const restored = useRef(saved)
|
||||
const [draft, setDraft] = useState(
|
||||
() => initialDraft ?? { ...newAutomationDraft(current), ...saved }
|
||||
)
|
||||
const original = useRef(newAutomationDraft(current))
|
||||
|
||||
useEffect(() => {
|
||||
return watchAutomationEditorDraft(draftKey, (edits) => {
|
||||
restored.current = edits
|
||||
setDraft((form) => laySavedEdits(form, original.current, edits))
|
||||
})
|
||||
}, [draftKey])
|
||||
|
||||
useEffect(() => {
|
||||
saveAutomationEditorDraft(draftKey, automationDraftEdits(original.current, draft))
|
||||
}, [draft, draftKey])
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
original,
|
||||
// The form rebuilt from the host's values, with the saved edits on top.
|
||||
withSavedEdits: (form: AutomationDraft): AutomationDraft => ({ ...form, ...restored.current }),
|
||||
discard: () => discardAutomationEditorDraft(draftKey)
|
||||
}
|
||||
}
|
||||
@@ -235,7 +235,8 @@ describe('host-owned automation reads', () => {
|
||||
catalog.rows,
|
||||
true,
|
||||
reconciled,
|
||||
catalog.isCurrentSnapshot
|
||||
catalog.isCurrentSnapshot,
|
||||
vi.fn()
|
||||
)
|
||||
state = { catalog, attempt }
|
||||
return null
|
||||
@@ -315,7 +316,7 @@ describe('host-owned automation reads', () => {
|
||||
const port = automationClient(() => ({ automation: savedAutomation() }))
|
||||
let state: ReturnType<typeof useAutomationCreateAttempt> | null = null
|
||||
function Probe() {
|
||||
state = useAutomationCreateAttempt([], true, vi.fn(), () => true)
|
||||
state = useAutomationCreateAttempt([], true, vi.fn(), () => true, vi.fn())
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
@@ -368,7 +369,7 @@ describe('host-owned automation reads', () => {
|
||||
const port = automationClient(() => ({}))
|
||||
let state: ReturnType<typeof useAutomationCreateAttempt> | null = null
|
||||
function Probe() {
|
||||
state = useAutomationCreateAttempt([], true, vi.fn(), () => true)
|
||||
state = useAutomationCreateAttempt([], true, vi.fn(), () => true, vi.fn())
|
||||
return null
|
||||
}
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { PageDeviceStore } from '../mobile-web-shell/bridge/page-device-store'
|
||||
import {
|
||||
readPageDeviceStore,
|
||||
type PageDeviceStore
|
||||
} from '../mobile-web-shell/bridge/page-device-store'
|
||||
import {
|
||||
decodeDeviceStoreValue,
|
||||
encodeDeviceStoreValue
|
||||
@@ -93,6 +96,18 @@ export function deviceStoreDraftBackend(
|
||||
}
|
||||
}
|
||||
|
||||
// An older shell grants no store; there the row lives only as long as the page.
|
||||
const MEMORY_BACKEND: DurableDraftRowBackend = {
|
||||
read: async () => ({ ok: true, value: null }),
|
||||
write: async () => true
|
||||
}
|
||||
|
||||
/** The device store when the shell grants it, else memory only, as before the store existed. */
|
||||
export function pageDraftRowBackend(key: string): DurableDraftRowBackend {
|
||||
const store = readPageDeviceStore()
|
||||
return store?.available() ? deviceStoreDraftBackend(store, key) : MEMORY_BACKEND
|
||||
}
|
||||
|
||||
function enqueue(row: Row, step: () => Promise<void>): Promise<void> {
|
||||
row.chain = row.chain.then(step).catch((error: unknown) => {
|
||||
console.warn('[draft-rows] a draft could not be saved; it is kept in memory', error)
|
||||
|
||||
Reference in New Issue
Block a user