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