display what accouting would create
This commit is contained in:
@@ -458,6 +458,31 @@ message ListColumnTypesResponse {
|
|||||||
// "temporal" for the date and time types, "gtin" for the GTIN lengths.
|
// "temporal" for the date and time types, "gtin" for the GTIN lengths.
|
||||||
// Empty when the type stands on its own.
|
// Empty when the type stands on its own.
|
||||||
string group = 9;
|
string group = 9;
|
||||||
|
|
||||||
|
// One column a compound type expands into.
|
||||||
|
message GeneratedColumn {
|
||||||
|
// Name the server gives the column. Fixed, and reserved: a table
|
||||||
|
// declaring a compound type may not also declare a column of this name.
|
||||||
|
string name = 1;
|
||||||
|
|
||||||
|
// The generated column's own logical type, as this same catalog
|
||||||
|
// describes it.
|
||||||
|
string field_type = 2;
|
||||||
|
|
||||||
|
// Whether the generated column carries the currency and rounding
|
||||||
|
// declared on the definition row.
|
||||||
|
bool inherits_currency = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What this type expands into, in the order the server creates the
|
||||||
|
// columns. Populated for compound types only, where the generated names
|
||||||
|
// are fixed; empty for every other type, including the ones whose
|
||||||
|
// companions are named after the declared column (phone, iban).
|
||||||
|
//
|
||||||
|
// A picker offering a compound type shows these, so choosing it is not a
|
||||||
|
// blind choice. The columns stay the server's to create — none of them may
|
||||||
|
// be declared.
|
||||||
|
repeated GeneratedColumn generated_columns = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every column type, declarable or not, ordered by name.
|
// Every column type, declarable or not, ordered by name.
|
||||||
|
|||||||
Binary file not shown.
@@ -431,7 +431,7 @@ pub struct ListColumnTypesResponse {
|
|||||||
/// Nested message and enum types in `ListColumnTypesResponse`.
|
/// Nested message and enum types in `ListColumnTypesResponse`.
|
||||||
pub mod list_column_types_response {
|
pub mod list_column_types_response {
|
||||||
/// One column type and everything a client needs to know to offer it.
|
/// One column type and everything a client needs to know to offer it.
|
||||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
pub struct ColumnType {
|
pub struct ColumnType {
|
||||||
/// Logical column type (e.g. "money", "instant"). Passed to the server as
|
/// Logical column type (e.g. "money", "instant"). Passed to the server as
|
||||||
/// ColumnDefinition.field_type — see `spelling`, which is what says whether
|
/// ColumnDefinition.field_type — see `spelling`, which is what says whether
|
||||||
@@ -474,6 +474,35 @@ pub mod list_column_types_response {
|
|||||||
/// Empty when the type stands on its own.
|
/// Empty when the type stands on its own.
|
||||||
#[prost(string, tag = "9")]
|
#[prost(string, tag = "9")]
|
||||||
pub group: ::prost::alloc::string::String,
|
pub group: ::prost::alloc::string::String,
|
||||||
|
/// What this type expands into, in the order the server creates the
|
||||||
|
/// columns. Populated for compound types only, where the generated names
|
||||||
|
/// are fixed; empty for every other type, including the ones whose
|
||||||
|
/// companions are named after the declared column (phone, iban).
|
||||||
|
///
|
||||||
|
/// A picker offering a compound type shows these, so choosing it is not a
|
||||||
|
/// blind choice. The columns stay the server's to create — none of them may
|
||||||
|
/// be declared.
|
||||||
|
#[prost(message, repeated, tag = "10")]
|
||||||
|
pub generated_columns: ::prost::alloc::vec::Vec<column_type::GeneratedColumn>,
|
||||||
|
}
|
||||||
|
/// Nested message and enum types in `ColumnType`.
|
||||||
|
pub mod column_type {
|
||||||
|
/// One column a compound type expands into.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||||
|
pub struct GeneratedColumn {
|
||||||
|
/// Name the server gives the column. Fixed, and reserved: a table
|
||||||
|
/// declaring a compound type may not also declare a column of this name.
|
||||||
|
#[prost(string, tag = "1")]
|
||||||
|
pub name: ::prost::alloc::string::String,
|
||||||
|
/// The generated column's own logical type, as this same catalog
|
||||||
|
/// describes it.
|
||||||
|
#[prost(string, tag = "2")]
|
||||||
|
pub field_type: ::prost::alloc::string::String,
|
||||||
|
/// Whether the generated column carries the currency and rounding
|
||||||
|
/// declared on the definition row.
|
||||||
|
#[prost(bool, tag = "3")]
|
||||||
|
pub inherits_currency: bool,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
|
||||||
|
|||||||
2
server
2
server
Submodule server updated: 22dcc2e032...195db2f04a
@@ -18,6 +18,11 @@ use crate::{
|
|||||||
schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier},
|
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.
|
/// One row of the "Table definition preview" — the schema as it will exist.
|
||||||
pub(crate) struct PreviewRow {
|
pub(crate) struct PreviewRow {
|
||||||
pub mark: String,
|
pub mark: String,
|
||||||
@@ -95,6 +100,15 @@ impl TableDraft {
|
|||||||
Ok(format!("Column `{}` removed.", removed.name))
|
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.
|
/// Adds or removes one display-column candidate.
|
||||||
///
|
///
|
||||||
/// Index 0 is `id`, which is not a display column of its own: choosing it
|
/// 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 {
|
rows.push(PreviewRow {
|
||||||
mark: self
|
mark: self
|
||||||
.row_display_position(&column.name)
|
.row_display_position(&column.name)
|
||||||
@@ -249,6 +263,38 @@ impl TableDraft {
|
|||||||
option: column.option_label(),
|
option: column.option_label(),
|
||||||
source: "user".to_string(),
|
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 {
|
rows.push(PreviewRow {
|
||||||
@@ -471,6 +517,88 @@ mod tests {
|
|||||||
assert_eq!(rows[0].mark, "[x]");
|
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]
|
#[test]
|
||||||
fn indexed_columns_become_the_index_list() {
|
fn indexed_columns_become_the_index_list() {
|
||||||
let mut draft = draft_with_column("number", "text");
|
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),
|
Ok(status) => page.status = Some(status),
|
||||||
Err(message) => page.error = Some(message),
|
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-index" => page.draft.columns.toggle_indexed(index),
|
||||||
"toggle-display" => page.draft.toggle_row_display_candidate(index),
|
"toggle-display" => page.draft.toggle_row_display_candidate(index),
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
use crate::schema::{ColumnCatalog, ColumnDraft, columns_from_rows};
|
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".
|
/// The `profile_name` option meaning "create a new profile too".
|
||||||
pub(crate) const NEW_PROFILE: &str = "__new__";
|
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
|
/// Row-display candidates: `id` first, then every column, matching the
|
||||||
/// client's candidate list.
|
/// client's candidate list.
|
||||||
pub(crate) fn row_display_candidates(&self) -> Vec<RowDisplayCandidate> {
|
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(crate) struct RowDisplayCandidate {
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -288,6 +374,57 @@ mod tests {
|
|||||||
assert_eq!(form.to_draft().row_display_columns, vec!["number"]);
|
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]
|
#[test]
|
||||||
fn the_draft_never_trusts_the_posted_table_list() {
|
fn the_draft_never_trusts_the_posted_table_list() {
|
||||||
// `existing_profile_tables` is what duplicate-name checks read, so it
|
// `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">"#));
|
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]
|
#[test]
|
||||||
fn the_new_profile_fields_appear_only_for_a_new_profile() {
|
fn the_new_profile_fields_appear_only_for_a_new_profile() {
|
||||||
let mut state = page();
|
let mut state = page();
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ pub(crate) struct ColumnType {
|
|||||||
/// Groups several types behind one choice in the picker; empty when the
|
/// Groups several types behind one choice in the picker; empty when the
|
||||||
/// type stands on its own.
|
/// type stands on its own.
|
||||||
pub group: String,
|
pub group: String,
|
||||||
|
/// What a compound type expands into, in the order the server creates the
|
||||||
|
/// columns. Empty for every other type.
|
||||||
|
pub generated_columns: Vec<GeneratedColumn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One column a compound type expands into, as the backend describes it.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) struct GeneratedColumn {
|
||||||
|
pub name: String,
|
||||||
|
pub data_type: String,
|
||||||
|
/// The column is kept in the currency and rounding declared on the
|
||||||
|
/// definition row.
|
||||||
|
pub inherits_currency: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every column type the backend accepts, as one screen's picker reads it.
|
/// Every column type the backend accepts, as one screen's picker reads it.
|
||||||
@@ -184,6 +197,15 @@ impl ColumnCatalog {
|
|||||||
.is_some_and(|column_type| column_type.compound)
|
.is_some_and(|column_type| column_type.compound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The columns a compound type expands into, so a screen can show what
|
||||||
|
/// choosing it will really add. The names are the server's, not this
|
||||||
|
/// crate's: it reports them with the type, and they are fixed.
|
||||||
|
pub(crate) fn generated_columns(&self, field_type: &str) -> &[GeneratedColumn] {
|
||||||
|
self.find(field_type)
|
||||||
|
.map(|column_type| column_type.generated_columns.as_slice())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
fn is_creation_only(&self, field_type: &str) -> bool {
|
fn is_creation_only(&self, field_type: &str) -> bool {
|
||||||
self.find(field_type)
|
self.find(field_type)
|
||||||
.is_some_and(|column_type| column_type.creation_only)
|
.is_some_and(|column_type| column_type.creation_only)
|
||||||
@@ -455,6 +477,23 @@ impl ColumnDraft {
|
|||||||
self.pending_carries_currency()
|
self.pending_carries_currency()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A compound column is named after its type, so there is nothing to type
|
||||||
|
/// in: the panel shows what it will generate instead of a name field.
|
||||||
|
pub(crate) fn pending_is_compound(&self) -> bool {
|
||||||
|
self.catalog.is_compound(&self.type_input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the pending choice would add to the table. Empty unless the choice
|
||||||
|
/// is a compound type.
|
||||||
|
pub(crate) fn pending_generated_columns(&self) -> &[GeneratedColumn] {
|
||||||
|
self.catalog.generated_columns(&self.type_input)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name a compound definition row takes, which is its own type.
|
||||||
|
pub(crate) fn pending_compound_name(&self) -> String {
|
||||||
|
self.type_input.trim().to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
/// The group the pending choice names, when it names one rather than a
|
/// The group the pending choice names, when it names one rather than a
|
||||||
/// type — which is what asks for a follow-up field.
|
/// type — which is what asks for a follow-up field.
|
||||||
fn pending_group(&self) -> Option<String> {
|
fn pending_group(&self) -> Option<String> {
|
||||||
@@ -599,6 +638,39 @@ impl ColumnDraft {
|
|||||||
Ok(self.added.remove(index))
|
Ok(self.added.remove(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Moves one column one place towards the front or the back of the list.
|
||||||
|
///
|
||||||
|
/// The order is the order the columns are declared in, which is the order
|
||||||
|
/// the table gets them in — including a compound column, whose generated
|
||||||
|
/// companions are created where its definition row sits. Moving the
|
||||||
|
/// definition row therefore moves the whole block it expands into.
|
||||||
|
///
|
||||||
|
/// A move off either end is not an error: the buttons for it are not
|
||||||
|
/// rendered, and a crafted post asking for one leaves the order alone.
|
||||||
|
pub(crate) fn move_column(&mut self, index: usize, offset: isize) -> Option<String> {
|
||||||
|
let target = index.checked_add_signed(offset)?;
|
||||||
|
if index >= self.added.len() || target >= self.added.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.added.swap(index, target);
|
||||||
|
Some(format!(
|
||||||
|
"Column `{}` moved {}.",
|
||||||
|
self.added[target].name,
|
||||||
|
if offset < 0 { "up" } else { "down" }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an already-added column expands into — empty unless it is a
|
||||||
|
/// compound one. This is what the column list shows underneath it, so the
|
||||||
|
/// columns a definition row brings are visible while the table is still
|
||||||
|
/// being described.
|
||||||
|
pub(crate) fn generated_columns_of(&self, index: usize) -> &[GeneratedColumn] {
|
||||||
|
self.added
|
||||||
|
.get(index)
|
||||||
|
.map(|column| self.catalog.generated_columns(&column.data_type))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a column can be indexed or identify a row. A compound column
|
/// Whether a column can be indexed or identify a row. A compound column
|
||||||
/// leaves no column of its own name behind, so it can do neither.
|
/// leaves no column of its own name behind, so it can do neither.
|
||||||
pub(crate) fn is_indexable(&self, index: usize) -> bool {
|
pub(crate) fn is_indexable(&self, index: usize) -> bool {
|
||||||
@@ -791,6 +863,15 @@ pub(crate) fn column_catalog(column_types: Vec<ProtoColumnType>) -> ColumnCatalo
|
|||||||
creation_only: column_type.creation_only,
|
creation_only: column_type.creation_only,
|
||||||
allows_quantity_ledger: column_type.allows_quantity_ledger,
|
allows_quantity_ledger: column_type.allows_quantity_ledger,
|
||||||
group: column_type.group,
|
group: column_type.group,
|
||||||
|
generated_columns: column_type
|
||||||
|
.generated_columns
|
||||||
|
.into_iter()
|
||||||
|
.map(|generated| GeneratedColumn {
|
||||||
|
name: generated.name,
|
||||||
|
data_type: generated.field_type,
|
||||||
|
inherits_currency: generated.inherits_currency,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
@@ -954,6 +1035,15 @@ pub(crate) mod tests {
|
|||||||
creation_only: false,
|
creation_only: false,
|
||||||
allows_quantity_ledger: false,
|
allows_quantity_ledger: false,
|
||||||
group: String::new(),
|
group: String::new(),
|
||||||
|
generated_columns: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generated_column(name: &str, data_type: &str, inherits_currency: bool) -> GeneratedColumn {
|
||||||
|
GeneratedColumn {
|
||||||
|
name: name.to_string(),
|
||||||
|
data_type: data_type.to_string(),
|
||||||
|
inherits_currency,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,9 +1077,23 @@ pub(crate) mod tests {
|
|||||||
ColumnCatalog::new(vec![
|
ColumnCatalog::new(vec![
|
||||||
ColumnType {
|
ColumnType {
|
||||||
requires_currency: true,
|
requires_currency: true,
|
||||||
|
// The companions the server reports with the type, in the
|
||||||
|
// order it creates them.
|
||||||
|
generated_columns: vec![
|
||||||
|
generated_column("name", "text", false),
|
||||||
|
generated_column("tax_point_date", "date", false),
|
||||||
|
generated_column("debit", "money", true),
|
||||||
|
generated_column("credit", "money", true),
|
||||||
|
],
|
||||||
..compound("accounting")
|
..compound("accounting")
|
||||||
},
|
},
|
||||||
compound("accounting_transfer"),
|
ColumnType {
|
||||||
|
generated_columns: vec![
|
||||||
|
generated_column("source_period_id", "bigint", false),
|
||||||
|
generated_column("target_period_id", "bigint", false),
|
||||||
|
],
|
||||||
|
..compound("accounting_transfer")
|
||||||
|
},
|
||||||
ColumnType {
|
ColumnType {
|
||||||
sql_type: "TIMESTAMPTZ(0)".to_string(),
|
sql_type: "TIMESTAMPTZ(0)".to_string(),
|
||||||
..grouped("instant", "temporal")
|
..grouped("instant", "temporal")
|
||||||
@@ -1394,6 +1498,78 @@ pub(crate) mod tests {
|
|||||||
assert!(columns[0].indexed);
|
assert!(columns[0].indexed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The declared order is the order the table gets its columns in, so it is
|
||||||
|
/// the user's to change — and a move off either end changes nothing rather
|
||||||
|
/// than wrapping around or panicking.
|
||||||
|
#[test]
|
||||||
|
fn a_column_moves_one_place_and_stops_at_the_ends() {
|
||||||
|
let mut draft = draft();
|
||||||
|
for name in ["number", "issued_on", "total"] {
|
||||||
|
draft.name_input = name.to_string();
|
||||||
|
draft.type_input = "text".to_string();
|
||||||
|
draft.add_from_inputs().unwrap();
|
||||||
|
}
|
||||||
|
let names = |draft: &ColumnDraft| {
|
||||||
|
draft
|
||||||
|
.added
|
||||||
|
.iter()
|
||||||
|
.map(|column| column.name.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
draft.move_column(2, -1),
|
||||||
|
Some("Column `total` moved up.".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(names(&draft), ["number", "total", "issued_on"]);
|
||||||
|
|
||||||
|
draft.move_column(0, 1).unwrap();
|
||||||
|
assert_eq!(names(&draft), ["total", "number", "issued_on"]);
|
||||||
|
|
||||||
|
// Off either end, and past the end of the list entirely.
|
||||||
|
assert_eq!(draft.move_column(0, -1), None);
|
||||||
|
assert_eq!(draft.move_column(2, 1), None);
|
||||||
|
assert_eq!(draft.move_column(9, -1), None);
|
||||||
|
assert_eq!(names(&draft), ["total", "number", "issued_on"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The columns a definition row expands into are the server's answer,
|
||||||
|
/// carried by the catalog rather than written down here.
|
||||||
|
#[test]
|
||||||
|
fn a_compound_column_reports_what_it_expands_into() {
|
||||||
|
let mut draft = draft();
|
||||||
|
draft.type_input = "accounting".to_string();
|
||||||
|
assert!(draft.pending_is_compound());
|
||||||
|
assert_eq!(draft.pending_compound_name(), "accounting");
|
||||||
|
assert_eq!(
|
||||||
|
draft
|
||||||
|
.pending_generated_columns()
|
||||||
|
.iter()
|
||||||
|
.map(|generated| generated.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["name", "tax_point_date", "debit", "credit"]
|
||||||
|
);
|
||||||
|
|
||||||
|
draft.add_from_inputs().unwrap();
|
||||||
|
assert_eq!(draft.generated_columns_of(0).len(), 4);
|
||||||
|
// DEBIT and CREDIT are the two kept in the declared currency.
|
||||||
|
assert_eq!(
|
||||||
|
draft
|
||||||
|
.generated_columns_of(0)
|
||||||
|
.iter()
|
||||||
|
.filter(|generated| generated.inherits_currency)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
// An ordinary column expands into nothing, and its name is its own.
|
||||||
|
draft.name_input = "number".to_string();
|
||||||
|
draft.type_input = "text".to_string();
|
||||||
|
assert!(!draft.pending_is_compound());
|
||||||
|
draft.add_from_inputs().unwrap();
|
||||||
|
assert!(draft.generated_columns_of(1).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn indexed_columns_become_the_index_list() {
|
fn indexed_columns_become_the_index_list() {
|
||||||
let mut draft = draft();
|
let mut draft = draft();
|
||||||
|
|||||||
@@ -151,6 +151,22 @@
|
|||||||
.builder-table .mark { width: 34px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #4a5568; }
|
.builder-table .mark { width: 34px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #4a5568; }
|
||||||
.builder-table.preview .source-system td { color: #7a8496; background: #fbfcfd; }
|
.builder-table.preview .source-system td { color: #7a8496; background: #fbfcfd; }
|
||||||
.builder-table.preview .source-relation td { color: #3c5a86; }
|
.builder-table.preview .source-relation td { color: #3c5a86; }
|
||||||
|
/* A compound column's companions, in both tables: the server's columns, so
|
||||||
|
they read as a block hanging off the definition row above them. */
|
||||||
|
.builder-table .generated-row td, .builder-table.preview .source-generated td { color: #5b6678; background: #fbfcfd; }
|
||||||
|
.builder-table .order { width: 78px; white-space: nowrap; }
|
||||||
|
.builder-table .order .hint { padding-left: 12px; }
|
||||||
|
|
||||||
|
button.toggle.move { min-width: 26px; padding: 3px 5px; }
|
||||||
|
button.toggle.move + button.toggle.move { margin-left: 4px; }
|
||||||
|
button.toggle:disabled { color: #b3bcc9; background: #f7f8fa; cursor: default; }
|
||||||
|
|
||||||
|
/* What a compound choice will add, shown before it is added. */
|
||||||
|
.compound-note { margin-top: 14px; padding: 12px 14px; border: 1px solid #dfe6f3; border-radius: 8px; background: #f7f9fe; }
|
||||||
|
.compound-note p { margin: 0; font-size: 13px; color: #33415c; }
|
||||||
|
.compound-note ul { margin: 8px 0; padding-left: 18px; font-size: 13px; }
|
||||||
|
.compound-note li { padding: 1px 0; }
|
||||||
|
.compound-note .hint { font-size: 12px; }
|
||||||
|
|
||||||
.builder-list { margin: 12px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
|
.builder-list { margin: 12px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.builder-list li { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid #e1e6ee; border-radius: 7px; background: white; }
|
.builder-list li { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid #e1e6ee; border-radius: 7px; background: white; }
|
||||||
|
|||||||
@@ -86,10 +86,17 @@
|
|||||||
<input type="hidden" name="accounting_currency" value="{{ page.draft.accounting_currency }}">
|
<input type="hidden" name="accounting_currency" value="{{ page.draft.accounting_currency }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{#
|
||||||
|
No hx-trigger here. A text input fires `change` on blur, so mousing into
|
||||||
|
the next field would swap the whole builder out from under the click that
|
||||||
|
is still in flight: the click lands on a detached node and the field only
|
||||||
|
takes focus on a second click. Nothing rendered depends on the table name
|
||||||
|
on its own — it filters the table out of its own link-target list, and
|
||||||
|
that list is only rendered after a column-type `change`, which posts the
|
||||||
|
current name along with the rest of the form.
|
||||||
|
#}
|
||||||
<label>Table name
|
<label>Table name
|
||||||
<input name="table_name" value="{{ page.draft.table_name }}" placeholder="invoice"
|
<input name="table_name" value="{{ page.draft.table_name }}" placeholder="invoice">
|
||||||
hx-post="/admin/tables/builder" hx-trigger="change" hx-include="#table-form"
|
|
||||||
hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "refresh"}'>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -98,10 +105,17 @@
|
|||||||
<section class="builder-section">
|
<section class="builder-section">
|
||||||
<h2>Add a column</h2>
|
<h2>Add a column</h2>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
{#
|
||||||
|
A compound choice is a definition row, not a column: it is named after
|
||||||
|
its own type and expands into companions the server creates. There is
|
||||||
|
nothing to name, so the field is replaced by what the choice will add.
|
||||||
|
#}
|
||||||
|
{% if !page.draft.columns.pending_is_compound() %}
|
||||||
<label>{% if page.draft.columns.show_link_target() %}Link alias{% else %}Column name{% endif %}
|
<label>{% if page.draft.columns.show_link_target() %}Link alias{% else %}Column name{% endif %}
|
||||||
<input name="column_name_input" value="{{ page.draft.columns.name_input }}" placeholder="number">
|
<input name="column_name_input" value="{{ page.draft.columns.name_input }}" placeholder="number">
|
||||||
{% if page.draft.columns.show_link_target() %}<small>The alias is the column name used to distinguish this relationship.</small>{% endif %}
|
{% if page.draft.columns.show_link_target() %}<small>The alias is the column name used to distinguish this relationship.</small>{% endif %}
|
||||||
</label>
|
</label>
|
||||||
|
{% endif %}
|
||||||
<label>Column type
|
<label>Column type
|
||||||
<select name="column_type_input" hx-post="/admin/tables/builder" hx-trigger="change"
|
<select name="column_type_input" hx-post="/admin/tables/builder" hx-trigger="change"
|
||||||
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
|
hx-include="#table-form" hx-target="#builder" hx-swap="innerHTML"
|
||||||
@@ -191,6 +205,9 @@
|
|||||||
</label>
|
</label>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Neither applies to a definition row: it leaves no column to index, and
|
||||||
|
nothing to keep a quantity ledger on. #}
|
||||||
|
{% if !page.draft.columns.pending_is_compound() %}
|
||||||
<label>Indexing
|
<label>Indexing
|
||||||
<select name="column_indexing_input">
|
<select name="column_indexing_input">
|
||||||
<option value="no" {% if page.draft.columns.indexing_input != "yes" %}selected{% endif %}>no</option>
|
<option value="no" {% if page.draft.columns.indexing_input != "yes" %}selected{% endif %}>no</option>
|
||||||
@@ -204,7 +221,26 @@
|
|||||||
</select>
|
</select>
|
||||||
<small>{{ page.draft.columns.quantity_ledger_types() }} only.</small>
|
<small>{{ page.draft.columns.quantity_ledger_types() }} only.</small>
|
||||||
</label>
|
</label>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if page.draft.columns.pending_is_compound() %}
|
||||||
|
<div class="compound-note">
|
||||||
|
<p><code>{{ page.draft.columns.pending_compound_name() }}</code> is a definition row, not a column of its own. Adding it gives the table:</p>
|
||||||
|
<ul>
|
||||||
|
{% for generated in page.draft.columns.pending_generated_columns() %}
|
||||||
|
{# The currency and rounding are named rather than shown: those two
|
||||||
|
inputs sit above with no `change` trigger of their own, so their
|
||||||
|
current values are only known here on the next render. #}
|
||||||
|
<li><code>{{ generated.name }}</code> <span class="hint">{{ generated.data_type }}{% if generated.inherits_currency %} — kept in the currency and rounding chosen above{% endif %}</span></li>
|
||||||
|
{% endfor %}
|
||||||
|
{% if page.draft.columns.pending_compound_name() == "accounting" %}
|
||||||
|
<li><code>account_id</code> <span class="hint">system column — the account each row is posted to, written as <code>account</code></span></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
<p class="hint">These names are reserved: no column of your own may use them, and the table may hold only one <code>{{ page.draft.columns.pending_compound_name() }}</code>. They land where this row sits in the column list, and can be moved with it.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<button type="button" class="secondary" hx-post="/admin/tables/builder" hx-include="#table-form"
|
<button type="button" class="secondary" hx-post="/admin/tables/builder" hx-include="#table-form"
|
||||||
hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "add-column"}'>Add column</button>
|
hx-target="#builder" hx-swap="innerHTML" hx-vals='{"action": "add-column"}'>Add column</button>
|
||||||
</section>
|
</section>
|
||||||
@@ -214,34 +250,58 @@
|
|||||||
{% if page.draft.columns.is_empty() %}
|
{% if page.draft.columns.is_empty() %}
|
||||||
<p class="hint">No columns yet. Describe one above and press <em>Add column</em>.</p>
|
<p class="hint">No columns yet. Describe one above and press <em>Add column</em>.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
<p class="hint">Columns are created in this order. Use the arrows to move one.</p>
|
||||||
<table class="builder-table">
|
<table class="builder-table">
|
||||||
<thead><tr><th>Name</th><th>Type</th><th>Indexed</th><th>Options</th><th></th></tr></thead>
|
<thead><tr><th>Order</th><th>Name</th><th>Type</th><th>Indexed</th><th>Options</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for column in page.draft.columns.added %}
|
{#
|
||||||
<tr>
|
Every declared column, each followed by the columns the server will
|
||||||
<td><code>{{ column.name }}</code></td>
|
generate from it. `row.index` is what says which is which: a
|
||||||
<td>{{ column.data_type }}</td>
|
generated column has none, because it is not the user's to move,
|
||||||
|
index or remove — it belongs to the definition row above it.
|
||||||
|
#}
|
||||||
|
{% for row in page.column_rows() %}
|
||||||
|
<tr {% if row.index.is_none() %}class="generated-row"{% endif %}>
|
||||||
|
<td class="order">
|
||||||
|
{% if let Some(index) = row.index %}
|
||||||
|
<button type="button" class="toggle move" aria-label="Move {{ row.name }} up"
|
||||||
|
{% if row.first %}disabled{% else %}hx-post="/admin/tables/builder" hx-include="#table-form"
|
||||||
|
hx-target="#builder" hx-swap="innerHTML"
|
||||||
|
hx-vals='{"action": "move-column-up", "index": "{{ index }}"}'{% endif %}>↑</button>
|
||||||
|
<button type="button" class="toggle move" aria-label="Move {{ row.name }} down"
|
||||||
|
{% if row.last %}disabled{% else %}hx-post="/admin/tables/builder" hx-include="#table-form"
|
||||||
|
hx-target="#builder" hx-swap="innerHTML"
|
||||||
|
hx-vals='{"action": "move-column-down", "index": "{{ index }}"}'{% endif %}>↓</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="hint">↳</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><code>{{ row.name }}</code></td>
|
||||||
|
<td>{{ row.data_type }}</td>
|
||||||
<td>
|
<td>
|
||||||
{# A compound column expands into schema-managed companions, so
|
{# A compound column expands into schema-managed companions, so
|
||||||
there is no column of its own name to index. #}
|
there is no column of its own name to index. #}
|
||||||
{% if page.draft.columns.is_indexable(*loop.index0) %}
|
{% if row.indexable %}
|
||||||
|
{% if let Some(index) = row.index %}
|
||||||
<button type="button" class="toggle" hx-post="/admin/tables/builder" hx-include="#table-form"
|
<button type="button" class="toggle" hx-post="/admin/tables/builder" hx-include="#table-form"
|
||||||
hx-target="#builder" hx-swap="innerHTML"
|
hx-target="#builder" hx-swap="innerHTML"
|
||||||
hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
|
hx-vals='{"action": "toggle-index", "index": "{{ index }}"}'>
|
||||||
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
|
{% if row.indexed %}[x]{% else %}[ ]{% endif %}
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% else if row.indexed %}
|
||||||
|
<span class="tag">indexed</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="hint">—</span>
|
<span class="hint">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{% for tag in row.tags %}<span class="tag">{{ tag }}</span>{% endfor %}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if column.quantity_ledger %}<span class="tag">quantity ledger</span>{% endif %}
|
{% if let Some(index) = row.index %}
|
||||||
{% if !column.option_label().is_empty() %}<span class="tag">{{ column.option_label() }}</span>{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button type="button" class="danger" hx-post="/admin/tables/builder" hx-include="#table-form"
|
<button type="button" class="danger" hx-post="/admin/tables/builder" hx-include="#table-form"
|
||||||
hx-target="#builder" hx-swap="innerHTML"
|
hx-target="#builder" hx-swap="innerHTML"
|
||||||
hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>Remove</button>
|
hx-vals='{"action": "remove-column", "index": "{{ index }}"}'>Remove</button>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
Reference in New Issue
Block a user