phlex-reactive 0.13.2 → 0.13.3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +135 -0
- data/README.md +77 -9
- data/app/controllers/phlex/reactive/actions_controller.rb +72 -3
- data/app/javascript/phlex/reactive/compute.js +9 -1
- data/app/javascript/phlex/reactive/compute.min.js.map +2 -2
- data/app/javascript/phlex/reactive/confirm_predicate.js +7 -1
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.js +417 -51
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +7 -0
- data/lib/phlex/reactive/component/helpers.rb +4 -3
- data/lib/phlex/reactive/component.rb +2 -1
- data/lib/phlex/reactive/param_schema.rb +16 -0
- data/lib/phlex/reactive/test_helpers.rb +10 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -1077,6 +1077,7 @@ const PERSIST_PREFIX = "phlex-reactive:persist:"
|
|
|
1077
1077
|
// Never persisted regardless of author intent: no server default to restore
|
|
1078
1078
|
// into (hidden, file), secrets (password), and non-value controls.
|
|
1079
1079
|
const PERSIST_EXCLUDED_TYPES = new Set(["hidden", "file", "password", "submit", "button", "reset", "image"])
|
|
1080
|
+
const PERSIST_NO_VALUE = Symbol("persist-no-value")
|
|
1080
1081
|
const PERSIST_STATE_ATTR = "data-reactive-persist-state"
|
|
1081
1082
|
// The editor query #collectFields reads (minus the [name] guard — an editor's
|
|
1082
1083
|
// name may live on its IDL `name` getter: Trix's `input=`-paired hidden input).
|
|
@@ -1235,6 +1236,14 @@ function persistEditorReady(el) {
|
|
|
1235
1236
|
return typeof el.value === "string"
|
|
1236
1237
|
}
|
|
1237
1238
|
|
|
1239
|
+
// The same question for #collectFields. A RICH editor (lexxy/trix) is only
|
|
1240
|
+
// ready once its custom element upgraded — Trix defines its elements in a
|
|
1241
|
+
// setTimeout after load — and reading it before that yields "", which is issue
|
|
1242
|
+
// #8. A bare [contenteditable] is plain DOM and always ready.
|
|
1243
|
+
function collectorEditorReady(el) {
|
|
1244
|
+
return PERSIST_EDITOR_TAGS.has(el.localName) ? persistEditorReady(el) : true
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1238
1247
|
// Ask the editor whether it is empty (Lexxy `isEmpty`; Trix
|
|
1239
1248
|
// `editor.getDocument().isEmpty()`), else the exact-string fallback. An
|
|
1240
1249
|
// attachment-only server body is therefore NON-blank and never overwritten.
|
|
@@ -1258,6 +1267,33 @@ function persistSelectMultiple(el) {
|
|
|
1258
1267
|
return el.tagName === "SELECT" && el.multiple
|
|
1259
1268
|
}
|
|
1260
1269
|
|
|
1270
|
+
// How many controls can contribute a value under each `[]` name, counted the
|
|
1271
|
+
// way persistSnapshot fills the slot — a radio is the exception there and here.
|
|
1272
|
+
// A group of ONE is unambiguous: its single entry can only have come from that
|
|
1273
|
+
// control.
|
|
1274
|
+
function persistGroupSizes(controls) {
|
|
1275
|
+
const sizes = new Map()
|
|
1276
|
+
for (const { el, name } of controls) {
|
|
1277
|
+
if (el.type === "radio" || !String(name).endsWith("[]")) continue
|
|
1278
|
+
sizes.set(name, (sizes.get(name) ?? 0) + 1)
|
|
1279
|
+
}
|
|
1280
|
+
return sizes
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// The value a control that cannot pick its own entry out of a list may take.
|
|
1284
|
+
// A list belongs to a `[]` group, and in a group of two or more nothing says
|
|
1285
|
+
// which entry came from which control — restoring it would paste
|
|
1286
|
+
// "freeform,news" into a text field. A group of ONE has no such ambiguity, and
|
|
1287
|
+
// refusing it would silently drop the draft of a plain field whose name merely
|
|
1288
|
+
// ends in `[]` (a list JS maintains), which worked before groups existed.
|
|
1289
|
+
// Returns PERSIST_NO_VALUE when the control must keep what the server rendered.
|
|
1290
|
+
function persistGenericValue(value, name, sizes) {
|
|
1291
|
+
if (!Array.isArray(value)) return value
|
|
1292
|
+
if (sizes.get(name) !== 1 || value.length !== 1) return PERSIST_NO_VALUE
|
|
1293
|
+
|
|
1294
|
+
return value[0]
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1261
1297
|
// Snapshot the owned controls: radio → the checked value (null when the group
|
|
1262
1298
|
// has none, so a restore leaves it alone), checkbox → checked, multi-select →
|
|
1263
1299
|
// the selected values, a rich editor → its serialized `value` (omitted while
|
|
@@ -1265,18 +1301,52 @@ function persistSelectMultiple(el) {
|
|
|
1265
1301
|
// its textContent, else .value. Mirrors #collectFields' reads.
|
|
1266
1302
|
function persistSnapshot(root, payload) {
|
|
1267
1303
|
const fields = {}
|
|
1304
|
+
// A `[]` name is a group for every control that can contribute a value —
|
|
1305
|
+
// checkboxes, selects, text inputs, editors and contenteditables all append
|
|
1306
|
+
// to one array, mirroring #collectFields. The one exception is a RADIO group,
|
|
1307
|
+
// which means "pick one" and keeps its single value with or without the
|
|
1308
|
+
// suffix, exactly as the collector treats it.
|
|
1309
|
+
//
|
|
1310
|
+
// Reading the slot without this (fields[name] ?? []) breaks as soon as a
|
|
1311
|
+
// non-checkbox shares the group's name: a text input or an editor leaves a
|
|
1312
|
+
// string there, `.push` on it throws inside the draft write, and that write
|
|
1313
|
+
// is swallowed — the root then persists nothing at all, silently. Editors
|
|
1314
|
+
// are collected AFTER the native controls, so they always land last.
|
|
1315
|
+
const groupSlot = (name) => {
|
|
1316
|
+
const existing = fields[name]
|
|
1317
|
+
return Array.isArray(existing) ? existing : (fields[name] = [])
|
|
1318
|
+
}
|
|
1268
1319
|
for (const { el, name, kind } of persistControls(root, payload)) {
|
|
1320
|
+
const group = String(name).endsWith("[]")
|
|
1269
1321
|
if (kind === "editor") {
|
|
1270
|
-
if (persistEditorReady(el))
|
|
1322
|
+
if (persistEditorReady(el)) {
|
|
1323
|
+
if (group) groupSlot(name).push(el.value)
|
|
1324
|
+
else fields[name] = el.value
|
|
1325
|
+
}
|
|
1271
1326
|
} else if (kind === "contenteditable") {
|
|
1272
|
-
|
|
1327
|
+
const text = el.textContent ?? ""
|
|
1328
|
+
if (group) groupSlot(name).push(text)
|
|
1329
|
+
else fields[name] = text
|
|
1273
1330
|
} else if (el.type === "radio") {
|
|
1274
1331
|
if (el.checked) fields[name] = el.value
|
|
1275
1332
|
else if (!Object.hasOwn(fields, name)) fields[name] = null
|
|
1276
1333
|
} else if (el.type === "checkbox") {
|
|
1277
|
-
|
|
1334
|
+
// A `[]` group drafts the list of ticked values, mirroring #collectFields
|
|
1335
|
+
// (issue #258). Without this the boxes overwrote each other and the draft
|
|
1336
|
+
// held one boolean, which the restore then applied to every box of the
|
|
1337
|
+
// group. A lone checkbox keeps the boolean it has always been.
|
|
1338
|
+
if (group) {
|
|
1339
|
+
const slot = groupSlot(name)
|
|
1340
|
+
if (el.checked) slot.push(el.value)
|
|
1341
|
+
} else {
|
|
1342
|
+
fields[name] = el.checked
|
|
1343
|
+
}
|
|
1278
1344
|
} else if (persistSelectMultiple(el)) {
|
|
1279
|
-
|
|
1345
|
+
const selected = [...el.options].filter((o) => o.selected).map((o) => o.value)
|
|
1346
|
+
if (group) groupSlot(name).push(...selected)
|
|
1347
|
+
else fields[name] = selected
|
|
1348
|
+
} else if (group) {
|
|
1349
|
+
groupSlot(name).push(el.value)
|
|
1280
1350
|
} else {
|
|
1281
1351
|
fields[name] = el.value
|
|
1282
1352
|
}
|
|
@@ -1292,10 +1362,61 @@ function persistSnapshot(root, payload) {
|
|
|
1292
1362
|
function persistApply(root, payload, fields) {
|
|
1293
1363
|
const always = payload.restore === "always"
|
|
1294
1364
|
const controls = persistControls(root, payload)
|
|
1365
|
+
// Which group names the SERVER rendered with a box already ticked. Computed
|
|
1366
|
+
// BEFORE the loop on purpose: the loop writes `checked`, so asking this
|
|
1367
|
+
// question from inside it would read THIS restore's own work — the first box
|
|
1368
|
+
// it ticks makes every later box of the same group look server-rendered, and
|
|
1369
|
+
// a draft of two values comes back as one. The radio branch below asks the
|
|
1370
|
+
// same question inline and stays correct only because a radio group holds a
|
|
1371
|
+
// single value.
|
|
1372
|
+
const groupSizes = persistGroupSizes(controls)
|
|
1373
|
+
const serverTicked = new Set()
|
|
1374
|
+
for (const control of controls) {
|
|
1375
|
+
if (control.kind === "native" && control.el.type === "checkbox" && control.el.checked) {
|
|
1376
|
+
serverTicked.add(control.name)
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1295
1379
|
for (const { el, name, kind } of controls) {
|
|
1296
1380
|
if (!Object.hasOwn(fields, name)) continue
|
|
1297
|
-
|
|
1381
|
+
let value = fields[name]
|
|
1298
1382
|
if (value === null || value === undefined) continue
|
|
1383
|
+
// A multi-select reads a list by matching option values, which is only
|
|
1384
|
+
// sound when the list is ITS list. In a group with another contributor the
|
|
1385
|
+
// entries are mixed, and a text value that happens to equal an option
|
|
1386
|
+
// would select it — measured, a draft of ["blue","freitext"] from a select
|
|
1387
|
+
// plus a text field selected both options. `?? 0` because a name without
|
|
1388
|
+
// the suffix is not in the map at all, and a plain `<select multiple
|
|
1389
|
+
// name="colors">` must keep restoring.
|
|
1390
|
+
if (Array.isArray(value) && persistSelectMultiple(el) && (groupSizes.get(name) ?? 0) > 1) continue
|
|
1391
|
+
|
|
1392
|
+
// An array belongs to a `[]` group, and only a control that can pick ITS
|
|
1393
|
+
// entry out of the list may read it: a checkbox matches by value, a
|
|
1394
|
+
// multi-select by option. Everything else — editors, contenteditables,
|
|
1395
|
+
// text inputs — keeps what the server rendered, because the list does not
|
|
1396
|
+
// record which entry came from which control. This sits ABOVE the branch
|
|
1397
|
+
// chain on purpose: below the editor branch it would never fire for the
|
|
1398
|
+
// very controls that land last in the snapshot.
|
|
1399
|
+
if (Array.isArray(value) && !(el.type === "checkbox" || persistSelectMultiple(el))) {
|
|
1400
|
+
value = persistGenericValue(value, name, groupSizes)
|
|
1401
|
+
if (value === PERSIST_NO_VALUE) continue
|
|
1402
|
+
}
|
|
1403
|
+
// The mirror, for a draft written BEFORE a group was drafted as a list
|
|
1404
|
+
// (#258): there `features[]` held ONE boolean, and applying it here ticks
|
|
1405
|
+
// every box of the group — precisely the state this fix removes, for as
|
|
1406
|
+
// long as the draft lives (default ttl 7 days). A group key that is not a
|
|
1407
|
+
// list is stale by definition, so the control keeps what the server
|
|
1408
|
+
// rendered and the next snapshot overwrites the key. It reads for a
|
|
1409
|
+
// multi-select too: 0.13.2 wrote last-writer-wins per name, so a checkbox
|
|
1410
|
+
// in a mixed group could leave its boolean under the select's name, and
|
|
1411
|
+
// under `restore: "always"` the select would then deselect everything —
|
|
1412
|
+
// `wanted` being Set{"true"} matches no option. Asking for the `[]` suffix
|
|
1413
|
+
// is what leaves a lone `gift` checkbox on the boolean it has always held —
|
|
1414
|
+
// and scoping the rule to the one key whose meaning changed is why
|
|
1415
|
+
// PERSIST_VERSION stays at 1: bumping it would also throw away the drafted
|
|
1416
|
+
// prose of every form that has no checkbox group at all.
|
|
1417
|
+
if ((el.type === "checkbox" || persistSelectMultiple(el)) && String(name).endsWith("[]") && !Array.isArray(value)) {
|
|
1418
|
+
continue
|
|
1419
|
+
}
|
|
1299
1420
|
if (kind === "editor") {
|
|
1300
1421
|
persistApplyEditor(root, el, name, value, always)
|
|
1301
1422
|
} else if (kind === "contenteditable") {
|
|
@@ -1304,6 +1425,12 @@ function persistApply(root, payload, fields) {
|
|
|
1304
1425
|
} else if (el.type === "radio") {
|
|
1305
1426
|
if (!always && controls.some((c) => c.kind === "native" && c.el.type === "radio" && c.name === name && c.el.checked)) continue
|
|
1306
1427
|
el.checked = el.value === String(value)
|
|
1428
|
+
} else if (el.type === "checkbox" && Array.isArray(value)) {
|
|
1429
|
+
// A drafted group ticks exactly the boxes it held. One box the SERVER
|
|
1430
|
+
// rendered ticked means it had a say, and the draft yields for the whole
|
|
1431
|
+
// group.
|
|
1432
|
+
if (!always && serverTicked.has(name)) continue
|
|
1433
|
+
el.checked = value.map(String).includes(el.value)
|
|
1307
1434
|
} else if (el.type === "checkbox") {
|
|
1308
1435
|
if (!always && el.checked) continue
|
|
1309
1436
|
el.checked = Boolean(value)
|
|
@@ -1323,6 +1450,11 @@ function persistApply(root, payload, fields) {
|
|
|
1323
1450
|
// editor's sanitizing import (Trix HTMLParser, Lexxy $generateNodesFromDOM +
|
|
1324
1451
|
// sanitizer). A throw (Lexxy before its editor exists) never escapes connect.
|
|
1325
1452
|
function persistApplyEditor(root, el, name, value, always) {
|
|
1453
|
+
// A list never reaches here: both callers resolve it through
|
|
1454
|
+
// persistGenericValue first — the group of two or more has no mapping back to
|
|
1455
|
+
// this editor, the group of one does. Belt and braces, because this is the
|
|
1456
|
+
// one apply path with a second entry point.
|
|
1457
|
+
if (Array.isArray(value)) return
|
|
1326
1458
|
if (!persistEditorReady(el)) return // not upgraded yet — persistDeferEditors re-applies after define
|
|
1327
1459
|
if (!always && !persistEditorBlank(el)) return
|
|
1328
1460
|
try {
|
|
@@ -1348,10 +1480,12 @@ function persistDeferEditors(root, payload, fields) {
|
|
|
1348
1480
|
for (const tag of pending) {
|
|
1349
1481
|
registry.whenDefined(tag).then(() => {
|
|
1350
1482
|
if (!root.isConnected) return
|
|
1351
|
-
|
|
1483
|
+
const deferred = persistControls(root, payload)
|
|
1484
|
+
const sizes = persistGroupSizes(deferred)
|
|
1485
|
+
for (const { el, name, kind } of deferred) {
|
|
1352
1486
|
if (kind !== "editor" || el.localName !== tag || !Object.hasOwn(fields, name)) continue
|
|
1353
|
-
const value = fields[name]
|
|
1354
|
-
if (value === null || value === undefined) continue
|
|
1487
|
+
const value = persistGenericValue(fields[name], name, sizes)
|
|
1488
|
+
if (value === null || value === undefined || value === PERSIST_NO_VALUE) continue
|
|
1355
1489
|
persistApplyEditor(root, el, name, value, always)
|
|
1356
1490
|
}
|
|
1357
1491
|
})
|
|
@@ -1813,6 +1947,94 @@ function parseOps(raw) {
|
|
|
1813
1947
|
}
|
|
1814
1948
|
}
|
|
1815
1949
|
|
|
1950
|
+
// The TEXT reading of a compute control (issue #262) — what a :string input
|
|
1951
|
+
// hands the reducer and what the identity/cross-root mirrors paint. A checkbox
|
|
1952
|
+
// reads its CHECKED STATE ("true"/"false", the strings reactive_show compares
|
|
1953
|
+
// against): its .value is a constant — "1", "on", whatever the markup says —
|
|
1954
|
+
// so reading it told the reducer nothing. A radio reads its value only while
|
|
1955
|
+
// checked ("" otherwise; the resolver hands over the checked radio of a group).
|
|
1956
|
+
// Anything else reads .value, as it always has.
|
|
1957
|
+
//
|
|
1958
|
+
// Every compute helper takes a CONTROL — the { el, kind } record #recompute's
|
|
1959
|
+
// resolver builds, kind being "checkbox", "radio" or "" — and never re-reads
|
|
1960
|
+
// el.type: the resolver reads it ONCE per name. Re-reading it in each helper
|
|
1961
|
+
// cost the 30-input calculator bench ~60% (16.8 → 27 µs/iter, measured).
|
|
1962
|
+
function computeText({ el, kind }) {
|
|
1963
|
+
if (!el) return ""
|
|
1964
|
+
if (kind === "checkbox") return el.checked ? "true" : "false"
|
|
1965
|
+
if (kind === "radio") return el.checked ? (el.value ?? "") : ""
|
|
1966
|
+
return el.value ?? ""
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
// The control for a name nothing owned resolves to.
|
|
1970
|
+
const COMPUTE_NO_CONTROL = Object.freeze({ el: null, kind: "" })
|
|
1971
|
+
|
|
1972
|
+
// Whether a value counts as "on" — for a :boolean input read off a control that
|
|
1973
|
+
// is not a checkbox, and for an output written INTO a checkbox. A boolean is
|
|
1974
|
+
// itself; otherwise "", "0" and "false" are off (what a hidden flag field or a
|
|
1975
|
+
// reducer returning 0 means) and anything else is on.
|
|
1976
|
+
function computeTruthy(value) {
|
|
1977
|
+
if (typeof value === "boolean") return value
|
|
1978
|
+
if (value == null) return false
|
|
1979
|
+
const text = String(value)
|
|
1980
|
+
return text !== "" && text !== "0" && text !== "false"
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// One declared input's value for the reducer, coerced by its declared type
|
|
1984
|
+
// (issue #104; checked-state controls issue #262):
|
|
1985
|
+
//
|
|
1986
|
+
// "string" → the text reading, raw (blank/absent → "")
|
|
1987
|
+
// "boolean" → a checkbox's checked state; any other control by computeTruthy
|
|
1988
|
+
// "number" → a checkbox is 1/0; anything else through Number (blank/NaN → 0,
|
|
1989
|
+
// the nanToZero the hand-written calculators use)
|
|
1990
|
+
//
|
|
1991
|
+
// A checkbox is 1/0 and never Number(its value): a box is a yes/no, and a
|
|
1992
|
+
// reducer that wants an amount writes `gift ? 25 : 0`.
|
|
1993
|
+
function computeValue(control, type) {
|
|
1994
|
+
if (type === "string") return computeText(control)
|
|
1995
|
+
const box = control.kind === "checkbox"
|
|
1996
|
+
if (type === "boolean") return box ? Boolean(control.el.checked) : computeTruthy(computeText(control))
|
|
1997
|
+
if (box) return control.el.checked ? 1 : 0
|
|
1998
|
+
const n = Number(computeText(control))
|
|
1999
|
+
return Number.isFinite(n) ? n : 0
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// Write one reducer output into the control its name resolved to (issue #262),
|
|
2003
|
+
// change-guarded. Returns the element to announce with an `input` event, or
|
|
2004
|
+
// null when nothing changed. A checkbox takes the result as its checked state
|
|
2005
|
+
// and a radio group checks the radio carrying it — neither ever has its value
|
|
2006
|
+
// attribute rewritten, which would change what the control SUBMITS. Anything
|
|
2007
|
+
// else takes the result as its .value (issue #76).
|
|
2008
|
+
function computeWrite(root, owns, { el, kind }, domName, value) {
|
|
2009
|
+
if (kind === "checkbox") {
|
|
2010
|
+
const checked = computeTruthy(value)
|
|
2011
|
+
if (Boolean(el.checked) === checked) return null
|
|
2012
|
+
el.checked = checked
|
|
2013
|
+
return el
|
|
2014
|
+
}
|
|
2015
|
+
if (kind === "radio") return computeCheckRadio(root, owns, domName, String(value))
|
|
2016
|
+
if (String(value) === el.value) return null
|
|
2017
|
+
el.value = value
|
|
2018
|
+
return el
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Check the owned radio of a group whose value is `wanted`, unchecking the
|
|
2022
|
+
// rest — the same per-radio rule a restored draft applies. A value no radio
|
|
2023
|
+
// carries clears the group. Returns the radio that GAINED the check, or, for a
|
|
2024
|
+
// cleared group, the one that lost it; null when the selection already matched.
|
|
2025
|
+
function computeCheckRadio(root, owns, domName, wanted) {
|
|
2026
|
+
let announced = null
|
|
2027
|
+
for (const el of root.querySelectorAll(`[name="${domName}"]`)) {
|
|
2028
|
+
if (el.type !== "radio" || !owns(el)) continue
|
|
2029
|
+
const checked = el.value === wanted
|
|
2030
|
+
if (Boolean(el.checked) === checked) continue
|
|
2031
|
+
el.checked = checked
|
|
2032
|
+
if (checked) announced = el
|
|
2033
|
+
else announced ??= el
|
|
2034
|
+
}
|
|
2035
|
+
return announced
|
|
2036
|
+
}
|
|
2037
|
+
|
|
1816
2038
|
// Normalize a reducer's reserved $ops output (issue #226): the compute `ops`
|
|
1817
2039
|
// builder (its .ops list), a raw [[name, args], ...] array, or null/undefined
|
|
1818
2040
|
// (no effect this pass). An EMPTY list is the same as null — "nothing to run"
|
|
@@ -2480,8 +2702,14 @@ export default class extends Controller {
|
|
|
2480
2702
|
// predicate in the common no-nested-root case (skipping closest() entirely)
|
|
2481
2703
|
// and the exact #ownsField check when a nested reactive root is present
|
|
2482
2704
|
// (issue #15 scoping, byte-identical to before). Resolution is memoized in a
|
|
2483
|
-
// per-CALL Map,
|
|
2484
|
-
//
|
|
2705
|
+
// per-CALL Map, so a name read as an input AND written as an output
|
|
2706
|
+
// resolves to the SAME element and is queried once.
|
|
2707
|
+
//
|
|
2708
|
+
// Which element a name resolves to (issue #262, mirroring #showFieldValue): a
|
|
2709
|
+
// CHECKBOX wins over the hidden companion Rails renders before it; a radio
|
|
2710
|
+
// group resolves to its CHECKED radio (any radio of the group when none is);
|
|
2711
|
+
// anything else is first-wins. Resolving first-wins across the board handed
|
|
2712
|
+
// the reducer the companion's constant "0" and the first radio's value.
|
|
2485
2713
|
//
|
|
2486
2714
|
// Why per-name `[name="X"]` queries and not one bare `[name]` sweep: a single
|
|
2487
2715
|
// sweep is the natural "one walk", but the resolver must issue the SAME
|
|
@@ -2500,25 +2728,37 @@ export default class extends Controller {
|
|
|
2500
2728
|
|
|
2501
2729
|
const owns = this.#ownershipFilter()
|
|
2502
2730
|
const byName = new Map()
|
|
2503
|
-
const
|
|
2504
|
-
|
|
2505
|
-
|
|
2731
|
+
const ownedControl = (name) => {
|
|
2732
|
+
const known = byName.get(name)
|
|
2733
|
+
if (known) return known
|
|
2734
|
+
let radio = null
|
|
2735
|
+
let first = null
|
|
2736
|
+
let control = null
|
|
2506
2737
|
for (const el of this.element.querySelectorAll(`[name="${scoped(name)}"]`)) {
|
|
2507
|
-
if (owns(el))
|
|
2508
|
-
|
|
2738
|
+
if (!owns(el)) continue
|
|
2739
|
+
const kind = el.type // read ONCE per element — see computeText
|
|
2740
|
+
if (kind === "checkbox" || (kind === "radio" && el.checked)) {
|
|
2741
|
+
control = { el, kind }
|
|
2509
2742
|
break
|
|
2510
2743
|
}
|
|
2744
|
+
if (kind === "radio") radio ??= el
|
|
2745
|
+
else first ??= el
|
|
2511
2746
|
}
|
|
2512
|
-
|
|
2513
|
-
|
|
2747
|
+
if (!control) {
|
|
2748
|
+
if (radio) control = { el: radio, kind: "radio" }
|
|
2749
|
+
else control = first ? { el: first, kind: "" } : COMPUTE_NO_CONTROL
|
|
2750
|
+
}
|
|
2751
|
+
byName.set(name, control)
|
|
2752
|
+
return control
|
|
2514
2753
|
}
|
|
2515
2754
|
|
|
2516
2755
|
// Identity-mirror pass (issue #104), ALWAYS run — even with NO registered
|
|
2517
2756
|
// reducer, so reactive_text(:title) mirrors a field into its text node with
|
|
2518
|
-
// zero reducer wiring. Each declared input's RAW
|
|
2519
|
-
//
|
|
2757
|
+
// zero reducer wiring. Each declared input's RAW text reading (computeText —
|
|
2758
|
+
// a checkbox paints "true"/"false", issue #262) is written to its owned
|
|
2759
|
+
// [data-reactive-text="<name>"] node(s). It runs BEFORE the reducer
|
|
2520
2760
|
// early-return below so a reducer-less binding still mirrors.
|
|
2521
|
-
for (const name of inputs) this.#mirrorText(name,
|
|
2761
|
+
for (const name of inputs) this.#mirrorText(name, computeText(ownedControl(name)))
|
|
2522
2762
|
|
|
2523
2763
|
const key = this.element.getAttribute("data-reactive-compute-reducer-param")
|
|
2524
2764
|
const reduce = key ? computeReducer(key) : null
|
|
@@ -2526,26 +2766,18 @@ export default class extends Controller {
|
|
|
2526
2766
|
// No reducer registered: the identity pass above still ran, so declared
|
|
2527
2767
|
// cross-root mirrors of the INPUT names still paint (issue #159) — a
|
|
2528
2768
|
// reducer-less binding mirrors, exactly like the owned-text-node case.
|
|
2529
|
-
this.#applyComputeMirrors({},
|
|
2769
|
+
this.#applyComputeMirrors({}, ownedControl)
|
|
2530
2770
|
return
|
|
2531
2771
|
}
|
|
2532
2772
|
|
|
2533
2773
|
const outputs = this.#parseComputeList("data-reactive-compute-outputs-param")
|
|
2534
2774
|
|
|
2535
|
-
// Coerce each input per its declared type (
|
|
2536
|
-
//
|
|
2537
|
-
//
|
|
2538
|
-
//
|
|
2775
|
+
// Coerce each input per its declared type (computeValue): "string" raw,
|
|
2776
|
+
// "boolean" a real boolean, else ("number", the array-form default) the
|
|
2777
|
+
// numeric coercion. A checkbox contributes its CHECKED STATE under every
|
|
2778
|
+
// type (issue #262). Reads from the memoized resolver — no re-query.
|
|
2539
2779
|
const values = {}
|
|
2540
|
-
for (const [name, type] of inputPairs)
|
|
2541
|
-
const field = ownedField(name)
|
|
2542
|
-
if (type === "string") {
|
|
2543
|
-
values[name] = field?.value ?? ""
|
|
2544
|
-
} else {
|
|
2545
|
-
const n = Number(field?.value)
|
|
2546
|
-
values[name] = Number.isFinite(n) ? n : 0
|
|
2547
|
-
}
|
|
2548
|
-
}
|
|
2780
|
+
for (const [name, type] of inputPairs) values[name] = computeValue(ownedControl(name), type)
|
|
2549
2781
|
|
|
2550
2782
|
// meta.changed stays on #changedComputeField (its own #ownsField check over
|
|
2551
2783
|
// the raw event target) — NOT this resolver. The issue-#15 nested-rejection
|
|
@@ -2564,7 +2796,8 @@ export default class extends Controller {
|
|
|
2564
2796
|
// 1. BATCH the field writes from the ONE result. Each output name in the
|
|
2565
2797
|
// allowlist (outputs:) whose owned field's value actually changes is
|
|
2566
2798
|
// written now (change-guarded) and remembered — but NO `input` event is
|
|
2567
|
-
// dispatched yet, so nothing re-enters mid-batch.
|
|
2799
|
+
// dispatched yet, so nothing re-enters mid-batch. A checkbox or radio
|
|
2800
|
+
// output is written as its CHECKED state (computeWrite, issue #262).
|
|
2568
2801
|
// 2. PAINT the sinks from the SETTLED values: any owned reactive_text node by
|
|
2569
2802
|
// presence (issue #183 change #4 — a text node no longer needs its name in
|
|
2570
2803
|
// outputs:), then the cross-root mirror: ids (issue #159).
|
|
@@ -2578,11 +2811,10 @@ export default class extends Controller {
|
|
|
2578
2811
|
const changedFields = []
|
|
2579
2812
|
for (const name of outputs) {
|
|
2580
2813
|
if (name === "$ops" || !(name in result)) continue
|
|
2581
|
-
const
|
|
2582
|
-
if (!
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
changedFields.push(field)
|
|
2814
|
+
const control = ownedControl(name)
|
|
2815
|
+
if (!control.el) continue // a non-field output paints as a text sink in phase 2
|
|
2816
|
+
const written = computeWrite(this.element, owns, control, scoped(name), result[name])
|
|
2817
|
+
if (written) changedFields.push(written) // null = change-guard, unchanged
|
|
2586
2818
|
}
|
|
2587
2819
|
|
|
2588
2820
|
// Phase 2 — text sinks declare themselves (issue #183 change #4): every result
|
|
@@ -2599,7 +2831,7 @@ export default class extends Controller {
|
|
|
2599
2831
|
|
|
2600
2832
|
// Cross-root text mirrors (issue #159) — AFTER the batch + text sinks, so a
|
|
2601
2833
|
// mirror keyed on a just-written output paints the settled value.
|
|
2602
|
-
this.#applyComputeMirrors(result,
|
|
2834
|
+
this.#applyComputeMirrors(result, ownedControl)
|
|
2603
2835
|
|
|
2604
2836
|
// Phase 3 — dispatch the deferred `input` events (issue #183). Real browsers
|
|
2605
2837
|
// do NOT fire `input` on a programmatic .value write (issue #76), so we do it
|
|
@@ -3167,10 +3399,11 @@ export default class extends Controller {
|
|
|
3167
3399
|
// only (never innerHTML), change-guarded, and NO input dispatch — same
|
|
3168
3400
|
// contract as #mirrorText. With no mirror declared this is one getAttribute
|
|
3169
3401
|
// and out — the shipped compute path never touches the document.
|
|
3170
|
-
#applyComputeMirrors(result,
|
|
3402
|
+
#applyComputeMirrors(result, ownedControl) {
|
|
3171
3403
|
const mirror = this.#parseComputeMirror()
|
|
3172
3404
|
for (const [name, selectors] of Object.entries(mirror)) {
|
|
3173
|
-
const
|
|
3405
|
+
const control = name in result ? null : ownedControl(name)
|
|
3406
|
+
const value = name in result ? result[name] : control.el ? computeText(control) : undefined
|
|
3174
3407
|
if (value === undefined || value === null) continue
|
|
3175
3408
|
const text = String(value)
|
|
3176
3409
|
for (const sel of Array.isArray(selectors) ? selectors : [selectors]) {
|
|
@@ -3894,6 +4127,7 @@ export default class extends Controller {
|
|
|
3894
4127
|
const fields = {}
|
|
3895
4128
|
const files = []
|
|
3896
4129
|
const owns = this.#ownershipFilter() // compute ONCE per dispatch (issue #117)
|
|
4130
|
+
const controls = []
|
|
3897
4131
|
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
3898
4132
|
if (!owns(field)) return
|
|
3899
4133
|
if (field.type === "file") {
|
|
@@ -3901,6 +4135,39 @@ export default class extends Controller {
|
|
|
3901
4135
|
// shape (params[name][]) even when the user picked exactly one file —
|
|
3902
4136
|
// otherwise a [:file] schema would see a lone scalar upload and drop it.
|
|
3903
4137
|
for (const file of field.files ?? []) files.push({ name: field.name, file, multiple: field.multiple })
|
|
4138
|
+
} else {
|
|
4139
|
+
// Held for a second pass: a hidden input is only a companion if a
|
|
4140
|
+
// checkbox somewhere in the root shares its name, which the first
|
|
4141
|
+
// occurrence cannot know yet.
|
|
4142
|
+
controls.push(field)
|
|
4143
|
+
}
|
|
4144
|
+
})
|
|
4145
|
+
// Collected before the first pass so the editor DOM is walked once.
|
|
4146
|
+
const editors = []
|
|
4147
|
+
this.element.querySelectorAll(`[name]${PERSIST_EDITOR_SELECTOR}`).forEach((el) => {
|
|
4148
|
+
if (owns(el)) editors.push(el) // the SAME hoisted predicate (nested reactive root, issue #15)
|
|
4149
|
+
})
|
|
4150
|
+
const arrayNames = this.#arrayFieldNames(controls)
|
|
4151
|
+
const companionNames = this.#companionNames(controls)
|
|
4152
|
+
for (const field of controls) {
|
|
4153
|
+
if (arrayNames.has(field.name)) {
|
|
4154
|
+
const slot = fields[field.name] ?? (fields[field.name] = [])
|
|
4155
|
+
if (field.type === "checkbox" || field.type === "radio") {
|
|
4156
|
+
// An unchecked box contributes NOTHING, the way a native submission
|
|
4157
|
+
// leaves it out. The group's value is the list of checked values, and
|
|
4158
|
+
// with none checked that list stays an EMPTY ARRAY rather than
|
|
4159
|
+
// vanishing: the action can tell "the operator cleared them" from
|
|
4160
|
+
// "the group never rendered", and an [:string] schema coerces [] to
|
|
4161
|
+
// []. A form body cannot carry the empty array, so #buildFormData
|
|
4162
|
+
// announces the group there instead.
|
|
4163
|
+
if (field.checked) slot.push(field.value)
|
|
4164
|
+
} else if (field.type === "hidden") {
|
|
4165
|
+
if (!companionNames.has(field.name)) slot.push(field.value)
|
|
4166
|
+
} else if (field.multiple && field.options) {
|
|
4167
|
+
for (const option of field.options) if (option.selected) slot.push(option.value)
|
|
4168
|
+
} else {
|
|
4169
|
+
slot.push(field.value)
|
|
4170
|
+
}
|
|
3904
4171
|
} else if (field.type === "checkbox") {
|
|
3905
4172
|
fields[field.name] = field.checked
|
|
3906
4173
|
} else if (field.type === "radio") {
|
|
@@ -3908,30 +4175,103 @@ export default class extends Controller {
|
|
|
3908
4175
|
} else {
|
|
3909
4176
|
fields[field.name] = field.value
|
|
3910
4177
|
}
|
|
3911
|
-
}
|
|
4178
|
+
}
|
|
3912
4179
|
// Named rich-text / custom editors (lexxy-editor, trix-editor) and bare
|
|
3913
4180
|
// [contenteditable]. These aren't input/select/textarea, so the query above
|
|
3914
4181
|
// skips them — without this, a reactive save posts an empty value and
|
|
3915
4182
|
// silently wipes the field (issue #8). Read whatever the element exposes:
|
|
3916
4183
|
// a custom editor's serialized `.value`, else its contenteditable text.
|
|
3917
|
-
//
|
|
3918
|
-
// hidden input (e.g. Trix mirrors into one) still wins
|
|
3919
|
-
|
|
3920
|
-
|
|
4184
|
+
// Under a plain name: only fill what the standard controls left absent or
|
|
4185
|
+
// empty, so a synced hidden input (e.g. Trix mirrors into one) still wins
|
|
4186
|
+
// when populated. Under a `[]` name the editor APPENDS to the group slot
|
|
4187
|
+
// instead, and a same-named hidden keeps its own say: nothing here can
|
|
4188
|
+
// tell a hidden that mirrors this editor from one that is a list JS
|
|
4189
|
+
// maintains.
|
|
4190
|
+
editors
|
|
3921
4191
|
.forEach((el) => {
|
|
3922
|
-
if (!owns(el)) return // reuse the SAME hoisted predicate (nested reactive root — issue #15)
|
|
3923
4192
|
// A plain element (e.g. a <div contenteditable>) has no `name` IDL
|
|
3924
4193
|
// property — only the attribute — so read getAttribute, not el.name.
|
|
3925
4194
|
const name = el.getAttribute("name")
|
|
3926
4195
|
if (!name) return
|
|
4196
|
+
const own = el.value ?? el.textContent ?? el.innerHTML ?? ""
|
|
4197
|
+
// A `[]` name is a group here too: the editor APPENDS its value
|
|
4198
|
+
// instead of replacing the slot. Assigning a scalar posted
|
|
4199
|
+
// `{"notes[]": "<p>x</p>"}` while the draft snapshot pushed the same
|
|
4200
|
+
// control into an array — the wire and the draft disagreeing about one
|
|
4201
|
+
// field, and a declared array type seeing a string. A same-named hidden
|
|
4202
|
+
// is NOT read as this editor's twin: nothing here can tell a mirror
|
|
4203
|
+
// from a list JS maintains, and a value posted twice is visible while a
|
|
4204
|
+
// suppressed one is not.
|
|
3927
4205
|
const existing = fields[name]
|
|
4206
|
+
// An editor that has not upgraded yet contributes NOTHING to a group.
|
|
4207
|
+
// Trix defines its elements in a setTimeout after load, and its "" is
|
|
4208
|
+
// not an empty value but an absent one: persistSnapshot omits such an
|
|
4209
|
+
// editor for the same reason, so this keeps the wire and the draft
|
|
4210
|
+
// saying the same thing.
|
|
4211
|
+
if (String(name).endsWith("[]") && !collectorEditorReady(el)) return
|
|
4212
|
+
if (String(name).endsWith("[]") && (existing === undefined || Array.isArray(existing))) {
|
|
4213
|
+
const slot = Array.isArray(existing) ? existing : (fields[name] = [])
|
|
4214
|
+
slot.push(own)
|
|
4215
|
+
return
|
|
4216
|
+
}
|
|
4217
|
+
// A scalar already under a `[]` name can only be a RADIO's: a radio
|
|
4218
|
+
// means "pick one" and keeps its single value with or without the
|
|
4219
|
+
// suffix, which is why #arrayFieldNames excepts it. Converting that to
|
|
4220
|
+
// a group here would discard the chosen value — measured, the post lost
|
|
4221
|
+
// it — so the editor stands down and the rule below applies, which
|
|
4222
|
+
// never overwrites a populated name.
|
|
4223
|
+
// Only fill what the standard controls left absent or empty, so a
|
|
4224
|
+
// synced hidden input still wins when populated.
|
|
3928
4225
|
if (existing == null || existing === "") {
|
|
3929
|
-
fields[name] =
|
|
4226
|
+
fields[name] = own
|
|
3930
4227
|
}
|
|
3931
4228
|
})
|
|
3932
4229
|
return { fields, files }
|
|
3933
4230
|
}
|
|
3934
4231
|
|
|
4232
|
+
// Names collected as an ARRAY rather than a single value: a name carrying the
|
|
4233
|
+
// `[]` suffix, the HTML convention for a group. That suffix is the ONLY
|
|
4234
|
+
// trigger — a group says so, it is not inferred from two controls happening
|
|
4235
|
+
// to share a name. Radios are excluded BY DESIGN: a radio group shares one
|
|
4236
|
+
// name to mean "pick one", and it keeps posting the single checked value,
|
|
4237
|
+
// suffix or not.
|
|
4238
|
+
//
|
|
4239
|
+
// Issue #258: without this, `fields[name] = field.checked` wrote a boolean per
|
|
4240
|
+
// checkbox and same-named boxes overwrote each other, so three boxes with two
|
|
4241
|
+
// ticked left the browser as the LAST box's checked state — the chosen values
|
|
4242
|
+
// never reached the wire, whatever the action's schema declared.
|
|
4243
|
+
#arrayFieldNames(controls) {
|
|
4244
|
+
const names = new Set()
|
|
4245
|
+
for (const field of controls) {
|
|
4246
|
+
// A radio group shares one name BY DESIGN to mean "pick one" and keeps
|
|
4247
|
+
// its single checked value, `[]` suffix or not.
|
|
4248
|
+
if (field.type === "radio") continue
|
|
4249
|
+
if (String(field.name).endsWith("[]")) names.add(field.name)
|
|
4250
|
+
}
|
|
4251
|
+
return names
|
|
4252
|
+
}
|
|
4253
|
+
|
|
4254
|
+
// Names whose group carries a hidden COMPANION: a hidden input is Rails' way
|
|
4255
|
+
// of giving a checkbox a value for the unchecked case, and it is never a
|
|
4256
|
+
// chosen value. Measured from the helpers, the three shapes are:
|
|
4257
|
+
//
|
|
4258
|
+
// check_box(:u, :sub)
|
|
4259
|
+
// <input name="u[sub]" type="hidden" value="0"><input type="checkbox" value="1" name="u[sub]">
|
|
4260
|
+
// check_box(:u, :ids, {multiple: true}, "3")
|
|
4261
|
+
// <input name="u[ids][]" type="hidden" value="0"><input type="checkbox" value="3" name="u[ids][]">
|
|
4262
|
+
// collection_check_boxes(...) / an unchecked_value of nil
|
|
4263
|
+
// <input type="hidden" name="u[ids][]" value=""> — or no hidden at all
|
|
4264
|
+
//
|
|
4265
|
+
// The value differs (unchecked_value, blank, absent), so the value cannot be
|
|
4266
|
+
// the test. What identifies a companion is that a checkbox shares its name.
|
|
4267
|
+
// A hidden WITHOUT a same-named checkbox is a list JS maintains, and its
|
|
4268
|
+
// value is a chosen value like any other.
|
|
4269
|
+
#companionNames(controls) {
|
|
4270
|
+
const names = new Set()
|
|
4271
|
+
for (const field of controls) if (field.type === "checkbox") names.add(field.name)
|
|
4272
|
+
return names
|
|
4273
|
+
}
|
|
4274
|
+
|
|
3935
4275
|
// Re-compute the dirty flag for EVERY field this root owns in one pass (issue
|
|
3936
4276
|
// #103), then reflect the total onto the root. Called on an owned field's input
|
|
3937
4277
|
// (trackDirty), on connect (baseline seed), and after a turbo:morph-element
|
|
@@ -4759,9 +5099,35 @@ export default class extends Controller {
|
|
|
4759
5099
|
const fd = new FormData()
|
|
4760
5100
|
fd.append("token", token)
|
|
4761
5101
|
fd.append("act", action)
|
|
5102
|
+
const emptyGroups = []
|
|
4762
5103
|
for (const [key, value] of Object.entries(params)) {
|
|
4763
|
-
|
|
5104
|
+
// A `[]` name carrying an array is the group shape (issue #258): every
|
|
5105
|
+
// element goes to params[name][], which Rack parses as an array. The
|
|
5106
|
+
// indexed form #appendField writes for a plain array (params[name][0],
|
|
5107
|
+
// params[name][1]) arrives as a hash of index keys — ParamSchema's array
|
|
5108
|
+
// type normalizes that back, but only an array type does, so the two
|
|
5109
|
+
// bodies would stop coercing identically for the same fields.
|
|
5110
|
+
//
|
|
5111
|
+
// An EMPTY group cannot be an empty array in a form body, so it is
|
|
5112
|
+
// ANNOUNCED instead: its key stays absent from params and its name goes
|
|
5113
|
+
// into `empty_groups[]`, a field of its own beside token/act/params. A
|
|
5114
|
+
// blank entry was the obvious alternative and is ambiguous — Rails leaves
|
|
5115
|
+
// `[""]` to the caller, and a `[:date]` or `[:file]` element reads it as
|
|
5116
|
+
// "did not come in", so treating it as "cleared" would change what those
|
|
5117
|
+
// params mean. The field is additive: a server that ignores it behaves
|
|
5118
|
+
// exactly as it does today, and so does a client that never sends it.
|
|
5119
|
+
if (Array.isArray(value) && String(key).endsWith("[]")) {
|
|
5120
|
+
if (value.length === 0) {
|
|
5121
|
+
emptyGroups.push(String(key).slice(0, -2))
|
|
5122
|
+
} else {
|
|
5123
|
+
const wire = `${this.#wireKey(key)}[]`
|
|
5124
|
+
for (const element of value) fd.append(wire, String(element))
|
|
5125
|
+
}
|
|
5126
|
+
} else {
|
|
5127
|
+
this.#appendField(fd, this.#wireKey(key), value)
|
|
5128
|
+
}
|
|
4764
5129
|
}
|
|
5130
|
+
for (const name of emptyGroups) fd.append("empty_groups[]", name)
|
|
4765
5131
|
const multiNames = this.#multiFileNames(files)
|
|
4766
5132
|
for (const { name, file, multiple } of files) {
|
|
4767
5133
|
// params[name][] when the input is `multiple` (array shape even for one
|