@talelk/newui-data 0.0.0-stage → 1.1.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 +65 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +170 -0
- package/src/aggregate.test.ts +231 -0
- package/src/aggregate.ts +320 -0
- package/src/config-store.test.ts +157 -0
- package/src/config-store.ts +295 -0
- package/src/csv-export.test.ts +156 -0
- package/src/csv-export.ts +129 -0
- package/src/dashboard.test.tsx +285 -0
- package/src/dashboard.tsx +381 -0
- package/src/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +135 -0
- package/src/form/autosave.test.ts +186 -0
- package/src/form/autosave.ts +194 -0
- package/src/form/config.test.tsx +288 -0
- package/src/form/config.ts +187 -0
- package/src/form/form.test.tsx +102 -0
- package/src/form/form.tsx +205 -0
- package/src/form/index.ts +3 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +10 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +278 -0
- package/src/query/collection.ts +207 -0
- package/src/query/index.ts +4 -0
- package/src/query/repository.ts +30 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -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 +77 -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/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/undo.test.ts +24 -0
- package/src/undo.ts +37 -0
- package/src/url-serialize.ts +93 -0
- package/src/url-state.test.ts +183 -0
- package/src/url-state.ts +168 -0
package/src/download.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saving a file, without the Blob/anchor/revoke dance every page wrote.
|
|
3
|
+
*
|
|
4
|
+
* `downloadCsv` quotes every cell and doubles inner quotes, which is the part
|
|
5
|
+
* that is easy to get subtly wrong.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Save a Blob (or a local `File`) under `name`. */
|
|
9
|
+
export function downloadBlob(name: string, blob: Blob): void {
|
|
10
|
+
const url = URL.createObjectURL(blob)
|
|
11
|
+
const link = document.createElement('a')
|
|
12
|
+
link.href = url
|
|
13
|
+
link.download = name
|
|
14
|
+
link.click()
|
|
15
|
+
URL.revokeObjectURL(url)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Save text under `name`; plain text unless the caller names a type. */
|
|
19
|
+
export function downloadText(
|
|
20
|
+
name: string,
|
|
21
|
+
text: string,
|
|
22
|
+
mime = 'text/plain;charset=utf-8',
|
|
23
|
+
): void {
|
|
24
|
+
downloadBlob(name, new Blob([text], { type: mime }))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Rows → CSV text, every cell quoted, quotes doubled. */
|
|
28
|
+
export function csvText(rows: (string | number)[][]): string {
|
|
29
|
+
return rows
|
|
30
|
+
.map((row) =>
|
|
31
|
+
row.map((value) => `"${String(value).replaceAll('"', '""')}"`).join(','),
|
|
32
|
+
)
|
|
33
|
+
.join('\r\n')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Save rows as a CSV file under `name`. */
|
|
37
|
+
export function downloadCsv(name: string, rows: (string | number)[][]): void {
|
|
38
|
+
downloadText(name, csvText(rows), 'text/csv;charset=utf-8')
|
|
39
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The logic layer's public surface, asserted by name and by entry point.
|
|
3
|
+
*
|
|
4
|
+
* The data package has four entries (`.`, `/query`, `/form`, `/resource`),
|
|
5
|
+
* which is exactly
|
|
6
|
+
* the shape an accidental `export *` or a stray
|
|
7
|
+
* re-export slips into. An entry's cost is part of its contract — 0.77 kB for
|
|
8
|
+
* the helpers, 15.5 for the query — so a new name on an entry is a decision, not
|
|
9
|
+
* a detail.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { describe, expect, it } from 'vitest'
|
|
14
|
+
import * as form from './form'
|
|
15
|
+
import * as root from './index'
|
|
16
|
+
import * as query from './query'
|
|
17
|
+
import * as resource from './resource'
|
|
18
|
+
|
|
19
|
+
const ENTRIES = {
|
|
20
|
+
'.': root,
|
|
21
|
+
'./query': query,
|
|
22
|
+
'./form': form,
|
|
23
|
+
'./resource': resource,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
const SURFACE: Record<keyof typeof ENTRIES, string[]> = {
|
|
27
|
+
// Formatting, selection, undo, downloads — and nothing that renders.
|
|
28
|
+
'.': [
|
|
29
|
+
'CONFIG_DOCUMENT_LIMIT',
|
|
30
|
+
'CONFIG_SCHEMA_VERSION',
|
|
31
|
+
'DashboardProvider',
|
|
32
|
+
'aggregate',
|
|
33
|
+
'bytes',
|
|
34
|
+
'count',
|
|
35
|
+
'createConfigStore',
|
|
36
|
+
'createDashboardState',
|
|
37
|
+
'createTablePersist',
|
|
38
|
+
'createUndo',
|
|
39
|
+
'createUrlState',
|
|
40
|
+
'csvText',
|
|
41
|
+
'downloadBlob',
|
|
42
|
+
'downloadCsv',
|
|
43
|
+
'downloadText',
|
|
44
|
+
'exportCsv',
|
|
45
|
+
'first',
|
|
46
|
+
'isEmail',
|
|
47
|
+
'optionsOf',
|
|
48
|
+
'rollup',
|
|
49
|
+
'timeBucket',
|
|
50
|
+
'usd',
|
|
51
|
+
'usdK',
|
|
52
|
+
'useDashboardState',
|
|
53
|
+
],
|
|
54
|
+
'./query': [
|
|
55
|
+
'DataProvider',
|
|
56
|
+
'createCollection',
|
|
57
|
+
'createDataClient',
|
|
58
|
+
'createListQuery',
|
|
59
|
+
'createServerTableState',
|
|
60
|
+
],
|
|
61
|
+
'./form': [
|
|
62
|
+
'Form',
|
|
63
|
+
'createAppForm',
|
|
64
|
+
'createAutoSave',
|
|
65
|
+
'fieldError',
|
|
66
|
+
'hiddenPaths',
|
|
67
|
+
'isUnderHidden',
|
|
68
|
+
'pruneHiddenValues',
|
|
69
|
+
],
|
|
70
|
+
'./resource': [
|
|
71
|
+
'RequestError',
|
|
72
|
+
'connectRealtime',
|
|
73
|
+
'createDocumentResource',
|
|
74
|
+
'createIdbStore',
|
|
75
|
+
'createListResource',
|
|
76
|
+
'createLocalStore',
|
|
77
|
+
'createRealtime',
|
|
78
|
+
'createResource',
|
|
79
|
+
'getConfig',
|
|
80
|
+
'isAbortError',
|
|
81
|
+
'normalizeError',
|
|
82
|
+
'request',
|
|
83
|
+
'resetConfig',
|
|
84
|
+
'setConfig',
|
|
85
|
+
'useCall',
|
|
86
|
+
'useDoc',
|
|
87
|
+
'useDoctype',
|
|
88
|
+
'useList',
|
|
89
|
+
'useNewDoc',
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const manifest = JSON.parse(
|
|
94
|
+
readFileSync(path.join(process.cwd(), 'packages/data/package.json'), 'utf8'),
|
|
95
|
+
) as { exports: Record<string, unknown> }
|
|
96
|
+
|
|
97
|
+
describe('@talelk/newui-data', () => {
|
|
98
|
+
it('publishes exactly the entries this test pins', () => {
|
|
99
|
+
expect(Object.keys(manifest.exports).sort()).toEqual(
|
|
100
|
+
Object.keys(ENTRIES).sort(),
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
for (const [entry, module] of Object.entries(ENTRIES)) {
|
|
105
|
+
it(`${entry} exports exactly its surface`, () => {
|
|
106
|
+
expect(Object.keys(module).sort()).toEqual(
|
|
107
|
+
SURFACE[entry as keyof typeof ENTRIES],
|
|
108
|
+
)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
it('keeps the root entry headless', () => {
|
|
113
|
+
// The root is the only entry with no component, no query client and no
|
|
114
|
+
// validator; every value it exports is a function over plain data — with
|
|
115
|
+
// the exception of the config-store constants, plain numbers by design.
|
|
116
|
+
for (const [name, value] of Object.entries(root)) {
|
|
117
|
+
if (
|
|
118
|
+
name === 'CONFIG_SCHEMA_VERSION' ||
|
|
119
|
+
name === 'CONFIG_DOCUMENT_LIMIT'
|
|
120
|
+
) {
|
|
121
|
+
expect(typeof value).toBe('number')
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
expect(typeof value).toBe('function')
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps the Frappe resource client off the root', () => {
|
|
129
|
+
// The DocType fetching layer is app-specific cost (fetch + IndexedDB +
|
|
130
|
+
// realtime multiplexer); it must stay behind its earned subpath.
|
|
131
|
+
for (const name of ['useDoc', 'useDoctype', 'frappeRequest', 'request']) {
|
|
132
|
+
expect(name in root).toBe(false)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { type AnyFormApi, createForm } from '@tanstack/solid-form'
|
|
2
|
+
import { createEffect, createRoot } from 'solid-js'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { createAutoSave } from './autosave'
|
|
5
|
+
import { createAppForm } from './form'
|
|
6
|
+
|
|
7
|
+
function memoryStorage() {
|
|
8
|
+
const map = new Map<string, string>()
|
|
9
|
+
return {
|
|
10
|
+
getItem: (key: string) => map.get(key) ?? null,
|
|
11
|
+
setItem: (key: string, value: string) => void map.set(key, value),
|
|
12
|
+
removeItem: (key: string) => void map.delete(key),
|
|
13
|
+
keys: () => [...map.keys()],
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function harness() {
|
|
18
|
+
// `createRoot` runs its callback synchronously, so `form` is set by return.
|
|
19
|
+
let form!: AnyFormApi
|
|
20
|
+
createRoot((dispose) => {
|
|
21
|
+
form = createForm(() => ({
|
|
22
|
+
defaultValues: { name: '' } satisfies { name: string },
|
|
23
|
+
validators: {
|
|
24
|
+
onChange: ({ value }: { value: { name: string } }) =>
|
|
25
|
+
value.name.length < 3 ? 'Too short.' : undefined,
|
|
26
|
+
},
|
|
27
|
+
}))
|
|
28
|
+
// Keep the root alive for the duration of the test.
|
|
29
|
+
void dispose
|
|
30
|
+
})
|
|
31
|
+
return form
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(() => vi.useFakeTimers())
|
|
35
|
+
|
|
36
|
+
describe('createAutoSave', () => {
|
|
37
|
+
it('saves a valid dirty value after the debounce', async () => {
|
|
38
|
+
const form = harness()
|
|
39
|
+
const save = vi.fn()
|
|
40
|
+
const auto = createAutoSave<{ name: string }>(form, { save, debounce: 100 })
|
|
41
|
+
|
|
42
|
+
form.setFieldValue('name', 'Ada Lovelace')
|
|
43
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
44
|
+
|
|
45
|
+
expect(save).toHaveBeenCalledTimes(1)
|
|
46
|
+
expect(save.mock.calls[0]?.[0]).toEqual({ name: 'Ada Lovelace' })
|
|
47
|
+
expect(auto.saved()).toBe(true)
|
|
48
|
+
expect(auto.dirty()).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not save while the form is invalid', async () => {
|
|
52
|
+
const form = harness()
|
|
53
|
+
const save = vi.fn()
|
|
54
|
+
createAutoSave<{ name: string }>(form, { save, debounce: 50 })
|
|
55
|
+
|
|
56
|
+
form.setFieldValue('name', 'ad')
|
|
57
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
58
|
+
|
|
59
|
+
expect(save).not.toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('skips the write when the filter rejects', async () => {
|
|
63
|
+
const form = harness()
|
|
64
|
+
const save = vi.fn()
|
|
65
|
+
createAutoSave<{ name: string }>(form, {
|
|
66
|
+
save,
|
|
67
|
+
debounce: 50,
|
|
68
|
+
filter: (values) => values.name !== 'skip',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
form.setFieldValue('name', 'skip')
|
|
72
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
73
|
+
|
|
74
|
+
expect(save).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('persists a draft and clears it after a successful save', async () => {
|
|
78
|
+
const form = harness()
|
|
79
|
+
const storage = memoryStorage()
|
|
80
|
+
const save = vi.fn()
|
|
81
|
+
const auto = createAutoSave<{ name: string }>(form, {
|
|
82
|
+
save,
|
|
83
|
+
debounce: 50,
|
|
84
|
+
id: 'profile',
|
|
85
|
+
storage,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
form.setFieldValue('name', 'Ada Lovelace')
|
|
89
|
+
// The draft is written on the change, before the debounce fires.
|
|
90
|
+
expect(auto.hasDraft()).toBe(true)
|
|
91
|
+
expect(auto.restoreDraft()).toEqual({ name: 'Ada Lovelace' })
|
|
92
|
+
|
|
93
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
94
|
+
expect(save).toHaveBeenCalled()
|
|
95
|
+
expect(auto.hasDraft()).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('tracks hasDraft reactively', async () => {
|
|
99
|
+
const form = harness()
|
|
100
|
+
const seen: boolean[] = []
|
|
101
|
+
createRoot(() => {
|
|
102
|
+
const auto = createAutoSave<{ name: string }>(form, {
|
|
103
|
+
save: vi.fn(),
|
|
104
|
+
debounce: 50,
|
|
105
|
+
id: 'reactive',
|
|
106
|
+
storage: memoryStorage(),
|
|
107
|
+
})
|
|
108
|
+
createEffect(() => {
|
|
109
|
+
seen.push(auto.hasDraft())
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
form.setFieldValue('name', 'Ada Lovelace')
|
|
113
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
114
|
+
expect(seen).toEqual([false, true, false])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
interface Doc {
|
|
119
|
+
mode: string
|
|
120
|
+
host: string
|
|
121
|
+
name: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** An app form whose `host` is hidden unless `mode` is `advanced`. */
|
|
125
|
+
function appHarness(onSubmit: (doc: Doc) => void = () => {}) {
|
|
126
|
+
let app!: ReturnType<typeof createAppForm<Doc>>
|
|
127
|
+
createRoot(() => {
|
|
128
|
+
app = createAppForm<Doc>({
|
|
129
|
+
defaultValues: { mode: 'simple', host: 'stale.example', name: '' },
|
|
130
|
+
ui: { host: { when: (values) => values.mode === 'advanced' } },
|
|
131
|
+
onSubmit,
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
return app
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe('createAutoSave with an app form', () => {
|
|
138
|
+
it('saves the pruned document, the one submit sends', async () => {
|
|
139
|
+
const app = appHarness()
|
|
140
|
+
const save = vi.fn()
|
|
141
|
+
createAutoSave<Doc>(app, { save, debounce: 50 })
|
|
142
|
+
|
|
143
|
+
app.form.setFieldValue('name', 'Ada')
|
|
144
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
145
|
+
|
|
146
|
+
expect(save).toHaveBeenCalledWith({ mode: 'simple', name: 'Ada' })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('keeps the draft unpruned', () => {
|
|
150
|
+
const app = appHarness()
|
|
151
|
+
const auto = createAutoSave<Doc>(app, {
|
|
152
|
+
save: vi.fn(),
|
|
153
|
+
debounce: 50,
|
|
154
|
+
id: 'settings',
|
|
155
|
+
storage: memoryStorage(),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
app.form.setFieldValue('name', 'Ada')
|
|
159
|
+
|
|
160
|
+
expect(auto.restoreDraft()).toEqual({
|
|
161
|
+
mode: 'simple',
|
|
162
|
+
host: 'stale.example',
|
|
163
|
+
name: 'Ada',
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('drops the draft on a successful submit and does not autosave after it', async () => {
|
|
168
|
+
const app = appHarness()
|
|
169
|
+
const save = vi.fn()
|
|
170
|
+
const auto = createAutoSave<Doc>(app, {
|
|
171
|
+
save,
|
|
172
|
+
debounce: 50,
|
|
173
|
+
id: 'settings',
|
|
174
|
+
storage: memoryStorage(),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
app.form.setFieldValue('name', 'Ada')
|
|
178
|
+
expect(auto.hasDraft()).toBe(true)
|
|
179
|
+
await app.form.handleSubmit()
|
|
180
|
+
|
|
181
|
+
expect(auto.hasDraft()).toBe(false)
|
|
182
|
+
expect(auto.dirty()).toBe(false)
|
|
183
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
184
|
+
expect(save).not.toHaveBeenCalled()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { AnyFormApi } from '@tanstack/solid-form'
|
|
2
|
+
import { type Accessor, createSignal, onCleanup } from 'solid-js'
|
|
3
|
+
import { type ConfigStorage, createConfigStore } from '../config-store'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Autosave and drafts for a live form.
|
|
7
|
+
*
|
|
8
|
+
* Two stores, two rules. The **autosave** is a debounced backend write of a
|
|
9
|
+
* valid, pruned document: every change re-arms the timer, and when it fires
|
|
10
|
+
* the form validates itself before anything is written — an invalid document
|
|
11
|
+
* is never saved behind the user's back. It is not a submit; the submit
|
|
12
|
+
* lifecycle does not move. The **draft** is the local, unpruned copy written
|
|
13
|
+
* on every change (through the config store under `newui.form-draft`, document
|
|
14
|
+
* name = `id`), so a closed tab loses nothing and re-showing a hidden field
|
|
15
|
+
* gives back what was typed. A successful autosave or submit drops the draft.
|
|
16
|
+
*/
|
|
17
|
+
export interface AutoSaveOptions<T extends object> {
|
|
18
|
+
/** Persists the valid, dirty document — usually a query mutation. */
|
|
19
|
+
save: (values: T) => void | Promise<void>
|
|
20
|
+
/**
|
|
21
|
+
* Milliseconds a change waits before it is saved.
|
|
22
|
+
* @default 800
|
|
23
|
+
*/
|
|
24
|
+
debounce?: number
|
|
25
|
+
/** Skips the write when it returns `false` (an unfinished required section). */
|
|
26
|
+
filter?: (values: T) => boolean
|
|
27
|
+
/** Draft identity. Omit it to autosave without persisting drafts. */
|
|
28
|
+
id?: string
|
|
29
|
+
/** Draft storage backend. Defaults to `localStorage`, like the config store. */
|
|
30
|
+
storage?: ConfigStorage
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What autosave watches: a TanStack form, or the `createAppForm` result —
|
|
35
|
+
* pass the latter so `save` receives the same pruned document submit sends.
|
|
36
|
+
*/
|
|
37
|
+
export type AutoSaveSource<T extends object> =
|
|
38
|
+
| AnyFormApi
|
|
39
|
+
| { form: AnyFormApi; document: (values: T) => T }
|
|
40
|
+
|
|
41
|
+
/** What `createAutoSave` returns; the chrome reads the accessors. */
|
|
42
|
+
export interface AutoSave<T extends object = Record<string, unknown>> {
|
|
43
|
+
/** True while a save is in flight. */
|
|
44
|
+
saving: Accessor<boolean>
|
|
45
|
+
/** True once a save has landed and nothing has changed since. */
|
|
46
|
+
saved: Accessor<boolean>
|
|
47
|
+
/** True when the form holds an unsaved change. */
|
|
48
|
+
dirty: Accessor<boolean>
|
|
49
|
+
/** Save now instead of waiting out the debounce. */
|
|
50
|
+
flush: () => Promise<void>
|
|
51
|
+
/** Whether a stored draft exists, reactive. */
|
|
52
|
+
hasDraft: Accessor<boolean>
|
|
53
|
+
/**
|
|
54
|
+
* The stored draft, if any — the app decides whether to apply it. A draft
|
|
55
|
+
* may predate the current schema or field map: treat it as untrusted input
|
|
56
|
+
* and let the form's validation report what no longer fits.
|
|
57
|
+
*/
|
|
58
|
+
restoreDraft: () => T | undefined
|
|
59
|
+
/** Drop the stored draft (on submit, discard, or after `restoreDraft`). */
|
|
60
|
+
discardDraft: () => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clone<T>(value: T): T {
|
|
64
|
+
return structuredClone(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
68
|
+
if (a === b) return true
|
|
69
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
|
|
70
|
+
if (
|
|
71
|
+
a === null ||
|
|
72
|
+
b === null ||
|
|
73
|
+
typeof a !== 'object' ||
|
|
74
|
+
typeof b !== 'object'
|
|
75
|
+
)
|
|
76
|
+
return false
|
|
77
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
78
|
+
const aKeys = Object.keys(a as object)
|
|
79
|
+
const bKeys = Object.keys(b as object)
|
|
80
|
+
if (aKeys.length !== bKeys.length) return false
|
|
81
|
+
return aKeys.every(
|
|
82
|
+
(key) =>
|
|
83
|
+
Object.hasOwn(b, key) &&
|
|
84
|
+
deepEqual(
|
|
85
|
+
(a as Record<string, unknown>)[key],
|
|
86
|
+
(b as Record<string, unknown>)[key],
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isAppForm<T extends object>(
|
|
92
|
+
source: AutoSaveSource<T>,
|
|
93
|
+
): source is { form: AnyFormApi; document: (values: T) => T } {
|
|
94
|
+
return 'document' in source && typeof source.document === 'function'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createAutoSave<T extends object>(
|
|
98
|
+
source: AutoSaveSource<T>,
|
|
99
|
+
options: AutoSaveOptions<T>,
|
|
100
|
+
): AutoSave<T> {
|
|
101
|
+
const form = isAppForm(source) ? source.form : source
|
|
102
|
+
const document = isAppForm(source) ? source.document : (values: T) => values
|
|
103
|
+
const debounce = options.debounce ?? 800
|
|
104
|
+
const store = options.id
|
|
105
|
+
? createConfigStore({ key: 'newui.form-draft', storage: options.storage })
|
|
106
|
+
: undefined
|
|
107
|
+
const id = options.id
|
|
108
|
+
|
|
109
|
+
let lastSaved: T = clone(form.state.values as T)
|
|
110
|
+
let running: Promise<void> = Promise.resolve()
|
|
111
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
112
|
+
const [saving, setSaving] = createSignal(false)
|
|
113
|
+
const [saved, setSaved] = createSignal(false)
|
|
114
|
+
const [dirty, setDirty] = createSignal(false)
|
|
115
|
+
const [hasDraft, setHasDraft] = createSignal(
|
|
116
|
+
store !== undefined && id !== undefined && store.load(id) !== undefined,
|
|
117
|
+
)
|
|
118
|
+
const writeDraft = (values: T) => {
|
|
119
|
+
if (!store || !id) return
|
|
120
|
+
store.save(id, values, { type: 'form-draft' })
|
|
121
|
+
setHasDraft(true)
|
|
122
|
+
}
|
|
123
|
+
const dropDraft = () => {
|
|
124
|
+
if (!store || !id) return
|
|
125
|
+
store.remove(id)
|
|
126
|
+
setHasDraft(false)
|
|
127
|
+
}
|
|
128
|
+
let submissions = form.state.submissionAttempts as number
|
|
129
|
+
|
|
130
|
+
const run = async (): Promise<void> => {
|
|
131
|
+
const values = clone(form.state.values as T)
|
|
132
|
+
if (deepEqual(values, lastSaved)) return
|
|
133
|
+
if (options.filter && !options.filter(values)) return
|
|
134
|
+
setSaving(true)
|
|
135
|
+
try {
|
|
136
|
+
await form.validateAllFields('submit')
|
|
137
|
+
if ((form.state.errors as unknown[]).length === 0) {
|
|
138
|
+
await options.save(document(values))
|
|
139
|
+
lastSaved = values
|
|
140
|
+
setSaved(true)
|
|
141
|
+
setDirty(false)
|
|
142
|
+
dropDraft()
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
setSaving(false)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const subscription = form.store.subscribe(() => {
|
|
150
|
+
const state = form.state
|
|
151
|
+
// A successful submit wrote the document: nothing is unsaved, the draft
|
|
152
|
+
// goes, and the values it sent are the new baseline.
|
|
153
|
+
if (
|
|
154
|
+
state.isSubmitSuccessful &&
|
|
155
|
+
(state.submissionAttempts as number) !== submissions
|
|
156
|
+
) {
|
|
157
|
+
submissions = state.submissionAttempts as number
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
lastSaved = clone(state.values as T)
|
|
160
|
+
setDirty(false)
|
|
161
|
+
dropDraft()
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
// Mid-submit changes (an `onSubmit` reset) are the submit's, not edits.
|
|
165
|
+
if (state.isSubmitting) return
|
|
166
|
+
const values = state.values as T
|
|
167
|
+
if (deepEqual(values, lastSaved)) return
|
|
168
|
+
setDirty(true)
|
|
169
|
+
setSaved(false)
|
|
170
|
+
writeDraft(values)
|
|
171
|
+
clearTimeout(timer)
|
|
172
|
+
timer = setTimeout(() => {
|
|
173
|
+
running = run()
|
|
174
|
+
}, debounce)
|
|
175
|
+
})
|
|
176
|
+
onCleanup(() => {
|
|
177
|
+
clearTimeout(timer)
|
|
178
|
+
subscription.unsubscribe()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
saving,
|
|
183
|
+
saved,
|
|
184
|
+
dirty,
|
|
185
|
+
async flush() {
|
|
186
|
+
clearTimeout(timer)
|
|
187
|
+
await running
|
|
188
|
+
await run()
|
|
189
|
+
},
|
|
190
|
+
hasDraft,
|
|
191
|
+
restoreDraft: () => (store && id ? store.load<T>(id)?.doc : undefined),
|
|
192
|
+
discardDraft: dropDraft,
|
|
193
|
+
}
|
|
194
|
+
}
|