This commit is contained in:
Priec
2026-08-15 18:31:51 +02:00
parent 56baa35cf2
commit 10d9784b9b
14 changed files with 404 additions and 111 deletions

View File

@@ -162,12 +162,15 @@ td-add-columns-title = Přidat sloupce
td-presentation-title = Zobrazení sloupců td-presentation-title = Zobrazení sloupců
td-add-columns-to = Přidat sloupce do 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-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-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-column-label = Sloupec
td-choose-column = Vyberte sloupec td-choose-column = Vyberte sloupec
td-new-name = Nový název 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-columns-now = Současné sloupce
td-col-column = Sloupec td-col-column = Sloupec
td-col-type = Typ 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-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-describe-column = Před přidáním popište alespoň jeden sloupec.
td-err-backend-no-columns = Backend nepřidal sloupce. 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-backend-no-rename = Backend nepřejmenoval sloupec.
td-err-type-name = Pro potvrzení smazání napište přesný název tabulky. td-err-type-name = Pro potvrzení smazání napište přesný název tabulky.
td-err-backend-no-delete = Backend nesmazal tabulku. td-err-backend-no-delete = Backend nesmazal tabulku.
@@ -609,6 +613,8 @@ export-profile = Profil
export-choose-profile = Vyberte profil export-choose-profile = Vyberte profil
export-tables = Tabulky export-tables = Tabulky
export-comma-hint = Více tabulek oddělte čárkami. 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-error-title = CSV se nepodařilo exportovat
export-download = Stáhnout CSV export-download = Stáhnout CSV
export-err-select-profile = Vyberte profil. export-err-select-profile = Vyberte profil.

View File

@@ -164,12 +164,15 @@ td-add-columns-title = Add columns
td-presentation-title = Column presentation td-presentation-title = Column presentation
td-add-columns-to = Add columns to 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-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-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-column-label = Column
td-choose-column = Choose a column td-choose-column = Choose a column
td-new-name = New name 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-columns-now = Columns it has now
td-col-column = Column td-col-column = Column
td-col-type = Type 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-not-a-table = `{ $missing }` is not a table of { $scope }.
td-err-describe-column = Describe at least one column before adding. 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-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-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-type-name = Type the table's name exactly to confirm the deletion.
td-err-backend-no-delete = The backend did not delete the table. 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-choose-profile = Choose a profile
export-tables = Tables export-tables = Tables
export-comma-hint = Separate multiple tables with commas. 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-error-title = Could not export CSV
export-download = Download CSV export-download = Download CSV
export-err-select-profile = Select a profile. export-err-select-profile = Select a profile.

View File

@@ -162,12 +162,15 @@ td-add-columns-title = Pridať stĺpce
td-presentation-title = Zobrazenie stĺpcov td-presentation-title = Zobrazenie stĺpcov
td-add-columns-to = Pridať stĺpce do 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-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-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-column-label = Stĺpec
td-choose-column = Vyberte stĺpec td-choose-column = Vyberte stĺpec
td-new-name = Nový názov 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-columns-now = Súčasné stĺpce
td-col-column = Stĺpec td-col-column = Stĺpec
td-col-type = Typ 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-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-describe-column = Pred pridaním popíšte aspoň jeden stĺpec.
td-err-backend-no-columns = Backend nepridal stĺpce. 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-backend-no-rename = Backend nepremenoval stĺpec.
td-err-type-name = Na potvrdenie vymazania napíšte presný názov tabuľky. td-err-type-name = Na potvrdenie vymazania napíšte presný názov tabuľky.
td-err-backend-no-delete = Backend nevymazal tabuľku. td-err-backend-no-delete = Backend nevymazal tabuľku.
@@ -609,6 +613,8 @@ export-profile = Profil
export-choose-profile = Vyberte profil export-choose-profile = Vyberte profil
export-tables = Tabuľky export-tables = Tabuľky
export-comma-hint = Viacero tabuliek oddeľte čiarkami. 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-error-title = CSV sa nepodarilo exportovať
export-download = Stiahnuť CSV export-download = Stiahnuť CSV
export-err-select-profile = Vyberte profil. export-err-select-profile = Vyberte profil.

View File

