diff --git a/client b/client index bfe8d72e..faeca44d 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit bfe8d72e32be69ad5a3358397d9418939fbbba10 +Subproject commit faeca44d40d0bdd5b3688fbd3d6871c2a3ff61f3 diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index cae3a33d..dc297479 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -167,11 +167,18 @@ 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-current-alias = Současný alias td-new-name = Nový název 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-order-hint = Přetáhněte sloupce na místo a uložte je, až vám bude pořadí vyhovovat. +td-drag-column = Přetažením změnit pořadí +td-order-clean = Žádné neuložené změny pořadí +td-order-unsaved = Neuložené změny pořadí +td-order-reset = Vrátit změny +td-order-save = Uložit pořadí +td-err-invalid-order = Pořadí sloupců se změnilo nebo není úplné. Načtěte ho znovu a zkuste to znovu. td-columns-now = Současné sloupce td-col-column = Sloupec td-col-type = Typ diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index bd62d2bf..1217e809 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -169,11 +169,18 @@ 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-current-alias = Current alias td-new-name = New name 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-order-hint = Drag columns into place, then save when the order feels right. +td-drag-column = Drag to reorder +td-order-clean = No unsaved order changes +td-order-unsaved = Unsaved order changes +td-order-reset = Revert +td-order-save = Save order +td-err-invalid-order = The column order changed or is incomplete. Reload it and try again. td-columns-now = Columns it has now td-col-column = Column td-col-type = Type diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index 571ef13a..c6208a27 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -167,11 +167,18 @@ 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-current-alias = Súčasný alias td-new-name = Nový názov 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-order-hint = Presuňte stĺpce potiahnutím a uložte ich, keď vám poradie vyhovuje. +td-drag-column = Potiahnutím zmeniť poradie +td-order-clean = Žiadne neuložené zmeny poradia +td-order-unsaved = Neuložené zmeny poradia +td-order-reset = Vrátiť zmeny +td-order-save = Uložiť poradie +td-err-invalid-order = Poradie stĺpcov sa zmenilo alebo nie je úplné. Načítajte ho znova a skúste to opäť. td-columns-now = Súčasné stĺpce td-col-column = Stĺpec td-col-type = Typ diff --git a/web/src/lib.rs b/web/src/lib.rs index c5e6697c..aaf11111 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -459,7 +459,7 @@ mod tests { ), ( "/admin/tables/presentation/order", - "profile=billing&table=invoice&column_id=1&direction=up", + "profile=billing&table=invoice&column_ids=1&column_ids=2", ), ("/admin/tables/columns/add", "profile=billing&table=invoice"), ("/admin/tables/builder", ""), diff --git a/web/src/pages/admin/table_definition/logic.rs b/web/src/pages/admin/table_definition/logic.rs index 0e2253cb..b48002fa 100644 --- a/web/src/pages/admin/table_definition/logic.rs +++ b/web/src/pages/admin/table_definition/logic.rs @@ -375,12 +375,12 @@ pub(crate) async fn set_column_alias( .await } -/// POST /admin/tables/presentation/order — SetColumnPresentation, moving one -/// column past its neighbour. +/// POST /admin/tables/presentation/order — SetColumnPresentation, saving the +/// order staged by the browser. /// /// 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. +/// write cannot rename a column. The ids from the browser must be an exact +/// permutation of the current columns before their order is accepted. pub(crate) async fn set_column_order( State(state): State, headers: HeaderMap, @@ -399,33 +399,38 @@ pub(crate) async fn set_column_order( Ok(columns) => columns, Err(response) => return response, }; - let Some(index) = columns + let current_ids = columns .iter() - .position(|column| column.column_id == form.column_id) - else { - let message = tr!(Locale::from_headers(&headers), "td-err-unknown-column"); + .map(|column| column.column_id) + .collect::>(); + let submitted_ids = form + .column_ids + .iter() + .copied() + .collect::>(); + if form.column_ids.len() != columns.len() + || submitted_ids.len() != form.column_ids.len() + || submitted_ids != current_ids + { + let message = tr!(Locale::from_headers(&headers), "td-err-invalid-order"); 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 + let columns_by_id = columns .iter() - .map(|column| ColumnPresentation { - column_id: column.column_id, - alias: column.name.clone(), + .map(|column| (column.column_id, column)) + .collect::>(); + let presentation = form + .column_ids + .iter() + .map(|column_id| { + let column = columns_by_id[column_id]; + ColumnPresentation { + column_id: *column_id, + alias: column.name.clone(), + } }) - .collect::>(); - presentation.swap(index, swap_with); + .collect(); apply_presentation( state, diff --git a/web/src/pages/admin/table_definition/mod.rs b/web/src/pages/admin/table_definition/mod.rs index 58ceaba9..aaa7fe8a 100644 --- a/web/src/pages/admin/table_definition/mod.rs +++ b/web/src/pages/admin/table_definition/mod.rs @@ -38,10 +38,9 @@ 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`. + // Naming a column and ordering the columns are one backend call but + // separate forms, because a request that carries both lets a stale + // alias ride along with an unrelated edit. See `state::AliasForm`. .route("/admin/tables/presentation", get(logic::presentation_page)) .route( "/admin/tables/presentation/alias", diff --git a/web/src/pages/admin/table_definition/state.rs b/web/src/pages/admin/table_definition/state.rs index b383c842..4e6a54b5 100644 --- a/web/src/pages/admin/table_definition/state.rs +++ b/web/src/pages/admin/table_definition/state.rs @@ -201,11 +201,12 @@ pub(crate) struct AliasForm { pub alias: String, } -/// Moving one column past its neighbour. +/// Saving a complete, staged column order. /// -/// 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. +/// It carries ids only and no aliases, so a reorder cannot rename anything, +/// whatever the browser still had on screen. The handler validates that this +/// is an exact permutation of the table's current ids and supplies fresh +/// aliases from the backend. See [`AliasForm`] for why that separation matters. #[derive(Clone, Debug, Default, serde::Deserialize)] pub(crate) struct OrderForm { #[serde(default)] @@ -215,10 +216,7 @@ pub(crate) struct OrderForm { #[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, + pub column_ids: Vec, } /// The copy-profile panel. An empty `table_names` copies the whole profile, diff --git a/web/src/pages/admin/table_definition/ui.rs b/web/src/pages/admin/table_definition/ui.rs index feda7992..0c5452b0 100644 --- a/web/src/pages/admin/table_definition/ui.rs +++ b/web/src/pages/admin/table_definition/ui.rs @@ -412,10 +412,9 @@ mod tests { 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. + /// Renaming and reordering are separate forms. An alias is posted with its + /// column id, while the staged order posts ids only, so saving one cannot + /// carry stale values from the other. #[test] fn renaming_and_reordering_are_separate_forms() { let html = render_presentation_page(&page()); @@ -430,13 +429,14 @@ mod tests { ); 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="alias" value="number""#), "{html}"); - assert!(html.contains(r#"name="direction" value="up""#), "{html}"); - assert!(html.contains(r#"name="direction" value="down""#), "{html}"); + assert!(html.contains(r#"name="alias" aria-label="New name: number""#), "{html}"); + assert!(!html.contains(r#"name="alias" value="number""#), "{html}"); + assert!(html.contains(r#"name="column_ids" value="1""#), "{html}"); + assert!(html.contains(r#"data-order-reset disabled"#), "{html}"); + assert!(html.contains(r#"data-order-save disabled"#), "{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}"); + // The old fused form is gone: the order form carries no positionally + // paired alias list and no action that can mix the two operations. assert!(!html.contains(r#"name="aliases""#), "{html}"); assert!(!html.contains(r#"name="action""#), "{html}"); diff --git a/web/static/app.css b/web/static/app.css index dd27c756..485871d2 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -220,6 +220,41 @@ .panel .count { margin-left: 6px; padding: 1px 7px; border-radius: 9px; font-size: 12px; color: #4b5563; background: #eef1f6; } .panel > button.secondary { margin-top: 16px; padding: 9px 16px; border: 1px solid #c9d2de; border-radius: 6px; color: #24324a; background: #f4f6fa; cursor: pointer; } .panel > button.secondary:hover { background: #e9edf4; } + .presentation-layout { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(320px, .9fr); align-items: start; gap: 16px; } + .presentation-panel > .hint { min-height: 40px; margin: -7px 0 15px; } + .alias-list { overflow: hidden; border: 1px solid #e2e7ee; border-radius: 8px; background: white; } + .alias-list-head, .alias-row { display: grid; grid-template-columns: minmax(90px, .8fr) 20px minmax(120px, 1.2fr) auto; align-items: center; gap: 8px; } + .alias-list-head { padding: 8px 10px 6px; color: #7b8797; background: #f7f9fb; font-size: 10px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; } + .alias-row { margin: 0; padding: 8px 10px; border-top: 1px solid #e8ecf1; } + .alias-current { min-width: 0; overflow: hidden; color: #33415c; font-size: 13px; text-overflow: ellipsis; } + .alias-arrow { color: #9aa5b4; text-align: center; } + .alias-row > input { min-width: 0; color: #24324a; font-size: 13px; font-weight: 500; } + .alias-row > input:focus { border-color: #6ea1ed; outline: 3px solid rgb(37 99 235 / 12%); } + .alias-row > button { padding: 9px 14px; border: 1px solid #b9cdf0; border-radius: 6px; color: #1d4ed8; background: #eef4ff; font-weight: 600; cursor: pointer; } + .alias-row > button:hover { border-color: #8eb1eb; background: #deebff; } + .alias-row > button:focus-visible { outline: 3px solid rgb(37 99 235 / 18%); outline-offset: 2px; } + .alias-row-locked { background: #fafbfc; } + .alias-row-locked .locked-label { grid-column: 3 / -1; } + .locked-label { color: #8a94a4; font-size: 12px; } + .column-order { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } + .column-order li { display: grid; grid-template-columns: 32px 30px minmax(0, 1fr); align-items: center; gap: 8px; min-height: 52px; padding: 8px 12px 8px 8px; border: 1px solid #e2e7ee; border-radius: 8px; background: #fafbfc; transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease, opacity 120ms ease; } + .column-order li:hover { border-color: #b8cae3; box-shadow: 0 3px 10px rgb(31 43 58 / 7%); } + .column-order li.is-dragging { z-index: 1; border-color: #7fa5df; box-shadow: 0 9px 22px rgb(31 43 58 / 18%); opacity: .82; transform: scale(1.015); } + .drag-handle { display: grid; place-items: center; width: 32px; height: 34px; padding: 0; border: 0; border-radius: 6px; color: #8b97a8; background: transparent; font-size: 22px; line-height: 1; cursor: grab; touch-action: none; user-select: none; } + .drag-handle:hover { color: #315f9d; background: #e9f0fb; } + .drag-handle:focus-visible { color: #1d4ed8; outline: 3px solid rgb(37 99 235 / 18%); } + .drag-handle:active { cursor: grabbing; } + .order-position { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; color: #64748b; background: #e9eef5; font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; } + .order-name { min-width: 0; overflow: hidden; color: #33415c; font-size: 13px; text-overflow: ellipsis; } + .order-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 8px; margin-top: 14px; padding-top: 14px; border-top: 1px solid #e5e9ef; } + .order-status { color: #788495; font-size: 12px; } + .order-editor.is-dirty .order-status { color: #9a5a08; font-weight: 600; } + .order-actions button { margin: 0; padding: 8px 13px; border: 0; border-radius: 6px; color: white; background: #2563eb; font-weight: 600; cursor: pointer; } + .order-actions button.secondary { border: 1px solid #c9d2de; color: #344054; background: white; } + .order-actions button:hover:not(:disabled) { background: #1d4ed8; } + .order-actions button.secondary:hover:not(:disabled) { background: #eef2f6; } + .order-actions button:focus-visible { outline: 3px solid rgb(37 99 235 / 18%); outline-offset: 2px; } + .order-actions button:disabled { border-color: #e1e6ed; color: #adb6c3; background: #f3f5f7; cursor: default; } .sql-preview { margin: 12px 0 0; padding: 12px; max-height: 260px; overflow: auto; border: 1px solid #e4e7ec; border-radius: 8px; font: 12px/1.5 ui-monospace, monospace; color: #33415c; background: #fafbfc; white-space: pre-wrap; } .danger-panel { border-color: #eccfcf; } .danger-panel h2 { color: #a12b2b; } @@ -340,6 +375,18 @@ .analytics { grid-template-columns: 1fr; } .sidebar { position: static; max-height: none; } .query-row { grid-template-columns: 1fr; } + .presentation-layout { grid-template-columns: 1fr; } + .presentation-panel > .hint { min-height: 0; } + } + + @media (max-width: 520px) { + .alias-list-head { display: none; } + .alias-row { grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1.3fr); } + .alias-row > button { grid-column: 3; width: 100%; } + .alias-row-locked .locked-label { grid-column: 3; } + .order-actions { grid-template-columns: 1fr 1fr; } + .order-status { grid-column: 1 / -1; } + .order-actions button { width: 100%; } } } diff --git a/web/templates/pages/admin/table_definition/presentation.html b/web/templates/pages/admin/table_definition/presentation.html index 574bf6d0..e3183bb7 100644 --- a/web/templates/pages/admin/table_definition/presentation.html +++ b/web/templates/pages/admin/table_definition/presentation.html @@ -3,6 +3,113 @@ {% block title %}{{ nav.tr("td-presentation-title") }}{% endblock %} +{% block head %} + +{% endblock %} + {% block content %}
{% include "pages/admin/table_definition/context.html" %} diff --git a/web/templates/pages/admin/table_definition/presentation_panel.html b/web/templates/pages/admin/table_definition/presentation_panel.html index 392b4a83..f69e48fc 100644 --- a/web/templates/pages/admin/table_definition/presentation_panel.html +++ b/web/templates/pages/admin/table_definition/presentation_panel.html @@ -5,54 +5,76 @@ {% 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 %} {% if column.renameable %} -
-
- -
- -
-
+ {{ column.name }} + + +
{% else %} -

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

+
+ {{ 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. #} -
+ {# Ordering is staged in the browser and submitted as one id-only form. + Aliases are still absent, so saving an order cannot rename anything. #} +

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

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

-
    - {% for column in detail.columns %} -
  1. - {{ column.name }} -
    - - - - - - -
    -
  2. - {% endfor %} -
+
+ + + +
    + {% for column in detail.columns %} +
  1. + + + + {{ column.name }} +
  2. + {% endfor %} +
+
+ {{ nav.tr("td-order-clean") }} + + +
+
+
{% endif %} {% endif %}