new form page row + import page dialog fix
This commit is contained in:
@@ -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í ------------------------------------------------------
|
||||
|
||||
@@ -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 -------------------------------------------------------
|
||||
|
||||
@@ -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 -----------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
10
web/templates/pages/import_export/import/success_dialog.html
Normal file
10
web/templates/pages/import_export/import/success_dialog.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user