web crate put add table

This commit is contained in:
Filipriec
2026-08-23 23:43:27 +02:00
parent a44dccf84b
commit b4135e5d20
14 changed files with 239 additions and 17 deletions

View File

@@ -289,6 +289,7 @@ pub(crate) async fn load_page(
history,
selection: inputs.selection,
columns: inputs.columns,
remove_column_ids: inputs.remove_column_ids,
copy: inputs.copy,
invoice: inputs.invoice,
status: inputs.status,

View File

@@ -24,6 +24,7 @@ use crate::{
definitions::table_definition::{
AddTableColumnsRequest, CopyProfileRequest, CreateInvoiceTemplateTableRequest,
ColumnPresentation, DeleteTableRequest, SetColumnPresentationRequest,
PutTableDefinitionRequest,
},
{i18n::Locale, tr},
schema::{ColumnForm, proto_columns},
@@ -214,7 +215,8 @@ pub(crate) async fn update_columns(
}
}
/// POST /admin/tables/columns/add — AddTableColumns.
/// POST /admin/tables/columns/add — append columns, or atomically replace part
/// of an empty table through PutTableDefinition.
pub(crate) async fn add_columns(
State(state): State<AppState>,
headers: HeaderMap,
@@ -235,6 +237,7 @@ pub(crate) async fn add_columns(
let mut inputs = PageInputs::for_selection(selection);
inputs.columns = form.to_draft(catalog.clone(), false);
inputs.remove_column_ids = form.remove_column_ids.clone();
if !inputs.selection.has_table() {
let message = tr!(
@@ -250,10 +253,10 @@ pub(crate) async fn add_columns(
)
.await;
}
if inputs.columns.is_empty() {
if inputs.columns.is_empty() && inputs.remove_column_ids.is_empty() {
let message = tr!(
Locale::from_headers(&headers),
"td-err-describe-column"
"td-err-describe-change"
);
return refuse(
state,
@@ -266,10 +269,88 @@ pub(crate) async fn add_columns(
}
// The same checks the server runs, applied to a draft that may have been
// rebuilt from a posted form rather than through the panel.
if let Err(message) = inputs.columns.validate(Locale::from_headers(&headers)) {
if !inputs.columns.is_empty()
&& let Err(message) = inputs.columns.validate(Locale::from_headers(&headers))
{
return refuse(state, headers, inputs, Page::AddColumns, message).await;
}
// Keep the old append capability for populated tables. Put is used only
// when a removal was requested, because that operation deliberately
// refuses every table which has ever stored a row.
if inputs.remove_column_ids.is_empty() {
return append_columns(state, headers, inputs, catalog, definitions).await;
}
let current = match load_page(state.clone(), &headers, inputs.clone()).await {
Ok(page) => page.detail,
Err(error) => return load_error_response(&headers, error),
};
let Some(current) = current else {
let message = tr!(Locale::from_headers(&headers), "td-err-select-table-first");
return refuse(state, headers, inputs, Page::AddColumns, message).await;
};
let remove_column_ids = match current.expanded_removal_ids(&inputs.remove_column_ids) {
Ok(ids) => ids,
Err(()) => {
let message = tr!(Locale::from_headers(&headers), "td-err-unknown-column");
return refuse(state, headers, inputs, Page::AddColumns, message).await;
}
};
let request = PutTableDefinitionRequest {
profile_name: inputs.selection.profile.clone(),
table_name: inputs.selection.table.clone(),
remove_column_ids,
add_columns: proto_columns(&inputs.columns.added),
add_indexes: inputs.columns.selected_index_names(),
generated_aliases: Vec::new(),
expected_row_version: form.expected_row_version,
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
match definitions.put_table_definition(request).await {
Ok(response) if response.get_ref().success => {
let response = response.into_inner();
let removed = response.removed_column_ids.len();
let added = inputs.columns.added.len();
inputs.sql = Some(response.sql);
inputs.status = Some(tr!(
Locale::from_headers(&headers),
"td-definition-adjusted",
"added" => added as i64,
"removed" => removed as i64,
"table" => inputs.selection.table.clone(),
));
inputs.columns = crate::schema::ColumnDraft::for_append(catalog);
inputs.remove_column_ids.clear();
respond(state, headers, inputs, Page::AddColumns, StatusCode::OK).await
}
Ok(response) => {
let message = response.into_inner().sql;
let message = if message.is_empty() {
tr!(Locale::from_headers(&headers), "td-err-backend-no-adjustment")
} else {
message
};
refuse(state, headers, inputs, Page::AddColumns, message).await
}
Err(error) => {
refuse(state, headers, inputs, Page::AddColumns, error.message().to_string()).await
}
}
}
async fn append_columns(
state: AppState,
headers: HeaderMap,
mut inputs: PageInputs,
catalog: crate::schema::ColumnCatalog,
mut definitions: crate::definitions::table_definition::table_definition_client::TableDefinitionClient<tonic::transport::Channel>,
) -> Response {
let request = AddTableColumnsRequest {
profile_name: inputs.selection.profile.clone(),
table_name: inputs.selection.table.clone(),

View File

@@ -1,6 +1,6 @@
//! What can be done to a table once it exists, as one page per decision.
//!
//! Adding columns, presenting columns, dropping the table, copying its profile,
//! Adjusting columns, presenting columns, dropping the table, copying its profile,
//! generating tables from a template and reading the rename history are six
//! different jobs, and they used to be panels stacked on one `/admin/table-definition`
//! workspace — which meant that after creating a table you landed on a screen

View File

@@ -101,6 +101,46 @@ impl TableDetailView {
.filter(|column| column.renameable)
.collect()
}
/// Expands a top-level removal choice to the generated columns the backend
/// requires to travel with it. PHONE/IBAN companions name their source;
/// linked projections use `link.source`.
pub(crate) fn expanded_removal_ids(&self, selected: &[i64]) -> Result<Vec<i64>, ()> {
let selected = selected.iter().copied().collect::<std::collections::HashSet<_>>();
if selected
.iter()
.any(|id| !self.columns.iter().any(|column| column.column_id == *id))
{
return Err(());
}
let mut roots = selected.clone();
for column in self.columns.iter().filter(|column| selected.contains(&column.column_id)) {
if let Some(root_name) = column.generated_from.split('.').next()
&& !root_name.is_empty()
&& let Some(root) = self.columns.iter().find(|candidate| candidate.name == root_name)
{
roots.insert(root.column_id);
}
}
Ok(self
.columns
.iter()
.filter(|column| {
roots.contains(&column.column_id)
|| self.columns.iter().any(|root| {
roots.contains(&root.column_id)
&& (column.generated_from == root.name
|| column
.generated_from
.strip_prefix(&root.name)
.is_some_and(|suffix| suffix.starts_with('.')))
})
})
.map(|column| column.column_id)
.collect())
}
}
#[derive(Clone, Debug)]
@@ -121,6 +161,12 @@ pub(crate) struct DetailColumn {
}
impl DetailColumn {
/// Generated/read-only rows are shown as part of their parent definition;
/// choosing the parent expands to them in `expanded_removal_ids`.
pub(crate) fn is_removal_choice(&self) -> bool {
self.column_id > 0 && !self.generated && !self.read_only
}
/// The badge list rendered under each column name.
pub(crate) fn flags(&self, locale: &crate::i18n::Locale) -> Vec<String> {
let mut flags = Vec::new();
@@ -274,8 +320,10 @@ pub(crate) struct DeleteForm {
#[derive(Clone, Debug, Default)]
pub(crate) struct PageInputs {
pub selection: Selection,
/// The columns staged for `AddTableColumns`.
/// The new columns staged for the structural edit.
pub columns: ColumnDraft,
/// Existing column identities selected for removal.
pub remove_column_ids: Vec<i64>,
pub copy: CopyForm,
pub invoice: InvoiceTemplateForm,
pub status: Option<String>,
@@ -316,6 +364,7 @@ pub(crate) struct TableDefinitionPageState {
pub detail: Option<TableDetailView>,
pub history: Vec<RenameEntry>,
pub columns: ColumnDraft,
pub remove_column_ids: Vec<i64>,
pub copy: CopyForm,
pub invoice: InvoiceTemplateForm,
pub status: Option<String>,
@@ -328,6 +377,10 @@ pub(crate) struct TableDefinitionPageState {
}
impl TableDefinitionPageState {
pub(crate) fn column_is_selected_for_removal(&self, column_id: &i64) -> bool {
self.remove_column_ids.contains(column_id)
}
/// Whether `name` is the page being rendered. Read by context.html.
pub(crate) fn is(&self, name: &str) -> bool {
self.active == name
@@ -490,5 +543,11 @@ mod tests {
detail.columns[2].flags(&crate::i18n::Locale::default()),
vec!["EUR", "generated from accounting"]
);
assert_eq!(detail.expanded_removal_ids(&[1]).unwrap(), [1, 2]);
assert_eq!(detail.expanded_removal_ids(&[2]).unwrap(), [1, 2]);
assert!(detail.expanded_removal_ids(&[99]).is_err());
assert!(detail.columns[0].is_removal_choice());
assert!(!detail.columns[1].is_removal_choice());
}
}

View File

@@ -291,6 +291,7 @@ mod tests {
}),
history: Vec::new(),
columns: ColumnDraft::for_append(crate::schema::tests::catalog()),
remove_column_ids: Vec::new(),
copy: CopyForm::default(),
invoice: InvoiceTemplateForm::default(),
status: None,
@@ -453,6 +454,9 @@ mod tests {
fn adding_columns_and_presentation_are_separate_pages() {
let add_html = render_add_columns_page(&page());
assert!(add_html.contains(r#"id="column-form""#), "{add_html}");
assert!(add_html.contains(r#"name="expected_row_version" value="1""#), "{add_html}");
assert!(add_html.contains(r#"name="remove_column_ids" value="1""#), "{add_html}");
assert!(add_html.contains("Adjust columns"), "{add_html}");
assert!(!add_html.contains(r#"name="alias""#), "{add_html}");
let presentation_html = render_presentation_page(&page());