Skip to content

Commit d70f326

Browse files
committed
Merge branch 'main' of github.com:MyJetTools/development-mcp
2 parents ca63fd6 + 4dcfd64 commit d70f326

1 file changed

Lines changed: 234 additions & 30 deletions

File tree

docs/DIOXUS_FULLSTACK_DESIGN_PATTERS.md

Lines changed: 234 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,47 @@ Applies to Dioxus **fullstack** projects (shared code server/web). Use these whe
1010
### 0) Shared models (client + server)
1111
- **Rule**: If a struct is used both on server **and** client (e.g., returned from a server function and rendered in UI) → put it in `src/models`.
1212
- **Rule**: If a struct is only used inside a server function (e.g., parsing an external API response) → keep it private in the `src/api/*.rs` file, gated with `#[cfg(feature = "server")]`.
13+
- **Naming**: Models returned from server functions (HTTP/server fn boundary) use the `HttpModel` suffix: `BalanceHistoryHttpModel`, `InstrumentHttpModel`.
14+
- **One file per type**: each model lives in its own file (`models/balance_history.rs`, `models/instrument.rs`).
15+
- `mod.rs` uses the standard re-export pattern: `mod x; pub use x::*;`
16+
- In components: `use crate::models::*;` — never enumerate types explicitly.
1317
- Derive `Serialize`/`Deserialize` for anything crossing the wire; keep structs minimal and web-safe.
14-
- If a model is used in `http_route` responses (server-only), also derive `MyHttpObjectStructure` when required by your HTTP doc tooling; otherwise `Serialize`/`Deserialize` is enough.
1518
- **Examples**:
16-
- `BinanceInstrumentCheckResponse` → in `src/models` (returned to client, shown in dialog)
17-
- `BinanceExchangeInfo`, `BinanceSymbolInfo` → private in `src/api/binance.rs` with `#[cfg(feature = "server")]` (only used to parse Binance API response)
18-
- `InputValue<T>` → in `src/models` (shared validation helper used in components)
19+
- `BalanceHistoryHttpModel` → in `src/models/balance_history.rs` (returned to client, shown in UI)
20+
- `BinanceExchangeInfo` → private in `src/api/binance.rs` with `#[cfg(feature = "server")]` (only used to parse external API response)
21+
22+
### 0.1) Mappers (server-only)
23+
- Mappers know about gRPC/server contracts — they live in `src/server/` (not visible to client code).
24+
- **1:1 mapping**`impl From<GrpcModel> for HttpModel`, then in api: `.map(|i| i.into()).collect()`
25+
- **Complex mapping** (multiple structs, logic) → a mapper function in `src/server/mappers/`
26+
27+
```rust
28+
// ✅ 1:1 — impl From in server/mappers/ or alongside the model
29+
impl From<BalanceHistoryGrpcModel> for BalanceHistoryHttpModel {
30+
fn from(i: BalanceHistoryGrpcModel) -> Self {
31+
Self { id: i.id, delta: i.delta, balance_after: i.balance_after, comment: i.comment, moment: i.moment }
32+
}
33+
}
34+
35+
// api/balance.rs — clean, no manual field mapping
36+
Ok(items.into_iter().map(|i| i.into()).collect())
37+
```
38+
39+
### 0.2) API calls — always full path, never `use`
40+
```rust
41+
// ✅ CORRECT — visible that this is a server function call
42+
crate::api::accounts::get_account(id).await
43+
crate::api::balance::balance_update(id, delta, comment).await
44+
45+
// ❌ WRONG — looks like a local function, hides the boundary
46+
use crate::api::accounts::get_account;
47+
get_account(id).await
48+
```
1949

2050
### 1) Dialogs: lifecycle and rendering
2151
- Keep a global `DialogState` in context (`Signal<DialogState>`). Define variants per dialog (`Confirmation`, `EditInstrument`, etc.).
2252
- Render all dialogs centrally via `RenderDialog`, matching on `DialogState` and embedding the concrete dialog component.
23-
- Use `DialogTemplate` for consistent header, close “X”, cancel button, and optional OK slot.
53+
- Use `DialogTemplate` for consistent header, close "X", cancel button, and optional OK slot.
2454
- Close dialogs by setting state to `DialogState::None` (either via `close()` or `set(DialogState::None)`).
2555
- **Example: `DialogState` and renderer**
2656
```rust
@@ -131,25 +161,83 @@ Applies to Dioxus **fullstack** projects (shared code server/web). Use these whe
131161
};
132162
```
133163

