//! What the table-definition pages render, and the wire formats they post. //! //! One state type serves all table-definition pages, because each of them needs the same //! context: which profile and table is being worked on, and what that table //! currently is. What differs is which panel the page renders, and `active` //! is what says so. //! //! Every panel carries the selection it acts on in hidden fields, because a //! write answers with its page re-read from the backend rather than with //! 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() } /// Whether the selection is the shared profile, whose name the caller has /// from the profile tree — the page does not spell it itself. pub(crate) fn is_shared_profile(&self, shared_profile: &str) -> bool { self.profile == shared_profile } /// 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) } /// The same, for the profile-wide pages, which have no table to name. pub(crate) fn profile_query(&self) -> String { format!("?profile={}", self.profile) } /// What the heading calls the scope, so the shared profile is not shown by /// its schema name. pub(crate) fn scope_label( &self, locale: &crate::i18n::Locale, shared_profile: &str, ) -> String { if self.is_shared_profile(shared_profile) { crate::tr!(*locale, "td-global-label") } else { self.profile.clone() } } } /// One table in the selected profile, from the profile tree. #[derive(Clone, Debug)] pub(crate) struct TableSummary { pub name: String, pub table_kind: crate::definitions::table_definition::ManagedTableKind, pub global: bool, pub depends_on: 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 == crate::definitions::table_definition::ManagedTableKind::System } } /// The selected table as the backend describes it, from `GetProfileDetails`. #[derive(Clone, Debug)] pub(crate) struct TableDetailView { pub id: i64, pub row_version: i64, pub has_data: bool, pub row_display_columns: Vec, pub columns: Vec, pub scripts: Vec, } impl TableDetailView { /// Columns a rename may target. Provenance and renameability are separate: /// accounting companions remain renameable while protected generated /// columns do not. #[cfg(test)] pub(crate) fn renameable_columns(&self) -> Vec<&DetailColumn> { self.columns .iter() .filter(|column| column.renameable) .collect() } /// Expands a top-level removal choice to the generated columns the backend /// requires to travel with it. PHONE/IBAN companions name their source; /// linked projections use `link.source`. pub(crate) fn expanded_removal_ids(&self, selected: &[i64]) -> Result, ()> { let selected = selected.iter().copied().collect::>(); if selected .iter() .any(|id| !self.columns.iter().any(|column| column.column_id == *id)) { return Err(()); } let mut roots = selected.clone(); for column in self.columns.iter().filter(|column| selected.contains(&column.column_id)) { if let Some(root_name) = column.generated_from.split('.').next() && !root_name.is_empty() && let Some(root) = self.columns.iter().find(|candidate| candidate.name == root_name) { roots.insert(root.column_id); } } Ok(self .columns .iter() .filter(|column| { roots.contains(&column.column_id) || self.columns.iter().any(|root| { roots.contains(&root.column_id) && (column.generated_from == root.name || column .generated_from .strip_prefix(&root.name) .is_some_and(|suffix| suffix.starts_with('.'))) }) }) .map(|column| column.column_id) .collect()) } } #[derive(Clone, Debug)] pub(crate) struct DetailColumn { pub column_id: i64, 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, pub renameable: bool, pub hidden_from_forms: bool, } impl DetailColumn { /// Generated/read-only rows are shown as part of their parent definition; /// choosing the parent expands to them in `expanded_removal_ids`. pub(crate) fn is_removal_choice(&self) -> bool { self.column_id > 0 && !self.generated && !self.read_only } /// The badge list rendered under each column name. pub(crate) fn flags(&self, locale: &crate::i18n::Locale) -> Vec { let mut flags = Vec::new(); if !self.currency.is_empty() { flags.push(self.currency.clone()); } if self.rounded { flags.push(crate::tr!(*locale, "column-flag-half-up")); } if self.quantity_ledger { flags.push(crate::tr!( *locale, "column-flag-quantity-ledger" )); } if self.read_only { flags.push(crate::tr!(*locale, "column-flag-read-only")); } if self.hidden_from_forms { flags.push(crate::tr!(*locale, "column-flag-hidden-from-forms")); } if !self.generated_from.is_empty() { flags.push(crate::tr!( *locale, "column-flag-generated-from", "source" => self.generated_from.clone(), )); } else if self.generated { flags.push(crate::tr!(*locale, "column-flag-generated")); } 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, } /// Renaming one column. /// /// `SetColumnPresentation` sets names and order in one call and insists on /// being given every column, so a browser that posts the whole table posts a /// list it read at some earlier moment. When the ids and the aliases came from /// two different moments, they still zipped into a valid request -- one that /// renamed `amount` to `note` and `note` to `amount`, and took every validation /// and type along with the names. This form carries one alias and the id of the /// column it was typed into, so there is no list to misalign; the handler fills /// the rest of the table in from the backend. #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct AliasForm { #[serde(default)] pub profile: String, #[serde(default)] pub table: String, #[serde(default)] pub expected_row_version: i64, #[serde(default)] pub column_id: i64, #[serde(default)] pub alias: String, } /// Saving a complete, staged column order. /// /// It carries ids only and no aliases, so a reorder cannot rename anything, /// whatever the browser still had on screen. The handler validates that this /// is an exact permutation of the table's current ids and supplies fresh /// aliases from the backend. See [`AliasForm`] for why that separation matters. #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct OrderForm { #[serde(default)] pub profile: String, #[serde(default)] pub table: String, #[serde(default)] pub expected_row_version: i64, #[serde(default)] pub column_ids: Vec, } #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct VisibilityForm { #[serde(default)] pub profile: String, #[serde(default)] pub table: String, #[serde(default)] pub expected_row_version: i64, #[serde(default)] pub column_id: i64, #[serde(default)] pub hidden_from_forms: bool, } /// 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 new columns staged for the structural edit. pub columns: ColumnDraft, /// Existing column identities selected for removal. pub remove_column_ids: Vec, 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 selection: Selection, /// The profile the shared tables are stored in, as the profile tree /// reports it. Everything that has to recognise the shared scope reads it /// from here rather than knowing the name. pub shared_profile: String, /// The tables of the selected scope — what the workspace browses and acts /// on. pub tables: Vec, /// What a new link may point at, which is not the same list: a profile's /// table may link to a shared one, and a shared table is not one of the /// profile's own. See `crate::pages::table_scope`. pub link_targets: Vec, pub detail: Option, pub history: Vec, pub columns: ColumnDraft, pub remove_column_ids: Vec, pub copy: CopyForm, pub invoice: InvoiceTemplateForm, pub status: Option, pub error: Option, pub sql: Option, pub generated: Vec, /// Which of the pages this is, for the action switcher they share. Set by /// the handler, because it is the one thing the loader cannot know. pub active: &'static str, } impl TableDefinitionPageState { /// Empty dynamic tables may be reshaped atomically. Once data exists, the /// backend deliberately exposes only append-only column changes. pub(crate) fn can_adjust_definition(&self) -> bool { self.table_is_writable() && self.detail.as_ref().is_some_and(|detail| !detail.has_data) } /// How many columns the table already has, for the unified "Columns" /// count the append panel shows alongside the create builder's own. pub(crate) fn existing_column_count(&self) -> usize { self.detail.as_ref().map_or(0, |detail| detail.columns.len()) } pub(crate) fn column_is_selected_for_removal(&self, column_id: &i64) -> bool { self.remove_column_ids.contains(column_id) } /// Whether `name` is the page being rendered. Read by context.html. pub(crate) fn is(&self, name: &str) -> bool { self.active == name } /// Whether the workspace is pointed at the shared profile rather than a /// profile of its own. Read by context.html. pub(crate) fn is_global(&self) -> bool { self.selection.is_shared_profile(&self.shared_profile) } /// The heading's name for the selected scope. Read by context.html. pub(crate) fn scope_label(&self, locale: &crate::i18n::Locale) -> String { self.selection.scope_label(locale, &self.shared_profile) } /// A table cannot link to itself, and the chart of accounts is reached /// through an ACCOUNTING row rather than through a link of one's own. fn eligible_link_target(&self, table: &TableSummary) -> bool { table.name != self.selection.table && table.name != crate::schema::LEDGER_ACCOUNTS_TABLE } pub(crate) fn global_link_target_tables(&self) -> Vec<&str> { self.link_targets .iter() .filter(|table| self.eligible_link_target(table) && table.global) .map(|table| table.name.as_str()) .collect() } pub(crate) fn user_link_target_tables(&self) -> Vec<&str> { self.link_targets .iter() .filter(|table| { self.eligible_link_target(table) && !table.global && !table.is_system() }) .map(|table| table.name.as_str()) .collect() } pub(crate) fn system_link_target_tables(&self) -> Vec<&str> { self.link_targets .iter() .filter(|table| { self.eligible_link_target(table) && !table.global && table.is_system() }) .map(|table| table.name.as_str()) .collect() } /// 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 provenance_and_renameability_are_independent() { let detail = TableDetailView { id: 1, row_version: 1, has_data: false, row_display_columns: Vec::new(), scripts: Vec::new(), columns: vec![ DetailColumn { column_id: 1, 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(), renameable: true, hidden_from_forms: false, }, DetailColumn { column_id: 2, 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(), renameable: false, hidden_from_forms: false, }, DetailColumn { column_id: 3, name: "charge".to_string(), field_type: "money".to_string(), sql_type: "NUMERIC".to_string(), currency: "EUR".to_string(), quantity_ledger: false, rounded: false, generated: true, read_only: false, generated_from: "accounting".to_string(), renameable: true, hidden_from_forms: false, }, ], }; let renameable = detail.renameable_columns(); assert_eq!(renameable.len(), 2); assert_eq!(renameable[0].name, "work_phone"); assert_eq!(renameable[1].name, "charge"); assert_eq!( detail.columns[1].flags(&crate::i18n::Locale::default()), vec!["read only", "generated from work_phone"] ); assert_eq!( detail.columns[2].flags(&crate::i18n::Locale::default()), vec!["EUR", "generated from accounting"] ); assert_eq!(detail.expanded_removal_ids(&[1]).unwrap(), [1, 2]); assert_eq!(detail.expanded_removal_ids(&[2]).unwrap(), [1, 2]); assert!(detail.expanded_removal_ids(&[99]).is_err()); assert!(detail.columns[0].is_removal_choice()); assert!(!detail.columns[1].is_removal_choice()); } }