🐛 Add download report to the error toast (#11762)

* 🐛 Add download report to the error toast

* 🐛 Restore old behavior for some cases
This commit is contained in:
Alonso Torres 2026-09-17 17:05:06 +02:00 committed by GitHub
parent 14c3135e21
commit 2fc2a9064a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 228 additions and 27 deletions

View File

@ -19,9 +19,11 @@
[app.main.router :as rt] [app.main.router :as rt]
[app.main.store :as st] [app.main.store :as st]
[app.main.worker] [app.main.worker]
[app.util.dom :as dom]
[app.util.globals :as g] [app.util.globals :as g]
[app.util.i18n :refer [tr]] [app.util.i18n :refer [tr]]
[app.util.timers :as ts] [app.util.timers :as ts]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cuerdas.core :as str] [cuerdas.core :as str]
[potok.v2.core :as ptk])) [potok.v2.core :as ptk]))
@ -170,6 +172,14 @@
:href (rt/get-current-href) :href (rt/get-current-href)
:report report})))) :report report}))))
(defn- download-report!
[report event]
(dom/prevent-default event)
(let [blob (wapi/create-blob report "text/plain")
uri (wapi/create-uri blob)]
(dom/trigger-download-uri "report" "text/plain" uri)
(ts/schedule-on-idle #(wapi/revoke-uri uri))))
(defn flash (defn flash
"Show error notification banner and emit error report. "Show error notification banner and emit error report.
A nil timeout keeps the notification visible until dismissed or replaced. A nil timeout keeps the notification visible until dismissed or replaced.
@ -180,23 +190,28 @@
synchronously from inside an error handler creates a re-entrant synchronously from inside an error handler creates a re-entrant
event-processing cycle that can exhaust the JS call stack event-processing cycle that can exhaust the JS call stack
(RangeError: Maximum call stack size exceeded)." (RangeError: Maximum call stack size exceeded)."
[& {:keys [type hint cause timeout] :or {type :handled timeout 5000}}] [& {:keys [type hint cause timeout report-link?]
(when (ex/exception? cause) :or {type :handled timeout 5000}}]
(when-let [event-name (case type (let [report (when (ex/exception? cause) (generate-report cause))]
:handled "handled-exception" (when report
:unhandled "unhandled-exception" (when-let [event-name (case type
:silent nil)] :handled "handled-exception"
(let [report (generate-report cause)] :unhandled "unhandled-exception"
:silent nil)]
(submit-report :event-name event-name (submit-report :event-name event-name
:report report :report report
:hint (ex/get-hint cause))))) :hint (ex/get-hint cause))))
(ts/schedule (ts/schedule
#(st/emit! #(st/emit!
(ntf/show {:content (or ^boolean hint (tr "errors.generic")) (ntf/show
:type :toast (cond-> {:content (or ^boolean hint (tr "errors.generic"))
:level :error :type :toast
:timeout timeout})))) :level :error
:timeout timeout}
(and report-link? report)
(assoc :links [{:label (tr "labels.download" "report.txt")
:callback (partial download-report! report)}])))))))
(defmethod ptk/handle-error :network (defmethod ptk/handle-error :network
[error] [error]
@ -207,13 +222,31 @@
(ex/print-throwable cause :prefix "Network Error")) (ex/print-throwable cause :prefix "Network Error"))
(flash :cause (::instance error) :type :handled)) (flash :cause (::instance error) :type :handled))
(def ^:private delegated-persistence-types
"Save failure causes routed to their own error handler: retaining the
changes cannot resolve them."
#{:authentication :not-found})
(defn- delegated-persistence-failure?
[{:keys [type cause-type code]}]
(or (contains? delegated-persistence-types type)
(contains? delegated-persistence-types cause-type)
;; The retained changes no longer apply to the restored version.
(= :vern-conflict code)))
(defn flash-persistence (defn flash-persistence
[cause] [cause]
(let [{:keys [type cause-type]} (ex-data cause)] (let [data (ex-data cause)]
;; Authentication has its own UI. `flash :silent` only skips reporting; (if (delegated-persistence-failure? data)
;; it still shows a toast, so do not call it for these failures. ;; The persistence state wraps the failure and records the original
(when-not (or (= :authentication type) (= :authentication cause-type)) ;; type under :cause-type; dispatch on it to reach the cause's handler.
(flash :cause cause :type :handled :timeout nil :hint (tr "errors.save-failed"))))) (on-error (-> (exception->error-data cause)
(assoc :type (or (:cause-type data) (:type data)))))
(flash :cause cause
:type :handled
:timeout nil
:report-link? true
:hint (tr "errors.save-failed")))))
(defmethod ptk/handle-error :persistence (defmethod ptk/handle-error :persistence
[error] [error]

View File

@ -5,6 +5,7 @@
;; Copyright (c) KALEIDOS INC Sucursal en España SL ;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.main.ui.notifications (ns app.main.ui.notifications
(:require-macros [app.main.style :as stl])
(:require (:require
[app.main.data.notifications :as ntf] [app.main.data.notifications :as ntf]
[app.main.store :as st] [app.main.store :as st]
@ -27,7 +28,20 @@
(= :floating (:position notification))) (= :floating (:position notification)))
toast? (or (= :toast (:type notification)) toast? (or (= :toast (:type notification))
(some? (:timeout notification))) (some? (:timeout notification)))
content (or (:content notification) "")] content (or (:content notification) "")
toast-content
(if-let [links (seq (:links notification))]
(mf/html
[:div {:class (stl/css :toast-body)}
[:div content]
[:nav {:class (stl/css :toast-links)}
(for [[index {:keys [label callback]}] (map-indexed vector links)]
[:a {:key (str "link-" index)
:class (stl/css :toast-link)
:href "#"
:on-click callback}
label])]])
content)]
(when notification (when notification
(cond (cond
@ -38,7 +52,7 @@
:is-html (boolean (:is-html notification)) :is-html (boolean (:is-html notification))
:detail (:detail notification) :detail (:detail notification)
:on-close on-close} :on-close on-close}
content] toast-content]
inline? inline?
[:& inline-notification [:& inline-notification
@ -60,4 +74,4 @@
:type (:type notification) :type (:type notification)
:is-html (boolean (:is-html notification)) :is-html (boolean (:is-html notification))
:detail (:detail notification) :detail (:detail notification)
:on-close on-close} content])))) :on-close on-close} toast-content]))))

