//! What the workspace renders, and the wire formats its panels post. //! //! Every panel is a form of its own, and each one carries the selection it //! acts on in hidden fields, because the workspace is swapped whole on every //! write: the response is rebuilt from the live profile tree rather than from //! whatever the browser still had on screen. use crate::schema::{ColumnCatalog, ColumnDraft}; /// The profile and table the workspace is pointed at. Arrives as a query /// string on the selector and on the column-panel endpoints, and as hidden /// fields on the panels that write. #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct Selection { #[serde(default)] pub profile: String, #[serde(default)] pub table: String, } impl Selection { pub(crate) fn has_profile(&self) -> bool { !self.profile.is_empty() } pub(crate) fn has_table(&self) -> bool { !self.table.is_empty() } /// The query string the column panel posts back to, so the panel's own /// form does not have to carry the selection among its column fields. pub(crate) fn query(&self) -> String { format!("?profile={}&table={}", self.profile, self.table) } } /// One table in the selected profile, from the profile tree. #[derive(Clone, Debug)] pub(crate) struct TableSummary { pub name: String, pub table_kind: String, pub depends_on: Vec, pub row_display_columns: Vec, } impl TableSummary { /// System tables are backend-managed: the server refuses every write below /// on them, so the workspace does not offer the panels either. pub(crate) fn is_system(&self) -> bool { self.table_kind == "system" } } /// The selected table as the backend describes it, from `GetProfileDetails`. #[derive(Clone, Debug)] pub(crate) struct TableDetailView { pub id: i64, pub name: String, pub table_kind: String, pub row_display_columns: Vec, pub columns: Vec, pub scripts: Vec, } impl TableDetailView { pub(crate) fn is_system(&self) -> bool { self.table_kind == "system" } /// Columns a rename may target. A generated companion (`phone_country`, /// the accounting fields) belongs to the column it was derived from, and /// the server refuses to rename one. pub(crate) fn renameable_columns(&self) -> Vec<&DetailColumn> { self.columns .iter() .filter(|column| !column.generated) .collect() } } #[derive(Clone, Debug)] pub(crate) struct DetailColumn { pub name: String, pub field_type: String, /// The PostgreSQL type the column is stored as, from the column-type /// catalog. Empty for a type the catalog does not describe. pub sql_type: String, pub currency: String, pub quantity_ledger: bool, pub rounded: bool, pub generated: bool, pub read_only: bool, pub generated_from: String, } impl DetailColumn { /// The badge list rendered under each column name. pub(crate) fn flags(&self) -> Vec { let mut flags = Vec::new(); if !self.currency.is_empty() { flags.push(self.currency.clone()); } if self.rounded { flags.push("half-up".to_string()); } if self.quantity_ledger { flags.push("quantity ledger".to_string()); } if self.read_only { flags.push("read only".to_string()); } if self.generated { flags.push(if self.generated_from.is_empty() { "generated".to_string() } else { format!("generated from {}", self.generated_from) }); } flags } } #[derive(Clone, Debug)] pub(crate) struct ScriptView { pub target_column: String, pub target_column_type: String, pub description: String, pub script: String, } /// One row of the stored rename history. #[derive(Clone, Debug)] pub(crate) struct RenameEntry { pub table_name: String, pub old_column_name: String, pub new_column_name: String, pub created_at: String, } /// One physical table created from an invoice template. #[derive(Clone, Debug)] pub(crate) struct GeneratedTableView { pub table_name: String, pub collection_path: String, pub parent_table_name: String, } /// The rename panel's inputs, kept across a failed submit so the user does not /// retype them. #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct RenameForm { #[serde(default)] pub profile: String, #[serde(default)] pub table: String, #[serde(default)] pub old_column_name: String, #[serde(default)] pub new_column_name: String, } /// The copy-profile panel. An empty `table_names` copies the whole profile, /// which is what the backend takes an empty list to mean. #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct CopyForm { #[serde(default)] pub profile: String, #[serde(default)] pub target_profile_name: String, #[serde(default)] pub table_names: Vec, } /// The invoice-template panel. `row_display_columns` is typed as a /// comma-separated list, because the columns do not exist yet — they are /// whatever the template's contract turns out to declare. #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct InvoiceTemplateForm { #[serde(default)] pub profile: String, #[serde(default)] pub table_name: String, #[serde(default)] pub typst_source: String, #[serde(default)] pub row_display_columns: String, } impl InvoiceTemplateForm { pub(crate) fn display_columns(&self) -> Vec { self.row_display_columns .split(',') .map(str::trim) .filter(|column| !column.is_empty()) .map(str::to_string) .collect() } } /// The delete panel. The table name has to be typed back: `DeleteTable` drops /// the physical table with CASCADE and takes the profile with it when it was /// the last one, so a mis-click must not be enough. #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct DeleteForm { #[serde(default)] pub profile: String, #[serde(default)] pub table: String, #[serde(default)] pub confirm_table_name: String, } /// Everything the caller decides before the loader fills in the live data. #[derive(Clone, Debug, Default)] pub(crate) struct PageInputs { pub selection: Selection, /// The columns staged for `AddTableColumns`. pub columns: ColumnDraft, pub rename: RenameForm, pub copy: CopyForm, pub invoice: InvoiceTemplateForm, pub status: Option, pub error: Option, /// The DDL a successful write reported. pub sql: Option, /// The bundle a successful invoice-template creation reported. pub generated: Vec, } impl PageInputs { /// A page with an empty append panel. The panel's vocabulary is filled in /// by the loader, which is the only thing that knows it. pub(crate) fn for_selection(selection: Selection) -> Self { Self { selection, columns: ColumnDraft::for_append(ColumnCatalog::default()), ..Self::default() } } } /// What the templates read. pub(crate) struct TableDefinitionPageState { pub nav: crate::ui::Nav, pub profiles: Vec, pub selection: Selection, pub tables: Vec, pub detail: Option, pub history: Vec, pub columns: ColumnDraft, pub rename: RenameForm, pub copy: CopyForm, pub invoice: InvoiceTemplateForm, pub status: Option, pub error: Option, pub sql: Option, pub generated: Vec, pub permission_object: String, pub role_permissions: Vec, } pub(crate) struct TableRolePermissions { pub role: String, pub actions: Vec, } pub(crate) struct TablePermissionAction { pub action: String, pub direct: bool, pub effective: bool, } impl TableDefinitionPageState { /// The selected table's summary, which is where its kind and dependencies /// come from. pub(crate) fn selected_table(&self) -> Option<&TableSummary> { self.tables .iter() .find(|table| table.name == self.selection.table) } /// Whether the write panels apply. They need a table, and that table has /// to be one the server will let anyone but itself modify. pub(crate) fn table_is_writable(&self) -> bool { self.selected_table() .is_some_and(|table| !table.is_system()) } /// Tables offered as copy sources — all of them, since `CopyProfile` /// copies structure and a system table is structure too. pub(crate) fn copy_candidates(&self) -> &[TableSummary] { &self.tables } pub(crate) fn copy_selected(&self, table_name: &str) -> bool { self.copy .table_names .iter() .any(|selected| selected == table_name) } } #[derive(Debug)] pub(crate) enum LoadError { Unauthenticated, Forbidden, Backend(String), } #[cfg(test)] mod tests { use super::*; #[test] fn display_columns_are_split_and_trimmed() { let form = InvoiceTemplateForm { row_display_columns: " number , , issued_on ".to_string(), ..Default::default() }; assert_eq!(form.display_columns(), vec!["number", "issued_on"]); } #[test] fn a_generated_column_says_what_it_came_from_and_cannot_be_renamed() { let detail = TableDetailView { id: 1, name: "contact".to_string(), table_kind: "dynamic".to_string(), row_display_columns: Vec::new(), scripts: Vec::new(), columns: vec![ DetailColumn { name: "work_phone".to_string(), field_type: "phone".to_string(), sql_type: "TEXT".to_string(), currency: String::new(), quantity_ledger: false, rounded: false, generated: false, read_only: false, generated_from: String::new(), }, DetailColumn { name: "work_phone_country".to_string(), field_type: "phone_country".to_string(), sql_type: "TEXT".to_string(), currency: String::new(), quantity_ledger: false, rounded: false, generated: true, read_only: true, generated_from: "work_phone".to_string(), }, ], }; let renameable = detail.renameable_columns(); assert_eq!(renameable.len(), 1); assert_eq!(renameable[0].name, "work_phone"); assert_eq!( detail.columns[1].flags(), vec!["read only", "generated from work_phone"] ); } }