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    // Guard against re-seeding form_data on subsequent memo evaluations.
165    // Writing a signal inside a use_memo would cause Dioxus SSR to
166    // re-evaluate the memo synchronously (signal changed → memo dirty →
167    // re-run → signal changes again → ...), producing an infinite loop and
168    // a stack overflow.  The flag is set once; subsequent evaluations skip
169    // the write and avoid the loop.
170    let mut seeded = use_signal(|| false);
171
172    // Derive fields reactively from seed_data so they are available on SSR
173    // (use_effect doesn't run during server-side rendering).
174    let fields = use_memo(move || {
175        let raw_fields = match &*seed_data.read() {
176            HyleDataState::Ready { row: Some(r), manifest, outcome, .. } => {
177                // Seed form_data from the row the first time it arrives.
178                // Only write when not yet seeded to break the reactive loop.
179                if !seeded() {
180                    let row_data: IndexMap<String, String> = r
181                        .iter()
182                        .map(|(k, v)| {
183                            let s = match v {
184                                Value::String(s) => s.clone(),
185                                Value::Null => String::new(),
186                                Value::Array(arr) => arr
187                                    .iter()
188                                    .map(|item| match item {
189                                        Value::String(s) => s.clone(),
190                                        other => other.to_string(),
191                                    })
192                                    .collect::<Vec<_>>()
193                                    .join(","),
194                                other => other.to_string(),
195                            };
196                            (k.clone(), s)
197                        })
198                        .collect();
199                    form_data.set(row_data);
200                    seeded.set(true);
201                }
202                build_filter_fields(&bp_for_fields, manifest, outcome)
203                    .into_iter()
204                    .map(|f| HyleFilterField { key: f.key, label: f.label, field: f.field, options: f.options, display_field_type: f.display_field_type, render: None })
205                    .collect()
206            }
207            HyleDataState::Ready { manifest, outcome, .. } => {
208                build_filter_fields(&bp_for_fields, manifest, outcome)
209                    .into_iter()
210                    .map(|f| HyleFilterField { key: f.key, label: f.label, field: f.field, options: f.options, display_field_type: f.display_field_type, render: None })
211                    .collect()
212            }
213            _ => vec![],
214        };
215        if let Some(ref c) = change {
216            hyle::apply_change(raw_fields, c)
217        } else {
218            raw_fields
219        }
220    });
221
222    let effective_query = use_memo(move || {
223        let q = query.clone();
224        let committed_snapshot = committed.cloned();
225        build_effective_query(&q, &committed_snapshot, q.page.unwrap_or(1), q.per_page.unwrap_or(5), None, true)
226    });
227
228    let set_field = use_callback(move |(name, value): (String, String)| {
229        form_data.with_mut(|m: &mut IndexMap<String, String>| { m.insert(name, value); });
230    });
231
232    let filter_apply = use_callback(move |()| {
233        let snapshot = form_data.cloned();
234        committed.with_mut(|c: &mut IndexMap<String, String>| c.extend(snapshot));
235    });
236
237    let filter_clear = use_callback(move |()| {
238        form_data.set(IndexMap::new());
239        committed.set(IndexMap::new());
240        filter_reset_key.with_mut(|k| *k += 1);
241    });
242
243    let validate = use_callback(move |()| {
244        let snapshot = form_data.cloned();
245        let model_name = effective_query.read().model.clone();
246        let active_keys: std::collections::HashSet<String> =
247            fields.read().iter().map(|f| f.key.clone()).collect();
248        let active_snapshot: IndexMap<String, String> = snapshot
249            .iter()
250            .filter(|(k, _)| active_keys.contains(*k))
251            .map(|(k, v)| (k.clone(), v.clone()))
252            .collect();
253        let errors = run_purify(&bp_for_validate, &model_name, &active_snapshot);
254        purify_errors.set(errors);
255    });
256
257    HyleFiltersState {
258        query: effective_query,
259        fields,
260        form_data,
261        set_field,
262        filter_apply,
263        filter_clear,
264        filter_reset_key,
265        validate,
266        purify_errors,
267    }
268}
269
270/// Auto-wired form hook. Derives edit/create mode from the query, reads the
271/// appropriate mutation from `HyleAdapterConfig` context, and delegates filter
272/// state to [`use_filters`].
273///
274/// # Panics
275///
276/// Panics if `use_adapter_config!` has not been called at the app root.
277#[must_use]
278pub fn use_form(
279    query: Query,
280    opts: crate::types::UseFormOptions,
281) -> crate::types::HyleFormState {
282    use dioxus::prelude::try_consume_context;
283
284    let adapter = try_consume_context::<HyleAdapter>()
285        .expect("HyleAdapter must be provided via use_adapter_config! at the app root");
286
287    let is_edit = query.where_.contains_key("id");
288    let model = query.model.clone();
289    let mutation = if is_edit { adapter.update } else { adapter.create };
290
291    let filters = use_filters(
292        query,
293        crate::types::UseFiltersOptions {
294            initial_committed: opts.initial_committed,
295            change: opts.change,
296        },
297    );
298
299    let is_valid = filters.purify_errors.read().is_none();
300
301    let on_submit = use_callback(move |()| {
302        filters.validate.call(());
303        if filters.purify_errors.read().is_some() {
304            return;
305        }
306        let snapshot = filters.form_data.cloned();
307        let id = snapshot.get("id").map(|v| {
308            v.parse::<u64>().map(JsonValue::from).unwrap_or_else(|_| JsonValue::String(v.clone()))
309        });
310        mutation.mutate.call(MutateInput { model: model.clone(), id, data: snapshot });
311    });
312
313    crate::types::HyleFormState { filters, is_edit, is_valid, on_submit, mutation }
314}
315
316/// Returns create/update/delete mutation handles with `model` pre-bound.
317///
318/// # Panics
319///
320/// Panics if `use_adapter_config!` has not been called at the app root.
321#[must_use]
322pub fn use_mutation(model: &'static str) -> crate::types::BoundMutations {
323    use dioxus::prelude::try_consume_context;
324    use crate::types::{BoundMutation, BoundMutateInput, BoundMutations};
325
326    let adapter = try_consume_context::<HyleAdapter>()
327        .expect("HyleAdapter must be provided via use_adapter_config! at the app root");
328
329    let bind = |hm: crate::types::HyleMutation| -> BoundMutation {
330        let mutate = use_callback(move |input: BoundMutateInput| {
331            hm.mutate.call(MutateInput { model: model.to_owned(), id: input.id, data: input.data });
332        });
333        BoundMutation { mutate, is_pending: hm.is_pending, is_success: hm.is_success, error: hm.error }
334    };
335
336    BoundMutations {
337        create: bind(adapter.create),
338        update: bind(adapter.update),
339        delete: bind(adapter.delete),
340    }
341}
342
343/// Fetch a forma definition from the `"forma"` model and derive a query for
344/// the target table.
345///
346/// Returns a `Memo` that resolves to `(Option<Query>, Option<Forma>)`.
347#[must_use]
348pub fn use_forma(
349    table_name: &'static str,
350    id: Option<JsonValue>,
351    opts: UseFormaOptions,
352) -> Memo<(Option<Query>, Option<Forma>)> {
353    use crate::types::FORMA_MODEL;
354
355    let forma_query = Query {
356        model: FORMA_MODEL.to_owned(),
357        where_: indexmap::indexmap! { "id".to_owned() => JsonValue::String(table_name.to_owned()) },
358        method: Some("one".to_owned()),
359        select: vec![
360            "fields".to_owned(),
361            "detail".to_owned(),
362            "form".to_owned(),
363            "column".to_owned(),
364            "filters".to_owned(),
365        ],
366        ..Default::default()
367    };
368
369    let data = use_data(forma_query);
370
371    use_memo(move || {
372        compute_forma_result(&data.cloned(), table_name, id.clone(), &opts.context)
373    })
374}
375
376// ── Utilities ─────────────────────────────────────────────────────────────────
377
378/// Convert an `IndexMap<String, String>` (e.g. a parsed HTML form body) into a
379/// JSON [`Value`] object.
380///
381/// ```rust
382/// use indexmap::IndexMap;
383/// use hyle_dioxus::{form_body, Value};
384///
385/// let mut form = IndexMap::new();
386/// form.insert("name".into(), "Alice".into());
387/// let body = form_body(&form);
388/// assert_eq!(body["name"], Value::String("Alice".into()));
389/// ```
390pub fn form_body(data: &IndexMap<String, String>) -> Value {
391    let map: serde_json::Map<String, Value> = data
392        .iter()
393        .map(|(k, v)| (k.clone(), Value::String(v.clone())))
394        .collect();
395    Value::Object(map)
396}