feat(tahuantinsuyu): modal "Guardar como…" real para cartas libres (F3)
Reemplaza el `save_free_chart_quick` MVP de la fase A por un
modal completo que el usuario controla:
Tree:
- Nuevo `Modal::SaveFreeChart { source_id, name, new_contact_name,
selected_contact, all_contacts, error }`.
- `open_save_free_chart_modal` abre el modal pre-poblando `name`
con el label de la carta libre y `selected_contact` con el
primer contacto existente (o `None` = nuevo contacto si no
hay ninguno).
- `gather_all_contacts` recorre la jerarquía recursivamente
devolviendo `(ContactId, "Grupo / Subgrupo / Contacto")` —
el usuario ve la ruta completa, no solo el nombre.
- `render_save_free_chart` pinta:
* Input "Nombre" pre-cargado
* Lista de contactos como botones radio (● / ○) + opción
"Nuevo contacto…" al final
* Si "Nuevo contacto…" seleccionado, aparece input
"Nombre del contacto nuevo"
* Botones Cancelar / Guardar
- `set_save_modal_contact` alterna el radio sin recrear inputs.
- Validaciones: nombre de carta no vacío; si `selected_contact`
es `None`, exigir `new_contact_name` no vacío. Errores se
muestran en una pill destructiva dentro del modal.
- Submit emite nuevo evento `TreeEvent::FreeChartSaveConfirmed
{ source_id, chart_name, contact, new_contact_name }`.
Shell:
- `persist_free_chart` resuelve el contacto destino (existente
o crea uno nuevo), llama `store.create_chart`, y al éxito
remueve la carta libre del mapa (salvo `sky-now`, que es
persistente). Si la carta libre estaba seleccionada, vuelve
al Cielo. Refresca opciones del picker para que el dropdown
ChartPicker incluya la carta recién guardada.
- El handler `SaveFreeChartRequested` queda como hook vacío;
el menú del tree abre el modal directamente con `window`.
10 tests verdes (no se afectaron los paths probados).
Próximo: F2 (editor inline de fecha/lugar/hora de la carta
libre) y F4 (botón "Guardar como…" en cada módulo overlay
con sufijo automático).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -481,12 +481,25 @@ impl Shell {
|
||||
self.apply_selection(TreeSelection::FreeChart(id), cx);
|
||||
return;
|
||||
}
|
||||
TreeEvent::SaveFreeChartRequested(id) => {
|
||||
// Por ahora: log + persistencia simple en "General"
|
||||
// (contact nuevo con el label de la carta). En la
|
||||
// fase B se reemplaza por un modal con dropdown de
|
||||
// contacto y custom name.
|
||||
self.save_free_chart_quick(id.clone());
|
||||
TreeEvent::SaveFreeChartRequested(_id) => {
|
||||
// El menú del tree abre el modal directamente; este
|
||||
// evento queda como hook por si una tecla u otra
|
||||
// UI quiere disparar el flujo sin pasar por el menú.
|
||||
return;
|
||||
}
|
||||
TreeEvent::FreeChartSaveConfirmed {
|
||||
source_id,
|
||||
chart_name,
|
||||
contact,
|
||||
new_contact_name,
|
||||
} => {
|
||||
self.persist_free_chart(
|
||||
source_id.clone(),
|
||||
chart_name.clone(),
|
||||
contact.clone(),
|
||||
new_contact_name.clone(),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
TreeEvent::DeleteFreeChartRequested(id) => {
|
||||
@@ -510,41 +523,71 @@ impl Shell {
|
||||
self.apply_selection(selection.clone(), cx);
|
||||
}
|
||||
|
||||
/// Guardado rápido de una carta libre como carta natal bajo un
|
||||
/// contacto nuevo (auto-nombrado con el label de la carta libre).
|
||||
/// Es la versión MVP — en la fase B se reemplaza por un modal
|
||||
/// con dropdown de contacto + input de nombre custom.
|
||||
fn save_free_chart_quick(&mut self, id: FreeChartId) {
|
||||
let Some(chart) = self.free_charts.get(&id).cloned() else {
|
||||
/// Persiste una carta libre como `Chart` en la store. El usuario
|
||||
/// eligió en el modal: nombre + contacto destino (existente o
|
||||
/// uno nuevo creado al vuelo). La carta libre se REMUEVE del
|
||||
/// mapa tras el persist exitoso — si quedaba seleccionada,
|
||||
/// volvemos a "Cielo ahora". Si falla la persistencia, la carta
|
||||
/// libre se conserva y logueamos.
|
||||
fn persist_free_chart(
|
||||
&mut self,
|
||||
source_id: FreeChartId,
|
||||
chart_name: String,
|
||||
contact: Option<ContactId>,
|
||||
new_contact_name: Option<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(chart) = self.free_charts.get(&source_id).cloned() else {
|
||||
return;
|
||||
};
|
||||
// 1) Crear o reusar un contacto. Por simplicidad, creamos uno
|
||||
// nuevo con el label de la carta como nombre. La fase B le
|
||||
// dará al usuario el dropdown.
|
||||
let contact = match self.store.create_contact(None, &chart.label, None) {
|
||||
Ok(c) => c,
|
||||
// 1) Resolver el contact destino (existente o crear nuevo).
|
||||
let contact_id = match (contact, new_contact_name) {
|
||||
(Some(cid), _) => cid,
|
||||
(None, Some(name)) => match self.store.create_contact(None, &name, None) {
|
||||
Ok(c) => c.id,
|
||||
Err(e) => {
|
||||
eprintln!("[shell] create_contact al guardar libre: {}", e);
|
||||
return;
|
||||
}
|
||||
},
|
||||
(None, None) => {
|
||||
eprintln!("[shell] persist_free_chart sin contacto ni nombre nuevo");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// 2) Crear la carta bajo ese contacto.
|
||||
if let Err(e) = self.store.create_chart(
|
||||
contact.id,
|
||||
// 2) Crear la carta.
|
||||
match self.store.create_chart(
|
||||
contact_id,
|
||||
chart.kind,
|
||||
&chart.label,
|
||||
&chart_name,
|
||||
&chart.birth_data,
|
||||
&chart.config,
|
||||
chart.related_chart_id,
|
||||
) {
|
||||
Ok(_) => {
|
||||
eprintln!(
|
||||
"[shell] carta libre {:?} guardada como '{}' bajo contacto {}",
|
||||
source_id, chart_name, contact_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[shell] create_chart al guardar libre: {}", e);
|
||||
return;
|
||||
}
|
||||
eprintln!(
|
||||
"[shell] carta libre '{}' guardada bajo contacto '{}' (id {})",
|
||||
chart.label, contact.name, contact.id
|
||||
}
|
||||
// 3) Sky-now se conserva (siempre es); las demás se quitan
|
||||
// del mapa libre. Si era la activa, volver al Cielo.
|
||||
if !source_id.is_sky_now() {
|
||||
self.free_charts.remove(&source_id);
|
||||
self.push_free_charts_to_tree(cx);
|
||||
// Si la activa era esta libre, regresar al Cielo.
|
||||
self.apply_selection(
|
||||
TreeSelection::FreeChart(FreeChartId::sky_now()),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
self.refresh_chart_options(cx);
|
||||
}
|
||||
|
||||
fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {
|
||||
match sel {
|
||||
|
||||
@@ -70,6 +70,15 @@ pub enum TreeEvent {
|
||||
/// Borrar una carta libre del mapa del shell. Si es `sky-now`,
|
||||
/// el shell ignora (no se puede borrar el Cielo).
|
||||
DeleteFreeChartRequested(FreeChartId),
|
||||
/// Submit del modal "Guardar como" — el shell crea/usa el
|
||||
/// contacto y persiste la carta. Si `contact` es `None`, el
|
||||
/// shell crea uno nuevo con `new_contact_name`.
|
||||
FreeChartSaveConfirmed {
|
||||
source_id: FreeChartId,
|
||||
chart_name: String,
|
||||
contact: Option<ContactId>,
|
||||
new_contact_name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -148,6 +157,25 @@ enum Modal {
|
||||
form: ChartForm,
|
||||
error: Option<SharedString>,
|
||||
},
|
||||
/// Guardar una carta libre como `Chart` persistido. El usuario
|
||||
/// elige nombre + contacto destino (existente de la lista o
|
||||
/// uno nuevo creado al vuelo). El shell escucha
|
||||
/// `TreeEvent::FreeChartSaveConfirmed` y materializa.
|
||||
SaveFreeChart {
|
||||
source_id: FreeChartId,
|
||||
name: Entity<TextInput>,
|
||||
/// Nombre del contacto NUEVO (solo aplica si
|
||||
/// `selected_contact == None`). Vacío para reusar uno
|
||||
/// existente.
|
||||
new_contact_name: Entity<TextInput>,
|
||||
/// `Some(id)` = usar contacto existente; `None` = crear
|
||||
/// contacto nuevo con `new_contact_name`.
|
||||
selected_contact: Option<ContactId>,
|
||||
/// Snapshot de contactos visibles al usuario en el momento
|
||||
/// de abrir el modal. Incluye contact id + label (nombre).
|
||||
all_contacts: Vec<(ContactId, String)>,
|
||||
error: Option<SharedString>,
|
||||
},
|
||||
}
|
||||
|
||||
struct ChartForm {
|
||||
@@ -845,6 +873,101 @@ impl TahuantinsuyuTree {
|
||||
self.close_menu(cx);
|
||||
}
|
||||
|
||||
/// Abre el modal "Guardar como" para una carta libre. Pre-puebla
|
||||
/// el `name` con el label actual de la entry. La lista de
|
||||
/// contactos es un snapshot recursivo de toda la jerarquía
|
||||
/// (no solo el nivel raíz). El usuario elige uno existente o
|
||||
/// deja en "Nuevo contacto" para que se cree uno al confirmar.
|
||||
/// Cambia `selected_contact` del modal `SaveFreeChart` activo
|
||||
/// sin recrear los inputs. Permite alternar entre los botones
|
||||
/// radio "contacto existente" y "Nuevo contacto…".
|
||||
fn set_save_modal_contact(
|
||||
&mut self,
|
||||
new_selection: Option<ContactId>,
|
||||
expected_source: &FreeChartId,
|
||||
cx: &mut Context<'_, Self>,
|
||||
) {
|
||||
if let Some(Modal::SaveFreeChart {
|
||||
source_id,
|
||||
selected_contact,
|
||||
..
|
||||
}) = self.modal.as_mut()
|
||||
{
|
||||
if source_id == expected_source {
|
||||
*selected_contact = new_selection;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_save_free_chart_modal(
|
||||
&mut self,
|
||||
source_id: FreeChartId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<'_, Self>,
|
||||
) {
|
||||
let default_label = self
|
||||
.free_charts
|
||||
.iter()
|
||||
.find(|e| e.id == source_id)
|
||||
.map(|e| e.label.clone())
|
||||
.unwrap_or_else(|| "Carta libre".into());
|
||||
let all_contacts = self.gather_all_contacts();
|
||||
let name_input = self.make_input("Nombre de la carta", &default_label, window, cx);
|
||||
let new_contact_input =
|
||||
self.make_input("Nombre del contacto nuevo", "", window, cx);
|
||||
// Default: primer contacto existente si lo hay; sino "Nuevo
|
||||
// contacto" (None). Eso minimiza clicks cuando el usuario ya
|
||||
// tiene contactos cargados.
|
||||
let selected_contact = all_contacts.first().map(|(id, _)| *id);
|
||||
self.modal = Some(Modal::SaveFreeChart {
|
||||
source_id,
|
||||
name: name_input,
|
||||
new_contact_name: new_contact_input,
|
||||
selected_contact,
|
||||
all_contacts,
|
||||
error: None,
|
||||
});
|
||||
self.close_menu(cx);
|
||||
}
|
||||
|
||||
/// Snapshot recursivo de todos los contactos del árbol —
|
||||
/// `(id, label)`. Usado por el modal "Guardar como" para
|
||||
/// listar destinos. Las cartas se cuelgan del contacto que el
|
||||
/// usuario elija.
|
||||
fn gather_all_contacts(&self) -> Vec<(ContactId, String)> {
|
||||
fn walk(
|
||||
store: &Store,
|
||||
parent: Option<GroupId>,
|
||||
prefix: &str,
|
||||
out: &mut Vec<(ContactId, String)>,
|
||||
) {
|
||||
if let Ok(contacts) = store.list_contacts(parent) {
|
||||
for c in contacts {
|
||||
let label = if prefix.is_empty() {
|
||||
c.name.clone()
|
||||
} else {
|
||||
format!("{}{}", prefix, c.name)
|
||||
};
|
||||
out.push((c.id, label));
|
||||
}
|
||||
}
|
||||
if let Ok(groups) = store.list_groups(parent) {
|
||||
for g in groups {
|
||||
let new_prefix = if prefix.is_empty() {
|
||||
format!("{} / ", g.name)
|
||||
} else {
|
||||
format!("{}{} / ", prefix, g.name)
|
||||
};
|
||||
walk(store, Some(g.id), &new_prefix, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&self.store, None, "", &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn open_create_chart(
|
||||
&mut self,
|
||||
contact: ContactId,
|
||||
@@ -1107,6 +1230,62 @@ impl TahuantinsuyuTree {
|
||||
}
|
||||
}
|
||||
}
|
||||
Modal::SaveFreeChart {
|
||||
source_id,
|
||||
name,
|
||||
new_contact_name,
|
||||
selected_contact,
|
||||
all_contacts,
|
||||
error: _,
|
||||
} => {
|
||||
let _ = value;
|
||||
let chart_name = name.read(cx).text().to_string();
|
||||
let chart_name = chart_name.trim();
|
||||
if chart_name.is_empty() {
|
||||
self.modal = Some(Modal::SaveFreeChart {
|
||||
source_id,
|
||||
name,
|
||||
new_contact_name,
|
||||
selected_contact,
|
||||
all_contacts,
|
||||
error: Some("El nombre de la carta no puede estar vacío".into()),
|
||||
});
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
let new_contact = if selected_contact.is_none() {
|
||||
let v = new_contact_name.read(cx).text().to_string();
|
||||
let v = v.trim();
|
||||
if v.is_empty() {
|
||||
self.modal = Some(Modal::SaveFreeChart {
|
||||
source_id,
|
||||
name,
|
||||
new_contact_name,
|
||||
selected_contact,
|
||||
all_contacts,
|
||||
error: Some(
|
||||
"Elegí un contacto existente o escribí un nombre para el nuevo"
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
Some(v.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
cx.emit(TreeEvent::FreeChartSaveConfirmed {
|
||||
source_id,
|
||||
chart_name: chart_name.to_string(),
|
||||
contact: selected_contact,
|
||||
new_contact_name: new_contact,
|
||||
});
|
||||
drop(name);
|
||||
drop(new_contact_name);
|
||||
self.modal = None;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1441,9 +1620,8 @@ impl TahuantinsuyuTree {
|
||||
let fid_save = fid.clone();
|
||||
items = items.child(
|
||||
menu_item("tts-menu-save-free", "Guardar como…", theme).on_click(
|
||||
cx.listener(move |this, _: &ClickEvent, _w, cx| {
|
||||
cx.emit(TreeEvent::SaveFreeChartRequested(fid_save.clone()));
|
||||
this.close_menu(cx);
|
||||
cx.listener(move |this, _: &ClickEvent, w, cx| {
|
||||
this.open_save_free_chart_modal(fid_save.clone(), w, cx);
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -1530,6 +1708,23 @@ impl TahuantinsuyuTree {
|
||||
&self.city_atlas,
|
||||
cx,
|
||||
),
|
||||
Modal::SaveFreeChart {
|
||||
source_id,
|
||||
name,
|
||||
new_contact_name,
|
||||
selected_contact,
|
||||
all_contacts,
|
||||
error,
|
||||
} => render_save_free_chart(
|
||||
theme,
|
||||
source_id.clone(),
|
||||
name.clone(),
|
||||
new_contact_name.clone(),
|
||||
*selected_contact,
|
||||
all_contacts,
|
||||
error.clone(),
|
||||
cx,
|
||||
),
|
||||
};
|
||||
|
||||
div()
|
||||
@@ -1603,6 +1798,184 @@ fn modal_box(
|
||||
)
|
||||
}
|
||||
|
||||
/// Modal "Guardar como" para una carta libre. Layout:
|
||||
///
|
||||
/// ```text
|
||||
/// [Nombre de la carta] — TextInput pre-poblado con label
|
||||
/// Contacto destino:
|
||||
/// ○ Contacto A
|
||||
/// ○ Contacto B
|
||||
/// ● Nuevo contacto… → [Nombre del contacto] TextInput
|
||||
/// [Cancelar] [Guardar]
|
||||
/// ```
|
||||
///
|
||||
/// El submit emite `TreeEvent::FreeChartSaveConfirmed` que el shell
|
||||
/// materializa contra la store.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_save_free_chart(
|
||||
theme: &Theme,
|
||||
source_id: FreeChartId,
|
||||
name: Entity<TextInput>,
|
||||
new_contact_name: Entity<TextInput>,
|
||||
selected_contact: Option<ContactId>,
|
||||
all_contacts: &[(ContactId, String)],
|
||||
error: Option<SharedString>,
|
||||
cx: &mut Context<'_, TahuantinsuyuTree>,
|
||||
) -> gpui::Div {
|
||||
let title_row = div()
|
||||
.text_size(px(14.0))
|
||||
.text_color(theme.fg_text)
|
||||
.child(SharedString::from("Guardar carta libre"));
|
||||
let label_row =
|
||||
|label: &'static str| -> gpui::Div {
|
||||
div()
|
||||
.text_size(px(10.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child(label)
|
||||
};
|
||||
let name_block = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(2.0))
|
||||
.child(label_row("Nombre"))
|
||||
.child(name.clone());
|
||||
|
||||
// Lista de contactos como botones radio.
|
||||
let mut contact_list = div().flex().flex_col().gap(px(4.0));
|
||||
for (cid, label) in all_contacts.iter() {
|
||||
let selected = selected_contact == Some(*cid);
|
||||
let cid_for_click = *cid;
|
||||
let source_for_click = source_id.clone();
|
||||
let row_id: SharedString =
|
||||
SharedString::from(format!("tts-save-pick-{}", cid));
|
||||
let bullet = if selected { "●" } else { "○" };
|
||||
let row = div()
|
||||
.id(gpui::ElementId::from(row_id))
|
||||
.flex()
|
||||
.flex_row()
|
||||
.gap(px(8.0))
|
||||
.px(px(6.0))
|
||||
.py(px(3.0))
|
||||
.rounded(px(4.0))
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_text)
|
||||
.hover(|s| s.bg(theme.bg_row_hover))
|
||||
.child(bullet.to_string())
|
||||
.child(label.clone())
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.set_save_modal_contact(Some(cid_for_click), &source_for_click, cx);
|
||||
}));
|
||||
contact_list = contact_list.child(row);
|
||||
}
|
||||
// Opción "Nuevo contacto…" — bullet activo si selected_contact==None.
|
||||
let new_selected = selected_contact.is_none();
|
||||
let new_bullet = if new_selected { "●" } else { "○" };
|
||||
let source_for_new = source_id.clone();
|
||||
contact_list = contact_list.child(
|
||||
div()
|
||||
.id(gpui::ElementId::from(SharedString::from(
|
||||
"tts-save-pick-new",
|
||||
)))
|
||||
.flex()
|
||||
.flex_row()
|
||||
.gap(px(8.0))
|
||||
.px(px(6.0))
|
||||
.py(px(3.0))
|
||||
.rounded(px(4.0))
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.fg_text)
|
||||
.hover(|s| s.bg(theme.bg_row_hover))
|
||||
.child(new_bullet.to_string())
|
||||
.child("Nuevo contacto…")
|
||||
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.set_save_modal_contact(None, &source_for_new, cx);
|
||||
})),
|
||||
);
|
||||
|
||||
let mut contacts_block = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(2.0))
|
||||
.child(label_row("Contacto destino"))
|
||||
.child(contact_list);
|
||||
|
||||
// Si "Nuevo contacto" está activo, mostrar el TextInput debajo.
|
||||
if new_selected {
|
||||
contacts_block = contacts_block.child(
|
||||
div()
|
||||
.pt(px(4.0))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(2.0))
|
||||
.child(label_row("Nombre del contacto nuevo"))
|
||||
.child(new_contact_name.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
let save_btn = div()
|
||||
.id("tts-save-free-confirm")
|
||||
.px(px(14.0))
|
||||
.py(px(8.0))
|
||||
.rounded(px(6.0))
|
||||
.bg(theme.bg_button())
|
||||
.hover(|s| s.bg(theme.bg_button_hover()))
|
||||
.text_size(px(12.0))
|
||||
.text_color(theme.fg_text)
|
||||
.child("Guardar")
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.submit_modal(String::new(), cx);
|
||||
}));
|
||||
let cancel_btn = div()
|
||||
.id("tts-save-free-cancel")
|
||||
.px(px(14.0))
|
||||
.py(px(8.0))
|
||||
.rounded(px(6.0))
|
||||
.bg(theme.bg_panel.clone())
|
||||
.hover(|s| s.bg(theme.bg_row_hover))
|
||||
.text_size(px(12.0))
|
||||
.text_color(theme.fg_muted)
|
||||
.child("Cancelar")
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.close_modal(cx);
|
||||
}));
|
||||
|
||||
let mut body = div()
|
||||
.min_w(px(420.0))
|
||||
.p(px(18.0))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(12.0))
|
||||
.bg(theme.bg_panel_alt.clone())
|
||||
.border_1()
|
||||
.border_color(theme.border_strong)
|
||||
.rounded(px(8.0))
|
||||
.child(title_row)
|
||||
.child(name_block)
|
||||
.child(contacts_block);
|
||||
if let Some(err) = error {
|
||||
body = body.child(
|
||||
div()
|
||||
.px(px(10.0))
|
||||
.py(px(6.0))
|
||||
.rounded(px(4.0))
|
||||
.bg(theme.bg_destructive_hover())
|
||||
.text_size(px(11.0))
|
||||
.text_color(theme.accent_destructive())
|
||||
.child(err),
|
||||
);
|
||||
}
|
||||
body = body.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.gap(px(8.0))
|
||||
.justify_end()
|
||||
.child(cancel_btn)
|
||||
.child(save_btn),
|
||||
);
|
||||
body
|
||||
}
|
||||
|
||||
fn render_chart_form(
|
||||
theme: &Theme,
|
||||
title: &str,
|
||||
|
||||
Reference in New Issue
Block a user