webspresso 0.0.108 → 0.0.110
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 +21 -0
- package/README.md +3 -3
- package/bin/commands/add-esbuild.js +7 -3
- package/bin/commands/add-tailwind.js +9 -5
- package/bin/commands/build.js +20 -4
- package/bin/commands/dev.js +57 -7
- package/bin/commands/doctor.js +42 -1
- package/bin/commands/favicon-generate.js +10 -3
- package/bin/commands/new.js +8 -3
- package/bin/commands/start.js +3 -1
- package/bin/utils/add-command.js +16 -0
- package/core/app.js +45 -3
- package/core/auth/authenticate.js +3 -3
- package/core/auth/hash.js +167 -43
- package/core/auth/index.js +13 -1
- package/core/auth/jwt.js +21 -2
- package/core/auth/manager.js +63 -14
- package/core/auth/middleware.js +4 -2
- package/core/auth/oauth.js +5 -1
- package/core/auth/tokens.js +7 -2
- package/core/auth/totp.js +42 -6
- package/core/auth/user-agent.js +2 -0
- package/core/client/adapter/datastar.js +1401 -0
- package/core/client/api.js +1337 -0
- package/core/client/controller.js +1706 -0
- package/core/client/debug.js +367 -0
- package/core/client/devtool.js +1134 -0
- package/core/client/forms.js +1164 -0
- package/core/client/fragments.js +894 -0
- package/core/client/index.d.ts +59 -0
- package/core/client/index.js +391 -0
- package/core/client/lite.js +175 -0
- package/core/client/server-fragments.js +868 -0
- package/core/client/storage.js +339 -0
- package/core/client/utils.js +281 -0
- package/core/compression/middleware.js +277 -26
- package/core/context/app-context.js +25 -3
- package/core/context/request.js +32 -4
- package/core/errors/middleware.js +14 -0
- package/core/events/dispatcher.js +33 -0
- package/core/forms/flash.js +136 -102
- package/core/health/middleware.js +36 -6
- package/core/http/pipeline.js +45 -6
- package/core/index.js +25 -7
- package/core/kernel/view.js +41 -10
- package/core/metrics/middleware.js +30 -6
- package/core/metrics/registry.js +13 -5
- package/core/orm/cache/fingerprint.js +32 -11
- package/core/orm/cache/layer.js +19 -2
- package/core/orm/hidden.js +145 -0
- package/core/orm/model.js +48 -2
- package/core/orm/query-builder.js +343 -6
- package/core/orm/query-filters.js +14 -2
- package/core/orm/repository.js +64 -13
- package/core/orm/schema-helpers.js +9 -6
- package/core/orm/transaction.js +50 -6
- package/core/orm/types.js +4 -0
- package/core/queue/adapters/database.js +184 -2
- package/core/queue/adapters/memory.js +153 -5
- package/core/queue/adapters/redis.js +59 -4
- package/core/queue/job.js +14 -1
- package/core/queue/manager.js +115 -0
- package/core/resilience/circuit-breaker.js +27 -8
- package/core/services/builtins/exchange.js +174 -7
- package/core/services/builtins/file-manager.js +36 -0
- package/core/services/builtins/mail.js +27 -0
- package/core/services/builtins/queue.js +144 -0
- package/core/services/builtins/security.js +118 -0
- package/core/services/builtins/webhook.js +110 -0
- package/core/services/executor.js +38 -16
- package/core/services/index.js +4 -1
- package/core/sse/stream.js +28 -5
- package/core/utils/timeout.js +8 -1
- package/core/validation/compiler.js +10 -7
- package/index.d.ts +1320 -74
- package/index.js +128 -8
- package/package.json +75 -27
- package/plugins/admin-panel/client/parts/08-record-list.js +717 -70
- package/plugins/admin-panel/index.js +19 -8
- package/plugins/csrf/index.js +3 -3
- package/plugins/data-exchange/export.js +214 -0
- package/plugins/data-exchange/import.js +48 -5
- package/plugins/data-exchange/index.js +70 -28
- package/plugins/data-exchange/preview.js +266 -0
- package/plugins/data-exchange/template.js +242 -0
- package/plugins/email/admin-component.js +783 -141
- package/plugins/email/api-handlers.js +51 -1
- package/plugins/email/index.js +5 -0
- package/plugins/email/service.js +48 -0
- package/plugins/file-manager/admin-component.js +1546 -70
- package/plugins/file-manager/api-handlers.js +166 -1
- package/plugins/file-manager/core.js +598 -8
- package/plugins/file-manager/index.js +8 -0
- package/plugins/index.js +7 -0
- package/plugins/mcp/auth.js +3 -3
- package/plugins/oauth/index.js +36 -3
- package/plugins/queue/admin-component.js +1092 -0
- package/plugins/queue/api-handlers.js +248 -0
- package/plugins/queue/index.js +69 -0
- package/plugins/rest-resources/index.js +7 -2
- package/plugins/security/admin-component.js +689 -0
- package/plugins/security/api-handlers.js +169 -0
- package/plugins/security/audit.js +153 -0
- package/plugins/security/bruteforce.js +244 -0
- package/plugins/security/firewall.js +459 -0
- package/plugins/security/headers.js +214 -0
- package/plugins/security/honeypot.js +234 -0
- package/plugins/security/incident-recorder.js +137 -0
- package/plugins/security/index.js +211 -0
- package/plugins/seo-checker/analyzer.js +20 -7
- package/plugins/upload/index.js +36 -0
- package/plugins/webhooks/admin-component.js +702 -175
- package/plugins/webhooks/api-handlers.js +75 -1
- package/plugins/webhooks/dispatcher.js +290 -11
- package/plugins/webhooks/index.js +37 -2
- package/plugins/webhooks/sender.js +56 -0
- package/plugins/xlsx/engine.js +19 -4
- package/src/api/api-loader.js +19 -1
- package/src/bundle/index.js +443 -17
- package/src/client-runtime/bootstrap-swup.js +9 -0
- package/src/client-runtime/mount.js +15 -20
- package/src/client-runtime/resolve.js +3 -2
- package/src/dev/api-typegen.js +1300 -0
- package/src/dev/banner.js +10 -1
- package/src/dev/colors.js +1 -1
- package/src/dev/index.js +7 -1
- package/src/dev/typegen.js +173 -17
- package/src/dev-reload/index.js +1 -1
- package/src/discovery/file-route-parser.js +17 -3
- package/src/discovery/scan-routes.js +4 -1
- package/src/file-router.js +253 -69
- package/src/helpers.js +46 -26
- package/src/njk-frontmatter.js +30 -4
- package/src/routing/cascading.js +26 -8
- package/src/routing/mount-discovered-routes.js +76 -0
- package/src/server.js +231 -70
- package/views/partials/webspresso-client-runtime.njk +1 -9
- package/.agents/ADMIN.md +0 -426
- package/.agents/AGENTS.md +0 -96
- package/.agents/ARCHITECTURE.md +0 -163
- package/.agents/AUTH.md +0 -581
- package/.agents/CACHE.md +0 -148
- package/.agents/CLI.md +0 -649
- package/.agents/COMPRESSION.md +0 -59
- package/.agents/CONTEXT.md +0 -152
- package/.agents/CONVENTIONS.md +0 -50
- package/.agents/EARLY_HINTS.md +0 -120
- package/.agents/EMAIL.md +0 -223
- package/.agents/ERRORS.md +0 -260
- package/.agents/EVENTS.md +0 -179
- package/.agents/FLAGS.md +0 -118
- package/.agents/HEALTH.md +0 -118
- package/.agents/LIMITS.md +0 -109
- package/.agents/LOGGING.md +0 -93
- package/.agents/MCP.md +0 -103
- package/.agents/METRICS.md +0 -127
- package/.agents/MIGRATION_JOBS.md +0 -95
- package/.agents/ORM.md +0 -700
- package/.agents/PLUGINS.md +0 -729
- package/.agents/QUEUE.md +0 -290
- package/.agents/RATE_LIMIT.md +0 -147
- package/.agents/REALTIME.md +0 -213
- package/.agents/RESILIENCE.md +0 -235
- package/.agents/ROUTING.md +0 -605
- package/.agents/SCALE_OUT.md +0 -109
- package/.agents/SECRETS.md +0 -117
- package/.agents/SERVICES.md +0 -341
- package/.agents/SHUTDOWN.md +0 -74
- package/.agents/STREAMING.md +0 -108
- package/.agents/WORKFLOW.md +0 -27
- package/.agents/internal/DOCUMENTATION_REVIEW.md +0 -485
- package/.agents/internal/TECHNICAL_REVIEW.md +0 -428
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +0 -122
- package/.agents/skills/webspresso-framework/SKILL.md +0 -89
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +0 -127
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +0 -130
- package/.agents/skills/webspresso-services/SKILL.md +0 -134
- package/.cursorrules +0 -14
- package/AGENTS.md +0 -203
- package/CLAUDE.md +0 -54
- package/core/kernel/run-demo.js +0 -58
- package/plugins/admin-panel/styles.js +0 -9
- package/plugins/data-exchange/export-xlsx.js +0 -119
- package/src/client-runtime/bootstrap-alpine-swup.js +0 -34
|
@@ -0,0 +1,1164 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { defaultStorage } = require('./storage');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Webspresso Native HTML Form Layer & Typed Form Handle
|
|
7
|
+
*
|
|
8
|
+
* Principles:
|
|
9
|
+
* - Built on native HTML <form action="/api/..." method="post" data-w-form="name"> semantics
|
|
10
|
+
* - Zero client JS required for standard form submission, validation error display, and fragment invalidation
|
|
11
|
+
* - Explicit, deterministic form-to-payload conversion rules
|
|
12
|
+
* - Starts with native constraint validation (`form.reportValidity()`); server validation remains authoritative
|
|
13
|
+
* - Accessible field error binding (`aria-invalid`, `aria-describedby`) and focus on first invalid field
|
|
14
|
+
* - Separation of `reset()` (restores initial default values) vs `clear()` (empties all controls)
|
|
15
|
+
* - Mutation success vs refresh failure separation: a refresh failure never puts a succeeded form into validation error state
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validate that a redirect URL uses a safe protocol (http/https) or relative path
|
|
20
|
+
* to prevent DOM-based XSS (e.g. javascript: URIs) and protocol injection.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} target
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
function isSafeRedirectUrl(target) {
|
|
26
|
+
if (!target || typeof target !== 'string') return false;
|
|
27
|
+
const trimmed = target.trim();
|
|
28
|
+
if (!trimmed) return false;
|
|
29
|
+
|
|
30
|
+
// Block control characters
|
|
31
|
+
if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return false;
|
|
32
|
+
|
|
33
|
+
// Block protocol-relative URLs (//example.com) and backslash traversal (/\ or \\)
|
|
34
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('/\\') || trimmed.startsWith('\\')) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Relative path starting with single slash (e.g. /dashboard)
|
|
39
|
+
if (trimmed.startsWith('/')) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Absolute URL: MUST explicitly parse as http: or https:
|
|
44
|
+
try {
|
|
45
|
+
const parsed = new URL(trimmed);
|
|
46
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let errorIdSeq = 0;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a form control is disabled (either directly or via an ancestor <fieldset disabled>).
|
|
56
|
+
* Note: Per HTML spec, controls inside the first <legend> of a disabled <fieldset> remain enabled.
|
|
57
|
+
*
|
|
58
|
+
* @param {Element} el
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function isControlDisabled(el) {
|
|
62
|
+
if (!el) return true;
|
|
63
|
+
if (el.disabled) return true;
|
|
64
|
+
let parent = el.parentElement;
|
|
65
|
+
while (parent) {
|
|
66
|
+
if (parent.tagName === 'FIELDSET' && parent.disabled) {
|
|
67
|
+
// Check if el is inside the first <legend> child of this fieldset
|
|
68
|
+
let firstLegend = null;
|
|
69
|
+
for (let i = 0; i < parent.children.length; i++) {
|
|
70
|
+
if (parent.children[i].tagName === 'LEGEND') {
|
|
71
|
+
firstLegend = parent.children[i];
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!firstLegend || !firstLegend.contains(el)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
parent = parent.parentElement;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Coerce a single raw string value according to control type and `data-w-*` hints.
|
|
86
|
+
*
|
|
87
|
+
* @param {Element} el
|
|
88
|
+
* @param {string} rawValue
|
|
89
|
+
* @returns {unknown}
|
|
90
|
+
*/
|
|
91
|
+
function coerceScalarControlValue(el, rawValue) {
|
|
92
|
+
const inputType = ((el.getAttribute && el.getAttribute('type')) || el.type || 'text').toLowerCase();
|
|
93
|
+
const dataType = ((el.getAttribute && el.getAttribute('data-w-type')) || '').toLowerCase();
|
|
94
|
+
const emptyMode = ((el.getAttribute && el.getAttribute('data-w-empty')) || '').toLowerCase();
|
|
95
|
+
|
|
96
|
+
if (rawValue === '') {
|
|
97
|
+
if (emptyMode === 'null') return null;
|
|
98
|
+
if (emptyMode === 'undefined' || emptyMode === 'omit') return undefined;
|
|
99
|
+
if (inputType === 'number' || inputType === 'range' || dataType === 'number') {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (inputType === 'number' || inputType === 'range' || dataType === 'number') {
|
|
106
|
+
const num = Number(rawValue);
|
|
107
|
+
return Number.isNaN(num) ? rawValue : num;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (dataType === 'boolean') {
|
|
111
|
+
if (rawValue === 'true' || rawValue === '1' || rawValue === 'on') return true;
|
|
112
|
+
if (rawValue === 'false' || rawValue === '0' || rawValue === 'off') return false;
|
|
113
|
+
return Boolean(rawValue);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return rawValue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse a `<form>` element into a structured payload and transport descriptor.
|
|
121
|
+
*
|
|
122
|
+
* Explicit Coercion Rules:
|
|
123
|
+
* 1. `disabled` controls (including inside disabled `<fieldset>`) are excluded.
|
|
124
|
+
* 2. `submitter`: if a submit button with `name` triggered the submit and is not disabled, its `name`/`value` is included.
|
|
125
|
+
* 3. `checkbox`:
|
|
126
|
+
* - Single checkbox for a given `name`:
|
|
127
|
+
* - Boolean checkbox (no custom `value` attribute, or `value="true"|"on"`, or `data-w-type="boolean"`): `true` when checked, `false` when unchecked.
|
|
128
|
+
* - Custom value checkbox (e.g. `value="yes"`): `"yes"` when checked, `null` (or `data-w-unchecked` value) when unchecked.
|
|
129
|
+
* - Multiple checkboxes sharing the same `name` (or `name="tags[]"`): `Array` of checked values (`[]` when none checked).
|
|
130
|
+
* 4. `number` / `range`: coerced to `number` (`null` when empty, or `undefined` with `data-w-empty="undefined"`).
|
|
131
|
+
* 5. `empty string`: preserved as `""` for text controls unless `data-w-empty="null|undefined"` is specified.
|
|
132
|
+
* 6. `repeated field names`: collected into an `Array` in DOM order. Trailing `[]` in `name="tags[]"` is normalized to `tags`.
|
|
133
|
+
* 7. `select[multiple]`: always returns an `Array` of selected option values (`[]` when none selected).
|
|
134
|
+
* 8. `file`:
|
|
135
|
+
* - Empty file input (`files.length === 0` or single 0-byte unnamed file): omitted (`undefined`).
|
|
136
|
+
* - `input[ type="file" multiple ]`: `File[]`.
|
|
137
|
+
* - Single `input[type="file"]`: `File`.
|
|
138
|
+
* - When any non-empty `File` is present or `form.enctype === 'multipart/form-data'`, `isMultipart` is `true` and `formData` is populated.
|
|
139
|
+
*
|
|
140
|
+
* @param {HTMLFormElement} formEl
|
|
141
|
+
* @param {HTMLElement|null} [submitter]
|
|
142
|
+
* @returns {{ data: Record<string, unknown>, formData: FormData|null, isMultipart: boolean }}
|
|
143
|
+
*/
|
|
144
|
+
function parseFormElement(formEl, submitterOrOptions = null) {
|
|
145
|
+
if (!formEl || typeof formEl.querySelectorAll !== 'function') {
|
|
146
|
+
return { data: {}, values: {}, formData: null, isMultipart: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const submitter =
|
|
150
|
+
submitterOrOptions &&
|
|
151
|
+
typeof submitterOrOptions === 'object' &&
|
|
152
|
+
!submitterOrOptions.nodeType &&
|
|
153
|
+
!('tagName' in submitterOrOptions) &&
|
|
154
|
+
'submitter' in submitterOrOptions
|
|
155
|
+
? submitterOrOptions.submitter
|
|
156
|
+
: submitterOrOptions;
|
|
157
|
+
|
|
158
|
+
const rawControls = Array.from(
|
|
159
|
+
formEl.querySelectorAll('input, select, textarea')
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Filter out disabled controls and button/submit/reset inputs (handled via submitter)
|
|
163
|
+
const controls = rawControls.filter((el) => {
|
|
164
|
+
if (isControlDisabled(el)) return false;
|
|
165
|
+
const rawName = el.getAttribute('name');
|
|
166
|
+
if (!rawName || !rawName.trim()) return false;
|
|
167
|
+
const type = (el.type || '').toLowerCase();
|
|
168
|
+
if (type === 'submit' || type === 'button' || type === 'reset' || type === 'image') {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Count occurrences per normalized field name and per checkbox/radio group
|
|
175
|
+
const nameCounts = new Map();
|
|
176
|
+
const checkboxCounts = new Map();
|
|
177
|
+
const radioNames = new Set();
|
|
178
|
+
|
|
179
|
+
function normalizeFieldName(rawName) {
|
|
180
|
+
const trimmed = String(rawName || '').trim();
|
|
181
|
+
return trimmed.endsWith('[]') ? trimmed.slice(0, -2) : trimmed;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const el of controls) {
|
|
185
|
+
const rawName = el.getAttribute('name').trim();
|
|
186
|
+
const normName = normalizeFieldName(rawName);
|
|
187
|
+
const isExplicitArray = rawName.endsWith('[]');
|
|
188
|
+
const type = (el.type || '').toLowerCase();
|
|
189
|
+
|
|
190
|
+
if (type === 'checkbox') {
|
|
191
|
+
checkboxCounts.set(normName, (checkboxCounts.get(normName) || 0) + (isExplicitArray ? 2 : 1));
|
|
192
|
+
} else if (type === 'radio') {
|
|
193
|
+
radioNames.add(normName);
|
|
194
|
+
} else {
|
|
195
|
+
nameCounts.set(normName, (nameCounts.get(normName) || 0) + (isExplicitArray ? 2 : 1));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const data = {};
|
|
200
|
+
let hasBinaryFile = false;
|
|
201
|
+
|
|
202
|
+
function appendValue(normName, val, forceArray) {
|
|
203
|
+
if (val === undefined) return;
|
|
204
|
+
if (forceArray) {
|
|
205
|
+
if (!Array.isArray(data[normName])) {
|
|
206
|
+
data[normName] = [];
|
|
207
|
+
}
|
|
208
|
+
data[normName].push(val);
|
|
209
|
+
} else if (normName in data) {
|
|
210
|
+
if (!Array.isArray(data[normName])) {
|
|
211
|
+
data[normName] = [data[normName]];
|
|
212
|
+
}
|
|
213
|
+
data[normName].push(val);
|
|
214
|
+
} else {
|
|
215
|
+
data[normName] = val;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Initialize multi-checkbox groups to [] and radio groups to null if none checked
|
|
220
|
+
for (const [normName, count] of checkboxCounts.entries()) {
|
|
221
|
+
if (count > 1) {
|
|
222
|
+
data[normName] = [];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
for (const normName of radioNames) {
|
|
226
|
+
if (!(normName in data)) {
|
|
227
|
+
data[normName] = null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const el of controls) {
|
|
232
|
+
const rawName = el.getAttribute('name').trim();
|
|
233
|
+
const normName = normalizeFieldName(rawName);
|
|
234
|
+
const isExplicitArray = rawName.endsWith('[]');
|
|
235
|
+
const tag = el.tagName;
|
|
236
|
+
const type = (el.type || '').toLowerCase();
|
|
237
|
+
|
|
238
|
+
if (tag === 'SELECT' && el.multiple) {
|
|
239
|
+
const selectedValues = [];
|
|
240
|
+
const options = Array.from(el.options || []);
|
|
241
|
+
for (const opt of options) {
|
|
242
|
+
if (opt.selected && !opt.disabled) {
|
|
243
|
+
selectedValues.push(coerceScalarControlValue(el, opt.value));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
data[normName] = selectedValues;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (type === 'checkbox') {
|
|
251
|
+
const totalCheckboxes = checkboxCounts.get(normName) || 1;
|
|
252
|
+
if (totalCheckboxes > 1 || isExplicitArray) {
|
|
253
|
+
if (el.checked) {
|
|
254
|
+
const valAttr = el.getAttribute('value');
|
|
255
|
+
const rawVal = valAttr !== null ? valAttr : 'on';
|
|
256
|
+
data[normName].push(coerceScalarControlValue(el, rawVal));
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
const valAttr = el.getAttribute('value');
|
|
260
|
+
const dataType = (el.getAttribute('data-w-type') || '').toLowerCase();
|
|
261
|
+
const isBooleanCheckbox =
|
|
262
|
+
dataType === 'boolean' ||
|
|
263
|
+
valAttr === null ||
|
|
264
|
+
valAttr === '' ||
|
|
265
|
+
valAttr === 'on' ||
|
|
266
|
+
valAttr === 'true';
|
|
267
|
+
|
|
268
|
+
if (isBooleanCheckbox) {
|
|
269
|
+
data[normName] = Boolean(el.checked);
|
|
270
|
+
} else if (el.checked) {
|
|
271
|
+
data[normName] = coerceScalarControlValue(el, valAttr);
|
|
272
|
+
} else {
|
|
273
|
+
const uncheckedAttr = el.getAttribute('data-w-unchecked');
|
|
274
|
+
data[normName] = uncheckedAttr !== null ? uncheckedAttr : null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (type === 'radio') {
|
|
281
|
+
if (el.checked) {
|
|
282
|
+
data[normName] = coerceScalarControlValue(el, el.value);
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (type === 'file') {
|
|
288
|
+
const files = el.files ? Array.from(el.files).filter((f) => f && (f.size > 0 || f.name)) : [];
|
|
289
|
+
if (files.length > 0) {
|
|
290
|
+
hasBinaryFile = true;
|
|
291
|
+
if (el.multiple || isExplicitArray || (nameCounts.get(normName) || 0) > 1) {
|
|
292
|
+
appendValue(normName, files.length === 1 && !el.multiple ? files[0] : files, !el.multiple);
|
|
293
|
+
if (el.multiple) {
|
|
294
|
+
data[normName] = files;
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
data[normName] = files[0];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const totalForName = nameCounts.get(normName) || 1;
|
|
304
|
+
const coerced = coerceScalarControlValue(el, el.value !== undefined ? String(el.value) : '');
|
|
305
|
+
appendValue(normName, coerced, totalForName > 1 || isExplicitArray);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Include submitter name/value if provided and enabled
|
|
309
|
+
if (submitter && !isControlDisabled(submitter)) {
|
|
310
|
+
const subName = submitter.getAttribute ? submitter.getAttribute('name') : submitter.name;
|
|
311
|
+
if (subName && String(subName).trim()) {
|
|
312
|
+
const normSubName = normalizeFieldName(subName);
|
|
313
|
+
const subValue =
|
|
314
|
+
(submitter.getAttribute && submitter.getAttribute('value')) ??
|
|
315
|
+
submitter.value ??
|
|
316
|
+
'';
|
|
317
|
+
data[normSubName] = coerceScalarControlValue(submitter, String(subValue));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const enctype = ((formEl.getAttribute && formEl.getAttribute('enctype')) || formEl.enctype || '').toLowerCase();
|
|
322
|
+
const isMultipart = hasBinaryFile || enctype === 'multipart/form-data';
|
|
323
|
+
|
|
324
|
+
let formData = null;
|
|
325
|
+
if (isMultipart && typeof FormData !== 'undefined') {
|
|
326
|
+
formData = new FormData();
|
|
327
|
+
for (const [key, val] of Object.entries(data)) {
|
|
328
|
+
if (val === undefined) continue;
|
|
329
|
+
if (Array.isArray(val)) {
|
|
330
|
+
for (const item of val) {
|
|
331
|
+
if (item === undefined) continue;
|
|
332
|
+
if (typeof Blob !== 'undefined' && item instanceof Blob) {
|
|
333
|
+
formData.append(key, item);
|
|
334
|
+
} else if (item !== null) {
|
|
335
|
+
formData.append(key, String(item));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} else if (typeof Blob !== 'undefined' && val instanceof Blob) {
|
|
339
|
+
formData.append(key, val);
|
|
340
|
+
} else if (val === null) {
|
|
341
|
+
formData.append(key, '');
|
|
342
|
+
} else {
|
|
343
|
+
formData.append(key, String(val));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { data, values: data, formData, isMultipart };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Snapshot initial default values on a form so `reset()` restores them accurately
|
|
353
|
+
* and `dirty` can compare current values against initial state.
|
|
354
|
+
*/
|
|
355
|
+
function captureInitialFormSnapshot(formEl) {
|
|
356
|
+
const snapshot = new Map();
|
|
357
|
+
if (!formEl || typeof formEl.querySelectorAll !== 'function') return snapshot;
|
|
358
|
+
const controls = formEl.querySelectorAll('input, select, textarea');
|
|
359
|
+
for (let i = 0; i < controls.length; i++) {
|
|
360
|
+
const el = controls[i];
|
|
361
|
+
const type = (el.type || '').toLowerCase();
|
|
362
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
363
|
+
snapshot.set(el, { checked: Boolean(el.checked) });
|
|
364
|
+
} else if (el.tagName === 'SELECT') {
|
|
365
|
+
const selectedIndices = [];
|
|
366
|
+
const opts = el.options || [];
|
|
367
|
+
for (let j = 0; j < opts.length; j++) {
|
|
368
|
+
if (opts[j].selected) selectedIndices.push(j);
|
|
369
|
+
}
|
|
370
|
+
snapshot.set(el, { selectedIndices, value: el.value });
|
|
371
|
+
} else if (type !== 'file') {
|
|
372
|
+
snapshot.set(el, { value: el.value !== undefined ? String(el.value) : '' });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return snapshot;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Check if any control in `formEl` differs from its captured initial snapshot.
|
|
380
|
+
*/
|
|
381
|
+
function computeFormDirty(formEl, initialSnapshot) {
|
|
382
|
+
if (!formEl || typeof formEl.querySelectorAll !== 'function') return false;
|
|
383
|
+
const controls = formEl.querySelectorAll('input, select, textarea');
|
|
384
|
+
for (let i = 0; i < controls.length; i++) {
|
|
385
|
+
const el = controls[i];
|
|
386
|
+
const type = (el.type || '').toLowerCase();
|
|
387
|
+
if (type === 'submit' || type === 'button' || type === 'reset' || type === 'hidden') continue;
|
|
388
|
+
const init = initialSnapshot.get(el);
|
|
389
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
390
|
+
const expected = init ? init.checked : Boolean(el.defaultChecked);
|
|
391
|
+
if (Boolean(el.checked) !== expected) return true;
|
|
392
|
+
} else if (el.tagName === 'SELECT') {
|
|
393
|
+
if (init) {
|
|
394
|
+
if (el.value !== init.value) return true;
|
|
395
|
+
}
|
|
396
|
+
} else if (type === 'file') {
|
|
397
|
+
if (el.files && el.files.length > 0) return true;
|
|
398
|
+
} else {
|
|
399
|
+
const expected = init ? init.value : (el.defaultValue || '');
|
|
400
|
+
if (String(el.value || '') !== expected) return true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Create a Form Handle managing a `<form>` element's lifecycle, validation errors,
|
|
408
|
+
* accessibility attributes, pending lock, and submission.
|
|
409
|
+
*
|
|
410
|
+
* @param {HTMLFormElement} formEl
|
|
411
|
+
* @param {object} options
|
|
412
|
+
* @param {object} options.api - Typed API client instance
|
|
413
|
+
* @param {object} [options.datastar] - Datastar adapter for reactive signals
|
|
414
|
+
* @param {(url: string) => void} [options.navigate] - Navigation handler for explicit redirects
|
|
415
|
+
* @param {object} [options.debug] - Client debugger
|
|
416
|
+
*/
|
|
417
|
+
function createFormHandle(formEl, options = {}) {
|
|
418
|
+
if (formEl && formEl.__wFormHandle) {
|
|
419
|
+
return formEl.__wFormHandle;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const api = options.api;
|
|
423
|
+
const debug = options.debug || null;
|
|
424
|
+
const formName =
|
|
425
|
+
(formEl && formEl.getAttribute && formEl.getAttribute('data-w-form')) ||
|
|
426
|
+
(formEl && formEl.getAttribute && formEl.getAttribute('name')) ||
|
|
427
|
+
'form';
|
|
428
|
+
|
|
429
|
+
let initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
430
|
+
let pending = false;
|
|
431
|
+
let dirty = false;
|
|
432
|
+
let fieldErrors = {};
|
|
433
|
+
let formError = null;
|
|
434
|
+
let lastData = null;
|
|
435
|
+
|
|
436
|
+
const successListeners = new Set();
|
|
437
|
+
const errorListeners = new Set();
|
|
438
|
+
const stateListeners = new Set();
|
|
439
|
+
|
|
440
|
+
// Form draft persistence setup (sessionStorage by default, or localStorage)
|
|
441
|
+
const rawDraftAttr = formEl
|
|
442
|
+
? formEl.getAttribute('data-w-persist-draft') || formEl.getAttribute('data-w-draft')
|
|
443
|
+
: null;
|
|
444
|
+
const isDraftEnabled =
|
|
445
|
+
options.persistDraft !== undefined
|
|
446
|
+
? Boolean(options.persistDraft)
|
|
447
|
+
: rawDraftAttr !== null && rawDraftAttr !== 'false';
|
|
448
|
+
|
|
449
|
+
let draftStorageType = 'session';
|
|
450
|
+
if (typeof options.persistDraft === 'string') {
|
|
451
|
+
draftStorageType = options.persistDraft.toLowerCase() === 'local' ? 'local' : 'session';
|
|
452
|
+
} else if (rawDraftAttr && rawDraftAttr.toLowerCase() === 'local') {
|
|
453
|
+
draftStorageType = 'local';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const draftKey =
|
|
457
|
+
options.draftKey ||
|
|
458
|
+
(formEl && (formEl.getAttribute('data-w-draft-key') || formEl.getAttribute('data-w-persist-key'))) ||
|
|
459
|
+
`form_draft:${formName || 'anon'}`;
|
|
460
|
+
|
|
461
|
+
const draftStorage =
|
|
462
|
+
(options.storage && options.storage[draftStorageType]) ||
|
|
463
|
+
(draftStorageType === 'local' ? defaultStorage.local : defaultStorage.session);
|
|
464
|
+
|
|
465
|
+
function extractDraftValues(el) {
|
|
466
|
+
if (!el || typeof el.querySelectorAll !== 'function') return {};
|
|
467
|
+
const { data } = parseFormElement(el);
|
|
468
|
+
const safeData = {};
|
|
469
|
+
for (const [key, val] of Object.entries(data)) {
|
|
470
|
+
const ctrl = el.querySelector(`[name="${key}"], [name="${key}[]"]`);
|
|
471
|
+
if (!ctrl) {
|
|
472
|
+
safeData[key] = val;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
const type = (ctrl.type || '').toLowerCase();
|
|
476
|
+
if (type === 'password' || type === 'file') continue;
|
|
477
|
+
if (ctrl.hasAttribute('data-w-no-draft') || ctrl.getAttribute('data-w-persist') === 'false') continue;
|
|
478
|
+
safeData[key] = val;
|
|
479
|
+
}
|
|
480
|
+
return safeData;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function saveDraft() {
|
|
484
|
+
if (!formEl || !isDraftEnabled) return false;
|
|
485
|
+
const draftPayload = extractDraftValues(formEl);
|
|
486
|
+
return draftStorage.set(draftKey, draftPayload);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function restoreDraft() {
|
|
490
|
+
if (!formEl || !isDraftEnabled) return false;
|
|
491
|
+
const saved = draftStorage.get(draftKey, null);
|
|
492
|
+
if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) {
|
|
493
|
+
fill(saved, { baseline: false });
|
|
494
|
+
if (typeof formEl.dispatchEvent === 'function') {
|
|
495
|
+
const CustomEventCtor =
|
|
496
|
+
(formEl.ownerDocument && formEl.ownerDocument.defaultView && formEl.ownerDocument.defaultView.CustomEvent) ||
|
|
497
|
+
(typeof CustomEvent !== 'undefined' ? CustomEvent : null);
|
|
498
|
+
if (CustomEventCtor) {
|
|
499
|
+
formEl.dispatchEvent(
|
|
500
|
+
new CustomEventCtor('w-form:draft-restored', {
|
|
501
|
+
bubbles: true,
|
|
502
|
+
cancelable: true,
|
|
503
|
+
detail: { form: handle, draft: saved, key: draftKey },
|
|
504
|
+
})
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function clearDraft() {
|
|
514
|
+
if (!isDraftEnabled) return false;
|
|
515
|
+
return draftStorage.remove(draftKey);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function hasDraft() {
|
|
519
|
+
if (!isDraftEnabled) return false;
|
|
520
|
+
return draftStorage.has(draftKey);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function notifyStateChange() {
|
|
524
|
+
for (const fn of stateListeners) {
|
|
525
|
+
try {
|
|
526
|
+
fn(handle);
|
|
527
|
+
} catch (_e) {
|
|
528
|
+
// Ignore
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Render field errors and form-level error into the DOM with accessible ARIA bindings.
|
|
535
|
+
* Optionally focuses the first invalid control.
|
|
536
|
+
*/
|
|
537
|
+
function syncErrorsToDom(focusFirstInvalid = false) {
|
|
538
|
+
if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
|
|
539
|
+
|
|
540
|
+
// 1. Clear previous error elements and aria-invalid states
|
|
541
|
+
const errorSpans = formEl.querySelectorAll('[data-w-error]');
|
|
542
|
+
for (let i = 0; i < errorSpans.length; i++) {
|
|
543
|
+
const span = errorSpans[i];
|
|
544
|
+
const fieldName = span.getAttribute('data-w-error');
|
|
545
|
+
const msg = fieldName && fieldErrors[fieldName] ? String(fieldErrors[fieldName]) : '';
|
|
546
|
+
span.textContent = msg;
|
|
547
|
+
if (msg) {
|
|
548
|
+
span.removeAttribute('hidden');
|
|
549
|
+
if (!span.id) {
|
|
550
|
+
span.id = `w-err-${formName}-${fieldName}-${++errorIdSeq}`;
|
|
551
|
+
}
|
|
552
|
+
if (!span.getAttribute('role')) {
|
|
553
|
+
span.setAttribute('role', 'alert');
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
span.setAttribute('hidden', '');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 2. Bind aria-invalid and aria-describedby on form controls, and find first invalid control
|
|
561
|
+
let firstInvalidControl = null;
|
|
562
|
+
const controls = formEl.querySelectorAll('input, select, textarea');
|
|
563
|
+
for (let i = 0; i < controls.length; i++) {
|
|
564
|
+
const ctrl = controls[i];
|
|
565
|
+
const rawName = ctrl.getAttribute('name');
|
|
566
|
+
if (!rawName) continue;
|
|
567
|
+
const normName = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
|
|
568
|
+
const errMsg = fieldErrors[normName] || fieldErrors[rawName];
|
|
569
|
+
|
|
570
|
+
if (errMsg) {
|
|
571
|
+
ctrl.setAttribute('aria-invalid', 'true');
|
|
572
|
+
const errSpan =
|
|
573
|
+
formEl.querySelector(`[data-w-error="${normName}"]`) ||
|
|
574
|
+
formEl.querySelector(`[data-w-error="${rawName}"]`);
|
|
575
|
+
if (errSpan && errSpan.id) {
|
|
576
|
+
ctrl.setAttribute('aria-describedby', errSpan.id);
|
|
577
|
+
}
|
|
578
|
+
if (!firstInvalidControl && typeof ctrl.focus === 'function' && !isControlDisabled(ctrl)) {
|
|
579
|
+
firstInvalidControl = ctrl;
|
|
580
|
+
}
|
|
581
|
+
} else {
|
|
582
|
+
ctrl.removeAttribute('aria-invalid');
|
|
583
|
+
const desc = ctrl.getAttribute('aria-describedby');
|
|
584
|
+
if (desc && desc.startsWith('w-err-')) {
|
|
585
|
+
ctrl.removeAttribute('aria-describedby');
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 3. Form-level error element `[data-w-form-error]`
|
|
591
|
+
const formErrorEl = formEl.querySelector('[data-w-form-error]');
|
|
592
|
+
if (formErrorEl) {
|
|
593
|
+
if (formError) {
|
|
594
|
+
formErrorEl.textContent = String(formError);
|
|
595
|
+
formErrorEl.removeAttribute('hidden');
|
|
596
|
+
if (!formErrorEl.getAttribute('role')) {
|
|
597
|
+
formErrorEl.setAttribute('role', 'alert');
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
formErrorEl.textContent = '';
|
|
601
|
+
formErrorEl.setAttribute('hidden', '');
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (focusFirstInvalid && firstInvalidControl) {
|
|
606
|
+
try {
|
|
607
|
+
firstInvalidControl.focus();
|
|
608
|
+
} catch (_e) {
|
|
609
|
+
// Ignore focus errors in headless environments
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Toggle pending state on the form and submit buttons to prevent double submissions.
|
|
616
|
+
*/
|
|
617
|
+
function setPendingState(nextPending, activeSubmitter = null) {
|
|
618
|
+
pending = Boolean(nextPending);
|
|
619
|
+
if (!formEl) return;
|
|
620
|
+
|
|
621
|
+
if (pending) {
|
|
622
|
+
formEl.setAttribute('aria-busy', 'true');
|
|
623
|
+
formEl.setAttribute('data-w-pending', 'true');
|
|
624
|
+
} else {
|
|
625
|
+
formEl.removeAttribute('aria-busy');
|
|
626
|
+
formEl.removeAttribute('data-w-pending');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const submitButtons = formEl.querySelectorAll
|
|
630
|
+
? formEl.querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]')
|
|
631
|
+
: [];
|
|
632
|
+
for (let i = 0; i < submitButtons.length; i++) {
|
|
633
|
+
const btn = submitButtons[i];
|
|
634
|
+
if (btn.hasAttribute('data-w-disabled')) continue; // Managed by controller binding
|
|
635
|
+
if (pending) {
|
|
636
|
+
btn.__wWasDisabled = Boolean(btn.disabled);
|
|
637
|
+
btn.disabled = true;
|
|
638
|
+
} else if (!btn.__wWasDisabled) {
|
|
639
|
+
btn.disabled = false;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (activeSubmitter && !activeSubmitter.hasAttribute('data-w-disabled')) {
|
|
643
|
+
activeSubmitter.disabled = pending;
|
|
644
|
+
}
|
|
645
|
+
notifyStateChange();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Reset form controls back to their initial default values (NOT emptying non-empty defaults).
|
|
650
|
+
*/
|
|
651
|
+
function reset() {
|
|
652
|
+
if (!formEl) return;
|
|
653
|
+
if (typeof formEl.reset === 'function') {
|
|
654
|
+
formEl.reset();
|
|
655
|
+
}
|
|
656
|
+
// Restore exact captured initial snapshot
|
|
657
|
+
for (const [el, init] of initialSnapshot.entries()) {
|
|
658
|
+
const type = (el.type || '').toLowerCase();
|
|
659
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
660
|
+
el.checked = init.checked;
|
|
661
|
+
} else if (el.tagName === 'SELECT') {
|
|
662
|
+
const opts = el.options || [];
|
|
663
|
+
for (let j = 0; j < opts.length; j++) {
|
|
664
|
+
opts[j].selected = init.selectedIndices.includes(j);
|
|
665
|
+
}
|
|
666
|
+
} else if (type !== 'file') {
|
|
667
|
+
el.value = init.value;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
dirty = false;
|
|
671
|
+
fieldErrors = {};
|
|
672
|
+
formError = null;
|
|
673
|
+
syncErrorsToDom(false);
|
|
674
|
+
notifyStateChange();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Completely clear all user-editable controls to empty values (distinct from `reset()`).
|
|
679
|
+
*/
|
|
680
|
+
function clear() {
|
|
681
|
+
if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
|
|
682
|
+
const controls = formEl.querySelectorAll('input, select, textarea');
|
|
683
|
+
for (let i = 0; i < controls.length; i++) {
|
|
684
|
+
const el = controls[i];
|
|
685
|
+
const type = (el.type || '').toLowerCase();
|
|
686
|
+
if (type === 'submit' || type === 'button' || type === 'reset' || type === 'hidden') continue;
|
|
687
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
688
|
+
el.checked = false;
|
|
689
|
+
} else if (el.tagName === 'SELECT') {
|
|
690
|
+
const opts = el.options || [];
|
|
691
|
+
for (let j = 0; j < opts.length; j++) {
|
|
692
|
+
opts[j].selected = false;
|
|
693
|
+
}
|
|
694
|
+
if (!el.multiple && opts.length > 0) {
|
|
695
|
+
el.value = '';
|
|
696
|
+
}
|
|
697
|
+
} else {
|
|
698
|
+
el.value = '';
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
702
|
+
fieldErrors = {};
|
|
703
|
+
formError = null;
|
|
704
|
+
syncErrorsToDom(false);
|
|
705
|
+
notifyStateChange();
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Programmatically fill form fields.
|
|
710
|
+
*
|
|
711
|
+
* @param {Record<string, unknown>} values - Object containing field name -> value mappings
|
|
712
|
+
* @param {object} [options]
|
|
713
|
+
* @param {boolean} [options.baseline=false] - If true, treats the filled values as the new baseline
|
|
714
|
+
* snapshot, clears `dirty`, and clears previous validation errors.
|
|
715
|
+
*/
|
|
716
|
+
function fill(values = {}, options = {}) {
|
|
717
|
+
if (!formEl || typeof formEl.querySelectorAll !== 'function' || !values || typeof values !== 'object') {
|
|
718
|
+
return handle;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const isBaseline = Boolean(options.baseline);
|
|
722
|
+
|
|
723
|
+
for (const [key, val] of Object.entries(values)) {
|
|
724
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
725
|
+
|
|
726
|
+
const normKey = key.endsWith('[]') ? key.slice(0, -2) : key;
|
|
727
|
+
const matchingControls = Array.from(
|
|
728
|
+
formEl.querySelectorAll(`[name="${key}"], [name="${normKey}"], [name="${normKey}[]"]`)
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
for (const ctrl of matchingControls) {
|
|
732
|
+
if (!ctrl || ctrl.tagName === 'BUTTON') continue;
|
|
733
|
+
const type = (ctrl.type || '').toLowerCase();
|
|
734
|
+
if (type === 'file') continue; // Browser security prevents setting file input value
|
|
735
|
+
|
|
736
|
+
if (type === 'checkbox') {
|
|
737
|
+
if (Array.isArray(val)) {
|
|
738
|
+
ctrl.checked = val.map(String).includes(String(ctrl.value));
|
|
739
|
+
} else {
|
|
740
|
+
ctrl.checked = Boolean(val);
|
|
741
|
+
}
|
|
742
|
+
} else if (type === 'radio') {
|
|
743
|
+
ctrl.checked = String(val) === String(ctrl.value);
|
|
744
|
+
} else if (ctrl.tagName === 'SELECT') {
|
|
745
|
+
if (ctrl.multiple) {
|
|
746
|
+
const arr = Array.isArray(val) ? val.map(String) : [String(val)];
|
|
747
|
+
const opts = ctrl.options || [];
|
|
748
|
+
for (let j = 0; j < opts.length; j++) {
|
|
749
|
+
opts[j].selected = arr.includes(String(opts[j].value));
|
|
750
|
+
}
|
|
751
|
+
} else {
|
|
752
|
+
ctrl.value = val === null || val === undefined ? '' : String(val);
|
|
753
|
+
}
|
|
754
|
+
} else {
|
|
755
|
+
ctrl.value = val === null || val === undefined ? '' : String(val);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Clear error for this field
|
|
759
|
+
delete fieldErrors[normKey];
|
|
760
|
+
delete fieldErrors[key];
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (isBaseline) {
|
|
765
|
+
initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
766
|
+
dirty = false;
|
|
767
|
+
fieldErrors = {};
|
|
768
|
+
formError = null;
|
|
769
|
+
syncErrorsToDom(false);
|
|
770
|
+
} else {
|
|
771
|
+
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
772
|
+
syncErrorsToDom(false);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
notifyStateChange();
|
|
776
|
+
return handle;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function setAction(url) {
|
|
780
|
+
if (formEl && url) {
|
|
781
|
+
formEl.setAttribute('action', String(url));
|
|
782
|
+
}
|
|
783
|
+
return handle;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function setRouteParams(params = {}) {
|
|
787
|
+
if (!formEl || !params) return handle;
|
|
788
|
+
let action = formEl.getAttribute('action') || '';
|
|
789
|
+
for (const [k, v] of Object.entries(params)) {
|
|
790
|
+
action = action.replace(new RegExp(`\\[${k}\\]`, 'g'), encodeURIComponent(String(v)));
|
|
791
|
+
action = action.replace(new RegExp(`:${k}\\b`, 'g'), encodeURIComponent(String(v)));
|
|
792
|
+
}
|
|
793
|
+
formEl.setAttribute('action', action);
|
|
794
|
+
return handle;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
let _manuallyDisabled = false;
|
|
798
|
+
function setDisabled(val) {
|
|
799
|
+
_manuallyDisabled = Boolean(val);
|
|
800
|
+
if (!formEl) return handle;
|
|
801
|
+
if (_manuallyDisabled) {
|
|
802
|
+
if (formEl.setAttribute) {
|
|
803
|
+
formEl.setAttribute('aria-disabled', 'true');
|
|
804
|
+
formEl.setAttribute('disabled', '');
|
|
805
|
+
}
|
|
806
|
+
} else {
|
|
807
|
+
if (formEl.removeAttribute) {
|
|
808
|
+
formEl.removeAttribute('aria-disabled');
|
|
809
|
+
formEl.removeAttribute('disabled');
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const controls = formEl.querySelectorAll ? formEl.querySelectorAll('input, select, textarea, button') : [];
|
|
813
|
+
for (let i = 0; i < controls.length; i++) {
|
|
814
|
+
controls[i].disabled = _manuallyDisabled;
|
|
815
|
+
}
|
|
816
|
+
notifyStateChange();
|
|
817
|
+
return handle;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Execute form submission.
|
|
822
|
+
*/
|
|
823
|
+
async function submit(eventOrOptions = null) {
|
|
824
|
+
let event = null;
|
|
825
|
+
let submitOptions = {};
|
|
826
|
+
|
|
827
|
+
if (eventOrOptions && typeof eventOrOptions.preventDefault === 'function') {
|
|
828
|
+
event = eventOrOptions;
|
|
829
|
+
event.preventDefault();
|
|
830
|
+
} else if (eventOrOptions && typeof eventOrOptions === 'object') {
|
|
831
|
+
submitOptions = eventOrOptions;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Prevent double submit while pending
|
|
835
|
+
if (pending) {
|
|
836
|
+
return { submitted: false, reason: 'pending' };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// 1. Native HTML5 constraint validation first
|
|
840
|
+
if (
|
|
841
|
+
submitOptions.validate !== false &&
|
|
842
|
+
formEl &&
|
|
843
|
+
!formEl.noValidate &&
|
|
844
|
+
typeof formEl.reportValidity === 'function'
|
|
845
|
+
) {
|
|
846
|
+
const isValid = formEl.reportValidity();
|
|
847
|
+
if (!isValid) {
|
|
848
|
+
return { submitted: false, reason: 'native-constraint-invalid' };
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const submitter =
|
|
853
|
+
submitOptions.submitter || (event && event.submitter ? event.submitter : null);
|
|
854
|
+
const actionUrl =
|
|
855
|
+
submitOptions.action ||
|
|
856
|
+
(submitter && submitter.getAttribute && submitter.getAttribute('formaction')) ||
|
|
857
|
+
(formEl && formEl.getAttribute && formEl.getAttribute('action')) ||
|
|
858
|
+
(typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
|
|
859
|
+
const rawMethod =
|
|
860
|
+
submitOptions.method ||
|
|
861
|
+
(submitter && submitter.getAttribute && submitter.getAttribute('formmethod')) ||
|
|
862
|
+
(formEl && formEl.getAttribute && formEl.getAttribute('method')) ||
|
|
863
|
+
'POST';
|
|
864
|
+
const method = String(rawMethod).toUpperCase();
|
|
865
|
+
|
|
866
|
+
const { data: payloadData, formData, isMultipart } = parseFormElement(formEl, submitter);
|
|
867
|
+
|
|
868
|
+
// Clear previous errors before request starts
|
|
869
|
+
fieldErrors = {};
|
|
870
|
+
formError = null;
|
|
871
|
+
syncErrorsToDom(false);
|
|
872
|
+
setPendingState(true, submitter);
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
let responseData;
|
|
876
|
+
const reqKey = `form:${formName}:${method}:${actionUrl}`;
|
|
877
|
+
if (method === 'GET') {
|
|
878
|
+
responseData = await api.get(actionUrl, payloadData, {
|
|
879
|
+
key: reqKey,
|
|
880
|
+
policy: 'latest',
|
|
881
|
+
});
|
|
882
|
+
} else {
|
|
883
|
+
const bodyToSend = isMultipart && formData ? formData : payloadData;
|
|
884
|
+
responseData = await api.request(method, actionUrl, bodyToSend, {
|
|
885
|
+
key: reqKey,
|
|
886
|
+
policy: 'drop',
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
lastData = responseData;
|
|
891
|
+
dirty = false;
|
|
892
|
+
fieldErrors = {};
|
|
893
|
+
formError = null;
|
|
894
|
+
syncErrorsToDom(false);
|
|
895
|
+
|
|
896
|
+
// Explicit reset / clear behavior on success
|
|
897
|
+
const resetAttr = formEl ? formEl.getAttribute('data-w-reset') : null;
|
|
898
|
+
const clearAttr = formEl ? formEl.getAttribute('data-w-clear') : null;
|
|
899
|
+
const shouldReset =
|
|
900
|
+
submitOptions.resetOnSuccess === true ||
|
|
901
|
+
options.resetOnSuccess === true ||
|
|
902
|
+
resetAttr === '' ||
|
|
903
|
+
resetAttr === 'true' ||
|
|
904
|
+
resetAttr === '1';
|
|
905
|
+
const shouldClear =
|
|
906
|
+
submitOptions.clearOnSuccess === true ||
|
|
907
|
+
options.clearOnSuccess === true ||
|
|
908
|
+
clearAttr === '' ||
|
|
909
|
+
clearAttr === 'true' ||
|
|
910
|
+
clearAttr === '1';
|
|
911
|
+
|
|
912
|
+
if (shouldClear) {
|
|
913
|
+
clear();
|
|
914
|
+
} else if (shouldReset) {
|
|
915
|
+
reset();
|
|
916
|
+
} else {
|
|
917
|
+
// Current state becomes the new clean baseline after successful save
|
|
918
|
+
initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (isDraftEnabled) {
|
|
922
|
+
clearDraft();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Invoke registered success hooks. Note: Any background fragment refresh failure
|
|
926
|
+
// is handled by the fragment coordinator and NEVER puts this form into validation error state!
|
|
927
|
+
for (const listener of successListeners) {
|
|
928
|
+
await listener(responseData, { values: payloadData, form: handle });
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Explicit redirect behavior
|
|
932
|
+
const redirectTarget =
|
|
933
|
+
submitOptions.redirectOnSuccess ||
|
|
934
|
+
(formEl && formEl.getAttribute('data-w-redirect')) ||
|
|
935
|
+
null;
|
|
936
|
+
if (redirectTarget && isSafeRedirectUrl(redirectTarget)) {
|
|
937
|
+
if (typeof options.navigate === 'function') {
|
|
938
|
+
options.navigate(redirectTarget);
|
|
939
|
+
} else if (typeof window !== 'undefined' && window.location) {
|
|
940
|
+
window.location.assign(redirectTarget);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
let refreshOk = true;
|
|
945
|
+
let refreshError = null;
|
|
946
|
+
if (responseData && typeof responseData === 'object' && responseData.__refreshPromise) {
|
|
947
|
+
const refreshOutcome = await responseData.__refreshPromise;
|
|
948
|
+
if (refreshOutcome && refreshOutcome.ok === false) {
|
|
949
|
+
refreshOk = false;
|
|
950
|
+
refreshError = refreshOutcome.error || null;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return { ok: true, submitted: true, data: responseData, refreshOk, refreshError };
|
|
955
|
+
} catch (err) {
|
|
956
|
+
if (err && err.kind === 'validation') {
|
|
957
|
+
fieldErrors = { ...(err.fieldErrors || {}) };
|
|
958
|
+
formError = err.formError || (Object.keys(fieldErrors).length === 0 ? err.message : null);
|
|
959
|
+
syncErrorsToDom(true);
|
|
960
|
+
} else if (err && err.isCanceled) {
|
|
961
|
+
// Canceled or dropped duplicate submit — do not overwrite form with error
|
|
962
|
+
} else {
|
|
963
|
+
formError = err && err.message ? err.message : 'Form submission failed.';
|
|
964
|
+
syncErrorsToDom(false);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
for (const listener of errorListeners) {
|
|
968
|
+
try {
|
|
969
|
+
await listener(err, { values: payloadData, form: handle });
|
|
970
|
+
} catch (_e) {
|
|
971
|
+
// Ignore
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (submitOptions.throwOnError) {
|
|
976
|
+
throw err;
|
|
977
|
+
}
|
|
978
|
+
return { ok: false, submitted: false, error: err };
|
|
979
|
+
} finally {
|
|
980
|
+
setPendingState(false, submitter);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Listen to native submit and input/change events on `formEl`
|
|
985
|
+
let draftDebounceTimer = null;
|
|
986
|
+
const onDomInput = (evt) => {
|
|
987
|
+
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
988
|
+
const target = evt && evt.target;
|
|
989
|
+
if (target && target.getAttribute) {
|
|
990
|
+
const rawName = target.getAttribute('name');
|
|
991
|
+
if (rawName) {
|
|
992
|
+
const norm = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
|
|
993
|
+
if (fieldErrors[norm] || fieldErrors[rawName]) {
|
|
994
|
+
delete fieldErrors[norm];
|
|
995
|
+
delete fieldErrors[rawName];
|
|
996
|
+
syncErrorsToDom(false);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (isDraftEnabled) {
|
|
1001
|
+
if (draftDebounceTimer) clearTimeout(draftDebounceTimer);
|
|
1002
|
+
draftDebounceTimer = setTimeout(() => {
|
|
1003
|
+
saveDraft();
|
|
1004
|
+
}, 150);
|
|
1005
|
+
}
|
|
1006
|
+
notifyStateChange();
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
const onDomSubmit = (evt) => {
|
|
1010
|
+
submit(evt);
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
const onDomReset = () => {
|
|
1014
|
+
setTimeout(() => {
|
|
1015
|
+
dirty = false;
|
|
1016
|
+
fieldErrors = {};
|
|
1017
|
+
formError = null;
|
|
1018
|
+
syncErrorsToDom(false);
|
|
1019
|
+
notifyStateChange();
|
|
1020
|
+
}, 0);
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
if (formEl && typeof formEl.addEventListener === 'function') {
|
|
1024
|
+
formEl.addEventListener('input', onDomInput);
|
|
1025
|
+
formEl.addEventListener('change', onDomInput);
|
|
1026
|
+
formEl.addEventListener('submit', onDomSubmit);
|
|
1027
|
+
formEl.addEventListener('reset', onDomReset);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Initialize hidden state on empty error placeholders
|
|
1031
|
+
syncErrorsToDom(false);
|
|
1032
|
+
|
|
1033
|
+
let handle = null;
|
|
1034
|
+
|
|
1035
|
+
handle = {
|
|
1036
|
+
name: formName,
|
|
1037
|
+
el: formEl,
|
|
1038
|
+
get pending() {
|
|
1039
|
+
return pending;
|
|
1040
|
+
},
|
|
1041
|
+
get dirty() {
|
|
1042
|
+
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
1043
|
+
return dirty;
|
|
1044
|
+
},
|
|
1045
|
+
get values() {
|
|
1046
|
+
return parseFormElement(formEl).data;
|
|
1047
|
+
},
|
|
1048
|
+
get errors() {
|
|
1049
|
+
return { ...fieldErrors };
|
|
1050
|
+
},
|
|
1051
|
+
get fieldErrors() {
|
|
1052
|
+
return { ...fieldErrors };
|
|
1053
|
+
},
|
|
1054
|
+
get formError() {
|
|
1055
|
+
return formError;
|
|
1056
|
+
},
|
|
1057
|
+
get lastData() {
|
|
1058
|
+
return lastData;
|
|
1059
|
+
},
|
|
1060
|
+
get disabled() {
|
|
1061
|
+
return _manuallyDisabled;
|
|
1062
|
+
},
|
|
1063
|
+
set disabled(val) {
|
|
1064
|
+
setDisabled(val);
|
|
1065
|
+
},
|
|
1066
|
+
read(submitter) {
|
|
1067
|
+
return parseFormElement(formEl, submitter).data;
|
|
1068
|
+
},
|
|
1069
|
+
getValues(submitter) {
|
|
1070
|
+
return parseFormElement(formEl, submitter).data;
|
|
1071
|
+
},
|
|
1072
|
+
fill,
|
|
1073
|
+
saveDraft,
|
|
1074
|
+
restoreDraft,
|
|
1075
|
+
clearDraft,
|
|
1076
|
+
get hasDraft() {
|
|
1077
|
+
return hasDraft();
|
|
1078
|
+
},
|
|
1079
|
+
setAction,
|
|
1080
|
+
setRouteParams,
|
|
1081
|
+
setDisabled,
|
|
1082
|
+
setFieldErrors(nextFieldErrors = {}, focusFirst = true) {
|
|
1083
|
+
fieldErrors = { ...nextFieldErrors };
|
|
1084
|
+
syncErrorsToDom(focusFirst);
|
|
1085
|
+
notifyStateChange();
|
|
1086
|
+
},
|
|
1087
|
+
setFormError(message) {
|
|
1088
|
+
formError = message ? String(message) : null;
|
|
1089
|
+
syncErrorsToDom(false);
|
|
1090
|
+
notifyStateChange();
|
|
1091
|
+
},
|
|
1092
|
+
clearErrors() {
|
|
1093
|
+
fieldErrors = {};
|
|
1094
|
+
formError = null;
|
|
1095
|
+
syncErrorsToDom(false);
|
|
1096
|
+
notifyStateChange();
|
|
1097
|
+
},
|
|
1098
|
+
reset,
|
|
1099
|
+
clear,
|
|
1100
|
+
submit,
|
|
1101
|
+
onSuccess(fn) {
|
|
1102
|
+
if (typeof fn === 'function') {
|
|
1103
|
+
successListeners.add(fn);
|
|
1104
|
+
return () => successListeners.delete(fn);
|
|
1105
|
+
}
|
|
1106
|
+
return () => {};
|
|
1107
|
+
},
|
|
1108
|
+
onError(fn) {
|
|
1109
|
+
if (typeof fn === 'function') {
|
|
1110
|
+
errorListeners.add(fn);
|
|
1111
|
+
return () => errorListeners.delete(fn);
|
|
1112
|
+
}
|
|
1113
|
+
return () => {};
|
|
1114
|
+
},
|
|
1115
|
+
subscribe(fn) {
|
|
1116
|
+
if (typeof fn === 'function') {
|
|
1117
|
+
stateListeners.add(fn);
|
|
1118
|
+
return () => stateListeners.delete(fn);
|
|
1119
|
+
}
|
|
1120
|
+
return () => {};
|
|
1121
|
+
},
|
|
1122
|
+
destroy() {
|
|
1123
|
+
if (formEl && typeof formEl.removeEventListener === 'function') {
|
|
1124
|
+
formEl.removeEventListener('input', onDomInput);
|
|
1125
|
+
formEl.removeEventListener('change', onDomInput);
|
|
1126
|
+
formEl.removeEventListener('submit', onDomSubmit);
|
|
1127
|
+
formEl.removeEventListener('reset', onDomReset);
|
|
1128
|
+
}
|
|
1129
|
+
if (formEl) {
|
|
1130
|
+
delete formEl.__wFormHandle;
|
|
1131
|
+
}
|
|
1132
|
+
successListeners.clear();
|
|
1133
|
+
errorListeners.clear();
|
|
1134
|
+
stateListeners.clear();
|
|
1135
|
+
},
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
if (formEl) {
|
|
1139
|
+
Object.defineProperty(formEl, '__wFormHandle', {
|
|
1140
|
+
value: handle,
|
|
1141
|
+
configurable: true,
|
|
1142
|
+
enumerable: false,
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (debug) {
|
|
1147
|
+
debug.recordEvent('form:bind', { name: formName });
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (isDraftEnabled) {
|
|
1151
|
+
restoreDraft();
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return handle;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
module.exports = {
|
|
1158
|
+
parseFormElement,
|
|
1159
|
+
isControlDisabled,
|
|
1160
|
+
coerceScalarControlValue,
|
|
1161
|
+
createFormHandle,
|
|
1162
|
+
isSafeRedirectUrl,
|
|
1163
|
+
};
|
|
1164
|
+
|