@talelk/newui-data 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +54 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +116 -0
- package/src/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +105 -0
- package/src/form/form.test.tsx +102 -0
- package/src/form/form.tsx +85 -0
- package/src/form/index.ts +1 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +4 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +151 -0
- package/src/query/collection.ts +192 -0
- package/src/query/index.ts +3 -0
- package/src/query/repository.ts +30 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -0
- package/src/resource/document.test.ts +151 -0
- package/src/resource/document.ts +198 -0
- package/src/resource/idb.test.ts +169 -0
- package/src/resource/idb.ts +167 -0
- package/src/resource/index.ts +70 -0
- package/src/resource/list.test.ts +150 -0
- package/src/resource/list.ts +230 -0
- package/src/resource/local.test.ts +111 -0
- package/src/resource/local.ts +155 -0
- package/src/resource/realtime.test.ts +331 -0
- package/src/resource/realtime.ts +287 -0
- package/src/resource/resource.test.ts +186 -0
- package/src/resource/resource.ts +213 -0
- package/src/resource/useCall.test.ts +60 -0
- package/src/resource/useCall.ts +74 -0
- package/src/resource/useDoc.test.ts +31 -0
- package/src/resource/useDoc.ts +23 -0
- package/src/resource/useDoctype.test.ts +104 -0
- package/src/resource/useDoctype.ts +128 -0
- package/src/resource/useList.test.ts +32 -0
- package/src/resource/useList.ts +22 -0
- package/src/resource/useNewDoc.test.ts +67 -0
- package/src/resource/useNewDoc.ts +78 -0
- package/src/select.test.ts +13 -0
- package/src/select.ts +19 -0
- package/src/undo.test.ts +24 -0
- package/src/undo.ts +37 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type Accessor, createSignal } from 'solid-js'
|
|
2
|
+
import type { RequestError } from './client'
|
|
3
|
+
import { createResource, type Resource, type ResourceFetcher } from './resource'
|
|
4
|
+
|
|
5
|
+
/** A single doctype-level write action. */
|
|
6
|
+
export interface DoctypeAction<TResult = unknown, TInput = unknown> {
|
|
7
|
+
data: Accessor<TResult | null>
|
|
8
|
+
loading: Accessor<boolean>
|
|
9
|
+
error: Accessor<RequestError | null>
|
|
10
|
+
submit: (input: TInput) => Promise<TResult | null>
|
|
11
|
+
reset: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Options for {@link useDoctype}. */
|
|
15
|
+
export interface UseDoctypeOptions {
|
|
16
|
+
fetcher?: ResourceFetcher
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Params for a document method. */
|
|
20
|
+
export interface RunDocMethodInput {
|
|
21
|
+
name: string
|
|
22
|
+
method: string
|
|
23
|
+
params?: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Params for a doctype-level method. */
|
|
27
|
+
export interface RunMethodInput {
|
|
28
|
+
method: string
|
|
29
|
+
params?: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The write actions {@link useDoctype} returns. */
|
|
33
|
+
export interface UseDoctypeReturn<TDoc = Record<string, unknown>> {
|
|
34
|
+
insert: DoctypeAction<TDoc, Partial<TDoc>>
|
|
35
|
+
delete: DoctypeAction<unknown, string>
|
|
36
|
+
setValue: DoctypeAction<TDoc, Record<string, unknown> & { name: string }>
|
|
37
|
+
runDocMethod: DoctypeAction<unknown, RunDocMethodInput>
|
|
38
|
+
runMethod: DoctypeAction<unknown, RunMethodInput>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const INSERT_URL = 'frappe.client.insert'
|
|
42
|
+
const SET_VALUE_URL = 'frappe.client.set_value'
|
|
43
|
+
const DELETE_URL = 'frappe.client.delete'
|
|
44
|
+
const RUN_DOC_METHOD_URL = 'run_doc_method'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The signal-based replacement for frappe-ui's `useDoctype`: the doctype-level
|
|
48
|
+
* write actions (`insert`, `delete`, `setValue`, `runDocMethod`, `runMethod`),
|
|
49
|
+
* each an inert resource that fires on `submit`.
|
|
50
|
+
*/
|
|
51
|
+
export function useDoctype<TDoc = Record<string, unknown>>(
|
|
52
|
+
doctype: string,
|
|
53
|
+
options: UseDoctypeOptions = {},
|
|
54
|
+
): UseDoctypeReturn<TDoc> {
|
|
55
|
+
const { fetcher } = options
|
|
56
|
+
|
|
57
|
+
const insertResource = createResource<TDoc, Partial<TDoc>>({
|
|
58
|
+
url: INSERT_URL,
|
|
59
|
+
method: 'POST',
|
|
60
|
+
makeParams: (input) => ({ doc: { doctype, ...(input as Partial<TDoc>) } }),
|
|
61
|
+
fetcher,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const deleteResource = createResource<unknown, string>({
|
|
65
|
+
url: DELETE_URL,
|
|
66
|
+
method: 'POST',
|
|
67
|
+
makeParams: (input) => ({ doctype, name: input }),
|
|
68
|
+
fetcher,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const setValueResource = createResource<
|
|
72
|
+
TDoc,
|
|
73
|
+
Record<string, unknown> & { name: string }
|
|
74
|
+
>({
|
|
75
|
+
url: SET_VALUE_URL,
|
|
76
|
+
method: 'POST',
|
|
77
|
+
makeParams: ({ name, ...values }) => ({ doctype, name, fieldname: values }),
|
|
78
|
+
fetcher,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const runDocMethodResource = createResource<unknown, RunDocMethodInput>({
|
|
82
|
+
url: RUN_DOC_METHOD_URL,
|
|
83
|
+
method: 'POST',
|
|
84
|
+
makeParams: ({ name, method, params }) => ({
|
|
85
|
+
dt: doctype,
|
|
86
|
+
dn: name,
|
|
87
|
+
method,
|
|
88
|
+
args: params ?? {},
|
|
89
|
+
}),
|
|
90
|
+
fetcher,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// `runMethod` POSTs to the method name itself, which is only known per call.
|
|
94
|
+
// `makeParams` runs before the URL is resolved (see `createResource`), so the
|
|
95
|
+
// signal it writes is already visible when the URL is read.
|
|
96
|
+
const [methodUrl, setMethodUrl] = createSignal('')
|
|
97
|
+
const runMethodResource = createResource<unknown, RunMethodInput>({
|
|
98
|
+
url: () => methodUrl(),
|
|
99
|
+
method: 'POST',
|
|
100
|
+
makeParams: ({ method, params }) => {
|
|
101
|
+
setMethodUrl(() => method)
|
|
102
|
+
return params ?? {}
|
|
103
|
+
},
|
|
104
|
+
fetcher,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
function toAction<TResult, TInput>(
|
|
108
|
+
resource: Resource<TResult, TInput>,
|
|
109
|
+
): DoctypeAction<TResult, TInput> {
|
|
110
|
+
return {
|
|
111
|
+
data: resource.data,
|
|
112
|
+
loading: resource.loading,
|
|
113
|
+
error: resource.error,
|
|
114
|
+
submit: (input: TInput) => resource.submit(input),
|
|
115
|
+
reset: resource.reset,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
insert: toAction<TDoc, Partial<TDoc>>(insertResource),
|
|
121
|
+
delete: toAction<unknown, string>(deleteResource),
|
|
122
|
+
setValue: toAction<TDoc, Record<string, unknown> & { name: string }>(
|
|
123
|
+
setValueResource,
|
|
124
|
+
),
|
|
125
|
+
runDocMethod: toAction<unknown, RunDocMethodInput>(runDocMethodResource),
|
|
126
|
+
runMethod: toAction<unknown, RunMethodInput>(runMethodResource),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useList } from './useList'
|
|
3
|
+
|
|
4
|
+
function mockResponse(body: unknown): Response {
|
|
5
|
+
return {
|
|
6
|
+
ok: true,
|
|
7
|
+
status: 200,
|
|
8
|
+
statusText: 'OK',
|
|
9
|
+
json: async () => body,
|
|
10
|
+
text: async () => JSON.stringify(body),
|
|
11
|
+
} as unknown as Response
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.unstubAllGlobals()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('useList', () => {
|
|
19
|
+
it('delegates to the list resource', async () => {
|
|
20
|
+
const mock = vi.fn(async () => mockResponse({ message: [{ name: 'T-1' }] }))
|
|
21
|
+
vi.stubGlobal('fetch', mock)
|
|
22
|
+
|
|
23
|
+
const list = useList<{ name: string }>({ doctype: 'Task' })
|
|
24
|
+
const rows = await list.reload()
|
|
25
|
+
|
|
26
|
+
expect(rows).toEqual([{ name: 'T-1' }])
|
|
27
|
+
expect(mock).toHaveBeenCalledWith(
|
|
28
|
+
'/api/method/frappe.client.get_list',
|
|
29
|
+
expect.objectContaining({ method: 'POST' }),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createListResource,
|
|
3
|
+
type ListResource,
|
|
4
|
+
type ListResourceOptions,
|
|
5
|
+
} from './list'
|
|
6
|
+
|
|
7
|
+
/** Options for {@link useList}. */
|
|
8
|
+
export type UseListOptions<TRow = Record<string, unknown>> =
|
|
9
|
+
ListResourceOptions<TRow>
|
|
10
|
+
|
|
11
|
+
/** The signal-based surface {@link useList} returns. */
|
|
12
|
+
export type UseListReturn<TRow = Record<string, unknown>> = ListResource<TRow>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A thin composable over {@link createListResource}: a paginated, filterable
|
|
16
|
+
* list read through signals.
|
|
17
|
+
*/
|
|
18
|
+
export function useList<TRow = Record<string, unknown>>(
|
|
19
|
+
options: ListResourceOptions<TRow>,
|
|
20
|
+
): ListResource<TRow> {
|
|
21
|
+
return createListResource(options)
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useNewDoc } from './useNewDoc'
|
|
3
|
+
|
|
4
|
+
function mockResponse(body: unknown): Response {
|
|
5
|
+
return {
|
|
6
|
+
ok: true,
|
|
7
|
+
status: 200,
|
|
8
|
+
statusText: 'OK',
|
|
9
|
+
json: async () => body,
|
|
10
|
+
text: async () => JSON.stringify(body),
|
|
11
|
+
} as unknown as Response
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FetchCall {
|
|
15
|
+
mock: { calls: Array<[string, RequestInit]> }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stubFetch(...responses: Response[]): FetchCall {
|
|
19
|
+
const mock = vi.fn()
|
|
20
|
+
for (const response of responses) mock.mockResolvedValueOnce(response)
|
|
21
|
+
vi.stubGlobal('fetch', mock)
|
|
22
|
+
return mock as unknown as FetchCall
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function urlAt(mock: FetchCall, index = 0): string {
|
|
26
|
+
return mock.mock.calls[index]?.[0] ?? ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bodyAt(mock: FetchCall, index = 0): Record<string, unknown> {
|
|
30
|
+
const call = mock.mock.calls[index]
|
|
31
|
+
return JSON.parse(String(call?.[1]?.body)) as Record<string, unknown>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.unstubAllGlobals()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('useNewDoc', () => {
|
|
39
|
+
it('builds a doc and inserts it', async () => {
|
|
40
|
+
const fetchMock = stubFetch(mockResponse({ message: { name: 'T-9' } }))
|
|
41
|
+
const newDoc = useNewDoc<{ name: string; subject: string }>('Task', {
|
|
42
|
+
subject: 'Hi',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
expect(newDoc.doc()).toEqual({ subject: 'Hi' })
|
|
46
|
+
newDoc.setDoc((doc) => ({ ...doc, subject: 'Bye' }))
|
|
47
|
+
|
|
48
|
+
const created = await newDoc.submit()
|
|
49
|
+
|
|
50
|
+
expect(created).toEqual({ name: 'T-9' })
|
|
51
|
+
expect(urlAt(fetchMock)).toBe('/api/method/frappe.client.insert')
|
|
52
|
+
expect(bodyAt(fetchMock)).toEqual({
|
|
53
|
+
doc: { doctype: 'Task', subject: 'Bye' },
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('reset clears the draft', async () => {
|
|
58
|
+
stubFetch(mockResponse({ message: { name: 'T-9' } }))
|
|
59
|
+
const newDoc = useNewDoc<{ subject: string }>('Task', { subject: 'Hi' })
|
|
60
|
+
|
|
61
|
+
newDoc.setDoc({ subject: 'Bye' })
|
|
62
|
+
newDoc.reset()
|
|
63
|
+
|
|
64
|
+
expect(newDoc.doc()).toEqual({ subject: 'Hi' })
|
|
65
|
+
expect(newDoc.data()).toBe(null)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type Accessor, createSignal } from 'solid-js'
|
|
2
|
+
import type { RequestError } from './client'
|
|
3
|
+
import { createResource, type ResourceFetcher } from './resource'
|
|
4
|
+
|
|
5
|
+
/** Options for {@link useNewDoc}. */
|
|
6
|
+
export interface UseNewDocOptions {
|
|
7
|
+
onSuccess?: (doc: Record<string, unknown>) => void
|
|
8
|
+
onError?: (error: RequestError) => void
|
|
9
|
+
fetcher?: ResourceFetcher
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The signal-based surface {@link useNewDoc} returns. */
|
|
13
|
+
export interface UseNewDocReturn<TDoc = Record<string, unknown>> {
|
|
14
|
+
/** The document being built, edited through {@link UseNewDocReturn.setDoc}. */
|
|
15
|
+
doc: Accessor<Partial<TDoc>>
|
|
16
|
+
setDoc: (
|
|
17
|
+
values: Partial<TDoc> | ((current: Partial<TDoc>) => Partial<TDoc>),
|
|
18
|
+
) => void
|
|
19
|
+
/** The created document, once `submit` resolves. */
|
|
20
|
+
data: Accessor<TDoc | null>
|
|
21
|
+
loading: Accessor<boolean>
|
|
22
|
+
error: Accessor<RequestError | null>
|
|
23
|
+
/** Insert the document (`frappe.client.insert`). */
|
|
24
|
+
submit: () => Promise<TDoc | null>
|
|
25
|
+
reset: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const INSERT_URL = 'frappe.client.insert'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The signal-based replacement for frappe-ui's `useNewDoc`: build a document
|
|
32
|
+
* locally, then insert it.
|
|
33
|
+
*/
|
|
34
|
+
export function useNewDoc<TDoc extends object = Record<string, unknown>>(
|
|
35
|
+
doctype: string,
|
|
36
|
+
initialValues: Partial<TDoc> = {},
|
|
37
|
+
options: UseNewDocOptions = {},
|
|
38
|
+
): UseNewDocReturn<TDoc> {
|
|
39
|
+
const [doc, setDocSignal] = createSignal<Partial<TDoc>>({ ...initialValues })
|
|
40
|
+
|
|
41
|
+
const resource = createResource<TDoc, Record<string, unknown>>({
|
|
42
|
+
url: INSERT_URL,
|
|
43
|
+
method: 'POST',
|
|
44
|
+
makeParams: (input) => ({ doc: { doctype, ...input } }),
|
|
45
|
+
fetcher: options.fetcher,
|
|
46
|
+
onSuccess: (data) => options.onSuccess?.(data as Record<string, unknown>),
|
|
47
|
+
onError: options.onError,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
function setDoc(
|
|
51
|
+
next: Partial<TDoc> | ((current: Partial<TDoc>) => Partial<TDoc>),
|
|
52
|
+
): void {
|
|
53
|
+
if (typeof next === 'function') {
|
|
54
|
+
setDocSignal((current) => next(current))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
setDocSignal(() => next)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function submit(): Promise<TDoc | null> {
|
|
61
|
+
return resource.submit(doc() as Record<string, unknown>)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function reset(): void {
|
|
65
|
+
setDocSignal(() => ({ ...initialValues }))
|
|
66
|
+
resource.reset()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
doc,
|
|
71
|
+
setDoc,
|
|
72
|
+
data: resource.data,
|
|
73
|
+
loading: resource.loading,
|
|
74
|
+
error: resource.error,
|
|
75
|
+
submit,
|
|
76
|
+
reset,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { first, optionsOf } from './select'
|
|
3
|
+
|
|
4
|
+
describe('select', () => {
|
|
5
|
+
it('mirrors values into options and unwraps with a fallback', () => {
|
|
6
|
+
expect(optionsOf(['Name', 'Size'])).toEqual([
|
|
7
|
+
{ label: 'Name', value: 'Name' },
|
|
8
|
+
{ label: 'Size', value: 'Size' },
|
|
9
|
+
])
|
|
10
|
+
expect(first(['Size'], 'Name')).toBe('Size')
|
|
11
|
+
expect(first([], 'Name')).toBe('Name')
|
|
12
|
+
})
|
|
13
|
+
})
|
package/src/select.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Select plumbing: a string list to options, and the chosen value back.
|
|
3
|
+
*
|
|
4
|
+
* Every page turned a list into `{ label, value }` inline and unwrapped the
|
|
5
|
+
* single-value array the same way. Two spellings, once.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface SelectOption {
|
|
9
|
+
label: string
|
|
10
|
+
value: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `['Name', 'Size']` → `[{ label: 'Name', value: 'Name' }, …]`. */
|
|
14
|
+
export const optionsOf = (values: readonly string[]): SelectOption[] =>
|
|
15
|
+
values.map((value) => ({ label: value, value }))
|
|
16
|
+
|
|
17
|
+
/** The chosen value, or `fallback` when the selection is empty. */
|
|
18
|
+
export const first = (value: string[], fallback: string): string =>
|
|
19
|
+
value[0] ?? fallback
|
package/src/undo.test.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createRoot } from 'solid-js'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createUndo } from './undo'
|
|
4
|
+
|
|
5
|
+
describe('undo', () => {
|
|
6
|
+
it('captures, restores once, then reports nothing left', () => {
|
|
7
|
+
createRoot((dispose) => {
|
|
8
|
+
const undo = createUndo<number[]>()
|
|
9
|
+
expect(undo.snapshot()).toBeUndefined()
|
|
10
|
+
expect(undo.undo(() => {})).toBe(false)
|
|
11
|
+
|
|
12
|
+
undo.capture([1, 2])
|
|
13
|
+
const restored: number[][] = []
|
|
14
|
+
expect(undo.undo((value) => restored.push(value))).toBe(true)
|
|
15
|
+
expect(restored).toEqual([[1, 2]])
|
|
16
|
+
expect(undo.snapshot()).toBeUndefined()
|
|
17
|
+
|
|
18
|
+
undo.capture([3])
|
|
19
|
+
undo.clear()
|
|
20
|
+
expect(undo.snapshot()).toBeUndefined()
|
|
21
|
+
dispose()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
})
|
package/src/undo.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Accessor, createSignal } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One-step undo.
|
|
5
|
+
*
|
|
6
|
+
* A snapshot signal, captured before a mutation, restored once. The page keeps
|
|
7
|
+
* owning its data and its notice copy; this owns the signal.
|
|
8
|
+
*/
|
|
9
|
+
export interface Undo<T> {
|
|
10
|
+
/** The captured value, or `undefined` when there is nothing to undo. */
|
|
11
|
+
snapshot: Accessor<T | undefined>
|
|
12
|
+
/** Remember `value` as the state an undo returns to. */
|
|
13
|
+
capture: (value: T) => void
|
|
14
|
+
/**
|
|
15
|
+
* Hand the snapshot back to `restore` and clear it. Returns whether a
|
|
16
|
+
* snapshot existed, so the caller can word its notice either way.
|
|
17
|
+
*/
|
|
18
|
+
undo: (restore: (value: T) => void) => boolean
|
|
19
|
+
/** Drop the snapshot without restoring. */
|
|
20
|
+
clear: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createUndo<T>(): Undo<T> {
|
|
24
|
+
const [snapshot, setSnapshot] = createSignal<T | undefined>(undefined)
|
|
25
|
+
return {
|
|
26
|
+
snapshot,
|
|
27
|
+
capture: (value) => setSnapshot(() => value),
|
|
28
|
+
undo: (restore) => {
|
|
29
|
+
const previous = snapshot()
|
|
30
|
+
if (previous === undefined) return false
|
|
31
|
+
restore(previous)
|
|
32
|
+
setSnapshot(undefined)
|
|
33
|
+
return true
|
|
34
|
+
},
|
|
35
|
+
clear: () => setSnapshot(undefined),
|
|
36
|
+
}
|
|
37
|
+
}
|