Skip to main content

hyle_dioxus/
hooks.rs

1use dioxus_hooks::{use_callback, use_memo, use_signal, use_context};
2use dioxus_signals::{Memo, ReadableExt, WritableExt};
3use indexmap::IndexMap;
4use serde_json::Value as JsonValue;
5
6use hyle::{
7    build_effective_query, build_filter_fields, compute_data, compute_forma_result,
8    compute_manifest, run_purify,
9    Forma, MutateInput, PurifyError, Query, Value,
10    HyleDataState, HyleManifestState, UseFormaOptions,
11};
12
13use crate::context::use_hyle_config;
14use crate::types::{
15    HyleAdapter, HyleFilterField, HyleFiltersState, HyleListState,
16    HyleSourceState, UseFiltersOptions,
17};
18
19// ── Hooks ─────────────────────────────────────────────────────────────────────
20
21/// Reactively derive a `Manifest` from a query.
22///
23/// Because `hyle` is pure Rust there is no async loading phase — the result is
24/// available synchronously on every render.
25#[must_use]
26pub fn use_manifest(query: Query) -> Memo<HyleManifestState> {
27    let config = use_hyle_config();
28    use_memo(move || compute_manifest(&config.blueprint, &query))
29}
30
31/// Reactively resolve data for a query.
32#[must_use]
33pub fn use_data(query: Query) -> Memo<HyleDataState> {
34    let config = use_hyle_config();
35    let adapter = use_context::<HyleAdapter>();
36    use_memo(move || {
37        let bp = config.blueprint.clone();
38        let source = adapter.source;
39
40        let manifest = match bp.manifest(query.clone()) {
41            Ok(m) => m,
42            Err(e) => return HyleDataState::Error { error: e.to_string(), manifest: None },
43        };
44
45        match source.read().clone() {
46            HyleSourceState::Loading => HyleDataState::Loading { manifest: Some(manifest) },
47            HyleSourceState::Error(e) => HyleDataState::Error { error: e, manifest: Some(manifest) },
48            HyleSourceState::Ready(src) => compute_data(bp, manifest, src),
49        }
50    })
51}
52
53/// Shared data-memo body used by both `use_list` and `use_list_with_filters`.
54fn use_list_data(effective_query: Memo<Query>) -> Memo<HyleDataState> {
55    let config = use_hyle_config();
56    let adapter = use_context::<HyleAdapter>();
57    use_memo(move || {
58        let bp = config.blueprint.clone();
59        let source = adapter.source;
60        let query = effective_query.read().clone();
61
62        let manifest = match bp.manifest(query) {
63            Ok(m) => m,
64            Err(e) => return HyleDataState::Error { error: e.to_string(), manifest: None },
65        };
66
67        match source.read().clone() {
68            HyleSourceState::Loading => HyleDataState::Loading { manifest: Some(manifest) },
69            HyleSourceState::Error(e) => HyleDataState::Error { error: e, manifest: Some(manifest) },
70            HyleSourceState::Ready(src) => compute_data(bp, manifest, src),
71        }
72    })
73}
74
75/// Reactive list view with pagination and sort signals.
76#[must_use]
77pub fn use_list(query: Query) -> HyleListState {
78    let page = use_signal(|| query.page.unwrap_or(1));
79    let per_page = use_signal(|| query.per_page.unwrap_or(5));
80    let sort_field = use_signal(|| query.sort.as_ref().map(|s| s.field.clone()));
81    let sort_ascending = use_signal(|| query.sort.as_ref().map(|s| s.ascending).unwrap_or(true));
82
83    let effective_query = use_memo(move || {
84        build_effective_query(
85            &query,
86            &IndexMap::new(),
87            page(),
88            per_page(),
89            sort_field().as_deref(),
90            sort_ascending(),
91        )
92    });
93
94    let data = use_list_data(effective_query);
95
96    HyleListState { data, query: effective_query, page, per_page, sort_field, sort_ascending }
97}
98
99/// Reactive list view driven by a live `HyleFiltersState`.
100#[must_use]
101pub fn use_list_with_filters(filters: HyleFiltersState) -> HyleListState {
102    let base = filters.query.read().clone();
103    let page = use_signal(|| base.page.unwrap_or(1));
104    let per_page = use_signal(|| base.per_page.unwrap_or(5));
105    let sort_field = use_signal(|| base.sort.as_ref().map(|s| s.field.clone()));
106    let sort_ascending = use_signal(|| base.sort.as_ref().map(|s| s.ascending).unwrap_or(true));
107    let filter_query = filters.query;
108
109    let effective_query = use_memo(move || {
110        let base = filter_query.read().clone();
111        build_effective_query(
112            &base,
113            &IndexMap::new(),
114            page(),
115            per_page(),
116            sort_field().as_deref(),
117            sort_ascending(),
118        )
119    });
120
121    let data = use_list_data(effective_query);
122
123    HyleListState { data, query: effective_query, page, per_page, sort_field, sort_ascending }
124}
125
126/// Reactive filter/form state with validation.
127///
128/// - `set_field` updates `form_data` without committing.
129/// - `filter_apply` merges `form_data` into the effective `where_` clause.
130/// - `filter_clear` resets both `form_data` and committed state.
131/// - `validate` runs `purify_row_sync` and updates `purify_errors`.
132///
133/// When `query.where_` contains an `"id"` key, `use_data` is called internally
134/// to seed `form_data` from the existing record.
135#[must_use]
136pub fn use_filters(
137    query: Query,
138    options: UseFiltersOptions,
139) -> HyleFiltersState {
140    let config = use_hyle_config();
141
142    let initial = options.initial_committed;
143    let initial2 = initial.clone();
144    let mut committed = use_signal(move || initial.clone());
145    let mut form_data = use_signal(move || initial2.clone());
146    let mut filter_reset_key = use_signal(|| 0u32);
147    let mut purify_errors = use_signal(|| Option::<Vec<PurifyError>>::None);
148    let change = options.change;
149
150    // When an id is present, fetch the existing record to seed form_data.
151    // When no id is present (filter mode), fetch without filters/pagination so
152    // we still get a manifest + lookups to populate HyleFilterField metadata.
153    let has_id = query.where_.contains_key("id");
154    let seed_query = if has_id {
155        query.clone()
156    } else {
157        Query { model: query.model.clone(), select: query.select.clone(), ..Default::default() }
158    };
159    let seed_data = use_data(seed_query);
160
161    let bp_for_fields = config.blueprint.clone();
162    let bp_for_validate = config.blueprint.clone();
163
164    // Derive fields reactively from seed_data so they are available on SSR
165    // (use_effect doesn't run during server-side rendering).
166    let fields = use_memo(move || {
167        let raw_fields = match &*seed_data.read() {
168            HyleDataState::Ready { row: Some(r), manifest, outcome, .. } => {
169                // Seed form_data from the row the first time it arrives.
170                let seeded: IndexMap<String, String> = r
171                    .iter()
172                    .map(|(k, v)| {
173                        let s = match v {
174                            Value::String(s) => s.clone(),
175                            Value::Null => String::new(),
176                            Value::Array(arr) => arr
177                                .iter()
178                                .map(|item| match item {
179                                    Value::String(s) => s.clone(),
180                                    other => other.to_string(),
181                                })
182                                .collect::<Vec<_>>()
183                                .join(","),
184                            other => other.to_string(),
185                        };
186                        (k.clone(), s)
187                    })
188                    .collect();
189                form_data.set(seeded);
190                // NOTE: `form_data.set()` is a side-effect inside a `use_memo` (normally a
191                // pure computation). Ideally this would live in a `use_effect`, but Dioxus
192                // SSR does not execute `use_effect` during server-side rendering, so moving
193                // it there would break pre-rendered forms. The extra render cycle this causes
194                // is acceptable given the SSR constraint.
195                build_filter_fields(&bp_for_fields, manifest, outcome)
196                    .into_iter()
197                    .map(|f| HyleFilterField { key: f.key, label: f.label, field: f.field, options: f.options, render: None })
198                    .collect()
199            }
200            HyleDataState::Ready { manifest, outcome, .. } => {
201                build_filter_fields(&bp_for_fields, manifest, outcome)
202                    .into_iter()
203                    .map(|f| HyleFilterField { key: f.key, label: f.label, field: f.field, options: f.options, render: None })
204                    .collect()
205            }
206            _ => vec![],
207        };
208        if let Some(ref c) = change {
209            hyle::apply_change(raw_fields, c)
210        } else {
211            raw_fields
212        }
213    });
214
215    let effective_query = use_memo(move || {
216        let q = query.clone();
217        let committed_snapshot = committed.cloned();
218        build_effective_query(&q, &committed_snapshot, q.page.unwrap_or(1), q.per_page.unwrap_or(5), None, true)
219    });
220
221    let set_field = use_callback(move |(name, value): (String, String)| {
222        form_data.with_mut(|m: &mut IndexMap<String, String>| { m.insert(name, value); });
223    });
224
225    let filter_apply = use_callback(move |()| {
226        let snapshot = form_data.cloned();
227        committed.with_mut(|c: &mut IndexMap<String, String>| c.extend(snapshot));
228    });
229
230    let filter_clear = use_callback(move |()| {
231        form_data.set(IndexMap::new());
232        committed.set(IndexMap::new());
233        filter_reset_key.with_mut(|k| *k += 1);
234    });
235
236    let validate = use_callback(move |()| {
237        let snapshot = form_data.cloned();
238        let model_name = effective_query.read().model.clone();
239        let active_keys: std::collections::HashSet<String> =
240            fields.read().iter().map(|f| f.key.clone()).collect();
241        let active_snapshot: IndexMap<String, String> = snapshot
242            .iter()
243            .filter(|(k, _)| active_keys.contains(*k))
244            .map(|(k, v)| (k.clone(), v.clone()))
245            .collect();
246        let errors = run_purify(&bp_for_validate, &model_name, &active_snapshot);
247        purify_errors.set(errors);
248    });
249
250    HyleFiltersState {
251        query: effective_query,
252        fields,
253        form_data,
254        set_field,
255        filter_apply,
256        filter_clear,
257        filter_reset_key,
258        validate,
259        purify_errors,
260    }
261}
262
263/// Auto-wired form hook. Derives edit/create mode from the query, reads the
264/// appropriate mutation from `HyleAdapterConfig` context, and delegates filter
265/// state to [`use_filters`].
266///
267/// # Panics
268///
269/// Panics if `use_adapter_config!` has not been called at the app root.
270#[must_use]
271pub fn use_form(
272    query: Query,
273    opts: crate::types::UseFormOptions,
274) -> crate::types::HyleFormState {
275    use dioxus::prelude::try_consume_context;
276
277    let adapter = try_consume_context::<HyleAdapter>()
278        .expect("HyleAdapter must be provided via use_adapter_config! at the app root");
279
280    let is_edit = query.where_.contains_key("id");
281    let model = query.model.clone();
282    let mutation = if is_edit { adapter.update } else { adapter.create };
283
284    let filters = use_filters(
285        query,
286        crate::types::UseFiltersOptions {
287            initial_committed: opts.initial_committed,
288            change: opts.change,
289        },
290    );
291
292    let is_valid = filters.purify_errors.read().is_none();
293
294    let on_submit = use_callback(move |()| {
295        filters.validate.call(());
296        if filters.purify_errors.read().is_some() {
297            return;
298        }
299        let snapshot = filters.form_data.cloned();
300        let id = snapshot.get("id").map(|v| {
301            v.parse::<u64>().map(JsonValue::from).unwrap_or_else(|_| JsonValue::String(v.clone()))
302        });
303        mutation.mutate.call(MutateInput { model: model.clone(), id, data: snapshot });
304    });
305
306    crate::types::HyleFormState { filters, is_edit, is_valid, on_submit, mutation }
307}
308
309/// Returns create/update/delete mutation handles with `model` pre-bound.
310///
311/// # Panics
312///
313/// Panics if `use_adapter_config!` has not been called at the app root.
314#[must_use]
315pub fn use_mutation(model: &'static str) -> crate::types::BoundMutations {
316    use dioxus::prelude::try_consume_context;
317    use crate::types::{BoundMutation, BoundMutateInput, BoundMutations};
318
319    let adapter = try_consume_context::<HyleAdapter>()
320        .expect("HyleAdapter must be provided via use_adapter_config! at the app root");
321
322    let bind = |hm: crate::types::HyleMutation| -> BoundMutation {
323        let mutate = use_callback(move |input: BoundMutateInput| {
324            hm.mutate.call(MutateInput { model: model.to_owned(), id: input.id, data: input.data });
325        });
326        BoundMutation { mutate, is_pending: hm.is_pending, is_success: hm.is_success, error: hm.error }
327    };
328
329    BoundMutations {
330        create: bind(adapter.create),
331        update: bind(adapter.update),
332        delete: bind(adapter.delete),
333    }
334}
335
336/// Fetch a forma definition from the `"forma"` model and derive a query for
337/// the target table.
338///
339/// Returns a `Memo` that resolves to `(Option<Query>, Option<Forma>)`.
340#[must_use]
341pub fn use_forma(
342    table_name: &'static str,
343    id: Option<JsonValue>,
344    opts: UseFormaOptions,
345) -> Memo<(Option<Query>, Option<Forma>)> {
346    use crate::types::FORMA_MODEL;
347
348    let forma_query = Query {
349        model: FORMA_MODEL.to_owned(),
350        where_: indexmap::indexmap! { "id".to_owned() => JsonValue::String(table_name.to_owned()) },
351        method: Some("one".to_owned()),
352        select: vec![
353            "fields".to_owned(),
354            "detail".to_owned(),
355            "form".to_owned(),
356            "column".to_owned(),
357            "filters".to_owned(),
358        ],
359        ..Default::default()
360    };
361
362    let data = use_data(forma_query);
363
364    use_memo(move || {
365        compute_forma_result(&data.cloned(), table_name, id.clone(), &opts.context)
366    })
367}
368
369// ── Utilities ─────────────────────────────────────────────────────────────────
370
371/// Convert an `IndexMap<String, String>` (e.g. a parsed HTML form body) into a
372/// JSON [`Value`] object.
373///
374/// ```rust
375/// use indexmap::IndexMap;
376/// use hyle_dioxus::{form_body, Value};
377///
378/// let mut form = IndexMap::new();
379/// form.insert("name".into(), "Alice".into());
380/// let body = form_body(&form);
381/// assert_eq!(body["name"], Value::String("Alice".into()));
382/// ```
383pub fn form_body(data: &IndexMap<String, String>) -> Value {
384    let map: serde_json::Map<String, Value> = data
385        .iter()
386        .map(|(k, v)| (k.clone(), Value::String(v.clone())))
387        .collect();
388    Value::Object(map)
389}