web interface for table_definition improved
This commit is contained in:
346
web/src/pages/admin/table_definition/state.rs
Normal file
346
web/src/pages/admin/table_definition/state.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
//! 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::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<String>,
|
||||
pub row_display_columns: 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 == "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<String>,
|
||||
pub columns: Vec<DetailColumn>,
|
||||
pub scripts: Vec<ScriptView>,
|
||||
}
|
||||
|
||||
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,
|
||||
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<String> {
|
||||
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<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 columns staged for `AddTableColumns`.
|
||||
pub columns: ColumnDraft,
|
||||
pub rename: RenameForm,
|
||||
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 {
|
||||
pub(crate) fn for_selection(selection: Selection) -> Self {
|
||||
Self {
|
||||
selection,
|
||||
columns: ColumnDraft::for_append(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the templates read.
|
||||
pub(crate) struct TableDefinitionPageState {
|
||||
pub nav: crate::ui::Nav,
|
||||
pub profiles: Vec<String>,
|
||||
pub selection: Selection,
|
||||
pub tables: Vec<TableSummary>,
|
||||
pub detail: Option<TableDetailView>,
|
||||
pub history: Vec<RenameEntry>,
|
||||
pub columns: ColumnDraft,
|
||||
pub rename: RenameForm,
|
||||
pub copy: CopyForm,
|
||||
pub invoice: InvoiceTemplateForm,
|
||||
pub status: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub sql: Option<String>,
|
||||
pub generated: Vec<GeneratedTableView>,
|
||||
}
|
||||
|
||||
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(),
|
||||
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(),
|
||||
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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user