589 lines
20 KiB
Rust
589 lines
20 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
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<String>,
|
|
pub columns: Vec<DetailColumn>,
|
|
pub scripts: Vec<ScriptView>,
|
|
}
|
|
|
|
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<Vec<i64>, ()> {
|
|
let selected = selected.iter().copied().collect::<std::collections::HashSet<_>>();
|
|
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<String> {
|
|
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<i64>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
/// 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<String> {
|
|
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<i64>,
|
|
pub copy: CopyForm,
|
|
pub invoice: InvoiceTemplateForm,
|
|
pub status: Option<String>,
|
|
pub error: Option<String>,
|
|
/// The DDL a successful write reported.
|
|
pub sql: Option<String>,
|
|
/// The bundle a successful invoice-template creation reported.
|
|
pub generated: Vec<GeneratedTableView>,
|
|
}
|
|
|
|
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<TableSummary>,
|
|
/// 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<TableSummary>,
|
|
pub detail: Option<TableDetailView>,
|
|
pub history: Vec<RenameEntry>,
|
|
pub columns: ColumnDraft,
|
|
pub remove_column_ids: Vec<i64>,
|
|
pub copy: CopyForm,
|
|
pub invoice: InvoiceTemplateForm,
|
|
pub status: Option<String>,
|
|
pub error: Option<String>,
|
|
pub sql: Option<String>,
|
|
pub generated: Vec<GeneratedTableView>,
|
|
/// 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());
|
|
}
|
|
}
|