From 10d9784b9b6162225f079e7161b32c34f4173532 Mon Sep 17 00:00:00 2001 From: Priec Date: Sat, 15 Aug 2026 18:31:51 +0200 Subject: [PATCH] fixes --- web/locales/cs/main.ftl | 12 +- web/locales/en/main.ftl | 12 +- web/locales/sk/main.ftl | 12 +- web/src/lib.rs | 12 +- web/src/pages/admin/table_definition/logic.rs | 205 +++++++++++++----- web/src/pages/admin/table_definition/mod.rs | 13 +- web/src/pages/admin/table_definition/state.rs | 39 +++- web/src/pages/admin/table_definition/ui.rs | 44 +++- web/src/pages/import_export/common/schema.rs | 67 +++++- web/src/pages/import_export/export/logic.rs | 12 +- web/src/pages/import_export/export/state.rs | 9 + web/src/pages/import_export/import/logic.rs | 8 +- .../table_definition/presentation_panel.html | 65 ++++-- .../pages/import_export/export/export.html | 5 + 14 files changed, 404 insertions(+), 111 deletions(-) diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index a227249b..3d86304a 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -162,12 +162,15 @@ td-add-columns-title = Přidat sloupce td-presentation-title = Zobrazení sloupců td-add-columns-to = Přidat sloupce do td-append-hint = Sloupce se přidávají na konec. Nic, co už existuje, se nemění a nové sloupce lze indexovat hned při přidání. -td-rename-column = Aliasy a pořadí sloupců +td-rename-column = Aliasy sloupců td-rename-hint = Přejmenovává se jen zobrazený název, ne fyzický sloupec pod ním, takže uložená data a skripty zůstávají nedotčené. td-column-label = Sloupec td-choose-column = Vyberte sloupec td-new-name = Nový název -td-rename-button = Uložit zobrazení +td-rename-button = Přejmenovat +td-alias-locked = nelze přejmenovat +td-order-heading = Pořadí sloupců +td-order-hint = Posune sloupec za souseda. Formulář pořadí neposílá názvy, takže nemůže nic přejmenovat. td-columns-now = Současné sloupce td-col-column = Sloupec td-col-type = Typ @@ -313,7 +316,8 @@ td-err-profile-gone = Profil `{ $missing }` už neexistuje. td-err-not-a-table = `{ $missing }` není tabulka rozsahu { $scope }. td-err-describe-column = Před přidáním popište alespoň jeden sloupec. td-err-backend-no-columns = Backend nepřidal sloupce. -td-err-choose-rename = Vyberte sloupec a zadejte jeho nový název. +td-err-choose-rename = Zadejte nový název sloupce. +td-err-unknown-column = Tento sloupec už do této tabulky nepatří. Panel níže ukazuje tabulku, jaká je teď. td-err-backend-no-rename = Backend nepřejmenoval sloupec. td-err-type-name = Pro potvrzení smazání napište přesný název tabulky. td-err-backend-no-delete = Backend nesmazal tabulku. @@ -609,6 +613,8 @@ export-profile = Profil export-choose-profile = Vyberte profil export-tables = Tabulky export-comma-hint = Více tabulek oddělte čárkami. +export-include-system = Zahrnout systémové sloupce +export-include-system-hint = Do souboru přidá id, deleted a row_revision. Import je ignoruje. export-error-title = CSV se nepodařilo exportovat export-download = Stáhnout CSV export-err-select-profile = Vyberte profil. diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index 3a7716fb..915080b6 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -164,12 +164,15 @@ td-add-columns-title = Add columns td-presentation-title = Column presentation td-add-columns-to = Add columns to td-append-hint = Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added. -td-rename-column = Column aliases and order +td-rename-column = Column aliases td-rename-hint = Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. td-column-label = Column td-choose-column = Choose a column td-new-name = New name -td-rename-button = Save presentation +td-rename-button = Rename +td-alias-locked = cannot be renamed +td-order-heading = Column order +td-order-hint = Moves a column past its neighbour. The order form carries no names, so it cannot rename anything. td-columns-now = Columns it has now td-col-column = Column td-col-type = Type @@ -309,7 +312,8 @@ td-err-profile-gone = Profile `{ $missing }` no longer exists. td-err-not-a-table = `{ $missing }` is not a table of { $scope }. td-err-describe-column = Describe at least one column before adding. td-err-backend-no-columns = The backend did not add the columns. -td-err-choose-rename = Choose a column and type its new name. +td-err-choose-rename = Type a new name for the column. +td-err-unknown-column = That column is not part of this table any more. The panel below is the table as it now is. td-err-backend-no-rename = The backend did not rename the column. td-err-type-name = Type the table's name exactly to confirm the deletion. td-err-backend-no-delete = The backend did not delete the table. @@ -596,6 +600,8 @@ export-profile = Profile export-choose-profile = Choose a profile export-tables = Tables export-comma-hint = Separate multiple tables with commas. +export-include-system = Include system columns +export-include-system-hint = Adds id, deleted and row_revision to the file. An import ignores them. export-error-title = Could not export CSV export-download = Download CSV export-err-select-profile = Select a profile. diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index 4637dc1e..75796f92 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -162,12 +162,15 @@ td-add-columns-title = Pridať stĺpce td-presentation-title = Zobrazenie stĺpcov td-add-columns-to = Pridať stĺpce do td-append-hint = Stĺpce sa pridávajú na koniec. Nič, čo už existuje, sa nemení a nové stĺpce možno indexovať hneď pri pridaní. -td-rename-column = Aliasy a poradie stĺpcov +td-rename-column = Aliasy stĺpcov td-rename-hint = Premenúva sa len zobrazený názov, nie fyzický stĺpec pod ním, takže uložené údaje a skripty zostávajú nedotknuté. td-column-label = Stĺpec td-choose-column = Vyberte stĺpec td-new-name = Nový názov -td-rename-button = Uložiť zobrazenie +td-rename-button = Premenovať +td-alias-locked = nedá sa premenovať +td-order-heading = Poradie stĺpcov +td-order-hint = Posunie stĺpec za suseda. Formulár poradia neposiela názvy, takže nemôže nič premenovať. td-columns-now = Súčasné stĺpce td-col-column = Stĺpec td-col-type = Typ @@ -313,7 +316,8 @@ td-err-profile-gone = Profil `{ $missing }` už neexistuje. td-err-not-a-table = `{ $missing }` nie je tabuľka rozsahu { $scope }. td-err-describe-column = Pred pridaním popíšte aspoň jeden stĺpec. td-err-backend-no-columns = Backend nepridal stĺpce. -td-err-choose-rename = Vyberte stĺpec a zadajte jeho nový názov. +td-err-choose-rename = Zadajte nový názov stĺpca. +td-err-unknown-column = Tento stĺpec už do tejto tabuľky nepatrí. Panel nižšie ukazuje tabuľku, aká je teraz. td-err-backend-no-rename = Backend nepremenoval stĺpec. td-err-type-name = Na potvrdenie vymazania napíšte presný názov tabuľky. td-err-backend-no-delete = Backend nevymazal tabuľku. @@ -609,6 +613,8 @@ export-profile = Profil export-choose-profile = Vyberte profil export-tables = Tabuľky export-comma-hint = Viacero tabuliek oddeľte čiarkami. +export-include-system = Zahrnúť systémové stĺpce +export-include-system-hint = Do súboru pridá id, deleted a row_revision. Import ich ignoruje. export-error-title = CSV sa nepodarilo exportovať export-download = Stiahnuť CSV export-err-select-profile = Vyberte profil. diff --git a/web/src/lib.rs b/web/src/lib.rs index 1dc5791a..c2f01733 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -324,7 +324,8 @@ mod tests { for path in [ "/admin/tables/columns/add", "/admin/tables/columns/add/builder", - "/admin/tables/presentation", + "/admin/tables/presentation/alias", + "/admin/tables/presentation/order", "/admin/tables/delete", "/admin/profiles/copy", "/admin/tables/from-template", @@ -452,7 +453,14 @@ mod tests { ), ("/admin/tables/delete", "profile=billing&table=invoice"), ("/admin/profiles/copy", "profile=billing&table=invoice"), - ("/admin/tables/presentation", "profile=billing&table=invoice"), + ( + "/admin/tables/presentation/alias", + "profile=billing&table=invoice&column_id=1&alias=amount", + ), + ( + "/admin/tables/presentation/order", + "profile=billing&table=invoice&column_id=1&direction=up", + ), ("/admin/tables/columns/add", "profile=billing&table=invoice"), ("/admin/tables/builder", ""), ("/admin/tables", ""), diff --git a/web/src/pages/admin/table_definition/logic.rs b/web/src/pages/admin/table_definition/logic.rs index 519738d6..0e2253cb 100644 --- a/web/src/pages/admin/table_definition/logic.rs +++ b/web/src/pages/admin/table_definition/logic.rs @@ -33,8 +33,8 @@ use crate::{ use super::{ loader::{self, load_page}, state::{ - CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs, - PresentationForm, Selection, TableDefinitionPageState, + AliasForm, CopyForm, DeleteForm, DetailColumn, GeneratedTableView, InvoiceTemplateForm, + LoadError, OrderForm, PageInputs, Selection, TableDefinitionPageState, }, ui, }; @@ -316,67 +316,164 @@ pub(crate) async fn add_columns( } } -/// POST /admin/tables/presentation — SetColumnPresentation. -pub(crate) async fn set_column_presentation( +/// POST /admin/tables/presentation/alias — SetColumnPresentation, renaming one +/// column. +/// +/// The request the backend wants is the whole table, so the columns this write +/// is not about are filled in from a fresh read rather than from the browser. +/// That is the point of the split: the only thing the form contributes is the +/// alias and the id of the column it was typed into. +pub(crate) async fn set_column_alias( State(state): State, headers: HeaderMap, - Form(form): Form, + Form(form): Form, ) -> Response { if let Some(rejection) = reject_cross_site(&headers) { return rejection; } - let mut inputs = PageInputs::for_selection(Selection { + let inputs = PageInputs::for_selection(Selection { profile: form.profile.clone(), table: form.table.clone(), }); - inputs.presentation = form.clone(); - if form.column_ids.is_empty() - || form.column_ids.len() != form.aliases.len() - || form.aliases.iter().any(|alias| alias.trim().is_empty()) - { - let message = tr!( - Locale::from_headers(&headers), - "td-err-choose-rename" - ); - return refuse( - state, - headers, - inputs, - Page::Presentation, - message, - ) - .await; + let alias = form.alias.trim().to_string(); + if alias.is_empty() { + let message = tr!(Locale::from_headers(&headers), "td-err-choose-rename"); + return refuse(state, headers, inputs, Page::Presentation, message).await; } - let mut columns = form - .column_ids + let columns = match current_columns(&state, &headers, &inputs).await { + Ok(columns) => columns, + Err(response) => return response, + }; + if !columns.iter().any(|column| column.column_id == form.column_id) { + let message = tr!(Locale::from_headers(&headers), "td-err-unknown-column"); + return refuse(state, headers, inputs, Page::Presentation, message).await; + } + let presentation = columns .iter() - .copied() - .zip(form.aliases.iter()) - .map(|(column_id, alias)| ColumnPresentation { - column_id, - alias: alias.trim().to_string(), + .map(|column| ColumnPresentation { + column_id: column.column_id, + alias: if column.column_id == form.column_id { + alias.clone() + } else { + column.name.clone() + }, + }) + .collect(); + + apply_presentation( + state, + headers, + inputs, + form.profile, + form.table, + form.expected_row_version, + presentation, + ) + .await +} + +/// POST /admin/tables/presentation/order — SetColumnPresentation, moving one +/// column past its neighbour. +/// +/// Every alias in the request is the name the backend just reported, so this +/// write cannot rename a column even when the browser's copy of the table is +/// stale. Only the order it sends comes from the form. +pub(crate) async fn set_column_order( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> Response { + if let Some(rejection) = reject_cross_site(&headers) { + return rejection; + } + + let inputs = PageInputs::for_selection(Selection { + profile: form.profile.clone(), + table: form.table.clone(), + }); + + let columns = match current_columns(&state, &headers, &inputs).await { + Ok(columns) => columns, + Err(response) => return response, + }; + let Some(index) = columns + .iter() + .position(|column| column.column_id == form.column_id) + else { + let message = tr!(Locale::from_headers(&headers), "td-err-unknown-column"); + return refuse(state, headers, inputs, Page::Presentation, message).await; + }; + // A column at the end of the table has nowhere further to go, and the + // button that says so is disabled; a request that asks anyway is answered + // with the table as it is. + let swap_with = match form.direction.as_str() { + "up" => index.checked_sub(1), + "down" if index + 1 < columns.len() => Some(index + 1), + _ => None, + }; + let Some(swap_with) = swap_with else { + return respond(state, headers, inputs, Page::Presentation, StatusCode::OK).await; + }; + + let mut presentation = columns + .iter() + .map(|column| ColumnPresentation { + column_id: column.column_id, + alias: column.name.clone(), }) .collect::>(); - if let Some((direction, index)) = form.action.split_once(':') { - if let Ok(index) = index.parse::() { - let other = match direction { - "up" => index.checked_sub(1), - "down" if index + 1 < columns.len() => Some(index + 1), - _ => None, - }; - if let Some(other) = other { - columns.swap(index, other); - } - } + presentation.swap(index, swap_with); + + apply_presentation( + state, + headers, + inputs, + form.profile, + form.table, + form.expected_row_version, + presentation, + ) + .await +} + +/// The table's columns as the backend has them now, in their current order. +/// +/// Both presentation writes have to send every column, and the ones they are +/// not about must carry the name the server holds this moment -- not the name +/// the browser was showing when the page was drawn. +async fn current_columns( + state: &AppState, + headers: &HeaderMap, + inputs: &PageInputs, +) -> Result, Response> { + match load_page(state.clone(), headers, inputs.clone()).await { + Ok(page) => Ok(page.detail.map(|detail| detail.columns).unwrap_or_default()), + Err(error) => Err(load_error_response(headers, error)), } +} + +/// The half both writes share: send the presentation, answer with the page. +/// +/// `expected_row_version` is the browser's, not the one the read above saw, so +/// a definition that changed under the user is still refused by the backend +/// rather than silently written over. +async fn apply_presentation( + state: AppState, + headers: HeaderMap, + inputs: PageInputs, + profile: String, + table: String, + expected_row_version: i64, + columns: Vec, +) -> Response { let request = SetColumnPresentationRequest { - profile_name: form.profile.clone(), - table_name: form.table.clone(), + profile_name: profile, + table_name: table, columns, - expected_row_version: form.expected_row_version, + expected_row_version, }; let Ok(request) = authenticated_request(&headers, request) else { return Redirect::to("/login").into_response(); @@ -385,28 +482,28 @@ pub(crate) async fn set_column_presentation( let mut definitions = state.definitions.clone(); match definitions.set_column_presentation(request).await { Ok(response) if response.get_ref().success => { + let mut inputs = inputs; inputs.status = Some(response.into_inner().message); - inputs.presentation = PresentationForm { - profile: form.profile, - table: form.table, - ..Default::default() - }; respond(state, headers, inputs, Page::Presentation, StatusCode::OK).await } Ok(response) => { let message = response.into_inner().message; let message = if message.is_empty() { - tr!( - Locale::from_headers(&headers), - "td-err-backend-no-rename" - ) + tr!(Locale::from_headers(&headers), "td-err-backend-no-rename") } else { message }; refuse(state, headers, inputs, Page::Presentation, message).await } Err(error) => { - refuse(state, headers, inputs, Page::Presentation, error.message().to_string()).await + refuse( + state, + headers, + inputs, + Page::Presentation, + error.message().to_string(), + ) + .await } } } diff --git a/web/src/pages/admin/table_definition/mod.rs b/web/src/pages/admin/table_definition/mod.rs index 88ffe9a6..58ceaba9 100644 --- a/web/src/pages/admin/table_definition/mod.rs +++ b/web/src/pages/admin/table_definition/mod.rs @@ -38,9 +38,18 @@ pub(crate) fn router() -> Router { "/admin/tables/columns/add/builder", post(logic::update_columns), ) + // Naming a column and ordering the columns are one backend call but two + // forms, because a request that carries both is a request in which a + // stale alias can ride along with an unrelated edit. See + // `state::AliasForm`. + .route("/admin/tables/presentation", get(logic::presentation_page)) .route( - "/admin/tables/presentation", - get(logic::presentation_page).post(logic::set_column_presentation), + "/admin/tables/presentation/alias", + post(logic::set_column_alias), + ) + .route( + "/admin/tables/presentation/order", + post(logic::set_column_order), ) .route("/admin/tables/delete", get(logic::delete_page)) .route("/admin/tables/delete", post(logic::delete_table)) diff --git a/web/src/pages/admin/table_definition/state.rs b/web/src/pages/admin/table_definition/state.rs index 6d0f4b60..abbdcc43 100644 --- a/web/src/pages/admin/table_definition/state.rs +++ b/web/src/pages/admin/table_definition/state.rs @@ -173,10 +173,18 @@ pub(crate) struct GeneratedTableView { pub parent_table_name: String, } -/// The rename panel's inputs, kept across a failed submit so the user does not -/// retype them. +/// Renaming one column. +/// +/// `SetColumnPresentation` sets names and order in one call and insists on +/// being given every column, so a browser that posts the whole table posts a +/// list it read at some earlier moment. When the ids and the aliases came from +/// two different moments, they still zipped into a valid request -- one that +/// renamed `amount` to `note` and `note` to `amount`, and took every validation +/// and type along with the names. This form carries one alias and the id of the +/// column it was typed into, so there is no list to misalign; the handler fills +/// the rest of the table in from the backend. #[derive(Clone, Debug, Default, serde::Deserialize)] -pub(crate) struct PresentationForm { +pub(crate) struct AliasForm { #[serde(default)] pub profile: String, #[serde(default)] @@ -184,11 +192,29 @@ pub(crate) struct PresentationForm { #[serde(default)] pub expected_row_version: i64, #[serde(default)] - pub column_ids: Vec, + pub column_id: i64, #[serde(default)] - pub aliases: Vec, + pub alias: String, +} + +/// Moving one column past its neighbour. +/// +/// It carries no alias at all -- not even the one it is moving -- so a reorder +/// cannot rename anything, whatever the browser still had on screen. See +/// [`AliasForm`] for why that separation is worth two forms. +#[derive(Clone, Debug, Default, serde::Deserialize)] +pub(crate) struct OrderForm { #[serde(default)] - pub action: String, + pub profile: String, + #[serde(default)] + pub table: String, + #[serde(default)] + pub expected_row_version: i64, + #[serde(default)] + pub column_id: i64, + /// `up` or `down`. Anything else moves nothing. + #[serde(default)] + pub direction: String, } /// The copy-profile panel. An empty `table_names` copies the whole profile, @@ -248,7 +274,6 @@ pub(crate) struct PageInputs { pub selection: Selection, /// The columns staged for `AddTableColumns`. pub columns: ColumnDraft, - pub presentation: PresentationForm, pub copy: CopyForm, pub invoice: InvoiceTemplateForm, pub status: Option, diff --git a/web/src/pages/admin/table_definition/ui.rs b/web/src/pages/admin/table_definition/ui.rs index 8cf924cf..3558e731 100644 --- a/web/src/pages/admin/table_definition/ui.rs +++ b/web/src/pages/admin/table_definition/ui.rs @@ -340,7 +340,7 @@ mod tests { assert!(html.contains("/admin/tables/delete")); assert!(html.contains("Type invoice to confirm")); // The other writes are links in the switcher, not forms on the page. - assert!(!html.contains(r#"hx-post="/admin/tables/presentation""#)); + assert!(!html.contains(r#"hx-post="/admin/tables/presentation/alias""#)); assert!(!html.contains("/admin/profiles/copy?profile=billing\" method")); } @@ -404,28 +404,54 @@ mod tests { assert!(!html.contains(r#"id="column-form""#)); let html = render_presentation_page(&state); - assert!(!html.contains(r#"name="aliases""#)); + assert!(!html.contains(r#"name="alias""#)); } + /// Renaming and reordering are two forms, and the split is what the page + /// has to keep: an alias is posted with the id of the column it was typed + /// into, and the order form posts no alias at all, so moving a column + /// cannot carry a stale name along with it. #[test] - fn column_presentation_posts_stable_ids_aliases_and_order_controls() { + fn renaming_and_reordering_are_separate_forms() { let html = render_presentation_page(&page()); - assert!(html.contains(r#"hx-post="/admin/tables/presentation""#), "{html}"); - assert!(html.contains(r#"name="column_ids" value="1""#), "{html}"); + assert!( + html.contains(r#"hx-post="/admin/tables/presentation/alias""#), + "{html}" + ); + assert!( + html.contains(r#"hx-post="/admin/tables/presentation/order""#), + "{html}" + ); + assert!(html.contains(r#"name="column_id" value="1""#), "{html}"); assert!(html.contains(r#"name="expected_row_version" value="1""#), "{html}"); - assert!(html.contains(r#"name="aliases" value="number""#), "{html}"); - assert!(html.contains(r#"name="action" value="save""#), "{html}"); + assert!(html.contains(r#"name="alias" value="number""#), "{html}"); + assert!(html.contains(r#"name="direction" value="up""#), "{html}"); + assert!(html.contains(r#"name="direction" value="down""#), "{html}"); + + // The old fused form is gone: no list of ids paired positionally with a + // list of aliases, and no single save that posts both at once. + assert!(!html.contains(r#"name="column_ids""#), "{html}"); + assert!(!html.contains(r#"name="aliases""#), "{html}"); + assert!(!html.contains(r#"name="action""#), "{html}"); + + // And the order form's own markup names no alias. + let order_form = html + .split(r#"hx-post="/admin/tables/presentation/order""#) + .nth(1) + .and_then(|rest| rest.split("").next()) + .expect("the order form is rendered"); + assert!(!order_form.contains(r#"name="alias""#), "{order_form}"); } #[test] 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="aliases""#), "{add_html}"); + assert!(!add_html.contains(r#"name="alias""#), "{add_html}"); let presentation_html = render_presentation_page(&page()); - assert!(presentation_html.contains(r#"name="aliases""#), "{presentation_html}"); + assert!(presentation_html.contains(r#"name="alias""#), "{presentation_html}"); assert!(!presentation_html.contains(r#"id="column-form""#), "{presentation_html}"); } diff --git a/web/src/pages/import_export/common/schema.rs b/web/src/pages/import_export/common/schema.rs index bd955d1c..306598ac 100644 --- a/web/src/pages/import_export/common/schema.rs +++ b/web/src/pages/import_export/common/schema.rs @@ -6,11 +6,10 @@ use crate::{i18n::Locale, tr}; use crate::definitions::table_structure::TableStructureResponse; -/// The columns a CSV carries, for an export and for the import that reads one -/// back. +/// The columns an import can write, and the export's default header. /// -/// Both ends use this list, so a file the export writes is a file the import -/// accepts. That only holds if every system column is left out: a row is +/// Both ends use this list, so a file the default export writes is a file the +/// import accepts. That only holds if every system column is left out: a row is /// inserted with `post_table_data`, which takes user columns and nothing else, /// so exporting `row_revision` or `created_at` produced a file whose own /// re-import the server answered with `Invalid column`. The names come from @@ -25,16 +24,48 @@ pub(crate) fn exportable_columns(schema: &TableStructureResponse) -> Vec .collect() } +/// Every column of a row the server will actually hand over: the user's +/// columns, plus `id`, `deleted` and `row_revision`. This is what the export +/// writes when asked for the whole row. +/// +/// "Everything" is bounded twice, and both bounds are the server's. Internal +/// columns -- the link version columns, `version`, `closed_by_user_id` -- are +/// dropped inside `get_table_structure` and never reach this crate. And +/// `created_at` is in the structure but not in the row: a read selects the +/// leading system columns and the user's, so asking for it would write a header +/// over a column of blanks. Filtering by what a read returns keeps the file +/// honest about that. +/// +/// Such a file is a record of the table, not a file to load back: the system +/// columns in it are the server's to write, and an import ignores them. +pub(crate) fn all_columns(schema: &TableStructureResponse) -> Vec { + schema + .columns + .iter() + .filter(|column| !is_read_omitted_column(&column.name)) + .map(|column| column.name.clone()) + .collect() +} + /// Whether `name` is one of the columns the server puts on every managed /// table. The virtual `account` name is deliberately not checked: it is an /// alias a user may write to, not a column the server fills in. -fn is_system_column(name: &str) -> bool { +pub(crate) fn is_system_column(name: &str) -> bool { crate::system_column::LEADING_SYSTEM_COLUMNS .iter() .chain(crate::system_column::TRAILING_SYSTEM_COLUMNS.iter()) .any(|column| column.name == name) } +/// Whether the structure lists `name` but a row read leaves it out. The +/// trailing system columns are declared after the user's and are not part of +/// what `get_table_data` selects. +fn is_read_omitted_column(name: &str) -> bool { + crate::system_column::TRAILING_SYSTEM_COLUMNS + .iter() + .any(|column| column.name == name) +} + pub(crate) fn column_types(schema: &TableStructureResponse) -> HashMap { schema .columns @@ -115,4 +146,30 @@ mod tests { assert_eq!(exportable_columns(&schema), vec!["number".to_string()]); } + + /// The whole-row export keeps the system columns the server hands over, + /// and drops `created_at`, which the structure lists but a read does not + /// return. + #[test] + fn the_whole_row_is_what_a_read_returns() { + let schema = TableStructureResponse { + columns: vec![ + column("id", true), + column("deleted", false), + column("row_revision", false), + column("number", false), + column("created_at", false), + ], + }; + + assert_eq!( + all_columns(&schema), + vec![ + "id".to_string(), + "deleted".to_string(), + "row_revision".to_string(), + "number".to_string(), + ] + ); + } } diff --git a/web/src/pages/import_export/export/logic.rs b/web/src/pages/import_export/export/logic.rs index c85e7c79..2f25ddbd 100644 --- a/web/src/pages/import_export/export/logic.rs +++ b/web/src/pages/import_export/export/logic.rs @@ -19,7 +19,7 @@ use super::{ super::common::{ csv::write_record, loader::LoadError, - schema::exportable_columns, + schema::{all_columns, exportable_columns}, }, loader::load_page, state::ExportForm, @@ -55,6 +55,14 @@ pub(crate) async fn export_csv( Ok(targets) => targets, Err(message) => return reject(&headers, message), }; + // Everything the structure exposes, or only what an import could write + // back. The first is a record of the table, the second a file that round + // trips. + let columns_of = if form.include_system_columns() { + all_columns + } else { + exportable_columns + }; let Some(profile) = catalog.profiles.iter().find(|profile| profile.name == profile_name) else { return reject( &headers, @@ -118,7 +126,7 @@ pub(crate) async fn export_csv( &tr!(Locale::from_headers(&headers), "export-err-negative-count"), ); }; - tables.push(ExportTable { name: table_name.clone(), columns: exportable_columns(&structure), count }); + tables.push(ExportTable { name: table_name.clone(), columns: columns_of(&structure), count }); } let mut csv = String::new(); diff --git a/web/src/pages/import_export/export/state.rs b/web/src/pages/import_export/export/state.rs index 0ea021fd..070f1dd2 100644 --- a/web/src/pages/import_export/export/state.rs +++ b/web/src/pages/import_export/export/state.rs @@ -6,6 +6,10 @@ pub(crate) struct ExportForm { pub profile_name: String, #[serde(default)] pub table_names: String, + /// An unchecked checkbox is not posted at all, so its absence is the + /// `false` and any value it does carry is the `true`. + #[serde(default)] + pub include_system_columns: Option, } pub(crate) struct ExportPageState { @@ -31,4 +35,9 @@ impl ExportForm { } Ok((profile.to_string(), tables)) } + + /// Whether the download carries the system columns as well. + pub(crate) fn include_system_columns(&self) -> bool { + self.include_system_columns.is_some() + } } diff --git a/web/src/pages/import_export/import/logic.rs b/web/src/pages/import_export/import/logic.rs index fde25eb8..26069db2 100644 --- a/web/src/pages/import_export/import/logic.rs +++ b/web/src/pages/import_export/import/logic.rs @@ -21,7 +21,7 @@ use super::{ super::common::{ csv::parse_csv, loader::LoadError, - schema::{column_types, csv_value, exportable_columns}, + schema::{column_types, csv_value, exportable_columns, is_system_column}, }, loader::load_page, state::ImportForm, @@ -134,6 +134,12 @@ pub(crate) async fn import_csv( } for (index, column) in columns.iter().enumerate() { let belongs = table_headers.as_ref().map_or(table_names.len() == 1, |headers| headers.get(index).is_some_and(|name| name == &table.name)); + // A system column is the server's to write, so a file that carries + // one -- an export taken with the system columns included -- loads + // with that column left where it is, not refused. + if belongs && is_system_column(column) { + continue; + } if belongs && !table.columns.contains(column) { return reject( &headers, diff --git a/web/templates/pages/admin/table_definition/presentation_panel.html b/web/templates/pages/admin/table_definition/presentation_panel.html index 47aa8173..392b4a83 100644 --- a/web/templates/pages/admin/table_definition/presentation_panel.html +++ b/web/templates/pages/admin/table_definition/presentation_panel.html @@ -3,30 +3,55 @@ {% if page.table_is_writable() %} {% if let Some(detail) = page.detail %} + {# One form per column, each carrying its own column id: an alias is never + posted apart from the column it was typed into. #}

