talbe definition web

This commit is contained in:
Priec
2026-08-06 17:47:53 +02:00
parent f13def52c1
commit 5f4b026624
14 changed files with 870 additions and 273 deletions

View File

@@ -15,7 +15,7 @@ use crate::{
definitions::table_definition::{
PostTableDefinitionRequest, TableLink as ProtoTableLink,
},
schema::{ColumnDraft, proto_columns, validate_identifier},
schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier},
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -102,11 +102,13 @@ pub(crate) struct TableDraft {
}
impl TableDraft {
/// A draft for a brand-new page load, matching the client's defaults.
/// A draft for a brand-new page load, matching the client's defaults. The
/// column panel's vocabulary is filled in by the loader, which is the only
/// thing that knows it.
pub(crate) fn new() -> Self {
Self {
accounting_currency: "EUR".to_string(),
columns: ColumnDraft::new(),
columns: ColumnDraft::new(ColumnCatalog::default()),
..Self::default()
}
}
@@ -148,6 +150,11 @@ impl TableDraft {
self.row_display_columns.clear();
return;
}
// A compound column leaves no column of its own name behind, so it can
// never identify a row; `row_display_candidates` does not offer one.
if !self.columns.is_indexable(index - 1) {
return;
}
let Some(column) = self
.columns
.added
@@ -354,6 +361,9 @@ mod tests {
fn draft_with_column(name: &str, data_type: &str) -> TableDraft {
let mut draft = TableDraft::new();
// The loader fills this in from the backend; a draft under test gets
// the same vocabulary directly.
draft.columns.catalog = crate::schema::tests::catalog();
draft.profile_name = "billing".to_string();
draft.table_name = "invoice".to_string();
draft.columns.added.push(ColumnDefinition {
@@ -471,6 +481,26 @@ mod tests {
assert!(draft.row_display_columns.is_empty());
}
/// A compound column expands into schema-managed companions, so there is
/// no column of that name for a row to be identified by — the builder does
/// not offer it, and a crafted post cannot choose it either.
#[test]
fn a_compound_column_never_identifies_a_row() {
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::Exact,
currency: "EUR".to_string(),
});
draft.toggle_row_display_candidate(2);
assert!(draft.row_display_columns.is_empty());
}
#[test]
fn removing_a_column_drops_it_from_the_display_columns() {
let mut draft = draft_with_column("number", "text");

View File

@@ -7,12 +7,14 @@ use crate::{
use super::{draft::TableDraft, state::AddTablePageState};
/// Loads everything the builder needs around the draft: the profiles that can
/// be picked, and — for whichever profile the table will belong to — the tables
/// that are link targets and the names the new table may not reuse.
/// Loads everything the builder needs around the draft: the column-type
/// vocabulary, the profiles that can be picked, and — for whichever profile the
/// table will belong to — the tables that are link targets and the names the
/// new table may not reuse.
///
/// The link targets and reserved names always come from the live profile tree,
/// never from the posted form, so they cannot be spoofed by a crafted request.
/// The vocabulary, link targets and reserved names always come from the live
/// backend, never from the posted form, so they cannot be spoofed by a crafted
/// request.
pub(crate) async fn load_page(
state: AppState,
headers: &HeaderMap,
@@ -36,6 +38,19 @@ pub(crate) async fn load_page(
}
let mut definitions = state.definitions;
// What a column may be is the backend's to say; the picker and every rule
// the draft applies are read from this.
draft.columns.catalog = crate::schema::column_catalog(
definitions
.list_column_types(
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner()
.column_types,
);
let tree = definitions
.get_profile_tree(
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,

View File

@@ -11,7 +11,7 @@
//! by [`crate::schema::columns_from_rows`], so the two screens that describe
//! columns cannot drift apart.
use crate::schema::{ColumnDraft, columns_from_rows};
use crate::schema::{ColumnCatalog, ColumnDraft, columns_from_rows};
use super::draft::{LinkDefinition, LinkMode, TableDraft};
@@ -110,8 +110,12 @@ impl BuilderForm {
&self.column_rounding,
&self.column_currencies,
),
// The table is being created here, so ACCOUNTING is on the table.
accounting_allowed: true,
// Filled in by the loader from `ListColumnTypes`, never by the
// form: it is the vocabulary the draft is validated against.
catalog: ColumnCatalog::default(),
// The table is being created here, so the creation-only types are
// on the table.
creating_table: true,
};
let link_count = self.link_tables.len().min(self.link_modes.len());
@@ -189,6 +193,9 @@ impl AddTablePageState {
.added
.iter()
.enumerate()
// A compound column expands into schema-managed companions, so
// there is no column of that name for a row to be shown by.
.filter(|(index, _)| self.draft.columns.is_indexable(*index))
.map(|(index, column)| RowDisplayCandidate {
index: index + 1,
name: column.name.clone(),

View File

@@ -1,7 +1,7 @@
use askama::Template;
use crate::{
schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
schema::CURRENCY_CODES,
ui::{Alert, Nav, render},
};
@@ -13,9 +13,9 @@ use super::state::AddTablePageState;
struct AddTablePage<'a> {
nav: Nav,
page: &'a AddTablePageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
column_types: Vec<String>,
temporal_types: Vec<String>,
gtin_types: Vec<String>,
currency_codes: &'static [&'static str],
}
@@ -25,18 +25,20 @@ struct AddTablePage<'a> {
#[template(path = "pages/add_table/builder.html")]
struct BuilderFragment<'a> {
page: &'a AddTablePageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
column_types: Vec<String>,
temporal_types: Vec<String>,
gtin_types: Vec<String>,
}
pub(crate) fn render_page(page: &AddTablePageState) -> String {
render(&AddTablePage {
nav: page.nav.clone(),
page,
// Every picker is the backend's own vocabulary, asked through the
// draft so it can only offer what the draft would accept.
column_types: page.draft.columns.offered_types(),
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
temporal_types: page.draft.columns.temporal_types(),
gtin_types: page.draft.columns.gtin_types(),
currency_codes: CURRENCY_CODES,
})
}
@@ -45,8 +47,8 @@ pub(crate) fn render_builder(page: &AddTablePageState) -> String {
render(&BuilderFragment {
page,
column_types: page.draft.columns.offered_types(),
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
temporal_types: page.draft.columns.temporal_types(),
gtin_types: page.draft.columns.gtin_types(),
})
}
@@ -67,6 +69,7 @@ mod tests {
fn page() -> AddTablePageState {
let mut draft = TableDraft::new();
draft.columns.catalog = crate::schema::tests::catalog();
draft.profile_name = "billing".to_string();
draft.table_name = "invoice".to_string();
draft.columns.added.push(ColumnDefinition {

View File

@@ -1,13 +1,15 @@
//! Reads everything the workspace shows.
//!
//! Three calls, in this order: the profile tree names the profiles and their
//! tables, the profile details describe the selected table's columns and
//! scripts, and the rename history explains how those columns got their names.
//! Four calls, in this order: the column-type catalog is the vocabulary the
//! append panel offers, the profile tree names the profiles and their tables,
//! the profile details describe the selected table's columns and scripts, and
//! the rename history explains how those columns got their names.
//! Nothing here trusts the posted selection — a profile or table that is gone
//! is dropped from the selection rather than reported as an error, because the
//! commonest way to get here with a stale one is having just deleted it.
use axum::http::HeaderMap;
use tonic::transport::Channel;
use crate::{
AppState,
@@ -16,8 +18,10 @@ use crate::{
common::Empty,
table_definition::{
GetColumnAliasRenameHistoryRequest, GetProfileDetailsRequest, MoneyRounding,
table_definition_client::TableDefinitionClient,
},
},
schema::{ColumnCatalog, column_catalog},
services::authenticated_request,
};
@@ -26,6 +30,27 @@ use super::state::{
TableDetailView, TableSummary,
};
/// Reads the column-type vocabulary on its own.
///
/// The panel's handlers stage and validate a column before the rest of the
/// workspace is read, and they cannot do either without the vocabulary, so
/// they fetch it with this and hand it to [`load_page`] on the draft.
pub(crate) async fn load_column_catalog(
definitions: &mut TableDefinitionClient<Channel>,
headers: &HeaderMap,
) -> Result<ColumnCatalog, LoadError> {
let request =
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?;
let response = definitions
.list_column_types(request)
.await
.map_err(|error| match error.code() {
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
_ => LoadError::Backend(error.message().to_string()),
})?;
Ok(column_catalog(response.into_inner().column_types))
}
pub(crate) async fn load_page(
state: AppState,
headers: &HeaderMap,
@@ -47,6 +72,15 @@ pub(crate) async fn load_page(
}
let mut definitions = state.definitions;
// A handler that had to stage or validate a column before it got here has
// already read the vocabulary; anything else reads it now.
if !inputs.columns.catalog.is_loaded() {
inputs.columns.catalog = load_column_catalog(&mut definitions, headers).await?;
}
// Also what the definition below is read through: the catalog describes
// the server-generated companion types as well as the declarable ones.
let catalog = inputs.columns.catalog.clone();
let tree = definitions
.get_profile_tree(
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
@@ -119,6 +153,7 @@ pub(crate) async fn load_page(
let behavior = table.column_behaviors.get(&column.name);
DetailColumn {
name: column.name.clone(),
sql_type: catalog.sql_type(&column.field_type),
field_type: column.field_type.clone(),
currency: column.currency.clone(),
quantity_ledger: column.quantity_ledger,

View File

@@ -27,7 +27,7 @@ use crate::{
};
use super::{
loader::load_page,
loader::{self, load_page},
state::{
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
RenameForm, Selection,
@@ -73,8 +73,16 @@ pub(crate) async fn update_columns(
return rejection;
}
// Staging a column applies the vocabulary's rules, so it is read before
// the panel is rebuilt; `load_page` reuses what is read here.
let mut definitions = state.definitions.clone();
let catalog = match loader::load_column_catalog(&mut definitions, &headers).await {
Ok(catalog) => catalog,
Err(error) => return load_error_response(error),
};
let mut inputs = PageInputs::for_selection(selection);
inputs.columns = form.to_draft(false);
inputs.columns = form.to_draft(catalog, false);
let index = form.index.unwrap_or(0);
match form.action.as_str() {
@@ -108,8 +116,16 @@ pub(crate) async fn add_columns(
return rejection;
}
// The draft is validated before the write, so the vocabulary it is held to
// is read first; `load_page` reuses what is read here.
let mut definitions = state.definitions.clone();
let catalog = match loader::load_column_catalog(&mut definitions, &headers).await {
Ok(catalog) => catalog,
Err(error) => return load_error_response(error),
};
let mut inputs = PageInputs::for_selection(selection);
inputs.columns = form.to_draft(false);
inputs.columns = form.to_draft(catalog.clone(), false);
if !inputs.selection.has_table() {
return refuse(state, headers, inputs, "Select a table first.".to_string()).await;
@@ -139,7 +155,6 @@ pub(crate) async fn add_columns(
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.add_table_columns(request).await {
Ok(response) if response.get_ref().success => {
let added = inputs.columns.added.len();
@@ -150,7 +165,7 @@ pub(crate) async fn add_columns(
inputs.selection.table
));
// The columns are the table's now, so the panel starts empty.
inputs.columns = crate::schema::ColumnDraft::for_append();
inputs.columns = crate::schema::ColumnDraft::for_append(catalog);
respond(state, headers, inputs, StatusCode::OK).await
}
Ok(response) => {

View File

@@ -5,7 +5,7 @@
//! write: the response is rebuilt from the live profile tree rather than from
//! whatever the browser still had on screen.
use crate::schema::ColumnDraft;
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
@@ -82,6 +82,9 @@ impl TableDetailView {
pub(crate) struct DetailColumn {
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,
@@ -225,10 +228,12 @@ pub(crate) struct PageInputs {
}
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(),
columns: ColumnDraft::for_append(ColumnCatalog::default()),
..Self::default()
}
}
@@ -315,6 +320,7 @@ mod tests {
DetailColumn {
name: "work_phone".to_string(),
field_type: "phone".to_string(),
sql_type: "TEXT".to_string(),
currency: String::new(),
quantity_ledger: false,
rounded: false,
@@ -325,6 +331,7 @@ mod tests {
DetailColumn {
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,

View File

@@ -1,7 +1,7 @@
use askama::Template;
use crate::{
schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
schema::CURRENCY_CODES,
ui::{Alert, Nav, render},
};
@@ -13,9 +13,9 @@ use super::state::TableDefinitionPageState;
struct TableDefinitionPage<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
column_types: Vec<String>,
temporal_types: Vec<String>,
gtin_types: Vec<String>,
currency_codes: &'static [&'static str],
/// False, as on the workspace fragment: the page embeds both, and the
/// outcome is reported once, at the top.
@@ -28,9 +28,9 @@ struct TableDefinitionPage<'a> {
#[template(path = "pages/admin/table_definition/workspace.html")]
struct WorkspaceFragment<'a> {
page: &'a TableDefinitionPageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
column_types: Vec<String>,
temporal_types: Vec<String>,
gtin_types: Vec<String>,
/// False: the workspace shows the outcome of the last action itself, at
/// the top, so the panel it embeds must not repeat it.
standalone_column_panel: bool,
@@ -41,9 +41,9 @@ struct WorkspaceFragment<'a> {
#[template(path = "pages/admin/table_definition/column_panel.html")]
struct ColumnPanelFragment<'a> {
page: &'a TableDefinitionPageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
column_types: Vec<String>,
temporal_types: Vec<String>,
gtin_types: Vec<String>,
/// True: this is the whole response, so a refused column has nowhere else
/// to be reported.
standalone_column_panel: bool,
@@ -56,8 +56,8 @@ pub(crate) fn render_page(page: &TableDefinitionPageState) -> String {
// Asking the draft, so the picker can only ever offer what the draft
// would accept — the accounting rule is stated once.
column_types: page.columns.offered_types(),
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
temporal_types: page.columns.temporal_types(),
gtin_types: page.columns.gtin_types(),
currency_codes: CURRENCY_CODES,
standalone_column_panel: false,
})
@@ -69,8 +69,8 @@ pub(crate) fn render_workspace(page: &TableDefinitionPageState) -> String {
// Asking the draft, so the picker can only ever offer what the draft
// would accept — the accounting rule is stated once.
column_types: page.columns.offered_types(),
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
temporal_types: page.columns.temporal_types(),
gtin_types: page.columns.gtin_types(),
standalone_column_panel: false,
})
}
@@ -81,8 +81,8 @@ pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
// Asking the draft, so the picker can only ever offer what the draft
// would accept — the accounting rule is stated once.
column_types: page.columns.offered_types(),
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
temporal_types: page.columns.temporal_types(),
gtin_types: page.columns.gtin_types(),
standalone_column_panel: true,
})
}
@@ -131,6 +131,7 @@ mod tests {
columns: vec![DetailColumn {
name: "number".to_string(),
field_type: "text".to_string(),
sql_type: "TEXT".to_string(),
currency: String::new(),
quantity_ledger: false,
rounded: false,
@@ -140,7 +141,7 @@ mod tests {
}],
}),
history: Vec::new(),
columns: ColumnDraft::for_append(),
columns: ColumnDraft::for_append(crate::schema::tests::catalog()),
rename: RenameForm::default(),
copy: CopyForm::default(),
invoice: InvoiceTemplateForm::default(),