better UX/UI

This commit is contained in:
Priec
2026-08-17 21:35:59 +02:00
parent e13447e3d6
commit 9b2b429dc6
12 changed files with 282 additions and 83 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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", ""),

View File

@@ -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<AppState>,
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::<std::collections::HashSet<_>>();
let submitted_ids = form
.column_ids
.iter()
.copied()
.collect::<std::collections::HashSet<_>>();
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::<std::collections::HashMap<_, _>>();
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::<Vec<_>>();
presentation.swap(index, swap_with);
.collect();
apply_presentation(
state,

View File

@@ -38,10 +38,9 @@ pub(crate) fn router() -> Router<AppState> {
"/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",

View File

@@ -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<i64>,
}
/// The copy-profile panel. An empty `table_names` copies the whole profile,

View File

@@ -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}");

View File

@@ -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%; }
}
}

View File

@@ -3,6 +3,113 @@
{% block title %}{{ nav.tr("td-presentation-title") }}{% endblock %}
{% block head %}
<script>
document.addEventListener("DOMContentLoaded", function () {
let activeDrag = null;
function rows(editor) {
return Array.from(editor.querySelectorAll("[data-order-item]"));
}
function ids(editor) {
return rows(editor).map(function (row) { return row.dataset.columnId; });
}
function refresh(editor) {
rows(editor).forEach(function (row, index) {
row.querySelector(".order-position").textContent = index + 1;
});
const dirty = ids(editor).join(",") !== editor.dataset.originalOrder;
editor.classList.toggle("is-dirty", dirty);
editor.querySelector("[data-order-status]").textContent = dirty
? editor.dataset.dirtyText
: editor.dataset.cleanText;
editor.querySelector("[data-order-reset]").disabled = !dirty;
editor.querySelector("[data-order-save]").disabled = !dirty;
}
function initialise(root) {
root.querySelectorAll("[data-order-editor]").forEach(function (editor) {
editor.dataset.originalOrder = ids(editor).join(",");
refresh(editor);
});
}
function finishDrag(event) {
if (!activeDrag || (event.pointerId !== undefined && event.pointerId !== activeDrag.pointerId)) return;
activeDrag.row.classList.remove("is-dragging");
if (activeDrag.handle.hasPointerCapture(activeDrag.pointerId)) {
activeDrag.handle.releasePointerCapture(activeDrag.pointerId);
}
refresh(activeDrag.editor);
activeDrag = null;
}
document.addEventListener("pointerdown", function (event) {
const handle = event.target.closest("[data-drag-handle]");
if (!handle || (event.button !== undefined && event.button !== 0)) return;
const row = handle.closest("[data-order-item]");
activeDrag = {
editor: handle.closest("[data-order-editor]"),
handle: handle,
list: row.parentElement,
pointerId: event.pointerId,
row: row
};
handle.setPointerCapture(event.pointerId);
row.classList.add("is-dragging");
event.preventDefault();
});
document.addEventListener("pointermove", function (event) {
if (!activeDrag || event.pointerId !== activeDrag.pointerId) return;
const hovered = document.elementFromPoint(event.clientX, event.clientY);
const target = hovered && hovered.closest("[data-order-item]");
if (!target || target === activeDrag.row || target.parentElement !== activeDrag.list) return;
const before = event.clientY < target.getBoundingClientRect().top + target.offsetHeight / 2;
activeDrag.list.insertBefore(activeDrag.row, before ? target : target.nextElementSibling);
refresh(activeDrag.editor);
});
document.addEventListener("pointerup", finishDrag);
document.addEventListener("pointercancel", finishDrag);
document.addEventListener("keydown", function (event) {
const handle = event.target.closest("[data-drag-handle]");
if (!handle || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
const row = handle.closest("[data-order-item]");
const sibling = event.key === "ArrowUp" ? row.previousElementSibling : row.nextElementSibling;
if (!sibling) return;
if (event.key === "ArrowUp") row.parentElement.insertBefore(row, sibling);
else row.parentElement.insertBefore(sibling, row);
refresh(handle.closest("[data-order-editor]"));
event.preventDefault();
});
document.addEventListener("click", function (event) {
const reset = event.target.closest("[data-order-reset]");
if (!reset) return;
const editor = reset.closest("[data-order-editor]");
const byId = new Map(rows(editor).map(function (row) { return [row.dataset.columnId, row]; }));
const list = editor.querySelector("[data-order-list]");
editor.dataset.originalOrder.split(",").forEach(function (id) { list.appendChild(byId.get(id)); });
refresh(editor);
});
initialise(document);
document.body.addEventListener("htmx:afterSwap", function (event) {
initialise(event.detail.target);
});
});
</script>
{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}

View File

@@ -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. #}
<section class="panel">
<div class="presentation-layout">
<section class="panel presentation-panel">
<h2>{{ nav.tr("td-rename-column") }}</h2>
<p class="hint">{{ nav.tr("td-rename-hint") }}</p>
<div class="alias-list">
<div class="alias-list-head" aria-hidden="true">
<span>{{ nav.tr("td-current-alias") }}</span>
<span></span>
<span>{{ nav.tr("td-new-name") }}</span>
<span></span>
</div>
{% for column in detail.columns %}
{% if column.renameable %}
<form hx-post="/admin/tables/presentation/alias"
<form class="alias-row" hx-post="/admin/tables/presentation/alias"
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 }}">
<div class="form-grid">
<label>{{ nav.tr("td-column-label") }}
<input name="alias" value="{{ column.name }}">
</label>
<div class="form-actions">
<button type="submit">{{ nav.tr("td-rename-button") }}</button>
</div>
</div>
<code class="alias-current">{{ column.name }}</code>
<span class="alias-arrow" aria-hidden="true"></span>
<input name="alias" aria-label="{{ nav.tr("td-new-name") }}: {{ column.name }}"
placeholder="{{ nav.tr("td-new-name") }}" required>
<button type="submit">{{ nav.tr("td-rename-button") }}</button>
</form>
{% else %}
<p class="hint"><code>{{ column.name }}</code> — {{ nav.tr("td-alias-locked") }}</p>
<div class="alias-row alias-row-locked">
<code class="alias-current">{{ column.name }}</code>
<span class="locked-label">{{ nav.tr("td-alias-locked") }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</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">
{# Ordering is staged in the browser and submitted as one id-only form.
Aliases are still absent, so saving an order cannot rename anything. #}
<section class="panel presentation-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>
<form class="order-editor" data-order-editor
data-clean-text="{{ nav.tr("td-order-clean") }}"
data-dirty-text="{{ nav.tr("td-order-unsaved") }}"
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 }}">
<ol class="column-order" data-order-list>
{% for column in detail.columns %}
<li data-order-item data-column-id="{{ column.column_id }}">
<input type="hidden" name="column_ids" value="{{ column.column_id }}">
<button class="drag-handle" type="button" data-drag-handle
aria-label="{{ nav.tr("td-drag-column") }}: {{ column.name }}"
title="{{ nav.tr("td-drag-column") }}">
<span aria-hidden="true"></span>
</button>
<span class="order-position" aria-hidden="true">{{ loop.index }}</span>
<code class="order-name">{{ column.name }}</code>
</li>
{% endfor %}
</ol>
<div class="order-actions">
<span class="order-status" data-order-status aria-live="polite">{{ nav.tr("td-order-clean") }}</span>
<button class="secondary" type="button" data-order-reset disabled>{{ nav.tr("td-order-reset") }}</button>
<button type="submit" data-order-save disabled>{{ nav.tr("td-order-save") }}</button>
</div>
</form>
</section>
</div>
{% endif %}
{% endif %}