{{ nav.tr("td-rename-column") }}

{{ nav.tr("td-rename-hint") }}

-
- - - -
- {% for column in detail.columns %} - - -
- - + {% for column in detail.columns %} + {% if column.renameable %} + + + + + +
+ +
+ +
- {% endfor %} -
-
- -
- + + {% else %} +

{{ column.name }} — {{ nav.tr("td-alias-locked") }}

+ {% endif %} + {% endfor %} +
+ + {# The order forms carry no alias at all, so moving a column cannot rename + one. The names below are labels, not inputs. #} +
+

{{ nav.tr("td-order-heading") }}

+

{{ nav.tr("td-order-hint") }}

+
    + {% for column in detail.columns %} +
  1. + {{ column.name }} +
    + + + + + + +
    +
  2. + {% endfor %} +
{% endif %} {% endif %} diff --git a/web/templates/pages/import_export/export/export.html b/web/templates/pages/import_export/export/export.html index 0fe161c3..60374e6f 100644 --- a/web/templates/pages/import_export/export/export.html +++ b/web/templates/pages/import_export/export/export.html @@ -23,6 +23,11 @@ {{ nav.tr("export-comma-hint") }} + + {{ nav.tr("export-include-system-hint") }} {%- for profile in page.catalog.profiles %}{% for table in profile.tables %}{% endfor %}{% endfor -%}