View File

@ -0,0 +1,24 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
// Stacks the message and its links as a single item of the notification
// pill's flex row.
.toast-body {
display: flex;
flex-direction: column;
gap: var(--sp-s);
}
.toast-links {
display: flex;
flex-wrap: wrap;
gap: var(--sp-s);
}
.toast-links .toast-link {
color: currentcolor;
text-decoration: underline;
}

View File

@ -13,16 +13,22 @@
- on-error re-entrancy guard prevents recursive invocations - on-error re-entrancy guard prevents recursive invocations
- flash schedules async emit ntf/show is not emitted synchronously - flash schedules async emit ntf/show is not emitted synchronously
- organization SSO recovery expired SSO sessions go back to the provider - organization SSO recovery expired SSO sessions go back to the provider
- invalid-sso-config handler requires :organization-id to promote to :sso-error" - invalid-sso-config handler requires :organization-id to promote to :sso-error
- save failure notification sticky toast carrying the error report
- delegated save failures causes handled by the handler for their type"
(:require (:require
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.main.data.persistence :as dps] [app.main.data.persistence :as dps]
[app.main.data.workspace :as-alias dw]
[app.main.errors :as errors] [app.main.errors :as errors]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.router :as rt] [app.main.router :as rt]
[app.main.store :as st] [app.main.store :as st]
[app.util.dom :as dom]
[app.util.i18n :as i18n]
[app.util.timers :as tm] [app.util.timers :as tm]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cljs.test :as t :include-macros true] [cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.mock :as mock]
@ -416,6 +422,46 @@
(t/is (= timeout (get-in state [:notification :timeout]))) (t/is (= timeout (get-in state [:notification :timeout])))
(t/is (= :visible (get-in state [:notification :status])))))))) (t/is (= :visible (get-in state [:notification :status]))))))))
(t/deftest persistence-notifications-include-an-error-report-download
(let [scheduled (atom [])
idle-callbacks (atom [])
events (atom [])
downloads (atom [])
revoked (atom [])
report "generated error report"
cause (ex-info "Save failed" {:type :validation})]
(with-redefs [dom/prevent-default (fn [_])
dom/trigger-download-uri (fn [& params]
(swap! downloads conj params))
errors/generate-report (fn [_] report)
errors/submit-report (fn [& _])
;; `tr` is called with one and with two arguments, and its
;; two-argument arity is variadic: the stub exposes both
;; shapes so the compiled static calls resolve.
i18n/tr (fn ([key] (str key ":"))
([key & args]
(str key ":" (first args))))
st/emit! (mock/stub (fn [& emitted]
(swap! events into emitted)))
tm/schedule (mock/stub (fn [callback]
(swap! scheduled conj callback)))
tm/schedule-on-idle (mock/stub (fn [callback]
(swap! idle-callbacks conj callback)))
wapi/create-blob (mock/stub (fn [content media-type]
{:content content :media-type media-type}))
wapi/create-uri (fn [_] "blob:report")
wapi/revoke-uri (fn [uri]
(swap! revoked conj uri))]
(errors/flash-persistence cause)
(doseq [callback @scheduled] (callback))
(let [state (ptk/update (first @events) {})
download (get-in state [:notification :links 0])]
(t/is (= "labels.download:report.txt" (:label download)))
((:callback download) nil)
(t/is (= [["report" "text/plain" "blob:report"]] @downloads))
(doseq [callback @idle-callbacks] (callback))
(t/is (= ["blob:report"] @revoked))))))
(t/deftest persistence-waiters-do-not-report-an-already-handled-failure (t/deftest persistence-waiters-do-not-report-an-already-handled-failure
(let [reports (atom []) (let [reports (atom [])
rejected (atom []) rejected (atom [])
@ -444,3 +490,84 @@
(t/is (= 3 (count @reports))) (t/is (= 3 (count @reports)))
(finally (finally
(rx/dispose! store)))))) (rx/dispose! store))))))
;; ---------------------------------------------------------------------------
;; Save failures that belong to their own handler
;;
;; Some causes are not resolved by retaining the changes: the session has to
;; be renewed, the file is gone, or a different version was restored. Each
;; one is handled by the error handler for its own type.
;; ---------------------------------------------------------------------------
(t/deftest expired-session-during-save-goes-to-the-authentication-handler
(t/testing "a lost session is handled as an authentication error, not notified"
(let [assigned (atom nil)
scheduled (atom [])]
(with-redefs [rt/get-current-href (constantly workspace-href)
rt/assign-exception (fn [error] error)
st/async-emit! (fn [& events] (reset! assigned (first events)))
tm/schedule (mock/stub (fn [callback]
(swap! scheduled conj callback)))]
(errors/flash-persistence (ex-info "Session expired" {:type :authentication}))
(t/is (= :authentication (:type @assigned)))
(t/is (empty? @scheduled) "An expired session shows no save notification")))))
(t/deftest expired-organization-sso-during-save-renews-the-session
(t/async done
(t/testing "an SSO-guarded save failure goes back through the identity provider"
(let [events (atom [])]
(mock/with-mocks
{rp/cmd! (mock/stub
(fn [_command _params]
(rx/of {:authorized false
:redirect-uri "https://idp.example.com/authorize"})))
rt/get-current-href (constantly workspace-href)
st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))}
(fn [done']
(errors/flash-persistence (ex-info "SSO required" (sso-required-error)))
(t/is (= [::rt/nav-raw] (mapv ptk/type @events)))
(done'))
done)))))
(t/deftest a-deleted-file-during-save-shows-the-exception-page
(t/testing "a file that no longer exists shows its page, not the save notification"
(let [events (atom [])
scheduled (atom [])]
(with-redefs [rt/assign-exception (fn [error] error)
st/emit! (mock/stub (fn [& emitted]
(swap! events into emitted)))
tm/schedule (mock/stub (fn [callback]
(swap! scheduled conj callback)))]
(errors/flash-persistence (ex-info "File not found" {:type :not-found}))
(doseq [callback @scheduled] (callback))
(t/is (= [:not-found] (mapv :type @events)))))))
(t/deftest a-restored-version-during-save-reloads-the-file
(t/testing "a version restored elsewhere reloads the file instead of notifying"
(let [events (atom [])
scheduled (atom [])]
(with-redefs [st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))
tm/schedule (mock/stub (fn [callback]
(swap! scheduled conj callback)))]
(errors/flash-persistence (ex-info "A different version has been restored"
{:type :validation :code :vern-conflict}))
(t/is (= [::dw/reload-current-file] (mapv ptk/type @events)))
(t/is (empty? @scheduled) "A restored version shows no save notification")))))
(t/deftest other-validation-failures-during-save-keep-the-notification
(t/testing "a validation failure without a recovery of its own is still notified"
(let [events (atom [])
scheduled (atom [])]
(with-redefs [errors/generate-report (fn [_] "generated error report")
errors/submit-report (fn [& _])
st/emit! (mock/stub (fn [& emitted]
(swap! events into emitted)))
tm/schedule (mock/stub (fn [callback]
(swap! scheduled conj callback)))]
(errors/flash-persistence (ex-info "Invalid data" {:type :validation
:code :invalid-data}))
(doseq [callback @scheduled] (callback))
(t/is (= 1 (count @events)))
(let [state (ptk/update (first @events) {})]
(t/is (nil? (get-in state [:notification :timeout])))
(t/is (some? (get-in state [:notification :links 0]))))))))

