diff --git a/frontend/resources/styles/common/refactor/design-tokens.scss b/frontend/resources/styles/common/refactor/design-tokens.scss index 782d4e49a9..201aaf51f2 100644 --- a/frontend/resources/styles/common/refactor/design-tokens.scss +++ b/frontend/resources/styles/common/refactor/design-tokens.scss @@ -323,7 +323,7 @@ // STATUS WIDGET --status-widget-background-color-success: var(--status-color-success-500); --status-widget-background-color-warning: var(--status-color-warning-500); - --status-widget-background-color-pending: var(--status-color-info-500); + --status-widget-background-color-pending: var(--status-color-warning-500); --status-widget-background-color-error: var(--status-color-error-500); --status-widget-icon-foreground-color: var(--color-background-primary); diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index 74a2d97659..d849fb95ed 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -244,8 +244,10 @@ features (get state :features) permissions (get state :permissions)] - ;; Prevent commit changes by a viewer team member (it really should never happen) - (when (:can-edit permissions) + ;; Historical previews must not create edits to the live file. Check + ;; this when creating commits so previously queued edits can still save. + (when (and (:can-edit permissions) + (not (dm/get-in state [:workspace-global :preview-id]))) (log/trace :hint "commit-changes" :redo-changes redo-changes) (let [selected (dm/get-in state [:workspace-local :selected])] (rx/of (-> params diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index 03fbdb319a..adb9f10945 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -9,9 +9,13 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.logging :as log] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.main.data.changes :as dch] + [app.main.data.common :as-alias dc] [app.main.data.helpers :as dsh] + [app.main.data.workspace :as-alias dw] + [app.main.errors :as errors] [app.main.refs :as refs] [app.main.repo :as rp] [beicon.v2.core :as rx] @@ -21,24 +25,46 @@ (log/set-level! :warn) -(def running (atom false)) (def revn-data (atom {})) +(defonce ^:private active-requests (atom #{})) (def queue-conj (fnil conj #queue [])) (def force-persist? #(= % ::force-persist)) -(defn wait-persisted +(def ^:private saving-stall-timeout-ms (* 5 60 1000)) +(def ^:private saving-check-interval-ms 30000) +(def ^:private save-wait-timeout-ms (* 2 60 1000)) + +(defn wait-persisted-or-error "Returns an observable that emits the first terminal persistence status - (nil | :saved) and completes. With a timeout-ms, if persistence doesn't - settle in time the observable completes silently without emitting." + (nil | :saved) and completes. Raises when the queue has failed and, with + a timeout-ms, when persistence does not settle in time." + ([] (wait-persisted-or-error save-wait-timeout-ms)) + ([timeout-ms] + (let [base (->> (rx/from-atom refs/persistence {:emit-current-value? true}) + (rx/filter (fn [{:keys [status queue]}] + (or (= status :error) + (and (empty? queue) + (or (nil? status) (= status :saved)))))) + (rx/take 1) + (rx/mapcat (fn [{:keys [status error]}] + (if (= status :error) + (rx/throw (ex-info "Changes could not be saved" + (merge {:type :persistence :code :save-failed} error))) + (rx/of status)))))] + (cond->> base + timeout-ms + (rx/timeout timeout-ms + (rx/throw (ex-info "Timed out waiting for changes to be saved" + {:type :persistence :code :save-timeout}))))))) + +(defn wait-persisted + "Best-effort variant of `wait-persisted-or-error`: a failed or timed out + save completes the observable silently instead of raising." ([] (wait-persisted nil)) ([timeout-ms] - (let [base (->> (rx/from-atom refs/persistence-state {:emit-current-value? true}) - (rx/filter #(or (nil? %) (= :saved %))) - (rx/take 1))] - (if timeout-ms - (->> base (rx/timeout timeout-ms (rx/empty))) - base)))) + (->> (wait-persisted-or-error timeout-ms) + (rx/catch (fn [_] (rx/empty)))))) (defn force-persist-and-wait "Convenience that emits the force-persist event and then waits for @@ -47,25 +73,75 @@ ([timeout-ms] (rx/concat (rx/of ::force-persist) (wait-persisted timeout-ms)))) +(defn- next-status + "Refuses downgrades: a save in progress stays :saving, and a failed save + stays :error until persistence is resumed." + [from to] + (cond + (and (= to :pending) (= from :saving)) from + (and (= from :error) (#{:pending :saving} to)) from + :else to)) + (defn- update-status [status] (ptk/reify ::update-status ptk/UpdateEvent (update [_ state] - (update state :persistence (fn [pstate] - (log/trc :hint "update-status" - :from (:status pstate) - :to status) - (let [status (if (and (= status :pending) - (= (:status pstate) :saving)) - (:status pstate) - status)] + (update state :persistence + (fn [pstate] + (log/trc :hint "update-status" + :from (:status pstate) + :to status) + (let [status (next-status (:status pstate) status)] + (cond-> (assoc pstate :status status) + (#{:pending :saving} status) + (update :last-progress-at d/nilv (inst-ms (ct/now))) - (-> (assoc pstate :status status) - (cond-> (= status :error) - (dissoc :run-id)) - (cond-> (= status :saved) - (dissoc :run-id))))))))) + (#{:error :saved} status) + (dissoc :run-id :last-progress-at :stall-reported?)))))))) + +(defn- report-stalled-persistence + [now] + (ptk/reify ::report-stalled-persistence + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:persistence :stall-reported?] true)) + + ptk/EffectEvent + (effect [_ state _] + (let [{:keys [queue index status run-id last-progress-at]} (:persistence state) + commit-id (peek queue) + commit (get index commit-id) + hint "File saving has made no progress for more than five minutes" + cause (ex-info hint + {:type :persistence + :code :saving-stalled + :file-id (or (:file-id commit) (:current-file-id state)) + :commit-id commit-id + :run-id run-id + :status status + :queued-commits (count queue) + :elapsed-ms (- now last-progress-at) + :can-edit (dm/get-in state [:permissions :can-edit]) + :read-only? (dm/get-in state [:workspace-global :read-only?]) + :preview-id (dm/get-in state [:workspace-global :preview-id]) + :render-context-lost? (dm/get-in state [:render-state :lost])})] + (errors/submit-report :event-name "handled-exception" + :hint hint + :report (errors/generate-report cause)))))) + +(defn- check-persistence + [] + (ptk/reify ::check-persistence + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [status last-progress-at stall-reported?]} (:persistence state) + now (inst-ms (ct/now))] + (when (and (#{:pending :saving} status) + last-progress-at + (not stall-reported?) + (> (- now last-progress-at) saving-stall-timeout-ms)) + (rx/of (report-stalled-persistence now))))))) (defn- update-file-revn [file-id revn] @@ -90,7 +166,9 @@ (if (= commit-id (peek queue)) (pop queue) (throw (ex-info "invalid state" {}))))) - (update :index dissoc commit-id))))))) + (update :index dissoc commit-id) + (assoc :last-progress-at (inst-ms (ct/now))) + (dissoc :stall-reported?))))))) (defn- append-commit "Event used internally to append the current change to the @@ -104,62 +182,152 @@ (update state :persistence (fn [pstate] (-> pstate - (update :run-id d/nilv run-id) + (cond-> (not= :error (:status pstate)) + (update :run-id d/nilv run-id)) (update :queue queue-conj id) (update :index assoc id commit))))) ptk/WatchEvent (watch [_ state _] (let [pstate (:persistence state)] - (when (= run-id (:run-id pstate)) - (rx/of (run-persistence-task) - (update-status :saving)))))))) + (when (and (not= :error (:status pstate)) + (= run-id (:run-id pstate))) + (rx/of (update-status :saving) + (run-persistence-task)))))))) -(defn- discard-persistence-state - [] - (ptk/reify ::discard-persistence-state +(defn- persistence-failed + [commit-id cause] + (ptk/reify ::persistence-failed ptk/UpdateEvent (update [_ state] - (dissoc state :persistence)))) + (let [data (ex-data cause)] + (update state :persistence + (fn [pstate] + (-> pstate + (assoc :status :error + :error (assoc data + :type :persistence + :code (:code data :save-failed) + :cause-type (:type data) + :commit-id commit-id + :hint (ex-message cause) + ::errors/handled? true)) + (dissoc :run-id :last-progress-at :stall-reported?)))))) + + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::error cause))) + + ptk/EffectEvent + (effect [_ _ _] + ;; Report without invoking global handlers that may reload the file or + ;; navigate away before the user can recover the retained changes. + (errors/flash-persistence cause)))) + +(defn- commit-persisted + [commit] + (ptk/reify ::commit-persisted + IDeref + (-deref [_] commit) + + ptk/UpdateEvent + (update [_ state] + ;; Keep the acknowledgment even if the queue runner has stopped. + (d/update-in-when state [:persistence :index (:id commit)] + assoc ::acknowledged? true)))) + +(defn- update-file-request + "Issues the `update-file` request, tracked as active for its lifetime." + [request-id params] + (rx/create + (fn [subscriber] + (swap! active-requests conj request-id) + (let [source (try + (rp/cmd! :update-file params) + (catch :default cause + (rx/throw cause))) + subscription (.subscribe source subscriber)] + (fn [] + (swap! active-requests disj request-id) + (rx/dispose! subscription)))))) + +(defn- attempt-state + "Classifies what should happen with a queued commit before sending it. + The attempt stamp and the send decision both read this, so a commit is + only ever stamped with a request that is actually going to be sent." + [state commit-id request-id] + (let [commit (dm/get-in state [:persistence :index commit-id])] + (cond + (= :error (dm/get-in state [:persistence :status])) :halted + (nil? commit) :missing-commit + (::acknowledged? commit) :acknowledged + (contains? @active-requests (::request-id commit)) :in-flight + (and (::request-id commit) + (not= request-id (::request-id commit))) :unknown-outcome + (not (dm/get-in state [:permissions :can-edit])) :permission-denied + :else :ready))) + +(defn- send-queued-commit + "Sends one queued commit and maps its outcome to persistence events." + [request-id session-id {:keys [id file-id file-revn file-vern changes features] :as commit}] + (let [params {:id file-id + :revn (max file-revn (get @revn-data file-id 0)) + :vern file-vern + :session-id session-id + :origin (:origin commit) + :created-at (:created-at commit) + :commit-id id + :changes (vec changes) + :features features}] + ;; UI read-only mode does not invalidate already queued edits. + (->> (update-file-request request-id params) + (rx/take 1) + ;; A response that carries no revision, including one that never + ;; arrived, is treated as a failed save rather than a saved file. + (rx/if-empty nil) + (rx/mapcat (fn [{:keys [revn]}] + (if (and (int? revn) (<= 0 revn)) + (rx/of (update-file-revn file-id revn) + (commit-persisted commit)) + (rx/throw (ex-info "The save response has no valid revision" + {:type :persistence + :code :invalid-save-response + :file-id file-id}))))) + (rx/catch (fn [cause] + (rx/of (persistence-failed id cause))))))) (defn- persist-commit [commit-id] - (ptk/reify ::persist-commit - ptk/WatchEvent - (watch [_ state _] - (log/dbg :hint "persist-commit" :commit-id (dm/str commit-id)) - (when-let [{:keys [file-id file-revn file-vern changes features] :as commit} (dm/get-in state [:persistence :index commit-id])] - (let [sid (:session-id state) - revn (max file-revn (get @revn-data file-id 0)) - params {:id file-id - :revn revn - :vern file-vern - :session-id sid - :origin (:origin commit) - :created-at (:created-at commit) - :commit-id commit-id - :changes (vec changes) - :features features} - permissions (:permissions state)] + (let [request-id (uuid/next)] + (ptk/reify ::persist-commit + ptk/UpdateEvent + (update [_ state] + (if (= :ready (attempt-state state commit-id request-id)) + ;; Record the attempt before starting I/O. An interrupted request + ;; may have reached the server and must not be replayed blindly. + (assoc-in state [:persistence :index commit-id ::request-id] request-id) + state)) - ;; Prevent saving changes when in version preview (read-only) mode - ;; or when the user does not have edition permission. - (when (and (:can-edit permissions) - (not (get-in state [:workspace-global :read-only?]))) - (->> (rp/cmd! :update-file params) - (rx/mapcat (fn [{:keys [revn lagged] :as response}] - (log/debug :hint "changes persisted" :commit-id (dm/str commit-id) :lagged (count lagged)) - (rx/of (ptk/data-event ::commit-persisted commit) - (update-file-revn file-id revn)))) - - (rx/catch (fn [cause] - (rx/concat - (if (= :authentication (:type cause)) - (rx/empty) - (rx/of (ptk/data-event ::error cause) - (update-status :error))) - (rx/of (discard-persistence-state)) - (rx/throw cause))))))))))) + ptk/WatchEvent + (watch [_ state _] + (let [commit (dm/get-in state [:persistence :index commit-id]) + fail (fn [code hint] + (rx/of (persistence-failed commit-id + (ex-info hint {:type :persistence + :code code + :commit-id commit-id + :file-id (:file-id commit)}))))] + (case (attempt-state state commit-id request-id) + :halted (rx/empty) + :missing-commit (fail :missing-commit "A queued save has no change data") + :acknowledged (rx/of (commit-persisted commit)) + ;; The replacement runner listens for the original request's result. + :in-flight (rx/empty) + ;; Even :network and :offline do not prove that the server + ;; skipped the write. Keep the attempt stamp to prevent replay. + :unknown-outcome (fail :save-outcome-unknown "An interrupted save has an unknown outcome") + :permission-denied (fail :save-permission-denied "Edit permission was lost before changes could be saved") + :ready (send-queued-commit request-id (:session-id state) commit))))))) (defn- run-persistence-task @@ -168,14 +336,18 @@ ptk/WatchEvent (watch [_ state stream] (let [queue (-> state :persistence :queue)] - (if-let [commit-id (peek queue)] - (let [stoper-s (rx/merge + (cond + (= :error (dm/get-in state [:persistence :status])) + (rx/empty) + + (seq queue) + (let [commit-id (peek queue) + stoper-s (rx/merge (rx/filter (ptk/type? ::run-persistence-task) stream) (rx/filter (ptk/type? ::error) stream))] (log/dbg :hint "run-persistence-task" :commit-id (dm/str commit-id)) (->> (rx/merge - (rx/of (persist-commit commit-id)) (->> stream (rx/filter (ptk/type? ::commit-persisted)) (rx/map deref) @@ -183,8 +355,47 @@ (rx/take 1) (rx/mapcat (fn [_] (rx/of (discard-commit commit-id) - (run-persistence-task)))))) + (run-persistence-task))))) + (rx/of (persist-commit commit-id))) (rx/take-until stoper-s))) + + :else + (rx/of (update-status :saved))))))) + +(defn- resume-persistence + [] + (ptk/reify ::resume-persistence + ptk/UpdateEvent + (update [_ state] + (update state :persistence + (fn [pstate] + (-> pstate + (dissoc :error) + (assoc :run-id (uuid/next) :status :saving) + (update :last-progress-at d/nilv (inst-ms (ct/now))))))) + ptk/WatchEvent + (watch [_ _ _] + (rx/of (run-persistence-task))))) + +(defn- recover-persistence + [] + (ptk/reify ::recover-persistence + ptk/WatchEvent + (watch [_ state _] + (let [{:keys [queue index status error run-id]} (:persistence state) + commit (get index (peek queue))] + (cond + (and (seq queue) + (or (not= status :error) + (and (= :save-permission-denied (:code error)) + (not (::request-id commit)) + (= (:file-id commit) (:current-file-id state)) + (dm/get-in state [:permissions :can-edit])))) + (rx/of (resume-persistence)) + + (and (empty? queue) + (not= status :error) + (or run-id (#{:pending :saving} status))) (rx/of (update-status :saved))))))) (def ^:private xf-mapcat-undo @@ -229,8 +440,21 @@ (rx/filter #(= % ::force-persist))))] (rx/merge + (rx/of (recover-persistence)) + + (->> stream + (rx/filter #(or (ptk/type? ::dc/change-team-role %) + (ptk/type? ::dw/workspace-initialized %))) + (rx/map (fn [_] (recover-persistence))) + (rx/take-until stoper-s)) + + (->> (rx/interval saving-check-interval-ms) + (rx/map (fn [_] (check-persistence))) + (rx/take-until stoper-s)) + (->> notifier-s - (rx/map #(ptk/data-event ::persistence-notification))) + (rx/map #(ptk/data-event ::persistence-notification)) + (rx/take-until stoper-s)) (->> local-commits-s (rx/debounce 200) @@ -242,10 +466,10 @@ ;; chunks (very near in time commits) and append them to the ;; persistence queue (->> local-commits-s + (rx/take-until stoper-s) (rx/buffer-until notifier-s) (rx/mapcat merge-commit) (rx/map append-commit) - (rx/take-until (rx/delay 100 stoper-s)) (rx/finalize (fn [] (log/debug :hint "finalize persistence: changes watcher")))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index da6a9bb076..bfa0276ce9 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -601,12 +601,11 @@ :workspace-editor-state :workspace-wasm-editor-styles :workspace-media-objects - :workspace-persistence :workspace-presence :workspace-tokens :workspace-undo :workspace-versions) - (update :workspace-global dissoc :read-only? :default-font) + (update :workspace-global dissoc :read-only? :preview-id :default-font) (assoc-in [:workspace-global :options-mode] :design) (update :files d/update-vals #(dissoc % :data)))) diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 7dc4bfbb1f..0f841f2072 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -72,7 +72,7 @@ (rx/of ::dwp/force-persist (ev/event {::ev/name "create-version"})) - (->> (dwp/wait-persisted) + (->> (dwp/wait-persisted-or-error) (rx/mapcat #(rp/cmd! :create-file-snapshot {:file-id file-id :label label})) (rx/mapcat (fn [{:keys [id]}] @@ -95,6 +95,15 @@ (->> (rp/cmd! :update-file-snapshot {:id id :label label}) (rx/map fetch-versions))))))) +(defn- clear-preview-state + [] + (ptk/reify ::clear-preview-state + ptk/UpdateEvent + (update [_ state] + (-> state + (update :workspace-versions dissoc :backup) + (update :workspace-global dissoc :read-only? :preview-id))))) + (defn- initialize-version [] (ptk/reify ::initialize-version @@ -108,7 +117,10 @@ (->> stream (rx/filter (ptk/type? ::dw/bundle-fetched)) (rx/take 1) - (rx/map #(dwpg/initialize-page file-id page-id))) + ;; Keep historical content read-only until the restored file + ;; has loaded, including when saving or loading fails. + (rx/mapcat #(rx/of (clear-preview-state) + (dwpg/initialize-page file-id page-id)))) (rx/of (ntf/hide :tag :restore-dialog) (dw/initialize-file team-id file-id))))) @@ -200,12 +212,6 @@ [id] (assert (uuid? id) "expected valid uuid for `id`") (ptk/reify ::restore-version - ptk/UpdateEvent - (update [_ state] - ;; Clear preview state if we're restoring from preview mode - (-> state - (update :workspace-versions dissoc :backup) - (update :workspace-global dissoc :read-only? :preview-id))) ptk/WatchEvent (watch [_ state _] (let [file-id (:current-file-id state)] @@ -213,9 +219,12 @@ (rx/of ::dwp/force-persist (dw/remove-layout-flag :document-history)) - (->> (dwp/wait-persisted) + (->> (dwp/wait-persisted-or-error) (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) - (rx/map #(initialize-version)))))))) + (rx/map #(initialize-version)) + (rx/catch (fn [cause] + (rx/concat (rx/of (exit-preview)) + (rx/throw cause)))))))))) (defn enter-restore [id] @@ -356,7 +365,7 @@ (rx/of ::dwp/force-persist)) (->> (if (= file-id current-file-id) - (dwp/wait-persisted) + (dwp/wait-persisted-or-error) (rx/of :nothing)) (rx/mapcat (fn [_] @@ -386,7 +395,7 @@ ::ev/origin "plugins"}) ::dwp/force-persist) - (->> (dwp/wait-persisted) + (->> (dwp/wait-persisted-or-error) (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id})) (rx/map #(initialize-version))) @@ -394,10 +403,11 @@ (rx/tap resolve) (rx/ignore))) - ;; On error reject the promise and empty the stream + ;; Restore the live file before rejecting the plugin promise. (rx/catch (fn [error] - (reject error) - (rx/empty))))))) - - - + (rx/concat + (rx/of (exit-preview)) + (->> (rx/of error) + (rx/observe-on :async) + (rx/tap reject) + (rx/ignore))))))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 48aba11881..84fd7194ea 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -172,6 +172,7 @@ (defn flash "Show error notification banner and emit error report. + A nil timeout keeps the notification visible until dismissed or replaced. The notification is scheduled asynchronously (via tm/schedule) to avoid pushing a new event into the potok store while the store's own @@ -179,7 +180,7 @@ synchronously from inside an error handler creates a re-entrant event-processing cycle that can exhaust the JS call stack (RangeError: Maximum call stack size exceeded)." - [& {:keys [type hint cause] :or {type :handled}}] + [& {:keys [type hint cause timeout] :or {type :handled timeout 5000}}] (when (ex/exception? cause) (when-let [event-name (case type :handled "handled-exception" @@ -195,7 +196,7 @@ (ntf/show {:content (or ^boolean hint (tr "errors.generic")) :type :toast :level :error - :timeout 5000})))) + :timeout timeout})))) (defmethod ptk/handle-error :network [error] @@ -206,6 +207,21 @@ (ex/print-throwable cause :prefix "Network Error")) (flash :cause (::instance error) :type :handled)) +(defn flash-persistence + [cause] + (let [{:keys [type cause-type]} (ex-data cause)] + ;; Authentication has its own UI. `flash :silent` only skips reporting; + ;; it still shows a toast, so do not call it for these failures. + (when-not (or (= :authentication type) (= :authentication cause-type)) + (flash :cause cause :type :handled :timeout nil :hint (tr "errors.save-failed"))))) + +(defmethod ptk/handle-error :persistence + [error] + ;; The persistence failure event reports the original cause. Waiters still + ;; reject, but must not report that same incident again. + (when-not (::handled? error) + (flash-persistence (::instance error)))) + (defmethod ptk/handle-error :internal [error] (st/emit! (rt/assign-exception error)) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 5d8a6b9824..456469320a 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -666,9 +666,6 @@ (def updating-library (l/derived :updating-library st/state)) -(def persistence-state - (l/derived (comp :status :persistence) st/state)) - (def progress (l/derived :progress st/state)) diff --git a/frontend/src/app/main/ui/workspace/left_header.cljs b/frontend/src/app/main/ui/workspace/left_header.cljs index 218b9df4b3..244135c959 100644 --- a/frontend/src/app/main/ui/workspace/left_header.cljs +++ b/frontend/src/app/main/ui/workspace/left_header.cljs @@ -34,8 +34,9 @@ persistence (mf/deref refs/persistence) + ;; Nothing queued to save means the file is up to date. persistence-status - (get persistence :status) + (or (:status persistence) :saved) editing* (mf/use-state false) editing? (deref editing*) @@ -109,7 +110,7 @@ {:class (stl/css :file-name) :title file-name :on-double-click start-editing-name} - ;;-- Persistende state widget + ;; Persistence state widget [:div {:class (case persistence-status :pending (stl/css :status-notification :pending-status) :saving (stl/css :status-notification :saving-status) diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss index 20c731dd86..9127ec1270 100644 --- a/frontend/src/app/main/ui/workspace/left_header.scss +++ b/frontend/src/app/main/ui/workspace/left_header.scss @@ -103,7 +103,6 @@ border-radius: 50%; margin-right: deprecated.$s-4; flex-shrink: 0; - background-color: var(--status-widget-background-color-pending); &.pending-status { background-color: var(--status-widget-background-color-warning); diff --git a/frontend/test/frontend_tests/data/persistence_test.cljs b/frontend/test/frontend_tests/data/persistence_test.cljs new file mode 100644 index 0000000000..8cdb42789c --- /dev/null +++ b/frontend/test/frontend_tests/data/persistence_test.cljs @@ -0,0 +1,378 @@ +;; 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 + +(ns frontend-tests.data.persistence-test + (:require + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.main.data.changes :as dch] + [app.main.data.persistence :as dps] + [app.main.data.render-wasm :as drw] + [app.main.errors :as errors] + [app.main.repo :as rp] + [app.main.store :as st] + [app.util.i18n :as i18n] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) + +(defn- local-commit + [file-id] + (ptk/data-event ::dch/commit + {:id (uuid/next) + :file-id file-id + :file-revn 0 + :file-vern 0 + :source :local + :features #{} + :redo-changes [{:type :mod-page :id (uuid/next) :name "Edited"}] + :undo-changes []})) + +(t/deftest queued-edits-save-during-temporary-read-only-mode + (doseq [read-only-event [(drw/context-lost) + #(assoc-in % [:workspace-global :read-only?] true) + #(assoc % :workspace-global {:read-only? true + :preview-id (uuid/next)})]] + (let [file-id (uuid/next) + response (rx/subject) + errors (atom []) + store (ptk/store {:state {:permissions {:can-edit true} + :files {file-id {:id file-id :revn 0}}} + :on-error #(swap! errors conj %)})] + (with-redefs [rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response)))] + (try + (ptk/emit! store (dps/initialize-persistence) + (local-commit file-id) + read-only-event + ::dps/force-persist) + (rx/push! response {:revn 1}) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue]))) + + (ptk/emit! store (drw/context-restored) + #(assoc-in % [:workspace-global :read-only?] false) + (local-commit file-id) + ::dps/force-persist) + (rx/push! response {:revn 2}) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue]))) + (t/is (empty? @errors)) + (finally + (rx/dispose! store) + (rx/end! response))))))) + +(t/deftest historical-preview-cannot-create-local-commits + (let [file-id (uuid/next) + output (atom []) + state {:current-file-id file-id + :permissions {:can-edit true} + :files {file-id {:id file-id :revn 0 :vern 0}} + :workspace-global {:read-only? true :preview-id (uuid/next)}} + event (dch/commit-changes {:redo-changes [] :undo-changes []})] + (when-let [result (ptk/watch event state (rx/empty))] + (->> result (rx/subs! #(swap! output conj %)))) + (t/is (empty? @output)))) + +(defn- with-watchdog + [f] + (let [clock (atom 0) + ticks (rx/subject) + response (rx/subject) + reports (atom []) + causes (atom []) + render errors/generate-report + file-id (uuid/next) + store (ptk/store {:state {:current-file-id file-id + :permissions {:can-edit true} + :files {file-id {:id file-id :revn 0}}} + :on-error #(t/is false (str %))})] + (with-redefs [ct/now (mock/stub #(ct/inst @clock)) + rx/interval (mock/stub (fn [_] ticks)) + rp/cmd! (mock/stub (fn [_ _] (rx/take 1 response))) + st/state store + errors/generate-report (fn [cause] + (swap! causes conj cause) + (render cause)) + errors/submit-report (fn [& params] + (swap! reports conj (apply hash-map params)))] + (try + (ptk/emit! store (dps/initialize-persistence)) + (f {:clock clock :ticks ticks :response response :causes causes + :reports reports :store store :file-id file-id}) + (finally + (rx/dispose! store) + (rx/end! ticks) + (rx/end! response)))))) + +(t/deftest stalled-request-is-reported-once-without-discarding-edits + (with-watchdog + (fn [{:keys [clock ticks reports causes store file-id]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (reset! clock 300000) + (rx/push! ticks :tick) + (t/is (empty? @reports) "Five minutes must elapse before reporting") + + ;; More local edits must not reset the stalled request's clock. + (reset! clock 300001) + (ptk/emit! store (drw/context-lost) + (local-commit file-id) ::dps/force-persist) + (rx/push! ticks :tick) + (t/is (= 1 (count @reports))) + (t/is (= "handled-exception" (:event-name (first @reports)))) + (let [data (ex-data (first @causes))] + (t/is (= :saving-stalled (:code data))) + (t/is (= file-id (:file-id data))) + (t/is (true? (:render-context-lost? data)))) + + (reset! clock 900000) + (rx/push! ticks :tick) + (t/is (= 1 (count @reports)) "Do not repeat a report for the same stall") + (t/is (= :saving (get-in @store [:persistence :status]))) + (t/is (= 2 (count (get-in @store [:persistence :queue]))))))) + +(t/deftest successful-saves-reset-the-stall-clock-and-allow-a-new-report + (with-watchdog + (fn [{:keys [clock ticks response reports store file-id]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist + (local-commit file-id) ::dps/force-persist) + (reset! clock 290000) + (rx/push! response {:revn 1}) + (reset! clock 300001) + (rx/push! ticks :tick) + (t/is (empty? @reports) "The queue is making progress") + + (reset! clock 590001) + (rx/push! ticks :tick) + (t/is (= 1 (count @reports)) "The second request has now stalled") + + (rx/push! response {:revn 2}) + (t/is (= :saved (get-in @store [:persistence :status]))) + (reset! clock 1000000) + (rx/push! ticks :tick) + (t/is (= 1 (count @reports)) "A saved file must not be reported") + + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (reset! clock 1300001) + (rx/push! ticks :tick) + (t/is (= 2 (count @reports)) "A later stall gets its own report")))) + +(t/deftest pending-edits-are-monitored-without-extending-the-deadline + (with-watchdog + (fn [{:keys [clock ticks reports store]}] + (rx/push! ticks :tick) + (t/is (empty? @reports) "An idle file must not be reported") + (ptk/emit! store (#'dps/update-status :pending)) + (reset! clock 300001) + (ptk/emit! store (#'dps/update-status :pending)) + (rx/push! ticks :tick) + (t/is (= 1 (count @reports))) + (ptk/emit! store (#'dps/update-status :error)) + (reset! clock 900000) + (rx/push! ticks :tick) + (t/is (= 1 (count @reports)) "Do not report an already failed save")))) + +(t/deftest reinitializing-persistence-replaces-the-watchdog + (let [active-timers (atom 0) + ticks (rx/subject) + store (ptk/store {:state {} :on-error #(t/is false (str %))})] + (with-redefs [rx/interval (mock/stub + (fn [_] + (rx/create + (fn [subscriber] + (swap! active-timers inc) + (let [subscription (.subscribe ticks subscriber)] + (fn [] + (rx/dispose! subscription) + (swap! active-timers dec)))))))] + (try + (ptk/emit! store (dps/initialize-persistence)) + (t/is (= 1 @active-timers)) + (ptk/emit! store (dps/initialize-persistence)) + (t/is (= 1 @active-timers)) + (finally + (rx/dispose! store) + (rx/end! ticks)))) + (t/is (zero? @active-timers)))) + +(defn- with-persistence + [f] + (let [file-id (uuid/next) + response (rx/subject) + failures (atom []) + requests (atom []) + store (ptk/store {:state {:current-file-id file-id + :permissions {:can-edit true} + :files {file-id {:id file-id :revn 0}}} + :on-error #(t/is false (str %))})] + (with-redefs [rp/cmd! (mock/stub (fn [cmd params] + (swap! requests conj [cmd params]) + (rx/take 1 response))) + errors/flash (fn [& {:keys [cause]}] + (swap! failures conj cause))] + (try + (ptk/emit! store (dps/initialize-persistence)) + (f {:file-id file-id :response response :failures failures + :requests requests :store store}) + (finally + (rx/dispose! store) + (rx/end! response)))))) + +(t/deftest permission-loss-fails-without-discarding-queued-edits + (with-persistence + (fn [{:keys [file-id requests failures store]}] + (ptk/emit! store (local-commit file-id) + #(assoc-in % [:permissions :can-edit] false) + ::dps/force-persist) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + (t/is (empty? @requests)) + (t/is (= 1 (count @failures))) + (ptk/emit! store (local-commit file-id) ::dps/force-persist + (#'dps/update-status :pending)) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= 2 (count (get-in @store [:persistence :queue]))))))) + +(t/deftest failed-request-retains-the-queue-and-is-not-retried-on-initialization + (with-persistence + (fn [{:keys [file-id response requests store]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist + (local-commit file-id) ::dps/force-persist) + (.error response (ex-info "Connection lost" {:type :network})) + (ptk/emit! store (dps/initialize-persistence)) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= 2 (count (get-in @store [:persistence :queue])))) + (t/is (= 1 (count @requests)))))) + +(t/deftest save-failures-use-a-translated-warning-except-for-authentication + (doseq [cause-type [:network :offline :authentication]] + (with-persistence + (fn [{:keys [file-id response store]}] + (let [notifications (atom [])] + (with-redefs [errors/flash (fn [& params] + (swap! notifications conj (apply hash-map params))) + i18n/tr (mock/stub #(str "translated:" %))] + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (.error response (ex-info "Raw transport details" {:type cause-type})) + (t/is (= :error (get-in @store [:persistence :status]))) + (let [data (get-in @store [:persistence :error])] + (ptk/handle-error (assoc data ::errors/instance (ex-info "Save failed" data)))) + (if (= cause-type :authentication) + (t/is (empty? @notifications)) + (t/is (= ["translated:errors.save-failed"] + (mapv :hint @notifications)))))))))) + +(t/deftest missing-commit-is-an-error-instead-of-skipping-changes + (with-persistence + (fn [{:keys [requests store]}] + (let [id (uuid/next)] + (ptk/emit! store + #(assoc % :persistence {:queue (conj #queue [] id) + :index {} :run-id (uuid/next) + :status :saving}) + (dps/initialize-persistence)) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= [id] (vec (get-in @store [:persistence :queue])))) + (t/is (empty? @requests)))))) + +(t/deftest initialization-recovers-an-unsent-commit-with-a-dangling-run-id + (with-persistence + (fn [{:keys [file-id response requests store]}] + (let [commit (assoc @(local-commit file-id) :changes []) + id (:id commit)] + (ptk/emit! store + #(assoc % :persistence {:queue (conj #queue [] id) + :index {id commit} :run-id (uuid/next) + :status :saving}) + (dps/initialize-persistence)) + (t/is (= 1 (count @requests))) + (rx/push! response {:revn 1}) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue]))))))) + +(t/deftest an-active-request-is-never-sent-twice + (doseq [interrupt [[(dps/initialize-persistence)] + [(ptk/data-event ::dps/error)]]] + (with-persistence + (fn [{:keys [file-id response requests store]}] + (apply ptk/emit! store (local-commit file-id) ::dps/force-persist interrupt) + (ptk/emit! store (dps/initialize-persistence)) + (t/is (= 1 (count @requests))) + (rx/push! response {:revn 1}) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue]))))))) + +(t/deftest permission-restoration-resumes-only-unsent-edits + (with-persistence + (fn [{:keys [file-id response requests store]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist + (local-commit file-id) ::dps/force-persist + #(assoc-in % [:permissions :can-edit] false)) + (rx/push! response {:revn 1}) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + (ptk/emit! store + #(assoc-in % [:permissions :can-edit] true) + (ptk/data-event :app.main.data.common/change-team-role)) + (t/is (= 2 (count @requests))) + (rx/push! response {:revn 2}) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue])))))) + +(t/deftest recovery-keeps-an-acknowledgment-received-without-a-runner + (with-persistence + (fn [{:keys [file-id response requests store]}] + (ptk/emit! store (local-commit file-id) ::dps/force-persist + (ptk/data-event ::dps/error)) + (rx/push! response {:revn 1}) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + (ptk/emit! store (dps/initialize-persistence)) + (t/is (= 1 (count @requests)) "The acknowledged changes must not be sent again") + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue])))))) + +(t/deftest recovery-reports-an-unknown-request-outcome-without-replaying-it + (with-persistence + (fn [{:keys [file-id requests store]}] + (let [commit (assoc @(local-commit file-id) ::dps/request-id (uuid/next)) + id (:id commit)] + (ptk/emit! store + #(assoc % :persistence {:queue (conj #queue [] id) + :index {id commit} :status :saving}) + (dps/initialize-persistence)) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= :save-outcome-unknown (get-in @store [:persistence :error :code]))) + (t/is (= [id] (vec (get-in @store [:persistence :queue])))) + (t/is (empty? @requests)))))) + +(t/deftest initialization-flushes-buffered-edits-without-duplicating-them + (with-persistence + (fn [{:keys [file-id response requests store]}] + (ptk/emit! store (local-commit file-id) + (dps/initialize-persistence) + ::dps/force-persist) + (t/is (= 1 (count @requests))) + (t/is (= 1 (count (get-in @store [:persistence :queue])))) + (rx/push! response {:revn 1}) + (t/is (= :saved (get-in @store [:persistence :status])))))) + +(t/deftest synchronous-save-results-do-not-leave-a-dangling-runner + (with-persistence + (fn [{:keys [file-id store]}] + (with-redefs [rp/cmd! (mock/stub (fn [_ _] (rx/of {:revn 1})))] + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (t/is (= :saved (get-in @store [:persistence :status]))) + (t/is (empty? (get-in @store [:persistence :queue]))))))) + +(t/deftest empty-or-invalid-save-responses-preserve-the-queue-as-failed + (doseq [result [(rx/empty) (rx/of nil) (rx/of {:revn -1})]] + (with-persistence + (fn [{:keys [file-id store]}] + (with-redefs [rp/cmd! (mock/stub (fn [_ _] result))] + (ptk/emit! store (local-commit file-id) ::dps/force-persist) + (t/is (= :error (get-in @store [:persistence :status]))) + (t/is (= :invalid-save-response (get-in @store [:persistence :error :code]))) + (t/is (= 1 (count (get-in @store [:persistence :queue]))))))))) diff --git a/frontend/test/frontend_tests/data/workspace_versions_test.cljs b/frontend/test/frontend_tests/data/workspace_versions_test.cljs new file mode 100644 index 0000000000..71bde1bb01 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_versions_test.cljs @@ -0,0 +1,189 @@ +;; 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 + +(ns frontend-tests.data.workspace-versions-test + (:require + [app.common.uuid :as uuid] + [app.main.data.changes :as dch] + [app.main.data.persistence :as dps] + [app.main.data.workspace :as dw] + [app.main.data.workspace.pages :as dwpg] + [app.main.data.workspace.versions :as versions] + [app.main.refs :as refs] + [app.main.repo :as rp] + [app.util.timers :as tm] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] + [potok.v2.core :as ptk])) + +(t/deftest version-creation-rejects-save-failure-and-timeout + (doseq [failure [:error :timeout] + operation [:create :plugin-create]] + (t/testing (str operation " after " failure) + (let [file-id (uuid/next) + resolved (atom []) + rejected (atom []) + requests (atom []) + state {:current-file-id file-id} + event (case operation + :create (versions/create-version) + :plugin-create (versions/create-version-from-plugins + file-id "Version" + #(swap! resolved conj %) #(swap! rejected conj %)))] + (with-redefs [refs/persistence (atom {:status (if (= failure :error) :error :saving) + :error {:type :persistence :code :save-permission-denied}}) + rp/cmd! (mock/stub (fn [cmd _] + (swap! requests conj cmd) + (rx/of nil))) + rx/timeout (mock/stub (fn [_ fallback source] + (if (= failure :timeout) fallback source)))] + (->> (ptk/watch event state (rx/empty)) + (rx/subs! (fn [_]) #(swap! rejected conj %))) + (t/is (empty? @requests) "Do not create a version without saving") + (t/is (empty? @resolved) "Do not resolve the plugin promise on failure") + (t/is (= 1 (count @rejected))) + (t/is (= (if (= failure :error) :save-permission-denied :save-timeout) + (:code (ex-data (first @rejected)))))))))) + +(t/deftest leaving-the-workspace-clears-the-preview-flags + (let [team-id (uuid/next) + file-id (uuid/next) + other-id (uuid/next) + output (atom []) + state (-> {:current-file-id file-id + :current-team-id team-id + :permissions {:can-edit true} + :files {file-id {:id file-id :revn 0 :vern 0}} + :workspace-global {:read-only? true :preview-id (uuid/next)}} + (as-> $ (ptk/update (dw/finalize-workspace team-id file-id) $)) + (assoc :current-file-id other-id) + (assoc-in [:files other-id] {:id other-id :revn 0 :vern 0}))] + (t/is (nil? (get-in state [:workspace-global :preview-id]))) + (when-let [result (ptk/watch (dch/commit-changes {:redo-changes [] :undo-changes []}) + state (rx/empty))] + (->> result (rx/subs! #(swap! output conj %)))) + (t/is (= 1 (count @output)) + "Editing must work again on the next file opened in the tab"))) + +(t/deftest strict-persistence-wait-rejects-a-real-timeout + (t/async done + (mock/with-mocks + {refs/persistence (atom {:status :saving})} + (fn [finish] + (->> (dps/wait-persisted-or-error 0) + (rx/subs! (fn [_] + (t/is false "The stalled save must not succeed") + (finish)) + (fn [cause] + (t/is (= :save-timeout (:code (ex-data cause)))) + (finish))))) + done))) + +(t/deftest strict-persistence-wait-observes-a-later-failure + (let [pstate (atom {:status :saving}) + rejected (atom []) + resolved (atom [])] + (with-redefs [refs/persistence pstate] + (let [subscription (->> (dps/wait-persisted-or-error) + (rx/subs! #(swap! resolved conj %) #(swap! rejected conj %)))] + (try + (reset! pstate {:status :error :error {:code :missing-commit}}) + (t/is (empty? @resolved)) + (t/is (= :missing-commit (:code (ex-data (first @rejected))))) + (finally + (rx/dispose! subscription))))))) + +(t/deftest failed-restore-returns-to-the-live-file-before-reporting + ;; The plugin promise is rejected on a later tick, so every assertion about + ;; the rejection runs once the event loop has drained. + (t/async done + (let [deferred (atom [])] + (doseq [operation [:ui :plugin] + failure [:saving :timeout :restore] + preview? [true false]] + (let [label (str operation " after " failure ", preview=" preview?) + file-id (uuid/next) + version-id (uuid/next) + backup {:id file-id :revn 10} + snapshot {:id file-id :revn 5} + rejected (atom []) + resolved (atom []) + requests (atom []) + initialized (atom []) + at-reject (atom nil) + store (ptk/store {:state (cond-> {:current-file-id file-id + :files {file-id backup}} + preview? + (assoc :files {file-id snapshot} + :workspace-global {:read-only? true :preview-id version-id} + :workspace-versions {:backup backup})) + :on-error #(swap! rejected conj %)}) + reject (fn [cause] + (reset! at-reject + {:file (get-in @store [:files file-id]) + :preview-id (get-in @store [:workspace-global :preview-id])}) + (swap! rejected conj cause)) + event (if (= operation :ui) + (#'versions/restore-version version-id) + (versions/restore-version-from-plugin + file-id version-id #(swap! resolved conj %) reject))] + (t/testing label + (with-redefs [refs/persistence (atom {:status (case failure + :saving :error :timeout :saving :saved)}) + rp/cmd! (mock/stub (fn [cmd _] + (swap! requests conj cmd) + (rx/throw (ex-info "Restore failed" {:type :internal})))) + rx/timeout (mock/stub (fn [_ fallback source] + (if (= failure :timeout) fallback source))) + dwpg/initialize-page (mock/stub + (fn [_ _] + (fn [state] + (swap! initialized conj (get-in state [:files file-id])) + state)))] + (ptk/emit! store event) + (t/is (empty? @resolved)) + (t/is (= (if (= failure :restore) [:restore-file-snapshot] []) @requests) + "Do not restore a version without saving") + (t/is (= backup (get-in @store [:files file-id]))) + (t/is (nil? (get-in @store [:workspace-global :read-only?]))) + (t/is (nil? (get-in @store [:workspace-global :preview-id]))) + (t/is (nil? (get-in @store [:workspace-versions :backup]))) + (t/is (= (if preview? [backup] []) @initialized)))) + (swap! deferred conj + (fn [] + (t/testing label + (t/is (= 1 (count @rejected))) + (when (= operation :plugin) + (t/is (= {:file backup :preview-id nil} @at-reject) + "The live file is back before the plugin promise rejects"))) + (rx/dispose! store))))) + (tm/schedule 50 (fn [] + (doseq [check @deferred] (check)) + (done)))))) + +(t/deftest successful-restore-clears-preview-only-after-loading + (let [file-id (uuid/next) + state {:current-file-id file-id + :current-team-id (uuid/next) + :current-page-id (uuid/next) + :workspace-global {:read-only? true :preview-id (uuid/next)} + :workspace-versions {:backup {:id file-id}}} + stream (rx/subject) + events (atom []) + cleanup #(filter (ptk/type? ::versions/clear-preview-state) @events) + sub (->> (ptk/watch (#'versions/initialize-version) state stream) + (rx/subs! #(swap! events conj %)))] + (try + (t/is (empty? (cleanup))) + (rx/push! stream (ptk/data-event ::dw/bundle-fetched nil)) + (t/is (= 1 (count (cleanup)))) + (let [result (ptk/update (first (cleanup)) state)] + (t/is (nil? (get-in result [:workspace-global :read-only?]))) + (t/is (nil? (get-in result [:workspace-global :preview-id]))) + (t/is (nil? (get-in result [:workspace-versions :backup])))) + (finally + (rx/dispose! sub))))) diff --git a/frontend/test/frontend_tests/main_errors_test.cljs b/frontend/test/frontend_tests/main_errors_test.cljs index d35af2f2ca..50671411af 100644 --- a/frontend/test/frontend_tests/main_errors_test.cljs +++ b/frontend/test/frontend_tests/main_errors_test.cljs @@ -15,7 +15,10 @@ - organization SSO recovery – expired SSO sessions go back to the provider - invalid-sso-config handler – requires :organization-id to promote to :sso-error" (:require + [app.common.uuid :as uuid] + [app.main.data.persistence :as dps] [app.main.errors :as errors] + [app.main.refs :as refs] [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] @@ -396,3 +399,48 @@ (t/is (= :validation (:type assigned))) (t/is (nil? (:organization-id assigned))) (t/is (= :invalid-sso-config (:code assigned)))))) + +(t/deftest persistence-notifications-do-not-expire-but-other-flashes-do + (doseq [[notify timeout] [[#(errors/flash :hint "Ordinary error") 5000] + [#(errors/flash :hint "Custom error" :timeout 1000) 1000] + [#(errors/flash-persistence nil) nil]]] + (let [scheduled (atom []) + events (atom [])] + (with-redefs [tm/schedule (mock/stub #(swap! scheduled conj %)) + st/emit! (mock/stub (fn [& emitted] (swap! events into emitted)))] + (notify) + (t/is (empty? @events) "Keep notification delivery asynchronous") + (doseq [callback @scheduled] (callback)) + (t/is (= 1 (count @events))) + (let [state (ptk/update (first @events) {})] + (t/is (= timeout (get-in state [:notification :timeout]))) + (t/is (= :visible (get-in state [:notification :status])))))))) + +(t/deftest persistence-waiters-do-not-report-an-already-handled-failure + (let [reports (atom []) + rejected (atom []) + pstate (atom {:status :saving}) + store (ptk/store {:state {} :on-error errors/on-error}) + cause (ex-info "Save failed" {:type :network})] + (with-redefs [refs/persistence pstate + errors/submit-report (fn [& params] + (swap! reports conj (apply hash-map params))) + tm/schedule (mock/stub (fn [_]))] + (try + (ptk/emit! store (#'dps/persistence-failed (uuid/next) cause)) + (reset! pstate (:persistence @store)) + (dotimes [_ 2] + (->> (dps/wait-persisted-or-error) + (rx/subs! (fn [_] (t/is false "A failed save must still reject")) + (fn [error] + (swap! rejected conj error) + (errors/on-error error))))) + (t/is (= 2 (count @rejected))) + (t/is (= 1 (count @reports))) + ;; A standalone timeout and a later save failure are new incidents. + (errors/on-error (ex-info "Save timed out" {:type :persistence :code :save-timeout})) + (t/is (= 2 (count @reports))) + (ptk/emit! store (#'dps/persistence-failed (uuid/next) cause)) + (t/is (= 3 (count @reports))) + (finally + (rx/dispose! store)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index f62963d534..96b79f0419 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -11,6 +11,7 @@ [frontend-tests.data.dashboard-test] [frontend-tests.data.exports-assets-test] [frontend-tests.data.nitrate-test] + [frontend-tests.data.persistence-test] [frontend-tests.data.profile-test] [frontend-tests.data.repo-test] [frontend-tests.data.store-test] @@ -27,6 +28,7 @@ [frontend-tests.data.workspace-shortcuts-test] [frontend-tests.data.workspace-texts-test] [frontend-tests.data.workspace-thumbnails-test] + [frontend-tests.data.workspace-versions-test] [frontend-tests.errors-test] [frontend-tests.fonts-test] [frontend-tests.helpers-shapes-test] @@ -112,6 +114,7 @@ 'frontend-tests.copy-as-svg-test 'frontend-tests.data.dashboard-test 'frontend-tests.data.nitrate-test + 'frontend-tests.data.persistence-test 'frontend-tests.data.profile-test 'frontend-tests.data.repo-test 'frontend-tests.data.store-test @@ -129,6 +132,7 @@ 'frontend-tests.data.workspace-shortcuts-test 'frontend-tests.data.workspace-texts-test 'frontend-tests.data.workspace-thumbnails-test + 'frontend-tests.data.workspace-versions-test 'frontend-tests.errors-test 'frontend-tests.fonts-test 'frontend-tests.helpers-shapes-test diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 36d278f7d3..679aab600a 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1796,6 +1796,13 @@ msgstr "Your profile has emails muted (spam reports or high bounces)." msgid "errors.registration-disabled" msgstr "The registration is currently disabled." +#: src/app/main/errors.cljs:216 +msgid "errors.save-failed" +msgstr "" +"Your latest changes are not confirmed as saved. Saving may not resume " +"automatically. Do not close or reload this tab: you may lose changes. " +"Contact support for help." + #: src/app/main/errors.cljs:337 msgid "errors.svg-parser.invalid-svg" msgstr "SVG is invalid or malformed" @@ -10354,4 +10361,4 @@ msgid "labels.sso-error.retry" msgstr "Try again" msgid "dashboard.invite-profile-disabled" -msgstr "You don't have permission to invite people to this team" \ No newline at end of file +msgstr "You don't have permission to invite people to this team"