talbe definition web
This commit is contained in:
2
server
2
server
Submodule server updated: cb9906ac3e...35e52da815
@@ -14,6 +14,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`TableDefinition.ListColumnTypes`** — called by the add-table and
|
||||||
|
table-definition loaders. The whole response is consumed: `name`, `group`,
|
||||||
|
`declarable`, `compound`, `spelling`, `requires_currency`, `creation_only`,
|
||||||
|
`allows_quantity_ledger` and `sql_type` are each read by a rule that used to
|
||||||
|
be hardcoded in `crate::schema`. The column vocabulary is now the backend's,
|
||||||
|
so a type it adds is offered by both screens without a change here.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The column-type picker is the server's list** — it no longer carries its
|
||||||
|
own. Types the web crate never offered are now reachable: `numeric` and the
|
||||||
|
`ACCOUNTING_TRANSFER` compound column. Server-generated companion types
|
||||||
|
(`phone_country`, `iban_bban`, the transfer connectors) are listed by the
|
||||||
|
endpoint but never offered, and are refused if one is posted anyway.
|
||||||
|
- **Creation-only types are refused on the append panel by rule, not by name** —
|
||||||
|
`POST /admin/table-definition/columns` used to exclude `accounting` alone;
|
||||||
|
it now excludes every type the response marks `creation_only`, which is what
|
||||||
|
`AddTableColumns` rejects.
|
||||||
|
- **Compound columns are staged as the backend expands them** — a compound
|
||||||
|
column takes its type's name, cannot be indexed, and is not offered as a row
|
||||||
|
display column, since no column of that name survives the expansion.
|
||||||
|
- **Currency and quantity-ledger rules come from the response** —
|
||||||
|
`requires_currency` decides which types carry a currency (previously MONEY
|
||||||
|
and ACCOUNTING by name), and `allows_quantity_ledger` both validates the
|
||||||
|
choice and writes the hint under the input.
|
||||||
|
- **`GetProfileDetails` columns show the SQL type behind them** — the
|
||||||
|
workspace's column list renders `sql_type` from the catalog beside each
|
||||||
|
logical type, including for the companion columns the backend generates.
|
||||||
|
|
||||||
## [v0.8.38] — 2026-08-05
|
## [v0.8.38] — 2026-08-05
|
||||||
|
|
||||||
The web crate wires up seven gRPC services and calls the following endpoints.
|
The web crate wires up seven gRPC services and calls the following endpoints.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use crate::{
|
|||||||
definitions::table_definition::{
|
definitions::table_definition::{
|
||||||
PostTableDefinitionRequest, TableLink as ProtoTableLink,
|
PostTableDefinitionRequest, TableLink as ProtoTableLink,
|
||||||
},
|
},
|
||||||
schema::{ColumnDraft, proto_columns, validate_identifier},
|
schema::{ColumnCatalog, ColumnDraft, proto_columns, validate_identifier},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
@@ -102,11 +102,13 @@ pub(crate) struct TableDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub(crate) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
accounting_currency: "EUR".to_string(),
|
accounting_currency: "EUR".to_string(),
|
||||||
columns: ColumnDraft::new(),
|
columns: ColumnDraft::new(ColumnCatalog::default()),
|
||||||
..Self::default()
|
..Self::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +150,11 @@ impl TableDraft {
|
|||||||
self.row_display_columns.clear();
|
self.row_display_columns.clear();
|
||||||
return;
|
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
|
let Some(column) = self
|
||||||
.columns
|
.columns
|
||||||
.added
|
.added
|
||||||
@@ -354,6 +361,9 @@ mod tests {
|
|||||||
|
|
||||||
fn draft_with_column(name: &str, data_type: &str) -> TableDraft {
|
fn draft_with_column(name: &str, data_type: &str) -> TableDraft {
|
||||||
let mut draft = TableDraft::new();
|
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.profile_name = "billing".to_string();
|
||||||
draft.table_name = "invoice".to_string();
|
draft.table_name = "invoice".to_string();
|
||||||
draft.columns.added.push(ColumnDefinition {
|
draft.columns.added.push(ColumnDefinition {
|
||||||
@@ -471,6 +481,26 @@ mod tests {
|
|||||||
assert!(draft.row_display_columns.is_empty());
|
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]
|
#[test]
|
||||||
fn removing_a_column_drops_it_from_the_display_columns() {
|
fn removing_a_column_drops_it_from_the_display_columns() {
|
||||||
let mut draft = draft_with_column("number", "text");
|
let mut draft = draft_with_column("number", "text");
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ use crate::{
|
|||||||
|
|
||||||
use super::{draft::TableDraft, state::AddTablePageState};
|
use super::{draft::TableDraft, state::AddTablePageState};
|
||||||
|
|
||||||
/// Loads everything the builder needs around the draft: the profiles that can
|
/// Loads everything the builder needs around the draft: the column-type
|
||||||
/// be picked, and — for whichever profile the table will belong to — the tables
|
/// vocabulary, the profiles that can be picked, and — for whichever profile the
|
||||||
/// that are link targets and the names the new table may not reuse.
|
/// 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,
|
/// The vocabulary, link targets and reserved names always come from the live
|
||||||
/// never from the posted form, so they cannot be spoofed by a crafted request.
|
/// backend, never from the posted form, so they cannot be spoofed by a crafted
|
||||||
|
/// request.
|
||||||
pub(crate) async fn load_page(
|
pub(crate) async fn load_page(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
@@ -36,6 +38,19 @@ pub(crate) async fn load_page(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut definitions = state.definitions;
|
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
|
let tree = definitions
|
||||||
.get_profile_tree(
|
.get_profile_tree(
|
||||||
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
|
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
//! by [`crate::schema::columns_from_rows`], so the two screens that describe
|
//! by [`crate::schema::columns_from_rows`], so the two screens that describe
|
||||||
//! columns cannot drift apart.
|
//! 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};
|
use super::draft::{LinkDefinition, LinkMode, TableDraft};
|
||||||
|
|
||||||
@@ -110,8 +110,12 @@ impl BuilderForm {
|
|||||||
&self.column_rounding,
|
&self.column_rounding,
|
||||||
&self.column_currencies,
|
&self.column_currencies,
|
||||||
),
|
),
|
||||||
// The table is being created here, so ACCOUNTING is on the table.
|
// Filled in by the loader from `ListColumnTypes`, never by the
|
||||||
accounting_allowed: true,
|
// 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());
|
let link_count = self.link_tables.len().min(self.link_modes.len());
|
||||||
@@ -189,6 +193,9 @@ impl AddTablePageState {
|
|||||||
.added
|
.added
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.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 {
|
.map(|(index, column)| RowDisplayCandidate {
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
name: column.name.clone(),
|
name: column.name.clone(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
|
schema::CURRENCY_CODES,
|
||||||
ui::{Alert, Nav, render},
|
ui::{Alert, Nav, render},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,9 +13,9 @@ use super::state::AddTablePageState;
|
|||||||
struct AddTablePage<'a> {
|
struct AddTablePage<'a> {
|
||||||
nav: Nav,
|
nav: Nav,
|
||||||
page: &'a AddTablePageState,
|
page: &'a AddTablePageState,
|
||||||
column_types: &'static [&'static str],
|
column_types: Vec<String>,
|
||||||
temporal_types: &'static [&'static str],
|
temporal_types: Vec<String>,
|
||||||
gtin_types: &'static [&'static str],
|
gtin_types: Vec<String>,
|
||||||
currency_codes: &'static [&'static str],
|
currency_codes: &'static [&'static str],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,18 +25,20 @@ struct AddTablePage<'a> {
|
|||||||
#[template(path = "pages/add_table/builder.html")]
|
#[template(path = "pages/add_table/builder.html")]
|
||||||
struct BuilderFragment<'a> {
|
struct BuilderFragment<'a> {
|
||||||
page: &'a AddTablePageState,
|
page: &'a AddTablePageState,
|
||||||
column_types: &'static [&'static str],
|
column_types: Vec<String>,
|
||||||
temporal_types: &'static [&'static str],
|
temporal_types: Vec<String>,
|
||||||
gtin_types: &'static [&'static str],
|
gtin_types: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn render_page(page: &AddTablePageState) -> String {
|
pub(crate) fn render_page(page: &AddTablePageState) -> String {
|
||||||
render(&AddTablePage {
|
render(&AddTablePage {
|
||||||
nav: page.nav.clone(),
|
nav: page.nav.clone(),
|
||||||
page,
|
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(),
|
column_types: page.draft.columns.offered_types(),
|
||||||
temporal_types: TEMPORAL_TYPES,
|
temporal_types: page.draft.columns.temporal_types(),
|
||||||
gtin_types: GTIN_TYPES,
|
gtin_types: page.draft.columns.gtin_types(),
|
||||||
currency_codes: CURRENCY_CODES,
|
currency_codes: CURRENCY_CODES,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -45,8 +47,8 @@ pub(crate) fn render_builder(page: &AddTablePageState) -> String {
|
|||||||
render(&BuilderFragment {
|
render(&BuilderFragment {
|
||||||
page,
|
page,
|
||||||
column_types: page.draft.columns.offered_types(),
|
column_types: page.draft.columns.offered_types(),
|
||||||
temporal_types: TEMPORAL_TYPES,
|
temporal_types: page.draft.columns.temporal_types(),
|
||||||
gtin_types: GTIN_TYPES,
|
gtin_types: page.draft.columns.gtin_types(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +69,7 @@ mod tests {
|
|||||||
|
|
||||||
fn page() -> AddTablePageState {
|
fn page() -> AddTablePageState {
|
||||||
let mut draft = TableDraft::new();
|
let mut draft = TableDraft::new();
|
||||||
|
draft.columns.catalog = crate::schema::tests::catalog();
|
||||||
draft.profile_name = "billing".to_string();
|
draft.profile_name = "billing".to_string();
|
||||||
draft.table_name = "invoice".to_string();
|
draft.table_name = "invoice".to_string();
|
||||||
draft.columns.added.push(ColumnDefinition {
|
draft.columns.added.push(ColumnDefinition {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
//! Reads everything the workspace shows.
|
//! Reads everything the workspace shows.
|
||||||
//!
|
//!
|
||||||
//! Three calls, in this order: the profile tree names the profiles and their
|
//! Four calls, in this order: the column-type catalog is the vocabulary the
|
||||||
//! tables, the profile details describe the selected table's columns and
|
//! append panel offers, the profile tree names the profiles and their tables,
|
||||||
//! scripts, and the rename history explains how those columns got their names.
|
//! 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
|
//! 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
|
//! 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.
|
//! commonest way to get here with a stale one is having just deleted it.
|
||||||
|
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
|
use tonic::transport::Channel;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
@@ -16,8 +18,10 @@ use crate::{
|
|||||||
common::Empty,
|
common::Empty,
|
||||||
table_definition::{
|
table_definition::{
|
||||||
GetColumnAliasRenameHistoryRequest, GetProfileDetailsRequest, MoneyRounding,
|
GetColumnAliasRenameHistoryRequest, GetProfileDetailsRequest, MoneyRounding,
|
||||||
|
table_definition_client::TableDefinitionClient,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
schema::{ColumnCatalog, column_catalog},
|
||||||
services::authenticated_request,
|
services::authenticated_request,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,6 +30,27 @@ use super::state::{
|
|||||||
TableDetailView, TableSummary,
|
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(
|
pub(crate) async fn load_page(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
@@ -47,6 +72,15 @@ pub(crate) async fn load_page(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut definitions = state.definitions;
|
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
|
let tree = definitions
|
||||||
.get_profile_tree(
|
.get_profile_tree(
|
||||||
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
|
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);
|
let behavior = table.column_behaviors.get(&column.name);
|
||||||
DetailColumn {
|
DetailColumn {
|
||||||
name: column.name.clone(),
|
name: column.name.clone(),
|
||||||
|
sql_type: catalog.sql_type(&column.field_type),
|
||||||
field_type: column.field_type.clone(),
|
field_type: column.field_type.clone(),
|
||||||
currency: column.currency.clone(),
|
currency: column.currency.clone(),
|
||||||
quantity_ledger: column.quantity_ledger,
|
quantity_ledger: column.quantity_ledger,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
loader::load_page,
|
loader::{self, load_page},
|
||||||
state::{
|
state::{
|
||||||
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
|
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
|
||||||
RenameForm, Selection,
|
RenameForm, Selection,
|
||||||
@@ -73,8 +73,16 @@ pub(crate) async fn update_columns(
|
|||||||
return rejection;
|
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);
|
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);
|
let index = form.index.unwrap_or(0);
|
||||||
match form.action.as_str() {
|
match form.action.as_str() {
|
||||||
@@ -108,8 +116,16 @@ pub(crate) async fn add_columns(
|
|||||||
return rejection;
|
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);
|
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() {
|
if !inputs.selection.has_table() {
|
||||||
return refuse(state, headers, inputs, "Select a table first.".to_string()).await;
|
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();
|
return Redirect::to("/login").into_response();
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut definitions = state.definitions.clone();
|
|
||||||
match definitions.add_table_columns(request).await {
|
match definitions.add_table_columns(request).await {
|
||||||
Ok(response) if response.get_ref().success => {
|
Ok(response) if response.get_ref().success => {
|
||||||
let added = inputs.columns.added.len();
|
let added = inputs.columns.added.len();
|
||||||
@@ -150,7 +165,7 @@ pub(crate) async fn add_columns(
|
|||||||
inputs.selection.table
|
inputs.selection.table
|
||||||
));
|
));
|
||||||
// The columns are the table's now, so the panel starts empty.
|
// 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
|
respond(state, headers, inputs, StatusCode::OK).await
|
||||||
}
|
}
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! write: the response is rebuilt from the live profile tree rather than from
|
//! write: the response is rebuilt from the live profile tree rather than from
|
||||||
//! whatever the browser still had on screen.
|
//! 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
|
/// 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
|
/// string on the selector and on the column-panel endpoints, and as hidden
|
||||||
@@ -82,6 +82,9 @@ impl TableDetailView {
|
|||||||
pub(crate) struct DetailColumn {
|
pub(crate) struct DetailColumn {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub field_type: 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 currency: String,
|
||||||
pub quantity_ledger: bool,
|
pub quantity_ledger: bool,
|
||||||
pub rounded: bool,
|
pub rounded: bool,
|
||||||
@@ -225,10 +228,12 @@ pub(crate) struct PageInputs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub(crate) fn for_selection(selection: Selection) -> Self {
|
||||||
Self {
|
Self {
|
||||||
selection,
|
selection,
|
||||||
columns: ColumnDraft::for_append(),
|
columns: ColumnDraft::for_append(ColumnCatalog::default()),
|
||||||
..Self::default()
|
..Self::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +320,7 @@ mod tests {
|
|||||||
DetailColumn {
|
DetailColumn {
|
||||||
name: "work_phone".to_string(),
|
name: "work_phone".to_string(),
|
||||||
field_type: "phone".to_string(),
|
field_type: "phone".to_string(),
|
||||||
|
sql_type: "TEXT".to_string(),
|
||||||
currency: String::new(),
|
currency: String::new(),
|
||||||
quantity_ledger: false,
|
quantity_ledger: false,
|
||||||
rounded: false,
|
rounded: false,
|
||||||
@@ -325,6 +331,7 @@ mod tests {
|
|||||||
DetailColumn {
|
DetailColumn {
|
||||||
name: "work_phone_country".to_string(),
|
name: "work_phone_country".to_string(),
|
||||||
field_type: "phone_country".to_string(),
|
field_type: "phone_country".to_string(),
|
||||||
|
sql_type: "TEXT".to_string(),
|
||||||
currency: String::new(),
|
currency: String::new(),
|
||||||
quantity_ledger: false,
|
quantity_ledger: false,
|
||||||
rounded: false,
|
rounded: false,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
|
schema::CURRENCY_CODES,
|
||||||
ui::{Alert, Nav, render},
|
ui::{Alert, Nav, render},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,9 +13,9 @@ use super::state::TableDefinitionPageState;
|
|||||||
struct TableDefinitionPage<'a> {
|
struct TableDefinitionPage<'a> {
|
||||||
nav: Nav,
|
nav: Nav,
|
||||||
page: &'a TableDefinitionPageState,
|
page: &'a TableDefinitionPageState,
|
||||||
column_types: &'static [&'static str],
|
column_types: Vec<String>,
|
||||||
temporal_types: &'static [&'static str],
|
temporal_types: Vec<String>,
|
||||||
gtin_types: &'static [&'static str],
|
gtin_types: Vec<String>,
|
||||||
currency_codes: &'static [&'static str],
|
currency_codes: &'static [&'static str],
|
||||||
/// False, as on the workspace fragment: the page embeds both, and the
|
/// False, as on the workspace fragment: the page embeds both, and the
|
||||||
/// outcome is reported once, at the top.
|
/// outcome is reported once, at the top.
|
||||||
@@ -28,9 +28,9 @@ struct TableDefinitionPage<'a> {
|
|||||||
#[template(path = "pages/admin/table_definition/workspace.html")]
|
#[template(path = "pages/admin/table_definition/workspace.html")]
|
||||||
struct WorkspaceFragment<'a> {
|
struct WorkspaceFragment<'a> {
|
||||||
page: &'a TableDefinitionPageState,
|
page: &'a TableDefinitionPageState,
|
||||||
column_types: &'static [&'static str],
|
column_types: Vec<String>,
|
||||||
temporal_types: &'static [&'static str],
|
temporal_types: Vec<String>,
|
||||||
gtin_types: &'static [&'static str],
|
gtin_types: Vec<String>,
|
||||||
/// False: the workspace shows the outcome of the last action itself, at
|
/// False: the workspace shows the outcome of the last action itself, at
|
||||||
/// the top, so the panel it embeds must not repeat it.
|
/// the top, so the panel it embeds must not repeat it.
|
||||||
standalone_column_panel: bool,
|
standalone_column_panel: bool,
|
||||||
@@ -41,9 +41,9 @@ struct WorkspaceFragment<'a> {
|
|||||||
#[template(path = "pages/admin/table_definition/column_panel.html")]
|
#[template(path = "pages/admin/table_definition/column_panel.html")]
|
||||||
struct ColumnPanelFragment<'a> {
|
struct ColumnPanelFragment<'a> {
|
||||||
page: &'a TableDefinitionPageState,
|
page: &'a TableDefinitionPageState,
|
||||||
column_types: &'static [&'static str],
|
column_types: Vec<String>,
|
||||||
temporal_types: &'static [&'static str],
|
temporal_types: Vec<String>,
|
||||||
gtin_types: &'static [&'static str],
|
gtin_types: Vec<String>,
|
||||||
/// True: this is the whole response, so a refused column has nowhere else
|
/// True: this is the whole response, so a refused column has nowhere else
|
||||||
/// to be reported.
|
/// to be reported.
|
||||||
standalone_column_panel: bool,
|
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
|
// Asking the draft, so the picker can only ever offer what the draft
|
||||||
// would accept — the accounting rule is stated once.
|
// would accept — the accounting rule is stated once.
|
||||||
column_types: page.columns.offered_types(),
|
column_types: page.columns.offered_types(),
|
||||||
temporal_types: TEMPORAL_TYPES,
|
temporal_types: page.columns.temporal_types(),
|
||||||
gtin_types: GTIN_TYPES,
|
gtin_types: page.columns.gtin_types(),
|
||||||
currency_codes: CURRENCY_CODES,
|
currency_codes: CURRENCY_CODES,
|
||||||
standalone_column_panel: false,
|
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
|
// Asking the draft, so the picker can only ever offer what the draft
|
||||||
// would accept — the accounting rule is stated once.
|
// would accept — the accounting rule is stated once.
|
||||||
column_types: page.columns.offered_types(),
|
column_types: page.columns.offered_types(),
|
||||||
temporal_types: TEMPORAL_TYPES,
|
temporal_types: page.columns.temporal_types(),
|
||||||
gtin_types: GTIN_TYPES,
|
gtin_types: page.columns.gtin_types(),
|
||||||
standalone_column_panel: false,
|
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
|
// Asking the draft, so the picker can only ever offer what the draft
|
||||||
// would accept — the accounting rule is stated once.
|
// would accept — the accounting rule is stated once.
|
||||||
column_types: page.columns.offered_types(),
|
column_types: page.columns.offered_types(),
|
||||||
temporal_types: TEMPORAL_TYPES,
|
temporal_types: page.columns.temporal_types(),
|
||||||
gtin_types: GTIN_TYPES,
|
gtin_types: page.columns.gtin_types(),
|
||||||
standalone_column_panel: true,
|
standalone_column_panel: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -131,6 +131,7 @@ mod tests {
|
|||||||
columns: vec![DetailColumn {
|
columns: vec![DetailColumn {
|
||||||
name: "number".to_string(),
|
name: "number".to_string(),
|
||||||
field_type: "text".to_string(),
|
field_type: "text".to_string(),
|
||||||
|
sql_type: "TEXT".to_string(),
|
||||||
currency: String::new(),
|
currency: String::new(),
|
||||||
quantity_ledger: false,
|
quantity_ledger: false,
|
||||||
rounded: false,
|
rounded: false,
|
||||||
@@ -140,7 +141,7 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
}),
|
}),
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
columns: ColumnDraft::for_append(),
|
columns: ColumnDraft::for_append(crate::schema::tests::catalog()),
|
||||||
rename: RenameForm::default(),
|
rename: RenameForm::default(),
|
||||||
copy: CopyForm::default(),
|
copy: CopyForm::default(),
|
||||||
invoice: InvoiceTemplateForm::default(),
|
invoice: InvoiceTemplateForm::default(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -135,7 +135,7 @@
|
|||||||
<option value="no" {% if page.draft.columns.quantity_ledger_input != "yes" %}selected{% endif %}>no</option>
|
<option value="no" {% if page.draft.columns.quantity_ledger_input != "yes" %}selected{% endif %}>no</option>
|
||||||
<option value="yes" {% if page.draft.columns.quantity_ledger_input == "yes" %}selected{% endif %}>yes</option>
|
<option value="yes" {% if page.draft.columns.quantity_ledger_input == "yes" %}selected{% endif %}>yes</option>
|
||||||
</select>
|
</select>
|
||||||
<small>INT, BIGINT, DECIMAL or MONEY only.</small>
|
<small>{{ page.draft.columns.quantity_ledger_types() }} only.</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<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"
|
||||||
@@ -155,11 +155,17 @@
|
|||||||
<td><code>{{ column.name }}</code></td>
|
<td><code>{{ column.name }}</code></td>
|
||||||
<td>{{ column.data_type }}</td>
|
<td>{{ column.data_type }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
{# A compound column expands into schema-managed companions, so
|
||||||
|
there is no column of its own name to index. #}
|
||||||
|
{% if page.draft.columns.is_indexable(*loop.index0) %}
|
||||||
<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": "{{ loop.index0 }}"}'>
|
||||||
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
|
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
|
||||||
</button>
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="hint">—</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if column.quantity_ledger %}<span class="tag">quantity ledger</span>{% endif %}
|
{% if column.quantity_ledger %}<span class="tag">quantity ledger</span>{% endif %}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
<option value="no" {% if page.columns.quantity_ledger_input != "yes" %}selected{% endif %}>no</option>
|
<option value="no" {% if page.columns.quantity_ledger_input != "yes" %}selected{% endif %}>no</option>
|
||||||
<option value="yes" {% if page.columns.quantity_ledger_input == "yes" %}selected{% endif %}>yes</option>
|
<option value="yes" {% if page.columns.quantity_ledger_input == "yes" %}selected{% endif %}>yes</option>
|
||||||
</select>
|
</select>
|
||||||
<small>INT, BIGINT, DECIMAL or MONEY only.</small>
|
<small>{{ page.columns.quantity_ledger_types() }} only.</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,12 +117,18 @@
|
|||||||
<td><code>{{ column.name }}</code></td>
|
<td><code>{{ column.name }}</code></td>
|
||||||
<td>{{ column.data_type }}</td>
|
<td>{{ column.data_type }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
{# A compound column expands into schema-managed companions, so
|
||||||
|
there is no column of its own name to index. #}
|
||||||
|
{% if page.columns.is_indexable(*loop.index0) %}
|
||||||
<button type="button" class="toggle"
|
<button type="button" class="toggle"
|
||||||
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
|
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
|
||||||
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
|
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
|
||||||
hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
|
hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
|
||||||
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
|
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
|
||||||
</button>
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="hint">—</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if column.quantity_ledger %}<span class="tag">quantity ledger</span>{% endif %}
|
{% if column.quantity_ledger %}<span class="tag">quantity ledger</span>{% endif %}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
{% for column in detail.columns %}
|
{% for column in detail.columns %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{ column.name }}</code></td>
|
<td><code>{{ column.name }}</code></td>
|
||||||
<td>{{ column.field_type }}</td>
|
<td>{{ column.field_type }}{% if !column.sql_type.is_empty() %} <small class="hint">{{ column.sql_type }}</small>{% endif %}</td>
|
||||||
<td>
|
<td>
|
||||||
{%- for flag in column.flags() %}<span class="tag">{{ flag }}</span>{% endfor -%}
|
{%- for flag in column.flags() %}<span class="tag">{{ flag }}</span>{% endfor -%}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user