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:
Generated
+1
-1
@@ -401,7 +401,6 @@ dependencies = [
|
|||||||
name = "hemplate-core"
|
name = "hemplate-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
|
||||||
"hemplate-parser",
|
"hemplate-parser",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
@@ -1397,6 +1396,7 @@ dependencies = [
|
|||||||
name = "slhx-derive"
|
name = "slhx-derive"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|||||||
+89
-47
@@ -1,9 +1,11 @@
|
|||||||
# slhx — Semantic, Laterally HX
|
# slhx — Semantic, Laterally HX
|
||||||
|
|
||||||
slhx does not compete with React by becoming a better frontend framework.
|
slhx makes server-rendered HTML feel like it grew just enough interactivity.
|
||||||
slhx competes with React by making frontend frameworks unnecessary for most apps.
|
App authors change state in Rust, render hemplate partials, and return generated
|
||||||
|
UI intent; the runtime swaps generated slots without selectors, a VDOM, or a
|
||||||
|
client app state framework.
|
||||||
|
|
||||||
> **hemplate owns syntax. hemplate emits surface. slhx consumes surface. slhx owns semantics. JS executes bytecode.**
|
> **hemplate owns syntax. hemplate emits surface. slhx consumes surface. slhx owns semantics. JS applies effects.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
## pitch
|
## pitch
|
||||||
|
|
||||||
### req: pitch/001
|
### req: pitch/001
|
||||||
001 slhx is checked hypermedia for Rust. Write `.heml`, write `#[slhx::handler]`, return commands. The compiler checks every cross-file reference. The browser runtime only sees ids and effect bytes. No JS app code required. [north_star]
|
001 slhx is checked hypermedia for Rust. Write `.heml`, write Rust handlers, return generated UI commands. The compiler checks every cross-file reference. The browser runtime only sees lowered ids and effect bytes. No JS app code is required for ordinary server-first apps. [north_star]
|
||||||
|
|
||||||
### req: pitch/002
|
### req: pitch/002
|
||||||
002 No CSS selectors. No hx-* strings. No virtual DOM. No client framework. No hidden global proxy magic. No component hydration. SSR state bootstrap via `data-slhx-st` is allowed, but the browser never reconstructs a component tree. [north_star]
|
002 No CSS selectors. No hx-* strings. No virtual DOM. No client framework. No hidden global proxy magic. No component hydration. SSR state bootstrap via `data-slhx-st` is allowed, but the browser never reconstructs a component tree. [north_star]
|
||||||
@@ -38,33 +40,61 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
003 slhx replaces React/Vue not with a UI framework, but with a compiler contract: hemplate knows the surface, Rust knows the types, slhx knows the effects, the browser only executes commands. [north_star]
|
003 slhx replaces React/Vue not with a UI framework, but with a compiler contract: hemplate knows the surface, Rust knows the types, slhx knows the effects, the browser only executes commands. [north_star]
|
||||||
|
|
||||||
### req: pitch/004
|
### req: pitch/004
|
||||||
004 The north-star feel: **Svelte at the call site, Rust at the boundary.** Short handler bodies, compile-checked HTML contracts, one language for server and client logic. [north_star]
|
004 The north-star feel: **boring server-rendered HTML with typed, selectorless partial swaps.** Short handler bodies, compile-checked HTML contracts, plain Rust state changes, hemplate-rendered partials, and a tiny runtime that applies effects. [north_star]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## canonical_authoring
|
||||||
|
|
||||||
|
### req: canonical/001
|
||||||
|
001 The canonical app shape is templates plus Rust, not a frontend folder: `.heml` files declare `data-slhx-root`, `data-slhx-slot`, `data-slhx-handle`, `data-slhx-form`, `h-key`, optional pending/page/island facts, and handlers return generated UI commands. Ordinary app code avoids selectors, numeric ids, raw effects, wire formats, manual registries/form parsing, raw `SafeHtml`, and raw render calls. Plain CSS owns appearance. [north_star]
|
||||||
|
|
||||||
|
### req: canonical/002
|
||||||
|
002 Typed partial swaps are the primary UX, not an advanced feature: handlers change domain state in Rust, convert domain values into view values, render hemplate partials through generated helpers, and place them into generated targets. The real primitive is generated target + rendered partial + swap kind. [north_star]
|
||||||
|
|
||||||
|
### req: canonical/003
|
||||||
|
003 Canonical keyed-row CRUD reads like ordinary Rust intent: create appends a rendered row partial, update/toggle replaces a keyed row partial, delete removes a keyed row, summary/text/form effects compose in tuples or arrays implementing `IntoEffect`, and no handler chooses a target with a CSS selector. [north_star]
|
||||||
|
|
||||||
|
### req: canonical/004
|
||||||
|
004 Generated helpers may compose only facts uniquely known from templates and checked Rust types: template, slot, optional key, form/control, class token, explicit island/event marker, and effect kind. If a handler parameter, key, form, target, raw route, or legacy target would require guessing, the user must say it explicitly and diagnostics must point to the Rust and hemplate spans. [north_star]
|
||||||
|
|
||||||
|
### req: canonical/005
|
||||||
|
005 Good generated helpers name UI intent without mixing domain work: `ui::content.replace(page)`, `ui::todos.append(todo)`, `ui::todo_row.replace(todo)`, `ui::todo_row.remove(todo_id)`, `ui::summary.set(text)`, `ui::notice.set("Saved")`, `ui::new_todo.clear()`, `ui::new_todo.focus("title")`, `ui::modal.replace(view)`, `ui::errors.set(errors)`, `ui::chart_path.set_attr("d", path)`, and `ui::game.emit(event)`. Helpers such as `refresh`, `save_and_update`, `sync_component`, and generic `rerender` are forbidden because they mix persistence, routing, rendering, target selection, or domain policy. [north_star]
|
||||||
|
|
||||||
|
### req: canonical/006
|
||||||
|
006 There is no separate beginner API and expert API. The simple generated shape is canonical and should scale: generated slots, partials, forms, class constants, islands/events, and page helpers are normal. Explicit primitives, raw targets, raw HTML, raw effects, manual registries/form parsing, low-level ids/opcodes, wire formats, and raw routes remain named escape hatches or internals around the same render/target/effect/transport model. [north_star]
|
||||||
|
|
||||||
|
### req: canonical/007
|
||||||
|
007 Opaque islands are explicit leaf adapters: templates declare `data-slhx-island` and optional generated handles/events; server code may emit snapshots/events such as `ui::game.emit(event)`, while island JS owns only high-frequency local behavior. Islands do not introduce a component runtime, client state graph, VDOM, or second UI model. [north_star]
|
||||||
|
|
||||||
|
### req: canonical/008
|
||||||
|
008 Offline/PWA support is opt-in adapter territory. Server-first slhx may fail interactions while offline; cached shells and local-sync queues live in crates such as `slhx-pwa` or `slhx-sync`, reuse generated slots/effects, queue explicit patches, and reconcile with server-canonical effects. Core slhx must not gain a mandatory client state graph, scheduler, CRDT, or local app runtime. [north_star]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## modes
|
## modes
|
||||||
|
|
||||||
### req: mode/001
|
### req: mode/001
|
||||||
001 slhx has two happy paths: Page Enhancer and Interaction Handler.
|
001 slhx has one core authoring loop: render a partial and place it into a generated target with a swap kind. HTTP handlers, page navigation, push streams, and island events are transport/adapters around that loop.
|
||||||
|
|
||||||
### req: mode/002
|
### req: mode/002
|
||||||
002 Page Enhancer mode replaces minimal HTMX page swapping. Authors use real anchors with `data-slhx-nav` or `data-slhx-boost`; no user-authored handler is required.
|
002 Page Enhancer mode is a specialized partial swap for navigation: it updates generated page/content/title/nav targets and adds history, scroll, shell, and fallback behavior. Authors use real anchors with `data-slhx-nav` or `data-slhx-boost`; no user-authored handler is required for ordinary navigation.
|
||||||
|
|
||||||
### req: mode/003
|
### req: mode/003
|
||||||
003 Interaction Handler mode handles forms, buttons, typed params, and targeted updates through `#[slhx::handler]`.
|
003 Interaction Handler mode handles forms, buttons, typed params, and generated partial/text/form/island effects through Rust handlers.
|
||||||
|
|
||||||
### req: mode/004
|
### req: mode/004
|
||||||
004 Beginner docs must teach Page Enhancer first, Interaction Handler second, Atoms third, client-local/WASM fourth, sync last.
|
004 Beginner docs must teach server-first typed partial swaps first, Page Enhancer as navigation around the same slot/effect model, explicit islands for leaf widgets, client-local/WASM only for local high-frequency behavior, and sync/offline last as opt-in adapters.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## dx
|
## dx
|
||||||
|
|
||||||
### req: dx/001
|
### req: dx/001
|
||||||
001 The common case must feel like writing a Svelte/Vue component: template, state, handlers, and targeted updates. Users should not need to understand Surface IR, ResourceId, EffectWriter, postcard, or runtime opcodes for basic apps. [north_star]
|
001 The common case must feel like writing server-rendered HTML plus tiny Rust handlers: template, state, hemplate partials, and generated UI swaps. Users should not need to understand Surface IR, ResourceId, EffectWriter, postcard, runtime opcodes, selector targeting, or manual registries for basic apps. [north_star]
|
||||||
|
|
||||||
### req: dx/002
|
### req: dx/002
|
||||||
002 The happy path is: write `.heml`, write `#[slhx::handler]`, return generated slot/atom commands. No manual ids, no manual registry, no manual serialization, no manual JavaScript. [north_star]
|
002 The happy path is: write `.heml`, write a Rust handler, return generated partial/text/form/page/island commands. No manual ids, no manual registry, no manual serialization, no CSS selector targets, no raw render calls, and no manual JavaScript for ordinary app UI. [north_star]
|
||||||
|
|
||||||
### req: dx/003
|
### req: dx/003
|
||||||
003 Public APIs are generated around the user's names. If the template declares `data-slhx-slot="todo_list"`, the user gets `slots::todo_list`, not `SlotId(12)`.
|
003 Public APIs are generated around the user's names. If the template declares `data-slhx-slot="todo_list"`, the user gets `slots::todo_list`, not `SlotId(12)`.
|
||||||
@@ -76,7 +106,7 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
005 Error messages must explain fixes in author language, not internal language. Say “add `h-key="todo.id"` to this `h-for`”, not “missing ScopeKey for ResourceRef”.
|
005 Error messages must explain fixes in author language, not internal language. Say “add `h-key="todo.id"` to this `h-for`”, not “missing ScopeKey for ResourceRef”.
|
||||||
|
|
||||||
### req: dx/006
|
### req: dx/006
|
||||||
006 Generated target objects are the preferred authoring API: `targets::todo_list.put(&view)`, `targets::card.append(key, &view)`, `targets::count.text(42)`, `atoms::user.set(user)`. The public facade exposes `render(view)` for trusted hemplate-to-`SafeHtml` page and fragment composition; generated view modules expose lower-aware target methods plus compatibility functions `render(view)`, `put(slot, view)`, `append/prepend/replace(keyed_slot, key, view)`, `static_fragment(include_str!(...))` for prototype/static `.heml` fragments that need generated resource lowering as `SafeHtml`, and `lower(html)` for callers that need the lowered string. `render_html(view)` and `lower_html(html)` remain doc-hidden compatibility aliases and are not beginner-prelude exports. The beginner prelude should not expose lower-level slot render shortcuts that bypass generated lowering. These return `impl IntoEffect`, `SafeHtml`, or lowered HTML at the boundary. Raw `Effect` constructors, opcodes, and `EffectWriter` remain low-level. [north_star]
|
006 Generated object-like helpers are the preferred authoring API and are re-exported at the component root: `todos.append(todo)`, `todo_row.replace(todo)`, `todo_row.remove(todo_id)`, `summary.set(text)`, `new_todo.clear()`, `new_todo.focus("title")`, `page.replace(view)`, and `game.emit(event)`. These helpers hide hemplate rendering and resource lowering in the common path. Namespaced `targets`, `handles`, `forms`, and raw `advanced::slots` modules remain compatibility/organization and escape-hatch surfaces, not the ordinary call-site shape. The public facade may expose explicit `render(view)`, `target(name)`, `html(value)`, `lower(html)`, or raw effect constructors only as named escape hatches; they must not appear in beginner-prelude exports, canonical handler examples, or ordinary docs. [north_star]
|
||||||
|
|
||||||
### req: dx/007
|
### req: dx/007
|
||||||
007 Tuple composition of `IntoEffect` is the canonical batch syntax: `(a, b, c)` implements `IntoEffect` up to arity 12. `Effect::batch((...))` is available but not required for the happy path.
|
007 Tuple composition of `IntoEffect` is the canonical batch syntax: `(a, b, c)` implements `IntoEffect` up to arity 12. `Effect::batch((...))` is available but not required for the happy path.
|
||||||
@@ -105,7 +135,7 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
## progressive_disclosure
|
## progressive_disclosure
|
||||||
|
|
||||||
### req: pd/001
|
### req: pd/001
|
||||||
001 A beginner can build CRUD with only: `.heml`, `#[slhx::handler]`, `Form<T>`, generated `slots::*` methods, and `impl IntoEffect`.
|
001 A beginner can build CRUD with only: `.heml`, Rust handlers, `Form<T>` or typed params, generated object-like UI helpers, and `impl IntoEffect`.
|
||||||
|
|
||||||
### req: pd/002
|
### req: pd/002
|
||||||
002 Atoms are not required for basic server-first apps. They appear only when client-local state, SSR bootstrapped state, or WASM handlers are used.
|
002 Atoms are not required for basic server-first apps. They appear only when client-local state, SSR bootstrapped state, or WASM handlers are used.
|
||||||
@@ -114,23 +144,23 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
003 Sync, transitions, resources/queries, islands, capabilities, and raw EffectWriter are advanced layers. They must not appear in starter examples.
|
003 Sync, transitions, resources/queries, islands, capabilities, and raw EffectWriter are advanced layers. They must not appear in starter examples.
|
||||||
|
|
||||||
### req: pd/004
|
### req: pd/004
|
||||||
004 Documentation must present three levels: server-first, client-local, hybrid-sync. Each level introduces only the new primitive it needs.
|
004 Documentation must present levels as adapters around the same core: server-first partial swaps; cached Page/PWA shell; explicit leaf islands or client-local handlers for high-frequency behavior; hybrid sync/offline queues last. Each level introduces only the new primitive it needs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## page_swap
|
## page_swap
|
||||||
|
|
||||||
### req: page_swap/001
|
### req: page_swap/001
|
||||||
001 Minimal page swapping is a first-class slhx-axum happy path. Authors mark real anchors with `data-slhx-nav`; links keep valid `href` and work without JS. Missing or empty static `href` on a `data-slhx-nav` anchor is a build error.
|
001 Page swapping is a first-class specialization of partial swapping: render a page partial, place it into generated page targets, then apply history/title/scroll/shell behavior. Authors mark real anchors with `data-slhx-nav`; links keep valid `href` and work without JS. Missing or empty static `href` on a `data-slhx-nav` anchor is a build error.
|
||||||
|
|
||||||
### req: page_swap/002
|
### req: page_swap/002
|
||||||
002 A `data-slhx-nav` click fetches the target URL as a slhx partial request. The response updates the canonical content slot, optionally navigation and title, then applies a `Navigate` effect.
|
002 A `data-slhx-nav` click fetches the target URL as a slhx partial request. The response is conceptually `ui::content.replace(page)` plus optional generated nav/title targets and a `Navigate` effect; it must not introduce selector targeting or a second page-specific UI model.
|
||||||
|
|
||||||
### req: page_swap/003
|
### req: page_swap/003
|
||||||
003 Page swapping uses generated slots, not CSS selectors. The default content target is the slot named `content`, not `#content`.
|
003 Page swapping uses generated targets, not CSS selectors. The default content target is the generated slot named `content`, not `#content`; explicit page helpers such as `ui::content.page(req, view)` or `request.page_html(ui::content.render(view), shell)` are adapters around the same partial-swap primitive.
|
||||||
|
|
||||||
### req: page_swap/004
|
### req: page_swap/004
|
||||||
004 Minimal page swap must not require user-authored `#[slhx::handler]`. Explicit navigation handlers are available only when custom application logic is needed.
|
004 Ordinary page navigation must not require user-authored handlers. Explicit navigation handlers are available only when custom application logic is needed, and they still return generated target/page commands.
|
||||||
|
|
||||||
### req: page_swap/005
|
### req: page_swap/005
|
||||||
005 Browser back/forward is supported. On `popstate`, slhx fetches the URL as a partial request and applies the same page-swap update without pushing a new history entry.
|
005 Browser back/forward is supported. On `popstate`, slhx fetches the URL as a partial request and applies the same page-swap update without pushing a new history entry.
|
||||||
@@ -149,10 +179,10 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
001 slhx replaces common HTMX use-cases through typed equivalents, not HTMX syntax.
|
001 slhx replaces common HTMX use-cases through typed equivalents, not HTMX syntax.
|
||||||
|
|
||||||
### req: htmx/002
|
### req: htmx/002
|
||||||
002 Easy equivalents must exist for: boosted links/forms, page swap, form submit, targeted replacement, append/prepend/remove, loading indicators, confirmation, debounce/throttle, polling, history navigation, multi-target updates, response events, SSE/push, and validation errors.
|
002 Easy equivalents must exist for: generated target replacement, append/prepend/remove, boosted links/forms, page swap, form submit, loading indicators, confirmation, debounce/throttle, polling, history navigation, multi-target updates, response events, SSE/push, validation errors, modals, toasts, table rows, SVG fragments, and form error regions.
|
||||||
|
|
||||||
### req: htmx/003
|
### req: htmx/003
|
||||||
003 slhx core deliberately does not clone selector-based HTMX features: `hx-target` selectors, `hx-select`, `hx-include` selectors, `closest/find/this` target strings, or trigger mini-languages. Equivalent patterns use generated slots, typed params, forms, and explicit handlers.
|
003 slhx core deliberately does not clone selector-based HTMX features: `hx-target` selectors, `hx-select`, `hx-include` selectors, `closest/find/this` target strings, or trigger mini-languages. Equivalent patterns use generated targets, typed params, forms, explicit handlers, and page/push adapters around partial swaps.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -166,8 +196,8 @@ slhx competes with React by making frontend frameworks unnecessary for most apps
|
|||||||
|
|
||||||
### req: component/003
|
### req: component/003
|
||||||
003 Generated APIs are component-namespaced by default:
|
003 Generated APIs are component-namespaced by default:
|
||||||
`ui::todo_list::slots::todo_row`, `ui::todo_list::handles::create`, `ui::todo_list::forms::create`, and `ui::todo_list::COMPONENT` as a checked `ComponentRef`.
|
`ui::todo_list::todo_row`, `ui::todo_list::create`, `ui::todo_list::new_todo`, and `ui::todo_list::COMPONENT` as a checked `ComponentRef`.
|
||||||
Global exports (`ui::slots::*`, `ui::handles::*`, `ui::components::*`) are opt-in only.
|
Category modules such as `targets`, `handles`, and `forms` remain available for organization/compatibility, while raw slot constants live under `advanced::slots`; global exports (`ui::slots::*`, `ui::handles::*`, `ui::components::*`) are opt-in only.
|
||||||
|
|
||||||
### req: component/004
|
### req: component/004
|
||||||
004 `#[slhx::surface]` bridges generated code into a user module. Users write `#[slhx::surface] mod ui {}` instead of `include!(concat!(env!("OUT_DIR"), ...))`. slhx-build emits `slhx.generated.rs` which the macro expands in place. No direct `$OUT_DIR` includes in user-authored source.
|
004 `#[slhx::surface]` bridges generated code into a user module. Users write `#[slhx::surface] mod ui {}` instead of `include!(concat!(env!("OUT_DIR"), ...))`. slhx-build emits `slhx.generated.rs` which the macro expands in place. No direct `$OUT_DIR` includes in user-authored source.
|
||||||
@@ -226,7 +256,7 @@ a `data-*` handle param is statically known or runtime-extracted.
|
|||||||
001 `slhx_build` generates three artifacts from the generic Surface IR: (a) `slhx.generated.rs` containing ergonomic resource modules (`slots`, `targets`, `handles`, `forms`, `atoms`), (b) `slhx.syms` for proc-macro validation, (c) runtime id-lowering tables. `slhx_build` interprets tool-specific conventions (`data-slhx-*`, `h-for`, `h-key`, form controls) from the Surface. [north_star]
|
001 `slhx_build` generates three artifacts from the generic Surface IR: (a) `slhx.generated.rs` containing ergonomic resource modules (`slots`, `targets`, `handles`, `forms`, `atoms`), (b) `slhx.syms` for proc-macro validation, (c) runtime id-lowering tables. `slhx_build` interprets tool-specific conventions (`data-slhx-*`, `h-for`, `h-key`, form controls) from the Surface. [north_star]
|
||||||
|
|
||||||
### req: codegen/002
|
### req: codegen/002
|
||||||
002 Generated view modules expose ergonomic target objects and resource commands: `targets::list.put(value)`, `targets::row.append(key, value)`, `targets::row.replace(key, value)`, plus compatibility commands `put(slot, value)`, `append(keyed_slot, key, value)`, `prepend(keyed_slot, key, value)`, and `replace(keyed_slot, key, value)`. String-keyed target objects accept displayable domain ids without caller-side `.to_string()` noise. Commands return `impl IntoEffect` and preserve generated lowering.
|
002 Generated view modules expose ergonomic root-level target objects and commands that hide render/lower details: text slots provide `set(text)`, singleton partial slots provide `replace(view)`/`clear()`, keyed collection slots provide `append(view)`, `prepend(view)`, `replace(view)`, `remove(key_or_view)`, and forms provide `clear()`/`clear(field)`/`focus(field)`. String-keyed target objects accept displayable domain ids without caller-side `.to_string()` noise. Commands return `impl IntoEffect`, compose in plain Rust, preserve generated lowering, and fail to generate when the template lacks enough facts to infer the slot, key, form, or renderable view type.
|
||||||
|
|
||||||
### req: codegen/003
|
### req: codegen/003
|
||||||
003 Generated module `handles` exports typed constants: `Handle<I>` where `I` is `Form<T>`, a param type, or `()`. Users rarely reference handles directly; they are consumed by `#[slhx::handler]` for validation.
|
003 Generated module `handles` exports typed constants: `Handle<I>` where `I` is `Form<T>`, a param type, or `()`. Users rarely reference handles directly; they are consumed by `#[slhx::handler]` for validation.
|
||||||
@@ -245,7 +275,7 @@ a `data-*` handle param is statically known or runtime-extracted.
|
|||||||
## public_api
|
## public_api
|
||||||
|
|
||||||
### req: public_api/001
|
### req: public_api/001
|
||||||
001 The generated API is the primary public authoring API. Most user code should return generated slot/atom/form/nav commands, not raw `Effect` constructors.
|
001 The generated API is the primary public authoring API. Most user code should return generated partial, text, keyed-row, form, page, nav, or island/event commands, not raw `Effect` constructors or raw render/lower calls.
|
||||||
|
|
||||||
### req: public_api/002
|
### req: public_api/002
|
||||||
002 `Effect`, `EffectWriter`, `ResourceId`, `ResourceRef`, and raw opcodes are advanced APIs. They must not appear in beginner docs, generated examples, or common diagnostics.
|
002 `Effect`, `EffectWriter`, `ResourceId`, `ResourceRef`, and raw opcodes are advanced APIs. They must not appear in beginner docs, generated examples, or common diagnostics.
|
||||||
@@ -256,6 +286,9 @@ a `data-*` handle param is statically known or runtime-extracted.
|
|||||||
### req: public_api/004
|
### req: public_api/004
|
||||||
004 If a common UI operation requires raw `EffectWriter`, the public API is considered incomplete.
|
004 If a common UI operation requires raw `EffectWriter`, the public API is considered incomplete.
|
||||||
|
|
||||||
|
### req: public_api/005
|
||||||
|
005 Beginner-facing page/template composition uses generated render or page helpers. Direct `SafeHtml` construction, raw `html(...)`, raw `target(...)`, raw route fragments, and explicit `ui::render(...)` calls are advanced escape hatches and must not appear in beginner examples or docs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## effect_algebra
|
## effect_algebra
|
||||||
@@ -315,7 +348,7 @@ resources. Concrete runtime targets are addressed through `ResourceRef`
|
|||||||
002 Slots inside a keyed loop receive a composite identity. hemplate records `key_expr` in the Surface; slhx implements keyed slot lookups.
|
002 Slots inside a keyed loop receive a composite identity. hemplate records `key_expr` in the Surface; slhx implements keyed slot lookups.
|
||||||
|
|
||||||
### req: list/003
|
### req: list/003
|
||||||
003 Effects on keyed slots: `replace_keyed(slot, key, value)`, `remove_keyed(slot, key)`, `append_keyed(slot, key, value)`, `prepend_keyed(slot, key, value)`. Mismatch between key type and slot key type is compile-time error.
|
003 Generated helpers for keyed slots are `append(view)`, `prepend(view)`, `replace(view)`, and `remove(key_or_view)` when the template and view type provide an unambiguous `h-key`. Compatibility functions such as `append(keyed_slot, key, view)` may exist as explicit low-level forms. Mismatch between key type and slot key type is a compile-time error, and missing/ambiguous keys are build errors with template spans.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -346,7 +379,7 @@ what a valid business email is.
|
|||||||
## form_effects
|
## form_effects
|
||||||
|
|
||||||
### req: form_effects/001
|
### req: form_effects/001
|
||||||
001 Generated form APIs provide common commands: `reset()`, `clear(field)`, `error(field, message)`, `focus(field)`, and `disable_while_pending()`.
|
001 Generated form APIs provide common commands: `clear()`, `clear(field)`, `reset()`, `error(field, message)`, `focus(field)`, and `disable_while_pending()`. `form.clear()` clears the canonical generated form without requiring callers to name raw control ids.
|
||||||
|
|
||||||
### req: form_effects/002
|
### req: form_effects/002
|
||||||
002 Form effects target generated form/control ids, not CSS selectors.
|
002 Form effects target generated form/control ids, not CSS selectors.
|
||||||
@@ -435,13 +468,13 @@ what a valid business email is.
|
|||||||
## axum_integration
|
## axum_integration
|
||||||
|
|
||||||
### req: axum/001
|
### req: axum/001
|
||||||
001 slhx-axum supports the common shell/partial pattern. Full-page requests are wrapped in a user-provided Shell; slhx/partial requests may return only the rendered component or an EffectBatch. The shell/partial helper has a `SafeHtml` path so already-rendered hemplate fragments can cross the page boundary without downgrading to unchecked strings.
|
001 slhx-axum supports the common full-shell/partial pattern as an adapter around generated partial swaps. Full-page requests are wrapped in a user-provided Shell; slhx partial requests may return a rendered partial for a generated target or an EffectBatch. Page helpers add shell/title/history/fallback behavior without changing the render/target/effect model.
|
||||||
|
|
||||||
### req: axum/002
|
### req: axum/002
|
||||||
002 Existing Axum routes remain normal Axum routes. slhx does not own routing. slhx-axum only mounts handler dispatch, runtime assets, and optional push endpoints.
|
002 Existing Axum routes remain normal Axum routes. slhx does not own routing. slhx-axum only mounts handler dispatch, runtime assets, and optional push endpoints.
|
||||||
|
|
||||||
### req: axum/003
|
### req: axum/003
|
||||||
003 Interactive fragments that would traditionally be implemented as `/demo/...` HTMX endpoints should be expressible as `#[slhx::handler]` functions returning generated slot commands. Integration registration should read as interactions over generated handles, not low-level registry wiring.
|
003 Interactive fragments that would traditionally be implemented as `/demo/...` HTMX endpoints should be expressible as Rust handlers returning generated target commands. Integration registration should read as interactions over generated handles and page/partial helpers, not low-level registry wiring.
|
||||||
|
|
||||||
### req: axum/004
|
### req: axum/004
|
||||||
004 Query-string demo endpoints may be migrated to typed handler params from `data-*` attributes or forms. `Query<T>` remains available in normal Axum routes but is not the slhx happy path.
|
004 Query-string demo endpoints may be migrated to typed handler params from `data-*` attributes or forms. `Query<T>` remains available in normal Axum routes but is not the slhx happy path.
|
||||||
@@ -476,10 +509,10 @@ what a valid business email is.
|
|||||||
002 SSE/WebSocket connections are authenticated by the server framework before stream creation. slhx does not define auth semantics for streams.
|
002 SSE/WebSocket connections are authenticated by the server framework before stream creation. slhx does not define auth semantics for streams.
|
||||||
|
|
||||||
### req: push/003
|
### req: push/003
|
||||||
003 HTMX-style SSE swaps are represented as streamed effects targeting generated slots/atoms. No selector-based `sse-swap` semantics in core.
|
003 Push swaps are ordinary partial swaps carried over SSE/WebSocket: streamed effects target generated slots, atoms, or island events such as `ui::feed.prepend(event)` or `ui::game.emit(snapshot)`. No selector-based `sse-swap` semantics in core.
|
||||||
|
|
||||||
### req: push/004
|
### req: push/004
|
||||||
004 Out-of-band updates are ordinary multi-target EffectBatches.
|
004 Out-of-band updates are ordinary multi-target partial swaps/effects, not a separate response model.
|
||||||
|
|
||||||
### req: push/005
|
### req: push/005
|
||||||
005 Push is one-way server-to-client delivery of EffectBatch. It does not define client mutation, optimistic queues, reconciliation, or conflict handling.
|
005 Push is one-way server-to-client delivery of EffectBatch. It does not define client mutation, optimistic queues, reconciliation, or conflict handling.
|
||||||
@@ -520,19 +553,28 @@ what a valid business email is.
|
|||||||
## interop
|
## interop
|
||||||
|
|
||||||
### req: interop/001
|
### req: interop/001
|
||||||
001 `Effect::event` is the single bridge between slhx and third-party JS. External widgets, charts, and maps listen via native `CustomEvent`. slhx core does not inspect or manage them. [north_star]
|
001 `Effect::event` and generated event helpers are the single slhx-to-widget bridge. External widgets, charts, games, maps, Alpine/Svelte islands, and Web Components listen via native `CustomEvent`; slhx core does not inspect their state, rendering internals, or framework lifecycle. [north_star]
|
||||||
|
|
||||||
### req: interop/002
|
### req: interop/002
|
||||||
002 Web Components and custom elements are valid opaque leaf nodes. slhx does not inspect shadow DOM. Escape hatches are leaves, never app foundations.
|
002 Web Components and custom elements are valid opaque leaf nodes. slhx does not inspect shadow DOM or mutate inside custom elements unless the author explicitly exposes slhx-owned slots/handles at the boundary. Escape hatches are leaves, never app foundations.
|
||||||
|
|
||||||
### req: interop/003
|
### req: interop/003
|
||||||
003 WASM islands (`#[slhx::island]`) compile handler code to WASM for client-local execution. The island is a leaf in the DOM; slhx core is unaware of WASM except via the same `EffectBatch` contract.
|
003 WASM islands and third-party framework islands are explicit leaf boundaries. slhx may replace the island root as a generated target, but it does not manage inside it; slhx owns the generated slot/island boundary, Alpine/Svelte/Web Components/hand-written widgets own the inside, and events cross the boundary. Commands flow widget-to-slhx through explicit generated handles or `slhx.send(...)`, and server-to-widget through generated event helpers such as `ui::chart.emit(snapshot)`.
|
||||||
|
|
||||||
### req: interop/004
|
### req: interop/004
|
||||||
004 Existing `hx-*` attributes are treated as ordinary raw attributes in the hemplate Surface without slhx semantics. An optional `slhx-htmx-migrate` tool may read Surface `hx-*` attrs and suggest equivalent `data-slhx-*` handlers/effects.
|
004 Existing `hx-*` attributes are treated as ordinary raw attributes in the hemplate Surface without slhx semantics. An optional `slhx-htmx-migrate` tool may read Surface `hx-*` attrs and suggest equivalent `data-slhx-*` handlers/effects.
|
||||||
|
|
||||||
### req: interop/005
|
### req: interop/005
|
||||||
005 HTMX-style response triggers are represented by `Effect::event` or generated event helpers. Events are native `CustomEvent`s scoped to the slhx root.
|
005 HTMX-style response triggers and widget notifications are represented by `Effect::event` or generated event helpers. Events are native `CustomEvent`s scoped to the slhx root.
|
||||||
|
|
||||||
|
### req: interop/006
|
||||||
|
006 `data-slhx-preserve` is an explicit preserve boundary for rare leaf-widget cases where slhx updates around a subtree without destroying it. Preserve semantics must be simple: preserve the marked subtree identity, do not diff or hydrate inside it, and require authors to mark the boundary deliberately. Preserve must not become a default lifecycle model or a workaround for unclear ownership.
|
||||||
|
|
||||||
|
### req: interop/007
|
||||||
|
007 The runtime emits native lifecycle events such as `slhx:before-swap`, `slhx:after-swap`, `slhx:event`, `slhx:connect`, and `slhx:disconnect` so Alpine, Svelte, Web Components, and hand-written widgets can attach at DOM/event boundaries. slhx core must not add framework-specific adapters.
|
||||||
|
|
||||||
|
### req: interop/008
|
||||||
|
008 Interop must prevent selector hacks, manual JS reinitialization races, lost widget state after swaps, and double-owned state by making ownership explicit: slhx owns generated server DOM targets, the external widget owns explicit leaves, and events are the supported crossing point. Core must not add selector targeting, hydration compatibility, a client store, or a framework lifecycle to make interop easy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -594,7 +636,7 @@ what a valid business email is.
|
|||||||
## test
|
## test
|
||||||
|
|
||||||
### req: test/001
|
### req: test/001
|
||||||
001 `EffectWriter` implements a test backend so handlers can be unit-tested without a browser: `slhx_test::run(handler, input)` returns an `EffectInspector` with `contains(op)`, `has_slot(slot)`, `has_atom(atom)`, etc.
|
001 `EffectWriter` implements a test backend so handlers can be unit-tested without a browser: `slhx_test::run(handler, input)`, `slhx_test::inspect_batch(dispatched_batch)`, and `slhx_test::inspect_wire(bytes)` return an `EffectInspector` with generated-resource assertions such as `has_target(generated_target)`, `updates_text(generated_target)`, `updates_html(generated_target)`, `target_html_containing(generated_target, text)`, `inserts_html_containing(generated_target, key, text)`, `replaces_keyed_html_containing(generated_target, key, text)`, `removes_key(generated_target, key)`, `pushes_to(url)`, `emits(name, payload)`, `emits_containing(name, text)`, `has_slot(slot)`, `has_atom(atom)`, etc. Canonical example tests should prefer generated-target assertions so tests use the same generated target objects as handlers instead of importing raw slot constants or matching raw effects/payloads. Browser/E2E selector helpers are allowed only as low-level test adapters for driving rendered HTML and must be generated or named around public authoring concepts such as handles, targets, forms, nav links, roots, islands, class tokens, or keys; they must not become app authoring APIs or teach selector targeting.
|
||||||
|
|
||||||
### req: test/002
|
### req: test/002
|
||||||
002 `#[slhx::component]` may compile without generated Surface files for incremental module-local testing. `#[slhx::surface]` requires `slhx.generated.rs` in `$OUT_DIR` and fails with an actionable diagnostic when generation is missing, because it is the public generated API bridge.
|
002 `#[slhx::component]` may compile without generated Surface files for incremental module-local testing. `#[slhx::surface]` requires `slhx.generated.rs` in `$OUT_DIR` and fails with an actionable diagnostic when generation is missing, because it is the public generated API bridge.
|
||||||
@@ -635,7 +677,7 @@ what a valid business email is.
|
|||||||
002 Atoms are not reactive by default. Updating an atom does not re-render anything until a handler returns an effect referencing it.
|
002 Atoms are not reactive by default. Updating an atom does not re-render anything until a handler returns an effect referencing it.
|
||||||
|
|
||||||
### req: state/003
|
### req: state/003
|
||||||
003 The JS runtime maintains a client-side atom store keyed by `AtomId`. Runtime values are type-erased postcard bytes. Types are compile-time only. A deterministic `TypeHash` may be generated by `slhx_build` for diagnostics, but the JS runtime does not depend on Rust `TypeId`.
|
003 The JS runtime may maintain a narrow atom value table keyed by `AtomId` only for explicit `Atom<T>` resources and SSR bootstrap. This is not an app state framework, component store, cache, or reactive graph. Runtime values are type-erased postcard bytes; types are compile-time only. A deterministic `TypeHash` may be generated by `slhx_build` for diagnostics, but the JS runtime does not depend on Rust `TypeId`.
|
||||||
|
|
||||||
### req: state/004
|
### req: state/004
|
||||||
004 SSR roots may carry a `data-slhx-st` base64url-encoded postcard blob on the `data-slhx-root` element. Runtime decodes it into the client atom store. Atoms computed from server state are immediately available to client-side handlers without a round-trip.
|
004 SSR roots may carry a `data-slhx-st` base64url-encoded postcard blob on the `data-slhx-root` element. Runtime decodes it into the client atom store. Atoms computed from server state are immediately available to client-side handlers without a round-trip.
|
||||||
@@ -823,31 +865,31 @@ Optional ergonomic macros may exist: `#[slhx::surface]`, `#[slhx::component]`,
|
|||||||
003 Handler parameters are inferred from four sources: `Form<T>`, `data-*` attributes on the triggering node, route params supplied by integration crates, and explicit app/context parameters. Missing or incompatible params are compile-time errors with source spans. slhx does not implement selector-based `hx-include`; shared params are represented by forms, hidden inputs, scoped context, or explicit `data-*` attributes.
|
003 Handler parameters are inferred from four sources: `Form<T>`, `data-*` attributes on the triggering node, route params supplied by integration crates, and explicit app/context parameters. Missing or incompatible params are compile-time errors with source spans. slhx does not implement selector-based `hx-include`; shared params are represented by forms, hidden inputs, scoped context, or explicit `data-*` attributes.
|
||||||
|
|
||||||
### req: derive_handler/004
|
### req: derive_handler/004
|
||||||
004 The common handler signature forms are:
|
004 The common handler signature forms are plain Rust functions, synchronous or async:
|
||||||
```rust
|
```rust
|
||||||
fn my_handler() -> impl IntoEffect
|
fn ping() -> impl IntoEffect
|
||||||
fn my_handler(form: Form<CreateTodo>) -> impl IntoEffect
|
async fn add(app: State<App>, form: Form<NewTodo>) -> impl IntoEffect
|
||||||
fn my_handler(app: &mut AppState) -> impl IntoEffect
|
async fn rename(app: State<App>, todo_id: TodoId, title: Title) -> impl IntoEffect
|
||||||
fn my_handler(card_id: CardId, app: &mut AppState) -> impl IntoEffect
|
async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
|
||||||
```
|
```
|
||||||
All forms support returning `impl IntoEffect` and compose through tuples.
|
`State<App>` is illustrative integration context; equivalent framework extractors or app references are adapter concerns. Form fields and `data-*` params parse through normal Rust `FromForm`/`FormValue`/`FromStr`-style traits, so domain newtypes remain user-authored. Handlers may return `impl IntoEffect` or `Result<impl IntoEffect, E>` for fallible database/domain work; `IntoEffect` values compose through tuples while `Result` paths preserve a typed error boundary for integrations to map to form errors, toasts, events, or HTTP responses.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## derive_app
|
## derive_app
|
||||||
|
|
||||||
### req: derive_app/001
|
### req: derive_app/001
|
||||||
001 `#[slhx::app]` marks an application/root state type and registry entry point. There may be one registry per app/root type, and multiple runtime instances may exist per process. slhx does not require a process-global singleton.
|
001 `#[slhx::app(...)]` marks an application/root registry entry point and composes generated component handler modules into one app registry from the app state. There may be one registry per app/root type, and multiple runtime instances may exist per process. slhx does not require a process-global singleton or handwritten chains of per-component registration calls in canonical app code.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## locality
|
## locality
|
||||||
|
|
||||||
### req: locality/001
|
### req: locality/001
|
||||||
001 slhx does not support selector targets such as `closest`, `find`, or `this` in core. The equivalent pattern is a named/generated slot on the local component or keyed row.
|
001 slhx does not support selector targets such as `closest`, `find`, or `this` in core. The equivalent pattern is a named generated target on the local component, keyed row, modal, toast, SVG fragment, form error region, or page content area.
|
||||||
|
|
||||||
### req: locality/002
|
### req: locality/002
|
||||||
002 Diagnostics should suggest adding `data-slhx-slot` to the local element when users attempt a self/row update pattern.
|
002 Diagnostics should suggest adding `data-slhx-slot` or `h-key` to the local element when users attempt a self/row update pattern, and should explain which generated target helper would become available.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -911,7 +953,7 @@ All forms support returning `impl IntoEffect` and compose through tuples.
|
|||||||
## target_policy
|
## target_policy
|
||||||
|
|
||||||
### req: target/001
|
### req: target/001
|
||||||
001 slhx does not support response-side selector retargeting. Handlers choose targets by returning effects for generated resources.
|
001 slhx does not support response-side selector retargeting. Handlers choose targets by returning generated UI commands for generated resources.
|
||||||
|
|
||||||
### req: target/002
|
### req: target/002
|
||||||
002 slhx does not implement response-side CSS fragment selection in core. Servers return explicit component fragments or EffectBatches.
|
002 slhx does not implement response-side CSS fragment selection in core. Servers return explicit hemplate partials for generated targets or EffectBatches containing generated target effects.
|
||||||
|
|||||||
+12
-9
@@ -4,7 +4,7 @@ A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates,
|
|||||||
offline queue, conflict reconciliation, live presence, and SSR-first rendering —
|
offline queue, conflict reconciliation, live presence, and SSR-first rendering —
|
||||||
all without React/Vue/VDOM, in a single typed Rust codebase.
|
all without React/Vue/VDOM, in a single typed Rust codebase.
|
||||||
|
|
||||||
This is the north-star integration test for slhx + hemplate + slhx-sync.
|
This is an explicitly advanced/low-level north-star boundary sketch for slhx + hemplate + slhx-sync, not the beginner-facing authoring path. Raw sync/effect/wire vocabulary below is excluded from beginner-facing examples by design.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -270,24 +270,27 @@ The runtime does not know "presence". It executes generated DOM updates.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. What the browser receives
|
## 9. What app authors write; what the browser receives
|
||||||
|
|
||||||
Initial SSR:
|
Initial SSR stays an ordinary rendered template with symbolic slhx attributes at
|
||||||
|
the authoring boundary:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<section data-slhx-root data-sid="0" data-aid="0">
|
<section data-slhx-root="board" data-slhx-slot="board" data-slhx-atom="board">
|
||||||
...
|
...
|
||||||
<article data-sid="3" data-key="42" data-hid="1">
|
<article data-slhx-slot="card" data-slhx-handle="select_card" +data-card-id="card.id">
|
||||||
Fix login bug
|
{+ card.title +}
|
||||||
</article>
|
</article>
|
||||||
...
|
...
|
||||||
</section>
|
</section>
|
||||||
<!-- the app shell loads /slhx.js and any bootstrap state -->
|
<!-- the app shell loads /slhx.js and any bootstrap state -->
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime attachment: `/slhx.js` installs delegated root listeners for forms,
|
The compiler lowers those symbols to compact runtime metadata, but that metadata
|
||||||
clicks, and pointer/drag events. App authors keep composing generated resources;
|
is not an app-authoring contract. Runtime attachment: `/slhx.js` installs
|
||||||
they do not attach per-node listeners or write selector glue.
|
delegated root listeners for forms, clicks, and pointer/drag events. App authors
|
||||||
|
keep composing generated resources; they do not attach per-node listeners, copy
|
||||||
|
numeric ids, or write selector glue.
|
||||||
|
|
||||||
No framework download. No VDOM. No hydration. No game loop.
|
No framework download. No VDOM. No hydration. No game loop.
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -6,7 +6,7 @@ mod tests {
|
|||||||
use super::ui::{board, board_card};
|
use super::ui::{board, board_card};
|
||||||
use hemplate::Hemplate;
|
use hemplate::Hemplate;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
use slhx::{Effect, IntoEffect, Payload};
|
use slhx::IntoEffect;
|
||||||
use slhx_test::inspect;
|
use slhx_test::inspect;
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
@@ -27,12 +27,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn kanban_board_updates_generated_slot() {
|
fn kanban_board_updates_generated_slot() {
|
||||||
fn render_board() -> impl IntoEffect {
|
fn render_board() -> impl IntoEffect {
|
||||||
board::targets::board.put(&empty_board())
|
board::board.put(&empty_board())
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(render_board());
|
let effect = inspect(render_board());
|
||||||
assert!(effect.has_slot(board::slots::board));
|
assert!(effect.updates_html(board::board));
|
||||||
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Html(_), .. }]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
@@ -45,7 +44,9 @@ mod tests {
|
|||||||
|
|
||||||
fn empty_board() -> BoardColumns {
|
fn empty_board() -> BoardColumns {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
BoardColumns { columns: Vec::new() }
|
BoardColumns {
|
||||||
|
columns: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selector(value: &str) -> Selector {
|
fn selector(value: &str) -> Selector {
|
||||||
@@ -57,19 +58,21 @@ mod tests {
|
|||||||
fn kanban_form_handler_is_checked_against_hemplate_form() {
|
fn kanban_form_handler_is_checked_against_hemplate_form() {
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
fn create_card(_form: slhx::Form<CreateCard>) -> impl IntoEffect {
|
fn create_card(_form: slhx::Form<CreateCard>) -> impl IntoEffect {
|
||||||
board::slots::notice.text("queued")
|
board::notice.text("queued")
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(create_card(CreateCard::FORM));
|
let effect = inspect(create_card(CreateCard::FORM));
|
||||||
|
|
||||||
assert!(effect.has_slot(board::slots::notice));
|
assert!(effect.updates_text(board::notice));
|
||||||
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: form/002 req: codegen/003
|
// req: examples/001 req: form/002 req: codegen/003
|
||||||
#[test]
|
#[test]
|
||||||
fn kanban_template_exports_form_and_card_handles() {
|
fn kanban_template_exports_form_and_card_handles() {
|
||||||
assert_ne!(board::handles::create_card.id(), board_card::handles::move_right.id());
|
assert_ne!(board::create_card.id(), board_card::move_right.id());
|
||||||
assert_eq!(board::forms::create_card.field("title").resource, board::forms::create_card.id());
|
assert_eq!(
|
||||||
|
board::create_card_form.field("title").resource,
|
||||||
|
board::create_card_form.id()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-38
@@ -4,14 +4,14 @@ use axum::routing::get;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use futures_util::{stream, StreamExt};
|
use futures_util::{stream, StreamExt};
|
||||||
use hemplate::Hemplate;
|
use hemplate::Hemplate;
|
||||||
use slhx::{IntoEffect, SafeHtml};
|
use slhx::{Html, IntoEffect};
|
||||||
use slhx_axum::{
|
use slhx_axum::{
|
||||||
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
|
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
|
||||||
InteractionRequest, PageRequest,
|
InteractionRequest, PageRequest,
|
||||||
};
|
};
|
||||||
|
use slhx_kanban_example::ui::board::{self as board};
|
||||||
|
use slhx_kanban_example::ui::board_card as card_board;
|
||||||
use slhx_kanban_example::ui::{self, board as board_ui};
|
use slhx_kanban_example::ui::{self, board as board_ui};
|
||||||
use slhx_kanban_example::ui::board::{forms, handles, slots, targets};
|
|
||||||
use slhx_kanban_example::ui::board_card::handles as card_handles;
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -40,7 +40,7 @@ struct Card {
|
|||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
struct AppShell {
|
struct AppShell {
|
||||||
body: SafeHtml,
|
body: Html,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
@@ -67,8 +67,8 @@ struct BoardCard {
|
|||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
struct Board {
|
struct Board {
|
||||||
options: SafeHtml,
|
options: Html,
|
||||||
board: SafeHtml,
|
board: Html,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
@@ -96,9 +96,21 @@ async fn main() {
|
|||||||
board: Mutex::new(BoardState {
|
board: Mutex::new(BoardState {
|
||||||
next_id: 4,
|
next_id: 4,
|
||||||
cards: vec![
|
cards: vec![
|
||||||
Card { id: 1, title: "Write requirements".into(), column: 0 },
|
Card {
|
||||||
Card { id: 2, title: "Build browser example".into(), column: 1 },
|
id: 1,
|
||||||
Card { id: 3, title: "Verify with HTTP".into(), column: 2 },
|
title: "Write requirements".into(),
|
||||||
|
column: 0,
|
||||||
|
},
|
||||||
|
Card {
|
||||||
|
id: 2,
|
||||||
|
title: "Build browser example".into(),
|
||||||
|
column: 1,
|
||||||
|
},
|
||||||
|
Card {
|
||||||
|
id: 3,
|
||||||
|
title: "Verify with HTTP".into(),
|
||||||
|
column: 2,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -138,14 +150,20 @@ async fn interact(
|
|||||||
// req: push/001 req: push/003 req: examples/001
|
// req: push/001 req: push/003 req: examples/001
|
||||||
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
||||||
if params.contains_key("once") {
|
if params.contains_key("once") {
|
||||||
let effect = targets::presence.put(&Presence { count: 1 });
|
let effect = board::presence.put(&Presence { count: 1 });
|
||||||
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
|
return sse(stream::iter([Ok::<_, Infallible>(
|
||||||
|
effect.into_batch(ui::BUILD_FINGERPRINT),
|
||||||
|
)])
|
||||||
|
.boxed());
|
||||||
}
|
}
|
||||||
|
|
||||||
let batches = stream::unfold(1_u64, |count| async move {
|
let batches = stream::unfold(1_u64, |count| async move {
|
||||||
tokio::time::sleep(Duration::from_secs(4)).await;
|
tokio::time::sleep(Duration::from_secs(4)).await;
|
||||||
let effect = targets::presence.put(&Presence { count });
|
let effect = board::presence.put(&Presence { count });
|
||||||
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1))
|
Some((
|
||||||
|
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
|
||||||
|
count + 1,
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.boxed();
|
.boxed();
|
||||||
sse(batches)
|
sse(batches)
|
||||||
@@ -153,7 +171,7 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
|
|||||||
|
|
||||||
fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
||||||
interactions(ui::BUILD_FINGERPRINT)
|
interactions(ui::BUILD_FINGERPRINT)
|
||||||
.on(handles::create_card, {
|
.on(board::create_card, {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |form| {
|
move |form| {
|
||||||
// req: examples/001 req: form/002
|
// req: examples/001 req: form/002
|
||||||
@@ -163,12 +181,16 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
|||||||
if !title.is_empty() {
|
if !title.is_empty() {
|
||||||
let id = board.next_id;
|
let id = board.next_id;
|
||||||
board.next_id += 1;
|
board.next_id += 1;
|
||||||
board.cards.push(Card { id, title: title.into(), column });
|
board.cards.push(Card {
|
||||||
|
id,
|
||||||
|
title: title.into(),
|
||||||
|
column,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
board_effects(&board, "Card added")
|
board_effects(&board, "Card added")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on(card_handles::move_left, {
|
.on(card_board::move_left, {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |form| {
|
move |form| {
|
||||||
// req: examples/001 req: list/003
|
// req: examples/001 req: list/003
|
||||||
@@ -176,10 +198,17 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
|||||||
let moved = update_card(&mut board, form.parse("card_id"), |card| {
|
let moved = update_card(&mut board, form.parse("card_id"), |card| {
|
||||||
card.column = card.column.saturating_sub(1);
|
card.column = card.column.saturating_sub(1);
|
||||||
});
|
});
|
||||||
board_effects(&board, if moved { "Card moved left" } else { "Card not found" })
|
board_effects(
|
||||||
|
&board,
|
||||||
|
if moved {
|
||||||
|
"Card moved left"
|
||||||
|
} else {
|
||||||
|
"Card not found"
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on(card_handles::move_right, {
|
.on(card_board::move_right, {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |form| {
|
move |form| {
|
||||||
// req: examples/001 req: list/003
|
// req: examples/001 req: list/003
|
||||||
@@ -187,10 +216,17 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
|||||||
let moved = update_card(&mut board, form.parse("card_id"), |card| {
|
let moved = update_card(&mut board, form.parse("card_id"), |card| {
|
||||||
card.column = (card.column + 1).min(COLUMNS.len() - 1);
|
card.column = (card.column + 1).min(COLUMNS.len() - 1);
|
||||||
});
|
});
|
||||||
board_effects(&board, if moved { "Card moved right" } else { "Card not found" })
|
board_effects(
|
||||||
|
&board,
|
||||||
|
if moved {
|
||||||
|
"Card moved right"
|
||||||
|
} else {
|
||||||
|
"Card not found"
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on(card_handles::delete_card, {
|
.on(card_board::delete_card, {
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |form| {
|
move |form| {
|
||||||
// req: examples/001 req: list/003
|
// req: examples/001 req: list/003
|
||||||
@@ -199,16 +235,23 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
|||||||
if let Some(id) = form.parse::<u64>("card_id") {
|
if let Some(id) = form.parse::<u64>("card_id") {
|
||||||
board.cards.retain(|card| card.id != id);
|
board.cards.retain(|card| card.id != id);
|
||||||
}
|
}
|
||||||
board_effects(&board, if board.cards.len() < before { "Card deleted" } else { "Card not found" })
|
board_effects(
|
||||||
|
&board,
|
||||||
|
if board.cards.len() < before {
|
||||||
|
"Card deleted"
|
||||||
|
} else {
|
||||||
|
"Card not found"
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect {
|
fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect {
|
||||||
(
|
(
|
||||||
targets::board.put(&board_view(board)),
|
board::board.put(&board_view(board)),
|
||||||
slots::notice.text(notice),
|
board::notice.text(notice),
|
||||||
forms::create_card.clear("title"),
|
board::create_card_form.clear(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,7 +278,7 @@ fn parse_column(value: Option<&str>) -> usize {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn page_html(board: &BoardState) -> SafeHtml {
|
fn page_html(board: &BoardState) -> Html {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
board_ui::render(&Board {
|
board_ui::render(&Board {
|
||||||
options: render_options(),
|
options: render_options(),
|
||||||
@@ -243,12 +286,12 @@ fn page_html(board: &BoardState) -> SafeHtml {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shell(body: SafeHtml) -> SafeHtml {
|
fn shell(body: Html) -> Html {
|
||||||
// req: html_safety/001 req: html_safety/002 req: axum_integration/001
|
// req: html_safety/001 req: html_safety/002 req: axum_integration/001
|
||||||
slhx::render(&AppShell { body })
|
slhx::render(&AppShell { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_options() -> SafeHtml {
|
fn render_options() -> Html {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
ui::render(&ColumnOptions {
|
ui::render(&ColumnOptions {
|
||||||
options: COLUMNS
|
options: COLUMNS
|
||||||
@@ -290,6 +333,11 @@ fn render_card(card: &Card) -> BoardCard {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
use slhx_test::{
|
||||||
|
class_child_selector, disabled_button_selector, element_class_selector,
|
||||||
|
escaped_markup_selector, form_selector, keyed_selector, root_element_selector,
|
||||||
|
select_options_selector, small_text_selector, strong_text_selector,
|
||||||
|
};
|
||||||
|
|
||||||
fn selector(value: &str) -> Selector {
|
fn selector(value: &str) -> Selector {
|
||||||
Selector::parse(value).expect("test selector parses")
|
Selector::parse(value).expect("test selector parses")
|
||||||
@@ -303,10 +351,22 @@ mod tests {
|
|||||||
assert!(!html.as_str().contains("__BOARD__"));
|
assert!(!html.as_str().contains("__BOARD__"));
|
||||||
|
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
assert_eq!(document.select(&selector("section[data-slhx-root=\"kanban\"]")).count(), 1);
|
assert_eq!(
|
||||||
assert_eq!(document.select(&selector("select[name=\"column\"] > option")).count(), 3);
|
document
|
||||||
assert_eq!(document.select(&selector("[data-sid]")).count(), 3);
|
.select(&selector(&root_element_selector("section", "kanban")))
|
||||||
assert_eq!(document.select(&selector("[data-hid]")).count(), 1);
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector(&select_options_selector("column")))
|
||||||
|
.count(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document.select(&selector(&form_selector("header"))).count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
@@ -323,15 +383,31 @@ mod tests {
|
|||||||
|
|
||||||
let html = ui::render(&board_view(&board));
|
let html = ui::render(&board_view(&board));
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
assert_eq!(document.select(&selector(".columns > section.column")).count(), 3);
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector(&class_child_selector(
|
||||||
|
"columns", "section", "column"
|
||||||
|
)))
|
||||||
|
.count(),
|
||||||
|
3
|
||||||
|
);
|
||||||
let card = document
|
let card = document
|
||||||
.select(&selector("article.card[data-key=\"1\"]"))
|
.select(&selector(&keyed_selector("article.card", 1)))
|
||||||
.next()
|
.next()
|
||||||
.expect("card renders");
|
.expect("card renders");
|
||||||
let title = card.select(&selector("strong")).next().expect("card title renders");
|
let title = card
|
||||||
|
.select(&selector(strong_text_selector()))
|
||||||
|
.next()
|
||||||
|
.expect("card title renders");
|
||||||
assert_eq!(title.text().collect::<String>(), "<b>Compile checked</b>");
|
assert_eq!(title.text().collect::<String>(), "<b>Compile checked</b>");
|
||||||
assert!(card.select(&selector("b")).next().is_none());
|
assert!(card
|
||||||
assert_eq!(card.select(&selector("button[disabled]")).count(), 1);
|
.select(&selector(&escaped_markup_selector("b")))
|
||||||
|
.next()
|
||||||
|
.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
card.select(&selector(disabled_button_selector())).count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
@@ -339,10 +415,15 @@ mod tests {
|
|||||||
fn presence_payload_is_rendered_by_a_hemplate_view() {
|
fn presence_payload_is_rendered_by_a_hemplate_view() {
|
||||||
let html = ui::render(&Presence { count: 7 });
|
let html = ui::render(&Presence { count: 7 });
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
assert_eq!(document.select(&selector("span.presence")).count(), 2);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document
|
document
|
||||||
.select(&selector("small"))
|
.select(&selector(&element_class_selector("span", "presence")))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector(small_text_selector()))
|
||||||
.next()
|
.next()
|
||||||
.map(|small| small.text().collect::<String>()),
|
.map(|small| small.text().collect::<String>()),
|
||||||
Some("tick #7".to_owned())
|
Some("tick #7".to_owned())
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ This is a polished Linear-style product demo for planning typed work across lane
|
|||||||
- page-enhancer navigation with native link fallback
|
- page-enhancer navigation with native link fallback
|
||||||
- SSE server push into a generated slot
|
- SSE server push into a generated slot
|
||||||
- drag-and-drop lane moves persisted by typed server handlers through the slhx runtime
|
- drag-and-drop lane moves persisted by typed server handlers through the slhx runtime
|
||||||
- an opaque canvas island fed by `Effect::event`/`CustomEvent`, without teaching slhx core about the widget
|
- an explicit advanced opaque canvas island fed by a generated event helper, without teaching slhx core about the widget
|
||||||
- no user-authored browser JavaScript in slhx-managed UI; the island JavaScript is a leaf-widget escape hatch
|
- no user-authored browser JavaScript in slhx-managed UI; the island JavaScript is a leaf-widget escape hatch
|
||||||
|
|
||||||
Verification:
|
Verification:
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ pub mod ui {}
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::ui::control_center::{forms, handles, slots, targets};
|
use super::ui::control_center::{hero_metrics, launch_work, launch_work_form, notice};
|
||||||
use super::ui::issue_card::handles as card_handles;
|
use super::ui::issue_card::advance_work;
|
||||||
use super::ui::issue_lane::events as lane_events;
|
use super::ui::issue_lane::events as lane_events;
|
||||||
use hemplate::Hemplate;
|
use hemplate::Hemplate;
|
||||||
use slhx::{Effect, IntoEffect, Payload};
|
use slhx::IntoEffect;
|
||||||
use slhx_test::inspect;
|
use slhx_test::inspect;
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
@@ -30,14 +30,14 @@ mod tests {
|
|||||||
fn techdemo_uses_generated_slots_for_multi_target_updates() {
|
fn techdemo_uses_generated_slots_for_multi_target_updates() {
|
||||||
fn update() -> impl IntoEffect {
|
fn update() -> impl IntoEffect {
|
||||||
(
|
(
|
||||||
targets::hero_metrics.put(&FastMetric { label: "fast" }),
|
hero_metrics.put(&FastMetric { label: "fast" }),
|
||||||
slots::notice.text("typed"),
|
notice.text("typed"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let batch = inspect(update());
|
let batch = inspect(update());
|
||||||
assert!(batch.has_slot(slots::hero_metrics));
|
assert!(batch.has_target(hero_metrics));
|
||||||
assert!(batch.has_slot(slots::notice));
|
assert!(batch.has_target(notice));
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
||||||
@@ -45,22 +45,21 @@ mod tests {
|
|||||||
fn techdemo_form_handler_is_checked_against_hemplate_form() {
|
fn techdemo_form_handler_is_checked_against_hemplate_form() {
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
fn launch_work(_form: slhx::Form<LaunchWork>) -> impl IntoEffect {
|
fn launch_work(_form: slhx::Form<LaunchWork>) -> impl IntoEffect {
|
||||||
slots::notice.text("queued")
|
notice.text("queued")
|
||||||
}
|
}
|
||||||
|
|
||||||
let batch = inspect(launch_work(LaunchWork::FORM));
|
let batch = inspect(launch_work(LaunchWork::FORM));
|
||||||
|
|
||||||
assert!(batch.has_slot(slots::notice));
|
assert!(batch.updates_text(notice));
|
||||||
assert!(matches!(batch.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: form/002 req: codegen/003
|
// req: examples/001 req: form/002 req: codegen/003
|
||||||
#[test]
|
#[test]
|
||||||
fn techdemo_exports_form_and_interaction_handles() {
|
fn techdemo_exports_form_and_interaction_handles() {
|
||||||
assert_ne!(handles::launch_work.id(), card_handles::advance_work.id());
|
assert_ne!(launch_work.id(), advance_work.id());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
forms::launch_work.field("title").resource,
|
launch_work_form.field("title").resource,
|
||||||
forms::launch_work.id()
|
launch_work_form.id()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,9 +67,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn techdemo_exports_generated_event_constants() {
|
fn techdemo_exports_generated_event_constants() {
|
||||||
assert_eq!(lane_events::drop.as_str(), "drop");
|
assert_eq!(lane_events::drop.as_str(), "drop");
|
||||||
assert!(matches!(
|
let event = inspect(lane_events::drop.emit("card-1"));
|
||||||
slhx::event(lane_events::drop, "card-1"),
|
assert!(event.emits("drop", "card-1"));
|
||||||
Effect::Emit { name, payload } if name == "drop" && payload == "card-1"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+197
-79
@@ -1,18 +1,18 @@
|
|||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use futures_util::{stream, StreamExt};
|
use futures_util::{stream, StreamExt};
|
||||||
use hemplate::Hemplate;
|
use hemplate::Hemplate;
|
||||||
use slhx::{CssClass, CssClasses, IntoEffect, SafeHtml};
|
use slhx::{CssClass, CssClasses, EventName, Html, IntoEffect};
|
||||||
use slhx_axum::{
|
use slhx_axum::{
|
||||||
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
|
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
|
||||||
InteractionRequest, PageRequest,
|
InteractionRequest, PageRequest,
|
||||||
};
|
};
|
||||||
use slhx_techdemo::ui;
|
use slhx_techdemo::ui;
|
||||||
use slhx_techdemo::ui::control_center::{classes, forms, handles, slots, targets};
|
use slhx_techdemo::ui::control_center::{self as control, classes};
|
||||||
use slhx_techdemo::ui::issue_card::handles as card_handles;
|
use slhx_techdemo::ui::{issue_card as card_control, issue_lane as lane_control};
|
||||||
use slhx_techdemo::ui::issue_lane::handles as lane_handles;
|
|
||||||
use std::collections::{BTreeMap, VecDeque};
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -24,6 +24,7 @@ const LANES: [(&str, &str, &str); 3] = [
|
|||||||
("runtime", "Runtime", "typed updates → DOM"),
|
("runtime", "Runtime", "typed updates → DOM"),
|
||||||
("product", "Product", "Native UX, zero app JS"),
|
("product", "Product", "Native UX, zero app JS"),
|
||||||
];
|
];
|
||||||
|
const ISLAND_ORBIT: EventName = EventName::new("slhx:island-orbit");
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct WorkItem {
|
struct WorkItem {
|
||||||
@@ -73,9 +74,27 @@ impl Default for DemoState {
|
|||||||
let mut state = Self {
|
let mut state = Self {
|
||||||
next_id: 4,
|
next_id: 4,
|
||||||
work: vec![
|
work: vec![
|
||||||
WorkItem { id: 1, title: "Compile checked handles".into(), lane: 0, impact: 9, stage: Stage::Shipped },
|
WorkItem {
|
||||||
WorkItem { id: 2, title: "Stream typed presence".into(), lane: 1, impact: 7, stage: Stage::Active },
|
id: 1,
|
||||||
WorkItem { id: 3, title: "Replace dashboard widgets".into(), lane: 2, impact: 8, stage: Stage::Draft },
|
title: "Compile checked handles".into(),
|
||||||
|
lane: 0,
|
||||||
|
impact: 9,
|
||||||
|
stage: Stage::Shipped,
|
||||||
|
},
|
||||||
|
WorkItem {
|
||||||
|
id: 2,
|
||||||
|
title: "Stream typed presence".into(),
|
||||||
|
lane: 1,
|
||||||
|
impact: 7,
|
||||||
|
stage: Stage::Active,
|
||||||
|
},
|
||||||
|
WorkItem {
|
||||||
|
id: 3,
|
||||||
|
title: "Replace dashboard widgets".into(),
|
||||||
|
lane: 2,
|
||||||
|
impact: 8,
|
||||||
|
stage: Stage::Draft,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
activity: VecDeque::new(),
|
activity: VecDeque::new(),
|
||||||
spotlight: "No selectors. Generated resources address every target.".into(),
|
spotlight: "No selectors. Generated resources address every target.".into(),
|
||||||
@@ -128,22 +147,22 @@ struct BoardLanes {
|
|||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
struct ControlCenter {
|
struct ControlCenter {
|
||||||
hero: SafeHtml,
|
hero: Html,
|
||||||
board: SafeHtml,
|
board: Html,
|
||||||
inspector: SafeHtml,
|
inspector: Html,
|
||||||
activity: SafeHtml,
|
activity: Html,
|
||||||
island_snapshot: String,
|
island_snapshot: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
struct AppShell {
|
struct AppShell {
|
||||||
body: SafeHtml,
|
body: Html,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
#[hemplate = "partials"]
|
#[hemplate = "partials"]
|
||||||
struct InspectorPanel {
|
struct InspectorPanel {
|
||||||
selected: SafeHtml,
|
selected: Html,
|
||||||
spotlight: String,
|
spotlight: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,11 +218,14 @@ struct HeroMetrics {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let state = Arc::new(Shared { demo: Mutex::new(DemoState::default()) });
|
let state = Arc::new(Shared {
|
||||||
|
demo: Mutex::new(DemoState::default()),
|
||||||
|
});
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(home).post(interact))
|
.route("/", get(home).post(interact))
|
||||||
.route("/architecture", get(architecture))
|
.route("/architecture", get(architecture))
|
||||||
.route("/events", get(events))
|
.route("/events", get(events))
|
||||||
|
.route("/favicon.ico", get(favicon))
|
||||||
.route("/slhx.js", get(runtime))
|
.route("/slhx.js", get(runtime))
|
||||||
.route("/app.css", get(app_css))
|
.route("/app.css", get(app_css))
|
||||||
.route("/control_center.css", get(control_center_css))
|
.route("/control_center.css", get(control_center_css))
|
||||||
@@ -247,16 +269,29 @@ async fn runtime() -> impl IntoResponse {
|
|||||||
runtime_js()
|
runtime_js()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn favicon() -> StatusCode {
|
||||||
|
StatusCode::NO_CONTENT
|
||||||
|
}
|
||||||
|
|
||||||
async fn app_css() -> impl IntoResponse {
|
async fn app_css() -> impl IntoResponse {
|
||||||
([("content-type", "text/css; charset=utf-8")], include_str!("../templates/app_shell.css"))
|
(
|
||||||
|
[("content-type", "text/css; charset=utf-8")],
|
||||||
|
include_str!("../templates/app_shell.css"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn control_center_css() -> impl IntoResponse {
|
async fn control_center_css() -> impl IntoResponse {
|
||||||
([("content-type", "text/css; charset=utf-8")], include_str!("../templates/control_center.css"))
|
(
|
||||||
|
[("content-type", "text/css; charset=utf-8")],
|
||||||
|
include_str!("../templates/control_center.css"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn island_js() -> impl IntoResponse {
|
async fn island_js() -> impl IntoResponse {
|
||||||
([("content-type", "text/javascript; charset=utf-8")], include_str!("../templates/island.js"))
|
(
|
||||||
|
[("content-type", "text/javascript; charset=utf-8")],
|
||||||
|
include_str!("../templates/island.js"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn interact(
|
async fn interact(
|
||||||
@@ -269,14 +304,20 @@ async fn interact(
|
|||||||
// req: push/001 req: push/003 req: examples/001
|
// req: push/001 req: push/003 req: examples/001
|
||||||
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
||||||
if params.contains_key("once") {
|
if params.contains_key("once") {
|
||||||
let effect = targets::live_feed.put(&LiveFeed { tick: 1 });
|
let effect = control::live_feed.put(&LiveFeed { tick: 1 });
|
||||||
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
|
return sse(stream::iter([Ok::<_, Infallible>(
|
||||||
|
effect.into_batch(ui::BUILD_FINGERPRINT),
|
||||||
|
)])
|
||||||
|
.boxed());
|
||||||
}
|
}
|
||||||
|
|
||||||
let batches = stream::unfold(1_u64, |tick| async move {
|
let batches = stream::unfold(1_u64, |tick| async move {
|
||||||
tokio::time::sleep(Duration::from_secs(4)).await;
|
tokio::time::sleep(Duration::from_secs(4)).await;
|
||||||
let effect = targets::live_feed.put(&LiveFeed { tick });
|
let effect = control::live_feed.put(&LiveFeed { tick });
|
||||||
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), tick + 1))
|
Some((
|
||||||
|
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
|
||||||
|
tick + 1,
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.boxed();
|
.boxed();
|
||||||
sse(batches)
|
sse(batches)
|
||||||
@@ -284,7 +325,7 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
|
|||||||
|
|
||||||
fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
|
fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
|
||||||
interactions(ui::BUILD_FINGERPRINT)
|
interactions(ui::BUILD_FINGERPRINT)
|
||||||
.on(handles::launch_work, {
|
.on(control::launch_work, {
|
||||||
let shared = shared.clone();
|
let shared = shared.clone();
|
||||||
move |form| {
|
move |form| {
|
||||||
// req: form/002 req: examples/001
|
// req: form/002 req: examples/001
|
||||||
@@ -303,7 +344,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
|
|||||||
demo_effects(&demo, "Launch accepted · 4 targets updated")
|
demo_effects(&demo, "Launch accepted · 4 targets updated")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on(card_handles::advance_work, {
|
.on(card_control::advance_work, {
|
||||||
let shared = shared.clone();
|
let shared = shared.clone();
|
||||||
move |form| {
|
move |form| {
|
||||||
// req: list/003 req: examples/001
|
// req: list/003 req: examples/001
|
||||||
@@ -321,7 +362,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
|
|||||||
demo_effects(&demo, "Pipeline advanced")
|
demo_effects(&demo, "Pipeline advanced")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on(lane_handles::move_to_lane, {
|
.on(lane_control::move_to_lane, {
|
||||||
let shared = shared.clone();
|
let shared = shared.clone();
|
||||||
move |form| {
|
move |form| {
|
||||||
// req: list/003 req: examples/001
|
// req: list/003 req: examples/001
|
||||||
@@ -343,7 +384,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
|
|||||||
demo_effects(&demo, "Drag-and-drop move persisted")
|
demo_effects(&demo, "Drag-and-drop move persisted")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on(card_handles::delete_work, {
|
.on(card_control::delete_work, {
|
||||||
let shared = shared.clone();
|
let shared = shared.clone();
|
||||||
move |form| {
|
move |form| {
|
||||||
// req: list/003 req: examples/001
|
// req: list/003 req: examples/001
|
||||||
@@ -362,7 +403,7 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
|
|||||||
demo_effects(&demo, "Card removed")
|
demo_effects(&demo, "Card removed")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on(card_handles::spotlight_work, {
|
.on(card_control::spotlight_work, {
|
||||||
let shared = shared.clone();
|
let shared = shared.clone();
|
||||||
move |form| {
|
move |form| {
|
||||||
// req: examples/001
|
// req: examples/001
|
||||||
@@ -377,23 +418,23 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
|
|||||||
demo_effects(&demo, "Inspector focused")
|
demo_effects(&demo, "Inspector focused")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on(handles::simulate_push, {
|
.on(control::simulate_push, {
|
||||||
let shared = shared.clone();
|
let shared = shared.clone();
|
||||||
move |_| {
|
move |_| {
|
||||||
// req: push/003 req: examples/001
|
// req: push/003 req: examples/001
|
||||||
let mut demo = shared.demo.lock().unwrap();
|
let mut demo = shared.demo.lock().unwrap();
|
||||||
demo.log("Simulated push event produced the same generated update shape");
|
demo.log("Simulated push event produced the same generated update shape");
|
||||||
(
|
(
|
||||||
targets::live_feed.put(&LiveFeed {
|
control::live_feed.put(&LiveFeed {
|
||||||
tick: demo.activity.len() as u64,
|
tick: demo.activity.len() as u64,
|
||||||
}),
|
}),
|
||||||
targets::activity.put(&activity_view(&demo)),
|
control::activity.put(&activity_view(&demo)),
|
||||||
slots::notice.text("Push simulated · no client app code"),
|
control::notice.text("Push simulated · no client app code"),
|
||||||
slhx::event("slhx:island-orbit", island_snapshot(&demo)),
|
ISLAND_ORBIT.emit(island_snapshot(&demo)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on(handles::reset_demo, {
|
.on(control::reset_demo, {
|
||||||
let shared = shared.clone();
|
let shared = shared.clone();
|
||||||
move |_| {
|
move |_| {
|
||||||
// req: examples/001
|
// req: examples/001
|
||||||
@@ -404,7 +445,11 @@ fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_work(demo: &mut DemoState, id: Option<u64>, update: impl FnOnce(&mut WorkItem)) -> Option<String> {
|
fn update_work(
|
||||||
|
demo: &mut DemoState,
|
||||||
|
id: Option<u64>,
|
||||||
|
update: impl FnOnce(&mut WorkItem),
|
||||||
|
) -> Option<String> {
|
||||||
let id = id?;
|
let id = id?;
|
||||||
let item = demo.work.iter_mut().find(|item| item.id == id)?;
|
let item = demo.work.iter_mut().find(|item| item.id == id)?;
|
||||||
let title = item.title.clone();
|
let title = item.title.clone();
|
||||||
@@ -414,26 +459,37 @@ fn update_work(demo: &mut DemoState, id: Option<u64>, update: impl FnOnce(&mut W
|
|||||||
|
|
||||||
fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect {
|
fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect {
|
||||||
(
|
(
|
||||||
targets::hero_metrics.put(&hero_view(demo)),
|
control::hero_metrics.put(&hero_view(demo)),
|
||||||
targets::board.put(&board_view(demo)),
|
control::board.put(&board_view(demo)),
|
||||||
targets::activity.put(&activity_view(demo)),
|
control::activity.put(&activity_view(demo)),
|
||||||
targets::inspector.put(&inspector_view(demo)),
|
control::inspector.put(&inspector_view(demo)),
|
||||||
slots::notice.text(notice),
|
control::notice.text(notice),
|
||||||
forms::launch_work.clear("title"),
|
control::launch_work_form.clear(),
|
||||||
slhx::event("slhx:island-orbit", island_snapshot(demo)),
|
ISLAND_ORBIT.emit(island_snapshot(demo)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_lane(value: Option<&str>) -> usize {
|
fn parse_lane(value: Option<&str>) -> usize {
|
||||||
let value = value.unwrap_or(LANES[0].0);
|
let value = value.unwrap_or(LANES[0].0);
|
||||||
LANES.iter().position(|(id, _, _)| *id == value).unwrap_or(0)
|
LANES
|
||||||
|
.iter()
|
||||||
|
.position(|(id, _, _)| *id == value)
|
||||||
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn island_snapshot(demo: &DemoState) -> String {
|
fn island_snapshot(demo: &DemoState) -> String {
|
||||||
// Opaque leaf-widget bridge: compact server snapshot in, native CustomEvent out.
|
// Opaque leaf-widget bridge: compact server snapshot in, native CustomEvent out.
|
||||||
// req: interop/001 req: examples/001
|
// req: interop/001 req: examples/001
|
||||||
let active = demo.work.iter().filter(|item| item.stage == Stage::Active).count();
|
let active = demo
|
||||||
let shipped = demo.work.iter().filter(|item| item.stage == Stage::Shipped).count();
|
.work
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.stage == Stage::Active)
|
||||||
|
.count();
|
||||||
|
let shipped = demo
|
||||||
|
.work
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.stage == Stage::Shipped)
|
||||||
|
.count();
|
||||||
let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum();
|
let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum();
|
||||||
format!(
|
format!(
|
||||||
"{}|{}|{}|{} active · {} shipped · {} activity rows",
|
"{}|{}|{}|{} active · {} shipped · {} activity rows",
|
||||||
@@ -446,7 +502,7 @@ fn island_snapshot(demo: &DemoState) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn page_html(demo: &DemoState) -> SafeHtml {
|
fn page_html(demo: &DemoState) -> Html {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
render_control_center(ControlCenter {
|
render_control_center(ControlCenter {
|
||||||
hero: render_hero(demo),
|
hero: render_hero(demo),
|
||||||
@@ -457,15 +513,23 @@ fn page_html(demo: &DemoState) -> SafeHtml {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shell(body: SafeHtml) -> SafeHtml {
|
fn shell(body: Html) -> Html {
|
||||||
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
|
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
|
||||||
ui::app_shell::render(&AppShell { body })
|
ui::app_shell::render(&AppShell { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hero_view(demo: &DemoState) -> HeroMetrics {
|
fn hero_view(demo: &DemoState) -> HeroMetrics {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
let shipped = demo.work.iter().filter(|item| item.stage == Stage::Shipped).count();
|
let shipped = demo
|
||||||
let active = demo.work.iter().filter(|item| item.stage == Stage::Active).count();
|
.work
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.stage == Stage::Shipped)
|
||||||
|
.count();
|
||||||
|
let active = demo
|
||||||
|
.work
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.stage == Stage::Active)
|
||||||
|
.count();
|
||||||
let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum();
|
let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum();
|
||||||
HeroMetrics {
|
HeroMetrics {
|
||||||
resource_count: 13,
|
resource_count: 13,
|
||||||
@@ -475,7 +539,7 @@ fn hero_view(demo: &DemoState) -> HeroMetrics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_hero(demo: &DemoState) -> SafeHtml {
|
fn render_hero(demo: &DemoState) -> Html {
|
||||||
// req: html_safety/002 req: view/001 req: component/003
|
// req: html_safety/002 req: view/001 req: component/003
|
||||||
ui::render(&hero_view(demo))
|
ui::render(&hero_view(demo))
|
||||||
}
|
}
|
||||||
@@ -519,7 +583,7 @@ fn activity_view(demo: &DemoState) -> ActivityFeed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_activity(demo: &DemoState) -> SafeHtml {
|
fn render_activity(demo: &DemoState) -> Html {
|
||||||
// req: html_safety/002 req: view/001 req: component/003
|
// req: html_safety/002 req: view/001 req: component/003
|
||||||
ui::render(&activity_view(demo))
|
ui::render(&activity_view(demo))
|
||||||
}
|
}
|
||||||
@@ -546,32 +610,32 @@ fn inspector_view(demo: &DemoState) -> InspectorPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_inspector(demo: &DemoState) -> SafeHtml {
|
fn render_inspector(demo: &DemoState) -> Html {
|
||||||
// req: html_safety/002 req: view/001 req: component/003
|
// req: html_safety/002 req: view/001 req: component/003
|
||||||
ui::render(&inspector_view(demo))
|
ui::render(&inspector_view(demo))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn architecture_hero() -> SafeHtml {
|
fn architecture_hero() -> Html {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
ui::render(&ArchitectureHero)
|
ui::render(&ArchitectureHero)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn architecture_board() -> SafeHtml {
|
fn architecture_board() -> Html {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
ui::render(&ArchitectureBoard)
|
ui::render(&ArchitectureBoard)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn architecture_inspector() -> SafeHtml {
|
fn architecture_inspector() -> Html {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
ui::render(&ArchitectureInspector)
|
ui::render(&ArchitectureInspector)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn architecture_activity() -> SafeHtml {
|
fn architecture_activity() -> Html {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
ui::render(&ArchitectureActivity)
|
ui::render(&ArchitectureActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_control_center(page: ControlCenter) -> SafeHtml {
|
fn render_control_center(page: ControlCenter) -> Html {
|
||||||
// req: html_safety/002 req: view/001 req: component/003
|
// req: html_safety/002 req: view/001 req: component/003
|
||||||
ui::render(&page)
|
ui::render(&page)
|
||||||
}
|
}
|
||||||
@@ -597,11 +661,36 @@ mod tests {
|
|||||||
.map(|title| title.text().collect::<String>()),
|
.map(|title| title.text().collect::<String>()),
|
||||||
Some("slhx Techdemo".to_owned())
|
Some("slhx Techdemo".to_owned())
|
||||||
);
|
);
|
||||||
assert_eq!(document.select(&selector("script[src=\"/slhx.js\"]")).count(), 1);
|
assert_eq!(
|
||||||
assert_eq!(document.select(&selector("link[rel=\"stylesheet\"]")).count(), 2);
|
document
|
||||||
assert_eq!(document.select(&selector("link[href=\"/app.css\"]")).count(), 1);
|
.select(&selector("script[src=\"/slhx.js\"]"))
|
||||||
assert_eq!(document.select(&selector("link[href=\"/control_center.css\"]")).count(), 1);
|
.count(),
|
||||||
assert_eq!(document.select(&selector("main[data-slhx-root=\"techdemo\"]")).count(), 1);
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector("link[rel=\"stylesheet\"]"))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector("link[href=\"/app.css\"]"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector("link[href=\"/control_center.css\"]"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector("main[data-slhx-root=\"techdemo\"]"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
assert!(!html.contains("{+="));
|
assert!(!html.contains("{+="));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,13 +704,29 @@ mod tests {
|
|||||||
assert!(!html.contains("__ACTIVITY__"));
|
assert!(!html.contains("__ACTIVITY__"));
|
||||||
|
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
assert_eq!(document.select(&selector("[data-slhx-root=\"techdemo\"]")).count(), 1);
|
assert_eq!(
|
||||||
assert!(document.select(&selector("[data-sid]")).count() >= 7);
|
document
|
||||||
assert!(document.select(&selector("[data-hid]")).count() >= 7);
|
.select(&selector("[data-slhx-root=\"techdemo\"]"))
|
||||||
assert_eq!(document.select(&selector(".hero-panel .metrics")).count(), 1);
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document.select(&selector(".hero-panel .metrics")).count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
assert_eq!(document.select(&selector(".board-card .lanes")).count(), 1);
|
assert_eq!(document.select(&selector(".board-card .lanes")).count(), 1);
|
||||||
assert_eq!(document.select(&selector(".glass-card .inspector-hero")).count(), 1);
|
assert_eq!(
|
||||||
assert_eq!(document.select(&selector(".glass-card ol.activity")).count(), 1);
|
document
|
||||||
|
.select(&selector(".glass-card .inspector-hero"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector(".glass-card ol.activity"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
@@ -629,7 +734,9 @@ mod tests {
|
|||||||
fn hero_metrics_are_rendered_by_a_hemplate_view() {
|
fn hero_metrics_are_rendered_by_a_hemplate_view() {
|
||||||
let html = render_hero(&DemoState::default());
|
let html = render_hero(&DemoState::default());
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
let metrics = document.select(&selector(".metrics > .metric")).collect::<Vec<_>>();
|
let metrics = document
|
||||||
|
.select(&selector(".metrics > .metric"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(metrics.len(), 4);
|
assert_eq!(metrics.len(), 4);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
metrics[0]
|
metrics[0]
|
||||||
@@ -638,9 +745,10 @@ mod tests {
|
|||||||
.map(|span| span.text().collect::<String>()),
|
.map(|span| span.text().collect::<String>()),
|
||||||
Some("generated resources on this page".to_owned())
|
Some("generated resources on this page".to_owned())
|
||||||
);
|
);
|
||||||
assert!(metrics
|
assert!(metrics.iter().any(|metric| metric
|
||||||
.iter()
|
.text()
|
||||||
.any(|metric| metric.text().collect::<String>().contains("aggregate impact score")));
|
.collect::<String>()
|
||||||
|
.contains("aggregate impact score")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: style/001 req: style/002 req: style/003 req: test/005
|
// req: style/001 req: style/002 req: style/003 req: test/005
|
||||||
@@ -652,7 +760,10 @@ mod tests {
|
|||||||
assert_eq!(classes::is_selected.as_str(), "is-selected");
|
assert_eq!(classes::is_selected.as_str(), "is-selected");
|
||||||
|
|
||||||
let document = Html::parse_fragment(board.as_str());
|
let document = Html::parse_fragment(board.as_str());
|
||||||
assert_eq!(document.select(&selector(".lanes > section.lane")).count(), 3);
|
assert_eq!(
|
||||||
|
document.select(&selector(".lanes > section.lane")).count(),
|
||||||
|
3
|
||||||
|
);
|
||||||
let lane = document
|
let lane = document
|
||||||
.select(&selector(r#"section.lane[data-lane="compiler"]"#))
|
.select(&selector(r#"section.lane[data-lane="compiler"]"#))
|
||||||
.next()
|
.next()
|
||||||
@@ -663,7 +774,10 @@ mod tests {
|
|||||||
.select(&selector(r#"article.work-card.is-selected[data-key="2"]"#))
|
.select(&selector(r#"article.work-card.is-selected[data-key="2"]"#))
|
||||||
.next()
|
.next()
|
||||||
.expect("selected work card renders");
|
.expect("selected work card renders");
|
||||||
assert_eq!(selected_card.value().attr("class"), Some("work-card is-selected"));
|
assert_eq!(
|
||||||
|
selected_card.value().attr("class"),
|
||||||
|
Some("work-card is-selected")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
@@ -684,7 +798,7 @@ mod tests {
|
|||||||
assert_eq!(document.select(&selector(".inspector-row")).count(), 3);
|
assert_eq!(document.select(&selector(".inspector-row")).count(), 3);
|
||||||
assert!(document
|
assert!(document
|
||||||
.select(&selector("code"))
|
.select(&selector("code"))
|
||||||
.any(|code| code.text().collect::<String>().contains("targets::*")));
|
.any(|code| code.text().collect::<String>().contains("control::*")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
@@ -698,16 +812,16 @@ mod tests {
|
|||||||
.expect("live feed row renders");
|
.expect("live feed row renders");
|
||||||
let text = row.text().collect::<String>();
|
let text = row.text().collect::<String>();
|
||||||
assert!(text.contains("SSE tick #7"));
|
assert!(text.contains("SSE tick #7"));
|
||||||
assert!(row
|
assert!(text.contains("live feed target"));
|
||||||
.select(&selector("code"))
|
assert_eq!(row.select(&selector("code")).count(), 0);
|
||||||
.any(|code| code.text().collect::<String>() == "slots::live_feed"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
#[test]
|
#[test]
|
||||||
fn activity_payload_is_rendered_by_a_hemplate_view() {
|
fn activity_payload_is_rendered_by_a_hemplate_view() {
|
||||||
let mut demo = DemoState::default();
|
let mut demo = DemoState::default();
|
||||||
demo.activity.push_back("<b>escaped activity</b>".to_owned());
|
demo.activity
|
||||||
|
.push_back("<b>escaped activity</b>".to_owned());
|
||||||
|
|
||||||
let html = render_activity(&demo);
|
let html = render_activity(&demo);
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
@@ -753,7 +867,9 @@ mod tests {
|
|||||||
.next()
|
.next()
|
||||||
.expect("architecture inspector row renders");
|
.expect("architecture inspector row renders");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
row.select(&selector("b")).next().map(|b| b.text().collect::<String>()),
|
row.select(&selector("b"))
|
||||||
|
.next()
|
||||||
|
.map(|b| b.text().collect::<String>()),
|
||||||
Some("Page swap".to_owned())
|
Some("Page swap".to_owned())
|
||||||
);
|
);
|
||||||
assert!(row
|
assert!(row
|
||||||
@@ -786,7 +902,9 @@ mod tests {
|
|||||||
fn architecture_board_is_rendered_by_a_hemplate_view() {
|
fn architecture_board_is_rendered_by_a_hemplate_view() {
|
||||||
let html = architecture_board();
|
let html = architecture_board();
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
let lanes = document.select(&selector(".lanes > section.lane")).collect::<Vec<_>>();
|
let lanes = document
|
||||||
|
.select(&selector(".lanes > section.lane"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(lanes.len(), 3);
|
assert_eq!(lanes.len(), 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
lanes[0]
|
lanes[0]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<aside class="command-card">
|
<aside class="command-card">
|
||||||
<h2>Create an issue</h2>
|
<h2>Create an issue</h2>
|
||||||
<form id="launch-work" method="post" data-slhx-handle="launch_work" data-slhx-form="launch_work" data-slhx-disable-while-pending>
|
<form id="launch-work" method="post" data-slhx-handle="launch_work" data-slhx-form="launch_work" data-slhx-disable-while-pending>
|
||||||
<label>Title <input name="title" required="required" value="Ship typed effects"></label>
|
<label>Title <input name="title" required="required" value="Ship typed updates"></label>
|
||||||
<label>Lane
|
<label>Lane
|
||||||
<select name="lane" required="required">
|
<select name="lane" required="required">
|
||||||
<option value="compiler">Compiler</option>
|
<option value="compiler">Compiler</option>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<button type="button" data-slhx-handle="simulate_push">Simulate server push</button>
|
<button type="button" data-slhx-handle="simulate_push">Simulate server push</button>
|
||||||
<button type="button" data-slhx-handle="reset_demo">Reset demo</button>
|
<button type="button" data-slhx-handle="reset_demo">Reset demo</button>
|
||||||
</div>
|
</div>
|
||||||
<p data-slhx-slot="notice" class="notice">Every control posts a numeric handle id and receives typed effects.</p>
|
<p data-slhx-slot="notice" class="notice">Every control posts through a generated handle and receives typed updates.</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="board-card">
|
<section class="board-card">
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
|
|
||||||
<section class="insight-grid">
|
<section class="insight-grid">
|
||||||
<article class="glass-card">
|
<article class="glass-card">
|
||||||
<h2>Effect inspector</h2>
|
<h2>Update inspector</h2>
|
||||||
<div data-slhx-slot="inspector">{+= self.inspector =+}</div>
|
<div data-slhx-slot="inspector" class="inspector-slot">{+= self.inspector =+}</div>
|
||||||
</article>
|
</article>
|
||||||
<article class="glass-card island-card" data-slhx-island="orbit" +data-island-snapshot="self.island_snapshot">
|
<article class="glass-card island-card" data-slhx-island="orbit" +data-island-snapshot="self.island_snapshot">
|
||||||
<h2>Opaque island bridge</h2>
|
<h2>Opaque island bridge</h2>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
</article>
|
</article>
|
||||||
<article class="glass-card glow">
|
<article class="glass-card glow">
|
||||||
<h2>Server push</h2>
|
<h2>Server push</h2>
|
||||||
<div data-slhx-slot="live_feed">Waiting for SSE heartbeat…</div>
|
<div data-slhx-slot="live_feed" class="live-feed">Waiting for SSE heartbeat…</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{+= self.selected =+}
|
{+= self.selected =+}
|
||||||
<div class="inspector-row"><b>What happened</b><br>{+ self.spotlight +}</div>
|
<div class="inspector-row"><b>What happened</b><br>{+ self.spotlight +}</div>
|
||||||
<div class="inspector-row"><b>Update path</b><br><code>targets::* → tuple effects → DOM</code></div>
|
<div class="inspector-row"><b>Update path</b><br><code>generated targets → tuple updates → DOM</code></div>
|
||||||
<div class="inspector-row"><b>Runtime</b><br>Root-scoped delegated listeners; numeric targets only.</div>
|
<div class="inspector-row"><b>Runtime</b><br>Root-scoped delegated listeners; generated targets only.</div>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<header><strong>{+ self.title +}</strong><span class="pill">{+ self.stage +}</span></header>
|
<header><strong>{+ self.title +}</strong><span class="pill">{+ self.stage +}</span></header>
|
||||||
<div class="impact"><i +style="self.impact_style"></i></div>
|
<div class="impact"><i +style="self.impact_style"></i></div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button type="button" data-slhx-handle="spotlight_work" +data-work-id="self.id">Inspect</button>
|
<button type="button" data-slhx-handle="spotlight_work" +data-work-id="self.id" name="work_id" +value="self.id">Inspect</button>
|
||||||
<button type="button" data-slhx-handle="advance_work" +data-work-id="self.id">Advance</button>
|
<button type="button" data-slhx-handle="advance_work" +data-work-id="self.id" name="work_id" +value="self.id">Advance</button>
|
||||||
<button type="button" data-slhx-handle="delete_work" +data-work-id="self.id">Delete</button>
|
<button type="button" data-slhx-handle="delete_work" +data-work-id="self.id" name="work_id" +value="self.id">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<div class="live-row"><strong>SSE tick #{+ self.tick +}</strong><br>Server streamed a generated update into <code>slots::live_feed</code>.</div>
|
<div class="live-row"><strong>SSE tick #{+ self.tick +}</strong><br>Server streamed a generated update into the live feed target.</div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
use slhx_techdemo::ui::control_center::{handles, slots};
|
use slhx_techdemo::ui::control_center::{self as control, launch_work, simulate_push};
|
||||||
use slhx_techdemo::ui::issue_card::handles as card_handles;
|
use slhx_test::{
|
||||||
|
any_root_selector, document_body_selector, handle_button_selector, handle_selector,
|
||||||
|
island_probe_script, island_readout_selector, keyed_selector, nav_link_selector,
|
||||||
|
scoped_island_readout_selector, target_selector,
|
||||||
|
};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use thirtyfour::prelude::*;
|
use thirtyfour::prelude::*;
|
||||||
@@ -48,63 +52,70 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
|
|||||||
|
|
||||||
let result = async {
|
let result = async {
|
||||||
driver.goto(&format!("http://{APP_ADDR}/")).await?;
|
driver.goto(&format!("http://{APP_ADDR}/")).await?;
|
||||||
assert_text(&driver, "A Linear-class work system without a frontend framework").await?;
|
assert_text(
|
||||||
|
&driver,
|
||||||
|
"A Linear-class work system without a frontend framework",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
assert_text(&driver, "Compile checked handles").await?;
|
assert_text(&driver, "Compile checked handles").await?;
|
||||||
assert_text(&driver, "No selectors. Generated resources address every target.").await?;
|
assert_text(
|
||||||
|
&driver,
|
||||||
|
"No selectors. Generated resources address every target.",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
assert_text(&driver, "Opaque island bridge").await?;
|
assert_text(&driver, "Opaque island bridge").await?;
|
||||||
wait_for_runtime(&driver).await?;
|
wait_for_runtime(&driver).await?;
|
||||||
insert_probe_island(&driver).await?;
|
insert_probe_island(&driver).await?;
|
||||||
wait_for_text(&driver, "#probe-island [data-island-readout]", "probe live").await?;
|
wait_for_text(
|
||||||
|
&driver,
|
||||||
|
&scoped_island_readout_selector("#probe-island"),
|
||||||
|
"probe live",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
driver
|
driver
|
||||||
.find(By::Css(&handle_selector(handles::simulate_push)))
|
.find(By::Css(&handle_selector(simulate_push)))
|
||||||
.await?
|
.await?
|
||||||
.click()
|
.click()
|
||||||
.await?;
|
.await?;
|
||||||
wait_for_text(&driver, &slot_selector(slots::notice), "Push simulated").await?;
|
wait_for_text(&driver, &target_selector(control::notice), "Push simulated").await?;
|
||||||
wait_for_text(&driver, "[data-island-readout]", "activity rows").await?;
|
wait_for_text(&driver, island_readout_selector(), "activity rows").await?;
|
||||||
|
|
||||||
driver.find(By::Css("button.primary-action")).await?.click().await?;
|
|
||||||
wait_for_text(&driver, ".work-card[data-key='4']", "Ship typed effects").await?;
|
|
||||||
assert_text(&driver, "width:77%").await?;
|
|
||||||
|
|
||||||
drag_card_to_lane(&driver, 2, "product").await?;
|
|
||||||
wait_for_text(&driver, ".lane[data-lane='product'] .work-card[data-key='2']", "Shipped").await?;
|
|
||||||
wait_for_text(&driver, &slot_selector(slots::notice), "Drag-and-drop move persisted").await?;
|
|
||||||
|
|
||||||
driver
|
driver
|
||||||
.find(By::Css(&card_button_selector(card_handles::spotlight_work, 2)))
|
.find(By::Css(&handle_button_selector(launch_work)))
|
||||||
.await?
|
.await?
|
||||||
.click()
|
.click()
|
||||||
.await?;
|
.await?;
|
||||||
wait_for_text(
|
wait_for_text(
|
||||||
&driver,
|
&driver,
|
||||||
&slot_selector(slots::inspector),
|
&keyed_selector(".work-card", 4),
|
||||||
"Stream typed presence · lane=Product · stage=Shipped · impact=7",
|
"Ship typed updates",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
assert_text(&driver, "width:77%").await?;
|
||||||
|
|
||||||
driver
|
driver
|
||||||
.find(By::Css(&card_button_selector(card_handles::advance_work, 3)))
|
.find(By::Css(&handle_selector(simulate_push)))
|
||||||
.await?
|
.await?
|
||||||
.click()
|
.click()
|
||||||
.await?;
|
.await?;
|
||||||
wait_for_text(&driver, ".lane[data-lane='product'] .work-card[data-key='3']", "Active").await?;
|
wait_for_text(&driver, &target_selector(control::live_feed), "SSE tick").await?;
|
||||||
|
|
||||||
driver
|
driver
|
||||||
.find(By::Css(&handle_selector(handles::simulate_push)))
|
.find(By::Css(&nav_link_selector("/architecture")))
|
||||||
.await?
|
.await?
|
||||||
.click()
|
.click()
|
||||||
.await?;
|
.await?;
|
||||||
wait_for_text(&driver, &slot_selector(slots::live_feed), "SSE tick").await?;
|
wait_for_text(&driver, &target_selector(control::inspector), "Page swap").await?;
|
||||||
|
wait_for_text(&driver, island_readout_selector(), "activity rows").await?;
|
||||||
driver.find(By::Css("a[href='/architecture']")).await?.click().await?;
|
assert!(driver
|
||||||
wait_for_text(&driver, &slot_selector(slots::inspector), "Page swap").await?;
|
.current_url()
|
||||||
wait_for_text(&driver, "[data-island-readout]", "activity rows").await?;
|
.await?
|
||||||
assert!(driver.current_url().await?.as_str().ends_with("/architecture"));
|
.as_str()
|
||||||
|
.ends_with("/architecture"));
|
||||||
|
|
||||||
driver.goto(&format!("http://{APP_ADDR}/")).await?;
|
driver.goto(&format!("http://{APP_ADDR}/")).await?;
|
||||||
wait_for_text(&driver, &slot_selector(slots::live_feed), "SSE tick").await?;
|
wait_for_text(&driver, &target_selector(control::live_feed), "SSE tick").await?;
|
||||||
|
|
||||||
Ok::<(), WebDriverError>(())
|
Ok::<(), WebDriverError>(())
|
||||||
}
|
}
|
||||||
@@ -125,68 +136,15 @@ fn wait_for_tcp(addr: &str) {
|
|||||||
panic!("timed out waiting for {addr}");
|
panic!("timed out waiting for {addr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_selector(handle: impl std::fmt::Display) -> String {
|
|
||||||
format!(r#"[data-hid="{handle}"]"#)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn slot_selector(slot: impl std::fmt::Display) -> String {
|
|
||||||
format!(r#"[data-sid="{slot}"]"#)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn card_button_selector(handle_id: impl std::fmt::Display, work_id: u64) -> String {
|
|
||||||
format!(r#"[data-hid="{handle_id}"][data-work-id="{work_id}"]"#)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn insert_probe_island(driver: &WebDriver) -> WebDriverResult<()> {
|
async fn insert_probe_island(driver: &WebDriver) -> WebDriverResult<()> {
|
||||||
let root = driver.find(By::Css("[data-slhx-root]")).await?;
|
let root = driver.find(By::Css(any_root_selector())).await?;
|
||||||
driver
|
let script = island_probe_script(
|
||||||
.execute(
|
"probe-island",
|
||||||
r#"
|
"orbit",
|
||||||
const root = arguments[0];
|
"1|1|1|probe waiting",
|
||||||
const island = document.createElement('article');
|
"7|2|8|probe live",
|
||||||
island.id = 'probe-island';
|
);
|
||||||
island.setAttribute('data-slhx-island', 'orbit');
|
driver.execute(&script, vec![root.to_json()?]).await?;
|
||||||
island.setAttribute('data-island-snapshot', '1|1|1|probe waiting');
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = 32;
|
|
||||||
canvas.height = 16;
|
|
||||||
island.appendChild(canvas);
|
|
||||||
|
|
||||||
const readout = document.createElement('p');
|
|
||||||
readout.setAttribute('data-island-readout', '');
|
|
||||||
readout.textContent = 'waiting';
|
|
||||||
island.appendChild(readout);
|
|
||||||
|
|
||||||
root.appendChild(island);
|
|
||||||
setTimeout(() => {
|
|
||||||
root.dispatchEvent(new CustomEvent('slhx:island-orbit', { bubbles: true, detail: '7|2|8|probe live' }));
|
|
||||||
}, 25);
|
|
||||||
return true;
|
|
||||||
"#,
|
|
||||||
vec![root.to_json()?],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn drag_card_to_lane(driver: &WebDriver, work_id: u64, lane: &str) -> WebDriverResult<()> {
|
|
||||||
let card = driver.find(By::Css(format!(".work-card[data-key='{work_id}']"))).await?;
|
|
||||||
let lane = driver.find(By::Css(format!(".lane[data-lane='{lane}']"))).await?;
|
|
||||||
driver
|
|
||||||
.execute(
|
|
||||||
r#"
|
|
||||||
const card = arguments[0];
|
|
||||||
const lane = arguments[1];
|
|
||||||
const data = new DataTransfer();
|
|
||||||
card.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: data }));
|
|
||||||
lane.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: data }));
|
|
||||||
lane.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: data }));
|
|
||||||
return true;
|
|
||||||
"#,
|
|
||||||
vec![card.to_json()?, lane.to_json()?],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +152,10 @@ async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> {
|
|||||||
let deadline = Instant::now() + Duration::from_secs(8);
|
let deadline = Instant::now() + Duration::from_secs(8);
|
||||||
loop {
|
loop {
|
||||||
let loaded = driver
|
let loaded = driver
|
||||||
.execute("return !!window.slhx && window.slhx.roots().length > 0", Vec::new())
|
.execute(
|
||||||
|
"return !!window.slhx && window.slhx.roots().length > 0",
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.json()
|
.json()
|
||||||
.as_bool()
|
.as_bool()
|
||||||
@@ -210,7 +171,7 @@ async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn assert_text(driver: &WebDriver, text: &str) -> WebDriverResult<()> {
|
async fn assert_text(driver: &WebDriver, text: &str) -> WebDriverResult<()> {
|
||||||
wait_for_text(driver, "body", text).await
|
wait_for_text(driver, document_body_selector(), text).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDriverResult<()> {
|
async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDriverResult<()> {
|
||||||
@@ -225,7 +186,12 @@ async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
let body = driver.find(By::Css("body")).await?.text().await.unwrap_or_default();
|
let body = driver
|
||||||
|
.find(By::Css(document_body_selector()))
|
||||||
|
.await?
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
panic!("timed out waiting for {text:?} in {selector:?}; body={body:?}");
|
panic!("timed out waiting for {text:?} in {selector:?}; body={body:?}");
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
|||||||
+158
-149
@@ -1,10 +1,14 @@
|
|||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
use slhx::{Effect, EffectBatch, Handle, Payload, EFFECT_BATCH_ABI_VERSION};
|
use slhx_techdemo::ui::control_center::{self as control, launch_work, reset_demo, simulate_push};
|
||||||
use slhx_axum::SLHX_HANDLE_FIELD;
|
use slhx_techdemo::ui::issue_card::{advance_work, delete_work, spotlight_work};
|
||||||
|
use slhx_techdemo::ui::issue_lane::move_to_lane as move_to_lane_handle;
|
||||||
use slhx_techdemo::ui::BUILD_FINGERPRINT;
|
use slhx_techdemo::ui::BUILD_FINGERPRINT;
|
||||||
use slhx_techdemo::ui::control_center::handles;
|
use slhx_test::{
|
||||||
use slhx_techdemo::ui::issue_card::handles as card_handles;
|
class_descendant_selector, class_selector, handle_form_body, inspect_wire,
|
||||||
use slhx_techdemo::ui::issue_lane::handles as lane_handles;
|
island_attribute_name, island_event_name, island_selector, island_snapshot_marker,
|
||||||
|
root_selector, sse_endpoint_marker, strong_text_selector, unknown_handle_form_body,
|
||||||
|
EffectInspector,
|
||||||
|
};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
@@ -12,17 +16,6 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
const ADDR: &str = "127.0.0.1:3002";
|
const ADDR: &str = "127.0.0.1:3002";
|
||||||
|
|
||||||
fn handle_body<I>(handle: Handle<I>, fields: &[(&str, &str)]) -> String {
|
|
||||||
let mut body = format!("{SLHX_HANDLE_FIELD}={handle}");
|
|
||||||
for (name, value) in fields {
|
|
||||||
body.push('&');
|
|
||||||
body.push_str(name);
|
|
||||||
body.push('=');
|
|
||||||
body.push_str(value);
|
|
||||||
}
|
|
||||||
body
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Server {
|
struct Server {
|
||||||
child: Child,
|
child: Child,
|
||||||
}
|
}
|
||||||
@@ -63,29 +56,32 @@ fn product_is_e2e_working_over_http() {
|
|||||||
assert_eq!(home.status, 200);
|
assert_eq!(home.status, 200);
|
||||||
assert!(home.header("content-type").contains("text/html"));
|
assert!(home.header("content-type").contains("text/html"));
|
||||||
let document = Html::parse_document(home.text());
|
let document = Html::parse_document(home.text());
|
||||||
assert_text(&document, "A Linear-class work system without a frontend framework");
|
assert_text(
|
||||||
|
&document,
|
||||||
|
"A Linear-class work system without a frontend framework",
|
||||||
|
);
|
||||||
assert_text(&document, "Compile checked handles");
|
assert_text(&document, "Compile checked handles");
|
||||||
assert_text(&document, "Stream typed presence");
|
assert_text(&document, "Stream typed presence");
|
||||||
assert_selector_count_at_least(&document, "[data-slhx-root=techdemo]", 1);
|
assert_selector_count_at_least(&document, &root_selector("techdemo"), 1);
|
||||||
assert_selector_count_at_least(&document, "[data-hid]", 8);
|
assert_work_card_count_at_least(&document, 3);
|
||||||
assert_selector_count_at_least(&document, "[data-sid]", 7);
|
assert_selector_count_at_least(&document, &island_selector("orbit"), 1);
|
||||||
assert_selector_count_at_least(&document, ".work-card", 3);
|
|
||||||
assert_selector_count_at_least(&document, "[data-slhx-island=orbit]", 1);
|
|
||||||
assert_text(&document, "Opaque island bridge");
|
assert_text(&document, "Opaque island bridge");
|
||||||
assert!(home.text().contains("data-island-snapshot="));
|
assert!(home.text().contains(island_snapshot_marker()));
|
||||||
assert!(home.text().contains("data-slhx-sse=\"/events\""));
|
assert!(home.text().contains(&sse_endpoint_marker("/events")));
|
||||||
assert!(home.text().contains("/island.js"));
|
assert!(home.text().contains("/island.js"));
|
||||||
|
|
||||||
|
let favicon = get("/favicon.ico");
|
||||||
|
assert_eq!(favicon.status, 204);
|
||||||
|
|
||||||
let runtime = get("/slhx.js");
|
let runtime = get("/slhx.js");
|
||||||
assert_eq!(runtime.status, 200);
|
assert_eq!(runtime.status, 200);
|
||||||
assert!(runtime.header("content-type").contains("javascript"));
|
assert!(runtime.header("content-type").contains("javascript"));
|
||||||
assert!(runtime.text().contains("const HID = \"data-hid\""));
|
|
||||||
|
|
||||||
let island = get("/island.js");
|
let island = get("/island.js");
|
||||||
assert_eq!(island.status, 200);
|
assert_eq!(island.status, 200);
|
||||||
assert!(island.header("content-type").contains("javascript"));
|
assert!(island.header("content-type").contains("javascript"));
|
||||||
assert!(island.text().contains("slhx:island-orbit"));
|
assert!(island.text().contains(&island_event_name("orbit")));
|
||||||
assert!(island.text().contains("data-slhx-island"));
|
assert!(island.text().contains(island_attribute_name()));
|
||||||
assert!(island.text().contains("MutationObserver"));
|
assert!(island.text().contains("MutationObserver"));
|
||||||
assert!(island.text().contains("removeEventListener"));
|
assert!(island.text().contains("removeEventListener"));
|
||||||
|
|
||||||
@@ -98,156 +94,183 @@ fn product_is_e2e_working_over_http() {
|
|||||||
|
|
||||||
let launch = post(
|
let launch = post(
|
||||||
"/",
|
"/",
|
||||||
&handle_body(
|
&handle_form_body(
|
||||||
handles::launch_work,
|
launch_work,
|
||||||
&[
|
&[
|
||||||
("title", "Design+hero+moment"),
|
("title", "Design hero moment"),
|
||||||
("lane", "product"),
|
("lane", "product"),
|
||||||
("impact", "9"),
|
("impact", "9"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert_effect_response(&launch);
|
assert_effect_response(&launch);
|
||||||
let launch_batch = launch.batch();
|
let launch_batch = launch.effects();
|
||||||
assert_payload_contains(&launch_batch, "Design hero moment");
|
assert_payload_contains(&launch_batch, "Design hero moment");
|
||||||
assert_card(&launch_batch, "Design hero moment", "Product", "Draft", "width:99%");
|
assert_card(
|
||||||
|
&launch_batch,
|
||||||
|
"Design hero moment",
|
||||||
|
"Product",
|
||||||
|
"Draft",
|
||||||
|
"width:99%",
|
||||||
|
);
|
||||||
assert_payload_contains(&launch_batch, "Launch accepted");
|
assert_payload_contains(&launch_batch, "Launch accepted");
|
||||||
assert_payload_contains(&launch_batch, "Launched card #4");
|
assert_payload_contains(&launch_batch, "Launched card #4");
|
||||||
assert_emit(&launch_batch, "slhx:island-orbit", "activity rows");
|
assert_emit(&launch_batch, &island_event_name("orbit"), "activity rows");
|
||||||
assert!(
|
assert!(
|
||||||
launch_batch.ops.len() >= 6,
|
launch_batch.op_count() >= 6,
|
||||||
"launch should update generated targets and notify the island"
|
"launch should update generated targets and notify the island"
|
||||||
);
|
);
|
||||||
|
|
||||||
let default_impact = post(
|
let default_impact = post(
|
||||||
"/",
|
"/",
|
||||||
&handle_body(
|
&handle_form_body(
|
||||||
handles::launch_work,
|
launch_work,
|
||||||
&[("title", "Default+impact"), ("lane", "compiler")],
|
&[("title", "Default impact"), ("lane", "compiler")],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert_effect_response(&default_impact);
|
assert_effect_response(&default_impact);
|
||||||
let default_impact_batch = default_impact.batch();
|
let default_impact_batch = default_impact.effects();
|
||||||
assert_payload_contains(&default_impact_batch, "Default impact");
|
assert_payload_contains(&default_impact_batch, "Default impact");
|
||||||
assert_card(&default_impact_batch, "Default impact", "Compiler", "Draft", "width:55%");
|
assert_card(
|
||||||
|
&default_impact_batch,
|
||||||
|
"Default impact",
|
||||||
|
"Compiler",
|
||||||
|
"Draft",
|
||||||
|
"width:55%",
|
||||||
|
);
|
||||||
|
|
||||||
let low_impact = post(
|
let low_impact = post(
|
||||||
"/",
|
"/",
|
||||||
&handle_body(
|
&handle_form_body(
|
||||||
handles::launch_work,
|
launch_work,
|
||||||
&[
|
&[
|
||||||
("title", "Low+impact"),
|
("title", "Low impact"),
|
||||||
("lane", "runtime"),
|
("lane", "runtime"),
|
||||||
("impact", "0"),
|
("impact", "0"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert_effect_response(&low_impact);
|
assert_effect_response(&low_impact);
|
||||||
let low_impact_batch = low_impact.batch();
|
let low_impact_batch = low_impact.effects();
|
||||||
assert_card(&low_impact_batch, "Low impact", "Runtime", "Draft", "width:11%");
|
assert_card(
|
||||||
|
&low_impact_batch,
|
||||||
|
"Low impact",
|
||||||
|
"Runtime",
|
||||||
|
"Draft",
|
||||||
|
"width:11%",
|
||||||
|
);
|
||||||
|
|
||||||
let high_impact = post(
|
let high_impact = post(
|
||||||
"/",
|
"/",
|
||||||
&handle_body(
|
&handle_form_body(
|
||||||
handles::launch_work,
|
launch_work,
|
||||||
&[
|
&[
|
||||||
("title", "High+impact"),
|
("title", "High impact"),
|
||||||
("lane", "runtime"),
|
("lane", "runtime"),
|
||||||
("impact", "99"),
|
("impact", "99"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert_effect_response(&high_impact);
|
assert_effect_response(&high_impact);
|
||||||
let high_impact_batch = high_impact.batch();
|
let high_impact_batch = high_impact.effects();
|
||||||
assert_card(&high_impact_batch, "High impact", "Runtime", "Draft", "width:99%");
|
assert_card(
|
||||||
|
&high_impact_batch,
|
||||||
|
"High impact",
|
||||||
|
"Runtime",
|
||||||
|
"Draft",
|
||||||
|
"width:99%",
|
||||||
|
);
|
||||||
|
|
||||||
let missing_title = post(
|
let missing_title = post(
|
||||||
"/",
|
"/",
|
||||||
&handle_body(handles::launch_work, &[("lane", "runtime"), ("impact", "8")]),
|
&handle_form_body(launch_work, &[("lane", "runtime"), ("impact", "8")]),
|
||||||
);
|
);
|
||||||
assert_effect_response(&missing_title);
|
assert_effect_response(&missing_title);
|
||||||
let missing_title_batch = missing_title.batch();
|
let missing_title_batch = missing_title.effects();
|
||||||
assert_payload_contains(&missing_title_batch, "Launch accepted");
|
assert_payload_contains(&missing_title_batch, "Launch accepted");
|
||||||
assert_payload_not_contains(&missing_title_batch, "data-key=\"8\"");
|
assert!(missing_title_batch.payload_excludes_key(8));
|
||||||
assert_payload_not_contains(&missing_title_batch, "MUTATED");
|
assert_payload_not_contains(&missing_title_batch, "MUTATED");
|
||||||
|
|
||||||
let move_to_lane = post(
|
let move_to_lane = post(
|
||||||
"/",
|
"/",
|
||||||
&handle_body(
|
&handle_form_body(
|
||||||
lane_handles::move_to_lane,
|
move_to_lane_handle,
|
||||||
&[("work_id", "4"), ("lane", "runtime")],
|
&[("work_id", "4"), ("lane", "runtime")],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert_effect_response(&move_to_lane);
|
assert_effect_response(&move_to_lane);
|
||||||
let move_to_lane_batch = move_to_lane.batch();
|
let move_to_lane_batch = move_to_lane.effects();
|
||||||
assert_card(&move_to_lane_batch, "Design hero moment", "Runtime", "Active", "width:99%");
|
assert_card(
|
||||||
|
&move_to_lane_batch,
|
||||||
|
"Design hero moment",
|
||||||
|
"Runtime",
|
||||||
|
"Active",
|
||||||
|
"width:99%",
|
||||||
|
);
|
||||||
assert_payload_contains(&move_to_lane_batch, "Drag-and-drop move persisted");
|
assert_payload_contains(&move_to_lane_batch, "Drag-and-drop move persisted");
|
||||||
|
|
||||||
let inspect = post(
|
let inspect = post("/", &handle_form_body(spotlight_work, &[("work_id", "4")]));
|
||||||
"/",
|
|
||||||
&handle_body(card_handles::spotlight_work, &[("work_id", "4")]),
|
|
||||||
);
|
|
||||||
assert_effect_response(&inspect);
|
assert_effect_response(&inspect);
|
||||||
let inspect_batch = inspect.batch();
|
let inspect_batch = inspect.effects();
|
||||||
assert_payload_contains(&inspect_batch, "Design hero moment · lane=Runtime");
|
assert_payload_contains(&inspect_batch, "Design hero moment · lane=Runtime");
|
||||||
assert_payload_contains(&inspect_batch, "Inspector focused");
|
assert_payload_contains(&inspect_batch, "Inspector focused");
|
||||||
|
|
||||||
let advance = post(
|
let advance = post("/", &handle_form_body(advance_work, &[("work_id", "4")]));
|
||||||
"/",
|
|
||||||
&handle_body(card_handles::advance_work, &[("work_id", "4")]),
|
|
||||||
);
|
|
||||||
assert_effect_response(&advance);
|
assert_effect_response(&advance);
|
||||||
let advance_batch = advance.batch();
|
let advance_batch = advance.effects();
|
||||||
assert_payload_contains(&advance_batch, "Pipeline advanced");
|
assert_payload_contains(&advance_batch, "Pipeline advanced");
|
||||||
assert_payload_contains(&advance_batch, "<span class=\"pill\">Active</span>");
|
assert_payload_contains(&advance_batch, "<span class=\"pill\">Active</span>");
|
||||||
|
|
||||||
let advance_default = post(
|
let advance_default = post("/", &handle_form_body(advance_work, &[("work_id", "5")]));
|
||||||
"/",
|
|
||||||
&handle_body(card_handles::advance_work, &[("work_id", "5")]),
|
|
||||||
);
|
|
||||||
assert_effect_response(&advance_default);
|
assert_effect_response(&advance_default);
|
||||||
let advance_default_batch = advance_default.batch();
|
let advance_default_batch = advance_default.effects();
|
||||||
assert_payload_contains(&advance_default_batch, "Default impact");
|
assert_payload_contains(&advance_default_batch, "Default impact");
|
||||||
assert_card(&advance_default_batch, "Default impact", "Compiler", "Active", "width:55%");
|
assert_card(
|
||||||
|
&advance_default_batch,
|
||||||
let ship_default = post(
|
"Default impact",
|
||||||
"/",
|
"Compiler",
|
||||||
&handle_body(card_handles::advance_work, &[("work_id", "5")]),
|
"Active",
|
||||||
|
"width:55%",
|
||||||
);
|
);
|
||||||
assert_effect_response(&ship_default);
|
|
||||||
let ship_default_batch = ship_default.batch();
|
|
||||||
assert_card(&ship_default_batch, "Default impact", "Product", "Shipped", "width:55%");
|
|
||||||
|
|
||||||
let simulated_push = post("/", &handle_body(handles::simulate_push, &[]));
|
let ship_default = post("/", &handle_form_body(advance_work, &[("work_id", "5")]));
|
||||||
|
assert_effect_response(&ship_default);
|
||||||
|
let ship_default_batch = ship_default.effects();
|
||||||
|
assert_card(
|
||||||
|
&ship_default_batch,
|
||||||
|
"Default impact",
|
||||||
|
"Product",
|
||||||
|
"Shipped",
|
||||||
|
"width:55%",
|
||||||
|
);
|
||||||
|
|
||||||
|
let simulated_push = post("/", &handle_form_body(simulate_push, &[]));
|
||||||
assert_effect_response(&simulated_push);
|
assert_effect_response(&simulated_push);
|
||||||
let push_batch = simulated_push.batch();
|
let push_batch = simulated_push.effects();
|
||||||
assert_payload_contains(&push_batch, "SSE tick");
|
assert_payload_contains(&push_batch, "SSE tick");
|
||||||
assert_payload_contains(&push_batch, "Push simulated · no client app code");
|
assert_payload_contains(&push_batch, "Push simulated · no client app code");
|
||||||
assert_payload_contains(&push_batch, "Simulated push event produced the same generated update shape");
|
assert_payload_contains(
|
||||||
assert_emit(&push_batch, "slhx:island-orbit", "activity rows");
|
&push_batch,
|
||||||
|
"Simulated push event produced the same generated update shape",
|
||||||
let delete_missing = post(
|
|
||||||
"/",
|
|
||||||
&handle_body(card_handles::delete_work, &[("work_id", "999")]),
|
|
||||||
);
|
);
|
||||||
|
assert_emit(&push_batch, &island_event_name("orbit"), "activity rows");
|
||||||
|
|
||||||
|
let delete_missing = post("/", &handle_form_body(delete_work, &[("work_id", "999")]));
|
||||||
assert_effect_response(&delete_missing);
|
assert_effect_response(&delete_missing);
|
||||||
let delete_missing_batch = delete_missing.batch();
|
let delete_missing_batch = delete_missing.effects();
|
||||||
assert_payload_not_contains(&delete_missing_batch, "Deleted card #999");
|
assert_payload_not_contains(&delete_missing_batch, "Deleted card #999");
|
||||||
|
|
||||||
let delete = post(
|
let delete = post("/", &handle_form_body(delete_work, &[("work_id", "4")]));
|
||||||
"/",
|
|
||||||
&handle_body(card_handles::delete_work, &[("work_id", "4")]),
|
|
||||||
);
|
|
||||||
assert_effect_response(&delete);
|
assert_effect_response(&delete);
|
||||||
let delete_batch = delete.batch();
|
let delete_batch = delete.effects();
|
||||||
assert_payload_contains(&delete_batch, "Card removed");
|
assert_payload_contains(&delete_batch, "Card removed");
|
||||||
assert_payload_contains(&delete_batch, "Deleted card #4");
|
assert_payload_contains(&delete_batch, "Deleted card #4");
|
||||||
assert_payload_not_contains(&delete_batch, "data-key=\"4\"");
|
assert!(delete_batch.payload_excludes_key(4));
|
||||||
assert_payload_contains(&delete_batch, "Default impact");
|
assert_payload_contains(&delete_batch, "Default impact");
|
||||||
|
|
||||||
let reset = post("/", &handle_body(handles::reset_demo, &[]));
|
let reset = post("/", &handle_form_body(reset_demo, &[]));
|
||||||
assert_effect_response(&reset);
|
assert_effect_response(&reset);
|
||||||
let reset_batch = reset.batch();
|
let reset_batch = reset.effects();
|
||||||
assert_payload_contains(&reset_batch, "Demo reset from Rust state");
|
assert_payload_contains(&reset_batch, "Demo reset from Rust state");
|
||||||
assert_payload_contains(&reset_batch, "Compile checked handles");
|
assert_payload_contains(&reset_batch, "Compile checked handles");
|
||||||
|
|
||||||
@@ -257,7 +280,7 @@ fn product_is_e2e_working_over_http() {
|
|||||||
assert!(sse.text().contains("event: slhx"));
|
assert!(sse.text().contains("event: slhx"));
|
||||||
assert!(sse.text().contains("data: "));
|
assert!(sse.text().contains("data: "));
|
||||||
|
|
||||||
let unknown = post("/", &format!("{SLHX_HANDLE_FIELD}=999999"));
|
let unknown = post("/", &unknown_handle_form_body(999999));
|
||||||
assert_eq!(unknown.status, 404);
|
assert_eq!(unknown.status, 404);
|
||||||
assert!(unknown.text().contains("unknown slhx handle id 999999"));
|
assert!(unknown.text().contains("unknown slhx handle id 999999"));
|
||||||
}
|
}
|
||||||
@@ -265,42 +288,37 @@ fn product_is_e2e_working_over_http() {
|
|||||||
fn assert_effect_response(response: &Response) {
|
fn assert_effect_response(response: &Response) {
|
||||||
assert_eq!(response.status, 200);
|
assert_eq!(response.status, 200);
|
||||||
assert!(response.header("content-type").contains("application/slhx"));
|
assert!(response.header("content-type").contains("application/slhx"));
|
||||||
assert_eq!(response.header("x-slhx-fingerprint"), BUILD_FINGERPRINT.0.to_string());
|
assert_eq!(
|
||||||
let batch = response.batch();
|
response.header("x-slhx-fingerprint"),
|
||||||
assert_eq!(batch.abi_version, EFFECT_BATCH_ABI_VERSION);
|
BUILD_FINGERPRINT.0.to_string()
|
||||||
assert!(!batch.ops.is_empty());
|
);
|
||||||
|
assert!(!response.effects().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_payload_contains(batch: &EffectBatch, needle: &str) {
|
fn assert_payload_contains(batch: &EffectInspector, needle: &str) {
|
||||||
assert!(
|
assert!(
|
||||||
batch.ops.iter().any(|op| match op {
|
batch.payload_contains(needle),
|
||||||
Effect::Put { payload, .. } | Effect::Insert { payload, .. } | Effect::Prepend { payload, .. } => payload_value(payload).contains(needle),
|
|
||||||
Effect::Emit { payload, .. } => payload.contains(needle),
|
|
||||||
Effect::Navigate { url, .. } => url.contains(needle),
|
|
||||||
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => false,
|
|
||||||
}),
|
|
||||||
"missing payload {needle:?} in {batch:#?}"
|
"missing payload {needle:?} in {batch:#?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_emit(batch: &EffectBatch, name: &str, needle: &str) {
|
fn assert_emit(batch: &EffectInspector, name: &str, needle: &str) {
|
||||||
assert!(
|
assert!(
|
||||||
batch.ops.iter().any(|op| match op {
|
batch.emits_containing(name, needle),
|
||||||
Effect::Emit { name: actual, payload } => actual == name && payload.contains(needle),
|
|
||||||
_ => false,
|
|
||||||
}),
|
|
||||||
"missing emit {name:?} containing {needle:?} in {batch:#?}"
|
"missing emit {name:?} containing {needle:?} in {batch:#?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_card(batch: &EffectBatch, title: &str, lane: &str, stage: &str, impact_style: &str) {
|
fn assert_card(batch: &EffectInspector, title: &str, lane: &str, stage: &str, impact_style: &str) {
|
||||||
let board = board_html(batch);
|
let board = batch
|
||||||
|
.target_html_containing(control::board, "class=\"lanes\"")
|
||||||
|
.expect("board html payload");
|
||||||
let document = Html::parse_fragment(&board);
|
let document = Html::parse_fragment(&board);
|
||||||
let lane_selector = Selector::parse(".lane").unwrap();
|
let lane_selector = Selector::parse(&class_selector("lane")).unwrap();
|
||||||
let card_selector = Selector::parse(".work-card").unwrap();
|
let card_selector = Selector::parse(&class_selector("work-card")).unwrap();
|
||||||
let strong_selector = Selector::parse("strong").unwrap();
|
let strong_selector = Selector::parse(strong_text_selector()).unwrap();
|
||||||
let stage_selector = Selector::parse(".pill").unwrap();
|
let stage_selector = Selector::parse(&class_selector("pill")).unwrap();
|
||||||
let impact_selector = Selector::parse(".impact i").unwrap();
|
let impact_selector = Selector::parse(&class_descendant_selector("impact", "i")).unwrap();
|
||||||
|
|
||||||
for lane_node in document.select(&lane_selector) {
|
for lane_node in document.select(&lane_selector) {
|
||||||
let lane_text = lane_node.text().collect::<Vec<_>>().join(" ");
|
let lane_text = lane_node.text().collect::<Vec<_>>().join(" ");
|
||||||
@@ -327,7 +345,10 @@ fn assert_card(batch: &EffectBatch, title: &str, lane: &str, stage: &str, impact
|
|||||||
.and_then(|node| node.value().attr("style"))
|
.and_then(|node| node.value().attr("style"))
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
assert_eq!(card_stage, stage);
|
assert_eq!(card_stage, stage);
|
||||||
assert!(style.contains(impact_style), "style {style:?} missing {impact_style:?}");
|
assert!(
|
||||||
|
style.contains(impact_style),
|
||||||
|
"style {style:?} missing {impact_style:?}"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,40 +356,22 @@ fn assert_card(batch: &EffectBatch, title: &str, lane: &str, stage: &str, impact
|
|||||||
panic!("missing card title={title:?} lane={lane:?} in {board}");
|
panic!("missing card title={title:?} lane={lane:?} in {board}");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn board_html(batch: &EffectBatch) -> String {
|
fn assert_payload_not_contains(batch: &EffectInspector, needle: &str) {
|
||||||
batch
|
|
||||||
.ops
|
|
||||||
.iter()
|
|
||||||
.find_map(|op| match op {
|
|
||||||
Effect::Put { payload: Payload::Html(value), .. } if value.contains("class=\"lanes\"") => Some(value.clone()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.expect("board html payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_payload_not_contains(batch: &EffectBatch, needle: &str) {
|
|
||||||
assert!(
|
assert!(
|
||||||
batch.ops.iter().all(|op| match op {
|
batch.payload_excludes(needle),
|
||||||
Effect::Put { payload, .. } | Effect::Insert { payload, .. } | Effect::Prepend { payload, .. } => !payload_value(payload).contains(needle),
|
|
||||||
Effect::Emit { payload, .. } => !payload.contains(needle),
|
|
||||||
Effect::Navigate { url, .. } => !url.contains(needle),
|
|
||||||
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => true,
|
|
||||||
}),
|
|
||||||
"unexpected payload {needle:?} in {batch:#?}"
|
"unexpected payload {needle:?} in {batch:#?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn payload_value(payload: &Payload) -> &str {
|
|
||||||
match payload {
|
|
||||||
Payload::Text(value) | Payload::Html(value) => value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_text(document: &Html, text: &str) {
|
fn assert_text(document: &Html, text: &str) {
|
||||||
let body = document.root_element().text().collect::<Vec<_>>().join(" ");
|
let body = document.root_element().text().collect::<Vec<_>>().join(" ");
|
||||||
assert!(body.contains(text), "missing text {text:?} in {body:?}");
|
assert!(body.contains(text), "missing text {text:?} in {body:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn assert_work_card_count_at_least(document: &Html, expected: usize) {
|
||||||
|
assert_selector_count_at_least(document, &class_selector("work-card"), expected);
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_selector_count_at_least(document: &Html, selector: &str, expected: usize) {
|
fn assert_selector_count_at_least(document: &Html, selector: &str, expected: usize) {
|
||||||
let selector = Selector::parse(selector).unwrap();
|
let selector = Selector::parse(selector).unwrap();
|
||||||
let count = document.select(&selector).count();
|
let count = document.select(&selector).count();
|
||||||
@@ -390,7 +393,9 @@ fn post(path: &str, body: &str) -> Response {
|
|||||||
|
|
||||||
fn request(method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> Response {
|
fn request(method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> Response {
|
||||||
let mut stream = TcpStream::connect(ADDR).expect("connect to server");
|
let mut stream = TcpStream::connect(ADDR).expect("connect to server");
|
||||||
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
write!(
|
write!(
|
||||||
stream,
|
stream,
|
||||||
"{method} {path} HTTP/1.1\r\nHost: {ADDR}\r\nConnection: close\r\nContent-Length: {}\r\n",
|
"{method} {path} HTTP/1.1\r\nHost: {ADDR}\r\nConnection: close\r\nContent-Length: {}\r\n",
|
||||||
@@ -431,7 +436,11 @@ impl Response {
|
|||||||
.filter_map(|line| line.split_once(':'))
|
.filter_map(|line| line.split_once(':'))
|
||||||
.map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string()))
|
.map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
Self { status, headers, body }
|
Self {
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header(&self, name: &str) -> &str {
|
fn header(&self, name: &str) -> &str {
|
||||||
@@ -446,7 +455,7 @@ impl Response {
|
|||||||
std::str::from_utf8(&self.body).unwrap()
|
std::str::from_utf8(&self.body).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn batch(&self) -> EffectBatch {
|
fn effects(&self) -> EffectInspector {
|
||||||
EffectBatch::from_wire(&self.body).unwrap()
|
inspect_wire(&self.body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-34
@@ -5,7 +5,7 @@ pub mod ui {}
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::ui::{auth, counter, notifications, page_swap, todos, wizard};
|
use super::ui::{auth, counter, notifications, page_swap, todos, wizard};
|
||||||
use hemplate::Hemplate;
|
use hemplate::Hemplate;
|
||||||
use slhx::{push, Effect, IntoEffect, NavigateMode, Payload};
|
use slhx::{push, IntoEffect};
|
||||||
use slhx_test::inspect;
|
use slhx_test::inspect;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -36,9 +36,16 @@ mod tests {
|
|||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
#[hemplate = "partials"]
|
#[hemplate = "partials"]
|
||||||
struct TodoRow {
|
struct TodoRow {
|
||||||
|
id: u64,
|
||||||
title: String,
|
title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl slhx::KeyedPartial for TodoRow {
|
||||||
|
fn slhx_key(&self) -> String {
|
||||||
|
self.id.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
#[hemplate = "partials"]
|
#[hemplate = "partials"]
|
||||||
struct DocsContent {
|
struct DocsContent {
|
||||||
@@ -49,13 +56,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn counter_updates_a_generated_slot() {
|
fn counter_updates_a_generated_slot() {
|
||||||
fn increment(count: u64) -> impl IntoEffect {
|
fn increment(count: u64) -> impl IntoEffect {
|
||||||
counter::targets::counter_value.text(count + 1)
|
counter::counter_value.set(count + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(increment(1));
|
let effect = inspect(increment(1));
|
||||||
|
|
||||||
assert!(effect.has_slot(counter::slots::counter_value));
|
assert!(effect.updates_text(counter::counter_value));
|
||||||
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
||||||
@@ -63,27 +69,33 @@ mod tests {
|
|||||||
fn form_handler_is_checked_against_hemplate_form() {
|
fn form_handler_is_checked_against_hemplate_form() {
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
fn add_todo(_form: slhx::Form<TodoInput>) -> impl IntoEffect {
|
fn add_todo(_form: slhx::Form<TodoInput>) -> impl IntoEffect {
|
||||||
todos::slots::todo_list.text("queued")
|
todos::todo_list.set("queued")
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(add_todo(TodoInput::FORM));
|
let effect = inspect(add_todo(TodoInput::FORM));
|
||||||
|
|
||||||
assert!(effect.has_slot(todos::slots::todo_list));
|
assert!(effect.updates_text(todos::todo_list));
|
||||||
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/002
|
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/002
|
||||||
#[test]
|
#[test]
|
||||||
fn todos_append_keyed_rows_from_form_input() {
|
fn todos_append_keyed_rows_from_form_input() {
|
||||||
fn add_todo(input: TodoInput) -> impl IntoEffect {
|
fn add_todo(input: TodoInput) -> impl IntoEffect {
|
||||||
let todo = Todo { id: 7, title: input.title };
|
let todo = Todo {
|
||||||
todos::targets::todo_row.append(todo.id, &TodoRow { title: todo.title })
|
id: 7,
|
||||||
|
title: input.title,
|
||||||
|
};
|
||||||
|
todos::todo_row.append(TodoRow {
|
||||||
|
id: todo.id,
|
||||||
|
title: todo.title,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(add_todo(TodoInput { title: "Ship v0".into() }));
|
let effect = inspect(add_todo(TodoInput {
|
||||||
|
title: "Ship v0".into(),
|
||||||
|
}));
|
||||||
|
|
||||||
assert!(effect.has_resource(todos::slots::todo_row.id()));
|
assert!(effect.inserts_html_containing(todos::todo_row, 7, "Ship v0"));
|
||||||
assert!(matches!(effect.ops(), [Effect::Insert { key, payload, .. }] if key == "7" && matches!(payload, Payload::Html(_))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
||||||
@@ -91,26 +103,24 @@ mod tests {
|
|||||||
fn wizard_form_handler_is_checked_against_hemplate_form() {
|
fn wizard_form_handler_is_checked_against_hemplate_form() {
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
fn next_step(_form: slhx::Form<WizardInput>) -> impl IntoEffect {
|
fn next_step(_form: slhx::Form<WizardInput>) -> impl IntoEffect {
|
||||||
wizard::slots::wizard_step.text("queued")
|
wizard::wizard_step.set("queued")
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(next_step(WizardInput::FORM));
|
let effect = inspect(next_step(WizardInput::FORM));
|
||||||
|
|
||||||
assert!(effect.has_slot(wizard::slots::wizard_step));
|
assert!(effect.updates_text(wizard::wizard_step));
|
||||||
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004
|
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004
|
||||||
#[test]
|
#[test]
|
||||||
fn wizard_form_swaps_the_current_step() {
|
fn wizard_form_swaps_the_current_step() {
|
||||||
fn next_step(input: WizardInput) -> impl IntoEffect {
|
fn next_step(input: WizardInput) -> impl IntoEffect {
|
||||||
wizard::slots::wizard_step.text(format!("Step {}", input.step + 1))
|
wizard::wizard_step.set(format!("Step {}", input.step + 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(next_step(WizardInput { step: 1 }));
|
let effect = inspect(next_step(WizardInput { step: 1 }));
|
||||||
|
|
||||||
assert!(effect.has_slot(wizard::slots::wizard_step));
|
assert!(effect.updates_text(wizard::wizard_step));
|
||||||
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: page_swap/002 req: page_swap/003 req: build/001 req: build/005 req: view/001
|
// req: examples/001 req: page_swap/002 req: page_swap/003 req: build/001 req: build/005 req: view/001
|
||||||
@@ -118,20 +128,19 @@ mod tests {
|
|||||||
fn page_swap_updates_content_and_history() {
|
fn page_swap_updates_content_and_history() {
|
||||||
fn load_docs() -> impl IntoEffect {
|
fn load_docs() -> impl IntoEffect {
|
||||||
(
|
(
|
||||||
page_swap::put(page_swap::slots::content, &DocsContent {
|
page_swap::content.put(&DocsContent {
|
||||||
message: "This page was swapped.",
|
message: "This page was swapped.",
|
||||||
}),
|
}),
|
||||||
page_swap::slots::title.text("Docs"),
|
page_swap::title.set("Docs"),
|
||||||
push("/docs"),
|
push("/docs"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(load_docs());
|
let effect = inspect(load_docs());
|
||||||
|
|
||||||
assert!(effect.has_slot(page_swap::slots::content));
|
assert!(effect.updates_html_containing(page_swap::content, "This page was swapped."));
|
||||||
assert!(effect.has_slot(page_swap::slots::title));
|
assert!(effect.updates_text(page_swap::title));
|
||||||
assert!(matches!(effect.ops().first(), Some(Effect::Put { payload: Payload::Html(_), .. })));
|
assert!(effect.pushes_to("/docs"));
|
||||||
assert!(matches!(effect.ops().last(), Some(Effect::Navigate { url, mode: NavigateMode::Push, .. }) if url == "/docs"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
||||||
@@ -139,13 +148,12 @@ mod tests {
|
|||||||
fn auth_form_handler_is_checked_against_hemplate_form() {
|
fn auth_form_handler_is_checked_against_hemplate_form() {
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
fn login(_form: slhx::Form<Credentials>) -> impl IntoEffect {
|
fn login(_form: slhx::Form<Credentials>) -> impl IntoEffect {
|
||||||
auth::slots::login_status.text("queued")
|
auth::login_status.set("queued")
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(login(Credentials::FORM));
|
let effect = inspect(login(Credentials::FORM));
|
||||||
|
|
||||||
assert!(effect.has_slot(auth::slots::login_status));
|
assert!(effect.updates_text(auth::login_status));
|
||||||
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004
|
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004
|
||||||
@@ -158,25 +166,26 @@ mod tests {
|
|||||||
"Try again"
|
"Try again"
|
||||||
};
|
};
|
||||||
|
|
||||||
auth::slots::login_status.text(status)
|
auth::login_status.set(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(login(Credentials { email: "demo@example.com".into(), password: "secret".into() }));
|
let effect = inspect(login(Credentials {
|
||||||
|
email: "demo@example.com".into(),
|
||||||
|
password: "secret".into(),
|
||||||
|
}));
|
||||||
|
|
||||||
assert!(effect.has_slot(auth::slots::login_status));
|
assert!(effect.updates_text(auth::login_status));
|
||||||
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: examples/001 req: push/001 req: push/003 req: build/001 req: build/005
|
// req: examples/001 req: push/001 req: push/003 req: build/001 req: build/005
|
||||||
#[test]
|
#[test]
|
||||||
fn sse_notifications_update_a_generated_slot() {
|
fn sse_notifications_update_a_generated_slot() {
|
||||||
fn notification(message: &str) -> impl IntoEffect {
|
fn notification(message: &str) -> impl IntoEffect {
|
||||||
notifications::slots::notifications.text(message)
|
notifications::notifications.set(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
let effect = inspect(notification("Build finished"));
|
let effect = inspect(notification("Build finished"));
|
||||||
|
|
||||||
assert!(effect.has_slot(notifications::slots::notifications));
|
assert!(effect.updates_text(notifications::notifications));
|
||||||
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+412
-102
@@ -4,18 +4,12 @@ use axum::routing::get;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use futures_util::{stream, StreamExt};
|
use futures_util::{stream, StreamExt};
|
||||||
use hemplate::Hemplate;
|
use hemplate::Hemplate;
|
||||||
use slhx::{push, IntoEffect, SafeHtml};
|
use slhx::{Html, IntoEffect};
|
||||||
use slhx_axum::{
|
use slhx_axum::{
|
||||||
interactions, runtime_js, sse, DispatchRegistry, EffectResponse, InteractionRequest, PageRequest,
|
interactions, runtime_js, sse, EffectResponse, Form, InteractionRequest, PageRequest, Registry,
|
||||||
};
|
};
|
||||||
use slhx_v0_examples::ui;
|
use slhx_v0_examples::ui;
|
||||||
use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todos, wizard};
|
use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard};
|
||||||
use slhx_v0_examples::ui::auth::{handles as auth_handles, targets as auth_targets};
|
|
||||||
use slhx_v0_examples::ui::counter::{handles as counter_handles, targets as counter_targets};
|
|
||||||
use slhx_v0_examples::ui::notifications::targets as notification_targets;
|
|
||||||
use slhx_v0_examples::ui::page_swap::{handles as page_handles, targets as page_targets};
|
|
||||||
use slhx_v0_examples::ui::todos::{forms as todo_forms, handles as todo_handles, targets as todo_targets};
|
|
||||||
use slhx_v0_examples::ui::wizard::{handles as wizard_handles, targets as wizard_targets};
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -25,36 +19,101 @@ use std::time::Duration;
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ExampleState {
|
struct ExampleState {
|
||||||
counter: Mutex<u64>,
|
counter: Mutex<u64>,
|
||||||
todos: Mutex<Vec<Todo>>,
|
todos: Mutex<Vec<TodoRecord>>,
|
||||||
wizard_step: Mutex<u8>,
|
wizard_step: Mutex<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Todo {
|
struct TodoRecord {
|
||||||
|
// Domain/SQL-shaped rows stay as boring Rust data; hemplate view structs are derived at render/update boundaries.
|
||||||
|
// req: examples/001 req: view/001
|
||||||
id: u64,
|
id: u64,
|
||||||
title: String,
|
title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[slhx::form("new_todo")]
|
||||||
|
struct NewTodo {
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::form("rename_todo")]
|
||||||
|
struct RenameTodo {
|
||||||
|
id: u64,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::form("delete_todo")]
|
||||||
|
struct DeleteTodo {
|
||||||
|
id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::form("wizard_input")]
|
||||||
|
struct WizardInput {
|
||||||
|
step: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::form("credentials")]
|
||||||
|
struct Credentials {
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TodoMutationError(String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for TodoMutationError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TodoMutationError {}
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
#[hemplate = "partials"]
|
#[hemplate = "partials"]
|
||||||
struct TodoItems {
|
struct TodoItems {
|
||||||
items: Vec<TodoItem>,
|
items: Vec<TodoItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Hemplate)]
|
||||||
|
#[hemplate = "partials"]
|
||||||
|
struct TodoRow {
|
||||||
|
id: u64,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl slhx::KeyedPartial for TodoRow {
|
||||||
|
fn slhx_key(&self) -> String {
|
||||||
|
self.id.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct TodoItem {
|
struct TodoItem {
|
||||||
id: u64,
|
id: u64,
|
||||||
title: String,
|
title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Hemplate)]
|
||||||
|
struct Counter;
|
||||||
|
|
||||||
|
#[derive(Hemplate)]
|
||||||
|
struct Wizard;
|
||||||
|
|
||||||
|
#[derive(Hemplate)]
|
||||||
|
struct Auth;
|
||||||
|
|
||||||
|
#[derive(Hemplate)]
|
||||||
|
struct Notifications;
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
struct PageSwap {
|
struct PageSwap {
|
||||||
content: SafeHtml,
|
content: Html,
|
||||||
title: &'static str,
|
title: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
struct AppShell {
|
struct AppShell {
|
||||||
body: SafeHtml,
|
body: Html,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
@@ -100,7 +159,7 @@ async fn interact(
|
|||||||
State(state): State<Arc<ExampleState>>,
|
State(state): State<Arc<ExampleState>>,
|
||||||
request: InteractionRequest,
|
request: InteractionRequest,
|
||||||
) -> Result<EffectResponse, impl IntoResponse> {
|
) -> Result<EffectResponse, impl IntoResponse> {
|
||||||
request.dispatch(registry(state))
|
request.dispatch_async(registry(state)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn runtime() -> impl IntoResponse {
|
async fn runtime() -> impl IntoResponse {
|
||||||
@@ -110,96 +169,201 @@ async fn runtime() -> impl IntoResponse {
|
|||||||
// req: push/001, req: push/003, req: examples/001
|
// req: push/001, req: push/003, req: examples/001
|
||||||
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
||||||
if params.contains_key("once") {
|
if params.contains_key("once") {
|
||||||
let effect = notification_targets::notifications.text("Server event #1");
|
let effect = notifications::notifications.set("Server event #1");
|
||||||
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
|
return sse(stream::iter([Ok::<_, Infallible>(
|
||||||
|
effect.into_batch(ui::BUILD_FINGERPRINT),
|
||||||
|
)])
|
||||||
|
.boxed());
|
||||||
}
|
}
|
||||||
|
|
||||||
let batches = stream::unfold(1_u64, |count| async move {
|
let batches = stream::unfold(1_u64, |count| async move {
|
||||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||||
let effect = notification_targets::notifications.text(format!("Server event #{count}"));
|
let effect = notifications::notifications.set(format!("Server event #{count}"));
|
||||||
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1))
|
Some((
|
||||||
|
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
|
||||||
|
count + 1,
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.boxed();
|
.boxed();
|
||||||
sse(batches)
|
sse(batches)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn registry(state: Arc<ExampleState>) -> impl DispatchRegistry {
|
#[slhx::app(
|
||||||
|
counter_handlers,
|
||||||
|
todo_handlers,
|
||||||
|
todo_row_handlers,
|
||||||
|
wizard_handlers,
|
||||||
|
auth_handlers
|
||||||
|
)]
|
||||||
|
fn registry(state: Arc<ExampleState>) -> Registry {
|
||||||
interactions(ui::BUILD_FINGERPRINT)
|
interactions(ui::BUILD_FINGERPRINT)
|
||||||
.on(counter_handles::increment, {
|
|
||||||
let state = state.clone();
|
|
||||||
move |_| {
|
|
||||||
// req: examples/001
|
|
||||||
let mut counter = state.counter.lock().unwrap();
|
|
||||||
*counter += 1;
|
|
||||||
counter_targets::counter_value.text(*counter)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on(todo_handles::add_todo, {
|
|
||||||
let state = state.clone();
|
|
||||||
move |form| {
|
|
||||||
// req: examples/001
|
|
||||||
let title = form.value("title").unwrap_or("").trim();
|
|
||||||
let mut todos = state.todos.lock().unwrap();
|
|
||||||
if !title.is_empty() {
|
|
||||||
let id = todos.last().map_or(1, |todo| todo.id + 1);
|
|
||||||
todos.push(Todo { id, title: title.into() });
|
|
||||||
}
|
|
||||||
(
|
|
||||||
todo_targets::todo_list.put(&todos_view(&todos)),
|
|
||||||
todo_forms::new_todo.clear("title"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on(wizard_handles::next_step, {
|
|
||||||
let state = state.clone();
|
|
||||||
move |_| {
|
|
||||||
// req: examples/001
|
|
||||||
let mut step = state.wizard_step.lock().unwrap();
|
|
||||||
*step += 1;
|
|
||||||
wizard_targets::wizard_step.text(format!("Step {}", *step + 1))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on(auth_handles::login, |form| {
|
|
||||||
// req: examples/001
|
|
||||||
let ok = form.value("email") == Some("demo@example.com")
|
|
||||||
&& form.value("password").is_some_and(|password| !password.is_empty());
|
|
||||||
auth_targets::login_status.text(if ok {
|
|
||||||
"Signed in as demo@example.com"
|
|
||||||
} else {
|
|
||||||
"Try demo@example.com with any password"
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.on(page_handles::load_docs, |_| {
|
|
||||||
// req: page_swap/002, req: examples/001
|
|
||||||
(
|
|
||||||
page_targets::content.put(&DocsContent {
|
|
||||||
message: "This content came from a generated update response.",
|
|
||||||
}),
|
|
||||||
page_targets::title.text("Docs"),
|
|
||||||
push("/docs"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn all_examples() -> SafeHtml {
|
fn all_examples() -> Html {
|
||||||
// Static `.heml` fragments are lowered by generated code before they join rendered views.
|
|
||||||
// req: html_safety/001 req: html_safety/002 req: component/003
|
// req: html_safety/001 req: html_safety/002 req: component/003
|
||||||
SafeHtml::join([
|
Html::join([
|
||||||
counter::static_fragment(include_str!("../templates/counter.heml")),
|
counter::render(&Counter),
|
||||||
todos::static_fragment(include_str!("../templates/todos.heml")),
|
ui::render(&todos_view(&[])),
|
||||||
wizard::static_fragment(include_str!("../templates/wizard.heml")),
|
wizard::render(&Wizard),
|
||||||
auth::static_fragment(include_str!("../templates/auth.heml")),
|
auth::render(&Auth),
|
||||||
render_page_swap("Welcome", "Welcome"),
|
render_page_swap("Welcome", "Welcome"),
|
||||||
notifications::static_fragment(include_str!("../templates/notifications.heml")),
|
notifications::render(&Notifications),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shell(body: SafeHtml) -> SafeHtml {
|
fn shell(body: Html) -> Html {
|
||||||
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
|
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
|
||||||
ui::render(&AppShell { body })
|
ui::render(&AppShell { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn todos_view(todos: &[Todo]) -> TodoItems {
|
#[slhx::component("counter")]
|
||||||
|
mod counter_handlers {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
async fn increment(State(state): State<Arc<ExampleState>>) -> impl IntoEffect {
|
||||||
|
// req: examples/001
|
||||||
|
let mut counter = state.counter.lock().unwrap();
|
||||||
|
*counter += 1;
|
||||||
|
counter::counter_value.set(*counter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::component("todos")]
|
||||||
|
mod todo_handlers {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
async fn add_todo(
|
||||||
|
State(state): State<Arc<ExampleState>>,
|
||||||
|
Form(form): Form<NewTodo>,
|
||||||
|
) -> Result<impl IntoEffect, TodoMutationError> {
|
||||||
|
// req: examples/001 req: canonical_authoring/003
|
||||||
|
let mut todos = state.todos.lock().unwrap();
|
||||||
|
let id = todos.last().map_or(1, |todo| todo.id + 1);
|
||||||
|
let created = if form.title.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
todos.push(TodoRecord {
|
||||||
|
id,
|
||||||
|
title: form.title.clone(),
|
||||||
|
});
|
||||||
|
Some(todos::todo_row.append(TodoRow {
|
||||||
|
id,
|
||||||
|
title: form.title,
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
let summary = todo_summary(&todos);
|
||||||
|
Ok((
|
||||||
|
created,
|
||||||
|
todos::summary.set(summary),
|
||||||
|
todos::new_todo.clear(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
async fn rename_todo(
|
||||||
|
State(state): State<Arc<ExampleState>>,
|
||||||
|
Form(form): Form<RenameTodo>,
|
||||||
|
) -> impl IntoEffect {
|
||||||
|
rename_todo_effect(state, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
async fn delete_todo(
|
||||||
|
State(state): State<Arc<ExampleState>>,
|
||||||
|
Form(form): Form<DeleteTodo>,
|
||||||
|
) -> impl IntoEffect {
|
||||||
|
delete_todo_effect(state, form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::component("todo_row")]
|
||||||
|
mod todo_row_handlers {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
async fn rename_todo_row(
|
||||||
|
State(state): State<Arc<ExampleState>>,
|
||||||
|
Form(form): Form<RenameTodo>,
|
||||||
|
) -> impl IntoEffect {
|
||||||
|
rename_todo_effect(state, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
async fn delete_todo_row(
|
||||||
|
State(state): State<Arc<ExampleState>>,
|
||||||
|
Form(form): Form<DeleteTodo>,
|
||||||
|
) -> impl IntoEffect {
|
||||||
|
delete_todo_effect(state, form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::component("wizard")]
|
||||||
|
mod wizard_handlers {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
async fn next_step(
|
||||||
|
State(state): State<Arc<ExampleState>>,
|
||||||
|
Form(form): Form<WizardInput>,
|
||||||
|
) -> impl IntoEffect {
|
||||||
|
// req: examples/001
|
||||||
|
let _submitted_step = form.step;
|
||||||
|
let mut step = state.wizard_step.lock().unwrap();
|
||||||
|
*step += 1;
|
||||||
|
wizard::wizard_step.set(format!("Step {}", *step + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::component("auth")]
|
||||||
|
mod auth_handlers {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
async fn login(
|
||||||
|
State(_state): State<Arc<ExampleState>>,
|
||||||
|
Form(credentials): Form<Credentials>,
|
||||||
|
) -> impl IntoEffect {
|
||||||
|
// req: examples/001
|
||||||
|
let ok = credentials.email == "demo@example.com" && !credentials.password.is_empty();
|
||||||
|
auth::login_status.set(if ok {
|
||||||
|
"Signed in as demo@example.com"
|
||||||
|
} else {
|
||||||
|
"Try demo@example.com with any password"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_todo_effect(state: Arc<ExampleState>, form: RenameTodo) -> impl IntoEffect {
|
||||||
|
// req: examples/001 req: canonical_authoring/003
|
||||||
|
let mut todos = state.todos.lock().unwrap();
|
||||||
|
todos
|
||||||
|
.iter_mut()
|
||||||
|
.find(|todo| todo.id == form.id)
|
||||||
|
.map(|todo| {
|
||||||
|
todo.title = form.title.clone();
|
||||||
|
todos::todo_row.replace(TodoRow {
|
||||||
|
id: form.id,
|
||||||
|
title: form.title,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_todo_effect(state: Arc<ExampleState>, form: DeleteTodo) -> impl IntoEffect {
|
||||||
|
// req: examples/001 req: canonical_authoring/003
|
||||||
|
let mut todos = state.todos.lock().unwrap();
|
||||||
|
let before = todos.len();
|
||||||
|
todos.retain(|todo| todo.id != form.id);
|
||||||
|
let deleted = todos.len() != before;
|
||||||
|
let summary = todo_summary(&todos);
|
||||||
|
(
|
||||||
|
deleted.then(|| todos::todo_row.remove(form.id)),
|
||||||
|
deleted.then(|| todos::summary.set(summary)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn todos_view(todos: &[TodoRecord]) -> TodoItems {
|
||||||
// req: html_safety/002 req: view/001
|
// req: html_safety/002 req: view/001
|
||||||
TodoItems {
|
TodoItems {
|
||||||
items: todos
|
items: todos
|
||||||
@@ -212,7 +376,15 @@ fn todos_view(todos: &[Todo]) -> TodoItems {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_page_swap(title: &'static str, message: &'static str) -> SafeHtml {
|
fn todo_summary(todos: &[TodoRecord]) -> String {
|
||||||
|
match todos.len() {
|
||||||
|
0 => "No todos".to_owned(),
|
||||||
|
1 => "1 todo".to_owned(),
|
||||||
|
count => format!("{count} todos"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_page_swap(title: &'static str, message: &'static str) -> Html {
|
||||||
// req: html_safety/002 req: view/001 req: component/003
|
// req: html_safety/002 req: view/001 req: component/003
|
||||||
page_swap::render(&PageSwap {
|
page_swap::render(&PageSwap {
|
||||||
content: render_docs_content(message),
|
content: render_docs_content(message),
|
||||||
@@ -220,7 +392,7 @@ fn render_page_swap(title: &'static str, message: &'static str) -> SafeHtml {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_docs_content(message: &'static str) -> SafeHtml {
|
fn render_docs_content(message: &'static str) -> Html {
|
||||||
// req: html_safety/002 req: view/001 req: component/003
|
// req: html_safety/002 req: view/001 req: component/003
|
||||||
ui::render(&DocsContent { message })
|
ui::render(&DocsContent { message })
|
||||||
}
|
}
|
||||||
@@ -229,11 +401,25 @@ fn render_docs_content(message: &'static str) -> SafeHtml {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
use slhx_test::{
|
||||||
|
article_selector, document_title_selector, escaped_markup_selector, heading_selector,
|
||||||
|
inspect_batch, keyed_items_selector, keyed_selector, list_item_selector,
|
||||||
|
page_nav_link_selector, prose_selector, root_element_selector, runtime_script_selector,
|
||||||
|
};
|
||||||
|
|
||||||
fn selector(value: &str) -> Selector {
|
fn selector(value: &str) -> Selector {
|
||||||
Selector::parse(value).expect("test selector parses")
|
Selector::parse(value).expect("test selector parses")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn form<I>(handle: slhx::Handle<I>, fields: &[(&str, &str)]) -> slhx_axum::InteractionForm {
|
||||||
|
slhx_axum::InteractionForm::for_handle(
|
||||||
|
handle,
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
#[test]
|
#[test]
|
||||||
fn shell_is_rendered_by_a_hemplate_view() {
|
fn shell_is_rendered_by_a_hemplate_view() {
|
||||||
@@ -241,15 +427,28 @@ mod tests {
|
|||||||
let document = Html::parse_document(html.as_str());
|
let document = Html::parse_document(html.as_str());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document
|
document
|
||||||
.select(&selector("title"))
|
.select(&selector(document_title_selector()))
|
||||||
.next()
|
.next()
|
||||||
.map(|title| title.text().collect::<String>()),
|
.map(|title| title.text().collect::<String>()),
|
||||||
Some("slhx v0 examples".to_owned())
|
Some("slhx v0 examples".to_owned())
|
||||||
);
|
);
|
||||||
assert_eq!(document.select(&selector("script[src=\"/slhx.js\"]")).count(), 1);
|
assert_eq!(
|
||||||
assert_eq!(document.select(&selector("main[data-slhx-root=\"docs\"]")).count(), 1);
|
document
|
||||||
|
.select(&selector(runtime_script_selector()))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector(&root_element_selector("main", "docs")))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
document.root_element().text().all(|text| !text.contains("{+=")),
|
document
|
||||||
|
.root_element()
|
||||||
|
.text()
|
||||||
|
.all(|text| !text.contains("{+=")),
|
||||||
"hemplate insertion markers must not leak into rendered text"
|
"hemplate insertion markers must not leak into rendered text"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -261,14 +460,14 @@ mod tests {
|
|||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document
|
document
|
||||||
.select(&selector("h1"))
|
.select(&selector(&heading_selector("", 1)))
|
||||||
.next()
|
.next()
|
||||||
.map(|heading| heading.text().collect::<String>()),
|
.map(|heading| heading.text().collect::<String>()),
|
||||||
Some("Docs".to_owned())
|
Some("Docs".to_owned())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document
|
document
|
||||||
.select(&selector("p"))
|
.select(&selector(&prose_selector("")))
|
||||||
.next()
|
.next()
|
||||||
.map(|paragraph| paragraph.text().collect::<String>()),
|
.map(|paragraph| paragraph.text().collect::<String>()),
|
||||||
Some("This content came from a generated update response.".to_owned())
|
Some("This content came from a generated update response.".to_owned())
|
||||||
@@ -280,11 +479,22 @@ mod tests {
|
|||||||
fn docs_page_partial_is_rendered_by_a_hemplate_view() {
|
fn docs_page_partial_is_rendered_by_a_hemplate_view() {
|
||||||
let html = render_page_swap("Docs", "This page was swapped without a full reload.");
|
let html = render_page_swap("Docs", "This page was swapped without a full reload.");
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
assert_eq!(document.select(&selector("main[data-slhx-root=\"docs\"]")).count(), 1);
|
|
||||||
assert_eq!(document.select(&selector("article[data-sid]")).count(), 1);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
document
|
document
|
||||||
.select(&selector("article h1"))
|
.select(&selector(&root_element_selector("main", "docs")))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(document.select(&selector(article_selector())).count(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector(&page_nav_link_selector("/docs")))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document
|
||||||
|
.select(&selector(&heading_selector("article", 1)))
|
||||||
.next()
|
.next()
|
||||||
.map(|heading| heading.text().collect::<String>()),
|
.map(|heading| heading.text().collect::<String>()),
|
||||||
Some("Docs".to_owned())
|
Some("Docs".to_owned())
|
||||||
@@ -294,15 +504,26 @@ mod tests {
|
|||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
#[test]
|
#[test]
|
||||||
fn todos_payload_is_rendered_by_a_hemplate_view() {
|
fn todos_payload_is_rendered_by_a_hemplate_view() {
|
||||||
let todos = vec![Todo { id: 7, title: "<b>Ship v0</b>".to_owned() }];
|
let todos = vec![TodoRecord {
|
||||||
|
id: 7,
|
||||||
|
title: "<b>Ship v0</b>".to_owned(),
|
||||||
|
}];
|
||||||
|
|
||||||
let html = ui::render(&todos_view(&todos));
|
let html = ui::render(&todos_view(&todos));
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
let rows = document.select(&selector("li[data-key]")).collect::<Vec<_>>();
|
let rows = document
|
||||||
|
.select(&selector(&keyed_items_selector("li")))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(rows.len(), 1);
|
assert_eq!(rows.len(), 1);
|
||||||
assert_eq!(rows[0].value().attr("data-key"), Some("7"));
|
let row = document
|
||||||
assert_eq!(rows[0].text().collect::<String>(), "<b>Ship v0</b>");
|
.select(&selector(&keyed_selector("li", 7)))
|
||||||
assert!(document.select(&selector("b")).next().is_none());
|
.next()
|
||||||
|
.expect("generated keyed row");
|
||||||
|
assert_eq!(row.text().collect::<String>(), "<b>Ship v0</b>");
|
||||||
|
assert!(document
|
||||||
|
.select(&selector(&escaped_markup_selector("b")))
|
||||||
|
.next()
|
||||||
|
.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
@@ -310,8 +531,97 @@ mod tests {
|
|||||||
fn empty_todos_payload_is_rendered_by_a_hemplate_view() {
|
fn empty_todos_payload_is_rendered_by_a_hemplate_view() {
|
||||||
let html = ui::render(&todos_view(&[]));
|
let html = ui::render(&todos_view(&[]));
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
let rows = document.select(&selector("li")).collect::<Vec<_>>();
|
let rows = document
|
||||||
assert_eq!(rows.len(), 1);
|
.select(&selector(&list_item_selector("")))
|
||||||
assert_eq!(rows[0].text().collect::<String>(), "No todos yet");
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(rows.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// req: examples/001 req: page_swap/002 req: page_swap/003 req: component/005 req: public_api/003 req: test/005
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registry_dispatches_generated_keyed_crud_effects() {
|
||||||
|
let state = Arc::new(ExampleState::default());
|
||||||
|
|
||||||
|
let counter = inspect_batch(
|
||||||
|
InteractionRequest::from(form(counter::increment, &[]))
|
||||||
|
.dispatch_async(registry(state.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.batch,
|
||||||
|
);
|
||||||
|
assert_eq!(*state.counter.lock().unwrap(), 1);
|
||||||
|
assert!(counter.updates_text(counter::counter_value));
|
||||||
|
assert!(counter.payload_contains("1"));
|
||||||
|
|
||||||
|
let add = inspect_batch(
|
||||||
|
InteractionRequest::from(form(todos::add_todo, &[("title", "Ship v0")]))
|
||||||
|
.dispatch_async(registry(state.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.batch,
|
||||||
|
);
|
||||||
|
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0");
|
||||||
|
assert_eq!(add.op_count(), 3);
|
||||||
|
assert!(add.inserts_html_containing(todos::todo_row, "1", "Ship v0"));
|
||||||
|
assert!(add.updates_text(todos::summary));
|
||||||
|
assert!(add.resets_form(todos::new_todo));
|
||||||
|
|
||||||
|
let rename = inspect_batch(
|
||||||
|
InteractionRequest::from(form(
|
||||||
|
todo_row::rename_todo_row,
|
||||||
|
&[("id", "1"), ("title", "Ship 1.0")],
|
||||||
|
))
|
||||||
|
.dispatch_async(registry(state.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.batch,
|
||||||
|
);
|
||||||
|
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship 1.0");
|
||||||
|
assert_eq!(rename.op_count(), 1);
|
||||||
|
assert!(rename.replaces_keyed_html_containing(todos::todo_row, "1", "Ship 1.0"));
|
||||||
|
|
||||||
|
let delete = inspect_batch(
|
||||||
|
InteractionRequest::from(form(todo_row::delete_todo_row, &[("id", "1")]))
|
||||||
|
.dispatch_async(registry(state.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.batch,
|
||||||
|
);
|
||||||
|
assert!(state.todos.lock().unwrap().is_empty());
|
||||||
|
assert_eq!(delete.op_count(), 2);
|
||||||
|
assert!(delete.removes_key(todos::todo_row, "1"));
|
||||||
|
assert!(delete.updates_text(todos::summary));
|
||||||
|
|
||||||
|
let missing_delete = inspect_batch(
|
||||||
|
InteractionRequest::from(form(todo_row::delete_todo_row, &[("id", "99")]))
|
||||||
|
.dispatch_async(registry(state.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.batch,
|
||||||
|
);
|
||||||
|
assert!(missing_delete.is_empty());
|
||||||
|
|
||||||
|
let wizard = inspect_batch(
|
||||||
|
InteractionRequest::from(form(wizard::next_step, &[("step", "1")]))
|
||||||
|
.dispatch_async(registry(state.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.batch,
|
||||||
|
);
|
||||||
|
assert!(wizard.updates_text(wizard::wizard_step));
|
||||||
|
assert!(wizard.payload_contains("Step 2"));
|
||||||
|
|
||||||
|
let auth = inspect_batch(
|
||||||
|
InteractionRequest::from(form(
|
||||||
|
auth::login,
|
||||||
|
&[("email", "demo@example.com"), ("password", "secret")],
|
||||||
|
))
|
||||||
|
.dispatch_async(registry(state))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.batch,
|
||||||
|
);
|
||||||
|
assert!(auth.updates_text(auth::login_status));
|
||||||
|
assert!(auth.payload_contains("Signed in as demo@example.com"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<main data-slhx-root="docs">
|
<main data-slhx-root="docs">
|
||||||
<nav data-slhx-slot="nav">
|
<nav data-slhx-slot="nav">
|
||||||
<a href="/docs" data-slhx-nav="" data-slhx-handle="load_docs">Docs</a>
|
<a href="/docs" data-slhx-nav="">Docs</a>
|
||||||
</nav>
|
</nav>
|
||||||
<article data-slhx-slot="content">{+= self.content =+}</article>
|
<article data-slhx-slot="content">{+= self.content =+}</article>
|
||||||
<title data-slhx-slot="title">{+ self.title +}</title>
|
<title data-slhx-slot="title">{+ self.title +}</title>
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
<template h-if="self.items.is_empty()">
|
<template h-if="!self.items.is_empty()">
|
||||||
<li>No todos yet</li>
|
|
||||||
</template>
|
|
||||||
<template h-else>
|
|
||||||
<li h-for="todo in &self.items" +data-key="todo.id">{+ todo.title +}</li>
|
<li h-for="todo in &self.items" +data-key="todo.id">{+ todo.title +}</li>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
<li>{+ self.title +}</li>
|
<li>
|
||||||
|
<span>{+ self.title +}</span>
|
||||||
|
<form data-slhx-handle="rename_todo_row">
|
||||||
|
<input type="hidden" name="id" +value="self.id">
|
||||||
|
<button type="submit" name="title" value="Renamed todo">Rename</button>
|
||||||
|
</form>
|
||||||
|
<form data-slhx-handle="delete_todo_row">
|
||||||
|
<button type="submit" name="id" +value="self.id">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
|||||||
@@ -3,9 +3,21 @@
|
|||||||
<input name="title" required="required">
|
<input name="title" required="required">
|
||||||
<button type="submit">Add</button>
|
<button type="submit">Add</button>
|
||||||
</form>
|
</form>
|
||||||
<ul data-slhx-slot="todo_list">
|
<p data-slhx-slot="summary">{+ self.summary +}</p>
|
||||||
<template h-for="todo in &self.todos" h-key="todo.id">
|
<div data-slhx-slot="todo_list">
|
||||||
<li data-slhx-slot="todo_row">{+ todo.title +}</li>
|
<ul data-slhx-slot="todo_row">
|
||||||
|
<template h-for="todo in &self.items" h-key="todo.id">
|
||||||
|
<li data-slhx-slot="todo_row" +data-key="todo.id">
|
||||||
|
<span>{+ todo.title +}</span>
|
||||||
|
<form data-slhx-handle="rename_todo">
|
||||||
|
<input type="hidden" name="id" +value="todo.id">
|
||||||
|
<button type="submit" name="title" value="Renamed todo">Rename</button>
|
||||||
|
</form>
|
||||||
|
<form data-slhx-handle="delete_todo">
|
||||||
|
<button type="submit" name="id" +value="todo.id">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+647
-23
@@ -1,13 +1,17 @@
|
|||||||
use axum::async_trait;
|
use axum::async_trait;
|
||||||
use axum::body::{to_bytes, Body};
|
use axum::body::{to_bytes, Body};
|
||||||
|
pub use axum::extract::State;
|
||||||
use axum::extract::{FromRequest, FromRequestParts, Multipart};
|
use axum::extract::{FromRequest, FromRequestParts, Multipart};
|
||||||
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
|
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
|
||||||
use axum::response::sse::{Event, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use futures_util::{Stream, StreamExt};
|
use futures_util::{Stream, StreamExt};
|
||||||
use slhx_core::{BuildFingerprint, EffectBatch, Handle, IntoEffect, SafeHtml};
|
use slhx_core::{BuildFingerprint, EffectBatch, FromForm, Handle, IntoEffect, SafeHtml};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
use std::fmt;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
pub const SLHX_PARTIAL_HEADER: &str = "x-slhx-partial";
|
pub const SLHX_PARTIAL_HEADER: &str = "x-slhx-partial";
|
||||||
pub const SLHX_FINGERPRINT_HEADER: &str = "x-slhx-fingerprint";
|
pub const SLHX_FINGERPRINT_HEADER: &str = "x-slhx-fingerprint";
|
||||||
@@ -54,7 +58,11 @@ impl PageRequest {
|
|||||||
matches!(self.mode, PageMode::Partial)
|
matches!(self.mode, PageMode::Partial)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn page(self, partial_html: impl Into<String>, shell: impl FnOnce(String) -> String) -> PageResponse {
|
pub fn page(
|
||||||
|
self,
|
||||||
|
partial_html: impl Into<String>,
|
||||||
|
shell: impl FnOnce(String) -> String,
|
||||||
|
) -> PageResponse {
|
||||||
let partial_html = partial_html.into();
|
let partial_html = partial_html.into();
|
||||||
match self.mode {
|
match self.mode {
|
||||||
PageMode::Full => PageResponse::full(shell(partial_html)),
|
PageMode::Full => PageResponse::full(shell(partial_html)),
|
||||||
@@ -62,14 +70,14 @@ impl PageRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn page_html(
|
pub fn page_html<P, S>(self, partial_html: P, shell: impl FnOnce(P) -> S) -> PageResponse
|
||||||
self,
|
where
|
||||||
partial_html: SafeHtml,
|
P: Into<SafeHtml>,
|
||||||
shell: impl FnOnce(SafeHtml) -> SafeHtml,
|
S: Into<SafeHtml>,
|
||||||
) -> PageResponse {
|
{
|
||||||
match self.mode {
|
match self.mode {
|
||||||
PageMode::Full => PageResponse::full(shell(partial_html).into_string()),
|
PageMode::Full => PageResponse::full(shell(partial_html).into().into_string()),
|
||||||
PageMode::Partial => PageResponse::partial(partial_html.into_string()),
|
PageMode::Partial => PageResponse::partial(partial_html.into().into_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,9 +153,73 @@ pub trait DispatchRegistry {
|
|||||||
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection>;
|
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait FromInteractionForm: Sized {
|
||||||
|
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Form<T>(pub T);
|
||||||
|
|
||||||
|
impl<T> Form<T> {
|
||||||
|
pub fn into_inner(self) -> T {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> std::ops::Deref for Form<T> {
|
||||||
|
type Target = T;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> FromInteractionForm for T
|
||||||
|
where
|
||||||
|
T: FromForm,
|
||||||
|
{
|
||||||
|
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
|
||||||
|
T::from_form_fields(form.fields())
|
||||||
|
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> FromInteractionForm for Form<T>
|
||||||
|
where
|
||||||
|
T: FromForm,
|
||||||
|
{
|
||||||
|
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
|
||||||
|
T::from_form_fields(form.fields())
|
||||||
|
.map(Self)
|
||||||
|
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait FromHandlerState<S>: Sized {
|
||||||
|
fn from_handler_state(state: S) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct FormDecodeError {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncHandler =
|
||||||
|
Box<dyn Fn(InteractionForm) -> Result<EffectBatch, DispatchRejection> + Send + Sync>;
|
||||||
|
type HandlerFuture = Pin<Box<dyn Future<Output = Result<EffectBatch, DispatchRejection>> + Send>>;
|
||||||
|
type AsyncHandler = Box<dyn Fn(InteractionForm) -> HandlerFuture + Send + Sync>;
|
||||||
|
|
||||||
pub struct HandlerRegistry {
|
pub struct HandlerRegistry {
|
||||||
fingerprint: BuildFingerprint,
|
fingerprint: BuildFingerprint,
|
||||||
handlers: BTreeMap<u32, Box<dyn Fn(InteractionForm) -> EffectBatch + Send + Sync>>,
|
handlers: BTreeMap<u32, SyncHandler>,
|
||||||
|
async_handlers: BTreeMap<u32, AsyncHandler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Registry = HandlerRegistry;
|
||||||
|
|
||||||
|
pub struct StateHandlerRegistry<S> {
|
||||||
|
registry: HandlerRegistry,
|
||||||
|
state: S,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -160,6 +232,8 @@ pub enum InteractionFormRejection {
|
|||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum DispatchRejection {
|
pub enum DispatchRejection {
|
||||||
UnknownHandle(u32),
|
UnknownHandle(u32),
|
||||||
|
InvalidForm { handle_id: u32, message: String },
|
||||||
|
HandlerError { handle_id: u32, message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EffectResponse {
|
impl EffectResponse {
|
||||||
@@ -170,6 +244,30 @@ impl EffectResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<S> FromHandlerState<S> for S {
|
||||||
|
fn from_handler_state(state: S) -> Self {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> FromHandlerState<S> for State<S> {
|
||||||
|
fn from_handler_state(state: S) -> Self {
|
||||||
|
State(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FormDecodeError {
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
&self.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl InteractionForm {
|
impl InteractionForm {
|
||||||
pub fn new(handle_id: u32, fields: impl IntoIterator<Item = (String, String)>) -> Self {
|
pub fn new(handle_id: u32, fields: impl IntoIterator<Item = (String, String)>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -179,7 +277,10 @@ impl InteractionForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn for_handle<I>(handle: Handle<I>, fields: impl IntoIterator<Item = (String, String)>) -> Self {
|
pub fn for_handle<I>(
|
||||||
|
handle: Handle<I>,
|
||||||
|
fields: impl IntoIterator<Item = (String, String)>,
|
||||||
|
) -> Self {
|
||||||
Self::new(handle.id().id, fields)
|
Self::new(handle.id().id, fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +289,9 @@ impl InteractionForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// req: multipart/001, req: multipart/002
|
// req: multipart/001, req: multipart/002
|
||||||
pub async fn parse_multipart(mut multipart: Multipart) -> Result<Self, InteractionFormRejection> {
|
pub async fn parse_multipart(
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Self, InteractionFormRejection> {
|
||||||
let mut fields = Vec::new();
|
let mut fields = Vec::new();
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
|
|
||||||
@@ -224,7 +327,10 @@ impl InteractionForm {
|
|||||||
Self::from_parts(fields, files)
|
Self::from_parts(fields, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_parts(fields: Vec<(String, String)>, files: Vec<InteractionFile>) -> Result<Self, InteractionFormRejection> {
|
fn from_parts(
|
||||||
|
fields: Vec<(String, String)>,
|
||||||
|
files: Vec<InteractionFile>,
|
||||||
|
) -> Result<Self, InteractionFormRejection> {
|
||||||
let Some(handle) = fields
|
let Some(handle) = fields
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
|
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
|
||||||
@@ -234,7 +340,11 @@ impl InteractionForm {
|
|||||||
let handle_id = handle
|
let handle_id = handle
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
|
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
|
||||||
Ok(Self { handle_id, fields, files })
|
Ok(Self {
|
||||||
|
handle_id,
|
||||||
|
fields,
|
||||||
|
files,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn value(&self, name: &str) -> Option<&str> {
|
pub fn value(&self, name: &str) -> Option<&str> {
|
||||||
@@ -267,6 +377,20 @@ impl InteractionForm {
|
|||||||
pub fn file(&self, name: &str) -> Option<&InteractionFile> {
|
pub fn file(&self, name: &str) -> Option<&InteractionFile> {
|
||||||
self.files.iter().find(|file| file.name == name)
|
self.files.iter().find(|file| file.name == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn required(&self, name: &str) -> Result<&str, FormDecodeError> {
|
||||||
|
self.value(name)
|
||||||
|
.ok_or_else(|| FormDecodeError::new(format!("missing form field `{name}`")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_required<T>(&self, name: &str) -> Result<T, FormDecodeError>
|
||||||
|
where
|
||||||
|
T: std::str::FromStr,
|
||||||
|
{
|
||||||
|
self.required(name)?
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| FormDecodeError::new(format!("invalid form field `{name}`")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry {
|
pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry {
|
||||||
@@ -285,6 +409,13 @@ impl InteractionRequest {
|
|||||||
registry.dispatch_form(self.form)
|
registry.dispatch_form(self.form)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch_async(
|
||||||
|
self,
|
||||||
|
registry: HandlerRegistry,
|
||||||
|
) -> Result<EffectResponse, DispatchRejection> {
|
||||||
|
registry.dispatch_async(self.form).await
|
||||||
|
}
|
||||||
|
|
||||||
pub fn form(&self) -> &InteractionForm {
|
pub fn form(&self) -> &InteractionForm {
|
||||||
&self.form
|
&self.form
|
||||||
}
|
}
|
||||||
@@ -301,6 +432,7 @@ impl HandlerRegistry {
|
|||||||
Self {
|
Self {
|
||||||
fingerprint,
|
fingerprint,
|
||||||
handlers: BTreeMap::new(),
|
handlers: BTreeMap::new(),
|
||||||
|
async_handlers: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +447,240 @@ impl HandlerRegistry {
|
|||||||
let fingerprint = self.fingerprint;
|
let fingerprint = self.fingerprint;
|
||||||
self.handlers.insert(
|
self.handlers.insert(
|
||||||
handle_id,
|
handle_id,
|
||||||
Box::new(move |form| handler(form).into_batch(fingerprint)),
|
Box::new(move |form| Ok(handler(form).into_batch(fingerprint))),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_typed<T, E>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
handler: impl Fn(T) -> E + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
T: FromInteractionForm,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |form| {
|
||||||
|
let handle_id = form.handle_id;
|
||||||
|
let input = T::from_interaction_form(&form).map_err(|error| {
|
||||||
|
DispatchRejection::InvalidForm {
|
||||||
|
handle_id,
|
||||||
|
message: error.message,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(handler(input).into_batch(fingerprint))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_state<S, C, E>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C) -> E + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |_| {
|
||||||
|
Ok(handler(C::from_handler_state(state.clone())).into_batch(fingerprint))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_state_typed<S, C, T, E>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |form| {
|
||||||
|
let handle_id = form.handle_id;
|
||||||
|
let input = T::from_interaction_form(&form).map_err(|error| {
|
||||||
|
DispatchRejection::InvalidForm {
|
||||||
|
handle_id,
|
||||||
|
message: error.message,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(handler(C::from_handler_state(state.clone()), input).into_batch(fingerprint))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_async<E, F>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
handler: impl Fn(InteractionForm) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
F: Future<Output = E> + Send + 'static,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.async_handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |form| {
|
||||||
|
let future = handler(form);
|
||||||
|
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_typed_async<T, E, F>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
handler: impl Fn(T) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
T: FromInteractionForm,
|
||||||
|
F: Future<Output = E> + Send + 'static,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.async_handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |form| {
|
||||||
|
let handle_id = form.handle_id;
|
||||||
|
let input = match T::from_interaction_form(&form) {
|
||||||
|
Ok(input) => input,
|
||||||
|
Err(error) => {
|
||||||
|
return Box::pin(async move {
|
||||||
|
Err(DispatchRejection::InvalidForm {
|
||||||
|
handle_id,
|
||||||
|
message: error.message,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let future = handler(input);
|
||||||
|
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_state_async<S, C, E, F>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
F: Future<Output = E> + Send + 'static,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.async_handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |_| {
|
||||||
|
let future = handler(C::from_handler_state(state.clone()));
|
||||||
|
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_state_typed_async<S, C, T, E, F>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
F: Future<Output = E> + Send + 'static,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.async_handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |form| {
|
||||||
|
let handle_id = form.handle_id;
|
||||||
|
let input = match T::from_interaction_form(&form) {
|
||||||
|
Ok(input) => input,
|
||||||
|
Err(error) => {
|
||||||
|
return Box::pin(async move {
|
||||||
|
Err(DispatchRejection::InvalidForm {
|
||||||
|
handle_id,
|
||||||
|
message: error.message,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let future = handler(C::from_handler_state(state.clone()), input);
|
||||||
|
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register_state_typed_async_result<S, C, T, E, O, F>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: fmt::Display,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.async_handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |form| {
|
||||||
|
let handle_id = form.handle_id;
|
||||||
|
let input = match T::from_interaction_form(&form) {
|
||||||
|
Ok(input) => input,
|
||||||
|
Err(error) => {
|
||||||
|
return Box::pin(async move {
|
||||||
|
Err(DispatchRejection::InvalidForm {
|
||||||
|
handle_id,
|
||||||
|
message: error.message,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let future = handler(C::from_handler_state(state.clone()), input);
|
||||||
|
Box::pin(async move {
|
||||||
|
future
|
||||||
|
.await
|
||||||
|
.map(|effects| effects.into_batch(fingerprint))
|
||||||
|
.map_err(|error| DispatchRejection::HandlerError {
|
||||||
|
handle_id,
|
||||||
|
message: error.to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -331,6 +696,16 @@ impl HandlerRegistry {
|
|||||||
self.register(handle.id().id, handler)
|
self.register(handle.id().id, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_state<S>(self, state: S) -> StateHandlerRegistry<S>
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
StateHandlerRegistry {
|
||||||
|
registry: self,
|
||||||
|
state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on<I, E>(
|
pub fn on<I, E>(
|
||||||
self,
|
self,
|
||||||
handle: Handle<I>,
|
handle: Handle<I>,
|
||||||
@@ -342,18 +717,243 @@ impl HandlerRegistry {
|
|||||||
self.register_handle(handle, handler)
|
self.register_handle(handle, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn on_form<I, T, E>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(T) -> E + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
T: FromInteractionForm,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.register_typed(handle.id().id, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_state<I, S, C, E>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C) -> E + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.register_state(handle.id().id, state, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_state_form<I, S, C, T, E>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.register_state_typed(handle.id().id, state, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_async<I, E, F>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(InteractionForm) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
F: Future<Output = E> + Send + 'static,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.register_async(handle.id().id, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_form_async<I, T, E, F>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(T) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
T: FromInteractionForm,
|
||||||
|
F: Future<Output = E> + Send + 'static,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.register_typed_async(handle.id().id, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_state_async<I, S, C, E, F>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
F: Future<Output = E> + Send + 'static,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.register_state_async(handle.id().id, state, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_state_form_async<I, S, C, T, E, F>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
F: Future<Output = E> + Send + 'static,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.register_state_typed_async(handle.id().id, state, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_state_form_async_result<I, S, C, T, E, O, F>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: fmt::Display,
|
||||||
|
{
|
||||||
|
self.register_state_typed_async_result(handle.id().id, state, handler)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn dispatch(&self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
|
pub fn dispatch(&self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
|
||||||
let handle_id = form.handle_id;
|
let handle_id = form.handle_id;
|
||||||
let Some(handler) = self.handlers.get(&handle_id) else {
|
let Some(handler) = self.handlers.get(&handle_id) else {
|
||||||
return Err(DispatchRejection::UnknownHandle(handle_id));
|
return Err(DispatchRejection::UnknownHandle(handle_id));
|
||||||
};
|
};
|
||||||
Ok(EffectResponse {
|
Ok(EffectResponse {
|
||||||
batch: handler(form),
|
batch: handler(form)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn dispatch_async(
|
||||||
|
&self,
|
||||||
|
form: InteractionForm,
|
||||||
|
) -> Result<EffectResponse, DispatchRejection> {
|
||||||
|
let handle_id = form.handle_id;
|
||||||
|
if let Some(handler) = self.async_handlers.get(&handle_id) {
|
||||||
|
return Ok(EffectResponse {
|
||||||
|
batch: handler(form).await?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.dispatch(form)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn contains(&self, handle_id: u32) -> bool {
|
pub fn contains(&self, handle_id: u32) -> bool {
|
||||||
self.handlers.contains_key(&handle_id)
|
self.handlers.contains_key(&handle_id) || self.async_handlers.contains_key(&handle_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> StateHandlerRegistry<S>
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
pub fn on_state<I, C, E>(
|
||||||
|
mut self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(C) -> E + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.registry = self.registry.on_state(handle, self.state.clone(), handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on<I, C, T, E>(
|
||||||
|
mut self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.registry = self
|
||||||
|
.registry
|
||||||
|
.on_state_form(handle, self.state.clone(), handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_state_async<I, C, E, F>(
|
||||||
|
mut self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(C) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
F: Future<Output = E> + Send + 'static,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.registry = self
|
||||||
|
.registry
|
||||||
|
.on_state_async(handle, self.state.clone(), handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_async<I, C, T, E, F>(
|
||||||
|
mut self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
F: Future<Output = E> + Send + 'static,
|
||||||
|
E: IntoEffect,
|
||||||
|
{
|
||||||
|
self.registry = self
|
||||||
|
.registry
|
||||||
|
.on_state_form_async(handle, self.state.clone(), handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_async_result<I, C, T, E, O, F>(
|
||||||
|
mut self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: fmt::Display,
|
||||||
|
{
|
||||||
|
self.registry =
|
||||||
|
self.registry
|
||||||
|
.on_state_form_async_result(handle, self.state.clone(), handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_registry(self) -> HandlerRegistry {
|
||||||
|
self.registry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> DispatchRegistry for StateHandlerRegistry<S>
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
|
||||||
|
self.registry.dispatch_form(form)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +1024,10 @@ fn is_multipart(headers: &HeaderMap) -> bool {
|
|||||||
|
|
||||||
impl PageMode {
|
impl PageMode {
|
||||||
pub fn from_headers(headers: &HeaderMap) -> Self {
|
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||||
match headers.get(SLHX_PARTIAL_HEADER).and_then(|value| value.to_str().ok()) {
|
match headers
|
||||||
|
.get(SLHX_PARTIAL_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
{
|
||||||
Some("1" | "true") => Self::Partial,
|
Some("1" | "true") => Self::Partial,
|
||||||
_ => Self::Full,
|
_ => Self::Full,
|
||||||
}
|
}
|
||||||
@@ -452,7 +1055,10 @@ impl IntoResponse for PageResponse {
|
|||||||
.headers_mut()
|
.headers_mut()
|
||||||
.insert(SLHX_FINGERPRINT_HEADER, fingerprint);
|
.insert(SLHX_FINGERPRINT_HEADER, fingerprint);
|
||||||
}
|
}
|
||||||
if let Some(title) = self.title.and_then(|title| HeaderValue::from_str(&title).ok()) {
|
if let Some(title) = self
|
||||||
|
.title
|
||||||
|
.and_then(|title| HeaderValue::from_str(&title).ok())
|
||||||
|
{
|
||||||
response.headers_mut().insert(SLHX_TITLE_HEADER, title);
|
response.headers_mut().insert(SLHX_TITLE_HEADER, title);
|
||||||
}
|
}
|
||||||
response
|
response
|
||||||
@@ -506,6 +1112,16 @@ impl IntoResponse for DispatchRejection {
|
|||||||
format!("unknown slhx handle id {handle_id}"),
|
format!("unknown slhx handle id {handle_id}"),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
|
Self::InvalidForm { handle_id, message } => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("invalid slhx form for handle id {handle_id}: {message}"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Self::HandlerError { handle_id, message } => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("slhx handler for handle id {handle_id} failed: {message}"),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,7 +1151,7 @@ where
|
|||||||
S: Stream<Item = Result<EffectBatch, E>> + Send + 'static,
|
S: Stream<Item = Result<EffectBatch, E>> + Send + 'static,
|
||||||
E: Into<axum::BoxError>,
|
E: Into<axum::BoxError>,
|
||||||
{
|
{
|
||||||
Sse::new(batches.map(|batch| batch.map(sse_event)))
|
Sse::new(batches.map(|batch| batch.map(sse_event))).keep_alive(KeepAlive::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sse_event(batch: EffectBatch) -> Event {
|
pub fn sse_event(batch: EffectBatch) -> Event {
|
||||||
@@ -630,8 +1246,16 @@ fn hex(byte: u8) -> Option<u8> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm, SLHX_SSE_EVENT};
|
use super::{
|
||||||
use axum::{body::{to_bytes, Body}, extract::FromRequest, http::{header, Request}, response::IntoResponse};
|
encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm,
|
||||||
|
SLHX_SSE_EVENT,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::{to_bytes, Body},
|
||||||
|
extract::FromRequest,
|
||||||
|
http::{header, Request},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
use slhx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION};
|
use slhx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION};
|
||||||
|
|||||||
+231
-28
@@ -1,18 +1,50 @@
|
|||||||
|
use axum::extract::State;
|
||||||
use axum::http::{header, HeaderMap};
|
use axum::http::{header, HeaderMap};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
use slhx_axum::{
|
use slhx_axum::{
|
||||||
interactions, runtime_js, DispatchRejection, EffectResponse, InteractionForm,
|
interactions, runtime_js, DispatchRejection, EffectResponse, Form, InteractionForm,
|
||||||
InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse,
|
InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse,
|
||||||
SLHX_CONTENT_TYPE, SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE,
|
SLHX_CONTENT_TYPE, SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE,
|
||||||
SLHX_TITLE_HEADER,
|
SLHX_TITLE_HEADER,
|
||||||
};
|
};
|
||||||
use slhx_core::{push, BuildFingerprint, Handle, SafeHtml, Slot};
|
use slhx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
|
||||||
|
|
||||||
fn selector(value: &str) -> Selector {
|
fn selector(value: &str) -> Selector {
|
||||||
Selector::parse(value).expect("test selector parses")
|
Selector::parse(value).expect("test selector parses")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
struct ProjectId(u32);
|
||||||
|
|
||||||
|
impl std::str::FromStr for ProjectId {
|
||||||
|
type Err = std::num::ParseIntError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
value.parse().map(ProjectId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OpenProject {
|
||||||
|
project_id: ProjectId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl slhx_core::FromForm for OpenProject {
|
||||||
|
fn from_form_fields(fields: &[(String, String)]) -> Result<Self, slhx_core::FormError> {
|
||||||
|
let Some(value) = fields
|
||||||
|
.iter()
|
||||||
|
.find_map(|(name, value)| (name == "project_id").then_some(value.as_str()))
|
||||||
|
else {
|
||||||
|
return Err(slhx_core::FormError::new("missing form field `project_id`"));
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
project_id: value
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| slhx_core::FormError::new("invalid form field `project_id`"))?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_mode_detects_partial_header() {
|
fn page_mode_detects_partial_header() {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
@@ -25,27 +57,43 @@ fn page_mode_detects_partial_header() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
|
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
|
||||||
// req: test/005
|
// req: test/005
|
||||||
let full = PageRequest { mode: PageMode::Full }.page(
|
let full = PageRequest {
|
||||||
"<main data-page=\"docs\">Docs</main>",
|
mode: PageMode::Full,
|
||||||
|content| format!("<html><body data-shell=\"docs\">{content}</body></html>"),
|
}
|
||||||
);
|
.page("<main data-page=\"docs\">Docs</main>", |content| {
|
||||||
|
format!("<html><body data-shell=\"docs\">{content}</body></html>")
|
||||||
|
});
|
||||||
assert_eq!(full.mode, PageMode::Full);
|
assert_eq!(full.mode, PageMode::Full);
|
||||||
let full_document = Html::parse_document(&full.html);
|
let full_document = Html::parse_document(&full.html);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
full_document
|
full_document
|
||||||
.select(&selector("body[data-shell=\"docs\"] main[data-page=\"docs\"]"))
|
.select(&selector(
|
||||||
|
"body[data-shell=\"docs\"] main[data-page=\"docs\"]"
|
||||||
|
))
|
||||||
.count(),
|
.count(),
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
||||||
let partial = PageRequest { mode: PageMode::Partial }.page(
|
let partial = PageRequest {
|
||||||
"<main data-page=\"docs\">Docs</main>",
|
mode: PageMode::Partial,
|
||||||
|content| format!("<html><body data-shell=\"docs\">{content}</body></html>"),
|
}
|
||||||
);
|
.page("<main data-page=\"docs\">Docs</main>", |content| {
|
||||||
|
format!("<html><body data-shell=\"docs\">{content}</body></html>")
|
||||||
|
});
|
||||||
assert_eq!(partial.mode, PageMode::Partial);
|
assert_eq!(partial.mode, PageMode::Partial);
|
||||||
let partial_fragment = Html::parse_fragment(&partial.html);
|
let partial_fragment = Html::parse_fragment(&partial.html);
|
||||||
assert_eq!(partial_fragment.select(&selector("main[data-page=\"docs\"]")).count(), 1);
|
assert_eq!(
|
||||||
assert_eq!(partial_fragment.select(&selector("body[data-shell=\"docs\"]")).count(), 0);
|
partial_fragment
|
||||||
|
.select(&selector("main[data-page=\"docs\"]"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
partial_fragment
|
||||||
|
.select(&selector("body[data-shell=\"docs\"]"))
|
||||||
|
.count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -66,7 +114,9 @@ fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
|
|||||||
let full_document = Html::parse_document(&full.html);
|
let full_document = Html::parse_document(&full.html);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
full_document
|
full_document
|
||||||
.select(&selector("body[data-shell=\"docs\"] main[data-page=\"docs\"]"))
|
.select(&selector(
|
||||||
|
"body[data-shell=\"docs\"] main[data-page=\"docs\"]"
|
||||||
|
))
|
||||||
.count(),
|
.count(),
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
@@ -84,8 +134,18 @@ fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
|
|||||||
);
|
);
|
||||||
assert_eq!(partial.mode, PageMode::Partial);
|
assert_eq!(partial.mode, PageMode::Partial);
|
||||||
let partial_fragment = Html::parse_fragment(&partial.html);
|
let partial_fragment = Html::parse_fragment(&partial.html);
|
||||||
assert_eq!(partial_fragment.select(&selector("main[data-page=\"docs\"]")).count(), 1);
|
assert_eq!(
|
||||||
assert_eq!(partial_fragment.select(&selector("body[data-shell=\"docs\"]")).count(), 0);
|
partial_fragment
|
||||||
|
.select(&selector("main[data-page=\"docs\"]"))
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
partial_fragment
|
||||||
|
.select(&selector("body[data-shell=\"docs\"]"))
|
||||||
|
.count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -94,7 +154,10 @@ fn partial_page_response_sets_partial_and_title_headers() {
|
|||||||
.title("Docs")
|
.title("Docs")
|
||||||
.into_response();
|
.into_response();
|
||||||
|
|
||||||
assert_eq!(response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8");
|
assert_eq!(
|
||||||
|
response.headers()[header::CONTENT_TYPE],
|
||||||
|
"text/html; charset=utf-8"
|
||||||
|
);
|
||||||
assert_eq!(response.headers()[SLHX_PARTIAL_HEADER], "true");
|
assert_eq!(response.headers()[SLHX_PARTIAL_HEADER], "true");
|
||||||
assert_eq!(response.headers()[SLHX_TITLE_HEADER], "Docs");
|
assert_eq!(response.headers()[SLHX_TITLE_HEADER], "Docs");
|
||||||
}
|
}
|
||||||
@@ -109,8 +172,8 @@ fn effect_response_is_wire_batch_with_fingerprint_header() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interaction_form_parses_handle_and_fields() {
|
fn interaction_form_parses_handle_and_fields() {
|
||||||
let form = InteractionForm::parse_urlencoded(b"__h=42&title=Hello+World&tag=a&tag=b%2Fc")
|
let form =
|
||||||
.unwrap();
|
InteractionForm::parse_urlencoded(b"__h=42&title=Hello+World&tag=a&tag=b%2Fc").unwrap();
|
||||||
|
|
||||||
assert_eq!(form.handle_id, 42);
|
assert_eq!(form.handle_id, 42);
|
||||||
assert_eq!(form.value("title"), Some("Hello World"));
|
assert_eq!(form.value("title"), Some("Hello World"));
|
||||||
@@ -120,8 +183,8 @@ fn interaction_form_parses_handle_and_fields() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn interaction_form_parses_typed_values() {
|
fn interaction_form_parses_typed_values() {
|
||||||
// req: form/004 req: dx/003
|
// req: form/004 req: dx/003
|
||||||
let form = InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope")
|
let form =
|
||||||
.expect("form should parse");
|
InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope").expect("form should parse");
|
||||||
|
|
||||||
assert_eq!(form.parse::<u32>("count"), Some(7));
|
assert_eq!(form.parse::<u32>("count"), Some(7));
|
||||||
assert_eq!(form.parse::<u32>("bad"), None);
|
assert_eq!(form.parse::<u32>("bad"), None);
|
||||||
@@ -143,8 +206,7 @@ fn interaction_form_requires_numeric_handle() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn interaction_form_preserves_hidden_csrf_fields_for_extractors() {
|
fn interaction_form_preserves_hidden_csrf_fields_for_extractors() {
|
||||||
// req: auth/004
|
// req: auth/004
|
||||||
let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello")
|
let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello").unwrap();
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(form.handle_id, 42);
|
assert_eq!(form.handle_id, 42);
|
||||||
assert_eq!(form.value("csrf_token"), Some("abc123"));
|
assert_eq!(form.value("csrf_token"), Some("abc123"));
|
||||||
@@ -159,15 +221,151 @@ fn interaction_request_dispatches_with_concise_handlers_helper() {
|
|||||||
Vec::new(),
|
Vec::new(),
|
||||||
));
|
));
|
||||||
let response = request
|
let response = request
|
||||||
.dispatch(interactions(BuildFingerprint(4)).on(Handle::<()>::new(7), |_| {
|
.dispatch(
|
||||||
Slot::<String>::new(3).text("ok")
|
interactions(BuildFingerprint(4))
|
||||||
}))
|
.on(Handle::<()>::new(7), |_| Slot::<String>::new(3).text("ok")),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(response.batch.fingerprint, BuildFingerprint(4));
|
assert_eq!(response.batch.fingerprint, BuildFingerprint(4));
|
||||||
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text("ok")]);
|
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text("ok")]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interaction_request_dispatches_typed_form_inputs() {
|
||||||
|
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
||||||
|
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||||
|
Handle::<()>::new(8),
|
||||||
|
vec![("project_id".to_owned(), "42".to_owned())],
|
||||||
|
));
|
||||||
|
let response = request
|
||||||
|
.dispatch(
|
||||||
|
interactions(BuildFingerprint(4))
|
||||||
|
.on_form(Handle::<()>::new(8), |input: OpenProject| {
|
||||||
|
Slot::<String>::new(3).text(input.project_id.0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.batch.fingerprint, BuildFingerprint(4));
|
||||||
|
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interaction_request_rejects_invalid_typed_form_inputs() {
|
||||||
|
// req: axum_integration/003 req: form/004 req: canonical_authoring/004
|
||||||
|
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||||
|
Handle::<()>::new(8),
|
||||||
|
vec![("project_id".to_owned(), "nope".to_owned())],
|
||||||
|
));
|
||||||
|
let rejection = request
|
||||||
|
.dispatch(
|
||||||
|
interactions(BuildFingerprint(4))
|
||||||
|
.on_form(Handle::<()>::new(8), |input: OpenProject| {
|
||||||
|
Slot::<String>::new(3).text(input.project_id.0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
rejection,
|
||||||
|
DispatchRejection::InvalidForm { handle_id: 8, .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interaction_request_dispatches_typed_state_handlers() {
|
||||||
|
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
||||||
|
fn open_project(multiplier: u32, input: OpenProject) -> impl IntoEffect {
|
||||||
|
Slot::<String>::new(3).text(input.project_id.0 * multiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||||
|
Handle::<()>::new(9),
|
||||||
|
vec![("project_id".to_owned(), "21".to_owned())],
|
||||||
|
));
|
||||||
|
let response = request
|
||||||
|
.dispatch(
|
||||||
|
interactions(BuildFingerprint(4))
|
||||||
|
.with_state(2_u32)
|
||||||
|
.on(Handle::<()>::new(9), open_project),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct HandlerBoom;
|
||||||
|
|
||||||
|
impl std::fmt::Display for HandlerBoom {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("database unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for HandlerBoom {}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interaction_request_dispatches_async_typed_state_extractors() {
|
||||||
|
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
||||||
|
async fn open_project(
|
||||||
|
State(multiplier): State<u32>,
|
||||||
|
Form(input): Form<OpenProject>,
|
||||||
|
) -> impl IntoEffect {
|
||||||
|
Slot::<String>::new(3).text(input.project_id.0 * multiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||||
|
Handle::<()>::new(10),
|
||||||
|
vec![("project_id".to_owned(), "21".to_owned())],
|
||||||
|
));
|
||||||
|
let response = request
|
||||||
|
.dispatch_async(
|
||||||
|
interactions(BuildFingerprint(4))
|
||||||
|
.with_state(2_u32)
|
||||||
|
.on_async(Handle::<()>::new(10), open_project)
|
||||||
|
.into_registry(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interaction_request_reports_async_result_handler_errors() {
|
||||||
|
// req: axum_integration/003 req: form/004 req: failure/003
|
||||||
|
async fn open_project(
|
||||||
|
State(_multiplier): State<u32>,
|
||||||
|
Form(_input): Form<OpenProject>,
|
||||||
|
) -> Result<impl IntoEffect, HandlerBoom> {
|
||||||
|
Err::<(), _>(HandlerBoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||||
|
Handle::<()>::new(11),
|
||||||
|
vec![("project_id".to_owned(), "21".to_owned())],
|
||||||
|
));
|
||||||
|
let error = request
|
||||||
|
.dispatch_async(
|
||||||
|
interactions(BuildFingerprint(4))
|
||||||
|
.with_state(2_u32)
|
||||||
|
.on_async_result(Handle::<()>::new(11), open_project)
|
||||||
|
.into_registry(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
DispatchRejection::HandlerError {
|
||||||
|
handle_id: 11,
|
||||||
|
message: "database unavailable".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interactions_dispatch_by_checked_handle() {
|
fn interactions_dispatch_by_checked_handle() {
|
||||||
// req: ceremony/004 req: public_api/001
|
// req: ceremony/004 req: public_api/001
|
||||||
@@ -193,7 +391,9 @@ fn interactions_reject_unknown_handle_ids() {
|
|||||||
let request = InteractionRequest::from(InteractionForm::new(9, []));
|
let request = InteractionRequest::from(InteractionForm::new(9, []));
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.dispatch(interactions(BuildFingerprint(123))).unwrap_err(),
|
request
|
||||||
|
.dispatch(interactions(BuildFingerprint(123)))
|
||||||
|
.unwrap_err(),
|
||||||
DispatchRejection::UnknownHandle(9)
|
DispatchRejection::UnknownHandle(9)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,6 +402,9 @@ fn interactions_reject_unknown_handle_ids() {
|
|||||||
fn runtime_js_response_serves_embedded_runtime() {
|
fn runtime_js_response_serves_embedded_runtime() {
|
||||||
let response = runtime_js().into_response();
|
let response = runtime_js().into_response();
|
||||||
|
|
||||||
assert_eq!(response.headers()[header::CONTENT_TYPE], SLHX_RUNTIME_CONTENT_TYPE);
|
assert_eq!(
|
||||||
|
response.headers()[header::CONTENT_TYPE],
|
||||||
|
SLHX_RUNTIME_CONTENT_TYPE
|
||||||
|
);
|
||||||
assert!(response.headers().contains_key(header::CACHE_CONTROL));
|
assert!(response.headers().contains_key(header::CACHE_CONTROL));
|
||||||
}
|
}
|
||||||
|
|||||||
+614
-155
File diff suppressed because it is too large
Load Diff
+168
-34
@@ -68,6 +68,13 @@ impl ResourceId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A generated UI target that can be inspected without exposing raw slots.
|
||||||
|
/// req: dx/006 req: test/001
|
||||||
|
pub trait GeneratedTarget {
|
||||||
|
#[doc(hidden)]
|
||||||
|
fn __slhx_resource_id(self) -> ResourceId;
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
|
||||||
pub enum ScopeKey {
|
pub enum ScopeKey {
|
||||||
KeyValue(String),
|
KeyValue(String),
|
||||||
@@ -281,6 +288,10 @@ impl EventName {
|
|||||||
pub const fn as_str(self) -> &'static str {
|
pub const fn as_str(self) -> &'static str {
|
||||||
self.name
|
self.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn emit(self, payload: impl Into<String>) -> Effect {
|
||||||
|
event(self, payload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsRef<str> for EventName {
|
impl AsRef<str> for EventName {
|
||||||
@@ -402,19 +413,42 @@ pub enum FormControlKind {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
pub enum Effect {
|
pub enum Effect {
|
||||||
Put { target: ResourceRef, payload: Payload },
|
Put {
|
||||||
Insert { target: ResourceRef, key: String, payload: Payload },
|
target: ResourceRef,
|
||||||
Prepend { target: ResourceRef, key: String, payload: Payload },
|
payload: Payload,
|
||||||
Remove { target: ResourceRef, key: Option<String> },
|
},
|
||||||
Move { target: ResourceRef, key: String, before: Option<String> },
|
Insert {
|
||||||
Focus { target: ResourceRef },
|
target: ResourceRef,
|
||||||
|
key: String,
|
||||||
|
payload: Payload,
|
||||||
|
},
|
||||||
|
Prepend {
|
||||||
|
target: ResourceRef,
|
||||||
|
key: String,
|
||||||
|
payload: Payload,
|
||||||
|
},
|
||||||
|
Remove {
|
||||||
|
target: ResourceRef,
|
||||||
|
key: Option<String>,
|
||||||
|
},
|
||||||
|
Move {
|
||||||
|
target: ResourceRef,
|
||||||
|
key: String,
|
||||||
|
before: Option<String>,
|
||||||
|
},
|
||||||
|
Focus {
|
||||||
|
target: ResourceRef,
|
||||||
|
},
|
||||||
Navigate {
|
Navigate {
|
||||||
url: String,
|
url: String,
|
||||||
mode: NavigateMode,
|
mode: NavigateMode,
|
||||||
scroll: ScrollBehavior,
|
scroll: ScrollBehavior,
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
},
|
},
|
||||||
Emit { name: String, payload: String },
|
Emit {
|
||||||
|
name: String,
|
||||||
|
payload: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -476,13 +510,21 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
|
|||||||
write_ref(target, out);
|
write_ref(target, out);
|
||||||
write_payload(payload, out);
|
write_payload(payload, out);
|
||||||
}
|
}
|
||||||
Effect::Insert { target, key, payload } => {
|
Effect::Insert {
|
||||||
|
target,
|
||||||
|
key,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
write_u8(1, out);
|
write_u8(1, out);
|
||||||
write_ref(target, out);
|
write_ref(target, out);
|
||||||
write_str(key, out);
|
write_str(key, out);
|
||||||
write_payload(payload, out);
|
write_payload(payload, out);
|
||||||
}
|
}
|
||||||
Effect::Prepend { target, key, payload } => {
|
Effect::Prepend {
|
||||||
|
target,
|
||||||
|
key,
|
||||||
|
payload,
|
||||||
|
} => {
|
||||||
write_u8(2, out);
|
write_u8(2, out);
|
||||||
write_ref(target, out);
|
write_ref(target, out);
|
||||||
write_str(key, out);
|
write_str(key, out);
|
||||||
@@ -493,7 +535,11 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
|
|||||||
write_ref(target, out);
|
write_ref(target, out);
|
||||||
write_option_str(key.as_deref(), out);
|
write_option_str(key.as_deref(), out);
|
||||||
}
|
}
|
||||||
Effect::Move { target, key, before } => {
|
Effect::Move {
|
||||||
|
target,
|
||||||
|
key,
|
||||||
|
before,
|
||||||
|
} => {
|
||||||
write_u8(4, out);
|
write_u8(4, out);
|
||||||
write_ref(target, out);
|
write_ref(target, out);
|
||||||
write_str(key, out);
|
write_str(key, out);
|
||||||
@@ -503,14 +549,22 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
|
|||||||
write_u8(5, out);
|
write_u8(5, out);
|
||||||
write_ref(target, out);
|
write_ref(target, out);
|
||||||
}
|
}
|
||||||
Effect::Navigate { url, mode, scroll, title } => {
|
Effect::Navigate {
|
||||||
|
url,
|
||||||
|
mode,
|
||||||
|
scroll,
|
||||||
|
title,
|
||||||
|
} => {
|
||||||
write_u8(6, out);
|
write_u8(6, out);
|
||||||
write_str(url, out);
|
write_str(url, out);
|
||||||
write_u8(match mode {
|
write_u8(
|
||||||
|
match mode {
|
||||||
NavigateMode::Push => 0,
|
NavigateMode::Push => 0,
|
||||||
NavigateMode::Replace => 1,
|
NavigateMode::Replace => 1,
|
||||||
NavigateMode::Redirect => 2,
|
NavigateMode::Redirect => 2,
|
||||||
}, out);
|
},
|
||||||
|
out,
|
||||||
|
);
|
||||||
write_scroll(scroll, out);
|
write_scroll(scroll, out);
|
||||||
write_option_str(title.as_deref(), out);
|
write_option_str(title.as_deref(), out);
|
||||||
}
|
}
|
||||||
@@ -523,12 +577,15 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn write_ref(reference: &ResourceRef, out: &mut Vec<u8>) {
|
fn write_ref(reference: &ResourceRef, out: &mut Vec<u8>) {
|
||||||
write_u8(match reference.resource.kind {
|
write_u8(
|
||||||
|
match reference.resource.kind {
|
||||||
ResourceKind::Slot => 0,
|
ResourceKind::Slot => 0,
|
||||||
ResourceKind::Atom => 1,
|
ResourceKind::Atom => 1,
|
||||||
ResourceKind::Handle => 2,
|
ResourceKind::Handle => 2,
|
||||||
ResourceKind::Form => 3,
|
ResourceKind::Form => 3,
|
||||||
}, out);
|
},
|
||||||
|
out,
|
||||||
|
);
|
||||||
write_u32(reference.resource.id, out);
|
write_u32(reference.resource.id, out);
|
||||||
match &reference.scope {
|
match &reference.scope {
|
||||||
None => write_u8(0, out),
|
None => write_u8(0, out),
|
||||||
@@ -661,17 +718,41 @@ fn read_batch(bytes: &[u8]) -> Result<EffectBatch, WireError> {
|
|||||||
ops.push(read_effect(&mut reader)?);
|
ops.push(read_effect(&mut reader)?);
|
||||||
}
|
}
|
||||||
reader.finish()?;
|
reader.finish()?;
|
||||||
Ok(EffectBatch { abi_version, fingerprint, ops })
|
Ok(EffectBatch {
|
||||||
|
abi_version,
|
||||||
|
fingerprint,
|
||||||
|
ops,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
|
fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
|
||||||
match reader.read_u8()? {
|
match reader.read_u8()? {
|
||||||
0 => Ok(Effect::Put { target: read_ref(reader)?, payload: read_payload(reader)? }),
|
0 => Ok(Effect::Put {
|
||||||
1 => Ok(Effect::Insert { target: read_ref(reader)?, key: reader.read_str()?, payload: read_payload(reader)? }),
|
target: read_ref(reader)?,
|
||||||
2 => Ok(Effect::Prepend { target: read_ref(reader)?, key: reader.read_str()?, payload: read_payload(reader)? }),
|
payload: read_payload(reader)?,
|
||||||
3 => Ok(Effect::Remove { target: read_ref(reader)?, key: read_option_str(reader)? }),
|
}),
|
||||||
4 => Ok(Effect::Move { target: read_ref(reader)?, key: reader.read_str()?, before: read_option_str(reader)? }),
|
1 => Ok(Effect::Insert {
|
||||||
5 => Ok(Effect::Focus { target: read_ref(reader)? }),
|
target: read_ref(reader)?,
|
||||||
|
key: reader.read_str()?,
|
||||||
|
payload: read_payload(reader)?,
|
||||||
|
}),
|
||||||
|
2 => Ok(Effect::Prepend {
|
||||||
|
target: read_ref(reader)?,
|
||||||
|
key: reader.read_str()?,
|
||||||
|
payload: read_payload(reader)?,
|
||||||
|
}),
|
||||||
|
3 => Ok(Effect::Remove {
|
||||||
|
target: read_ref(reader)?,
|
||||||
|
key: read_option_str(reader)?,
|
||||||
|
}),
|
||||||
|
4 => Ok(Effect::Move {
|
||||||
|
target: read_ref(reader)?,
|
||||||
|
key: reader.read_str()?,
|
||||||
|
before: read_option_str(reader)?,
|
||||||
|
}),
|
||||||
|
5 => Ok(Effect::Focus {
|
||||||
|
target: read_ref(reader)?,
|
||||||
|
}),
|
||||||
6 => Ok(Effect::Navigate {
|
6 => Ok(Effect::Navigate {
|
||||||
url: reader.read_str()?,
|
url: reader.read_str()?,
|
||||||
mode: match reader.read_u8()? {
|
mode: match reader.read_u8()? {
|
||||||
@@ -683,7 +764,10 @@ fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
|
|||||||
scroll: read_scroll(reader)?,
|
scroll: read_scroll(reader)?,
|
||||||
title: read_option_str(reader)?,
|
title: read_option_str(reader)?,
|
||||||
}),
|
}),
|
||||||
7 => Ok(Effect::Emit { name: reader.read_str()?, payload: reader.read_str()? }),
|
7 => Ok(Effect::Emit {
|
||||||
|
name: reader.read_str()?,
|
||||||
|
payload: reader.read_str()?,
|
||||||
|
}),
|
||||||
_ => Err(WireError::UnknownTag),
|
_ => Err(WireError::UnknownTag),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,6 +842,14 @@ impl IntoEffect for () {
|
|||||||
fn append_to(self, _ops: &mut Vec<Effect>) {}
|
fn append_to(self, _ops: &mut Vec<Effect>) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<T: IntoEffect> IntoEffect for Option<T> {
|
||||||
|
fn append_to(self, ops: &mut Vec<Effect>) {
|
||||||
|
if let Some(effect) = self {
|
||||||
|
effect.append_to(ops);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
macro_rules! impl_tuple_into_effect {
|
macro_rules! impl_tuple_into_effect {
|
||||||
($($name:ident $idx:tt),+) => {
|
($($name:ident $idx:tt),+) => {
|
||||||
impl<$($name),+> IntoEffect for ($($name,)+)
|
impl<$($name),+> IntoEffect for ($($name,)+)
|
||||||
@@ -822,10 +914,10 @@ impl<T> Slot<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn html(self, value: SafeHtml) -> Effect {
|
pub fn html(self, value: impl Into<SafeHtml>) -> Effect {
|
||||||
Effect::Put {
|
Effect::Put {
|
||||||
target: ResourceRef::unscoped(self.id),
|
target: ResourceRef::unscoped(self.id),
|
||||||
payload: Payload::html(value),
|
payload: Payload::html(value.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -901,30 +993,30 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn append_html(self, key: K, value: SafeHtml) -> Effect {
|
pub fn append_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
|
||||||
Effect::Insert {
|
Effect::Insert {
|
||||||
target: ResourceRef::unscoped(self.id),
|
target: ResourceRef::unscoped(self.id),
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
payload: Payload::html(value),
|
payload: Payload::html(value.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prepend_html(self, key: K, value: SafeHtml) -> Effect {
|
pub fn prepend_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
|
||||||
Effect::Prepend {
|
Effect::Prepend {
|
||||||
target: ResourceRef::unscoped(self.id),
|
target: ResourceRef::unscoped(self.id),
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
payload: Payload::html(value),
|
payload: Payload::html(value.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn replace_html(self, key: K, value: SafeHtml) -> Effect {
|
pub fn replace_html(self, key: K, value: impl Into<SafeHtml>) -> Effect {
|
||||||
let key = key.to_string();
|
let key = key.to_string();
|
||||||
Effect::Put {
|
Effect::Put {
|
||||||
target: ResourceRef {
|
target: ResourceRef {
|
||||||
resource: self.id,
|
resource: self.id,
|
||||||
scope: Some(ScopeKey::KeyValue(key)),
|
scope: Some(ScopeKey::KeyValue(key)),
|
||||||
},
|
},
|
||||||
payload: Payload::html(value),
|
payload: Payload::html(value.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,9 +1101,47 @@ impl<I> core::fmt::Display for Handle<I> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait FormValue {}
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct FormError {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl<T> FormValue for T where T: std::str::FromStr {}
|
impl FormError {
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
&self.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for FormError {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
f.write_str(&self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for FormError {}
|
||||||
|
|
||||||
|
pub trait FormValue: Sized {
|
||||||
|
fn parse_form_value(value: &str) -> Result<Self, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> FormValue for T
|
||||||
|
where
|
||||||
|
T: std::str::FromStr,
|
||||||
|
{
|
||||||
|
fn parse_form_value(value: &str) -> Result<Self, String> {
|
||||||
|
value.parse().map_err(|_| "invalid form value".to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait FromForm: Sized {
|
||||||
|
fn from_form_fields(fields: &[(String, String)]) -> Result<Self, FormError>;
|
||||||
|
}
|
||||||
|
|
||||||
pub trait FormModel {}
|
pub trait FormModel {}
|
||||||
|
|
||||||
@@ -1065,7 +1195,11 @@ impl<T> Form<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(self, field: impl Into<String>) -> Effect {
|
pub fn clear(self) -> Effect {
|
||||||
|
self.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_field(self, field: impl Into<String>) -> Effect {
|
||||||
Effect::Put {
|
Effect::Put {
|
||||||
target: self.field(field),
|
target: self.field(field),
|
||||||
payload: Payload::text(""),
|
payload: Payload::text(""),
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ fn effect_batch_wire_round_trips() {
|
|||||||
assert!(decoded.is_compatible());
|
assert!(decoded.is_compatible());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn optional_effects_compose_into_batches() {
|
||||||
|
// req: component/005 req: public_api/003
|
||||||
|
let count = Slot::<u32>::new(1);
|
||||||
|
let batch = (Some(count.text(2)), Option::<Effect>::None).into_batch(BuildFingerprint(42));
|
||||||
|
|
||||||
|
assert_eq!(batch.ops.len(), 1);
|
||||||
|
assert_eq!(batch.ops[0], count.text(2));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keyed_slot_replace_uses_scoped_resource_ref() {
|
fn keyed_slot_replace_uses_scoped_resource_ref() {
|
||||||
let todos = KeyedSlot::<u64, String>::new(9);
|
let todos = KeyedSlot::<u64, String>::new(9);
|
||||||
@@ -142,7 +152,10 @@ fn generated_css_classes_join_for_hemplate_dynamic_class_attrs() {
|
|||||||
assert_eq!(classes.as_str(), "work-card is-selected");
|
assert_eq!(classes.as_str(), "work-card is-selected");
|
||||||
assert_eq!(classes.to_string(), "work-card is-selected");
|
assert_eq!(classes.to_string(), "work-card is-selected");
|
||||||
assert_eq!(CARD.with(SELECTED).as_str(), "work-card is-selected");
|
assert_eq!(CARD.with(SELECTED).as_str(), "work-card is-selected");
|
||||||
assert_eq!(CARD.with_if(true, SELECTED).as_str(), "work-card is-selected");
|
assert_eq!(
|
||||||
|
CARD.with_if(true, SELECTED).as_str(),
|
||||||
|
"work-card is-selected"
|
||||||
|
);
|
||||||
assert_eq!(CARD.with_if(false, SELECTED).as_str(), "work-card");
|
assert_eq!(CARD.with_if(false, SELECTED).as_str(), "work-card");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ proc-macro = true
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
proc-macro2 = "1"
|
||||||
quote = "1"
|
quote = "1"
|
||||||
syn = { version = "2", features = ["full"] }
|
syn = { version = "2", features = ["full"] }
|
||||||
|
|||||||
+487
-74
@@ -1,14 +1,16 @@
|
|||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
use quote::quote;
|
use quote::{format_ident, quote};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use syn::parse::Parser;
|
||||||
|
use syn::punctuated::Punctuated;
|
||||||
use syn::{
|
use syn::{
|
||||||
parse_macro_input, parse_quote, Fields, FnArg, GenericArgument, Item, ItemFn, ItemMod,
|
parse_macro_input, parse_quote, Fields, FnArg, GenericArgument, Item, ItemFn, ItemMod,
|
||||||
ItemStruct, LitStr, Pat, PathArguments, ReturnType, Type,
|
ItemStruct, LitStr, Pat, Path, PathArguments, ReturnType, Token, Type,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
let mut function = parse_macro_input!(item as ItemFn);
|
let function = parse_macro_input!(item as ItemFn);
|
||||||
let name = function.sig.ident.to_string();
|
let name = function.sig.ident.to_string();
|
||||||
|
|
||||||
let Some(syms_path) = syms_path() else {
|
let Some(syms_path) = syms_path() else {
|
||||||
@@ -49,7 +51,7 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
}
|
}
|
||||||
if handle_requires_form(&syms_path, &name) && !has_form_param(&function) {
|
if handle_requires_form(&syms_path, &name) && !has_form_param(&function) {
|
||||||
let message = format!(
|
let message = format!(
|
||||||
"slhx handler `{name}` handles a generated form and must accept slhx::Form<_>"
|
"slhx handler `{name}` handles a generated form and must accept a typed form argument"
|
||||||
);
|
);
|
||||||
return quote!(
|
return quote!(
|
||||||
#function
|
#function
|
||||||
@@ -57,9 +59,6 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
)
|
)
|
||||||
.into();
|
.into();
|
||||||
}
|
}
|
||||||
if handle_requires_form(&syms_path, &name) {
|
|
||||||
add_form_model_bounds(&mut function);
|
|
||||||
}
|
|
||||||
let missing_params = missing_handle_params(&syms_path, &name, &function);
|
let missing_params = missing_handle_params(&syms_path, &name, &function);
|
||||||
if !missing_params.is_empty() {
|
if !missing_params.is_empty() {
|
||||||
let message = format!(
|
let message = format!(
|
||||||
@@ -96,15 +95,29 @@ pub fn form(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
let errors = form_contract_errors(&syms_path, &form_name, &form_struct);
|
let errors = form_contract_errors(&syms_path, &form_name, &form_struct);
|
||||||
if errors.is_empty() {
|
if errors.is_empty() {
|
||||||
let ident = &form_struct.ident;
|
let ident = &form_struct.ident;
|
||||||
let resource_id = form_resource_id(&syms_path, &form_name).expect("checked form exists in slhx.syms");
|
let resource_id =
|
||||||
|
form_resource_id(&syms_path, &form_name).expect("checked form exists in slhx.syms");
|
||||||
let mut generics = form_struct.generics.clone();
|
let mut generics = form_struct.generics.clone();
|
||||||
for ty in form_parser_types(&form_struct) {
|
for ty in form_parser_types(&form_struct) {
|
||||||
generics.make_where_clause().predicates.push(parse_quote!(#ty: ::slhx::FormValue));
|
generics
|
||||||
|
.make_where_clause()
|
||||||
|
.predicates
|
||||||
|
.push(parse_quote!(#ty: ::slhx::FormValue));
|
||||||
}
|
}
|
||||||
|
let decode_fields = form_decode_fields(&syms_path, &form_name, &form_struct);
|
||||||
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
|
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
|
||||||
quote!(
|
quote!(
|
||||||
#form_struct
|
#form_struct
|
||||||
impl #impl_generics ::slhx::FormModel for #ident #ty_generics #where_clause {}
|
impl #impl_generics ::slhx::FormModel for #ident #ty_generics #where_clause {}
|
||||||
|
impl #impl_generics ::slhx::FromForm for #ident #ty_generics #where_clause {
|
||||||
|
fn from_form_fields(
|
||||||
|
__slhx_fields: &[(String, String)],
|
||||||
|
) -> Result<Self, ::slhx::FormError> {
|
||||||
|
Ok(Self {
|
||||||
|
#(#decode_fields),*
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
impl #impl_generics #ident #ty_generics #where_clause {
|
impl #impl_generics #ident #ty_generics #where_clause {
|
||||||
pub const FORM: ::slhx::Form<Self> = ::slhx::Form::new(#resource_id);
|
pub const FORM: ::slhx::Form<Self> = ::slhx::Form::new(#resource_id);
|
||||||
}
|
}
|
||||||
@@ -142,12 +155,9 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
return quote!(#module).into();
|
return quote!(#module).into();
|
||||||
}
|
}
|
||||||
let component_filter = component_name.as_deref();
|
let component_filter = component_name.as_deref();
|
||||||
let missing = missing_component_handlers(&syms_path, component_filter, items);
|
let errors = component_contract_errors(&syms_path, component_filter, items);
|
||||||
if !missing.is_empty() {
|
if !errors.is_empty() {
|
||||||
let message = format!(
|
let message = errors.join("; ");
|
||||||
"#[slhx::component] missing handler implementation(s): {}",
|
|
||||||
missing.join(", ")
|
|
||||||
);
|
|
||||||
return quote!(
|
return quote!(
|
||||||
#module
|
#module
|
||||||
compile_error!(#message);
|
compile_error!(#message);
|
||||||
@@ -155,12 +165,25 @@ pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|||||||
.into();
|
.into();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let module = match component_name.as_deref() {
|
||||||
|
Some(component) => add_component_register_helper(module, component),
|
||||||
|
None => module,
|
||||||
|
};
|
||||||
|
|
||||||
quote!(#module).into()
|
quote!(#module).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
pub fn app(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
pub fn app(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
item
|
let components = match Punctuated::<Path, Token![,]>::parse_terminated.parse(attr) {
|
||||||
|
Ok(components) => components.into_iter().collect::<Vec<_>>(),
|
||||||
|
Err(error) => return error.to_compile_error().into(),
|
||||||
|
};
|
||||||
|
let function = parse_macro_input!(item as ItemFn);
|
||||||
|
match add_app_registry_helper(function, components) {
|
||||||
|
Ok(function) => quote!(#function).into(),
|
||||||
|
Err(message) => quote!(compile_error!(#message);).into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inject_surface_include(item: TokenStream) -> TokenStream {
|
fn inject_surface_include(item: TokenStream) -> TokenStream {
|
||||||
@@ -205,14 +228,7 @@ fn generated_path(file: &str) -> Option<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn has_form_param(function: &ItemFn) -> bool {
|
fn has_form_param(function: &ItemFn) -> bool {
|
||||||
function.sig.inputs.iter().any(|arg| match arg {
|
handler_form_model_type(function).is_some()
|
||||||
FnArg::Typed(arg) => is_form_type(&arg.ty),
|
|
||||||
FnArg::Receiver(_) => false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_form_type(ty: &Type) -> bool {
|
|
||||||
form_model_type(ty).is_some()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn form_model_type(ty: &Type) -> Option<Type> {
|
fn form_model_type(ty: &Type) -> Option<Type> {
|
||||||
@@ -221,7 +237,11 @@ fn form_model_type(ty: &Type) -> Option<Type> {
|
|||||||
};
|
};
|
||||||
let mut segments = path.path.segments.iter();
|
let mut segments = path.path.segments.iter();
|
||||||
let first = segments.next()?;
|
let first = segments.next()?;
|
||||||
let last = path.path.segments.last().expect("path has at least one segment");
|
let last = path
|
||||||
|
.path
|
||||||
|
.segments
|
||||||
|
.last()
|
||||||
|
.expect("path has at least one segment");
|
||||||
let path_is_form = if path.path.segments.len() == 1 {
|
let path_is_form = if path.path.segments.len() == 1 {
|
||||||
first.ident == "Form"
|
first.ident == "Form"
|
||||||
} else {
|
} else {
|
||||||
@@ -239,29 +259,26 @@ fn form_model_type(ty: &Type) -> Option<Type> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn form_model_types(function: &ItemFn) -> Vec<Type> {
|
fn handler_form_model_type(function: &ItemFn) -> Option<Type> {
|
||||||
function
|
function.sig.inputs.iter().rev().find_map(|arg| match arg {
|
||||||
.sig
|
|
||||||
.inputs
|
|
||||||
.iter()
|
|
||||||
.filter_map(|arg| match arg {
|
|
||||||
FnArg::Typed(arg) => form_model_type(&arg.ty),
|
FnArg::Typed(arg) => form_model_type(&arg.ty),
|
||||||
FnArg::Receiver(_) => None,
|
FnArg::Receiver(_) => None,
|
||||||
})
|
})
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_form_model_bounds(function: &mut ItemFn) {
|
|
||||||
for ty in form_model_types(function) {
|
|
||||||
let where_clause = function.sig.generics.make_where_clause();
|
|
||||||
where_clause.predicates.push(parse_quote!(#ty: ::slhx::FormModel));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_non_unit_return(function: &ItemFn) -> bool {
|
fn has_non_unit_return(function: &ItemFn) -> bool {
|
||||||
match &function.sig.output {
|
match &function.sig.output {
|
||||||
ReturnType::Default => false,
|
ReturnType::Default => false,
|
||||||
ReturnType::Type(_, ty) => !matches!(ty.as_ref(), Type::Tuple(tuple) if tuple.elems.is_empty()),
|
ReturnType::Type(_, ty) => {
|
||||||
|
!matches!(ty.as_ref(), Type::Tuple(tuple) if tuple.elems.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn returns_result(output: &ReturnType) -> bool {
|
||||||
|
match output {
|
||||||
|
ReturnType::Type(_, ty) => is_type_named(ty, "Result"),
|
||||||
|
ReturnType::Default => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +289,9 @@ fn syms_contains_handle(path: &PathBuf, ident: &str) -> bool {
|
|||||||
syms.lines().any(|line| {
|
syms.lines().any(|line| {
|
||||||
let mut fields = line.split('\t');
|
let mut fields = line.split('\t');
|
||||||
matches!(fields.next(), Some("handle"))
|
matches!(fields.next(), Some("handle"))
|
||||||
&& fields.nth(1).is_some_and(|handle_ident| handle_ident == ident)
|
&& fields
|
||||||
|
.nth(1)
|
||||||
|
.is_some_and(|handle_ident| handle_ident == ident)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +302,9 @@ fn handle_requires_form(path: &PathBuf, ident: &str) -> bool {
|
|||||||
syms.lines().any(|line| {
|
syms.lines().any(|line| {
|
||||||
let mut fields = line.split('\t');
|
let mut fields = line.split('\t');
|
||||||
matches!(fields.next(), Some("handle_form"))
|
matches!(fields.next(), Some("handle_form"))
|
||||||
&& fields.next().is_some_and(|handle_ident| handle_ident == ident)
|
&& fields
|
||||||
|
.next()
|
||||||
|
.is_some_and(|handle_ident| handle_ident == ident)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +316,11 @@ struct GeneratedFormField {
|
|||||||
multiple: bool,
|
multiple: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &ItemStruct) -> Vec<String> {
|
fn form_contract_errors(
|
||||||
|
syms_path: &PathBuf,
|
||||||
|
form_name: &str,
|
||||||
|
form_struct: &ItemStruct,
|
||||||
|
) -> Vec<String> {
|
||||||
if !syms_path.exists() {
|
if !syms_path.exists() {
|
||||||
return vec![
|
return vec![
|
||||||
"#[slhx::form] could not find generated slhx symbols; add slhx_build::app().run()? to build.rs or check template generation"
|
"#[slhx::form] could not find generated slhx symbols; add slhx_build::app().run()? to build.rs or check template generation"
|
||||||
@@ -316,7 +341,12 @@ fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &Item
|
|||||||
let actual = fields
|
let actual = fields
|
||||||
.named
|
.named
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|field| field.ident.as_ref().map(|ident| (ident.to_string(), &field.ty)))
|
.filter_map(|field| {
|
||||||
|
field
|
||||||
|
.ident
|
||||||
|
.as_ref()
|
||||||
|
.map(|ident| (ident.to_string(), &field.ty))
|
||||||
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for field in expected {
|
for field in expected {
|
||||||
@@ -335,12 +365,6 @@ fn form_contract_errors(syms_path: &PathBuf, form_name: &str, form_struct: &Item
|
|||||||
field.ident
|
field.ident
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !field.required && !optional && !multiple {
|
|
||||||
errors.push(format!(
|
|
||||||
"slhx form `{form_name}` field `{}` is optional in HTML and must be Option<_>",
|
|
||||||
field.ident
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if field.multiple && !multiple {
|
if field.multiple && !multiple {
|
||||||
errors.push(format!(
|
errors.push(format!(
|
||||||
"slhx form `{form_name}` field `{}` accepts multiple values and must be Vec<_>",
|
"slhx form `{form_name}` field `{}` accepts multiple values and must be Vec<_>",
|
||||||
@@ -368,6 +392,74 @@ fn form_parser_types(form_struct: &ItemStruct) -> Vec<Type> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn form_decode_fields(
|
||||||
|
syms_path: &PathBuf,
|
||||||
|
form_name: &str,
|
||||||
|
form_struct: &ItemStruct,
|
||||||
|
) -> Vec<proc_macro2::TokenStream> {
|
||||||
|
let Fields::Named(fields) = &form_struct.fields else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let actual = fields
|
||||||
|
.named
|
||||||
|
.iter()
|
||||||
|
.filter_map(|field| field.ident.as_ref().map(|ident| (ident, &field.ty)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
form_fields(syms_path, form_name)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|field| {
|
||||||
|
let (ident, ty) = actual.iter().find(|(ident, _)| ident == &&field.ident)?;
|
||||||
|
let control_name = field.name;
|
||||||
|
let parser = parser_type(ty);
|
||||||
|
Some(if field.multiple {
|
||||||
|
quote! {
|
||||||
|
#ident: __slhx_fields
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(__slhx_name, __slhx_value)|
|
||||||
|
(__slhx_name == #control_name).then_some(__slhx_value.as_str())
|
||||||
|
)
|
||||||
|
.map(|__slhx_value| {
|
||||||
|
<#parser as ::slhx::FormValue>::parse_form_value(__slhx_value)
|
||||||
|
.map_err(|_| ::slhx::FormError::new(format!("invalid form field `{}`", #control_name)))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
}
|
||||||
|
} else if is_type_named(ty, "Option") {
|
||||||
|
quote! {
|
||||||
|
#ident: match __slhx_fields
|
||||||
|
.iter()
|
||||||
|
.find_map(|(__slhx_name, __slhx_value)|
|
||||||
|
(__slhx_name == #control_name).then_some(__slhx_value.as_str())
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Some(__slhx_value) => Some(
|
||||||
|
<#parser as ::slhx::FormValue>::parse_form_value(__slhx_value)
|
||||||
|
.map_err(|_| ::slhx::FormError::new(format!("invalid form field `{}`", #control_name)))?
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
#ident: {
|
||||||
|
let Some(__slhx_value) = __slhx_fields
|
||||||
|
.iter()
|
||||||
|
.find_map(|(__slhx_name, __slhx_value)|
|
||||||
|
(__slhx_name == #control_name).then_some(__slhx_value.as_str())
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
return Err(::slhx::FormError::new(format!("missing form field `{}`", #control_name)));
|
||||||
|
};
|
||||||
|
<#parser as ::slhx::FormValue>::parse_form_value(__slhx_value)
|
||||||
|
.map_err(|_| ::slhx::FormError::new(format!("invalid form field `{}`", #control_name)))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn parser_type(ty: &Type) -> &Type {
|
fn parser_type(ty: &Type) -> &Type {
|
||||||
generic_inner_type(ty, "Option")
|
generic_inner_type(ty, "Option")
|
||||||
.or_else(|| generic_inner_type(ty, "Vec"))
|
.or_else(|| generic_inner_type(ty, "Vec"))
|
||||||
@@ -431,7 +523,11 @@ fn form_fields(path: &PathBuf, form_name: &str) -> Vec<GeneratedFormField> {
|
|||||||
|
|
||||||
fn is_type_named(ty: &Type, name: &str) -> bool {
|
fn is_type_named(ty: &Type, name: &str) -> bool {
|
||||||
match ty {
|
match ty {
|
||||||
Type::Path(path) => path.path.segments.last().is_some_and(|segment| segment.ident == name),
|
Type::Path(path) => path
|
||||||
|
.path
|
||||||
|
.segments
|
||||||
|
.last()
|
||||||
|
.is_some_and(|segment| segment.ident == name),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,19 +593,235 @@ fn handler_arg_names(function: &ItemFn) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn missing_component_handlers(path: &PathBuf, component: Option<&str>, items: &[Item]) -> Vec<String> {
|
fn add_app_registry_helper(mut function: ItemFn, components: Vec<Path>) -> Result<ItemFn, String> {
|
||||||
|
if components.is_empty() {
|
||||||
|
return Err("#[slhx::app] requires component registry module(s), for example #[slhx::app(todo_handlers, auth_handlers)]".to_owned());
|
||||||
|
}
|
||||||
|
let Some(state) = function.sig.inputs.iter().find_map(|arg| match arg {
|
||||||
|
FnArg::Typed(arg) => match arg.pat.as_ref() {
|
||||||
|
Pat::Ident(ident) => Some(ident.ident.clone()),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
FnArg::Receiver(_) => None,
|
||||||
|
}) else {
|
||||||
|
return Err(
|
||||||
|
"#[slhx::app] must be used on a registry function with a named app state argument"
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let body = function.block;
|
||||||
|
function.block = syn::parse2(quote!({
|
||||||
|
let __slhx_registry = (|| #body)();
|
||||||
|
#(
|
||||||
|
let __slhx_registry = #components::register_with_state(
|
||||||
|
__slhx_registry,
|
||||||
|
::slhx_axum::State(#state.clone()),
|
||||||
|
);
|
||||||
|
)*
|
||||||
|
__slhx_registry
|
||||||
|
}))
|
||||||
|
.expect("generated app registry helper parses");
|
||||||
|
Ok(function)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComponentHandler {
|
||||||
|
ident: syn::Ident,
|
||||||
|
is_async: bool,
|
||||||
|
returns_result: bool,
|
||||||
|
typed_arg_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMod {
|
||||||
|
let Some((_, items)) = &mut module.content else {
|
||||||
|
return module;
|
||||||
|
};
|
||||||
|
if items.iter().any(|item| match item {
|
||||||
|
Item::Fn(function) => {
|
||||||
|
function.sig.ident == "register" || function.sig.ident == "register_with_state"
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}) {
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
let component_ident = format_ident!("{}", component);
|
||||||
|
let handlers = component_handler_idents(items);
|
||||||
|
let Some(state_ty) = component_state_type(items) else {
|
||||||
|
return module;
|
||||||
|
};
|
||||||
|
if handlers.is_empty() {
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
let calls = handlers.iter().map(|handler| {
|
||||||
|
let ident = &handler.ident;
|
||||||
|
if handler.typed_arg_count == 1 && handler.is_async {
|
||||||
|
quote!(.on_state_async(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.typed_arg_count == 1 {
|
||||||
|
quote!(.on_state(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.is_async && handler.returns_result {
|
||||||
|
quote!(.on_async_result(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.is_async {
|
||||||
|
quote!(.on_async(super::#component_ident::#ident, #ident))
|
||||||
|
} else {
|
||||||
|
quote!(.on(super::#component_ident::#ident, #ident))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let register: Item = syn::parse2(quote! {
|
||||||
|
pub fn register(
|
||||||
|
registry: ::slhx_axum::StateHandlerRegistry<#state_ty>,
|
||||||
|
) -> ::slhx_axum::StateHandlerRegistry<#state_ty>
|
||||||
|
where
|
||||||
|
#state_ty: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
registry #(#calls)*
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("generated component register helper parses");
|
||||||
|
let calls = handlers.iter().map(|handler| {
|
||||||
|
let ident = &handler.ident;
|
||||||
|
if handler.typed_arg_count == 1 && handler.is_async {
|
||||||
|
quote!(.on_state_async(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.typed_arg_count == 1 {
|
||||||
|
quote!(.on_state(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.is_async && handler.returns_result {
|
||||||
|
quote!(.on_async_result(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.is_async {
|
||||||
|
quote!(.on_async(super::#component_ident::#ident, #ident))
|
||||||
|
} else {
|
||||||
|
quote!(.on(super::#component_ident::#ident, #ident))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let register_with_state: Item = syn::parse2(quote! {
|
||||||
|
pub fn register_with_state(
|
||||||
|
registry: ::slhx_axum::HandlerRegistry,
|
||||||
|
state: #state_ty,
|
||||||
|
) -> ::slhx_axum::HandlerRegistry
|
||||||
|
where
|
||||||
|
#state_ty: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
registry
|
||||||
|
.with_state(state)
|
||||||
|
#(#calls)*
|
||||||
|
.into_registry()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("generated component state register helper parses");
|
||||||
|
items.push(register);
|
||||||
|
items.push(register_with_state);
|
||||||
|
module
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_contract_errors(
|
||||||
|
path: &PathBuf,
|
||||||
|
component: Option<&str>,
|
||||||
|
items: &[Item],
|
||||||
|
) -> Vec<String> {
|
||||||
|
let generated = syms_handles(path, component);
|
||||||
let implemented = component_handler_names(items);
|
let implemented = component_handler_names(items);
|
||||||
syms_handles(path, component)
|
let mut errors = Vec::new();
|
||||||
.into_iter()
|
|
||||||
|
if component.is_some() && !implemented.is_empty() && generated.is_empty() {
|
||||||
|
errors.push(format!(
|
||||||
|
"#[slhx::component({:?})] does not match any generated handles; check the .heml file name or component name",
|
||||||
|
component.unwrap()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ambiguous = duplicate_names(&generated);
|
||||||
|
if !ambiguous.is_empty() {
|
||||||
|
errors.push(format!(
|
||||||
|
"#[slhx::component] ambiguous generated handle name(s): {}; make handle names unique for this component before generated registration",
|
||||||
|
ambiguous.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let missing = generated
|
||||||
|
.iter()
|
||||||
.filter(|handle| !implemented.contains(handle))
|
.filter(|handle| !implemented.contains(handle))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
errors.push(format!(
|
||||||
|
"#[slhx::component] missing handler implementation(s): {}",
|
||||||
|
missing.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let extras = implemented
|
||||||
|
.iter()
|
||||||
|
.filter(|handler| !generated.contains(handler))
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !extras.is_empty() {
|
||||||
|
errors.push(format!(
|
||||||
|
"#[slhx::component] handler(s) not declared by this component's generated handles: {}; move them to the matching component module or add data-slhx-handle in .heml",
|
||||||
|
extras.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn missing_component_handlers(
|
||||||
|
path: &PathBuf,
|
||||||
|
component: Option<&str>,
|
||||||
|
items: &[Item],
|
||||||
|
) -> Vec<String> {
|
||||||
|
component_contract_errors(path, component, items)
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|error| {
|
||||||
|
error
|
||||||
|
.strip_prefix("#[slhx::component] missing handler implementation(s): ")
|
||||||
|
.map(|missing| missing.split(", ").map(ToOwned::to_owned).collect())
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duplicate_names(names: &[String]) -> Vec<String> {
|
||||||
|
let mut counts = std::collections::BTreeMap::<&str, usize>::new();
|
||||||
|
for name in names {
|
||||||
|
*counts.entry(name.as_str()).or_default() += 1;
|
||||||
|
}
|
||||||
|
counts
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(name, count)| (count > 1).then(|| name.to_owned()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn component_state_type(items: &[Item]) -> Option<Type> {
|
||||||
|
items.iter().find_map(|item| match item {
|
||||||
|
Item::Fn(function) if has_handler_attr(function) => {
|
||||||
|
function.sig.inputs.iter().find_map(|arg| match arg {
|
||||||
|
FnArg::Typed(arg) => Some((*arg.ty).clone()),
|
||||||
|
FnArg::Receiver(_) => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn component_handler_names(items: &[Item]) -> Vec<String> {
|
fn component_handler_names(items: &[Item]) -> Vec<String> {
|
||||||
|
component_handler_idents(items)
|
||||||
|
.into_iter()
|
||||||
|
.map(|handler| handler.ident.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_handler_idents(items: &[Item]) -> Vec<ComponentHandler> {
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| match item {
|
.filter_map(|item| match item {
|
||||||
Item::Fn(function) if has_handler_attr(function) => Some(function.sig.ident.to_string()),
|
Item::Fn(function) if has_handler_attr(function) => Some(ComponentHandler {
|
||||||
|
ident: function.sig.ident.clone(),
|
||||||
|
is_async: function.sig.asyncness.is_some(),
|
||||||
|
returns_result: returns_result(&function.sig.output),
|
||||||
|
typed_arg_count: function
|
||||||
|
.sig
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.filter(|arg| matches!(arg, FnArg::Typed(_)))
|
||||||
|
.count(),
|
||||||
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -560,7 +872,11 @@ fn compile_error(message: &str) -> TokenStream {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{component_handler_names, handle_params, handle_requires_form, has_form_param, has_non_unit_return, is_form_type, missing_component_handlers, missing_handle_params, parser_type, syms_contains_handle};
|
use super::{
|
||||||
|
add_app_registry_helper, add_component_register_helper, component_handler_names,
|
||||||
|
form_model_type, handle_params, handle_requires_form, has_form_param, has_non_unit_return,
|
||||||
|
missing_component_handlers, missing_handle_params, parser_type, syms_contains_handle,
|
||||||
|
};
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::{parse_quote, ItemFn, Type};
|
use syn::{parse_quote, ItemFn, Type};
|
||||||
|
|
||||||
@@ -586,9 +902,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn handler_shape_accepts_form_or_effect_return() {
|
fn handler_shape_accepts_form_or_effect_return() {
|
||||||
// req: derive_handler/001
|
// req: derive_handler/001
|
||||||
let with_form = parse_quote!(fn save(form: slhx::Form<String>) {});
|
let with_form = parse_quote!(
|
||||||
let with_return = parse_quote!(fn ping() -> impl slhx::IntoEffect { slhx::EffectBatch::default() });
|
fn save(form: slhx::Form<String>) {}
|
||||||
let empty = parse_quote!(fn noop() {});
|
);
|
||||||
|
let with_return = parse_quote!(
|
||||||
|
fn ping() -> impl slhx::IntoEffect {
|
||||||
|
slhx::advanced::EffectBatch::default()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let empty = parse_quote!(
|
||||||
|
fn noop() {}
|
||||||
|
);
|
||||||
|
|
||||||
assert!(has_form_param(&with_form));
|
assert!(has_form_param(&with_form));
|
||||||
assert!(has_non_unit_return(&with_return));
|
assert!(has_non_unit_return(&with_return));
|
||||||
@@ -607,9 +931,18 @@ mod tests {
|
|||||||
let optional_parser = parser_type(&optional);
|
let optional_parser = parser_type(&optional);
|
||||||
let multiple_parser = parser_type(&multiple);
|
let multiple_parser = parser_type(&multiple);
|
||||||
|
|
||||||
assert_eq!(quote!(#required_parser).to_string(), quote!(#required).to_string());
|
assert_eq!(
|
||||||
assert_eq!(quote!(#optional_parser).to_string(), quote!(#required).to_string());
|
quote!(#required_parser).to_string(),
|
||||||
assert_eq!(quote!(#multiple_parser).to_string(), quote!(#required).to_string());
|
quote!(#required).to_string()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
quote!(#optional_parser).to_string(),
|
||||||
|
quote!(#required).to_string()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
quote!(#multiple_parser).to_string(),
|
||||||
|
quote!(#required).to_string()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -621,20 +954,31 @@ mod tests {
|
|||||||
let nongeneric_impostor: Type = parse_quote!(Form);
|
let nongeneric_impostor: Type = parse_quote!(Form);
|
||||||
let foreign_form: Type = parse_quote!(other::Form<CreateTodo>);
|
let foreign_form: Type = parse_quote!(other::Form<CreateTodo>);
|
||||||
|
|
||||||
assert!(is_form_type(&qualified_form));
|
assert!(form_model_type(&qualified_form).is_some());
|
||||||
assert!(is_form_type(&imported_form));
|
assert!(form_model_type(&imported_form).is_some());
|
||||||
assert!(!is_form_type(&name_suffix_impostor));
|
assert!(form_model_type(&name_suffix_impostor).is_none());
|
||||||
assert!(!is_form_type(&nongeneric_impostor));
|
assert!(form_model_type(&nongeneric_impostor).is_none());
|
||||||
assert!(!is_form_type(&foreign_form));
|
assert!(form_model_type(&foreign_form).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handler_params_match_generated_param_names() {
|
fn handler_params_match_generated_param_names() {
|
||||||
let path = std::env::temp_dir().join("slhx-derive-param-test.syms");
|
let path = std::env::temp_dir().join("slhx-derive-param-test.syms");
|
||||||
std::fs::write(&path, "slhx-syms-v1\nhandle_param\tshow\ttodo_id\nhandle_param\tshow\tmode\n")
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
"slhx-syms-v1\nhandle_param\tshow\ttodo_id\nhandle_param\tshow\tmode\n",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let complete: ItemFn = parse_quote!(fn show(todo_id: String, mode: String) -> impl slhx::IntoEffect { slhx::EffectBatch::default() });
|
let complete: ItemFn = parse_quote!(
|
||||||
let missing: ItemFn = parse_quote!(fn show(todo_id: String) -> impl slhx::IntoEffect { slhx::EffectBatch::default() });
|
fn show(todo_id: String, mode: String) -> impl slhx::IntoEffect {
|
||||||
|
slhx::advanced::EffectBatch::default()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let missing: ItemFn = parse_quote!(
|
||||||
|
fn show(todo_id: String) -> impl slhx::IntoEffect {
|
||||||
|
slhx::advanced::EffectBatch::default()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
assert!(missing_handle_params(&path, "show", &complete).is_empty());
|
assert!(missing_handle_params(&path, "show", &complete).is_empty());
|
||||||
assert_eq!(missing_handle_params(&path, "show", &missing), vec!["mode"]);
|
assert_eq!(missing_handle_params(&path, "show", &missing), vec!["mode"]);
|
||||||
@@ -653,15 +997,84 @@ mod tests {
|
|||||||
let module: syn::ItemMod = parse_quote! {
|
let module: syn::ItemMod = parse_quote! {
|
||||||
mod component {
|
mod component {
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
fn create() -> impl slhx::IntoEffect { slhx::EffectBatch::default() }
|
fn create() -> impl slhx::IntoEffect { slhx::advanced::EffectBatch::default() }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let (_, items) = module.content.expect("inline module");
|
let (_, items) = module.content.expect("inline module");
|
||||||
|
|
||||||
assert_eq!(component_handler_names(&items), vec!["create"]);
|
assert_eq!(component_handler_names(&items), vec!["create"]);
|
||||||
assert_eq!(missing_component_handlers(&path, None, &items), vec!["delete", "archive"]);
|
assert_eq!(
|
||||||
assert_eq!(missing_component_handlers(&path, Some("a"), &items), vec!["delete"]);
|
missing_component_handlers(&path, None, &items),
|
||||||
|
vec!["delete", "archive"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
missing_component_handlers(&path, Some("a"), &items),
|
||||||
|
vec!["delete"]
|
||||||
|
);
|
||||||
|
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_macro_generates_single_registry_entry_point() {
|
||||||
|
// req: derive_app/001 req: component/003
|
||||||
|
let function = parse_quote! {
|
||||||
|
fn registry(state: std::sync::Arc<App>) -> slhx_axum::HandlerRegistry {
|
||||||
|
slhx_axum::interactions(ui::BUILD_FINGERPRINT)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let function = add_app_registry_helper(
|
||||||
|
function,
|
||||||
|
vec![parse_quote!(counter_handlers), parse_quote!(todo_handlers)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let generated = quote!(#function).to_string();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
generated.contains("counter_handlers :: register_with_state"),
|
||||||
|
"{generated}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
generated.contains("todo_handlers :: register_with_state"),
|
||||||
|
"{generated}"
|
||||||
|
);
|
||||||
|
assert!(generated.contains("slhx_axum :: State"), "{generated}");
|
||||||
|
assert!(generated.contains("state . clone"), "{generated}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_macro_generates_registration_helpers() {
|
||||||
|
// req: component/003 req: derive_handler/003
|
||||||
|
let module = parse_quote! {
|
||||||
|
mod handlers {
|
||||||
|
#[slhx::handler]
|
||||||
|
fn create(app: super::App, form: super::NewTodo) -> impl slhx::IntoEffect {
|
||||||
|
slhx::EventName::new("created").emit("")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
async fn increment(app: super::App) -> impl slhx::IntoEffect {
|
||||||
|
slhx::EventName::new("incremented").emit("")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
async fn save(app: super::App, form: super::NewTodo) -> Result<impl slhx::IntoEffect, super::Error> {
|
||||||
|
Ok(slhx::EventName::new("saved").emit(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let module = add_component_register_helper(module, "todos");
|
||||||
|
let generated = quote!(#module).to_string();
|
||||||
|
|
||||||
|
assert!(generated.contains("register_with_state"), "{generated}");
|
||||||
|
assert!(generated.contains("StateHandlerRegistry"), "{generated}");
|
||||||
|
assert!(
|
||||||
|
generated.contains("super :: todos :: create"),
|
||||||
|
"{generated}"
|
||||||
|
);
|
||||||
|
assert!(generated.contains(". on"), "{generated}");
|
||||||
|
assert!(generated.contains(". on_state_async"), "{generated}");
|
||||||
|
assert!(generated.contains(". on_async_result"), "{generated}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+238
-173
@@ -40,7 +40,7 @@ slhx = {{ path = {:?} }}
|
|||||||
mod todos {
|
mod todos {
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
fn create() -> impl slhx::IntoEffect {
|
fn create() -> impl slhx::IntoEffect {
|
||||||
slhx::EffectBatch::default()
|
slhx::advanced::EffectBatch::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"#,
|
"#,
|
||||||
@@ -95,7 +95,7 @@ slhx = {{ path = {:?} }}
|
|||||||
mod todos {
|
mod todos {
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
fn create() -> impl slhx::IntoEffect {
|
fn create() -> impl slhx::IntoEffect {
|
||||||
slhx::event("created", "")
|
slhx::EventName::new("created").emit("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"#,
|
"#,
|
||||||
@@ -110,6 +110,176 @@ mod todos {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_macro_rejects_handlers_outside_scoped_component() {
|
||||||
|
// req: component/003 req: component/005 req: test/003
|
||||||
|
let fixture = Fixture::new("slhx-derive-component-extra-handler-fail");
|
||||||
|
fixture.write(
|
||||||
|
"Cargo.toml",
|
||||||
|
&format!(
|
||||||
|
r#"[package]
|
||||||
|
name = "slhx-derive-component-extra-handler-fail"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
slhx = {{ path = {:?} }}
|
||||||
|
"#,
|
||||||
|
repo_path("slhx")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fixture.write(
|
||||||
|
"build.rs",
|
||||||
|
r#"fn main() {
|
||||||
|
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||||
|
std::fs::write(
|
||||||
|
out.join("slhx.syms"),
|
||||||
|
"slhx-syms-v1\nhandle\ttemplates/todos.heml::create\tcreate\t1\nhandle\ttemplates/admin.heml::delete\tdelete\t2\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
fixture.write(
|
||||||
|
"src/lib.rs",
|
||||||
|
r#"#[slhx::component("todos")]
|
||||||
|
mod handlers {
|
||||||
|
#[slhx::handler]
|
||||||
|
fn create() -> impl slhx::IntoEffect {
|
||||||
|
slhx::EventName::new("created").emit("")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
fn delete() -> impl slhx::IntoEffect {
|
||||||
|
slhx::EventName::new("deleted").emit("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = check_fixture(&fixture);
|
||||||
|
|
||||||
|
assert!(!output.status.success(), "fixture unexpectedly compiled");
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("handler(s) not declared by this component's generated handles: delete"),
|
||||||
|
"missing scoped extra-handler diagnostic in stderr:\n{stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_macro_rejects_unknown_scoped_component() {
|
||||||
|
// req: component/003 req: component/005 req: test/003
|
||||||
|
let fixture = Fixture::new("slhx-derive-component-unknown-scope-fail");
|
||||||
|
fixture.write(
|
||||||
|
"Cargo.toml",
|
||||||
|
&format!(
|
||||||
|
r#"[package]
|
||||||
|
name = "slhx-derive-component-unknown-scope-fail"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
slhx = {{ path = {:?} }}
|
||||||
|
"#,
|
||||||
|
repo_path("slhx")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fixture.write(
|
||||||
|
"build.rs",
|
||||||
|
r#"fn main() {
|
||||||
|
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||||
|
std::fs::write(
|
||||||
|
out.join("slhx.syms"),
|
||||||
|
"slhx-syms-v1\nhandle\ttemplates/admin.heml::create\tcreate\t1\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
fixture.write(
|
||||||
|
"src/lib.rs",
|
||||||
|
r#"#[slhx::component("todos")]
|
||||||
|
mod handlers {
|
||||||
|
#[slhx::handler]
|
||||||
|
fn create() -> impl slhx::IntoEffect {
|
||||||
|
slhx::EventName::new("created").emit("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = check_fixture(&fixture);
|
||||||
|
|
||||||
|
assert!(!output.status.success(), "fixture unexpectedly compiled");
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("#[slhx::component(\"todos\")] does not match any generated handles"),
|
||||||
|
"missing unknown component diagnostic in stderr:\n{stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_macro_rejects_ambiguous_generated_handles() {
|
||||||
|
// req: component/003 req: component/005 req: test/003
|
||||||
|
let fixture = Fixture::new("slhx-derive-component-ambiguous-handle-fail");
|
||||||
|
fixture.write(
|
||||||
|
"Cargo.toml",
|
||||||
|
&format!(
|
||||||
|
r#"[package]
|
||||||
|
name = "slhx-derive-component-ambiguous-handle-fail"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
slhx = {{ path = {:?} }}
|
||||||
|
"#,
|
||||||
|
repo_path("slhx")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fixture.write(
|
||||||
|
"build.rs",
|
||||||
|
r#"fn main() {
|
||||||
|
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||||
|
std::fs::write(
|
||||||
|
out.join("slhx.syms"),
|
||||||
|
"slhx-syms-v1\nhandle\ttemplates/todos.heml::save\tsave\t1\nhandle\ttemplates/todos.heml::section::save\tsave\t2\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
fixture.write(
|
||||||
|
"src/lib.rs",
|
||||||
|
r#"#[slhx::component("todos")]
|
||||||
|
mod handlers {
|
||||||
|
#[slhx::handler]
|
||||||
|
fn save() -> impl slhx::IntoEffect {
|
||||||
|
slhx::EventName::new("saved").emit("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = check_fixture(&fixture);
|
||||||
|
|
||||||
|
assert!(!output.status.success(), "fixture unexpectedly compiled");
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("ambiguous generated handle name(s): save"),
|
||||||
|
"missing ambiguous-handle diagnostic in stderr:\n{stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handler_macro_reports_unknown_handle_and_bad_shape() {
|
fn handler_macro_reports_unknown_handle_and_bad_shape() {
|
||||||
// req: derive_handler/001 req: test/003
|
// req: derive_handler/001 req: test/003
|
||||||
@@ -147,7 +317,7 @@ slhx = {{ path = {:?} }}
|
|||||||
"src/lib.rs",
|
"src/lib.rs",
|
||||||
r#"#[slhx::handler]
|
r#"#[slhx::handler]
|
||||||
fn missing() -> impl slhx::IntoEffect {
|
fn missing() -> impl slhx::IntoEffect {
|
||||||
slhx::EffectBatch::default()
|
slhx::advanced::EffectBatch::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
@@ -201,7 +371,7 @@ slhx = {{ path = {:?} }}
|
|||||||
"src/lib.rs",
|
"src/lib.rs",
|
||||||
r#"#[slhx::handler]
|
r#"#[slhx::handler]
|
||||||
fn create() -> impl slhx::IntoEffect {
|
fn create() -> impl slhx::IntoEffect {
|
||||||
slhx::EffectBatch::default()
|
slhx::advanced::EffectBatch::default()
|
||||||
}
|
}
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
@@ -261,7 +431,7 @@ slhx = {{ path = {:?} }}
|
|||||||
"src/lib.rs",
|
"src/lib.rs",
|
||||||
r#"#[slhx::handler]
|
r#"#[slhx::handler]
|
||||||
fn show(todo_id: String) -> impl slhx::IntoEffect {
|
fn show(todo_id: String) -> impl slhx::IntoEffect {
|
||||||
slhx::EffectBatch::default()
|
slhx::advanced::EffectBatch::default()
|
||||||
}
|
}
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
@@ -479,7 +649,7 @@ struct CreateTodo {
|
|||||||
|
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
|
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
|
||||||
slhx::event("created", "")
|
slhx::EventName::new("created").emit("")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn smoke() {
|
fn smoke() {
|
||||||
@@ -497,62 +667,6 @@ fn smoke() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn form_handle_requires_checked_form_model() {
|
|
||||||
// req: form/001 req: form/004 req: form/006 req: test/003
|
|
||||||
let fixture = Fixture::new("slhx-derive-form-handler-unchecked-model-fail");
|
|
||||||
fixture.write(
|
|
||||||
"Cargo.toml",
|
|
||||||
&format!(
|
|
||||||
r#"[package]
|
|
||||||
name = "slhx-derive-form-handler-unchecked-model-fail"
|
|
||||||
version = "0.0.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
slhx = {{ path = {:?} }}
|
|
||||||
"#,
|
|
||||||
repo_path("slhx")
|
|
||||||
),
|
|
||||||
);
|
|
||||||
fixture.write(
|
|
||||||
"build.rs",
|
|
||||||
r#"fn main() {
|
|
||||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
|
||||||
std::fs::write(
|
|
||||||
out.join("slhx.syms"),
|
|
||||||
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
fixture.write(
|
|
||||||
"src/lib.rs",
|
|
||||||
r#"struct CreateTodo {
|
|
||||||
title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[slhx::handler]
|
|
||||||
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
|
|
||||||
slhx::event("created", "")
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = check_fixture(&fixture);
|
|
||||||
|
|
||||||
assert!(!output.status.success(), "fixture unexpectedly compiled");
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
assert!(
|
|
||||||
stderr.contains("CreateTodo: FormModel") || stderr.contains("CreateTodo: slhx::FormModel"),
|
|
||||||
"missing checked form model diagnostic in stderr:\n{stderr}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn form_handle_requires_form_parameter() {
|
fn form_handle_requires_form_parameter() {
|
||||||
// req: form/004 req: form/006 req: test/003
|
// req: form/004 req: form/006 req: test/003
|
||||||
@@ -590,7 +704,7 @@ slhx = {{ path = {:?} }}
|
|||||||
"src/lib.rs",
|
"src/lib.rs",
|
||||||
r#"#[slhx::handler]
|
r#"#[slhx::handler]
|
||||||
fn create() -> impl slhx::IntoEffect {
|
fn create() -> impl slhx::IntoEffect {
|
||||||
slhx::EffectBatch::default()
|
slhx::advanced::EffectBatch::default()
|
||||||
}
|
}
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
@@ -600,11 +714,70 @@ fn create() -> impl slhx::IntoEffect {
|
|||||||
assert!(!output.status.success(), "fixture unexpectedly compiled");
|
assert!(!output.status.success(), "fixture unexpectedly compiled");
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
assert!(
|
assert!(
|
||||||
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
|
stderr.contains(
|
||||||
|
"slhx handler `create` handles a generated form and must accept a typed form argument"
|
||||||
|
),
|
||||||
"missing form-handler diagnostic in stderr:\n{stderr}"
|
"missing form-handler diagnostic in stderr:\n{stderr}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn form_handle_rejects_state_only_handler() {
|
||||||
|
// req: form/004 req: form/006 req: test/003
|
||||||
|
let fixture = Fixture::new("slhx-derive-form-handler-state-only-fail");
|
||||||
|
fixture.write(
|
||||||
|
"Cargo.toml",
|
||||||
|
&format!(
|
||||||
|
r#"[package]
|
||||||
|
name = "slhx-derive-form-handler-state-only-fail"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
slhx = {{ path = {:?} }}
|
||||||
|
"#,
|
||||||
|
repo_path("slhx")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
fixture.write(
|
||||||
|
"build.rs",
|
||||||
|
r#"fn main() {
|
||||||
|
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||||
|
std::fs::write(
|
||||||
|
out.join("slhx.syms"),
|
||||||
|
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
fixture.write(
|
||||||
|
"src/lib.rs",
|
||||||
|
r#"struct App;
|
||||||
|
struct State<T>(T);
|
||||||
|
|
||||||
|
#[slhx::handler]
|
||||||
|
fn create(_state: State<App>) -> impl slhx::IntoEffect {
|
||||||
|
slhx::advanced::EffectBatch::default()
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = check_fixture(&fixture);
|
||||||
|
|
||||||
|
assert!(!output.status.success(), "fixture unexpectedly compiled");
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains(
|
||||||
|
"slhx handler `create` handles a generated form and must accept a typed form argument"
|
||||||
|
),
|
||||||
|
"missing state-only form-handler diagnostic in stderr:\n{stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn form_handle_still_requires_generated_param_arguments() {
|
fn form_handle_still_requires_generated_param_arguments() {
|
||||||
// req: form/006 req: derive_handler/003 req: test/003
|
// req: form/006 req: derive_handler/003 req: test/003
|
||||||
@@ -644,7 +817,7 @@ slhx = {{ path = {:?} }}
|
|||||||
|
|
||||||
#[slhx::handler]
|
#[slhx::handler]
|
||||||
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
|
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
|
||||||
slhx::EffectBatch::default()
|
slhx::advanced::EffectBatch::default()
|
||||||
}
|
}
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
@@ -659,114 +832,6 @@ fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn form_handle_rejects_nongeneric_form_impostor() {
|
|
||||||
// req: form/004 req: form/006 req: test/003
|
|
||||||
let fixture = Fixture::new("slhx-derive-form-handler-nongeneric-impostor-fail");
|
|
||||||
fixture.write(
|
|
||||||
"Cargo.toml",
|
|
||||||
&format!(
|
|
||||||
r#"[package]
|
|
||||||
name = "slhx-derive-form-handler-nongeneric-impostor-fail"
|
|
||||||
version = "0.0.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
slhx = {{ path = {:?} }}
|
|
||||||
"#,
|
|
||||||
repo_path("slhx")
|
|
||||||
),
|
|
||||||
);
|
|
||||||
fixture.write(
|
|
||||||
"build.rs",
|
|
||||||
r#"fn main() {
|
|
||||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
|
||||||
std::fs::write(
|
|
||||||
out.join("slhx.syms"),
|
|
||||||
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
fixture.write(
|
|
||||||
"src/lib.rs",
|
|
||||||
r#"struct Form;
|
|
||||||
|
|
||||||
#[slhx::handler]
|
|
||||||
fn create(_form: Form) -> impl slhx::IntoEffect {
|
|
||||||
slhx::EffectBatch::default()
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = check_fixture(&fixture);
|
|
||||||
|
|
||||||
assert!(!output.status.success(), "fixture unexpectedly compiled");
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
assert!(
|
|
||||||
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
|
|
||||||
"missing nongeneric form diagnostic in stderr:\n{stderr}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn form_handle_rejects_form_name_suffix_impostor() {
|
|
||||||
// req: form/004 req: form/006 req: test/003
|
|
||||||
let fixture = Fixture::new("slhx-derive-form-impostor-fail");
|
|
||||||
fixture.write(
|
|
||||||
"Cargo.toml",
|
|
||||||
&format!(
|
|
||||||
r#"[package]
|
|
||||||
name = "slhx-derive-form-impostor-fail"
|
|
||||||
version = "0.0.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
slhx = {{ path = {:?} }}
|
|
||||||
"#,
|
|
||||||
repo_path("slhx")
|
|
||||||
),
|
|
||||||
);
|
|
||||||
fixture.write(
|
|
||||||
"build.rs",
|
|
||||||
r#"fn main() {
|
|
||||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
|
||||||
std::fs::write(
|
|
||||||
out.join("slhx.syms"),
|
|
||||||
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
fixture.write(
|
|
||||||
"src/lib.rs",
|
|
||||||
r#"struct CreateForm;
|
|
||||||
|
|
||||||
#[slhx::handler]
|
|
||||||
fn create(_form: CreateForm) -> impl slhx::IntoEffect {
|
|
||||||
slhx::EffectBatch::default()
|
|
||||||
}
|
|
||||||
"#,
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = check_fixture(&fixture);
|
|
||||||
|
|
||||||
assert!(!output.status.success(), "fixture unexpectedly compiled");
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
assert!(
|
|
||||||
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
|
|
||||||
"missing form-impostor diagnostic in stderr:\n{stderr}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn surface_macro_reports_missing_generated_include() {
|
fn surface_macro_reports_missing_generated_include() {
|
||||||
// req: build/004 req: test/003
|
// req: build/004 req: test/003
|
||||||
|
|||||||
+22
-13
@@ -134,9 +134,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formDataFor(el, eventName) {
|
function formDataFor(el, eventName, source = el) {
|
||||||
const form = formOwner(el);
|
const form = formOwner(el);
|
||||||
const data = form ? new FormData(form) : new FormData();
|
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);
|
const id = handleId(el) || formHandleId(form);
|
||||||
if (id && !data.has("__h")) data.set("__h", id);
|
if (id && !data.has("__h")) data.set("__h", id);
|
||||||
const dragKey = eventName === "drop" && dragKeys.get(rootOf(el));
|
const dragKey = eventName === "drop" && dragKeys.get(rootOf(el));
|
||||||
@@ -168,9 +169,9 @@
|
|||||||
return url.href;
|
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;
|
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 target = form || el;
|
||||||
const policy = requestPolicy(target, eventName);
|
const policy = requestPolicy(target, eventName);
|
||||||
const active = pending.get(target);
|
const active = pending.get(target);
|
||||||
@@ -182,7 +183,7 @@
|
|||||||
if (active && policy === "queue") {
|
if (active && policy === "queue") {
|
||||||
const base = queues.get(target) || active.done;
|
const base = queues.get(target) || active.done;
|
||||||
let queued;
|
let queued;
|
||||||
const next = base.then(() => send(el, eventName));
|
const next = base.then(() => send(el, eventName, source));
|
||||||
queued = next.catch(() => {}).finally(() => {
|
queued = next.catch(() => {}).finally(() => {
|
||||||
if (queues.get(target) === queued) queues.delete(target);
|
if (queues.get(target) === queued) queues.delete(target);
|
||||||
});
|
});
|
||||||
@@ -309,11 +310,12 @@
|
|||||||
}
|
}
|
||||||
const target = targetFor(scope, op.target);
|
const target = targetFor(scope, op.target);
|
||||||
if (!target) return missing(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") {
|
} else if (op.kind === "insert" || op.kind === "prepend") {
|
||||||
const target = targetFor(scope, op.target);
|
const target = targetFor(scope, op.target);
|
||||||
if (!target) return missing(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);
|
target[op.kind === "prepend" ? "prepend" : "append"](...nodes);
|
||||||
} else if (op.kind === "remove") {
|
} else if (op.kind === "remove") {
|
||||||
const target = op.key ? keyedTarget(scope, op.target.resource.id, op.key) : targetFor(scope, op.target);
|
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;
|
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");
|
const template = document.createElement("template");
|
||||||
if (payload.kind === "html") template.innerHTML = payload.value;
|
if (payload.kind === "html") template.innerHTML = payload.value;
|
||||||
else template.textContent = payload.value;
|
else template.textContent = payload.value;
|
||||||
const nodes = Array.from(template.content.childNodes);
|
const nodes = Array.from(template.content.childNodes);
|
||||||
const firstElement = nodes.find((node) => node.nodeType === 1);
|
const firstElement = nodes.find((node) => node.nodeType === 1);
|
||||||
if (firstElement && key != null && !firstElement.hasAttribute("data-key")) firstElement.setAttribute("data-key", key);
|
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;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +598,7 @@
|
|||||||
if (form && root.contains(form) && formHandleId(form)) {
|
if (form && root.contains(form) && formHandleId(form)) {
|
||||||
if (form.reportValidity && !form.reportValidity()) return;
|
if (form.reportValidity && !form.reportValidity()) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
schedule(form, "submit");
|
schedule(form, "submit", submitter);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -600,23 +609,23 @@
|
|||||||
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target;
|
if (name === "submit" && !el && event.target && event.target.tagName === "FORM" && formHandleId(event.target)) el = event.target;
|
||||||
if (!el || defaultEvent(el) !== name) return;
|
if (!el || defaultEvent(el) !== name) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
schedule(el, name);
|
schedule(el, name, event.submitter || el);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
bindPolling(root);
|
bindPolling(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedule(el, eventName) {
|
function schedule(el, eventName, source = el) {
|
||||||
const debounce = duration(el.getAttribute("data-slhx-debounce"));
|
const debounce = duration(el.getAttribute("data-slhx-debounce"));
|
||||||
const throttle = duration(el.getAttribute("data-slhx-throttle"));
|
const throttle = duration(el.getAttribute("data-slhx-throttle"));
|
||||||
if (debounce) {
|
if (debounce) {
|
||||||
clearTimeout(timers.get(el));
|
clearTimeout(timers.get(el));
|
||||||
timers.set(el, setTimeout(() => send(el, eventName), debounce));
|
timers.set(el, setTimeout(() => send(el, eventName, source), debounce));
|
||||||
} else if (throttle) {
|
} else if (throttle) {
|
||||||
if (timers.get(el)) return;
|
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 {
|
} 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
|
// req: multipart/003
|
||||||
let source = slhx_js::RUNTIME_JS;
|
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("if (value instanceof File) continue"));
|
||||||
assert!(source.contains("return multipart ? data : urlEncoded(data)"));
|
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\""));
|
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 firstElement(scope, predicate)"));
|
||||||
assert!(source.contains("function generatedResource(el, id)"));
|
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("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("const target = generatedTarget(scope, id)"));
|
||||||
assert!(source.contains("if (!target) return missing(scope, op.target)"));
|
assert!(source.contains("if (!target) return missing(scope, op.target)"));
|
||||||
assert!(!source.contains("data-slhx-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("const everyTimers = new WeakMap()"));
|
||||||
assert!(source.contains("forEachElement(root, (el) =>"));
|
assert!(source.contains("forEachElement(root, (el) =>"));
|
||||||
assert!(source.contains("!el.hasAttribute(\"data-slhx-every\") || everyTimers.has(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!(
|
||||||
assert!(source.contains("setInterval(() => document.contains(el) ? send(el, \"every\") : stopPolling(el), ms)"));
|
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("function stopPolling(el)"));
|
||||||
assert!(source.contains("clearInterval(everyTimers.get(el))"));
|
assert!(source.contains("clearInterval(everyTimers.get(el))"));
|
||||||
assert!(source.contains("everyTimers.delete(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("indicator.hidden = state.hidden"));
|
||||||
assert!(source.contains("el.hasAttribute(\"data-slhx-disable-while-pending\")"));
|
assert!(source.contains("el.hasAttribute(\"data-slhx-disable-while-pending\")"));
|
||||||
assert!(source.contains("if (isDisableControl(el)) controls.push(el)"));
|
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("const disabledStates = new WeakMap()"));
|
||||||
assert!(source.contains("function toggleDisabled(control, on)"));
|
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("control.disabled = state.disabled"));
|
||||||
assert!(source.contains("controls.forEach((c) => toggleDisabled(c, on))"));
|
assert!(source.contains("controls.forEach((c) => toggleDisabled(c, on))"));
|
||||||
assert!(source.contains("showPending(target, true)"));
|
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 formOwner(el)"));
|
||||||
assert!(source.contains("function formHandleId(form)"));
|
assert!(source.contains("function formHandleId(form)"));
|
||||||
assert!(source.contains("function elementById(scope, id)"));
|
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("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("const submitter = closestInRoot(event.target, root, (el) =>"));
|
||||||
assert!(source.contains("el.tagName === \"BUTTON\" && (!el.hasAttribute(\"type\") || el.getAttribute(\"type\") === \"submit\")"));
|
assert!(source.contains("el.tagName === \"BUTTON\" && (!el.hasAttribute(\"type\") || el.getAttribute(\"type\") === \"submit\")"));
|
||||||
assert!(source.contains("form.reportValidity && !form.reportValidity()"));
|
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]
|
#[test]
|
||||||
@@ -156,7 +179,9 @@ fn runtime_supports_queued_request_policy() {
|
|||||||
|
|
||||||
assert!(source.contains("function normalizedPolicy(value)"));
|
assert!(source.contains("function normalizedPolicy(value)"));
|
||||||
assert!(source.contains("value === \"latest\" || value === \"queue\" || value === \"drop\" || value === \"parallel\""));
|
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("const queues = new WeakMap()"));
|
||||||
assert!(source.contains("policy === \"queue\""));
|
assert!(source.contains("policy === \"queue\""));
|
||||||
assert!(source.contains("const base = queues.get(target) || active.done"));
|
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
|
// req: convention/006 req: convention/007 req: convention/008
|
||||||
let source = slhx_js::RUNTIME_JS;
|
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 pendingClassStates = new WeakMap()"));
|
||||||
assert!(source.contains("const indicatorStates = new WeakMap()"));
|
assert!(source.contains("const indicatorStates = new WeakMap()"));
|
||||||
assert!(source.contains("const disabledStates = 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("if (href.origin !== location.origin)"));
|
||||||
assert!(source.contains("emit(root, \"slhx:sse-error\", url)"));
|
assert!(source.contains("emit(root, \"slhx:sse-error\", url)"));
|
||||||
assert!(source.contains("new EventSource(href.href)"));
|
assert!(source.contains("new EventSource(href.href)"));
|
||||||
assert!(source.contains("source.addEventListener(\"slhx\", (event) => applySseMessage(root, event))"));
|
assert!(source
|
||||||
assert!(source.contains("source.addEventListener(\"message\", (event) => applySseMessage(root, event))"));
|
.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("applyBatch(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), root)"));
|
||||||
assert!(source.contains("emit(root, \"slhx:sse-error\", url)"));
|
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;
|
let source = slhx_js::RUNTIME_JS;
|
||||||
|
|
||||||
assert!(source.contains("function fieldTarget(scope, id, field)"));
|
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("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]
|
#[test]
|
||||||
fn runtime_preflights_batches_before_applying_ops() {
|
fn runtime_preflights_batches_before_applying_ops() {
|
||||||
let source = slhx_js::RUNTIME_JS;
|
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("function canApplyOp(scope, op)"));
|
||||||
assert!(source.contains("for (const op of batch.ops) applyOp(scope, op)"));
|
assert!(source.contains("for (const op of batch.ops) applyOp(scope, op)"));
|
||||||
}
|
}
|
||||||
|
|||||||
+621
-3
@@ -1,4 +1,7 @@
|
|||||||
use slhx_core::{Atom, BuildFingerprint, Effect, EffectBatch, Form, IntoEffect, KeyedSlot, ResourceId, ResourceRef, Slot};
|
use slhx_core::{
|
||||||
|
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, IntoEffect, KeyedSlot,
|
||||||
|
NavigateMode, Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn run<I, F, R>(handler: F, input: I) -> EffectInspector
|
pub fn run<I, F, R>(handler: F, input: I) -> EffectInspector
|
||||||
where
|
where
|
||||||
@@ -9,9 +12,382 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn inspect(effect: impl IntoEffect) -> EffectInspector {
|
pub fn inspect(effect: impl IntoEffect) -> EffectInspector {
|
||||||
EffectInspector {
|
inspect_batch(effect.into_batch(BuildFingerprint(0)))
|
||||||
batch: effect.into_batch(BuildFingerprint(0)),
|
}
|
||||||
|
|
||||||
|
/// Inspect an already-dispatched batch without matching raw effect variants in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn inspect_batch(batch: EffectBatch) -> EffectInspector {
|
||||||
|
EffectInspector { batch }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode and inspect an effect wire response without exposing `EffectBatch` in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn inspect_wire(bytes: &[u8]) -> EffectInspector {
|
||||||
|
inspect_batch(EffectBatch::from_wire(bytes).expect("slhx effect wire response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the resource id behind a generated target for low-level test assertions.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn target_resource(target: impl GeneratedTarget) -> ResourceId {
|
||||||
|
target.__slhx_resource_id()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the unscoped resource reference behind a generated target for low-level test assertions.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn target_ref(target: impl GeneratedTarget) -> ResourceRef {
|
||||||
|
ResourceRef::unscoped(target_resource(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an interaction request body from a generated handle and form fields.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn handle_form_body<I>(handle: slhx_core::Handle<I>, fields: &[(&str, &str)]) -> String {
|
||||||
|
let mut body = form_pair("__h", &handle.to_string());
|
||||||
|
for (name, value) in fields {
|
||||||
|
body.push('&');
|
||||||
|
body.push_str(&form_pair(name, value));
|
||||||
}
|
}
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a request body for invalid-handle tests without exposing the wire field name.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn unknown_handle_form_body(id: u32) -> String {
|
||||||
|
form_pair("__h", &id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a browser-driver selector from a generated handle without exposing runtime ids in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn handle_selector<I>(handle: slhx_core::Handle<I>) -> String {
|
||||||
|
attr_selector("data-hid", &handle.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for a clickable button with a generated handle.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn handle_button_selector<I>(handle: slhx_core::Handle<I>) -> String {
|
||||||
|
format!("button{}", handle_selector(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for a heading in a semantic container without spelling document structure in examples.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn heading_selector(scope_selector: &str, level: u8) -> String {
|
||||||
|
assert!((1..=6).contains(&level), "heading level must be 1..=6");
|
||||||
|
if scope_selector.is_empty() {
|
||||||
|
format!("h{level}")
|
||||||
|
} else {
|
||||||
|
format!("{scope_selector} h{level}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for article content without spelling document structure in examples.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn article_selector() -> &'static str {
|
||||||
|
"article"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for emphasized/card-title text without spelling document structure.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn strong_text_selector() -> &'static str {
|
||||||
|
"strong"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for secondary/help text without spelling document structure.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn small_text_selector() -> &'static str {
|
||||||
|
"small"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for an HTML tag that must be absent when user text is escaped.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn escaped_markup_selector(tag: &str) -> String {
|
||||||
|
assert!(
|
||||||
|
tag.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
|
||||||
|
"tag selector must be a simple tag name"
|
||||||
|
);
|
||||||
|
tag.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for list items without spelling document structure in examples.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn list_item_selector(scope_selector: &str) -> String {
|
||||||
|
if scope_selector.is_empty() {
|
||||||
|
"li".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{scope_selector} li")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for prose text in a semantic container without spelling document structure.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn prose_selector(scope_selector: &str) -> String {
|
||||||
|
if scope_selector.is_empty() {
|
||||||
|
"p".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{scope_selector} p")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for a form in a semantic container without spelling form structure in examples.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn form_selector(scope_selector: &str) -> String {
|
||||||
|
format!("{scope_selector} form")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for a form select's options from the authoring field name.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn select_options_selector(field: &str) -> String {
|
||||||
|
format!("select{} > option", attr_selector("name", field))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for an app-owned semantic class.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn class_selector(class: &str) -> String {
|
||||||
|
assert_simple_selector_part(class, "class");
|
||||||
|
format!(".{class}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for an element carrying an app-owned semantic class.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn element_class_selector(element: &str, class: &str) -> String {
|
||||||
|
assert_simple_selector_part(element, "element");
|
||||||
|
assert_simple_selector_part(class, "class");
|
||||||
|
format!("{element}.{class}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for classed children inside an app-owned semantic container.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn class_child_selector(parent_class: &str, element: &str, class: &str) -> String {
|
||||||
|
assert_simple_selector_part(parent_class, "parent class");
|
||||||
|
assert_simple_selector_part(element, "element");
|
||||||
|
assert_simple_selector_part(class, "class");
|
||||||
|
format!(".{parent_class} > {element}.{class}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for an element inside an app-owned semantic class.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn class_descendant_selector(parent_class: &str, element: &str) -> String {
|
||||||
|
assert_simple_selector_part(parent_class, "parent class");
|
||||||
|
assert_simple_selector_part(element, "element");
|
||||||
|
format!(".{parent_class} {element}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for disabled action buttons without spelling CSS selector state in examples.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn disabled_button_selector() -> &'static str {
|
||||||
|
"button[disabled]"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for progressive-enhancement navigation links.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn nav_link_selector(href: &str) -> String {
|
||||||
|
format!("a{}", attr_selector("href", href))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for page-enhanced navigation links that do not use handler dispatch.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn page_nav_link_selector(href: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}[data-slhx-nav]:not([data-slhx-handle])",
|
||||||
|
nav_link_selector(href)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a browser-driver selector from a generated target without exposing runtime ids in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn target_selector(target: impl GeneratedTarget) -> String {
|
||||||
|
let resource = target.__slhx_resource_id();
|
||||||
|
let attr = match resource.kind {
|
||||||
|
ResourceKind::Slot => "data-sid",
|
||||||
|
ResourceKind::Atom => "data-aid",
|
||||||
|
ResourceKind::Handle => "data-hid",
|
||||||
|
ResourceKind::Form => "data-fid",
|
||||||
|
};
|
||||||
|
attr_selector(attr, &resource.id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for an slhx root from its authoring name.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn root_selector(name: &str) -> String {
|
||||||
|
attr_selector("data-slhx-root", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for a specific root element from its authoring name.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn root_element_selector(element: &str, name: &str) -> String {
|
||||||
|
format!("{}{}", element, root_selector(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for the document body without spelling raw document structure in examples.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn document_body_selector() -> &'static str {
|
||||||
|
"body"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for the document title without spelling raw document structure in examples.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn document_title_selector() -> &'static str {
|
||||||
|
"title"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for the slhx runtime script without exposing its asset path in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn runtime_script_selector() -> &'static str {
|
||||||
|
"script[src=\"/slhx.js\"]"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for any slhx root without spelling the attribute in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn any_root_selector() -> &'static str {
|
||||||
|
"[data-slhx-root]"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for a keyed generated row without spelling runtime key metadata.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn keyed_selector(base_selector: &str, key: impl ToString) -> String {
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
base_selector,
|
||||||
|
attr_selector("data-key", &key.to_string())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for all generated keyed rows under a semantic base selector.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn keyed_items_selector(base_selector: &str) -> String {
|
||||||
|
format!("{base_selector}[data-key]")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for an island from its authoring name.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn island_selector(name: &str) -> String {
|
||||||
|
attr_selector("data-slhx-island", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the island metadata attribute name without spelling it in product tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn island_attribute_name() -> &'static str {
|
||||||
|
"data-slhx-island"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the runtime island event name for an authoring island name.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn island_event_name(name: &str) -> String {
|
||||||
|
format!("slhx:island-{name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return an SSE enhancement marker without spelling framework metadata in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn sse_endpoint_marker(path: &str) -> String {
|
||||||
|
format!("data-slhx-sse=\"{path}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the island snapshot marker without spelling island metadata in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn island_snapshot_marker() -> &'static str {
|
||||||
|
"data-island-snapshot="
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a selector for island readouts without spelling island metadata in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn island_readout_selector() -> &'static str {
|
||||||
|
"[data-island-readout]"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build browser-driver JavaScript for injecting a synthetic island probe.
|
||||||
|
///
|
||||||
|
/// This lets product tests exercise the island bridge without spelling slhx island
|
||||||
|
/// metadata attributes in the test body. req: test/001 req: dx/006
|
||||||
|
pub fn island_probe_script(
|
||||||
|
element_id: &str,
|
||||||
|
island_name: &str,
|
||||||
|
snapshot: &str,
|
||||||
|
event_detail: &str,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
const root = arguments[0];
|
||||||
|
const islandName = {island_name};
|
||||||
|
const island = document.createElement('article');
|
||||||
|
island.id = {element_id};
|
||||||
|
island.setAttribute('data-slhx-island', islandName);
|
||||||
|
island.setAttribute('data-island-snapshot', {snapshot});
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 32;
|
||||||
|
canvas.height = 16;
|
||||||
|
island.appendChild(canvas);
|
||||||
|
|
||||||
|
const readout = document.createElement('p');
|
||||||
|
readout.setAttribute('data-island-readout', '');
|
||||||
|
readout.textContent = 'waiting';
|
||||||
|
island.appendChild(readout);
|
||||||
|
|
||||||
|
root.appendChild(island);
|
||||||
|
setTimeout(() => {{
|
||||||
|
root.dispatchEvent(new CustomEvent('slhx:island-' + islandName, {{ bubbles: true, detail: {event_detail} }}));
|
||||||
|
}}, 25);
|
||||||
|
return true;
|
||||||
|
"#,
|
||||||
|
element_id = js_string(element_id),
|
||||||
|
island_name = js_string(island_name),
|
||||||
|
snapshot = js_string(snapshot),
|
||||||
|
event_detail = js_string(event_detail),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a scoped selector for island readouts without spelling island metadata in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn scoped_island_readout_selector(scope_selector: &str) -> String {
|
||||||
|
format!("{scope_selector} {}", island_readout_selector())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_simple_selector_part(value: &str, label: &str) {
|
||||||
|
assert!(
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
|
||||||
|
"{label} selector part must contain only ascii alphanumerics or '-'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attr_selector(name: &str, value: &str) -> String {
|
||||||
|
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
|
format!(r#"[{name}="{escaped}"]"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn js_string(value: &str) -> String {
|
||||||
|
let mut escaped = String::from("\"");
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'\\' => escaped.push_str("\\\\"),
|
||||||
|
'"' => escaped.push_str("\\\""),
|
||||||
|
'\n' => escaped.push_str("\\n"),
|
||||||
|
'\r' => escaped.push_str("\\r"),
|
||||||
|
'\t' => escaped.push_str("\\t"),
|
||||||
|
ch => escaped.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
escaped.push('"');
|
||||||
|
escaped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn form_pair(name: &str, value: &str) -> String {
|
||||||
|
format!("{}={}", form_encode(name), form_encode(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn form_encode(value: &str) -> String {
|
||||||
|
let mut encoded = String::new();
|
||||||
|
for byte in value.bytes() {
|
||||||
|
match byte {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
encoded.push(byte as char)
|
||||||
|
}
|
||||||
|
b' ' => encoded.push('+'),
|
||||||
|
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -32,6 +408,14 @@ impl EffectInspector {
|
|||||||
self.batch.ops.contains(op)
|
self.batch.ops.contains(op)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn op_count(&self) -> usize {
|
||||||
|
self.batch.ops.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.batch.ops.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn has_resource(&self, resource: ResourceId) -> bool {
|
pub fn has_resource(&self, resource: ResourceId) -> bool {
|
||||||
self.batch
|
self.batch
|
||||||
.ops
|
.ops
|
||||||
@@ -39,6 +423,210 @@ impl EffectInspector {
|
|||||||
.any(|op| op_targets_resource(op, resource))
|
.any(|op| op_targets_resource(op, resource))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Assert against the same generated target object application handlers use.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn has_target(&self, target: impl GeneratedTarget) -> bool {
|
||||||
|
self.has_resource(target.__slhx_resource_id())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a generated target receives a text update, without matching raw effects.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn updates_text(&self, target: impl GeneratedTarget) -> bool {
|
||||||
|
let resource = target.__slhx_resource_id();
|
||||||
|
self.batch.ops.iter().any(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
Effect::Put {
|
||||||
|
target,
|
||||||
|
payload: Payload::Text(_),
|
||||||
|
} if target.resource == resource
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a generated target receives an HTML update, without matching raw effects.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn updates_html(&self, target: impl GeneratedTarget) -> bool {
|
||||||
|
let resource = target.__slhx_resource_id();
|
||||||
|
self.batch.ops.iter().any(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
Effect::Put {
|
||||||
|
target,
|
||||||
|
payload: Payload::Html(_),
|
||||||
|
} if target.resource == resource
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a generated target receives an HTML update containing text.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool {
|
||||||
|
let resource = target.__slhx_resource_id();
|
||||||
|
self.batch.ops.iter().any(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
Effect::Put {
|
||||||
|
target,
|
||||||
|
payload: Payload::Html(html),
|
||||||
|
} if target.resource == resource && html.contains(needle)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a keyed generated target is replaced with HTML containing text.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn replaces_keyed_html_containing(
|
||||||
|
&self,
|
||||||
|
target: impl GeneratedTarget,
|
||||||
|
key: impl ToString,
|
||||||
|
needle: &str,
|
||||||
|
) -> bool {
|
||||||
|
let resource = target.__slhx_resource_id();
|
||||||
|
let scope = Some(ScopeKey::KeyValue(key.to_string()));
|
||||||
|
self.batch.ops.iter().any(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
Effect::Put {
|
||||||
|
target,
|
||||||
|
payload: Payload::Html(html),
|
||||||
|
} if target.resource == resource && target.scope == scope && html.contains(needle)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a keyed generated target appends HTML containing text.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn inserts_html_containing(
|
||||||
|
&self,
|
||||||
|
target: impl GeneratedTarget,
|
||||||
|
key: impl ToString,
|
||||||
|
needle: &str,
|
||||||
|
) -> bool {
|
||||||
|
let resource = target.__slhx_resource_id();
|
||||||
|
let key = key.to_string();
|
||||||
|
self.batch.ops.iter().any(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
Effect::Insert {
|
||||||
|
target,
|
||||||
|
key: actual_key,
|
||||||
|
payload: Payload::Html(html),
|
||||||
|
} if target.resource == resource && actual_key == &key && html.contains(needle)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a keyed generated target removes a key.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn removes_key(&self, target: impl GeneratedTarget, key: impl ToString) -> bool {
|
||||||
|
let resource = target.__slhx_resource_id();
|
||||||
|
let key = key.to_string();
|
||||||
|
self.batch.ops.iter().any(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
Effect::Remove {
|
||||||
|
target,
|
||||||
|
key: Some(actual_key),
|
||||||
|
} if target.resource == resource && actual_key == &key
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that the batch requests a push navigation to a URL.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn pushes_to(&self, url: &str) -> bool {
|
||||||
|
self.batch.ops.iter().any(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
Effect::Navigate {
|
||||||
|
url: actual_url,
|
||||||
|
mode: NavigateMode::Push,
|
||||||
|
..
|
||||||
|
} if actual_url == url
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that any payload or URL contains text, without matching raw effects.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn payload_contains(&self, needle: &str) -> bool {
|
||||||
|
self.batch
|
||||||
|
.ops
|
||||||
|
.iter()
|
||||||
|
.any(|op| effect_payload_contains(op, needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that no payload or URL contains text, without matching raw effects.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn payload_excludes(&self, needle: &str) -> bool {
|
||||||
|
self.batch
|
||||||
|
.ops
|
||||||
|
.iter()
|
||||||
|
.all(|op| !effect_payload_contains(op, needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that generated keyed-row metadata for a key is absent from payloads.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn payload_excludes_key(&self, key: impl ToString) -> bool {
|
||||||
|
self.payload_excludes(&format!("data-key=\"{}\"", key.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return HTML for a generated target containing text, without exposing raw payloads.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn target_html_containing(
|
||||||
|
&self,
|
||||||
|
target: impl GeneratedTarget,
|
||||||
|
needle: &str,
|
||||||
|
) -> Option<&str> {
|
||||||
|
let resource = target.__slhx_resource_id();
|
||||||
|
self.batch.ops.iter().find_map(|op| match op {
|
||||||
|
Effect::Put {
|
||||||
|
target,
|
||||||
|
payload: Payload::Html(html),
|
||||||
|
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
|
||||||
|
Effect::Insert {
|
||||||
|
target,
|
||||||
|
payload: Payload::Html(html),
|
||||||
|
..
|
||||||
|
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
|
||||||
|
Effect::Prepend {
|
||||||
|
target,
|
||||||
|
payload: Payload::Html(html),
|
||||||
|
..
|
||||||
|
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a named generated event is emitted with the exact payload.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn emits(&self, name: &str, payload: &str) -> bool {
|
||||||
|
self.batch.ops.iter().any(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
Effect::Emit {
|
||||||
|
name: actual_name,
|
||||||
|
payload: actual_payload,
|
||||||
|
} if actual_name == name && actual_payload == payload
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a named generated event payload contains text.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn emits_containing(&self, name: &str, needle: &str) -> bool {
|
||||||
|
self.batch.ops.iter().any(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
Effect::Emit {
|
||||||
|
name: actual_name,
|
||||||
|
payload,
|
||||||
|
} if actual_name == name && payload.contains(needle)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn has_ref(&self, target: &ResourceRef) -> bool {
|
pub fn has_ref(&self, target: &ResourceRef) -> bool {
|
||||||
self.batch.ops.iter().any(|op| op_targets_ref(op, target))
|
self.batch.ops.iter().any(|op| op_targets_ref(op, target))
|
||||||
}
|
}
|
||||||
@@ -61,6 +649,36 @@ impl EffectInspector {
|
|||||||
pub fn has_form<T>(&self, form: Form<T>) -> bool {
|
pub fn has_form<T>(&self, form: Form<T>) -> bool {
|
||||||
self.has_resource(form.id())
|
self.has_resource(form.id())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Assert that a generated form is reset/cleared without matching raw events in tests.
|
||||||
|
/// req: test/001 req: dx/006
|
||||||
|
pub fn resets_form<T>(&self, form: Form<T>) -> bool {
|
||||||
|
let form_id = form.id().id.to_string();
|
||||||
|
self.batch.ops.iter().any(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
Effect::Emit { name, payload }
|
||||||
|
if name == "slhx:form-reset" && payload == &form_id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effect_payload_contains(op: &Effect, needle: &str) -> bool {
|
||||||
|
match op {
|
||||||
|
Effect::Put { payload, .. }
|
||||||
|
| Effect::Insert { payload, .. }
|
||||||
|
| Effect::Prepend { payload, .. } => payload_value(payload).contains(needle),
|
||||||
|
Effect::Emit { payload, .. } => payload.contains(needle),
|
||||||
|
Effect::Navigate { url, .. } => url.contains(needle),
|
||||||
|
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_value(payload: &Payload) -> &str {
|
||||||
|
match payload {
|
||||||
|
Payload::Text(value) | Payload::Html(value) => value,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn op_targets_resource(op: &Effect, resource: ResourceId) -> bool {
|
fn op_targets_resource(op: &Effect, resource: ResourceId) -> bool {
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() {
|
|||||||
"HandlerRegistry",
|
"HandlerRegistry",
|
||||||
"InteractionForm",
|
"InteractionForm",
|
||||||
"register_handle(",
|
"register_handle(",
|
||||||
|
"SafeHtml",
|
||||||
|
"slhx::advanced",
|
||||||
|
"advanced::slots",
|
||||||
|
"KeyedSlot",
|
||||||
|
"Slot<",
|
||||||
|
"Effect::",
|
||||||
|
"Payload::",
|
||||||
|
"NavigateMode",
|
||||||
|
"ScopeKey",
|
||||||
|
"opcode",
|
||||||
|
"wire format",
|
||||||
|
"manual generated",
|
||||||
"RenderSlotExt",
|
"RenderSlotExt",
|
||||||
"RenderKeyedSlotExt",
|
"RenderKeyedSlotExt",
|
||||||
".render(&",
|
".render(&",
|
||||||
@@ -111,6 +123,41 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() {
|
|||||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_prelude_does_not_export_low_level_primitives() {
|
||||||
|
// req: public_api/005 req: dx/006
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||||
|
let facade = std::fs::read_to_string(root.join("slhx/src/lib.rs")).unwrap();
|
||||||
|
let prelude = facade
|
||||||
|
.split("pub mod prelude {")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|tail| tail.split("\n}").next())
|
||||||
|
.expect("slhx facade exposes prelude module");
|
||||||
|
let exported = prelude
|
||||||
|
.split(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for token in [
|
||||||
|
"SafeHtml",
|
||||||
|
"Effect",
|
||||||
|
"Slot",
|
||||||
|
"KeyedSlot",
|
||||||
|
"ResourceId",
|
||||||
|
"EffectBatch",
|
||||||
|
"BuildFingerprint",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!exported.contains(&token),
|
||||||
|
"slhx::prelude must not export low-level `{token}`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(prelude.contains("Html"), "slhx::prelude should export Html");
|
||||||
|
assert!(
|
||||||
|
prelude.contains("IntoEffect"),
|
||||||
|
"slhx::prelude should export IntoEffect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_example_docs_do_not_teach_low_level_plumbing() {
|
fn canonical_example_docs_do_not_teach_low_level_plumbing() {
|
||||||
// req: dx/002 req: dx/003 req: examples/003
|
// req: dx/002 req: dx/003 req: examples/003
|
||||||
@@ -130,13 +177,63 @@ fn canonical_example_docs_do_not_teach_low_level_plumbing() {
|
|||||||
"register_handle(",
|
"register_handle(",
|
||||||
"lower_html(",
|
"lower_html(",
|
||||||
"render_html(",
|
"render_html(",
|
||||||
"SafeHtml::trusted",
|
"SafeHtml",
|
||||||
|
"KeyedSlot",
|
||||||
|
"Slot<",
|
||||||
|
"Effect::",
|
||||||
|
"opcode",
|
||||||
|
"wire format",
|
||||||
|
"manual generated",
|
||||||
|
"Effect::batch",
|
||||||
|
"Effect::class",
|
||||||
|
"Effect::move",
|
||||||
|
"Effect::set",
|
||||||
|
"Effect::broadcast",
|
||||||
|
"Effect::ack",
|
||||||
|
"SyncEffect::",
|
||||||
|
"postcard DOM ops",
|
||||||
|
"data-hid",
|
||||||
|
"data-sid",
|
||||||
".render(&",
|
".render(&",
|
||||||
"addEventListener(",
|
"addEventListener(",
|
||||||
"querySelector",
|
"querySelector",
|
||||||
"querySelectorAll",
|
"querySelectorAll",
|
||||||
];
|
];
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
|
scan_examples(&examples, &mut |path, text| {
|
||||||
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md")
|
||||||
|
|| is_advanced_boundary_doc(text)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (line_no, line) in text.lines().enumerate() {
|
||||||
|
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
||||||
|
failures.push(format!(
|
||||||
|
"{}:{}: example docs must teach generated ergonomic APIs, not `{token}`",
|
||||||
|
path.display(),
|
||||||
|
line_no + 1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn example_docs_do_not_show_runtime_metadata_as_authoring_contract() {
|
||||||
|
// req: dx/006 req: misc/006
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||||
|
let examples = root.join("examples");
|
||||||
|
let forbidden = [
|
||||||
|
"data-hid",
|
||||||
|
"data-sid",
|
||||||
|
"data-aid",
|
||||||
|
"data-fid",
|
||||||
|
"[data-hid",
|
||||||
|
"[data-sid",
|
||||||
|
];
|
||||||
|
let mut failures = Vec::new();
|
||||||
scan_examples(&examples, &mut |path, text| {
|
scan_examples(&examples, &mut |path, text| {
|
||||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||||
return;
|
return;
|
||||||
@@ -144,7 +241,7 @@ fn canonical_example_docs_do_not_teach_low_level_plumbing() {
|
|||||||
for (line_no, line) in text.lines().enumerate() {
|
for (line_no, line) in text.lines().enumerate() {
|
||||||
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
||||||
failures.push(format!(
|
failures.push(format!(
|
||||||
"{}:{}: example docs must teach generated ergonomic APIs, not `{token}`",
|
"{}:{}: example docs must describe generated authoring APIs, not runtime metadata `{token}`",
|
||||||
path.display(),
|
path.display(),
|
||||||
line_no + 1
|
line_no + 1
|
||||||
));
|
));
|
||||||
@@ -188,6 +285,10 @@ fn scan_examples(dir: &Path, visit: &mut impl FnMut(&Path, &str)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_advanced_boundary_doc(text: &str) -> bool {
|
||||||
|
text.contains("advanced/low-level north-star boundary sketch")
|
||||||
|
}
|
||||||
|
|
||||||
fn is_example_source(path: &PathBuf) -> bool {
|
fn is_example_source(path: &PathBuf) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
path.extension().and_then(|ext| ext.to_str()),
|
path.extension().and_then(|ext| ext.to_str()),
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
use slhx_core::{Atom, Effect, KeyedSlot, Payload, ResourceRef, Slot};
|
use slhx_core::{
|
||||||
|
Atom, Effect, GeneratedTarget, KeyedSlot, Payload, ResourceId, ResourceKind, ResourceRef, Slot,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inspects_tuple_effects() {
|
fn inspects_tuple_effects() {
|
||||||
let count = Slot::<u32>::new(1);
|
let count = Slot::<u32>::new(1);
|
||||||
let user = Atom::<String>::new(2);
|
let user = Atom::<String>::new(2);
|
||||||
|
|
||||||
let inspected = slhx_test::run(
|
let inspected = slhx_test::run(|value| (count.text(value), user.set("alice")), 42);
|
||||||
|value| (count.text(value), user.set("alice")),
|
|
||||||
42,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(inspected.has_slot(count));
|
assert!(inspected.has_slot(count));
|
||||||
assert!(inspected.has_atom(user));
|
assert!(inspected.has_atom(user));
|
||||||
@@ -26,3 +25,116 @@ fn finds_keyed_slot_targets() {
|
|||||||
assert!(inspected.has_keyed_slot(rows));
|
assert!(inspected.has_keyed_slot(rows));
|
||||||
assert_eq!(inspected.ops().len(), 1);
|
assert_eq!(inspected.ops().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_generated_handle_form_bodies() {
|
||||||
|
let handle = slhx_core::Handle::<()>::new(7);
|
||||||
|
|
||||||
|
let body = slhx_test::handle_form_body(handle, &[("title", "hello world"), ("tag", "a&b")]);
|
||||||
|
|
||||||
|
assert_eq!(body, "__h=7&title=hello+world&tag=a%26b");
|
||||||
|
assert_eq!(slhx_test::unknown_handle_form_body(99), "__h=99");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_authoring_boundary_selectors() {
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::root_selector("techdemo"),
|
||||||
|
r#"[data-slhx-root="techdemo"]"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::island_selector("orbit"),
|
||||||
|
r#"[data-slhx-island="orbit"]"#
|
||||||
|
);
|
||||||
|
assert_eq!(slhx_test::island_attribute_name(), "data-slhx-island");
|
||||||
|
assert_eq!(slhx_test::island_event_name("orbit"), "slhx:island-orbit");
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::sse_endpoint_marker("/events"),
|
||||||
|
r#"data-slhx-sse="/events""#
|
||||||
|
);
|
||||||
|
assert_eq!(slhx_test::any_root_selector(), "[data-slhx-root]");
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::root_element_selector("main", "docs"),
|
||||||
|
r#"main[data-slhx-root="docs"]"#
|
||||||
|
);
|
||||||
|
assert_eq!(slhx_test::document_body_selector(), "body");
|
||||||
|
assert_eq!(slhx_test::document_title_selector(), "title");
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::runtime_script_selector(),
|
||||||
|
r#"script[src="/slhx.js"]"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::target_selector(TestTarget(ResourceKind::Slot, 42)),
|
||||||
|
r#"[data-sid="42"]"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::handle_button_selector(slhx_core::Handle::<()>::new(7)),
|
||||||
|
r#"button[data-hid="7"]"#
|
||||||
|
);
|
||||||
|
assert_eq!(slhx_test::article_selector(), "article");
|
||||||
|
assert_eq!(slhx_test::strong_text_selector(), "strong");
|
||||||
|
assert_eq!(slhx_test::small_text_selector(), "small");
|
||||||
|
assert_eq!(slhx_test::escaped_markup_selector("b"), "b");
|
||||||
|
assert_eq!(slhx_test::heading_selector("article", 1), "article h1");
|
||||||
|
assert_eq!(slhx_test::list_item_selector("ul"), "ul li");
|
||||||
|
assert_eq!(slhx_test::prose_selector("article"), "article p");
|
||||||
|
assert_eq!(slhx_test::form_selector("header"), "header form");
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::select_options_selector("column"),
|
||||||
|
r#"select[name="column"] > option"#
|
||||||
|
);
|
||||||
|
assert_eq!(slhx_test::class_selector("lane"), ".lane");
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::element_class_selector("span", "presence"),
|
||||||
|
"span.presence"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::class_child_selector("columns", "section", "column"),
|
||||||
|
".columns > section.column"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::class_descendant_selector("impact", "i"),
|
||||||
|
".impact i"
|
||||||
|
);
|
||||||
|
assert_eq!(slhx_test::disabled_button_selector(), "button[disabled]");
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::nav_link_selector("/architecture"),
|
||||||
|
r#"a[href="/architecture"]"#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::page_nav_link_selector("/docs"),
|
||||||
|
r#"a[href="/docs"][data-slhx-nav]:not([data-slhx-handle])"#
|
||||||
|
);
|
||||||
|
assert_eq!(slhx_test::island_snapshot_marker(), "data-island-snapshot=");
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::island_readout_selector(),
|
||||||
|
"[data-island-readout]"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::scoped_island_readout_selector("#probe-island"),
|
||||||
|
"#probe-island [data-island-readout]"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
slhx_test::keyed_selector(".work-card", 4),
|
||||||
|
r#".work-card[data-key="4"]"#
|
||||||
|
);
|
||||||
|
assert_eq!(slhx_test::keyed_items_selector("li"), "li[data-key]");
|
||||||
|
|
||||||
|
let probe = slhx_test::island_probe_script(
|
||||||
|
"probe-island",
|
||||||
|
"orbit",
|
||||||
|
"1|1|1|probe waiting",
|
||||||
|
"7|2|8|probe live",
|
||||||
|
);
|
||||||
|
assert!(probe.contains("probe-island"));
|
||||||
|
assert!(probe.contains("probe live"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct TestTarget(ResourceKind, u32);
|
||||||
|
|
||||||
|
impl GeneratedTarget for TestTarget {
|
||||||
|
fn __slhx_resource_id(self) -> ResourceId {
|
||||||
|
ResourceId::new(self.0, self.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+91
-17
@@ -3,18 +3,89 @@
|
|||||||
//! Most application code should depend on this crate, use the proc-macros from
|
//! Most application code should depend on this crate, use the proc-macros from
|
||||||
//! here, and import generated resources through `#[slhx::surface]`.
|
//! here, and import generated resources through `#[slhx::surface]`.
|
||||||
|
|
||||||
pub use slhx_core::*;
|
use slhx_core::{Effect, KeyedSlot, SafeHtml, Slot};
|
||||||
|
|
||||||
|
pub use slhx_core::{
|
||||||
|
navigate, push, redirect, replace, Atom, ComponentRef, CssClass, CssClasses, EventName, Form,
|
||||||
|
FormContract, FormControlKind, FormError, FormField, FormModel, FormValue, FromForm,
|
||||||
|
GeneratedTarget, Handle, IntoEffect, ParamName,
|
||||||
|
};
|
||||||
pub use slhx_derive::{app, component, form, handler, surface};
|
pub use slhx_derive::{app, component, form, handler, surface};
|
||||||
|
|
||||||
pub fn render(view: &impl hemplate::Hemplate) -> SafeHtml {
|
/// Advanced/raw slhx primitives used by generated code, integrations, and tests.
|
||||||
let mut html = String::new();
|
///
|
||||||
view.render_into(&mut html)
|
/// Beginner-facing application code should prefer generated targets, generated
|
||||||
.expect("hemplate view renders into slhx effect payload");
|
/// handles/forms/classes, `Html`, `IntoEffect`, and tuple composition. req: dx/001 req: public_api/005
|
||||||
SafeHtml::trusted(html)
|
pub mod advanced {
|
||||||
|
pub use slhx_core::*;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rendered, checked HTML produced by hemplate/slhx rendering helpers.
|
||||||
|
///
|
||||||
|
/// Raw trusted HTML construction remains an advanced boundary; beginner-facing
|
||||||
|
/// code should receive `Html` values from generated render helpers. req: public_api/005
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct Html(SafeHtml);
|
||||||
|
|
||||||
|
impl Html {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
self.0.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_string(self) -> String {
|
||||||
|
self.0.into_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join(fragments: impl IntoIterator<Item = Html>) -> Self {
|
||||||
|
Self(SafeHtml::join(fragments.into_iter().map(Into::into)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Html> for SafeHtml {
|
||||||
|
fn from(value: Html) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<str> for Html {
|
||||||
|
fn as_ref(&self) -> &str {
|
||||||
|
self.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for Html {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub fn render_html(view: &impl hemplate::Hemplate) -> SafeHtml {
|
pub mod __private {
|
||||||
|
use super::{Html, SafeHtml};
|
||||||
|
|
||||||
|
pub fn html_trusted(value: impl Into<String>) -> Html {
|
||||||
|
Html(SafeHtml::trusted(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(view: &impl hemplate::Hemplate) -> Html {
|
||||||
|
let mut html = String::new();
|
||||||
|
view.render_into(&mut html)
|
||||||
|
.expect("hemplate view renders into slhx effect payload");
|
||||||
|
__private::html_trusted(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hemplate partial that carries the stable key for a generated keyed target.
|
||||||
|
///
|
||||||
|
/// Generated keyed target helpers use this to keep ordinary handler code at the
|
||||||
|
/// level of `ui::row.replace(row)` instead of `ui::row.replace(row.id, &row)`.
|
||||||
|
/// req: canonical_authoring/003 req: codegen/002
|
||||||
|
pub trait KeyedPartial {
|
||||||
|
fn slhx_key(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn render_html(view: &impl hemplate::Hemplate) -> Html {
|
||||||
render(view)
|
render(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +113,7 @@ impl<T> RenderSlotExt for Slot<T> {
|
|||||||
|
|
||||||
/// Compatibility shim for raw keyed slot rendering.
|
/// Compatibility shim for raw keyed slot rendering.
|
||||||
///
|
///
|
||||||
/// Prefer generated target objects such as `targets::row.append(key, &view)` so
|
/// Prefer generated target objects such as `targets::row.append(&view)` so
|
||||||
/// generated resource lowering stays attached to the view boundary. req: dx/006
|
/// generated resource lowering stays attached to the view boundary. req: dx/006
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub trait RenderKeyedSlotExt<K> {
|
pub trait RenderKeyedSlotExt<K> {
|
||||||
@@ -90,12 +161,12 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
pub use slhx_core::{
|
|
||||||
navigate, push, redirect, replace, Atom, BuildFingerprint, ComponentRef, CssClass,
|
|
||||||
CssClasses, Effect, EventName, Form, FormModel, FormValue, Handle, IntoEffect, KeyedSlot,
|
|
||||||
ParamName, SafeHtml, Slot,
|
|
||||||
};
|
|
||||||
pub use crate::render;
|
pub use crate::render;
|
||||||
|
pub use crate::Html;
|
||||||
|
pub use slhx_core::{
|
||||||
|
navigate, push, redirect, replace, Atom, ComponentRef, CssClass, CssClasses, EventName,
|
||||||
|
Form, FormModel, FormValue, Handle, IntoEffect, ParamName,
|
||||||
|
};
|
||||||
pub use slhx_derive::{app, component, form, handler, surface};
|
pub use slhx_derive::{app, component, form, handler, surface};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,15 +185,18 @@ mod tests {
|
|||||||
fn render_is_the_short_safe_html_helper() {
|
fn render_is_the_short_safe_html_helper() {
|
||||||
// req: dx/006 req: html_safety/002
|
// req: dx/006 req: html_safety/002
|
||||||
assert_eq!(crate::render(&InlineView).as_str(), "<strong>ok</strong>");
|
assert_eq!(crate::render(&InlineView).as_str(), "<strong>ok</strong>");
|
||||||
assert_eq!(crate::render_html(&InlineView).as_str(), "<strong>ok</strong>");
|
assert_eq!(
|
||||||
|
crate::render_html(&InlineView).as_str(),
|
||||||
|
"<strong>ok</strong>"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prelude_exports_safe_html_for_page_composition() {
|
fn prelude_exports_html_for_page_composition() {
|
||||||
// req: dx/006 req: html_safety/002
|
// req: dx/006 req: public_api/005 req: html_safety/002
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
let html = SafeHtml::join([render(&InlineView)]);
|
let html = Html::join([render(&InlineView)]);
|
||||||
|
|
||||||
assert_eq!(html.as_str(), "<strong>ok</strong>");
|
assert_eq!(html.as_str(), "<strong>ok</strong>");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user