mirror of
https://github.com/penpot/penpot.git
synced 2026-11-05 17:40:39 +00:00
✨ Tell repairs, damaged data and other files apart in save notices
The notice of a rejected save always said the same thing. It did not name the file when the user had moved to another one, gave no hint when the page held data that was already invalid, and replaced the notice of a backend repair still on screen, since the store holds one toast. The backend flags a rejection whose first commit touches pages or components that failed validation before it (:preexisting-damage). The rejection notice names a file other than the open one, says when the data was damaged before, and folds into a repair notice still on screen; a later repair notice folds a rejection notice in the same way. The notice says that the latest changes were undone, no longer that the rest of the work is saved.
This commit is contained in:
parent
2114d95a2e
commit
e4e03b9c23
@ -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:<kind>` 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:<kind>` 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`.
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -263,6 +263,7 @@ eliminarlo
|
||||
eliminarlos
|
||||
ella
|
||||
ellos
|
||||
ese
|
||||
eso
|
||||
esos
|
||||
este
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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 {})))]
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user