feat(api): streamline generated app authoring
Move the canonical examples toward generated component-root helpers, typed form decoding, async/state handler registration, and derive-driven app/component registry wiring. Tighten requirements and diagnostics for the server-first, selectorless authoring path. Verified with cargo run -p slhx-xtask -- test, cargo check --workspace, redgate list, redgate refs, redgate health --strict, and git diff --check. req: canonical/001 req: canonical/003 req: canonical/004 req: dx/002 req: derive_app/001 req: component/003 req: form/004 req: axum_integration/003
This commit is contained in:
+22
-13
@@ -134,9 +134,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formDataFor(el, eventName) {
|
||||
function formDataFor(el, eventName, source = el) {
|
||||
const form = formOwner(el);
|
||||
const data = form ? new FormData(form) : new FormData();
|
||||
if (form && source && source !== form && source.name && !source.disabled) data.append(source.name, source.value);
|
||||
const id = handleId(el) || formHandleId(form);
|
||||
if (id && !data.has("__h")) data.set("__h", id);
|
||||
const dragKey = eventName === "drop" && dragKeys.get(rootOf(el));
|
||||
@@ -168,9 +169,9 @@
|
||||
return url.href;
|
||||
}
|
||||
|
||||
async function send(el, eventName) {
|
||||
async function send(el, eventName, source = el) {
|
||||
if (el.getAttribute("data-slhx-confirm") && !confirm(el.getAttribute("data-slhx-confirm"))) return;
|
||||
const { form, data, multipart } = formDataFor(el, eventName);
|
||||
const { form, data, multipart } = formDataFor(el, eventName, source);
|
||||
const target = form || el;
|
||||
const policy = requestPolicy(target, eventName);
|
||||
const active = pending.get(target);
|
||||
@@ -182,7 +183,7 @@
|
||||
if (active && policy === "queue") {
|
||||
const base = queues.get(target) || active.done;
|
||||
let queued;
|
||||
const next = base.then(() => send(el, eventName));
|
||||
const next = base.then(() => send(el, eventName, source));
|
||||
queued = next.catch(() => {}).finally(() => {
|
||||
if (queues.get(target) === queued) queues.delete(target);
|
||||
});
|
||||
@@ -309,11 +310,12 @@
|
||||
}
|
||||
const target = targetFor(scope, op.target);
|
||||
if (!target) return missing(scope, op.target);
|
||||
putPayload(target, op.payload);
|
||||
if (op.target.scope && op.target.scope.kind === "key" && op.payload.kind === "html") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id);
|
||||
else putPayload(target, op.payload);
|
||||
} else if (op.kind === "insert" || op.kind === "prepend") {
|
||||
const target = targetFor(scope, op.target);
|
||||
if (!target) return missing(scope, op.target);
|
||||
const nodes = fragmentNodes(op.payload, op.key);
|
||||
const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id);
|
||||
target[op.kind === "prepend" ? "prepend" : "append"](...nodes);
|
||||
} else if (op.kind === "remove") {
|
||||
const target = op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target);
|
||||
@@ -448,13 +450,20 @@
|
||||
else target.textContent = payload.value;
|
||||
}
|
||||
|
||||
function fragmentNodes(payload, key) {
|
||||
function replacePayload(target, payload, key, resourceId) {
|
||||
const nodes = fragmentNodes(payload, key, resourceId);
|
||||
if (nodes.length) target.replaceWith(...nodes);
|
||||
else target.innerHTML = "";
|
||||
}
|
||||
|
||||
function fragmentNodes(payload, key, resourceId) {
|
||||
const template = document.createElement("template");
|
||||
if (payload.kind === "html") template.innerHTML = payload.value;
|
||||
else template.textContent = payload.value;
|
||||
const nodes = Array.from(template.content.childNodes);
|
||||
const firstElement = nodes.find((node) => node.nodeType === 1);
|
||||
if (firstElement && key != null && !firstElement.hasAttribute("data-key")) firstElement.setAttribute("data-key", key);
|
||||
if (firstElement && resourceId != null && !firstElement.hasAttribute("data-sid")) firstElement.setAttribute("data-sid", resourceId);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
@@ -589,7 +598,7 @@
|
||||
if (form && root.contains(form) && formHandleId(form)) {
|
||||
if (form.reportValidity && !form.reportValidity()) return;
|
||||
event.preventDefault();
|
||||
schedule(form, "submit");
|
||||
schedule(form, "submit", submitter);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -600,23 +609,23 @@
|
||||
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target;
|
||||
if (!el || defaultEvent(el) !== name) return;
|
||||
event.preventDefault();
|
||||
schedule(el, name);
|
||||
schedule(el, name, event.submitter || el);
|
||||
});
|
||||
});
|
||||
bindPolling(root);
|
||||
}
|
||||
|
||||
function schedule(el, eventName) {
|
||||
function schedule(el, eventName, source = el) {
|
||||
const debounce = duration(el.getAttribute("data-slhx-debounce"));
|
||||
const throttle = duration(el.getAttribute("data-slhx-throttle"));
|
||||
if (debounce) {
|
||||
clearTimeout(timers.get(el));
|
||||
timers.set(el, setTimeout(() => send(el, eventName), debounce));
|
||||
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
|
||||
} else if (throttle) {
|
||||
if (timers.get(el)) return;
|
||||
send(el, eventName).finally(() => setTimeout(() => timers.delete(el), throttle));
|
||||
send(el, eventName, source).finally(() => setTimeout(() => timers.delete(el), throttle));
|
||||
} else {
|
||||
send(el, eventName);
|
||||
send(el, eventName, source);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
-15
@@ -14,7 +14,9 @@ fn runtime_preserves_multipart_file_upload_fallback_shape() {
|
||||
// req: multipart/003
|
||||
let source = slhx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("multipart: form && String(form.enctype).toLowerCase() === \"multipart/form-data\""));
|
||||
assert!(source.contains(
|
||||
"multipart: form && String(form.enctype).toLowerCase() === \"multipart/form-data\""
|
||||
));
|
||||
assert!(source.contains("if (value instanceof File) continue"));
|
||||
assert!(source.contains("return multipart ? data : urlEncoded(data)"));
|
||||
assert!(source.contains("if (body instanceof URLSearchParams) headers[\"Content-Type\"] = \"application/x-www-form-urlencoded;charset=UTF-8\""));
|
||||
@@ -46,6 +48,13 @@ fn runtime_targets_generated_resources_not_response_selectors() {
|
||||
assert!(source.contains("function firstElement(scope, predicate)"));
|
||||
assert!(source.contains("function generatedResource(el, id)"));
|
||||
assert!(source.contains("return firstElement(scope, (el) => attrEquals(el, \"data-key\", key) && withinGeneratedResource(el, scope, id))"));
|
||||
assert!(
|
||||
source.contains("const nodes = fragmentNodes(op.payload, op.key, op.target.resource.id)")
|
||||
);
|
||||
assert!(source.contains("if (op.target.scope && op.target.scope.kind === \"key\" && op.payload.kind === \"html\") replacePayload(target, op.payload, op.target.scope.value, op.target.resource.id)"));
|
||||
assert!(source.contains("function replacePayload(target, payload, key, resourceId)"));
|
||||
assert!(source.contains("target.replaceWith(...nodes)"));
|
||||
assert!(source.contains("firstElement.setAttribute(\"data-sid\", resourceId)"));
|
||||
assert!(source.contains("const target = generatedTarget(scope, id)"));
|
||||
assert!(source.contains("if (!target) return missing(scope, op.target)"));
|
||||
assert!(!source.contains("data-slhx-target"));
|
||||
@@ -60,8 +69,12 @@ fn runtime_interval_dispatch_avoids_duplicate_timers() {
|
||||
assert!(source.contains("const everyTimers = new WeakMap()"));
|
||||
assert!(source.contains("forEachElement(root, (el) =>"));
|
||||
assert!(source.contains("!el.hasAttribute(\"data-slhx-every\") || everyTimers.has(el)"));
|
||||
assert!(source.contains("if (!el.hasAttribute(\"data-slhx-every\") || everyTimers.has(el)) return"));
|
||||
assert!(source.contains("setInterval(() => document.contains(el) ? send(el, \"every\") : stopPolling(el), ms)"));
|
||||
assert!(
|
||||
source.contains("if (!el.hasAttribute(\"data-slhx-every\") || everyTimers.has(el)) return")
|
||||
);
|
||||
assert!(source.contains(
|
||||
"setInterval(() => document.contains(el) ? send(el, \"every\") : stopPolling(el), ms)"
|
||||
));
|
||||
assert!(source.contains("function stopPolling(el)"));
|
||||
assert!(source.contains("clearInterval(everyTimers.get(el))"));
|
||||
assert!(source.contains("everyTimers.delete(el)"));
|
||||
@@ -84,10 +97,13 @@ fn runtime_toggles_pending_conventions_around_requests() {
|
||||
assert!(source.contains("indicator.hidden = state.hidden"));
|
||||
assert!(source.contains("el.hasAttribute(\"data-slhx-disable-while-pending\")"));
|
||||
assert!(source.contains("if (isDisableControl(el)) controls.push(el)"));
|
||||
assert!(source.contains("forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); })"));
|
||||
assert!(source.contains(
|
||||
"forEachElement(el, (child) => { if (isDisableControl(child)) controls.push(child); })"
|
||||
));
|
||||
assert!(source.contains("const disabledStates = new WeakMap()"));
|
||||
assert!(source.contains("function toggleDisabled(control, on)"));
|
||||
assert!(source.contains("else disabledStates.set(control, { count: 1, disabled: control.disabled })"));
|
||||
assert!(source
|
||||
.contains("else disabledStates.set(control, { count: 1, disabled: control.disabled })"));
|
||||
assert!(source.contains("control.disabled = state.disabled"));
|
||||
assert!(source.contains("controls.forEach((c) => toggleDisabled(c, on))"));
|
||||
assert!(source.contains("showPending(target, true)"));
|
||||
@@ -128,13 +144,20 @@ fn runtime_clicking_submitter_schedules_form_submit() {
|
||||
assert!(source.contains("function formOwner(el)"));
|
||||
assert!(source.contains("function formHandleId(form)"));
|
||||
assert!(source.contains("function elementById(scope, id)"));
|
||||
assert!(source.contains("return elementById(rootOf(el) || document, el.getAttribute(\"form\"))"));
|
||||
assert!(
|
||||
source.contains("return elementById(rootOf(el) || document, el.getAttribute(\"form\"))")
|
||||
);
|
||||
assert!(!source.contains("document.getElementById(el.getAttribute(\"form\"))"));
|
||||
assert!(source.contains("const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID))"));
|
||||
assert!(source.contains(
|
||||
"const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID))"
|
||||
));
|
||||
assert!(source.contains("const submitter = closestInRoot(event.target, root, (el) =>"));
|
||||
assert!(source.contains("el.tagName === \"BUTTON\" && (!el.hasAttribute(\"type\") || el.getAttribute(\"type\") === \"submit\")"));
|
||||
assert!(source.contains("form.reportValidity && !form.reportValidity()"));
|
||||
assert!(source.contains("schedule(form, \"submit\")"));
|
||||
assert!(source.contains("schedule(form, \"submit\", submitter)"));
|
||||
assert!(source.contains("function formDataFor(el, eventName, source = el)"));
|
||||
assert!(source.contains("data.append(source.name, source.value)"));
|
||||
assert!(source.contains("schedule(el, name, event.submitter || el)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -156,7 +179,9 @@ fn runtime_supports_queued_request_policy() {
|
||||
|
||||
assert!(source.contains("function normalizedPolicy(value)"));
|
||||
assert!(source.contains("value === \"latest\" || value === \"queue\" || value === \"drop\" || value === \"parallel\""));
|
||||
assert!(source.contains("const policy = normalizedPolicy(el.getAttribute(\"data-slhx-policy\"))"));
|
||||
assert!(
|
||||
source.contains("const policy = normalizedPolicy(el.getAttribute(\"data-slhx-policy\"))")
|
||||
);
|
||||
assert!(source.contains("const queues = new WeakMap()"));
|
||||
assert!(source.contains("policy === \"queue\""));
|
||||
assert!(source.contains("const base = queues.get(target) || active.done"));
|
||||
@@ -176,7 +201,9 @@ fn runtime_parallel_request_policy_releases_each_pending_state() {
|
||||
// req: convention/006 req: convention/007 req: convention/008
|
||||
let source = slhx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("else if (policy === \"parallel\") {\n showPending(target, false);\n }"));
|
||||
assert!(source.contains(
|
||||
"else if (policy === \"parallel\") {\n showPending(target, false);\n }"
|
||||
));
|
||||
assert!(source.contains("const pendingClassStates = new WeakMap()"));
|
||||
assert!(source.contains("const indicatorStates = new WeakMap()"));
|
||||
assert!(source.contains("const disabledStates = new WeakMap()"));
|
||||
@@ -256,8 +283,10 @@ fn runtime_applies_sse_effect_batches_inside_roots() {
|
||||
assert!(source.contains("if (href.origin !== location.origin)"));
|
||||
assert!(source.contains("emit(root, \"slhx:sse-error\", url)"));
|
||||
assert!(source.contains("new EventSource(href.href)"));
|
||||
assert!(source.contains("source.addEventListener(\"slhx\", (event) => applySseMessage(root, event))"));
|
||||
assert!(source.contains("source.addEventListener(\"message\", (event) => applySseMessage(root, event))"));
|
||||
assert!(source
|
||||
.contains("source.addEventListener(\"slhx\", (event) => applySseMessage(root, event))"));
|
||||
assert!(source
|
||||
.contains("source.addEventListener(\"message\", (event) => applySseMessage(root, event))"));
|
||||
assert!(source.contains("applyBatch(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), root)"));
|
||||
assert!(source.contains("emit(root, \"slhx:sse-error\", url)"));
|
||||
}
|
||||
@@ -278,16 +307,22 @@ fn runtime_keeps_form_field_targets_separate_from_error_targets() {
|
||||
let source = slhx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("function fieldTarget(scope, id, field)"));
|
||||
assert!(source.contains("attrEquals(el, \"name\", field) && withinGeneratedForm(el, scope, id)"));
|
||||
assert!(
|
||||
source.contains("attrEquals(el, \"name\", field) && withinGeneratedForm(el, scope, id)")
|
||||
);
|
||||
assert!(source.contains("function formErrorTarget(scope, id, field)"));
|
||||
assert!(source.contains("attrEquals(el, \"data-slhx-error-for\", field) && withinGeneratedForm(el, scope, id)"));
|
||||
assert!(source.contains(
|
||||
"attrEquals(el, \"data-slhx-error-for\", field) && withinGeneratedForm(el, scope, id)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_preflights_batches_before_applying_ops() {
|
||||
let source = slhx_js::RUNTIME_JS;
|
||||
|
||||
assert!(source.contains("const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean)"));
|
||||
assert!(source.contains(
|
||||
"const missingTarget = batch.ops.map((op) => canApplyOp(scope, op)).find(Boolean)"
|
||||
));
|
||||
assert!(source.contains("function canApplyOp(scope, op)"));
|
||||
assert!(source.contains("for (const op of batch.ops) applyOp(scope, op)"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user