new form page row + import page dialog fix

This commit is contained in:
Filipriec
2026-08-22 11:10:45 +02:00
parent adaa2c4ac6
commit 2d850b12ca
11 changed files with 278 additions and 31 deletions

View File

@@ -619,6 +619,7 @@ import-progress-minutes = { $minutes } m { $seconds } s
import-progress-unknown-yet = —
import-progress-hint = Import běží na serveru a pokračuje, i když tuto stránku zavřete. Tato karta se aktualizuje každou sekundu.
import-progress-gone = Tento import se už nesleduje. Buď skončil před více než pěti minutami, nebo byl spuštěn v jiné relaci.
import-progress-done-close = Zavřít
import-err-already-running = Jeden import už běží. Počkejte, až skončí, a teprve potom spusťte další.
# --- Krok 2: mapování ------------------------------------------------------

View File

@@ -607,6 +607,7 @@ import-progress-minutes = { $minutes } m { $seconds } s
import-progress-unknown-yet = —
import-progress-hint = The import runs on the server and keeps going even if this page is closed. This card updates every second.
import-progress-gone = This import is no longer being tracked. It either finished more than five minutes ago or was started in another session.
import-progress-done-close = Close
import-err-already-running = An import is already running. Wait for it to finish before starting another one.
# --- Step 2: mapping -------------------------------------------------------

View File

@@ -617,6 +617,7 @@ import-progress-minutes = { $minutes } m { $seconds } s
import-progress-unknown-yet = —
import-progress-hint = Import beží na serveri a pokračuje, aj keď túto stránku zavriete. Táto karta sa aktualizuje každú sekundu.
import-progress-gone = Tento import sa už nesleduje. Buď skončil pred viac ako piatimi minútami, alebo bol spustený v inej relácii.
import-progress-done-close = Zavrieť
import-err-already-running = Jeden import už beží. Počkajte, kým skončí, a až potom spustite ďalší.
# --- Krok 2: mapovanie -----------------------------------------------------

View File