@@ -324,7 +324,8 @@ mod tests {
for path in [ for path in [
"/admin/tables/columns/add", "/admin/tables/columns/add",
"/admin/tables/columns/add/builder", "/admin/tables/columns/add/builder",
"/admin/tables/presentation", "/admin/tables/presentation/alias",
"/admin/tables/presentation/order",
"/admin/tables/delete", "/admin/tables/delete",
"/admin/profiles/copy", "/admin/profiles/copy",
"/admin/tables/from-template", "/admin/tables/from-template",
@@ -452,7 +453,14 @@ mod tests {
), ),
("/admin/tables/delete", "profile=billing&table=invoice"), ("/admin/tables/delete", "profile=billing&table=invoice"),
("/admin/profiles/copy", "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/columns/add", "profile=billing&table=invoice"),
("/admin/tables/builder", ""), ("/admin/tables/builder", ""),
("/admin/tables", ""), ("/admin/tables", ""),

View File

@@ -33,8 +33,8 @@ use crate::{
use super::{ use super::{
loader::{self, load_page}, loader::{self, load_page},
state::{ state::{
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs, AliasForm, CopyForm, DeleteForm, DetailColumn, GeneratedTableView, InvoiceTemplateForm,
PresentationForm, Selection, TableDefinitionPageState, LoadError, OrderForm, PageInputs, Selection, TableDefinitionPageState,
}, },
ui, ui,
}; };
@@ -316,67 +316,164 @@ pub(crate) async fn add_columns(
} }
} }
/// POST /admin/tables/presentation — SetColumnPresentation. /// POST /admin/tables/presentation/alias — SetColumnPresentation, renaming one
pub(crate) async fn set_column_presentation( /// 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<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<PresentationForm>, Form(form): Form<AliasForm>,
) -> Response { ) -> Response {
if let Some(rejection) = reject_cross_site(&headers) { if let Some(rejection) = reject_cross_site(&headers) {
return rejection; return rejection;
} }
let mut inputs = PageInputs::for_selection(Selection { let inputs = PageInputs::for_selection(Selection {
profile: form.profile.clone(), profile: form.profile.clone(),
table: form.table.clone(), table: form.table.clone(),
}); });
inputs.presentation = form.clone();
if form.column_ids.is_empty() let alias = form.alias.trim().to_string();
|| form.column_ids.len() != form.aliases.len() if alias.is_empty() {
|| 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 message = tr!( }
Locale::from_headers(&headers),
"td-err-choose-rename" let columns = match current_columns(&state, &headers, &inputs).await {
); Ok(columns) => columns,
return refuse( 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()
.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, state,
headers, headers,
inputs, inputs,
Page::Presentation, form.profile,
message, form.table,
form.expected_row_version,
presentation,
) )
.await; .await
} }
let mut columns = form /// POST /admin/tables/presentation/order — SetColumnPresentation, moving one
.column_ids /// 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<AppState>,
headers: HeaderMap,
Form(form): Form<OrderForm>,
) -> 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() .iter()
.copied() .position(|column| column.column_id == form.column_id)
.zip(form.aliases.iter()) else {
.map(|(column_id, alias)| ColumnPresentation { let message = tr!(Locale::from_headers(&headers), "td-err-unknown-column");
column_id, return refuse(state, headers, inputs, Page::Presentation, message).await;
alias: alias.trim().to_string(), };
}) // A column at the end of the table has nowhere further to go, and the
.collect::<Vec<_>>(); // button that says so is disabled; a request that asks anyway is answered
if let Some((direction, index)) = form.action.split_once(':') { // with the table as it is.
if let Ok(index) = index.parse::<usize>() { let swap_with = match form.direction.as_str() {
let other = match direction {
"up" => index.checked_sub(1), "up" => index.checked_sub(1),
"down" if index + 1 < columns.len() => Some(index + 1), "down" if index + 1 < columns.len() => Some(index + 1),
_ => None, _ => None,
}; };
if let Some(other) = other { let Some(swap_with) = swap_with else {
columns.swap(index, other); 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::<Vec<_>>();
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<Vec<DetailColumn>, 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<ColumnPresentation>,
) -> Response {
let request = SetColumnPresentationRequest { let request = SetColumnPresentationRequest {
profile_name: form.profile.clone(), profile_name: profile,
table_name: form.table.clone(), table_name: table,
columns, columns,
expected_row_version: form.expected_row_version, expected_row_version,
}; };
let Ok(request) = authenticated_request(&headers, request) else { let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response(); return Redirect::to("/login").into_response();
@@ -385,28 +482,28 @@ pub(crate) async fn set_column_presentation(
let mut definitions = state.definitions.clone(); let mut definitions = state.definitions.clone();
match definitions.set_column_presentation(request).await { match definitions.set_column_presentation(request).await {
Ok(response) if response.get_ref().success => { Ok(response) if response.get_ref().success => {
let mut inputs = inputs;
inputs.status = Some(response.into_inner().message); 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 respond(state, headers, inputs, Page::Presentation, StatusCode::OK).await
} }
Ok(response) => { Ok(response) => {
let message = response.into_inner().message; let message = response.into_inner().message;
let message = if message.is_empty() { let message = if message.is_empty() {
tr!( tr!(Locale::from_headers(&headers), "td-err-backend-no-rename")
Locale::from_headers(&headers),
"td-err-backend-no-rename"
)
} else { } else {
message message
}; };
refuse(state, headers, inputs, Page::Presentation, message).await refuse(state, headers, inputs, Page::Presentation, message).await
} }
Err(error) => { Err(error) => {
refuse(state, headers, inputs, Page::Presentation, error.message().to_string()).await refuse(
state,
headers,
inputs,
Page::Presentation,
error.message().to_string(),
)
.await
} }
} }
} }

View File

@@ -38,9 +38,18 @@ pub(crate) fn router() -> Router<AppState> {
"/admin/tables/columns/add/builder", "/admin/tables/columns/add/builder",
post(logic::update_columns), 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( .route(
"/admin/tables/presentation", "/admin/tables/presentation/alias",
get(logic::presentation_page).post(logic::set_column_presentation), 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", get(logic::delete_page))
.route("/admin/tables/delete", post(logic::delete_table)) .route("/admin/tables/delete", post(logic::delete_table))

View File

@@ -173,10 +173,18 @@ pub(crate) struct GeneratedTableView {
pub parent_table_name: String, pub parent_table_name: String,
} }
/// The rename panel's inputs, kept across a failed submit so the user does not /// Renaming one column.
/// retype them. ///
/// `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)] #[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct PresentationForm { pub(crate) struct AliasForm {
#[serde(default)] #[serde(default)]
pub profile: String, pub profile: String,
#[serde(default)] #[serde(default)]
@@ -184,11 +192,29 @@ pub(crate) struct PresentationForm {
#[serde(default)] #[serde(default)]
pub expected_row_version: i64, pub expected_row_version: i64,
#[serde(default)] #[serde(default)]
pub column_ids: Vec<i64>, pub column_id: i64,
#[serde(default)] #[serde(default)]
pub aliases: Vec<String>, 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)] #[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, /// The copy-profile panel. An empty `table_names` copies the whole profile,
@@ -248,7 +274,6 @@ pub(crate) struct PageInputs {
pub selection: Selection, pub selection: Selection,
/// The columns staged for `AddTableColumns`. /// The columns staged for `AddTableColumns`.
pub columns: ColumnDraft, pub columns: ColumnDraft,
pub presentation: PresentationForm,
pub copy: CopyForm, pub copy: CopyForm,
pub invoice: InvoiceTemplateForm, pub invoice: InvoiceTemplateForm,
pub status: Option<String>, pub status: Option<String>,

View File

@@ -340,7 +340,7 @@ mod tests {
assert!(html.contains("/admin/tables/delete")); assert!(html.contains("/admin/tables/delete"));
assert!(html.contains("Type <code>invoice</code> to confirm")); assert!(html.contains("Type <code>invoice</code> to confirm"));
// The other writes are links in the switcher, not forms on the page. // 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")); assert!(!html.contains("/admin/profiles/copy?profile=billing\" method"));
} }
@@ -404,28 +404,54 @@ mod tests {
assert!(!html.contains(r#"id="column-form""#)); assert!(!html.contains(r#"id="column-form""#));
let html = render_presentation_page(&state); 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] #[test]
fn column_presentation_posts_stable_ids_aliases_and_order_controls() { fn renaming_and_reordering_are_separate_forms() {
let html = render_presentation_page(&page()); let html = render_presentation_page(&page());
assert!(html.contains(r#"hx-post="/admin/tables/presentation""#), "{html}"); assert!(
assert!(html.contains(r#"name="column_ids" value="1""#), "{html}"); 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="expected_row_version" value="1""#), "{html}");
assert!(html.contains(r#"name="aliases" value="number""#), "{html}"); assert!(html.contains(r#"name="alias" value="number""#), "{html}");
assert!(html.contains(r#"name="action" value="save""#), "{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("</form>").next())
.expect("the order form is rendered");
assert!(!order_form.contains(r#"name="alias""#), "{order_form}");
} }
#[test] #[test]
fn adding_columns_and_presentation_are_separate_pages() { fn adding_columns_and_presentation_are_separate_pages() {
let add_html = render_add_columns_page(&page()); let add_html = render_add_columns_page(&page());
assert!(add_html.contains(r#"id="column-form""#), "{add_html}"); 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()); 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}"); assert!(!presentation_html.contains(r#"id="column-form""#), "{presentation_html}");
} }

View File

@@ -6,11 +6,10 @@ use crate::{i18n::Locale, tr};
use crate::definitions::table_structure::TableStructureResponse; use crate::definitions::table_structure::TableStructureResponse;
/// The columns a CSV carries, for an export and for the import that reads one /// The columns an import can write, and the export's default header.
/// back.
/// ///
/// Both ends use this list, so a file the export writes is a file the import /// Both ends use this list, so a file the default export writes is a file the
/// accepts. That only holds if every system column is left out: a row is /// 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, /// inserted with `post_table_data`, which takes user columns and nothing else,
/// so exporting `row_revision` or `created_at` produced a file whose own /// so exporting `row_revision` or `created_at` produced a file whose own
/// re-import the server answered with `Invalid column`. The names come from /// re-import the server answered with `Invalid column`. The names come from
@@ -25,16 +24,48 @@ pub(crate) fn exportable_columns(schema: &TableStructureResponse) -> Vec<String>
.collect() .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<String> {
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 /// 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 /// 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. /// 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 crate::system_column::LEADING_SYSTEM_COLUMNS
.iter() .iter()
.chain(crate::system_column::TRAILING_SYSTEM_COLUMNS.iter()) .chain(crate::system_column::TRAILING_SYSTEM_COLUMNS.iter())
.any(|column| column.name == name) .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<String, String> { pub(crate) fn column_types(schema: &TableStructureResponse) -> HashMap<String, String> {
schema schema
.columns .columns
@@ -115,4 +146,30 @@ mod tests {
assert_eq!(exportable_columns(&schema), vec!["number".to_string()]); 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(),
]
);
}
} }

View File

@@ -19,7 +19,7 @@ use super::{
super::common::{ super::common::{
csv::write_record, csv::write_record,
loader::LoadError, loader::LoadError,
schema::exportable_columns, schema::{all_columns, exportable_columns},
}, },
loader::load_page, loader::load_page,
state::ExportForm, state::ExportForm,
@@ -55,6 +55,14 @@ pub(crate) async fn export_csv(
Ok(targets) => targets, Ok(targets) => targets,
Err(message) => return reject(&headers, message), 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 { let Some(profile) = catalog.profiles.iter().find(|profile| profile.name == profile_name) else {
return reject( return reject(
&headers, &headers,
@@ -118,7 +126,7 @@ pub(crate) async fn export_csv(
&tr!(Locale::from_headers(&headers), "export-err-negative-count"), &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(); let mut csv = String::new();

View File

@@ -6,6 +6,10 @@ pub(crate) struct ExportForm {
pub profile_name: String, pub profile_name: String,
#[serde(default)] #[serde(default)]
pub table_names: String, 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<String>,
} }
pub(crate) struct ExportPageState { pub(crate) struct ExportPageState {
@@ -31,4 +35,9 @@ impl ExportForm {
} }
Ok((profile.to_string(), tables)) 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()
}
} }

View File

@@ -21,7 +21,7 @@ use super::{
super::common::{ super::common::{
csv::parse_csv, csv::parse_csv,
loader::LoadError, loader::LoadError,
schema::{column_types, csv_value, exportable_columns}, schema::{column_types, csv_value, exportable_columns, is_system_column},
}, },
loader::load_page, loader::load_page,
state::ImportForm, state::ImportForm,
@@ -134,6 +134,12 @@ pub(crate) async fn import_csv(
} }
for (index, column) in columns.iter().enumerate() { 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)); 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) { if belongs && !table.columns.contains(column) {
return reject( return reject(
&headers, &headers,

View File

@@ -3,30 +3,55 @@
{% if page.table_is_writable() %} {% if page.table_is_writable() %}
{% if let Some(detail) = page.detail %} {% 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. #}
<section class="panel"> <section class="panel">
<h2>{{ nav.tr("td-rename-column") }}</h2> <h2>{{ nav.tr("td-rename-column") }}</h2>
<p class="hint">{{ nav.tr("td-rename-hint") }}</p> <p class="hint">{{ nav.tr("td-rename-hint") }}</p>
<form hx-post="/admin/tables/presentation" {% for column in detail.columns %}
{% if column.renameable %}
<form hx-post="/admin/tables/presentation/alias"
hx-target="#table-panel" hx-swap="innerHTML"> hx-target="#table-panel" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}"> <input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}"> <input type="hidden" name="table" value="{{ page.selection.table }}">
<input type="hidden" name="expected_row_version" value="{{ detail.row_version }}"> <input type="hidden" name="expected_row_version" value="{{ detail.row_version }}">
<input type="hidden" name="column_id" value="{{ column.column_id }}">
<div class="form-grid"> <div class="form-grid">
{% for column in detail.columns %}
<input type="hidden" name="column_ids" value="{{ column.column_id }}">
<label>{{ nav.tr("td-column-label") }} <label>{{ nav.tr("td-column-label") }}
<input name="aliases" value="{{ column.name }}" {% if !column.renameable %}readonly{% endif %}> <input name="alias" value="{{ column.name }}">
</label> </label>
<div class="form-actions"> <div class="form-actions">
<button type="submit" name="action" value="up:{{ loop.index0 }}" {% if loop.first %}disabled{% endif %}></button> <button type="submit">{{ nav.tr("td-rename-button") }}</button>
<button type="submit" name="action" value="down:{{ loop.index0 }}" {% if loop.last %}disabled{% endif %}></button>
</div> </div>
{% endfor %}
</div>
<div class="form-actions">
<button type="submit" name="action" value="save">{{ nav.tr("td-rename-button") }}</button>
</div> </div>
</form> </form>
{% else %}
<p class="hint"><code>{{ column.name }}</code> — {{ nav.tr("td-alias-locked") }}</p>
{% endif %}
{% endfor %}
</section>
{# The order forms carry no alias at all, so moving a column cannot rename
one. The names below are labels, not inputs. #}
<section class="panel">
<h2>{{ nav.tr("td-order-heading") }}</h2>
<p class="hint">{{ nav.tr("td-order-hint") }}</p>
<ol class="column-order">
{% for column in detail.columns %}
<li>
<code>{{ column.name }}</code>
<form hx-post="/admin/tables/presentation/order"
hx-target="#table-panel" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<input type="hidden" name="expected_row_version" value="{{ detail.row_version }}">
<input type="hidden" name="column_id" value="{{ column.column_id }}">
<button type="submit" name="direction" value="up" {% if loop.first %}disabled{% endif %}></button>
<button type="submit" name="direction" value="down" {% if loop.last %}disabled{% endif %}></button>
</form>
</li>
{% endfor %}
</ol>
</section> </section>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@@ -23,6 +23,11 @@
<small>{{ nav.tr("export-comma-hint") }}</small> <small>{{ nav.tr("export-comma-hint") }}</small>
</label> </label>
</div> </div>
<label class="check">
<input type="checkbox" name="include_system_columns" value="true">
{{ nav.tr("export-include-system") }}
</label>
<small>{{ nav.tr("export-include-system-hint") }}</small>
<datalist id="export-tables"> <datalist id="export-tables">
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}<option value="{{ table }}"></option>{% endfor %}{% endfor -%} {%- for profile in page.catalog.profiles %}{% for table in profile.tables %}<option value="{{ table }}"></option>{% endfor %}{% endfor -%}
</datalist> </datalist>