aliasing now move column position also

This commit is contained in:
Priec
2026-08-14 21:23:14 +02:00
parent 5792a1f533
commit c3f5046c85
17 changed files with 164 additions and 86 deletions

View File

@@ -160,8 +160,8 @@ response is consumed.
(table, old/new column names, timestamp); only those four fields are rendered.
- **`TableDefinition.AddTableColumns`** — called by
`POST /admin/table-definition/columns`. Adds columns to an existing table.
- **`TableDefinition.RenameColumnAlias`** — called by
`POST /admin/table-definition/rename`. Renames a table column alias.
- **`TableDefinition.SetColumnPresentation`** — called by
`POST /admin/tables/presentation`. Atomically changes column aliases and order.
- **`TableDefinition.DeleteTable`** — called by
`POST /admin/table-definition/delete`. Deletes a table definition.
- **`TableDefinition.CopyProfile`** — called by

View File

@@ -323,7 +323,7 @@ mod tests {
for path in [
"/admin/tables/columns",
"/admin/tables/columns/builder",
"/admin/tables/rename",
"/admin/tables/presentation",
"/admin/tables/delete",
"/admin/profiles/copy",
"/admin/tables/from-template",

View File

@@ -200,6 +200,7 @@ pub(crate) async fn load_page(
.map(|column| {
let behavior = table.column_behaviors.get(&column.name);
DetailColumn {
column_id: behavior.map(|behavior| behavior.column_id).unwrap_or_default(),
name: column.name.clone(),
sql_type: catalog.sql_type(&column.field_type),
field_type: column.field_type.clone(),
@@ -279,7 +280,6 @@ pub(crate) async fn load_page(
history,
selection: inputs.selection,
columns: inputs.columns,
rename: inputs.rename,
copy: inputs.copy,
invoice: inputs.invoice,
status: inputs.status,

View File

@@ -23,7 +23,7 @@ use crate::{
AppState,
definitions::table_definition::{
AddTableColumnsRequest, CopyProfileRequest, CreateInvoiceTemplateTableRequest,
DeleteTableRequest, RenameColumnAliasRequest,
ColumnPresentation, DeleteTableRequest, SetColumnPresentationRequest,
},
{i18n::Locale, tr},
schema::{ColumnForm, proto_columns},
@@ -34,7 +34,7 @@ use super::{
loader::{self, load_page},
state::{
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
RenameForm, Selection, TableDefinitionPageState,
PresentationForm, Selection, TableDefinitionPageState,
},
ui,
};
@@ -303,11 +303,11 @@ pub(crate) async fn add_columns(
}
}
/// POST /admin/tables/rename — RenameColumnAlias.
pub(crate) async fn rename_column(
/// POST /admin/tables/presentation — SetColumnPresentation.
pub(crate) async fn set_column_presentation(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<RenameForm>,
Form(form): Form<PresentationForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
@@ -317,9 +317,12 @@ pub(crate) async fn rename_column(
profile: form.profile.clone(),
table: form.table.clone(),
});
inputs.rename = form.clone();
inputs.presentation = form.clone();
if form.old_column_name.is_empty() || form.new_column_name.trim().is_empty() {
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"
@@ -334,21 +337,42 @@ pub(crate) async fn rename_column(
.await;
}
let request = RenameColumnAliasRequest {
let mut columns = form
.column_ids
.iter()
.copied()
.zip(form.aliases.iter())
.map(|(column_id, alias)| ColumnPresentation {
column_id,
alias: alias.trim().to_string(),
})
.collect::<Vec<_>>();
if let Some((direction, index)) = form.action.split_once(':') {
if let Ok(index) = index.parse::<usize>() {
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);
}
}
}
let request = SetColumnPresentationRequest {
profile_name: form.profile.clone(),
table_name: form.table.clone(),
old_column_name: form.old_column_name.clone(),
new_column_name: form.new_column_name.trim().to_string(),
columns,
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.rename_column_alias(request).await {
match definitions.set_column_presentation(request).await {
Ok(response) if response.get_ref().success => {
inputs.status = Some(response.into_inner().message);
inputs.rename = RenameForm {
inputs.presentation = PresentationForm {
profile: form.profile,
table: form.table,
..Default::default()

View File

@@ -36,7 +36,7 @@ pub(crate) fn router() -> Router<AppState> {
"/admin/tables/columns/builder",
post(logic::update_columns),
)
.route("/admin/tables/rename", post(logic::rename_column))
.route("/admin/tables/presentation", post(logic::set_column_presentation))
.route("/admin/tables/delete", get(logic::delete_page))
.route("/admin/tables/delete", post(logic::delete_table))
// Profile-scoped.

View File

@@ -89,6 +89,7 @@ impl TableDetailView {
/// Columns a rename may target. Provenance and renameability are separate:
/// accounting companions remain renameable while protected generated
/// columns do not.
#[cfg(test)]
pub(crate) fn renameable_columns(&self) -> Vec<&DetailColumn> {
self.columns
.iter()
@@ -99,6 +100,7 @@ impl TableDetailView {
#[derive(Clone, Debug)]
pub(crate) struct DetailColumn {
pub column_id: i64,
pub name: String,
pub field_type: String,
/// The PostgreSQL type the column is stored as, from the column-type
@@ -173,15 +175,17 @@ pub(crate) struct GeneratedTableView {
/// The rename panel's inputs, kept across a failed submit so the user does not
/// retype them.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct RenameForm {
pub(crate) struct PresentationForm {
#[serde(default)]
pub profile: String,
#[serde(default)]
pub table: String,
#[serde(default)]
pub old_column_name: String,
pub column_ids: Vec<i64>,
#[serde(default)]
pub new_column_name: String,
pub aliases: Vec<String>,
#[serde(default)]
pub action: String,
}
/// The copy-profile panel. An empty `table_names` copies the whole profile,
@@ -241,7 +245,7 @@ pub(crate) struct PageInputs {
pub selection: Selection,
/// The columns staged for `AddTableColumns`.
pub columns: ColumnDraft,
pub rename: RenameForm,
pub presentation: PresentationForm,
pub copy: CopyForm,
pub invoice: InvoiceTemplateForm,
pub status: Option<String>,
@@ -278,7 +282,6 @@ pub(crate) struct TableDefinitionPageState {
pub detail: Option<TableDetailView>,
pub history: Vec<RenameEntry>,
pub columns: ColumnDraft,
pub rename: RenameForm,
pub copy: CopyForm,
pub invoice: InvoiceTemplateForm,
pub status: Option<String>,
@@ -388,6 +391,7 @@ mod tests {
scripts: Vec::new(),
columns: vec![
DetailColumn {
column_id: 1,
name: "work_phone".to_string(),
field_type: "phone".to_string(),
sql_type: "TEXT".to_string(),
@@ -400,6 +404,7 @@ mod tests {
renameable: true,
},
DetailColumn {
column_id: 2,
name: "work_phone_country".to_string(),
field_type: "phone_country".to_string(),
sql_type: "TEXT".to_string(),
@@ -412,6 +417,7 @@ mod tests {
renameable: false,
},
DetailColumn {
column_id: 3,
name: "charge".to_string(),
field_type: "money".to_string(),
sql_type: "NUMERIC".to_string(),

View File

@@ -211,7 +211,7 @@ mod tests {
use super::*;
use crate::{
pages::admin::table_definition::state::{
CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView,
CopyForm, DetailColumn, InvoiceTemplateForm, Selection, TableDetailView,
TableSummary,
},
schema::ColumnDraft,
@@ -240,6 +240,7 @@ mod tests {
row_display_columns: vec!["number".to_string()],
scripts: Vec::new(),
columns: vec![DetailColumn {
column_id: 1,
name: "number".to_string(),
field_type: "text".to_string(),
sql_type: "TEXT".to_string(),
@@ -254,7 +255,6 @@ mod tests {
}),
history: Vec::new(),
columns: ColumnDraft::for_append(crate::schema::tests::catalog()),
rename: RenameForm::default(),
copy: CopyForm::default(),
invoice: InvoiceTemplateForm::default(),
status: None,
@@ -307,7 +307,7 @@ mod tests {
assert!(html.contains("/admin/tables/delete"));
assert!(html.contains("Type <code>invoice</code> to confirm"));
// The other writes are links in the switcher, not forms on the page.
assert!(!html.contains("/admin/tables/rename"));
assert!(!html.contains("/admin/tables/presentation"));
assert!(!html.contains("/admin/profiles/copy?profile=billing\" method"));
}
@@ -368,7 +368,17 @@ mod tests {
assert!(!html.contains(r#"name="confirm_table_name""#));
let html = render_columns_page(&state);
assert!(!html.contains("/admin/tables/rename"));
assert!(!html.contains("/admin/tables/presentation"));
}
#[test]
fn column_presentation_posts_stable_ids_aliases_and_order_controls() {
let html = render_columns_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#"name="aliases" value="number""#), "{html}");
assert!(html.contains(r#"name="action" value="save""#), "{html}");
}
/// The profile-wide pages need only a profile, and say so by still

View File

@@ -28,25 +28,24 @@
<section class="panel">
<h2>{{ nav.tr("td-rename-column") }}</h2>
<p class="hint">{{ nav.tr("td-rename-hint") }}</p>
<form hx-post="/admin/tables/rename"
<form hx-post="/admin/tables/presentation"
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 }}">
<div class="form-grid">
<label>{{ nav.tr("td-column-label") }}
<select name="old_column_name">
<option value="">{{ nav.tr("td-choose-column") }}</option>
{% for column in detail.renameable_columns() %}
<option value="{{ column.name }}" {% if page.rename.old_column_name == column.name %}selected{% endif %}>{{ column.name }}</option>
{% endfor %}
</select>
</label>
<label>{{ nav.tr("td-new-name") }}
<input name="new_column_name" value="{{ page.rename.new_column_name }}" placeholder="invoice_number">
</label>
{% for column in detail.columns %}
<input type="hidden" name="column_ids" value="{{ column.column_id }}">
<label>{{ nav.tr("td-column-label") }}
<input name="aliases" value="{{ column.name }}" {% if !column.renameable %}readonly{% endif %}>
</label>
<div class="form-actions">
<button type="submit" name="action" value="up:{{ loop.index0 }}" {% if loop.first %}disabled{% endif %}></button>
<button type="submit" name="action" value="down:{{ loop.index0 }}" {% if loop.last %}disabled{% endif %}></button>
</div>
{% endfor %}
</div>
<div class="form-actions">
<button type="submit">{{ nav.tr("td-rename-button") }}</button>
<button type="submit" name="action" value="save">{{ nav.tr("td-rename-button") }}</button>
</div>
</form>
</section>