attaform 0.21.1 → 0.22.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/dist/chunks/dev-key-collision-warnings.cjs +1 -1
- package/dist/chunks/dev-key-collision-warnings.mjs +1 -1
- package/dist/chunks/devtools.cjs +1 -1
- package/dist/chunks/devtools.mjs +1 -1
- package/dist/chunks/fingerprint2.cjs +1 -1
- package/dist/chunks/fingerprint2.mjs +1 -1
- package/dist/chunks/indexeddb.cjs +1 -1
- package/dist/chunks/indexeddb.mjs +1 -1
- package/dist/chunks/local-storage.cjs +1 -1
- package/dist/chunks/local-storage.mjs +1 -1
- package/dist/chunks/multi-tab-sync.cjs +2 -2
- package/dist/chunks/multi-tab-sync.mjs +2 -2
- package/dist/chunks/session-storage.cjs +1 -1
- package/dist/chunks/session-storage.mjs +1 -1
- package/dist/chunks/wire-persistence.cjs +2 -2
- package/dist/chunks/wire-persistence.mjs +2 -2
- package/dist/index.cjs +37 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +20 -18
- package/dist/index.d.mts +20 -18
- package/dist/index.d.ts +20 -18
- package/dist/index.mjs +38 -25
- package/dist/index.mjs.map +1 -1
- package/dist/nuxt.d.cts +1 -1
- package/dist/nuxt.d.mts +1 -1
- package/dist/nuxt.d.ts +1 -1
- package/dist/runtime/components/AttaformDevtoolsPanel.vue +396 -216
- package/dist/runtime/components/DevtoolsValueTree.vue +176 -114
- package/dist/runtime/plugins/attaform.cjs +2 -2
- package/dist/runtime/plugins/attaform.mjs +2 -2
- package/dist/shared/{attaform.D32WwKk6.cjs → attaform.01iKS_lz.cjs} +260 -356
- package/dist/shared/attaform.01iKS_lz.cjs.map +1 -0
- package/dist/shared/{attaform.Y1ZGhM4k.mjs → attaform.6xE0Lcfd.mjs} +2 -2
- package/dist/shared/{attaform.Y1ZGhM4k.mjs.map → attaform.6xE0Lcfd.mjs.map} +1 -1
- package/dist/shared/{attaform.S-pYLSo4.cjs → attaform.AyujQoHp.cjs} +13 -16
- package/dist/shared/attaform.AyujQoHp.cjs.map +1 -0
- package/dist/shared/{attaform.BupwXkj_.mjs → attaform.BFWb6hDk.mjs} +29 -23
- package/dist/shared/attaform.BFWb6hDk.mjs.map +1 -0
- package/dist/shared/{attaform.NQ8mybyW.d.mts → attaform.BGwNZ9GV.d.cts} +63 -64
- package/dist/shared/{attaform.pmtahXKy.mjs → attaform.BKFwekY2.mjs} +257 -356
- package/dist/shared/attaform.BKFwekY2.mjs.map +1 -0
- package/dist/shared/{attaform.BSkvn43g.cjs → attaform.C-RtnCJM.cjs} +116 -47
- package/dist/shared/attaform.C-RtnCJM.cjs.map +1 -0
- package/dist/shared/{attaform.Bv7dRDWK.d.ts → attaform.CCCeEPwa.d.mts} +63 -64
- package/dist/shared/{attaform.BM6YD9kZ.cjs → attaform.CR6wGvNu.cjs} +29 -23
- package/dist/shared/attaform.CR6wGvNu.cjs.map +1 -0
- package/dist/shared/{attaform.Bq5sX7TF.cjs → attaform.CRzpFCjV.cjs} +2 -2
- package/dist/shared/{attaform.Bq5sX7TF.cjs.map → attaform.CRzpFCjV.cjs.map} +1 -1
- package/dist/shared/{attaform.ClXwitZj.cjs → attaform.CjMcwV7W.cjs} +894 -342
- package/dist/shared/attaform.CjMcwV7W.cjs.map +1 -0
- package/dist/shared/{attaform.DR6RmxWZ.mjs → attaform.CsB-iKbU.mjs} +888 -337
- package/dist/shared/attaform.CsB-iKbU.mjs.map +1 -0
- package/dist/shared/{attaform.BWfliRIK.d.cts → attaform.D4XYaasQ.d.ts} +63 -64
- package/dist/shared/{attaform.Be8NZG9M.mjs → attaform.DCjgGir_.mjs} +19 -45
- package/dist/shared/attaform.DCjgGir_.mjs.map +1 -0
- package/dist/shared/{attaform.DMEP_ENr.mjs → attaform.DNuiFCXG.mjs} +14 -17
- package/dist/shared/attaform.DNuiFCXG.mjs.map +1 -0
- package/dist/shared/{attaform.MtrpT6Ki.d.ts → attaform.DUMWQefY.d.ts} +1 -1
- package/dist/shared/{attaform.DozgVlCE.mjs → attaform.DgCfLqay.mjs} +116 -47
- package/dist/shared/attaform.DgCfLqay.mjs.map +1 -0
- package/dist/shared/{attaform.D0dWZsJt.d.mts → attaform.DvUH4a3o.d.cts} +307 -133
- package/dist/shared/{attaform.D0dWZsJt.d.cts → attaform.DvUH4a3o.d.mts} +307 -133
- package/dist/shared/{attaform.D0dWZsJt.d.ts → attaform.DvUH4a3o.d.ts} +307 -133
- package/dist/shared/{attaform.Duecg2NO.d.mts → attaform.FN0vaQAg.d.mts} +1 -1
- package/dist/shared/{attaform.CICFZ1iS.cjs → attaform.Q3eAD2wD.cjs} +19 -45
- package/dist/shared/attaform.Q3eAD2wD.cjs.map +1 -0
- package/dist/shared/{attaform.FudOcHaa.d.cts → attaform.aekT7mMx.d.cts} +1 -1
- package/dist/transforms.cjs +1 -1
- package/dist/transforms.mjs +1 -1
- package/dist/vite.cjs +1 -1
- package/dist/vite.mjs +1 -1
- package/dist/zod-v3.cjs +3 -4
- package/dist/zod-v3.cjs.map +1 -1
- package/dist/zod-v3.d.cts +4 -4
- package/dist/zod-v3.d.mts +4 -4
- package/dist/zod-v3.d.ts +4 -4
- package/dist/zod-v3.mjs +2 -3
- package/dist/zod-v3.mjs.map +1 -1
- package/dist/zod-v4.cjs +3 -4
- package/dist/zod-v4.cjs.map +1 -1
- package/dist/zod-v4.d.cts +4 -4
- package/dist/zod-v4.d.mts +4 -4
- package/dist/zod-v4.d.ts +4 -4
- package/dist/zod-v4.mjs +2 -3
- package/dist/zod-v4.mjs.map +1 -1
- package/dist/zod.cjs +6 -6
- package/dist/zod.cjs.map +1 -1
- package/dist/zod.d.cts +31 -22
- package/dist/zod.d.mts +31 -22
- package/dist/zod.d.ts +31 -22
- package/dist/zod.mjs +5 -6
- package/dist/zod.mjs.map +1 -1
- package/package.json +4 -12
- package/dist/shared/attaform.BM6YD9kZ.cjs.map +0 -1
- package/dist/shared/attaform.BSkvn43g.cjs.map +0 -1
- package/dist/shared/attaform.Be8NZG9M.mjs.map +0 -1
- package/dist/shared/attaform.BupwXkj_.mjs.map +0 -1
- package/dist/shared/attaform.CICFZ1iS.cjs.map +0 -1
- package/dist/shared/attaform.ClXwitZj.cjs.map +0 -1
- package/dist/shared/attaform.D32WwKk6.cjs.map +0 -1
- package/dist/shared/attaform.DMEP_ENr.mjs.map +0 -1
- package/dist/shared/attaform.DR6RmxWZ.mjs.map +0 -1
- package/dist/shared/attaform.DozgVlCE.mjs.map +0 -1
- package/dist/shared/attaform.S-pYLSo4.cjs.map +0 -1
- package/dist/shared/attaform.pmtahXKy.mjs.map +0 -1
- package/dist/shared/{attaform.DSD85fHb.d.cts → attaform.nf83TIR5.d.cts} +10 -10
- package/dist/shared/{attaform.DSD85fHb.d.mts → attaform.nf83TIR5.d.mts} +10 -10
- package/dist/shared/{attaform.DSD85fHb.d.ts → attaform.nf83TIR5.d.ts} +10 -10
|
@@ -1,230 +1,410 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import { computed, onUnmounted, ref, watch } from
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
3
|
+
// Imports route through the public `attaform` entry so the published
|
|
4
|
+
// `.vue` file (shipped via mkdist under `dist/runtime/components/`)
|
|
5
|
+
// can resolve through the consumer's node_modules → `dist/index.mjs`,
|
|
6
|
+
// rather than through brittle relative paths into the rollup-bundled
|
|
7
|
+
// shared chunks (which mkdist doesn't co-locate). Type-only imports of
|
|
8
|
+
// internal types stay relative — they're erased at compile time and
|
|
9
|
+
// don't need to resolve at the consumer's runtime.
|
|
10
|
+
import { type AttaformDevtoolsBridge, canonicalizePath, type Segment } from 'attaform'
|
|
11
|
+
import type { FormStore } from '../core/create-form-store'
|
|
12
|
+
import type { GenericForm } from '../types/types-core'
|
|
13
|
+
import type { FormKey } from '../types/types-api'
|
|
14
|
+
import DevtoolsValueTree from './DevtoolsValueTree.vue'
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
bridge: AttaformDevtoolsBridge
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
// Cross-iframe reactivity bridge. Vue's reactivity system is module-
|
|
21
|
+
// scoped — the host's `@vue/reactivity` instance and the panel's are
|
|
22
|
+
// different copies of the same module, each with its own targetMap.
|
|
23
|
+
// When the panel reads `host.form.value` in a `computed`, the proxy's
|
|
24
|
+
// get trap runs in the host's tracking context (the function lives
|
|
25
|
+
// there) but `getCurrentEffect()` returns nothing because the panel's
|
|
26
|
+
// active effect is in a different runtime. The dependency never
|
|
27
|
+
// registers, and the computed never re-evaluates on host mutations.
|
|
28
|
+
//
|
|
29
|
+
// Workaround: a tick ref that all data-fetching computeds depend on.
|
|
30
|
+
// We bump the tick on every host event we DO have a callback for
|
|
31
|
+
// (`onFormChange` / `onSubmitSuccess` / `onReset`), plus a 250ms
|
|
32
|
+
// polling fallback for state that changes outside those events (user
|
|
33
|
+
// errors via `setFieldErrors*`, submit-lifecycle flags). The panel's
|
|
34
|
+
// own reactivity then re-evaluates everything in one pass — cheap
|
|
35
|
+
// because the underlying reads are direct property accesses.
|
|
36
|
+
const updateTick = ref(0)
|
|
37
|
+
|
|
38
|
+
const registry = computed(() => props.bridge.registry)
|
|
39
|
+
const formEntries = computed(() => {
|
|
40
|
+
// Touch the tick so the form list refreshes when new forms register
|
|
41
|
+
// (the registry's reactive Map updates wouldn't notify us otherwise).
|
|
42
|
+
void updateTick.value
|
|
43
|
+
return Array.from(registry.value.forms.entries())
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Explicit selection. `null` means "auto-pick first available form".
|
|
47
|
+
const selectedFormKey = ref<FormKey | null>(null)
|
|
48
|
+
|
|
49
|
+
const activeKey = computed<FormKey | null>(() => {
|
|
50
|
+
if (selectedFormKey.value !== null && registry.value.forms.has(selectedFormKey.value)) {
|
|
51
|
+
return selectedFormKey.value
|
|
52
|
+
}
|
|
53
|
+
return formEntries.value[0]?.[0] ?? null
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const activeForm = computed(() => {
|
|
57
|
+
const key = activeKey.value
|
|
58
|
+
return key !== null ? (registry.value.forms.get(key) ?? null) : null
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const formValueView = computed(() => {
|
|
62
|
+
void updateTick.value
|
|
63
|
+
const form = activeForm.value
|
|
64
|
+
if (form === null) return null
|
|
65
|
+
// Devtools is dev-only; render raw values. Consumers concerned about
|
|
66
|
+
// screen-share leaks should close the panel before sharing, the same
|
|
67
|
+
// way they'd hide their browser DevTools console.
|
|
68
|
+
return form.form.value
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const schemaErrorRows = computed(() => {
|
|
72
|
+
void updateTick.value
|
|
73
|
+
const form = activeForm.value
|
|
74
|
+
if (form === null) return []
|
|
75
|
+
return Array.from(form.schemaErrors.entries())
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const userErrorRows = computed(() => {
|
|
79
|
+
void updateTick.value
|
|
80
|
+
const form = activeForm.value
|
|
81
|
+
if (form === null) return []
|
|
82
|
+
return Array.from(form.userErrors.entries())
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const aggregates = computed(() => {
|
|
86
|
+
void updateTick.value
|
|
87
|
+
const form = activeForm.value
|
|
88
|
+
if (form === null) return null
|
|
89
|
+
return {
|
|
90
|
+
submitting: form.submitting.value,
|
|
91
|
+
submissionAttempts: form.submissionAttempts.value,
|
|
92
|
+
submitError: form.submitError.value,
|
|
93
|
+
activeValidations: form.activeValidations.value,
|
|
62
94
|
}
|
|
63
|
-
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
function selectForm(key: FormKey): void {
|
|
98
|
+
selectedFormKey.value = key
|
|
64
99
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Convert a canonical PathKey (e.g. `'["users",0,"name"]'`) back to a
|
|
103
|
+
* readable dotted form (`users.0.name`) for the error table. Falls
|
|
104
|
+
* back to the raw key if it's not a JSON-array shape.
|
|
105
|
+
*/
|
|
106
|
+
function humanizePathKey(key: string): string {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(key) as unknown[]
|
|
109
|
+
if (Array.isArray(parsed)) {
|
|
110
|
+
return parsed.map((seg) => String(seg)).join('.')
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// not JSON, render as-is
|
|
114
|
+
}
|
|
115
|
+
return key
|
|
73
116
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render a JS value as a debug-friendly string with no masking —
|
|
120
|
+
* `null` / `undefined` show as their literal names, booleans and
|
|
121
|
+
* numbers as-is, strings bare (no surrounding quotes), everything
|
|
122
|
+
* else JSON-stringified. Devtools is for inspecting state, not for
|
|
123
|
+
* pretty-printing it; the user-author sees the actual runtime
|
|
124
|
+
* shape.
|
|
125
|
+
*/
|
|
126
|
+
function fmt(v: unknown): string {
|
|
127
|
+
if (v === null) return 'null'
|
|
128
|
+
if (v === undefined) return 'undefined'
|
|
129
|
+
if (typeof v === 'string') return v
|
|
130
|
+
if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint') {
|
|
131
|
+
return String(v)
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return JSON.stringify(v)
|
|
135
|
+
} catch {
|
|
136
|
+
return String(v)
|
|
137
|
+
}
|
|
78
138
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Commit a leaf edit from the value tree. Mirrors the exact flow the
|
|
142
|
+
* Vue DevTools `editInspectorState` handler uses in
|
|
143
|
+
* `src/runtime/core/devtools.ts` so both surfaces have identical
|
|
144
|
+
* semantics:
|
|
145
|
+
* 1. canonicalize the structured path
|
|
146
|
+
* 2. refuse sensitive-name paths (the tree already gates the UI,
|
|
147
|
+
* but a defensive check here keeps the contract honest)
|
|
148
|
+
* 3. derive the persist flag from the path's element opt-ins
|
|
149
|
+
* 4. call setValueAtPath with the same write-meta the bound input
|
|
150
|
+
* would have produced
|
|
151
|
+
*/
|
|
152
|
+
// Field-state inspector. Click any key in the Form value tree to
|
|
153
|
+
// "select" that path; the Field state section below resolves
|
|
154
|
+
// `form.fields(path)` for it. Click the same key again to deselect.
|
|
155
|
+
const selectedPath = ref<ReadonlyArray<string | number> | null>(null)
|
|
156
|
+
const selectedKey = computed(() =>
|
|
157
|
+
selectedPath.value === null ? null : JSON.stringify(selectedPath.value)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
function selectPath(path: ReadonlyArray<string | number>): void {
|
|
161
|
+
const key = JSON.stringify(path)
|
|
162
|
+
if (selectedKey.value === key) {
|
|
163
|
+
selectedPath.value = null
|
|
164
|
+
} else {
|
|
165
|
+
selectedPath.value = path
|
|
166
|
+
}
|
|
90
167
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Raw field data at the selected path, composed from `FormStore`
|
|
171
|
+
* primitives. The callable `form.fields(path)` proxy lives on the
|
|
172
|
+
* public `useForm` return, not on `FormStore` — the bridge exposes
|
|
173
|
+
* the store, so we synthesise the same data from `fields.get(key)`
|
|
174
|
+
* + the error Maps + an inline value walk.
|
|
175
|
+
*
|
|
176
|
+
* Returns the raw `FieldRecord` (updatedAt / focused / blurred /
|
|
177
|
+
* touched / connected) rather than the wrapped `FieldState`
|
|
178
|
+
* surface. Sufficient for inspection — the full aggregated
|
|
179
|
+
* FieldState would require either lifting the surface-proxy into
|
|
180
|
+
* `FormStore` (architectural change) or rebuilding the aggregation
|
|
181
|
+
* walker in the panel (duplication).
|
|
182
|
+
*/
|
|
183
|
+
const selectedFieldState = computed<{
|
|
184
|
+
record: {
|
|
185
|
+
updatedAt: string | null
|
|
186
|
+
connected: boolean
|
|
187
|
+
focused: boolean | null
|
|
188
|
+
blurred: boolean | null
|
|
189
|
+
touched: boolean | null
|
|
190
|
+
} | null
|
|
191
|
+
value: unknown
|
|
192
|
+
errors: ReadonlyArray<{ message: string; code: string }>
|
|
193
|
+
schemaErrorCount: number
|
|
194
|
+
userErrorCount: number
|
|
195
|
+
} | null>(() => {
|
|
196
|
+
void updateTick.value
|
|
197
|
+
const form = activeForm.value
|
|
198
|
+
const path = selectedPath.value
|
|
199
|
+
if (form === null || path === null) return null
|
|
200
|
+
try {
|
|
201
|
+
const { key: canonicalKey } = canonicalizePath(path as readonly Segment[])
|
|
202
|
+
const record =
|
|
203
|
+
(form.fields.get(canonicalKey) as
|
|
204
|
+
| {
|
|
205
|
+
updatedAt: string | null
|
|
206
|
+
connected: boolean
|
|
207
|
+
focused: boolean | null
|
|
208
|
+
blurred: boolean | null
|
|
209
|
+
touched: boolean | null
|
|
210
|
+
}
|
|
211
|
+
| undefined) ?? null
|
|
212
|
+
|
|
213
|
+
// Inline path walk (avoids importing path-walker through the
|
|
214
|
+
// bridge — it lives in the host's shared chunk).
|
|
215
|
+
let value: unknown = form.form.value
|
|
216
|
+
for (const seg of path) {
|
|
217
|
+
if (value === null || typeof value !== 'object') {
|
|
218
|
+
value = undefined
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
value = (value as Record<string | number, unknown>)[seg]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const schemaEntries =
|
|
225
|
+
(form.schemaErrors.get(canonicalKey) as
|
|
226
|
+
| ReadonlyArray<{ message: string; code: string }>
|
|
227
|
+
| undefined) ?? []
|
|
228
|
+
const userEntries =
|
|
229
|
+
(form.userErrors.get(canonicalKey) as
|
|
230
|
+
| ReadonlyArray<{ message: string; code: string }>
|
|
231
|
+
| undefined) ?? []
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
record,
|
|
235
|
+
value,
|
|
236
|
+
errors: [...schemaEntries, ...userEntries],
|
|
237
|
+
schemaErrorCount: schemaEntries.length,
|
|
238
|
+
userErrorCount: userEntries.length,
|
|
105
239
|
}
|
|
106
|
-
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error('[attaform devtools] field-state lookup failed', { path, err })
|
|
242
|
+
return null
|
|
107
243
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
schemaErrorCount: schemaEntries.length,
|
|
115
|
-
userErrorCount: userEntries.length
|
|
116
|
-
};
|
|
117
|
-
} catch (err) {
|
|
118
|
-
console.error("[attaform devtools] field-state lookup failed", { path, err });
|
|
119
|
-
return null;
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
function humanizeSelectedPath(): string {
|
|
247
|
+
const path = selectedPath.value
|
|
248
|
+
if (path === null || path.length === 0) return '(root)'
|
|
249
|
+
return path.map((seg) => String(seg)).join('.')
|
|
120
250
|
}
|
|
121
|
-
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
function selectedValueView() {
|
|
128
|
-
const fs = selectedFieldState.value;
|
|
129
|
-
if (fs === null) return null;
|
|
130
|
-
return fs.value;
|
|
131
|
-
}
|
|
132
|
-
function handleEdit(rawPath, next) {
|
|
133
|
-
const form = activeForm.value;
|
|
134
|
-
if (form === null) return;
|
|
135
|
-
try {
|
|
136
|
-
const { segments: canonicalPath, key: canonicalKey } = canonicalizePath(
|
|
137
|
-
rawPath
|
|
138
|
-
);
|
|
139
|
-
form.setValueAtPath(canonicalPath, next, {
|
|
140
|
-
persist: form.persistOptIns.hasAnyOptInForPath(canonicalKey)
|
|
141
|
-
});
|
|
142
|
-
} catch (err) {
|
|
143
|
-
console.error("[attaform devtools] edit failed", { rawPath, next, err });
|
|
251
|
+
|
|
252
|
+
function selectedValueView(): unknown {
|
|
253
|
+
const fs = selectedFieldState.value
|
|
254
|
+
if (fs === null) return null
|
|
255
|
+
return fs.value
|
|
144
256
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
let eventIdCounter = 0;
|
|
150
|
-
function pushEvent(seed) {
|
|
151
|
-
const entry = { id: ++eventIdCounter, ...seed };
|
|
152
|
-
const next = [entry, ...events.value];
|
|
153
|
-
if (next.length > MAX_TIMELINE_EVENTS) next.length = MAX_TIMELINE_EVENTS;
|
|
154
|
-
events.value = next;
|
|
155
|
-
}
|
|
156
|
-
function clearTimeline() {
|
|
157
|
-
events.value = [];
|
|
158
|
-
expandedEventId.value = null;
|
|
159
|
-
}
|
|
160
|
-
function toggleEvent(id) {
|
|
161
|
-
expandedEventId.value = expandedEventId.value === id ? null : id;
|
|
162
|
-
}
|
|
163
|
-
function formatTime(time) {
|
|
164
|
-
const d = new Date(time);
|
|
165
|
-
const hh = String(d.getHours()).padStart(2, "0");
|
|
166
|
-
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
167
|
-
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
168
|
-
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
|
169
|
-
return `${hh}:${mm}:${ss}.${ms}`;
|
|
170
|
-
}
|
|
171
|
-
const subscribers = /* @__PURE__ */ new Map();
|
|
172
|
-
function subscribeForm(key, form) {
|
|
173
|
-
if (subscribers.has(key)) return;
|
|
174
|
-
const captureValue = () => {
|
|
257
|
+
|
|
258
|
+
function handleEdit(rawPath: ReadonlyArray<string | number>, next: unknown): void {
|
|
259
|
+
const form = activeForm.value
|
|
260
|
+
if (form === null) return
|
|
175
261
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
});
|
|
193
|
-
subscribers.set(key, () => {
|
|
194
|
-
unsubChange();
|
|
195
|
-
unsubSubmit();
|
|
196
|
-
unsubReset();
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
watch(
|
|
200
|
-
formEntries,
|
|
201
|
-
(entries) => {
|
|
202
|
-
const liveKeys = new Set(entries.map(([k]) => k));
|
|
203
|
-
for (const [key, form] of entries) {
|
|
204
|
-
if (!subscribers.has(key)) subscribeForm(key, form);
|
|
262
|
+
const { segments: canonicalPath, key: canonicalKey } = canonicalizePath(
|
|
263
|
+
rawPath as readonly Segment[]
|
|
264
|
+
)
|
|
265
|
+
form.setValueAtPath(canonicalPath, next, {
|
|
266
|
+
persist: form.persistOptIns.hasAnyOptInForPath(canonicalKey),
|
|
267
|
+
})
|
|
268
|
+
// The host's setValueAtPath fires `onFormChange` listeners, which
|
|
269
|
+
// bumps our updateTick (see subscribeForm) — that refreshes the
|
|
270
|
+
// panel's view of the new value on the next microtask.
|
|
271
|
+
} catch (err) {
|
|
272
|
+
// Surface cross-iframe write failures (e.g., type-instance checks
|
|
273
|
+
// tripping over panel-vs-host Array constructors) so we don't
|
|
274
|
+
// silently swallow them. Devtools-only path, so console.error is
|
|
275
|
+
// appropriate.
|
|
276
|
+
|
|
277
|
+
console.error('[attaform devtools] edit failed', { rawPath, next, err })
|
|
205
278
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Timeline log ----------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
type TimelineEventType = 'form.change' | 'submit.success' | 'reset'
|
|
284
|
+
|
|
285
|
+
interface TimelineEvent {
|
|
286
|
+
id: number
|
|
287
|
+
type: TimelineEventType
|
|
288
|
+
formKey: FormKey
|
|
289
|
+
time: number
|
|
290
|
+
value: unknown
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Hard cap on the in-memory event log. Sized for a debugging session,
|
|
295
|
+
* not an audit log — older events fall off the back when capacity
|
|
296
|
+
* fills. Tunable later if real consumers ask for more.
|
|
297
|
+
*/
|
|
298
|
+
const MAX_TIMELINE_EVENTS = 200
|
|
299
|
+
const events = ref<TimelineEvent[]>([])
|
|
300
|
+
const expandedEventId = ref<number | null>(null)
|
|
301
|
+
let eventIdCounter = 0
|
|
302
|
+
|
|
303
|
+
function pushEvent(seed: Omit<TimelineEvent, 'id'>): void {
|
|
304
|
+
const entry: TimelineEvent = { id: ++eventIdCounter, ...seed }
|
|
305
|
+
const next = [entry, ...events.value]
|
|
306
|
+
if (next.length > MAX_TIMELINE_EVENTS) next.length = MAX_TIMELINE_EVENTS
|
|
307
|
+
events.value = next
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function clearTimeline(): void {
|
|
311
|
+
events.value = []
|
|
312
|
+
expandedEventId.value = null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function toggleEvent(id: number): void {
|
|
316
|
+
expandedEventId.value = expandedEventId.value === id ? null : id
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function formatTime(time: number): string {
|
|
320
|
+
const d = new Date(time)
|
|
321
|
+
const hh = String(d.getHours()).padStart(2, '0')
|
|
322
|
+
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
323
|
+
const ss = String(d.getSeconds()).padStart(2, '0')
|
|
324
|
+
const ms = String(d.getMilliseconds()).padStart(3, '0')
|
|
325
|
+
return `${hh}:${mm}:${ss}.${ms}`
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Per-form subscriber bookkeeping. Re-scoped whenever the registry's
|
|
329
|
+
// form set changes so newly-registered forms wire up automatically and
|
|
330
|
+
// evicted forms drop their subscribers (no leaks across HMR reloads).
|
|
331
|
+
const subscribers = new Map<FormKey, () => void>()
|
|
332
|
+
|
|
333
|
+
function subscribeForm(key: FormKey, form: FormStore<GenericForm>): void {
|
|
334
|
+
if (subscribers.has(key)) return
|
|
335
|
+
// Deep-clone the form value at the moment of fire — FormStore
|
|
336
|
+
// mutates form data in place, so a stored reference would update
|
|
337
|
+
// every existing timeline entry whenever the form changed again
|
|
338
|
+
// ("type a, delete a, both timeline events show the empty value"
|
|
339
|
+
// bug). `structuredClone` also crosses the iframe-realm boundary,
|
|
340
|
+
// landing a panel-native plain object instead of the host's
|
|
341
|
+
// reactive proxy.
|
|
342
|
+
const captureValue = (): unknown => {
|
|
343
|
+
try {
|
|
344
|
+
return structuredClone(form.form.value)
|
|
345
|
+
} catch {
|
|
346
|
+
// Non-cloneable values (functions, Symbols, Vue refs, etc.) —
|
|
347
|
+
// fall back to the live reference. Worst case: that one entry
|
|
348
|
+
// still shows the current state, same as before this fix.
|
|
349
|
+
return form.form.value
|
|
210
350
|
}
|
|
211
351
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
})
|
|
352
|
+
const unsubChange = form.onFormChange(() => {
|
|
353
|
+
pushEvent({ type: 'form.change', formKey: key, time: Date.now(), value: captureValue() })
|
|
354
|
+
updateTick.value++
|
|
355
|
+
})
|
|
356
|
+
const unsubSubmit = form.onSubmitSuccess(() => {
|
|
357
|
+
pushEvent({ type: 'submit.success', formKey: key, time: Date.now(), value: captureValue() })
|
|
358
|
+
updateTick.value++
|
|
359
|
+
})
|
|
360
|
+
const unsubReset = form.onReset(() => {
|
|
361
|
+
pushEvent({ type: 'reset', formKey: key, time: Date.now(), value: captureValue() })
|
|
362
|
+
updateTick.value++
|
|
363
|
+
})
|
|
364
|
+
subscribers.set(key, () => {
|
|
365
|
+
unsubChange()
|
|
366
|
+
unsubSubmit()
|
|
367
|
+
unsubReset()
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
watch(
|
|
372
|
+
formEntries,
|
|
373
|
+
(entries) => {
|
|
374
|
+
const liveKeys = new Set(entries.map(([k]) => k))
|
|
375
|
+
for (const [key, form] of entries) {
|
|
376
|
+
if (!subscribers.has(key)) subscribeForm(key, form as FormStore<GenericForm>)
|
|
377
|
+
}
|
|
378
|
+
for (const [key, unsub] of subscribers) {
|
|
379
|
+
if (!liveKeys.has(key)) {
|
|
380
|
+
unsub()
|
|
381
|
+
subscribers.delete(key)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
{ immediate: true }
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
// Polling fallback for state that changes outside the `onFormChange` /
|
|
389
|
+
// `onSubmitSuccess` / `onReset` event surface — user errors injected
|
|
390
|
+
// via `setFieldErrors*`, submit-lifecycle flags between events, or
|
|
391
|
+
// new forms registered in the host's registry. 120ms is faster than
|
|
392
|
+
// a human can notice between an input event and a visible panel
|
|
393
|
+
// refresh; gas-cost is negligible (one ref-bump every 120ms).
|
|
394
|
+
const POLL_INTERVAL_MS = 120
|
|
395
|
+
const pollHandle = window.setInterval(() => {
|
|
396
|
+
updateTick.value++
|
|
397
|
+
}, POLL_INTERVAL_MS)
|
|
398
|
+
|
|
399
|
+
onUnmounted(() => {
|
|
400
|
+
window.clearInterval(pollHandle)
|
|
401
|
+
for (const unsub of subscribers.values()) unsub()
|
|
402
|
+
subscribers.clear()
|
|
403
|
+
})
|
|
224
404
|
</script>
|
|
225
405
|
|
|
226
406
|
<template>
|
|
227
|
-
|
|
407
|
+
<div class="atf-panel">
|
|
228
408
|
<header class="atf-header">
|
|
229
409
|
<div class="atf-brand">
|
|
230
410
|
<svg
|
|
@@ -401,7 +581,7 @@ onUnmounted(() => {
|
|
|
401
581
|
<h2 class="atf-section-title">
|
|
402
582
|
Timeline
|
|
403
583
|
<span v-if="events.length" class="atf-badge atf-badge-neutral">
|
|
404
|
-
{{ events.length }}{{ events.length === MAX_TIMELINE_EVENTS ?
|
|
584
|
+
{{ events.length }}{{ events.length === MAX_TIMELINE_EVENTS ? '+' : '' }}
|
|
405
585
|
</span>
|
|
406
586
|
<button
|
|
407
587
|
v-if="events.length"
|
|
@@ -423,16 +603,16 @@ onUnmounted(() => {
|
|
|
423
603
|
:key="event.id"
|
|
424
604
|
class="atf-timeline-entry"
|
|
425
605
|
:class="[
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
]"
|
|
606
|
+
`atf-timeline-${event.type.split('.')[0]}`,
|
|
607
|
+
{ expanded: expandedEventId === event.id },
|
|
608
|
+
]"
|
|
429
609
|
>
|
|
430
610
|
<div class="atf-timeline-row" @click="toggleEvent(event.id)">
|
|
431
611
|
<span class="atf-timeline-time">{{ formatTime(event.time) }}</span>
|
|
432
612
|
<span class="atf-timeline-type">{{ event.type }}</span>
|
|
433
613
|
<span class="atf-timeline-form">{{ event.formKey }}</span>
|
|
434
614
|
<span class="atf-timeline-caret">
|
|
435
|
-
{{ expandedEventId === event.id ?
|
|
615
|
+
{{ expandedEventId === event.id ? '−' : '+' }}
|
|
436
616
|
</span>
|
|
437
617
|
</div>
|
|
438
618
|
<div v-if="expandedEventId === event.id" class="atf-timeline-detail">
|