display what accouting would create
This commit is contained in:
@@ -18,6 +18,11 @@ use crate::{
|
||||
schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier},
|
||||
};
|
||||
|
||||
/// The compound type that also brings a system column with it. Which columns
|
||||
/// it generates is the catalog's answer; the account foreign key is not one of
|
||||
/// them, and [`TableDraft::preview_rows`] is where that is explained.
|
||||
pub(crate) const ACCOUNTING_FIELD_TYPE: &str = "accounting";
|
||||
|
||||
/// One row of the "Table definition preview" — the schema as it will exist.
|
||||
pub(crate) struct PreviewRow {
|
||||
pub mark: String,
|
||||
@@ -95,6 +100,15 @@ impl TableDraft {
|
||||
Ok(format!("Column `{}` removed.", removed.name))
|
||||
}
|
||||
|
||||
/// Moves one column one place up or down the list.
|
||||
///
|
||||
/// The display columns keep their own order, which is the order they were
|
||||
/// chosen in rather than the order the columns are declared in, so nothing
|
||||
/// here touches them.
|
||||
pub(crate) fn move_column(&mut self, index: usize, offset: isize) -> Option<String> {
|
||||
self.columns.move_column(index, offset)
|
||||
}
|
||||
|
||||
/// Adds or removes one display-column candidate.
|
||||
///
|
||||
/// Index 0 is `id`, which is not a display column of its own: choosing it
|
||||
@@ -238,7 +252,7 @@ impl TableDraft {
|
||||
},
|
||||
];
|
||||
|
||||
for column in &self.columns.added {
|
||||
for (index, column) in self.columns.added.iter().enumerate() {
|
||||
rows.push(PreviewRow {
|
||||
mark: self
|
||||
.row_display_position(&column.name)
|
||||
@@ -249,6 +263,38 @@ impl TableDraft {
|
||||
option: column.option_label(),
|
||||
source: "user".to_string(),
|
||||
});
|
||||
|
||||
// A compound column is a definition row: the columns the table
|
||||
// really gets are the ones it expands into, created where the
|
||||
// definition row sits. They are listed here so what a compound
|
||||
// choice does is visible before the table exists.
|
||||
for generated in self.columns.generated_columns_of(index) {
|
||||
rows.push(PreviewRow {
|
||||
mark: String::new(),
|
||||
column: generated.name.clone(),
|
||||
data_type: generated.data_type.clone(),
|
||||
option: if generated.inherits_currency {
|
||||
format!("{}, {}", column.currency, column.money_mode.label())
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
source: "generated".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// The one companion the catalog does not report, because it is a
|
||||
// system column rather than a user one: ACCOUNTING's foreign key to
|
||||
// the profile's accounts, which the data API exposes as the virtual
|
||||
// `account` field.
|
||||
if column.data_type == ACCOUNTING_FIELD_TYPE {
|
||||
rows.push(PreviewRow {
|
||||
mark: String::new(),
|
||||
column: "account_id".to_string(),
|
||||
data_type: "BIGINT".to_string(),
|
||||
option: "not null, → accounts, written as account".to_string(),
|
||||
source: "system".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
rows.push(PreviewRow {
|
||||
@@ -471,6 +517,88 @@ mod tests {
|
||||
assert_eq!(rows[0].mark, "[x]");
|
||||
}
|
||||
|
||||
/// Moving a column changes the order the table is declared in and nothing
|
||||
/// else — the display columns are shown in the order they were chosen in,
|
||||
/// which is not this order.
|
||||
#[test]
|
||||
fn moving_a_column_leaves_the_display_columns_alone() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.columns.added.push(ColumnDefinition {
|
||||
name: "issued_on".to_string(),
|
||||
data_type: "date".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: String::new(),
|
||||
});
|
||||
draft.toggle_row_display_candidate(1); // number
|
||||
draft.toggle_row_display_candidate(2); // issued_on
|
||||
|
||||
draft.move_column(0, 1).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
draft
|
||||
.columns
|
||||
.added
|
||||
.iter()
|
||||
.map(|column| column.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["issued_on", "number"]
|
||||
);
|
||||
assert_eq!(draft.row_display_columns, vec!["number", "issued_on"]);
|
||||
}
|
||||
|
||||
/// The point of the preview: a definition row is not one column, and the
|
||||
/// columns it stands for are the ones the table will really have.
|
||||
#[test]
|
||||
fn the_preview_expands_an_accounting_column() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
draft.columns.added.push(ColumnDefinition {
|
||||
name: "accounting".to_string(),
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Rounded,
|
||||
currency: "CZK".to_string(),
|
||||
});
|
||||
|
||||
let rows = draft.preview_rows();
|
||||
let columns = rows
|
||||
.iter()
|
||||
.map(|row| row.column.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
columns,
|
||||
vec![
|
||||
"id",
|
||||
"deleted",
|
||||
"number",
|
||||
"accounting",
|
||||
"name",
|
||||
"tax_point_date",
|
||||
"debit",
|
||||
"credit",
|
||||
// The account foreign key, which the catalog does not report
|
||||
// because it is a system column rather than a generated one.
|
||||
"account_id",
|
||||
"created_at",
|
||||
]
|
||||
);
|
||||
|
||||
let row = |name: &str| {
|
||||
rows.iter()
|
||||
.find(|row| row.column == name)
|
||||
.unwrap_or_else(|| panic!("`{name}` is missing from the preview"))
|
||||
};
|
||||
assert_eq!(row("name").source, "generated");
|
||||
assert_eq!(row("tax_point_date").data_type, "date");
|
||||
// DEBIT and CREDIT are kept in the definition row's own currency.
|
||||
assert_eq!(row("debit").option, "CZK, half-up");
|
||||
assert_eq!(row("name").option, "");
|
||||
assert_eq!(row("account_id").source, "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_columns_become_the_index_list() {
|
||||
let mut draft = draft_with_column("number", "text");
|
||||
|
||||
@@ -152,6 +152,10 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
|
||||
Ok(status) => page.status = Some(status),
|
||||
Err(message) => page.error = Some(message),
|
||||
},
|
||||
// The order columns are declared in is the order the table gets them
|
||||
// in, so moving one is a change to the draft like any other.
|
||||
"move-column-up" => page.status = page.draft.move_column(index, -1),
|
||||
"move-column-down" => page.status = page.draft.move_column(index, 1),
|
||||
"toggle-index" => page.draft.columns.toggle_indexed(index),
|
||||
"toggle-display" => page.draft.toggle_row_display_candidate(index),
|
||||
_ => {}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
use crate::schema::{ColumnCatalog, ColumnDraft, columns_from_rows};
|
||||
|
||||
use super::draft::TableDraft;
|
||||
use super::draft::{ACCOUNTING_FIELD_TYPE, TableDraft};
|
||||
|
||||
/// The `profile_name` option meaning "create a new profile too".
|
||||
pub(crate) const NEW_PROFILE: &str = "__new__";
|
||||
@@ -172,6 +172,75 @@ impl AddTablePageState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "Columns" list: every declared column, each followed by the columns
|
||||
/// it expands into.
|
||||
///
|
||||
/// The expansion is flattened here rather than nested in the template so
|
||||
/// the markup stays one loop over one list, and so what a definition row
|
||||
/// brings is decided in one place.
|
||||
pub(crate) fn column_rows(&self) -> Vec<ColumnRow> {
|
||||
let columns = &self.draft.columns;
|
||||
let last_index = columns.added.len().saturating_sub(1);
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for (index, column) in columns.added.iter().enumerate() {
|
||||
let mut tags = Vec::new();
|
||||
if column.quantity_ledger {
|
||||
tags.push("quantity ledger".to_string());
|
||||
}
|
||||
if !column.option_label().is_empty() {
|
||||
tags.push(column.option_label());
|
||||
}
|
||||
rows.push(ColumnRow {
|
||||
index: Some(index),
|
||||
first: index == 0,
|
||||
last: index == last_index,
|
||||
name: column.name.clone(),
|
||||
data_type: column.data_type.clone(),
|
||||
indexable: columns.is_indexable(index),
|
||||
indexed: column.indexed,
|
||||
tags,
|
||||
});
|
||||
|
||||
for generated in columns.generated_columns_of(index) {
|
||||
let mut tags = vec![format!("generated by {}", column.data_type)];
|
||||
if generated.inherits_currency {
|
||||
tags.push(format!("{}, {}", column.currency, column.money_mode.label()));
|
||||
}
|
||||
rows.push(ColumnRow {
|
||||
index: None,
|
||||
first: false,
|
||||
last: false,
|
||||
name: generated.name.clone(),
|
||||
data_type: generated.data_type.clone(),
|
||||
indexable: false,
|
||||
indexed: false,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
// The account foreign key is a system column rather than a
|
||||
// generated user column, so the catalog does not report it; the
|
||||
// same explanation as in `TableDraft::preview_rows`.
|
||||
if column.data_type == ACCOUNTING_FIELD_TYPE {
|
||||
rows.push(ColumnRow {
|
||||
index: None,
|
||||
first: false,
|
||||
last: false,
|
||||
name: "account_id".to_string(),
|
||||
data_type: "BIGINT".to_string(),
|
||||
indexable: false,
|
||||
indexed: true,
|
||||
tags: vec![
|
||||
"system column".to_string(),
|
||||
"written as account".to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Row-display candidates: `id` first, then every column, matching the
|
||||
/// client's candidate list.
|
||||
pub(crate) fn row_display_candidates(&self) -> Vec<RowDisplayCandidate> {
|
||||
@@ -203,6 +272,23 @@ impl AddTablePageState {
|
||||
}
|
||||
}
|
||||
|
||||
/// One line of the "Columns" list: either a column the user declared, or one
|
||||
/// the server will generate from the definition row above it.
|
||||
pub(crate) struct ColumnRow {
|
||||
/// Where the column sits in the draft, for the buttons that act on it.
|
||||
/// `None` for a generated column, which is not the user's to act on: it
|
||||
/// moves and is removed with the definition row it came from.
|
||||
pub index: Option<usize>,
|
||||
/// Whether it can move any further up, and any further down.
|
||||
pub first: bool,
|
||||
pub last: bool,
|
||||
pub name: String,
|
||||
pub data_type: String,
|
||||
pub indexable: bool,
|
||||
pub indexed: bool,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct RowDisplayCandidate {
|
||||
pub index: usize,
|
||||
pub name: String,
|
||||
@@ -288,6 +374,57 @@ mod tests {
|
||||
assert_eq!(form.to_draft().row_display_columns, vec!["number"]);
|
||||
}
|
||||
|
||||
/// The column list shows a definition row with the columns it expands
|
||||
/// into. Only the definition row is the user's to act on: the generated
|
||||
/// ones carry no index, so they get no buttons.
|
||||
#[test]
|
||||
fn the_column_list_shows_what_a_definition_row_expands_into() {
|
||||
let mut page = AddTablePageState {
|
||||
nav: crate::ui::Nav::default(),
|
||||
profiles: Vec::new(),
|
||||
draft: posted_form().to_draft(),
|
||||
status: None,
|
||||
error: None,
|
||||
};
|
||||
page.draft.columns.catalog = crate::schema::tests::catalog();
|
||||
page.draft.columns.added.push(crate::schema::ColumnDefinition {
|
||||
name: "accounting".to_string(),
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
|
||||
let rows = page.column_rows();
|
||||
assert_eq!(
|
||||
rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>(),
|
||||
[
|
||||
"number",
|
||||
"total",
|
||||
"accounting",
|
||||
"name",
|
||||
"tax_point_date",
|
||||
"debit",
|
||||
"credit",
|
||||
"account_id",
|
||||
]
|
||||
);
|
||||
|
||||
let row = |name: &str| rows.iter().find(|row| row.name == name).unwrap();
|
||||
// Only the three declared columns can be moved, indexed or removed.
|
||||
assert_eq!(
|
||||
rows.iter().filter(|row| row.index.is_some()).count(),
|
||||
3,
|
||||
"only declared columns carry an index"
|
||||
);
|
||||
assert!(row("number").first);
|
||||
assert!(row("accounting").last);
|
||||
assert!(!row("accounting").indexable, "a definition row is no column");
|
||||
assert!(row("debit").tags.contains(&"EUR, exact".to_string()));
|
||||
assert!(row("name").tags.contains(&"generated by accounting".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_draft_never_trusts_the_posted_table_list() {
|
||||
// `existing_profile_tables` is what duplicate-name checks read, so it
|
||||
|
||||
@@ -197,6 +197,77 @@ mod tests {
|
||||
assert!(html.contains(r#"<optgroup label="System-created">"#));
|
||||
}
|
||||
|
||||
/// Choosing a compound type says what it will add, instead of leaving the
|
||||
/// choice to be discovered after the table exists.
|
||||
#[test]
|
||||
fn choosing_accounting_shows_the_columns_it_will_generate() {
|
||||
let mut state = page();
|
||||
assert!(!render_builder(&state).contains("compound-note"));
|
||||
|
||||
state.draft.columns.type_input = "accounting".to_string();
|
||||
let html = render_builder(&state);
|
||||
|
||||
assert!(html.contains("compound-note"));
|
||||
for generated in ["name", "tax_point_date", "debit", "credit", "account_id"] {
|
||||
assert!(
|
||||
html.contains(&format!("<code>{generated}</code>")),
|
||||
"the note is missing {generated}"
|
||||
);
|
||||
}
|
||||
// A definition row is named after its own type, so there is nothing to
|
||||
// type in and nothing to index.
|
||||
assert!(!html.contains(r#"name="column_name_input""#));
|
||||
assert!(!html.contains(r#"name="column_indexing_input""#));
|
||||
// It does declare the currency its DEBIT and CREDIT are kept in.
|
||||
assert!(html.contains(r#"name="column_currency_input""#));
|
||||
}
|
||||
|
||||
/// The column list can be reordered, and the ends say so by refusing.
|
||||
#[test]
|
||||
fn columns_carry_move_buttons_that_stop_at_the_ends() {
|
||||
let mut state = page();
|
||||
state.draft.columns.added.push(ColumnDefinition {
|
||||
name: "total".to_string(),
|
||||
data_type: "money".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
|
||||
let html = render_builder(&state);
|
||||
|
||||
assert!(html.contains(r#""action": "move-column-down", "index": "0""#));
|
||||
assert!(html.contains(r#""action": "move-column-up", "index": "1""#));
|
||||
// The first column cannot move up, nor the last one down.
|
||||
assert!(!html.contains(r#""action": "move-column-up", "index": "0""#));
|
||||
assert!(!html.contains(r#""action": "move-column-down", "index": "1""#));
|
||||
assert_eq!(html.matches("disabled").count(), 2);
|
||||
}
|
||||
|
||||
/// And an added definition row is listed with the columns it stands for.
|
||||
#[test]
|
||||
fn the_column_list_shows_generated_columns_under_their_definition_row() {
|
||||
let mut state = page();
|
||||
state.draft.columns.added.push(ColumnDefinition {
|
||||
name: "accounting".to_string(),
|
||||
data_type: "accounting".to_string(),
|
||||
indexed: false,
|
||||
quantity_ledger: false,
|
||||
money_mode: MoneyMode::Exact,
|
||||
currency: "EUR".to_string(),
|
||||
});
|
||||
|
||||
let html = render_builder(&state);
|
||||
|
||||
assert!(html.contains("generated-row"));
|
||||
assert!(html.contains("generated by accounting"));
|
||||
assert!(html.contains("<code>tax_point_date</code>"));
|
||||
// A generated column is not the user's to remove: the definition row
|
||||
// is the only one of the two with a Remove button.
|
||||
assert_eq!(html.matches("remove-column").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_profile_fields_appear_only_for_a_new_profile() {
|
||||
let mut state = page();
|
||||
|
||||
Reference in New Issue
Block a user