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:
+175
-41
@@ -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)]
|
||||
pub enum ScopeKey {
|
||||
KeyValue(String),
|
||||
@@ -281,6 +288,10 @@ impl EventName {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
self.name
|
||||
}
|
||||
|
||||
pub fn emit(self, payload: impl Into<String>) -> Effect {
|
||||
event(self, payload)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for EventName {
|
||||
@@ -402,19 +413,42 @@ pub enum FormControlKind {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Effect {
|
||||
Put { target: ResourceRef, payload: Payload },
|
||||
Insert { 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 },
|
||||
Put {
|
||||
target: ResourceRef,
|
||||
payload: Payload,
|
||||
},
|
||||
Insert {
|
||||
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 {
|
||||
url: String,
|
||||
mode: NavigateMode,
|
||||
scroll: ScrollBehavior,
|
||||
title: Option<String>,
|
||||
},
|
||||
Emit { name: String, payload: String },
|
||||
Emit {
|
||||
name: String,
|
||||
payload: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[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_payload(payload, out);
|
||||
}
|
||||
Effect::Insert { target, key, payload } => {
|
||||
Effect::Insert {
|
||||
target,
|
||||
key,
|
||||
payload,
|
||||
} => {
|
||||
write_u8(1, out);
|
||||
write_ref(target, out);
|
||||
write_str(key, out);
|
||||
write_payload(payload, out);
|
||||
}
|
||||
Effect::Prepend { target, key, payload } => {
|
||||
Effect::Prepend {
|
||||
target,
|
||||
key,
|
||||
payload,
|
||||
} => {
|
||||
write_u8(2, out);
|
||||
write_ref(target, out);
|
||||
write_str(key, out);
|
||||
@@ -493,7 +535,11 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
|
||||
write_ref(target, out);
|
||||
write_option_str(key.as_deref(), out);
|
||||
}
|
||||
Effect::Move { target, key, before } => {
|
||||
Effect::Move {
|
||||
target,
|
||||
key,
|
||||
before,
|
||||
} => {
|
||||
write_u8(4, out);
|
||||
write_ref(target, out);
|
||||
write_str(key, out);
|
||||
@@ -503,14 +549,22 @@ fn write_effect(effect: &Effect, out: &mut Vec<u8>) {
|
||||
write_u8(5, out);
|
||||
write_ref(target, out);
|
||||
}
|
||||
Effect::Navigate { url, mode, scroll, title } => {
|
||||
Effect::Navigate {
|
||||
url,
|
||||
mode,
|
||||
scroll,
|
||||
title,
|
||||
} => {
|
||||
write_u8(6, out);
|
||||
write_str(url, out);
|
||||
write_u8(match mode {
|
||||
NavigateMode::Push => 0,
|
||||
NavigateMode::Replace => 1,
|
||||
NavigateMode::Redirect => 2,
|
||||
}, out);
|
||||
write_u8(
|
||||
match mode {
|
||||
NavigateMode::Push => 0,
|
||||
NavigateMode::Replace => 1,
|
||||
NavigateMode::Redirect => 2,
|
||||
},
|
||||
out,
|
||||
);
|
||||
write_scroll(scroll, 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>) {
|
||||
write_u8(match reference.resource.kind {
|
||||
ResourceKind::Slot => 0,
|
||||
ResourceKind::Atom => 1,
|
||||
ResourceKind::Handle => 2,
|
||||
ResourceKind::Form => 3,
|
||||
}, out);
|
||||
write_u8(
|
||||
match reference.resource.kind {
|
||||
ResourceKind::Slot => 0,
|
||||
ResourceKind::Atom => 1,
|
||||
ResourceKind::Handle => 2,
|
||||
ResourceKind::Form => 3,
|
||||
},
|
||||
out,
|
||||
);
|
||||
write_u32(reference.resource.id, out);
|
||||
match &reference.scope {
|
||||
None => write_u8(0, out),
|
||||
@@ -661,17 +718,41 @@ fn read_batch(bytes: &[u8]) -> Result<EffectBatch, WireError> {
|
||||
ops.push(read_effect(&mut reader)?);
|
||||
}
|
||||
reader.finish()?;
|
||||
Ok(EffectBatch { abi_version, fingerprint, ops })
|
||||
Ok(EffectBatch {
|
||||
abi_version,
|
||||
fingerprint,
|
||||
ops,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
|
||||
match reader.read_u8()? {
|
||||
0 => Ok(Effect::Put { target: read_ref(reader)?, payload: read_payload(reader)? }),
|
||||
1 => Ok(Effect::Insert { 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)? }),
|
||||
0 => Ok(Effect::Put {
|
||||
target: read_ref(reader)?,
|
||||
payload: read_payload(reader)?,
|
||||
}),
|
||||
1 => Ok(Effect::Insert {
|
||||
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 {
|
||||
url: reader.read_str()?,
|
||||
mode: match reader.read_u8()? {
|
||||
@@ -683,7 +764,10 @@ fn read_effect(reader: &mut WireReader<'_>) -> Result<Effect, WireError> {
|
||||
scroll: read_scroll(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),
|
||||
}
|
||||
}
|
||||
@@ -758,6 +842,14 @@ impl IntoEffect for () {
|
||||
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 {
|
||||
($($name:ident $idx:tt),+) => {
|
||||
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 {
|
||||
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 {
|
||||
target: ResourceRef::unscoped(self.id),
|
||||
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 {
|
||||
target: ResourceRef::unscoped(self.id),
|
||||
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();
|
||||
Effect::Put {
|
||||
target: ResourceRef {
|
||||
resource: self.id,
|
||||
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 {}
|
||||
|
||||
@@ -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 {
|
||||
target: self.field(field),
|
||||
payload: Payload::text(""),
|
||||
|
||||
@@ -28,6 +28,16 @@ fn effect_batch_wire_round_trips() {
|
||||
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]
|
||||
fn keyed_slot_replace_uses_scoped_resource_ref() {
|
||||
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.to_string(), "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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user