134-
### 6) Data loading lists
135-
- Use the `DataState`/`RenderState` pattern: start `None`, set `Loading`, fire `spawn` to fetch, then `set_value` or `set_error`.
136-
- After a mutation (save/delete), call `data.reset()` to force a reload through the existing load logic.
137-
- Filter/search by keeping the search string in state and applying it before rendering rows.
138-
- **Example: load on first render**
139-
```rust
140-
if matches!(state.data.as_ref(), RenderState::None) {
141-
spawn(async move {
142-
state.write().data.set_loading();
143-
match crate::api::instruments::get_instruments().await {
144-
Ok(items) => state.write().data.set_value(items.into_iter().map(Rc::new).collect()),
145-
Err(err) => state.write().data.set_error(err),
146-
}
147-
});
148-
return loading();
149-
}
150-
```
164+
### 6) Data loading — DataState + `get_data` pattern
165+
166+
**Every** piece of async data uses `DataState<T>` and a `get_data` helper function. Never call API directly in a component body or trigger loading via manual `loading: bool` fields.
167+
168+
The helper handles all four states and triggers loading automatically on first render (`None` branch spawns the fetch):
169+
170+
```rust
171+
#[derive(Default)]
172+
struct MyListState {
173+
data: DataState<Vec<MyHttpModel>>,
174+
}
175+
176+
#[component]
177+
fn MyList(some_id: i64) -> Element {
178+
let cs = use_signal(MyListState::default);
179+
let cs_ra = cs.read();
180+
181+
let items = match get_my_data(cs, &cs_ra, some_id) {
182+
Ok(d) => d,
183+
Err(el) => return el,
184+
};
185+
rsx! { /* render items */ }
186+
}
151187

152-
### 7) Server functions as API boundary (fullstack)
188+
fn get_my_data<'a>(
189+
mut cs: Signal<MyListState>,
190+
cs_ra: &'a MyListState,
191+
some_id: i64,
192+
) -> Result<&'a [MyHttpModel], Element> {
193+
match cs_ra.data.as_ref() {
194+
RenderState::None => {
195+
spawn(async move {
196+
cs.write().data.set_loading();
197+
match crate::api::something::get_items(some_id).await {
198+
Ok(data) => cs.write().data.set_loaded(data),
199+
Err(e) => cs.write().data.set_error(e.to_string()),
200+
}
201+
});
202+
Err(render_loading())
203+
}
204+
RenderState::Loading => Err(render_loading()),
205+
RenderState::Loaded(data) => Ok(data.as_slice()),
206+
RenderState::Error(err) => Err(render_error(err.as_str())),
207+
}
208+
}
209+
```
210+
211+
**Forced reload after mutation**: call `.reset()` on `DataState` — it returns to `None`, and the next render triggers a fresh load automatically.
212+
213+
```rust
214+
// After save/delete — just reset, get_data will reload on next render
215+
cs.write().data.reset();
216+
```
217+
218+
If the reset is triggered from **outside** the component (e.g., parent after a mutation), the `DataState` must be accessible from the caller: either lift it into the parent's state or pass `Signal<ChildState>` as a prop.
219+
220+
### 7) Component structure for pages with tabs and lists
221+
222+
- Each **tab** is its own `#[component]` receiving an ID prop and owning its `DataState`.
223+
- Each **list within a tab** (e.g., active positions + pending orders) is also a separate component with its own `DataState`.
224+
- **Page-level state** holds only UI: current tab, search input, selected item, flags — **never data arrays**.
225+
226+
```
227+
PageComponent ← PageState: input, selected item, tab, ui flags only
228+
└── ContentComponent
229+
├── TabA { id } ← own State + DataState, get_data pattern
230+
│ ├── ListOne { id } ← own State + DataState
231+
│ └── ListTwo { id } ← own State + DataState
232+
├── TabB { id } ← own State + DataState
233+
└── TabC { id, cs } ← uses parent Signal if parent needs to trigger reset
234+
```
235+
236+
When a child tab needs to be reloaded from the parent (e.g., balance history after deposit), either:
237+
- Lift the `DataState` into the parent's state and pass `Signal<PageState>` to the tab, or
238+
- Pass `Signal<ChildState>` as a prop so the parent can call `.reset()` directly.
239+
240+
### 8) Server functions as API boundary (fullstack)
153241
- Use Dioxus fullstack server functions (`#[get]`, `#[post]` in `src/api/*`) for all client <-> server calls; they compile to RPCs on web and direct calls on server.
154242
- Keep them thin: fetch app context, perform storage/NoSQL ops, return typed models (`InstrumentHttpModel`, etc.).
155243
- Prefer `Result<T, ServerFnError>`; let the client handle loading/error rendering via `DataState`.
@@ -165,10 +253,10 @@ Applies to Dioxus **fullstack** projects (shared code server/web). Use these whe
165253
}
166254
```
167255

168-
### 8) Dialog template usage
256+
### 9) Dialog template usage
169257
- Provide `header`, optional `header_content`, main `content`, optional `ok_button`, and `allocate_max_space` when needed.
170258
- Cancel/close is built in; for custom OK, pass a button element to `ok_button`.
171-
- The close “X” uses the dialog context; no per-dialog wiring required.
259+
- The close "X" uses the dialog context; no per-dialog wiring required.
172260
- **Example: template with OK**
173261
```rust
174262
DialogTemplate {
@@ -187,7 +275,7 @@ Applies to Dioxus **fullstack** projects (shared code server/web). Use these whe
187275
}
188276
```
189277

190-
### 9) Signal handling tips
278+
### 10) Signal handling tips
191279
- Signals are `Copy`; capture once in handlers. Only clone when moving into async blocks.
192280
- Avoid nested `cs.clone()` layers unless a separate handle is truly needed.
193281
- Read with `.read()` for an immutable snapshot; write with `.write()` to mutate.
@@ -203,11 +291,11 @@ Applies to Dioxus **fullstack** projects (shared code server/web). Use these whe
203291
};
204292
```
205293

