From 2d850b12ca0778476b9a8258617d674765bfd640 Mon Sep 17 00:00:00 2001 From: Filipriec Date: Sat, 22 Aug 2026 11:10:45 +0200 Subject: [PATCH] new form page row + import page dialog fix --- common/proto/tables_data.proto | 18 +++ common/src/proto/descriptor.bin | Bin 187473 -> 188544 bytes common/src/proto/komp_ac.tables_data.rs | 103 ++++++++++++++++++ web/locales/cs/main.ftl | 1 + web/locales/en/main.ftl | 1 + web/locales/sk/main.ftl | 1 + web/src/pages/import_export/import/logic.rs | 19 +++- web/src/pages/import_export/import/ui.rs | 45 +++++++- .../pages/import_export/import/progress.html | 65 +++++++---- .../import_export/import/success_dialog.html | 10 ++ web/templates/ui/dialog.html | 46 ++++++++ 11 files changed, 278 insertions(+), 31 deletions(-) create mode 100644 web/templates/pages/import_export/import/success_dialog.html diff --git a/common/proto/tables_data.proto b/common/proto/tables_data.proto index 2a0bce6d..c2060b86 100644 --- a/common/proto/tables_data.proto +++ b/common/proto/tables_data.proto @@ -104,6 +104,12 @@ service TablesData { // - If the physical table is missing but the definition exists, returns INTERNAL rpc GetTableDataCount(GetTableDataCountRequest) returns (komp_ac.common.CountResponse); + // Fetch the last non-deleted row by id together with the exact row count. + // This is the efficient form-opening path: unlike GetTableDataByPosition at + // the final position, it does not walk the table through a large OFFSET, and + // it avoids a separate client/server round trip for the count. + rpc GetLastTableData(GetLastTableDataRequest) returns (GetLastTableDataResponse); + // Fetch the N-th non-deleted row by id order (1-based), then return its full data. // // Behavior: @@ -420,6 +426,18 @@ message GetTableDataCountRequest { string table_name = 2; } +// Fetch the last visible row and the exact number of visible rows. +message GetLastTableDataRequest { + string profile_name = 1; + string table_name = 2; +} + +message GetLastTableDataResponse { + int64 total_count = 1; + // Absent when total_count is zero. + GetTableDataResponse row = 2; +} + // Fetch by ordinal position among non-deleted rows (1-based). message GetTableDataByPositionRequest { // Required. Profile (schema) name. diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 3a6f09feaca92937a3b7b3113c8ac480e080a54b..49017f96e5522fef7c4a5865cf8ee9704edb3c2d 100644 GIT binary patch delta 7223 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 Brgs1U delta 6349 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 a, +} /// Fetch by ordinal position among non-deleted rows (1-based). #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct GetTableDataByPositionRequest { @@ -1018,6 +1034,36 @@ pub mod tables_data_client { ); self.inner.unary(req, path, codec).await } + /// Fetch the last non-deleted row by id together with the exact row count. + /// This is the efficient form-opening path: unlike GetTableDataByPosition at + /// the final position, it does not walk the table through a large OFFSET, and + /// it avoids a separate client/server round trip for the count. + pub async fn get_last_table_data( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.tables_data.TablesData/GetLastTableData", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("komp_ac.tables_data.TablesData", "GetLastTableData"), + ); + self.inner.unary(req, path, codec).await + } /// Fetch the N-th non-deleted row by id order (1-based), then return its full data. /// /// Behavior: @@ -1236,6 +1282,17 @@ pub mod tables_data_server { tonic::Response, tonic::Status, >; + /// Fetch the last non-deleted row by id together with the exact row count. + /// This is the efficient form-opening path: unlike GetTableDataByPosition at + /// the final position, it does not walk the table through a large OFFSET, and + /// it avoids a separate client/server round trip for the count. + async fn get_last_table_data( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; /// Fetch the N-th non-deleted row by id order (1-based), then return its full data. /// /// Behavior: @@ -2025,6 +2082,52 @@ pub mod tables_data_server { }; Box::pin(fut) } + "/komp_ac.tables_data.TablesData/GetLastTableData" => { + #[allow(non_camel_case_types)] + struct GetLastTableDataSvc(pub Arc); + impl< + T: TablesData, + > tonic::server::UnaryService + for GetLastTableDataSvc { + type Response = super::GetLastTableDataResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_last_table_data(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetLastTableDataSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/komp_ac.tables_data.TablesData/GetTableDataByPosition" => { #[allow(non_camel_case_types)] struct GetTableDataByPositionSvc(pub Arc); diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index 8d363df2..194e2676 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -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í ------------------------------------------------------ diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index 3a6407cd..8c9f8318 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -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 ------------------------------------------------------- diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index bc81bafb..fc240161 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -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 ----------------------------------------------------- diff --git a/web/src/pages/import_export/import/logic.rs b/web/src/pages/import_export/import/logic.rs index 00ac8e98..6c721553 100644 --- a/web/src/pages/import_export/import/logic.rs +++ b/web/src/pages/import_export/import/logic.rs @@ -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 diff --git a/web/src/pages/import_export/import/ui.rs b/web/src/pages/import_export/import/ui.rs index 559c9f5c..a925ae90 100644 --- a/web/src/pages/import_export/import/ui.rs +++ b/web/src/pages/import_export/import/ui.rs @@ -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}"); + } } diff --git a/web/templates/pages/import_export/import/progress.html b/web/templates/pages/import_export/import/progress.html index ddfa58c1..c97bbe2e 100644 --- a/web/templates/pages/import_export/import/progress.html +++ b/web/templates/pages/import_export/import/progress.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. -#} -
-

{{ heading }}

-
- - {{ percent }}% -
-

{{ rows }}

-
- {% for stat in stats %} -
-
{{ stat.label }}
-
{{ stat.value }}
+ hx-target="#import-progress" hx-swap="outerHTML" + x-data="{ importProgressOpen: true }"> +
-

{{ hint }}

+
diff --git a/web/templates/pages/import_export/import/success_dialog.html b/web/templates/pages/import_export/import/success_dialog.html new file mode 100644 index 00000000..d77b9c65 --- /dev/null +++ b/web/templates/pages/import_export/import/success_dialog.html @@ -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 %} diff --git a/web/templates/ui/dialog.html b/web/templates/ui/dialog.html index c2a5789e..e4aea6e6 100644 --- a/web/templates/ui/dialog.html +++ b/web/templates/ui/dialog.html @@ -50,3 +50,49 @@ {% 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) %} +
+ +
+{% endmacro %}