View File

@ -1799,9 +1799,7 @@ msgstr "The registration is currently disabled."
#: src/app/main/errors.cljs:216 #: src/app/main/errors.cljs:216
msgid "errors.save-failed" msgid "errors.save-failed"
msgstr "" msgstr ""
"Your latest changes are not confirmed as saved. Saving may not resume " "Autosave is not working due to an error. Contact support to report the error and reload to continue from your last saved version."
"automatically. Do not close or reload this tab: you may lose changes. "
"Contact support for help."
#: src/app/main/errors.cljs:337 #: src/app/main/errors.cljs:337
msgid "errors.svg-parser.invalid-svg" msgid "errors.svg-parser.invalid-svg"

View File

@ -1763,6 +1763,11 @@ msgstr ""
msgid "errors.registration-disabled" msgid "errors.registration-disabled"
msgstr "El registro está actualmente desactivado." msgstr "El registro está actualmente desactivado."
#: src/app/main/errors.cljs:216
msgid "errors.save-failed"
msgstr ""
"El autoguardado se ha detenido debido a un error. Contacta soporte para informar del error y recarga para continuar desde los últimos cambios guardados."
#: src/app/main/errors.cljs:337 #: src/app/main/errors.cljs:337
msgid "errors.svg-parser.invalid-svg" msgid "errors.svg-parser.invalid-svg"
msgstr "El SVG no es válido o está mal formado" msgstr "El SVG no es válido o está mal formado"