206-
### 9.1) Client-side "now" date/time
207-
- **Rule**: If now date/time must be resolved on the **client side** in a Dioxus fullstack app, use `dioxus_utils::now_date_time()`.
294+
### 10.1) Client-side "now" date/time
295+
- **Rule**: If "now" date/time must be resolved on the **client side** in a Dioxus fullstack app, use `dioxus_utils::now_date_time()`.
208296
- This avoids server-side resolution and keeps client-local time semantics correct.
209297

210-
### 10) Status messaging
298+
### 11) Status messaging
211299
- Store transient statuses (availability checks, errors) in the form state and render inline near the related control.
212300
- Clear stale statuses when the input they depend on changes.
213301
- **Example**
@@ -218,4 +306,120 @@ Applies to Dioxus **fullstack** projects (shared code server/web). Use these whe
218306
{ status.message.unwrap_or_else(|| "OK".into()) }
219307
}
220308
}
221-
```
309+
```
310+
311+
### 12) `use` imports in server functions — always inside the function body
312+
313+
In server functions (`#[get]`, `#[post]`), put **all `use` imports inside the function body**, not at the file level. Top-level imports cause `unused import` warnings on the web target where `feature = "server"` is disabled.
314+
315+
```rust
316+
// ✅ CORRECT — imports inside function, no warnings on web target
317+
#[get("/api/swap-profiles/get")]
318+
pub async fn get_swap_profiles() -> Result<Vec<SwapProfileModel>, ServerFnError> {
319+
use std::collections::HashMap;
320+
use crate::margin_engine_grpc::SwapProfileGrpcModel;
321+
use crate::server::APP_CTX;
322+
// ...
323+
}
324+
325+
// ❌ WRONG — top-level imports cause unused warnings on web target
326+
use std::collections::HashMap;
327+
use crate::margin_engine_grpc::SwapProfileGrpcModel;
328+
329+
#[get("/api/swap-profiles/get")]
330+
pub async fn get_swap_profiles() -> Result<Vec<SwapProfileModel>, ServerFnError> { ... }
331+
```
332+
333+
### 13) `NotifyChildComponent<TValue>` — parent-to-child notification
334+
335+
Use when a parent action (e.g. deposit, save) must trigger a child component to reload its own `DataState`, and the child manages its state independently (not in the parent's state).
336+
337+
`NotifyChildComponent<TValue>` is in `dioxus_utils`. It wraps a `Signal<Option<TValue>>` and is `Copy + Clone`, so it can be passed as a component prop.
338+
339+
**Parent** — create once with `new()`, pass to child as prop, call `notify_other_components(value)` after mutation:
340+
341+
```rust
342+
// In parent component body (hook context):
343+
let notify_balance = NotifyChildComponent::<()>::new();
344+
345+
// Pass to child:
346+
ChildComponent { notify_balance }
347+
348+
// After mutation (e.g. deposit success):
349+
notify_balance.notify_other_components(());
350+
```
351+
352+
**Child** — call `on_notify(callback)` as a hook. Internally sets up `use_effect` that fires when the signal changes, consumes the value, and runs the callback:
353+
354+
```rust
355+
#[component]
356+
fn ChildComponent(notify_balance: NotifyChildComponent<()>) -> Element {
357+
let mut cs = use_signal(ChildState::default);
358+
359+
notify_balance.on_notify(move |_| {
360+
cs.write().data.reset(); // triggers get_data reload on next render
361+
});
362+
363+
let cs_ra = cs.read();
364+
let items = match get_data(cs, &cs_ra) { ... };
365+
rsx! { /* render */ }
366+
}
367+
```
368+
369+
**Key properties:**
370+
- `NotifyChildComponent` holds `Signal<Option<TValue>>``Copy`, safe to pass as prop
371+
- `on_notify` must be called at component top level (it wraps `use_effect`)
372+
- The notification is consumed once — child's `use_effect` fires, clears the value, runs callback
373+
- After `.reset()` on `DataState`, the `get_data` helper sees `None` and spawns a reload automatically
374+
375+
### 14) `dialog_template` / `dialog_template_ex` — standard dialog wrapper
376+
377+
All dialogs use `dialog_template` (or `dialog_template_ex` for custom size) instead of inlining modal HTML. This keeps dialog structure consistent and eliminates boilerplate.
378+
379+
```rust
380+
// Standard size
381+
super::dialog_template(title, content, ok_button)
382+
383+
// Custom size (e.g. modal-xl for wide dialogs)
384+
super::dialog_template_ex(title, content, ok_button, Some("modal-xl"))
385+
```
386+
387+
**Pattern** — compute title, build `content` and `ok_button` as `rsx!` blocks, then delegate:
388+
389+
```rust
390+
#[component]
391+
pub fn EditInstrumentDialog(instrument: Rc<InstrumentModel>, on_ok: EventHandler<InstrumentModel>) -> Element {
392+
let mut cs = use_signal(|| EditState::from(instrument.as_ref()));
393+
let cs_ra = cs.read();
394+
395+
let title = if cs_ra.is_new { "New Instrument" } else { "Edit Instrument" };
396+
397+
let content = rsx! { /* form inputs */ };
398+
399+
let ok_button = rsx! {
400+
button {
401+
class: "btn btn-success",
402+
disabled: !cs_ra.is_valid(),
403+
onclick: move |_| {
404+
let model = cs.read().to_model();
405+
consume_context::<Signal<super::DialogState>>().set(super::DialogState::None);
406+
on_ok.call(model);
407+
},
408+
"Save"
409+
}
410+
};
411+
412+
super::dialog_template(title, content, ok_button)
413+
}
414+
```
415+
416+
**When loading data** — pass the loading/error element as `content` with empty `ok_button`:
417+
418+
```rust
419+
let data = match get_data(cs, &cs_ra) {
420+
Ok(d) => d,
421+
Err(el) => return super::dialog_template(title, el, rsx! {}),
422+
};
423+
```
424+
425+
Cancel button and close (×) are built into `dialog_template` — no need to add them.

0 commit comments

Comments
 (0)