feat(techdemo): render issues with hemplate partials

req: examples/001

req: dx/008

req: list/003

req: form/002
This commit is contained in:
slhx agent
2026-05-11 07:53:51 +02:00
parent 5a29161148
commit ad8806c187
12 changed files with 260 additions and 42 deletions
Generated
+19
View File
@@ -389,6 +389,14 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hemplate"
version = "0.1.0"
dependencies = [
"hemplate-core",
"hemplate-derive",
]
[[package]] [[package]]
name = "hemplate-core" name = "hemplate-core"
version = "0.1.0" version = "0.1.0"
@@ -399,6 +407,16 @@ dependencies = [
"tree-sitter", "tree-sitter",
] ]
[[package]]
name = "hemplate-derive"
version = "0.1.0"
dependencies = [
"hemplate-core",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "hemplate-parser" name = "hemplate-parser"
version = "0.1.0" version = "0.1.0"
@@ -1404,6 +1422,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
"hemplate",
"scraper", "scraper",
"slhx", "slhx",
"slhx-axum", "slhx-axum",
+1
View File
@@ -10,6 +10,7 @@ path = "src/lib.rs"
[dependencies] [dependencies]
axum = "0.7" axum = "0.7"
futures-util = "0.3" futures-util = "0.3"
hemplate = { path = "../../../hemplate/hemplate" }
slhx = { path = "../../slhx" } slhx = { path = "../../slhx" }
slhx-axum = { path = "../../slhx-axum" } slhx-axum = { path = "../../slhx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
+2 -1
View File
@@ -10,13 +10,14 @@ This is a polished Linear-style product demo for planning typed work across lane
- modern SSR-first UI - modern SSR-first UI
- generated resource modules from `.heml` - generated resource modules from `.heml`
- hemplate partials for issue lanes, cards, and inspector panels
- native form posts carrying `__h` numeric handle ids - native form posts carrying `__h` numeric handle ids
- multi-target `application/slhx` EffectBatch responses - multi-target `application/slhx` EffectBatch responses
- generated slot updates instead of selectors - generated slot updates instead of selectors
- root-scoped runtime lowering (`data-hid`, `data-sid`) - root-scoped runtime lowering (`data-hid`, `data-sid`)
- 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
- one tiny launch glue function plus slhx runtime-driven interactions - drag-and-drop lane moves persisted by typed server handlers
Verification: Verification:
+171 -38
View File
@@ -3,6 +3,7 @@ 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 slhx::{IntoEffect, SafeHtml}; use slhx::{IntoEffect, SafeHtml};
use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest}; use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
use slhx_techdemo::ui; use slhx_techdemo::ui;
@@ -58,6 +59,7 @@ struct DemoState {
work: Vec<WorkItem>, work: Vec<WorkItem>,
activity: VecDeque<String>, activity: VecDeque<String>,
spotlight: String, spotlight: String,
selected_id: Option<u64>,
} }
impl Default for DemoState { impl Default for DemoState {
@@ -71,6 +73,7 @@ impl Default for DemoState {
], ],
activity: VecDeque::new(), activity: VecDeque::new(),
spotlight: "No selectors. Generated resources address every target.".into(), spotlight: "No selectors. Generated resources address every target.".into(),
selected_id: Some(2),
}; };
state.log("Demo booted from server-rendered HTML"); state.log("Demo booted from server-rendered HTML");
state.log("Runtime attached one delegated listener per root"); state.log("Runtime attached one delegated listener per root");
@@ -91,6 +94,50 @@ struct Shared {
demo: Mutex<DemoState>, demo: Mutex<DemoState>,
} }
#[derive(Hemplate)]
#[hemplate = "partials"]
struct IssueLane {
class: String,
lane_id: &'static str,
move_handle: u32,
title: &'static str,
description: &'static str,
cards: Vec<IssueCard>,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct IssueCard {
class: String,
id: u64,
title: String,
stage: &'static str,
impact_style: String,
spotlight_handle: u32,
advance_handle: u32,
delete_handle: u32,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct InspectorPanel {
selected: String,
spotlight: String,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct InspectorSelected {
title: String,
lane: &'static str,
stage: &'static str,
impact: u8,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct InspectorEmpty;
#[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()) });
@@ -173,6 +220,7 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
let id = demo.next_id; let id = demo.next_id;
demo.next_id += 1; demo.next_id += 1;
demo.work.push(WorkItem { id, title: title.into(), lane, impact, stage: Stage::Draft }); demo.work.push(WorkItem { id, title: title.into(), lane, impact, stage: Stage::Draft });
demo.selected_id = Some(id);
demo.spotlight = format!("Form data became typed Rust state; card #{id} was rendered by a generated slot."); demo.spotlight = format!("Form data became typed Rust state; card #{id} was rendered by a generated slot.");
demo.log(format!("Launched card #{id}: {title}")); demo.log(format!("Launched card #{id}: {title}"));
} }
@@ -197,6 +245,28 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
demo_effects(&demo, "Pipeline advanced") demo_effects(&demo, "Pipeline advanced")
} }
}) })
.register(ui::control_center::handles::move_to_lane.id().id, {
let shared = shared.clone();
move |form| {
// req: list/003 req: examples/001
let mut demo = shared.demo.lock().unwrap();
let lane = parse_lane(form.value("lane"));
let title = update_work(&mut demo, form.value("work_id"), |item| {
item.lane = lane;
item.stage = match lane {
0 => Stage::Draft,
1 => Stage::Active,
_ => Stage::Shipped,
};
});
if let Some(title) = title {
demo.selected_id = form.value("work_id").and_then(|value| value.parse().ok());
demo.spotlight = format!("{title} moved to {} by drag-and-drop; Rust re-rendered the board slot.", LANES[lane].1);
demo.log(format!("Dragged {title} to {}", LANES[lane].1));
}
demo_effects(&demo, "Drag-and-drop move persisted")
}
})
.register(ui::control_center::handles::delete_work.id().id, { .register(ui::control_center::handles::delete_work.id().id, {
let shared = shared.clone(); let shared = shared.clone();
move |form| { move |form| {
@@ -206,6 +276,9 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
let before = demo.work.len(); let before = demo.work.len();
demo.work.retain(|item| item.id != id); demo.work.retain(|item| item.id != id);
if demo.work.len() < before { if demo.work.len() < before {
if demo.selected_id == Some(id) {
demo.selected_id = demo.work.first().map(|item| item.id);
}
demo.spotlight = format!("Card #{id} removed; the board, metrics, activity, and inspector updated together."); demo.spotlight = format!("Card #{id} removed; the board, metrics, activity, and inspector updated together.");
demo.log(format!("Deleted card #{id}")); demo.log(format!("Deleted card #{id}"));
} }
@@ -219,7 +292,8 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
// req: examples/001 // req: examples/001
let mut demo = shared.demo.lock().unwrap(); let mut demo = shared.demo.lock().unwrap();
if let Some(id) = form.value("work_id").and_then(|value| value.parse::<u64>().ok()) { if let Some(id) = form.value("work_id").and_then(|value| value.parse::<u64>().ok()) {
if let Some(item) = demo.work.iter().find(|item| item.id == id) { if let Some(item) = demo.work.iter().find(|item| item.id == id).cloned() {
demo.selected_id = Some(id);
demo.spotlight = format!("{} · lane={} · stage={} · impact={}", item.title, LANES[item.lane].1, item.stage.label(), item.impact); demo.spotlight = format!("{} · lane={} · stage={} · impact={}", item.title, LANES[item.lane].1, item.stage.label(), item.impact);
demo.log(format!("Inspected card #{id}")); demo.log(format!("Inspected card #{id}"));
} }
@@ -293,11 +367,7 @@ fn shell(body: String) -> String {
<title>slhx Techdemo</title> <title>slhx Techdemo</title>
<script src="/slhx.js" defer></script> <script src="/slhx.js" defer></script>
<script> <script>
function slhxTechdemoLaunch(button) {{ function slhxTechdemoEffect(root, data) {{
const form = button.closest('form');
const root = button.closest('[data-slhx-root]');
const data = new URLSearchParams(new FormData(form));
data.set('__h', button.getAttribute('data-hid'));
fetch('/', {{ fetch('/', {{
method: 'POST', method: 'POST',
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Accept': 'application/slhx' }}, headers: {{ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Accept': 'application/slhx' }},
@@ -305,6 +375,36 @@ fn shell(body: String) -> String {
}}) }})
.then((response) => response.arrayBuffer()) .then((response) => response.arrayBuffer())
.then((buffer) => window.slhx.applyBatch(buffer, root)); .then((buffer) => window.slhx.applyBatch(buffer, root));
}}
function slhxTechdemoLaunch(button) {{
const form = button.closest('form');
const data = new URLSearchParams(new FormData(form));
data.set('__h', button.getAttribute('data-hid'));
slhxTechdemoEffect(button.closest('[data-slhx-root]'), data);
return false;
}}
function slhxTechdemoDrag(event) {{
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', event.currentTarget.getAttribute('data-key'));
}}
function slhxTechdemoDragOver(event) {{
event.preventDefault();
event.currentTarget.classList.add('drop-ready');
}}
function slhxTechdemoDragLeave(event) {{
event.currentTarget.classList.remove('drop-ready');
}}
function slhxTechdemoDrop(event) {{
event.preventDefault();
const lane = event.currentTarget;
lane.classList.remove('drop-ready');
const id = event.dataTransfer.getData('text/plain');
if (!id) return false;
const data = new URLSearchParams();
data.set('__h', lane.getAttribute('data-move-hid'));
data.set('work_id', id);
data.set('lane', lane.getAttribute('data-lane'));
slhxTechdemoEffect(lane.closest('[data-slhx-root]'), data);
return false; return false;
}} }}
</script> </script>
@@ -346,7 +446,10 @@ fn shell(body: String) -> String {
.lane {{ min-height:330px; border:1px solid var(--line); border-radius:24px; padding:14px; background:linear-gradient(180deg, rgba(0,0,0,.26), rgba(255,255,255,.035)); overflow:hidden; }} .lane {{ min-height:330px; border:1px solid var(--line); border-radius:24px; padding:14px; background:linear-gradient(180deg, rgba(0,0,0,.26), rgba(255,255,255,.035)); overflow:hidden; }}
.lane h3 {{ margin:0 0 4px; }} .lane h3 {{ margin:0 0 4px; }}
.lane p {{ color:var(--muted); margin:0 0 12px; font-size:13px; }} .lane p {{ color:var(--muted); margin:0 0 12px; font-size:13px; }}
.work-card {{ border:1px solid rgba(255,255,255,.18); border-radius:20px; margin:12px 0; padding:14px; background:linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.055)); box-shadow:0 14px 38px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.1); overflow:hidden; overflow-wrap:anywhere; }} .work-card {{ border:1px solid rgba(255,255,255,.18); border-radius:20px; margin:12px 0; padding:14px; background:linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.055)); box-shadow:0 14px 38px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.1); overflow:hidden; overflow-wrap:anywhere; cursor:grab; }}
.work-card:active {{ cursor:grabbing; }}
.work-card.is-selected {{ border-color:rgba(68,231,255,.9); box-shadow:0 0 0 1px rgba(68,231,255,.4), 0 22px 55px rgba(68,231,255,.14); }}
.lane.drop-ready {{ border-color:rgba(184,255,90,.85); background:linear-gradient(180deg, rgba(184,255,90,.11), rgba(255,255,255,.04)); }}
.work-card header {{ display:flex; justify-content:space-between; gap:10px; align-items:start; }} .work-card header {{ display:flex; justify-content:space-between; gap:10px; align-items:start; }}
.pill {{ display:inline-flex; border:1px solid var(--line); border-radius:999px; padding:4px 9px; font-size:12px; color:var(--lime); }} .pill {{ display:inline-flex; border:1px solid var(--line); border-radius:999px; padding:4px 9px; font-size:12px; color:var(--lime); }}
.impact {{ height:7px; border-radius:999px; background:rgba(255,255,255,.12); overflow:hidden; margin:12px 0; }} .impact {{ height:7px; border-radius:999px; background:rgba(255,255,255,.12); overflow:hidden; margin:12px 0; }}
@@ -358,6 +461,10 @@ fn shell(body: String) -> String {
.glow {{ box-shadow:0 0 0 1px rgba(184,255,90,.12), 0 24px 90px rgba(184,255,90,.08); }} .glow {{ box-shadow:0 0 0 1px rgba(184,255,90,.12), 0 24px 90px rgba(184,255,90,.08); }}
.activity {{ display:grid; gap:10px; padding:0; margin:0; list-style:none; }} .activity {{ display:grid; gap:10px; padding:0; margin:0; list-style:none; }}
.activity li, .inspector-row, .live-row {{ border:1px solid var(--line); border-radius:16px; padding:12px; background:rgba(0,0,0,.18); color:var(--muted); overflow-wrap:anywhere; }} .activity li, .inspector-row, .live-row {{ border:1px solid var(--line); border-radius:16px; padding:12px; background:rgba(0,0,0,.18); color:var(--muted); overflow-wrap:anywhere; }}
.inspector-hero {{ border:1px solid rgba(68,231,255,.35); border-radius:20px; padding:16px; margin-bottom:12px; background:linear-gradient(135deg, rgba(68,231,255,.15), rgba(255,79,216,.1)); }}
.inspector-hero span {{ display:block; color:var(--cyan); font-size:12px; text-transform:uppercase; letter-spacing:.14em; font-weight:900; }}
.inspector-hero strong {{ display:block; font-size:22px; letter-spacing:-.035em; margin-top:8px; overflow-wrap:anywhere; }}
.inspector-hero em {{ display:block; color:var(--muted); font-style:normal; margin-top:6px; }}
.inspector-row b {{ color:var(--text); }} .inspector-row b {{ color:var(--text); }}
.live-row strong {{ color:var(--lime); }} .live-row strong {{ color:var(--lime); }}
code {{ color:var(--cyan); }} code {{ color:var(--cyan); }}
@@ -388,37 +495,47 @@ fn render_hero(demo: &DemoState) -> String {
} }
fn render_board(demo: &DemoState) -> String { fn render_board(demo: &DemoState) -> String {
let lanes = LANES
.iter()
.enumerate()
.map(|(idx, (lane_id, title, description))| IssueLane {
class: String::from("lane"),
lane_id,
move_handle: ui::control_center::handles::move_to_lane.id().id,
title,
description,
cards: demo
.work
.iter()
.filter(|item| item.lane == idx)
.map(|item| issue_card(item, demo.selected_id == Some(item.id)))
.collect(),
})
.collect::<Vec<_>>();
let mut out = String::from("<div class=\"lanes\">"); let mut out = String::from("<div class=\"lanes\">");
for (idx, (_, title, description)) in LANES.iter().enumerate() { for lane in lanes {
out.push_str(&format!(r#"<section class="lane"><h3>{}</h3><p>{}</p>"#, escape_html(title), escape_html(description))); out.push_str(&render_template(&lane));
for item in demo.work.iter().filter(|item| item.lane == idx) {
out.push_str(&render_card(item));
}
out.push_str("</section>");
} }
out.push_str("</div>"); out.push_str("</div>");
out out
} }
fn render_card(item: &WorkItem) -> String { fn issue_card(item: &WorkItem, selected: bool) -> IssueCard {
format!( IssueCard {
r#"<article class="work-card" data-key="{id}"> class: if selected {
<header><strong>{title}</strong><span class="pill">{stage}</span></header> String::from("work-card is-selected")
<div class="impact"><i style="width:{impact_percent}%"></i></div> } else {
<div class="card-actions"> String::from("work-card")
<button type="button" data-hid="{spotlight}" data-work-id="{id}">Inspect</button> },
<button type="button" data-hid="{advance}" data-work-id="{id}">Advance</button> id: item.id,
<button type="button" data-hid="{delete}" data-work-id="{id}">Delete</button> title: item.title.clone(),
</div> stage: item.stage.label(),
</article>"#, impact_style: format!("width:{}%", item.impact as usize * 11),
id = item.id, spotlight_handle: ui::control_center::handles::spotlight_work.id().id,
title = escape_html(&item.title), advance_handle: ui::control_center::handles::advance_work.id().id,
stage = item.stage.label(), delete_handle: ui::control_center::handles::delete_work.id().id,
impact_percent = item.impact as usize * 11, }
spotlight = ui::control_center::handles::spotlight_work.id().id,
advance = ui::control_center::handles::advance_work.id().id,
delete = ui::control_center::handles::delete_work.id().id,
)
} }
fn render_activity(demo: &DemoState) -> String { fn render_activity(demo: &DemoState) -> String {
@@ -431,12 +548,24 @@ fn render_activity(demo: &DemoState) -> String {
} }
fn render_inspector(demo: &DemoState) -> String { fn render_inspector(demo: &DemoState) -> String {
format!( let selected = demo
r#"<div class="inspector-row"><b>Selected fact</b><br>{}</div> .selected_id
<div class="inspector-row"><b>Wire contract</b><br><code>POST __h → application/slhx → EffectBatch</code></div> .and_then(|id| demo.work.iter().find(|item| item.id == id));
<div class="inspector-row"><b>Runtime</b><br>Root-scoped delegated listeners; numeric targets only.</div>"#, let selected = selected.map_or_else(
escape_html(&demo.spotlight), || render_template(&InspectorEmpty),
) |item| {
render_template(&InspectorSelected {
title: item.title.clone(),
lane: LANES[item.lane].1,
stage: item.stage.label(),
impact: item.impact,
})
},
);
render_template(&InspectorPanel {
selected,
spotlight: demo.spotlight.clone(),
})
} }
fn live_feed(tick: u64) -> String { fn live_feed(tick: u64) -> String {
@@ -461,6 +590,10 @@ fn architecture_activity() -> String {
"<ol class=\"activity\"><li>Clicked a real anchor</li><li>Fetched HTML with X-SLHX-Partial</li><li>Preserved native fallback semantics</li></ol>".into() "<ol class=\"activity\"><li>Clicked a real anchor</li><li>Fetched HTML with X-SLHX-Partial</li><li>Preserved native fallback semantics</li></ol>".into()
} }
fn render_template(template: &impl Hemplate) -> String {
template.render().expect("techdemo hemplate partial renders")
}
fn escape_html(value: &str) -> String { fn escape_html(value: &str) -> String {
value value
.replace('&', "&amp;") .replace('&', "&amp;")
@@ -63,4 +63,5 @@
<button type="button" data-slhx-handle="advance_work" hidden="hidden">Advance</button> <button type="button" data-slhx-handle="advance_work" hidden="hidden">Advance</button>
<button type="button" data-slhx-handle="delete_work" hidden="hidden">Delete</button> <button type="button" data-slhx-handle="delete_work" hidden="hidden">Delete</button>
<button type="button" data-slhx-handle="spotlight_work" hidden="hidden">Spotlight</button> <button type="button" data-slhx-handle="spotlight_work" hidden="hidden">Spotlight</button>
<button type="button" data-slhx-handle="move_to_lane" hidden="hidden">Move to lane</button>
</main> </main>
@@ -0,0 +1,3 @@
<div class="inspector-hero">
<span>No issue selected</span>
</div>
@@ -0,0 +1,4 @@
{+= self.selected =+}
<div class="inspector-row"><b>What happened</b><br>{+ self.spotlight +}</div>
<div class="inspector-row"><b>Wire contract</b><br><code>POST __h → application/slhx → EffectBatch</code></div>
<div class="inspector-row"><b>Runtime</b><br>Root-scoped delegated listeners; numeric targets only.</div>
@@ -0,0 +1,5 @@
<div class="inspector-hero">
<span>Selected issue</span>
<strong>{+ self.title +}</strong>
<em>{+ self.lane +} · {+ self.stage +} · impact {+ self.impact +}</em>
</div>
@@ -0,0 +1,9 @@
<article +class="self.class" +data-key="self.id" draggable="true" ondragstart="slhxTechdemoDrag(event)">
<header><strong>{+ self.title +}</strong><span class="pill">{+ self.stage +}</span></header>
<div class="impact"><i +style="self.impact_style"></i></div>
<div class="card-actions">
<button type="button" +data-hid="self.spotlight_handle" +data-work-id="self.id">Inspect</button>
<button type="button" +data-hid="self.advance_handle" +data-work-id="self.id">Advance</button>
<button type="button" +data-hid="self.delete_handle" +data-work-id="self.id">Delete</button>
</div>
</article>
@@ -0,0 +1,7 @@
<section +class="self.class" +data-lane="self.lane_id" +data-move-hid="self.move_handle" ondragover="slhxTechdemoDragOver(event)" ondragleave="slhxTechdemoDragLeave(event)" ondrop="slhxTechdemoDrop(event)">
<h3>{+ self.title +}</h3>
<p>{+ self.description +}</p>
<template h-for="card in &self.cards">
{+ card +}
</template>
</section>
+28 -2
View File
@@ -81,6 +81,10 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
wait_for_text(&driver, "body", "Browser verified issue").await?; wait_for_text(&driver, "body", "Browser verified issue").await?;
assert_text(&driver, "width:88%").await?; assert_text(&driver, "width:88%").await?;
drag_card_to_lane(&driver, 4, "runtime").await?;
wait_for_text(&driver, ".lane[data-lane='runtime'] .work-card[data-key='4']", "Active").await?;
wait_for_text(&driver, &slot_selector(ui::control_center::slots::notice.id().id), "Drag-and-drop move persisted").await?;
driver driver
.find(By::Css(&card_button_selector(ui::control_center::handles::spotlight_work.id().id, 4))) .find(By::Css(&card_button_selector(ui::control_center::handles::spotlight_work.id().id, 4)))
.await? .await?
@@ -89,7 +93,7 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
wait_for_text( wait_for_text(
&driver, &driver,
&slot_selector(ui::control_center::slots::inspector.id().id), &slot_selector(ui::control_center::slots::inspector.id().id),
"Browser verified issue · lane=Product · stage=Draft · impact=8", "Browser verified issue · lane=Runtime · stage=Active · impact=8",
) )
.await?; .await?;
@@ -98,7 +102,7 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
.await? .await?
.click() .click()
.await?; .await?;
wait_for_text(&driver, ".work-card[data-key='4']", "Active").await?; wait_for_text(&driver, ".lane[data-lane='product'] .work-card[data-key='4']", "Shipped").await?;
driver driver
.find(By::Css(&handle_selector(ui::control_center::handles::simulate_push.id().id))) .find(By::Css(&handle_selector(ui::control_center::handles::simulate_push.id().id)))
@@ -145,6 +149,28 @@ fn card_button_selector(handle_id: u32, work_id: u64) -> String {
format!(r#"[data-hid="{handle_id}"][data-work-id="{work_id}"]"#) format!(r#"[data-hid="{handle_id}"][data-work-id="{work_id}"]"#)
} }
async fn drag_card_to_lane(driver: &WebDriver, work_id: u64, lane: &str) -> WebDriverResult<()> {
driver
.execute(
&format!(
r#"
const card = document.querySelector({:?});
const lane = document.querySelector({:?});
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;
"#,
format!(".work-card[data-key='{work_id}']"),
format!(".lane[data-lane='{lane}']"),
),
Vec::new(),
)
.await?;
Ok(())
}
async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> { 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 {
+10 -1
View File
@@ -101,10 +101,19 @@ fn product_is_e2e_working_over_http() {
assert_payload_not_contains(&missing_title_batch, "data-key=\"8\""); assert_payload_not_contains(&missing_title_batch, "data-key=\"8\"");
assert_payload_not_contains(&missing_title_batch, "MUTATED"); assert_payload_not_contains(&missing_title_batch, "MUTATED");
let move_to_lane = post(
"/",
&format!("__h={}&work_id=4&lane=runtime", ui::control_center::handles::move_to_lane.id().id),
);
assert_effect_response(&move_to_lane);
let move_to_lane_batch = move_to_lane.batch();
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");
let inspect = post("/", "__h=3112123592&work_id=4"); let inspect = post("/", "__h=3112123592&work_id=4");
assert_effect_response(&inspect); assert_effect_response(&inspect);
let inspect_batch = inspect.batch(); let inspect_batch = inspect.batch();
assert_payload_contains(&inspect_batch, "Design hero moment · lane=Product"); 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("/", "__h=4085043678&work_id=4"); let advance = post("/", "__h=4085043678&work_id=4");