@@ -295,10 +295,10 @@ pub(crate) async fn import_progress(
// is nothing to report, and the alert says so rather than the card polling
// a job that will never answer.
let Some(snapshot) = state.imports.snapshot(&id, session) else {
return reject(&headers, tr!(locale, "import-progress-gone"));
return no_store(reject(&headers, tr!(locale, "import-progress-gone")));
};
match &snapshot.outcome {
let response = match &snapshot.outcome {
None => Html(ui::render_progress(locale, &id, &snapshot)).into_response(),
Some(Outcome::Succeeded) => Html(ui::render_success(
locale,
@@ -336,7 +336,20 @@ pub(crate) async fn import_progress(
Html(String::new()),
)
.into_response(),
}
};
no_store(response)
}
/// A browser or an intermediary caching this poll's answer is exactly how "the
/// backend finished" and "what the page shows" come apart: the dialog's whole
/// point is that every second's answer is the server's current answer, never
/// a replay of an earlier one.
fn no_store(mut response: Response) -> Response {
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("no-store"),
);
response
}
/// One spawned import: what the walk through the prepared rows needs, and

View File

@@ -152,23 +152,35 @@ pub(crate) fn render_import_failure(
))
}
/// The confirmation a completed import answers with, once the progress dialog
/// stops polling. A blocking dialog rather than `Alert::success`'s toast: the
/// toast dismisses itself on a timer, which is the same "did this actually
/// finish?" doubt the progress dialog exists to remove.
#[derive(Template)]
#[template(path = "pages/import_export/import/success_dialog.html")]
struct ImportSuccessDialog<'a> {
locale: Locale,
title: &'a str,
message: &'a str,
}
pub(crate) fn render_success(
locale: Locale,
inserted: usize,
prepared_rows: usize,
table_name: &str,
) -> String {
render(&Alert::success(
render(&ImportSuccessDialog {
locale,
&tr!(locale, "import-success-title"),
&tr!(
title: &tr!(locale, "import-success-title"),
message: &tr!(
locale,
"import-success-message",
"inserted" => inserted as i64,
"source_rows" => prepared_rows as i64,
"table" => table_name.to_string(),
),
))
})
}
#[cfg(test)]
@@ -414,6 +426,14 @@ mod tests {
// 500 rows in 40 seconds, so the 1500 left are about two minutes away.
assert!(html.contains("13 rows/s"), "{html}");
assert!(html.contains("1 m 56 s"), "{html}");
// A running import blocks the page rather than sitting as an inline
// card the user can miss or click past.
assert!(html.contains(r#"role="dialog""#), "{html}");
assert!(html.contains("aria-modal=\"true\""), "{html}");
// Unlike ui/dialog.html's dismissible dialogs, this one has no escape
// hatch: clicking Import again would send the file a second time.
assert!(!html.contains("keydown.esc"), "{html}");
assert!(!html.contains("click.self"), "{html}");
}
/// A rate needs rows and time behind it. Before there are either, the card
@@ -460,4 +480,21 @@ mod tests {
assert!(html.contains("Nothing was imported"), "{html}");
assert!(!html.contains("already imported remain"), "{html}");
}
/// The card the progress dialog polls into once the import is done: a
/// dialog the user has to actively dismiss, not a toast on its own timer.
#[test]
fn a_finished_import_answers_with_a_dismissible_dialog_not_a_toast() {
let html = render_success(Locale::English, 500, 500, "customers");
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains(r#"role="dialog""#), "{html}");
assert!(html.contains("Import complete"), "{html}");
assert!(html.contains("500"), "{html}");
assert!(html.contains("customers"), "{html}");
// The toast fires-and-forgets via a window event; this answers with
// the dialog itself instead.
assert!(!html.contains("$dispatch('notify'"), "{html}");
assert!(html.contains("keydown.esc"), "{html}");
}
}

View File

@@ -3,31 +3,48 @@
The card carries its own poll trigger, the way admin/ecb's status card does:
while the import is running each answer is another card with the trigger
still on it, and the answer that ends the import is the success or failure
alert instead — which has no trigger, and that is what stops the polling.
still on it, and the answer that ends the import is the success dialog or
failure alert instead — which has no trigger, and that is what stops the
polling.
It is also a blocking dialog rather than an inline card: an import that is
actually done answers a stale-looking page if the tab is left alone, and a
second click on Import is how a duplicate row gets written. Pinning the
modal over the page for as long as the job runs makes "click it again" not
an option, and there is deliberately no way to dismiss it early — Escape and
a backdrop click do nothing here, unlike the dialogs in ui/dialog.html.
#}
{#
The card sits inside #submission-status, which is a polite live region: left
alone, a screen reader would read the whole card out again every second. It
silences itself instead, and the alert that replaces it when the import ends
— which is the part worth hearing — is announced by the region as usual.
#}
<div id="import-progress" class="import-progress" aria-live="off"
<div id="import-progress"
hx-get="{{ poll_url }}" hx-trigger="every 1s"
hx-target="#import-progress" hx-swap="outerHTML">
<h3>{{ heading }}</h3>
<div class="progress-line">
<progress max="100" value="{{ percent }}" aria-label="{{ label }}"></progress>
<span class="progress-percent">{{ percent }}%</span>
</div>
<p class="progress-rows"><strong>{{ rows }}</strong></p>
<dl class="progress-stats">
{% for stat in stats %}
<div>
<dt>{{ stat.label }}</dt>
<dd>{{ stat.value }}</dd>
hx-target="#import-progress" hx-swap="outerHTML"
x-data="{ importProgressOpen: true }">
<div x-cloak x-show="importProgressOpen" x-trap.inert.noscroll="importProgressOpen"
class="fixed inset-0 z-30 flex items-end justify-center bg-black/20 p-4 pb-8 backdrop-blur-md sm:items-center lg:p-8"
role="dialog" aria-modal="true" aria-labelledby="import-progress-heading">
<div class="flex w-full max-w-lg flex-col overflow-hidden rounded-radius border border-outline bg-surface text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark">
{#
The card sits inside a polite live region: left alone, a screen reader
would read the whole card out again every second. It silences itself
instead, and the dialog or alert that replaces it when the import ends
— which is the part worth hearing — is announced as usual.
#}
<div class="import-progress" aria-live="off">
<h3 id="import-progress-heading">{{ heading }}</h3>
<div class="progress-line">
<progress max="100" value="{{ percent }}" aria-label="{{ label }}"></progress>
<span class="progress-percent">{{ percent }}%</span>
</div>
<p class="progress-rows"><strong>{{ rows }}</strong></p>
<dl class="progress-stats">
{% for stat in stats %}
<div>
<dt>{{ stat.label }}</dt>
<dd>{{ stat.value }}</dd>
</div>
{% endfor %}
</dl>
<p class="hint">{{ hint }}</p>
</div>
</div>
{% endfor %}
</dl>
<p class="hint">{{ hint }}</p>
</div>
</div>

View File

@@ -0,0 +1,10 @@
{#
The import's success confirmation — crate::pages::import_export::import::ui::render_success.
A blocking dialog rather than the toast every other successful form uses:
the user has been staring at the progress dialog this replaces, and a toast
that shows itself and times out on its own schedule is exactly the "did it
actually finish?" ambiguity that motivated the dialog in the first place.
#}
{% import "ui/dialog.html" as dialog %}
{% call dialog::success(locale, title, message) %}{% endcall %}

View File

@@ -50,3 +50,49 @@
</div>
</div>
{% endmacro %}
{#
Blocking confirmation modal — Penguin UI's success modal, taken from
penguinui-components/modal/modal-alerts.html.
Used where a toast's own timer would leave the "did it actually finish?"
question open — a long-running import in particular, where the user has been
staring at a progress dialog and needs the same dialog to tell them it is
done, not a notification that can appear and vanish while they are looking
at the wrong corner of the screen. Dismissed the same three ways as
dialog::error: the close button, Escape, or a click on the backdrop.
{% import "ui/dialog.html" as dialog %}
{% call dialog::success(locale, "Import complete", message) %}{% endcall %}
#}
{% macro success(locale, title, message) %}
<div x-data="{ successModalIsOpen: true }">
<div x-cloak x-show="successModalIsOpen" x-transition.opacity.duration.200ms x-trap.inert.noscroll="successModalIsOpen" x-on:keydown.esc.window="successModalIsOpen = false" x-on:click.self="successModalIsOpen = false" class="fixed inset-0 z-30 flex items-end justify-center bg-black/20 p-4 pb-8 backdrop-blur-md sm:items-center lg:p-8" role="dialog" aria-modal="true" aria-labelledby="successModalTitle">
<!-- Modal Dialog -->
<div x-show="successModalIsOpen" x-transition:enter="transition ease-out duration-200 delay-100 motion-reduce:transition-opacity" x-transition:enter-start="opacity-0 scale-50" x-transition:enter-end="opacity-100 scale-100" class="flex max-w-lg flex-col gap-4 overflow-hidden rounded-radius border border-outline bg-surface text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark">
<!-- Dialog Header -->
<div class="flex items-center justify-between border-b border-outline bg-surface-alt/60 px-4 py-2 dark:border-outline-dark dark:bg-surface-dark/20">
<div class="flex items-center justify-center rounded-full bg-success/20 text-success p-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
</svg>
</div>
<button x-on:click="successModalIsOpen = false" aria-label="{{ locale.lookup("ui-close-dialog") }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor" fill="none" stroke-width="1.4" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Dialog Body -->
<div class="px-4 text-center">
<h3 id="successModalTitle" class="mb-2 font-semibold tracking-wide text-on-surface-strong dark:text-on-surface-dark-strong">{{ title }}</h3>
<p class="whitespace-pre-wrap">{{ message }}</p>
</div>
<!-- Dialog Footer -->
<div class="flex items-center justify-center border-outline p-4 dark:border-outline-dark">
<button x-on:click="successModalIsOpen = false" type="button" class="w-full whitespace-nowrap rounded-radius border border-success bg-success px-4 py-2 text-center text-sm font-semibold tracking-wide text-on-success transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-success active:opacity-100 active:outline-offset-0">{{ locale.lookup("import-progress-done-close") }}</button>
</div>
</div>
</div>
</div>
{% endmacro %}