diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index 5d32783a4e..bfceaaffed 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -110,4 +110,4 @@ - Revisions live only in `file_change` and have no `file_data` counterpart. They are born already scheduled for deletion one hour later (`app.rpc.commands.files-update`) and the snapshot paths cannot reach one, because they resolve their row through a `label IS NOT NULL` filter. - A deletion date is assigned once and never moved: `restore-files` (`app.rpc.commands.files`) unmarks only durable history, the `:file` method of `app.tasks.delete-object` dates only rows that have no date yet, and `objects-gc` collects only rows that carry one. Unmarking a revision leaves it invisible to `objects-gc` for ever, because nothing sets that date again. - Binfile v1/v3 import and the v3 export `get-file` step run inside one long transaction. They raise `idle_in_transaction_session_timeout` to 30 min (`app.binfile.common/transaction-idle-timeout-ms`) instead of disabling it, so a connection left idle while the ZIP is decoded or the file data is realized is bounded; `statement_timeout` stays at 300 s. -- `update-file` with `:changes-with-metadata` groups that all carry `:commit-id` is partial: fast path applies and validates everything once; on a change failure it bisects the commits for the longest prefix that applies and validates (each attempt applies a prefix from the original data and runs referential integrity + file schema; ~log2 K attempts, none for a single commit, the first attempt's error reused), keeping that prefix (a commit that repairs an earlier one can keep both) and answering 200 with `:rejected {:commit-ids :failed-commit-id :error}`. Only accepted changes reach `file_change`, persistence and the msgbus; a fully rejected request writes and broadcasts nothing (revn unchanged). Flat `:changes` or groups without ids stay all-or-nothing. Before each attempt (`apply-prefix`, which returns `{:file :tracked :refs}` or the cause) `pmap/*tracked*` and the `cpc/*state*` atom are reset to their pre-attempt values (at the end `apply-commits` sets those of the kept prefix): a pointer map already `modified?` keeps its id when changed again, so the failed version would be persisted. DB/IO/interrupt errors and `:not-found` fragments (`change-failure?`) fail the whole request, never reject a commit. A rejection whose ex-info has no `:type` reports `:internal :unexpected-error`. `app.http.errors` answers 503 (`:server-error` + code) for transient SQLSTATEs (classes 08/53, 57P01–3, 40001, 40P01), `SQLTransientException`, `InterruptedException` and only the `IOException`s that pass on their own (`transient-io-exception?`: `SocketException`, `InterruptedIOException` incl. socket timeouts, `UnknownHostException`, `HttpTimeoutException`), which the client's save retries (and GETs retry by themselves); any other `IOException` (EOF from a corrupt blob, Jackson parse errors, missing files) stays 500 `:io-exception`, as do other SQL errors. With the `:debug-save-faults` flag, a `:set-plugin-data` file change in namespace `:penpot-debug` keyed `save-fault:` raises that fault in `apply-changes` (used by `debug.failNextSave`/`commitInvalidChange`). With a schema check running (hard schema validation without skip-validate, or soft) and 2+ commits, the touched pages' objects are decoded on the original file first (`decode-objects`; referential validation alone reads few objects, so no pre-decode then): objects-map caches decoded values per instance and a derived map copies the cache, so otherwise every attempt re-decodes the page (~260 ms at 10k shapes). Validation step timings (`with-validation-timing`, upstream `penpot_rpc_update_file_validation_timing` + the `update-file` debug log) come from the first attempt only (retries run with a cfg without metrics and timings); the whole bisection is one `retry` step sample, and the rejection log carries `:attempts`/`:elapsed`. +- `update-file` with `:changes-with-metadata` groups that all carry `:commit-id` is partial: fast path applies and validates everything once; on a change failure it bisects the commits for the longest prefix that applies and validates (each attempt applies a prefix from the original data and runs referential integrity + file schema; ~log2 K attempts, none for a single commit, the first attempt's error reused), keeping that prefix (a commit that repairs an earlier one can keep both) and answering 200 with `:rejected {:commit-ids :failed-commit-id :error}`, plus `:preexisting-damage true` when the data the first rejected commit touches already fails validation without it (`preexisting-damage?`: one more referential + schema check of its pages/components on the base file, the accepted prefix or the original; the frontend then says the page data is damaged). Only accepted changes reach `file_change`, persistence and the msgbus; a fully rejected request writes and broadcasts nothing (revn unchanged). Flat `:changes` or groups without ids stay all-or-nothing. Before each attempt (`apply-prefix`, which returns `{:file :tracked :refs}` or the cause) `pmap/*tracked*` and the `cpc/*state*` atom are reset to their pre-attempt values (at the end `apply-commits` sets those of the kept prefix): a pointer map already `modified?` keeps its id when changed again, so the failed version would be persisted. DB/IO/interrupt errors and `:not-found` fragments (`change-failure?`) fail the whole request, never reject a commit. A rejection whose ex-info has no `:type` reports `:internal :unexpected-error`. `app.http.errors` answers 503 (`:server-error` + code) for transient SQLSTATEs (classes 08/53, 57P01–3, 40001, 40P01), `SQLTransientException`, `InterruptedException` and only the `IOException`s that pass on their own (`transient-io-exception?`: `SocketException`, `InterruptedIOException` incl. socket timeouts, `UnknownHostException`, `HttpTimeoutException`), which the client's save retries (and GETs retry by themselves); any other `IOException` (EOF from a corrupt blob, Jackson parse errors, missing files) stays 500 `:io-exception`, as do other SQL errors. With the `:debug-save-faults` flag, a `:set-plugin-data` file change in namespace `:penpot-debug` keyed `save-fault:` raises that fault in `apply-changes` (used by `debug.failNextSave`/`commitInvalidChange`). With a schema check running (hard schema validation without skip-validate, or soft) and 2+ commits, the touched pages' objects are decoded on the original file first (`decode-objects`; referential validation alone reads few objects, so no pre-decode then): objects-map caches decoded values per instance and a derived map copies the cache, so otherwise every attempt re-decodes the page (~260 ms at 10k shapes). Validation step timings (`with-validation-timing`, upstream `penpot_rpc_update_file_validation_timing` + the `update-file` debug log) come from the first attempt only (retries run with a cfg without metrics and timings); the whole bisection is one `retry` step sample, and the rejection log carries `:attempts`/`:elapsed`. diff --git a/backend/src/app/rpc/commands/files_update.clj b/backend/src/app/rpc/commands/files_update.clj index ef7cc0b5f5..266214ec27 100644 --- a/backend/src/app/rpc/commands/files_update.clj +++ b/backend/src/app/rpc/commands/files_update.clj @@ -94,6 +94,8 @@ [:map [:commit-ids [:vector ::sm/uuid]] [:failed-commit-id ::sm/uuid] + ;; The data the failed commit touches was invalid before it + [:preexisting-damage {:optional true} :boolean] [:error [:map [:type :keyword] @@ -654,22 +656,43 @@ (instance? AssertionError cause)) (not (infrastructure-error? cause)))) +(defn- preexisting-damage? + "True when `file` already fails validation on the pages and components + `changes` touch, before they are applied." + [file libs changes skip-validate] + (try + (binding [pmap/*tracked* nil] + (when (and (contains? cf/flags :file-validation) + (not skip-validate)) + (val/validate-file-affected! file libs changes)) + (when (and (contains? cf/flags :file-schema-validation) + (not skip-validate)) + (val/validate-file-schema-affected! file changes))) + false + (catch Throwable cause + (if (change-failure? cause) + true + (throw cause))))) + (defn- rejection - "Describes `commits` as rejected, the first one failing with `cause`." - [commits cause] + "Describes `commits` as rejected, the first one failing with `cause`. + With `damaged`, the data that commit touches was invalid before it." + [commits cause damaged] (let [data (ex-data cause)] - {:commit-ids (mapv :commit-id commits) - :failed-commit-id (:commit-id (first commits)) - ;; An ex-info without a type reports like any unexpected exception - :error (if (:type data) - (cond-> {:type (:type data) - :code (:code data) - :hint (or (:hint data) (ex-message cause))} - (:details data) - (assoc :details (mapv #(dissoc % :shape) (:details data)))) - {:type :internal - :code :unexpected-error - :hint (or (ex-message cause) (.getName (class cause)))})})) + (cond-> {:commit-ids (mapv :commit-id commits) + :failed-commit-id (:commit-id (first commits)) + ;; An ex-info without a type reports like any unexpected exception + :error (if (:type data) + (cond-> {:type (:type data) + :code (:code data) + :hint (or (:hint data) (ex-message cause))} + (:details data) + (assoc :details (mapv #(dissoc % :shape) (:details data)))) + {:type :internal + :code :unexpected-error + :hint (or (ex-message cause) (.getName (class cause)))})} + damaged + (assoc :preexisting-damage true)))) (defn- apply-prefix "Applies and validates the first `n` commits of `attempt`, starting from @@ -697,8 +720,10 @@ attempts; a commit that repairs an earlier one can keep both. Leaves the pointers and media refs of the kept prefix in place and returns its file, with the accepted changes in `::changes` and the rejection in - `::rejected`. The whole search is timed as the `retry` step of `cfg`." - [cfg {:keys [file commits state tracked] :as attempt} cause] + `::rejected`, which tells whether the data the first rejected commit + touches was already invalid. The whole search is timed as the `retry` + step of `cfg`." + [cfg {:keys [file libs commits skip-validate state tracked] :as attempt} cause] (let [tpoint (ct/tpoint)] (with-validation-timing cfg :retry-ms "retry" (loop [lo 0 @@ -712,7 +737,10 @@ (if (instance? Throwable result) (recur lo mid result (inc attempts) accepted) (recur mid hi cause (inc attempts) result))) - (let [rejected (subvec commits lo)] + (let [rejected (subvec commits lo) + base (:file accepted file) + damaged (preexisting-damage? base libs (:changes (first rejected)) + skip-validate)] (reset! pmap/*tracked* (:tracked accepted tracked)) (reset! state (:refs accepted {})) (l/error :hint "update-file commit rejected" @@ -721,10 +749,11 @@ :rejected (count rejected) :commits (count commits) :attempts attempts + :preexisting-damage damaged :elapsed (ct/format-duration (tpoint)) :cause cause) (assoc (:file accepted (assoc file ::changes [])) - ::rejected (rejection rejected cause)))))))) + ::rejected (rejection rejected cause damaged)))))))) (defn- fail-repair [cfg file errors cause] diff --git a/backend/test/backend_tests/rpc_file_update_test.clj b/backend/test/backend_tests/rpc_file_update_test.clj index 663013fca5..c5a8ba9b6d 100644 --- a/backend/test/backend_tests/rpc_file_update_test.clj +++ b/backend/test/backend_tests/rpc_file_update_test.clj @@ -110,7 +110,8 @@ (t/is (= [(:commit-id commit2) (:commit-id commit3)] (:commit-ids rejected))) (t/is (= (:commit-id commit2) (:failed-commit-id rejected))) (t/is (= :validation (-> rejected :error :type))) - (t/is (= :referential-integrity (-> rejected :error :code))))) + (t/is (= :referential-integrity (-> rejected :error :code))) + (t/is (nil? (:preexisting-damage rejected))))) (t/testing "the file holds the accepted commit only" (let [objects (-> (get-file-data ctx) :pages-index (get page-id) :objects)] @@ -184,9 +185,37 @@ (t/is (= [(:commit-id commit2) (:commit-id commit3)] (-> out :result :rejected :commit-ids))) (t/is (= :schema-validation (-> out :result :rejected :error :code))) + (t/is (nil? (-> out :result :rejected :preexisting-damage)) + "the data was valid before the rejected commit") (t/is (= "rect" (get-in objects [shape-a :name]))) (t/is (not (contains? objects shape-c))))) +(t/deftest flags-a-rejection-on-data-damaged-before-the-commit + (let [{:keys [page-id] :as ctx} (create-file) + shape-a (uuid/next) + _ (update-file ctx {:changes [(th/add-rect-change page-id shape-a) + {:type :mod-obj + :page-id page-id + :id shape-a + :operations [{:type :set :attr :name :val "damaged"}]}]}) + commit1 (commit (th/add-rect-change page-id (uuid/next))) + validate val/validate-file-schema-affected! + ;; The page fails the schema check whenever it holds the "damaged" + ;; shape, as stored data that an older version saved invalid would + out (with-redefs [val/validate-file-schema-affected! + (fn [file changes] + (when (= "damaged" + (get-in file [:data :pages-index page-id :objects shape-a :name])) + (ex/raise :type :validation :code :schema-validation :hint "schema")) + (validate file changes))] + (update-file ctx {:revn 1 :changes-with-metadata [commit1]})) + rejected (-> out :result :rejected)] + + (t/is (nil? (:error out))) + (t/is (= [(:commit-id commit1)] (:commit-ids rejected))) + (t/is (true? (:preexisting-damage rejected))) + (t/is (= 1 (:revn (th/db-get :file {:id (:file-id ctx)})))))) + (t/deftest saves-every-commit-when-all-are-valid (let [{:keys [page-id] :as ctx} (create-file) out (update-file ctx {:changes-with-metadata [(commit (th/add-rect-change page-id (uuid/next))) diff --git a/frontend/scripts/check-translations/words.es.txt b/frontend/scripts/check-translations/words.es.txt index eb74debd90..5111ebe320 100644 --- a/frontend/scripts/check-translations/words.es.txt +++ b/frontend/scripts/check-translations/words.es.txt @@ -263,6 +263,7 @@ eliminarlo eliminarlos ella ellos +ese eso esos este diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index c6a316d98e..245afddf88 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -627,14 +627,81 @@ (d/update-in-when state [:persistence :index (:id commit)] assoc ::acknowledged true)))) -(def ^:private rejected-tag - "Tag of the rejected changes notice." +(def rejected-tag + "Tag of the notice that changes to the open file were rejected." :persistence-rejected) +(def ^:private other-file-rejected-tag + "Tag of the notice that changes to a file not open were rejected." + :persistence-rejected-other-file) + +(def file-repaired-tag + "Tag of the notice that the backend repaired the open file." + :file-repaired) + +(def repaired-rejected-tag + "Tag of the notice that tells both of a repair and of rejected changes." + :file-repaired-rejected) + (def ^:private rejected-timeout-ms "How long the rejected changes notice stays; the report is sent anyway." 10000) +(defn notify-repaired + "Tells the user the backend repaired the open file. The store holds one + toast, so a notice of rejected changes to that file still on screen is + folded into it, keeping its report link." + [] + (ptk/reify ::notify-repaired + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [tag links]} (:notification state)] + (rx/of + (ntf/show + (if (contains? #{rejected-tag repaired-rejected-tag} tag) + (cond-> {:content (tr "workspace.notifications.file-repaired-changes-undone") + :type :toast + :level :warning + :timeout nil + :tag repaired-rejected-tag} + (seq links) + (assoc :links links)) + {:content (tr "workspace.notifications.file-repaired") + :type :toast + :level :info + :timeout nil + :tag file-repaired-tag}))))))) + +(defn- rejection-notice + "Text, tag and timeout of the notice that the changes `rejected` in a + save of `file-id` were undone. A file other than the open one is named; + a repair notice of the open file still on screen is folded into it." + [state file-id {:keys [preexisting-damage]}] + (let [file-name (dm/get-in state [:files file-id :name]) + current? (= file-id (:current-file-id state)) + repaired? (contains? #{file-repaired-tag repaired-rejected-tag} + (dm/get-in state [:notification :tag]))] + (cond + (and (not current?) (some? file-name)) + {:hint (tr "errors.save-rejected-changes-undone-in-file" file-name) + :tag other-file-rejected-tag + :timeout rejected-timeout-ms} + + (and current? repaired?) + {:hint (tr "workspace.notifications.file-repaired-changes-undone") + :tag repaired-rejected-tag + :timeout nil} + + (and current? preexisting-damage) + {:hint (tr "errors.save-rejected-damaged-data") + :tag rejected-tag + :timeout rejected-timeout-ms} + + :else + {:hint (tr "errors.save-rejected-changes-undone") + :tag (if current? rejected-tag other-file-rejected-tag) + :timeout rejected-timeout-ms}))) + (defn- discard-path-edit "Forgets the path editor's state for `id`, so leaving the edition saves nothing from it." @@ -783,9 +850,10 @@ (rx/empty))) ptk/EffectEvent - (effect [_ _ _] + (effect [_ state _] (when-let [plan @plan] - (let [{:keys [error commit-ids]} rejected] + (let [{:keys [error commit-ids]} rejected + {:keys [hint tag timeout]} (rejection-notice state file-id rejected)] (errors/flash :cause (ex-info "The server rejected some changes" (merge error {:file-id file-id @@ -793,10 +861,10 @@ (pcm/rollback-summary plan))) :type :unhandled :level :warning - :timeout rejected-timeout-ms - :tag rejected-tag + :timeout timeout + :tag tag :report-link? true - :hint (tr "errors.save-rejected-changes-undone")))))))) + :hint hint))))))) (defn- update-file-request "Issues the `update-file` request, tracked as active for its lifetime." diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index 2cd1562589..459e4e70b9 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -16,7 +16,6 @@ [app.main.data.common :as dc] [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] - [app.main.data.notifications :as ntf] [app.main.data.persistence :as dps] [app.main.data.plugins :as dpl] [app.main.data.websocket :as dws] @@ -28,7 +27,6 @@ [app.main.data.workspace.texts :as dwt] [app.main.router :as rt] [app.util.globals :refer [global]] - [app.util.i18n :refer [tr]] [app.util.mouse :as mse] [app.util.object :as obj] [app.util.rxops :as rxs] @@ -303,10 +301,7 @@ :redo-changes (vec changes) :undo-changes []})) (if (:repaired msg) - (rx/of (ntf/show {:content (tr "workspace.notifications.file-repaired") - :type :toast - :level :info - :timeout nil})) + (rx/of (dps/notify-repaired)) (rx/empty)))))) (defn handle-file-deleted diff --git a/frontend/test/frontend_tests/data/persistence_rollback_test.cljs b/frontend/test/frontend_tests/data/persistence_rollback_test.cljs index 1f5ebd6ef9..730bcc5b33 100644 --- a/frontend/test/frontend_tests/data/persistence_rollback_test.cljs +++ b/frontend/test/frontend_tests/data/persistence_rollback_test.cljs @@ -17,9 +17,12 @@ [app.main.data.persistence :as dps] [app.main.data.workspace :as dw] [app.main.data.workspace.undo :as dwu] + [app.main.errors :as errors] + [app.util.i18n :as i18n] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.async :as async] + [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.persistence :as thp] [potok.v2.core :as ptk])) @@ -689,3 +692,70 @@ (t/is (nil? (get-in @store [:workspace-global :persistence-lock]))) (t/is (= "Restored" (thp/shape-name store file-id page-id shape-id)))))))) + +(defn- rejection-notice + "Saves a rename of the open file and answers it with `answer` applied to + a rejection of it, after `prepare` updates the state (given the file + id) while the save is in flight. Resolves with the params the notice was + flashed with; `tr` answers each key with its arguments." + [prepare answer] + (let [flashes (atom [])] + (-> (thp/with-persistence + (^:async fn [{:keys [file-id response requests store]}] + (await + (mock/with-mocks* + {errors/flash (fn [& {:as params}] + (swap! flashes conj params) + (js/Promise.resolve nil)) + i18n/tr (fn ([key] [key]) ([key & args] (into [key] args)))} + (let [page-id (uuid/next) + shape-id (uuid/next) + commit1 (thp/rename-commit file-id page-id shape-id "A" "B")] + (ptk/emit! store (thp/open-file file-id page-id (thp/file-data page-id shape-id "B"))) + (ptk/emit! store commit1 ::dps/force-persist) + (await (async/wait-for #(= 1 (count @requests)) "the rename is sent")) + (ptk/emit! store #(prepare % file-id)) + (rx/push! response (answer (thp/rejection (:id @commit1)))) + (await (async/wait-for #(seq @flashes) "the notice is flashed"))))))) + (.then (fn [_] (first @flashes)))))) + +;; Scenario: an edit made while the save was in flight is undone with the +;; rejected one. Proves: the notice says the latest changes were undone, +;; and goes away by itself. +(t/deftest ^:async a-rejection-notice-says-the-latest-changes-were-undone + (let [{:keys [hint tag timeout]} (await (rejection-notice (fn [state _] state) identity))] + (t/is (= ["errors.save-rejected-changes-undone"] hint)) + (t/is (= dps/rejected-tag tag)) + (t/is (some? timeout)))) + +;; Scenario: the user went to another file before the answer came back. +;; Proves: the notice names the file it is about. +(t/deftest ^:async a-rejection-in-a-file-not-open-names-it + (let [{:keys [hint]} (await (rejection-notice + (fn [state file-id] + (-> state + (assoc-in [:files file-id :name] "Brand kit") + (assoc :current-file-id (uuid/next)))) + identity))] + (t/is (= ["errors.save-rejected-changes-undone-in-file" "Brand kit"] hint)))) + +;; Scenario: the page holds data that was invalid before the change, so the +;; backend flags the rejection. Proves: the notice says the data is damaged. +(t/deftest ^:async a-rejection-on-damaged-data-says-so + (let [{:keys [hint]} (await (rejection-notice + (fn [state _] state) + #(assoc-in % [:rejected :preexisting-damage] true)))] + (t/is (= ["errors.save-rejected-damaged-data"] hint)))) + +;; Scenario: the repair notice of the open file is still on screen when a +;; save of it comes back rejected. The store holds one toast. Proves: the +;; notice tells both, and stays like the repair notice did. +(t/deftest ^:async a-rejection-keeps-the-repair-notice-on-screen + (let [{:keys [hint tag timeout]} + (await (rejection-notice + (fn [state _] + (assoc state :notification {:content "repaired" :tag dps/file-repaired-tag})) + identity))] + (t/is (= ["workspace.notifications.file-repaired-changes-undone"] hint)) + (t/is (= dps/repaired-rejected-tag tag)) + (t/is (nil? timeout)))) diff --git a/frontend/test/frontend_tests/data/workspace_notifications_test.cljs b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs index 0bb9d87f95..0ad8a12697 100644 --- a/frontend/test/frontend_tests/data/workspace_notifications_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs @@ -6,12 +6,12 @@ (ns frontend-tests.data.workspace-notifications-test (:require - [app.common.data :as d] [app.common.time :as ct] [app.common.uuid :as uuid] [app.main.data.changes :as dch] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.data.persistence :as dps] [app.main.data.websocket :as dws] [app.main.data.workspace :as-alias dw] [app.main.data.workspace.notifications :as dwntf] @@ -40,17 +40,40 @@ :on-next #(swap! events conj %))) @events)) +(defn- ^:async shown-notice + "The notification the watch of `event` shows on `state`." + [event state] + (let [events (atom [])] + (await (async/observe (ptk/watch event state (rx/empty)) + :on-next #(swap! events conj %))) + (some #(when (= ::ntf/show (ptk/type %)) + (:notification (ptk/update % {}))) + @events))) + (t/deftest ^:async repaired-file-change-applies-and-notifies (let [events (await (watch-file-change (file-change-msg {:repaired true}))) - types (mapv ptk/type events) - show (d/seek #(= ::ntf/show (ptk/type %)) events) - state (ptk/update show {})] - (t/is (= [::dch/commit ::ntf/show] types)) - (t/is (= :info (get-in state [:notification :level]))) - (t/is (nil? (get-in state [:notification :timeout])) - "the toast stays until the user closes it") - (t/is (= (i18n/tr "workspace.notifications.file-repaired") - (get-in state [:notification :content]))))) + notice (await (shown-notice (second events) {}))] + (t/is (= [::dch/commit ::dps/notify-repaired] (mapv ptk/type events))) + (t/is (= :info (:level notice))) + (t/is (nil? (:timeout notice)) "the toast stays until the user closes it") + (t/is (= dps/file-repaired-tag (:tag notice))) + (t/is (= (i18n/tr "workspace.notifications.file-repaired") (:content notice))))) + +;; Scenario: a save of the open file came back rejected and its notice is +;; still on screen when the backend repairs the file. The store holds one +;; toast. Proves: the repair notice tells both and keeps the report link. +(t/deftest ^:async a-repair-notice-keeps-a-rejection-notice-on-screen + (let [link {:label "report.txt" :callback identity} + notice (await (shown-notice (dps/notify-repaired) + {:notification {:content "rejected" + :tag dps/rejected-tag + :links [link]}}))] + (t/is (= (i18n/tr "workspace.notifications.file-repaired-changes-undone") + (:content notice))) + (t/is (= :warning (:level notice))) + (t/is (= dps/repaired-rejected-tag (:tag notice))) + (t/is (= [link] (:links notice))) + (t/is (nil? (:timeout notice))))) (t/deftest ^:async ordinary-file-change-does-not-notify (let [events (await (watch-file-change (file-change-msg {})))] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 3976c1e321..1244293995 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1834,7 +1834,15 @@ msgstr[1] "You can no longer edit this file, so %s changes could not be saved. R #: src/app/main/data/persistence.cljs msgid "errors.save-rejected-changes-undone" -msgstr "Some of your latest changes couldn't be saved and were undone. The rest of your work is saved." +msgstr "Some changes couldn't be saved, so your latest changes were undone." + +#: src/app/main/data/persistence.cljs +msgid "errors.save-rejected-changes-undone-in-file" +msgstr "Some changes to “%s” couldn't be saved, so your latest changes to that file were undone." + +#: src/app/main/data/persistence.cljs +msgid "errors.save-rejected-damaged-data" +msgstr "This page has damaged data that can't be repaired automatically, so changes to it can't be saved and were undone. Contact support." #: src/app/main/data/persistence.cljs:310 msgid "errors.save-retrying" @@ -7225,6 +7233,11 @@ msgstr "" "We fixed some issues in this file. You can find a copy of the original version in " "the version history." +#: src/app/main/data/persistence.cljs +msgid "workspace.notifications.file-repaired-changes-undone" +msgstr "" +"We fixed some issues in this file. You can find a copy of the original version in the version history. Some changes couldn't be saved, so your latest changes were undone." + #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 msgid "workspace.options.add-interaction" msgstr "Click the + button to add interactions." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index dd85f5e0e9..db017dc84e 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1877,7 +1877,22 @@ msgstr[1] "" #: src/app/main/data/persistence.cljs msgid "errors.save-rejected-changes-undone" -msgstr "Algunos de tus últimos cambios no se han podido guardar y se han deshecho. El resto de tu trabajo está guardado." +msgstr "" +"Algunos cambios no se han podido guardar, así que se han deshecho tus " +"últimos cambios." + +#: src/app/main/data/persistence.cljs +msgid "errors.save-rejected-changes-undone-in-file" +msgstr "" +"Algunos cambios en «%s» no se han podido guardar, así que se han deshecho " +"tus últimos cambios en ese archivo." + +#: src/app/main/data/persistence.cljs +msgid "errors.save-rejected-damaged-data" +msgstr "" +"Esta página tiene datos dañados que no se pueden reparar automáticamente, " +"así que sus cambios no se pueden guardar y se han deshecho. Contacta con " +"soporte." #: src/app/main/data/persistence.cljs msgid "errors.save-retrying-busy" @@ -7356,6 +7371,13 @@ msgstr "" "Hemos corregido algunos problemas de este archivo. Hay una copia de la " "versión original en el historial de versiones." +#: src/app/main/data/persistence.cljs +msgid "workspace.notifications.file-repaired-changes-undone" +msgstr "" +"Hemos corregido algunos problemas de este archivo. Hay una copia de la " +"versión original en el historial de versiones. Algunos cambios no se han " +"podido guardar, así que se han deshecho tus últimos cambios." + #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 msgid "workspace.options.add-interaction" msgstr "Pulsa el botón + para añadir interacciones."