new form page row + import page dialog fix
This commit is contained in:
@@ -104,6 +104,12 @@ service TablesData {
|
|||||||
// - If the physical table is missing but the definition exists, returns INTERNAL
|
// - If the physical table is missing but the definition exists, returns INTERNAL
|
||||||
rpc GetTableDataCount(GetTableDataCountRequest) returns (komp_ac.common.CountResponse);
|
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.
|
// Fetch the N-th non-deleted row by id order (1-based), then return its full data.
|
||||||
//
|
//
|
||||||
// Behavior:
|
// Behavior:
|
||||||
@@ -420,6 +426,18 @@ message GetTableDataCountRequest {
|
|||||||
string table_name = 2;
|
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).
|
// Fetch by ordinal position among non-deleted rows (1-based).
|
||||||
message GetTableDataByPositionRequest {
|
message GetTableDataByPositionRequest {
|
||||||
// Required. Profile (schema) name.
|
// Required. Profile (schema) name.
|
||||||
|
|||||||
Binary file not shown.
@@ -387,6 +387,22 @@ pub struct GetTableDataCountRequest {
|
|||||||
#[prost(string, tag = "2")]
|
#[prost(string, tag = "2")]
|
||||||
pub table_name: ::prost::alloc::string::String,
|
pub table_name: ::prost::alloc::string::String,
|
||||||
}
|
}
|
||||||
|
/// Fetch the last visible row and the exact number of visible rows.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||||
|
pub struct GetLastTableDataRequest {
|
||||||
|
#[prost(string, tag = "1")]
|
||||||
|
pub profile_name: ::prost::alloc::string::String,
|
||||||
|
#[prost(string, tag = "2")]
|
||||||
|
pub table_name: ::prost::alloc::string::String,
|
||||||
|
}
|
||||||
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
|
pub struct GetLastTableDataResponse {
|
||||||
|
#[prost(int64, tag = "1")]
|
||||||
|
pub total_count: i64,
|
||||||
|
/// Absent when total_count is zero.
|
||||||
|
#[prost(message, optional, tag = "2")]
|
||||||
|
pub row: ::core::option::Option<GetTableDataResponse>,
|
||||||
|
}
|
||||||
/// Fetch by ordinal position among non-deleted rows (1-based).
|
/// Fetch by ordinal position among non-deleted rows (1-based).
|
||||||
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
|
||||||
pub struct GetTableDataByPositionRequest {
|
pub struct GetTableDataByPositionRequest {
|
||||||
@@ -1018,6 +1034,36 @@ pub mod tables_data_client {
|
|||||||
);
|
);
|
||||||
self.inner.unary(req, path, codec).await
|
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<super::GetLastTableDataRequest>,
|
||||||
|
) -> std::result::Result<
|
||||||
|
tonic::Response<super::GetLastTableDataResponse>,
|
||||||
|
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.
|
/// Fetch the N-th non-deleted row by id order (1-based), then return its full data.
|
||||||
///
|
///
|
||||||
/// Behavior:
|
/// Behavior:
|
||||||
@@ -1236,6 +1282,17 @@ pub mod tables_data_server {
|
|||||||
tonic::Response<super::super::common::CountResponse>,
|
tonic::Response<super::super::common::CountResponse>,
|
||||||
tonic::Status,
|
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<super::GetLastTableDataRequest>,
|
||||||
|
) -> std::result::Result<
|
||||||
|
tonic::Response<super::GetLastTableDataResponse>,
|
||||||
|
tonic::Status,
|
||||||
|
>;
|
||||||
/// Fetch the N-th non-deleted row by id order (1-based), then return its full data.
|
/// Fetch the N-th non-deleted row by id order (1-based), then return its full data.
|
||||||
///
|
///
|
||||||
/// Behavior:
|
/// Behavior:
|
||||||
@@ -2025,6 +2082,52 @@ pub mod tables_data_server {
|
|||||||
};
|
};
|
||||||
Box::pin(fut)
|
Box::pin(fut)
|
||||||
}
|
}
|
||||||
|
"/komp_ac.tables_data.TablesData/GetLastTableData" => {
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
struct GetLastTableDataSvc<T: TablesData>(pub Arc<T>);
|
||||||
|
impl<
|
||||||
|
T: TablesData,
|
||||||
|
> tonic::server::UnaryService<super::GetLastTableDataRequest>
|
||||||
|
for GetLastTableDataSvc<T> {
|
||||||
|
type Response = super::GetLastTableDataResponse;
|
||||||
|
type Future = BoxFuture<
|
||||||
|
tonic::Response<Self::Response>,
|
||||||
|
tonic::Status,
|
||||||
|
>;
|
||||||
|
fn call(
|
||||||
|
&mut self,
|
||||||
|
request: tonic::Request<super::GetLastTableDataRequest>,
|
||||||
|
) -> Self::Future {
|
||||||
|
let inner = Arc::clone(&self.0);
|
||||||
|
let fut = async move {
|
||||||
|
<T as TablesData>::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" => {
|
"/komp_ac.tables_data.TablesData/GetTableDataByPosition" => {
|
||||||
#[allow(non_camel_case_types)]
|
#[allow(non_camel_case_types)]
|
||||||
struct GetTableDataByPositionSvc<T: TablesData>(pub Arc<T>);
|
struct GetTableDataByPositionSvc<T: TablesData>(pub Arc<T>);
|
||||||
|
|||||||
@@ -619,6 +619,7 @@ import-progress-minutes = { $minutes } m { $seconds } s
|
|||||||
import-progress-unknown-yet = —
|
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-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-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ší.
|
import-err-already-running = Jeden import už běží. Počkejte, až skončí, a teprve potom spusťte další.
|
||||||
|
|
||||||
# --- Krok 2: mapování ------------------------------------------------------
|
# --- Krok 2: mapování ------------------------------------------------------
|
||||||
|
|||||||
@@ -607,6 +607,7 @@ import-progress-minutes = { $minutes } m { $seconds } s
|
|||||||
import-progress-unknown-yet = —
|
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-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-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.
|
import-err-already-running = An import is already running. Wait for it to finish before starting another one.
|
||||||
|
|
||||||
# --- Step 2: mapping -------------------------------------------------------
|
# --- Step 2: mapping -------------------------------------------------------
|
||||||
|
|||||||
@@ -617,6 +617,7 @@ import-progress-minutes = { $minutes } m { $seconds } s
|
|||||||
import-progress-unknown-yet = —
|
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-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-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ší.
|
import-err-already-running = Jeden import už beží. Počkajte, kým skončí, a až potom spustite ďalší.
|
||||||
|
|
||||||
# --- Krok 2: mapovanie -----------------------------------------------------
|
# --- 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
|
// is nothing to report, and the alert says so rather than the card polling
|
||||||
// a job that will never answer.
|
// a job that will never answer.
|
||||||
let Some(snapshot) = state.imports.snapshot(&id, session) else {
|
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(),
|
None => Html(ui::render_progress(locale, &id, &snapshot)).into_response(),
|
||||||
Some(Outcome::Succeeded) => Html(ui::render_success(
|
Some(Outcome::Succeeded) => Html(ui::render_success(
|
||||||
locale,
|
locale,
|
||||||
@@ -336,7 +336,20 @@ pub(crate) async fn import_progress(
|
|||||||
Html(String::new()),
|
Html(String::new()),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.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
|
/// 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(
|
pub(crate) fn render_success(
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
inserted: usize,
|
inserted: usize,
|
||||||
prepared_rows: usize,
|
prepared_rows: usize,
|
||||||
table_name: &str,
|
table_name: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
render(&Alert::success(
|
render(&ImportSuccessDialog {
|
||||||
locale,
|
locale,
|
||||||
&tr!(locale, "import-success-title"),
|
title: &tr!(locale, "import-success-title"),
|
||||||
&tr!(
|
message: &tr!(
|
||||||
locale,
|
locale,
|
||||||
"import-success-message",
|
"import-success-message",
|
||||||
"inserted" => inserted as i64,
|
"inserted" => inserted as i64,
|
||||||
"source_rows" => prepared_rows as i64,
|
"source_rows" => prepared_rows as i64,
|
||||||
"table" => table_name.to_string(),
|
"table" => table_name.to_string(),
|
||||||
),
|
),
|
||||||
))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -414,6 +426,14 @@ mod tests {
|
|||||||
// 500 rows in 40 seconds, so the 1500 left are about two minutes away.
|
// 500 rows in 40 seconds, so the 1500 left are about two minutes away.
|
||||||
assert!(html.contains("13 rows/s"), "{html}");
|
assert!(html.contains("13 rows/s"), "{html}");
|
||||||
assert!(html.contains("1 m 56 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
|
/// 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("Nothing was imported"), "{html}");
|
||||||
assert!(!html.contains("already imported remain"), "{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:
|
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
|
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
|
still on it, and the answer that ends the import is the success dialog or
|
||||||
alert instead — which has no trigger, and that is what stops the polling.
|
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.
|
||||||
#}
|
#}
|
||||||
{#
|
<div id="import-progress"
|
||||||
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"
|
|
||||||
hx-get="{{ poll_url }}" hx-trigger="every 1s"
|
hx-get="{{ poll_url }}" hx-trigger="every 1s"
|
||||||
hx-target="#import-progress" hx-swap="outerHTML">
|
hx-target="#import-progress" hx-swap="outerHTML"
|
||||||
<h3>{{ heading }}</h3>
|
x-data="{ importProgressOpen: true }">
|
||||||
<div class="progress-line">
|
<div x-cloak x-show="importProgressOpen" x-trap.inert.noscroll="importProgressOpen"
|
||||||
<progress max="100" value="{{ percent }}" aria-label="{{ label }}"></progress>
|
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"
|
||||||
<span class="progress-percent">{{ percent }}%</span>
|
role="dialog" aria-modal="true" aria-labelledby="import-progress-heading">
|
||||||
</div>
|
<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">
|
||||||
<p class="progress-rows"><strong>{{ rows }}</strong></p>
|
{#
|
||||||
<dl class="progress-stats">
|
The card sits inside a polite live region: left alone, a screen reader
|
||||||
{% for stat in stats %}
|
would read the whole card out again every second. It silences itself
|
||||||
<div>
|
instead, and the dialog or alert that replaces it when the import ends
|
||||||
<dt>{{ stat.label }}</dt>
|
— which is the part worth hearing — is announced as usual.
|
||||||
<dd>{{ stat.value }}</dd>
|
#}
|
||||||
|
<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>
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% 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