mirror of
https://github.com/penpot/penpot.git
synced 2026-11-05 17:40:39 +00:00
🐛 Keep comments, focus, thumbnails and text in step with a rollback
A rollback reverts the local data with a rebase, which is not a commit, so the parts of the workspace that follow commits missed it: - Comment pins moved in the reverted action stayed where they were, on screen and on the server, and pins on a board the rollback removed kept pointing to it. They go back to the reverted position, and pins on a removed board move to the page root. - Focus mode stayed on with no shape left when the rollback removed the focused ones, and the next shape drawn turned it on again. It ends when its last shape goes, and the selection drops removed shapes. - Component thumbnails kept the rejected look. The thumbnails of the reverted mains are rendered and uploaded again, and an upload whose shapes changed during the render is skipped. - Under WASM, an open text editor reset to the reverted content but the canvas kept the old layout. It is laid out again. - A library sync whose save failed showed no update notice again once the release undid it. The notice is checked again after that rollback.
This commit is contained in:
parent
707026acbb
commit
d019804fd4
File diff suppressed because one or more lines are too long
@ -200,14 +200,18 @@
|
||||
(:shapes change))))
|
||||
|
||||
(defn rollback-targets
|
||||
"The files of the commits `plan` reverts, and the pages and page shapes
|
||||
their changes touch."
|
||||
"The files of the commits `plan` reverts, and the pages, page shapes and
|
||||
comment thread positions their changes touch."
|
||||
[{:keys [files compensate]}]
|
||||
(let [commits (into (vec compensate) (mapcat (comp :reverted val)) files)
|
||||
changes (into [] (mapcat :redo-changes) commits)]
|
||||
{:file-ids (into (set (keys files)) (map :file-id) compensate)
|
||||
:page-ids (into #{} (keep change-page-id) changes)
|
||||
:shape-ids (into #{} (mapcat change-shape-ids) changes)}))
|
||||
{:file-ids (into (set (keys files)) (map :file-id) compensate)
|
||||
:page-ids (into #{} (keep change-page-id) changes)
|
||||
:shape-ids (into #{} (mapcat change-shape-ids) changes)
|
||||
:comment-thread-ids (into #{}
|
||||
(comp (filter #(= :set-comment-thread-position (:type %)))
|
||||
(map :comment-thread-id))
|
||||
changes)}))
|
||||
|
||||
(defn- drop-commits
|
||||
"Removes the commits in `ids` from the queue and the buffer; an entry left
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
[app.main.data.event :as ev]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.notifications :as ntf]
|
||||
[app.main.data.persistence :as-alias dps]
|
||||
[app.main.data.workspace.common :as dwco]
|
||||
[app.main.data.workspace.drawing :as dwd]
|
||||
[app.main.data.workspace.edition :as dwe]
|
||||
@ -219,21 +220,62 @@
|
||||
(rx/ignore))
|
||||
(rx/empty)))))
|
||||
|
||||
(defn- restore-rolled-back-threads
|
||||
"After a rollback, puts the threads in `comment-thread-ids` back where the
|
||||
restored page data has them, in the rendered threads and the backend.
|
||||
Threads left on a frame the rollback removed (in `shape-ids`) move to the
|
||||
page root, as deleting the frame does."
|
||||
[{:keys [comment-thread-ids shape-ids]}]
|
||||
(ptk/reify ::restore-rolled-back-threads
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [threads (:comment-threads state)
|
||||
|
||||
restored
|
||||
(into {}
|
||||
(keep (fn [id]
|
||||
(when-let [{:keys [page-id]} (get threads id)]
|
||||
(when-let [{:keys [position frame-id]}
|
||||
(-> (dsh/lookup-page state page-id)
|
||||
(dm/get-in [:comment-thread-positions id]))]
|
||||
[id {:comment-thread-id id
|
||||
:position position
|
||||
:frame-id frame-id}]))))
|
||||
comment-thread-ids)
|
||||
|
||||
orphaned?
|
||||
(fn [{:keys [id page-id] :as thread}]
|
||||
(let [frame-id (or (dm/get-in restored [id :frame-id]) (:frame-id thread))]
|
||||
(and (contains? shape-ids frame-id)
|
||||
(nil? (get (dsh/lookup-page-objects state page-id) frame-id)))))]
|
||||
|
||||
(rx/concat
|
||||
(rx/from (map sync-comment-thread-position (vals restored)))
|
||||
(->> (vals threads)
|
||||
(filter orphaned?)
|
||||
(map dcmt/update-comment-thread-frame)
|
||||
(rx/from)))))))
|
||||
|
||||
(defn watch-comment-thread-position-changes
|
||||
"Syncs rendered threads and the backend when an undo/redo changes a comment position."
|
||||
"Syncs rendered threads and the backend when an undo/redo changes a comment
|
||||
position, and when a rollback reverts a change of positions or frames."
|
||||
[stopper]
|
||||
(ptk/reify ::watch-comment-thread-position-changes
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ stream]
|
||||
(->> stream
|
||||
(rx/filter dwc/commit?)
|
||||
(rx/map deref)
|
||||
(rx/filter #(contains? undo-origins (:origin %)))
|
||||
(rx/mapcat (fn [commit]
|
||||
(->> (:redo-changes commit)
|
||||
(filter #(= :set-comment-thread-position (:type %)))
|
||||
(rx/from))))
|
||||
(rx/map sync-comment-thread-position)
|
||||
(->> (rx/merge
|
||||
(->> stream
|
||||
(rx/filter dwc/commit?)
|
||||
(rx/map deref)
|
||||
(rx/filter #(contains? undo-origins (:origin %)))
|
||||
(rx/mapcat (fn [commit]
|
||||
(->> (:redo-changes commit)
|
||||
(filter #(= :set-comment-thread-position (:type %)))
|
||||
(rx/from))))
|
||||
(rx/map sync-comment-thread-position))
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? ::dps/rolled-back))
|
||||
(rx/map (comp restore-rolled-back-threads deref))))
|
||||
(rx/take-until stopper)))))
|
||||
|
||||
(defn frame-pin-transform
|
||||
|
||||
@ -1305,16 +1305,19 @@
|
||||
(wrf/with-pending :sync-file pending-ids frontend-sync)
|
||||
|
||||
(when (not= file-id library-id)
|
||||
(let [stopper-s (rx/filter (ptk/type? ::dw/finalize-workspace) stream)
|
||||
restore (if (= ::unchanged @synced-at)
|
||||
(rx/empty)
|
||||
(rx/of (restore-library-synced-at library-id @synced-at)))]
|
||||
(let [stopper-s (rx/filter (ptk/type? ::dw/finalize-workspace) stream)
|
||||
rolled-back-s (->> stream
|
||||
(rx/filter (ptk/type? ::dps/rolled-back))
|
||||
(rx/filter #(contains? (:file-ids (deref %)) file-id)))
|
||||
restore (if (= ::unchanged @synced-at)
|
||||
(rx/empty)
|
||||
(rx/of (restore-library-synced-at library-id @synced-at)))
|
||||
;; Checked once the rollback has undone the sync locally
|
||||
notify #(->> (dps/wait-persisted)
|
||||
(rx/map (fn [_] (notify-sync-file))))]
|
||||
(->> (rx/merge
|
||||
;; The server rejected the sync: its commits were undone
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? ::dps/rolled-back))
|
||||
(rx/filter #(contains? (:file-ids (deref %)) file-id))
|
||||
(rx/map (constantly ::rolled-back)))
|
||||
(rx/map (constantly ::rolled-back) rolled-back-s)
|
||||
;; The flush of the sync commits starts within 3 s; a save
|
||||
;; that fails for good ends the wait with no value
|
||||
(->> (rx/timer 3000)
|
||||
@ -1329,15 +1332,17 @@
|
||||
(mark-library-synced file-id library-id)
|
||||
|
||||
;; Never saved: the update notice can show again
|
||||
;; once the release of the failed save undoes the sync
|
||||
::failed
|
||||
restore
|
||||
|
||||
::rolled-back
|
||||
(rx/concat
|
||||
restore
|
||||
;; Checked once the rollback has undone the sync locally
|
||||
(->> (dps/wait-persisted)
|
||||
(rx/map (fn [_] (notify-sync-file)))))))))))))))))))
|
||||
(->> rolled-back-s
|
||||
(rx/take 1)
|
||||
(rx/take-until stopper-s)
|
||||
(rx/mapcat (fn [_] (notify)))))
|
||||
|
||||
::rolled-back
|
||||
(rx/concat restore (notify))))))))))))))))
|
||||
|
||||
|
||||
;; FIXME: the data should be set on the backend for clock consistency
|
||||
@ -1603,7 +1608,31 @@
|
||||
notifier-s
|
||||
(->> component-events-s
|
||||
(rx/debounce 5000)
|
||||
(rx/tap #(log/trc :hint "buffer initialized")))]
|
||||
(rx/tap #(log/trc :hint "buffer initialized")))
|
||||
|
||||
;; A rollback is a rebase, not a commit: no component change
|
||||
;; is detected, so the thumbnails of the mains it reverted are
|
||||
;; rendered and uploaded here, each rebase checked against the
|
||||
;; file data right before it
|
||||
rolled-back-thumbnails-s
|
||||
(->> stream
|
||||
(rx/filter (ptk/type? ::dch/rebase-commits))
|
||||
(rx/map deref)
|
||||
(rx/filter (complement :failed))
|
||||
;; Its own data pairing: `workspace-data-s` is shared and
|
||||
;; does not replay the current data to a late subscriber
|
||||
(rx/with-latest-from
|
||||
(->> (rx/from-atom refs/workspace-data {:emit-current-value? true})
|
||||
(rx/concat (rx/of nil))
|
||||
(rx/buffer 2 1)
|
||||
(rx/map first)))
|
||||
(rx/mapcat
|
||||
(fn [[{:keys [file-id changes]} old-data]]
|
||||
(if (= file-id (:id old-data))
|
||||
(->> (into #{} (mapcat (partial ch/components-changed old-data)) changes)
|
||||
(rx/from)
|
||||
(rx/map #(update-component-thumbnail % file-id)))
|
||||
(rx/empty)))))]
|
||||
|
||||
(when (or (contains? cf/flags :component-thumbnails)
|
||||
(features/active-feature? state "render-wasm/v1"))
|
||||
@ -1635,7 +1664,9 @@
|
||||
(->> component-events-s
|
||||
(rx/filter (ptk/type? ::touch-component))
|
||||
(rx/map deref)
|
||||
(rx/map render-component-thumbnail-event)))
|
||||
(rx/map render-component-thumbnail-event))
|
||||
|
||||
rolled-back-thumbnails-s)
|
||||
|
||||
(rx/take-until stopper-s)))))))
|
||||
|
||||
|
||||
@ -580,21 +580,57 @@
|
||||
(let [hover-value (if value #{id} #{})]
|
||||
(assoc-in state [:workspace-local :hover] hover-value)))))
|
||||
|
||||
(defn- leave-focus-mode
|
||||
"Leaves focus mode, bringing back the viewport and the selection from
|
||||
before it. Shapes removed since then are left out of the selection."
|
||||
[state]
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
local (:workspace-pre-focus state)
|
||||
selected (:selected local)]
|
||||
(-> state
|
||||
(assoc :workspace-local
|
||||
(cond-> local
|
||||
(some? selected)
|
||||
(assoc :selected (into (empty selected) (filter #(contains? objects %)) selected))))
|
||||
(dissoc :workspace-focus-selected)
|
||||
(dissoc :workspace-pre-focus))))
|
||||
|
||||
(defn update-focus-shapes
|
||||
"Adds the `added` shapes to the focus set and takes the `removed` ones
|
||||
out. Focus mode ends when no focused shape is left, so a later shape
|
||||
never brings it back on its own."
|
||||
[added removed]
|
||||
(ptk/reify ::update-focus-shapes
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [left? (volatile! false)]
|
||||
(ptk/reify ::update-focus-shapes
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
current (:workspace-focus-selected state)
|
||||
focus (-> current
|
||||
(set/union added)
|
||||
(set/difference removed))
|
||||
focus (cfh/clean-loops objects focus)]
|
||||
(cond
|
||||
(empty? current)
|
||||
state
|
||||
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
(empty? focus)
|
||||
(do (vreset! left? true)
|
||||
(leave-focus-mode state))
|
||||
|
||||
focus (-> (:workspace-focus-selected state)
|
||||
(set/union added)
|
||||
(set/difference removed))
|
||||
focus (cfh/clean-loops objects focus)]
|
||||
:else
|
||||
(assoc state :workspace-focus-selected focus))))
|
||||
|
||||
(-> state
|
||||
(assoc :workspace-focus-selected focus))))))
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(if @left?
|
||||
(rx/of (ptk/data-event ::focus-mode-left))
|
||||
(rx/empty)))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ state _]
|
||||
(when @left?
|
||||
(dwvw/maybe-sync-workspace-local-viewport! state))))))
|
||||
|
||||
(defn toggle-focus-mode
|
||||
"Zoom in on and center viewport on selection;
|
||||
@ -615,10 +651,7 @@
|
||||
|
||||
(if in-mode?
|
||||
;; Exit focus, restoring previous viewport, selection, etc
|
||||
(-> state
|
||||
(assoc :workspace-local (:workspace-pre-focus state))
|
||||
(dissoc :workspace-focus-selected)
|
||||
(dissoc :workspace-pre-focus))
|
||||
(leave-focus-mode state)
|
||||
(if have-selection?
|
||||
;; Enter focus and save viewport, selection, etc
|
||||
(-> state
|
||||
@ -633,6 +666,7 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state stream]
|
||||
(let [stopper (rx/filter #(or (= ::toggle-focus-mode (ptk/type %))
|
||||
(= ::focus-mode-left (ptk/type %))
|
||||
(= ::dwpg/finalize-page (ptk/type %))) stream)]
|
||||
(when (d/not-empty? (:workspace-focus-selected state))
|
||||
(->> (rx/merge
|
||||
|
||||
@ -361,10 +361,12 @@
|
||||
(rx/buffer 2 1)
|
||||
(rx/share))
|
||||
|
||||
;; All commits stream, indepentendly of the source of the commit
|
||||
;; All commits stream, indepentendly of the source of the commit.
|
||||
;; A rollback's rebase is not a commit but changes the data too.
|
||||
all-commits-s
|
||||
(->> stream
|
||||
(rx/filter dch/commit?)
|
||||
(rx/filter #(or (dch/commit? %)
|
||||
(ptk/type? ::dch/rebase-commits %)))
|
||||
(rx/map deref)
|
||||
(rx/observe-on :async)
|
||||
(rx/with-latest-from workspace-data-s)
|
||||
|
||||
@ -122,16 +122,25 @@
|
||||
(catch :default err
|
||||
(rx/error! subs err)))))))
|
||||
|
||||
(get-subtree
|
||||
[state]
|
||||
(-> (dsh/lookup-page-objects state file-id page-id)
|
||||
(cfh/get-children-with-self frame-id)))
|
||||
|
||||
(persist-to-server
|
||||
[data-uri]
|
||||
(let [blob (wapi/data-uri->blob data-uri)]
|
||||
(->> (rp/cmd! :create-file-object-thumbnail
|
||||
{:file-id file-id
|
||||
:object-id object-id
|
||||
:media blob
|
||||
:tag "component"})
|
||||
(rx/catch rx/empty)
|
||||
(rx/ignore))))
|
||||
;; A change during the render (a rollback, an edit) makes
|
||||
;; it stale; that change brings its own render
|
||||
(if (= (get-subtree state) (get-subtree @st/state))
|
||||
(let [blob (wapi/data-uri->blob data-uri)]
|
||||
(->> (rp/cmd! :create-file-object-thumbnail
|
||||
{:file-id file-id
|
||||
:object-id object-id
|
||||
:media blob
|
||||
:tag "component"})
|
||||
(rx/catch rx/empty)
|
||||
(rx/ignore)))
|
||||
(rx/empty)))
|
||||
|
||||
(do-render-thumbnail
|
||||
[]
|
||||
|
||||
@ -192,6 +192,8 @@
|
||||
;; content in the editor, which would otherwise keep showing and
|
||||
;; saving the rejected one. Saves failing do the same with the last
|
||||
;; committed content, since the typing not yet committed is dropped.
|
||||
;; The WASM canvas draws the editor's layout, so it is laid out
|
||||
;; again from that content.
|
||||
rolled-back-sub
|
||||
(->> st/stream
|
||||
(rx/filter #(or (ptk/type? ::dps/rolled-back %)
|
||||
@ -203,7 +205,9 @@
|
||||
current (content/dom->cljs (dwt/get-editor-root instance))]
|
||||
(when (and (some? content)
|
||||
(not (dwt/same-styled-text? content current)))
|
||||
(dwt/set-editor-root! instance (content/cljs->dom content)))))))]
|
||||
(dwt/set-editor-root! instance (content/cljs->dom content))
|
||||
(when (features/active-feature? @st/state "render-wasm/v1")
|
||||
(st/emit! (dwt/v2-sync-wasm-text-layout shape-id content))))))))]
|
||||
|
||||
(.addEventListener ^js global/document "keyup" on-key-up)
|
||||
(.addEventListener ^js instance "focus" on-focus)
|
||||
|
||||
@ -160,13 +160,16 @@
|
||||
"the rest of the undo group goes too")))
|
||||
|
||||
(t/deftest rollback-targets-name-the-files-pages-and-page-shapes-of-the-reverted-changes
|
||||
(let [[f1 f2 p1 p2 p3 s1 s2 s3 s4 s5] (repeatedly 10 uuid/next)
|
||||
(let [[f1 f2 p1 p2 p3 s1 s2 s3 s4 s5 c1] (repeatedly 11 uuid/next)
|
||||
plan {:files {f1 {:reverted [{:redo-changes [{:type :add-obj :page-id p1 :id s1}
|
||||
{:type :mov-objects :page-id p1 :shapes [s2 s3]}
|
||||
{:type :mod-obj :component-id (uuid/next) :id s4}
|
||||
{:type :mod-page :id p2}]}]}}
|
||||
{:type :mod-page :id p2}
|
||||
{:type :set-comment-thread-position :page-id p1
|
||||
:comment-thread-id c1}]}]}}
|
||||
:compensate [{:file-id f2 :redo-changes [{:type :del-obj :page-id p3 :id s5}]}]}]
|
||||
(t/is (= {:file-ids #{f1 f2}
|
||||
:page-ids #{p1 p2 p3}
|
||||
:shape-ids #{s1 s2 s3 s5}}
|
||||
(t/is (= {:file-ids #{f1 f2}
|
||||
:page-ids #{p1 p2 p3}
|
||||
:shape-ids #{s1 s2 s3 s5}
|
||||
:comment-thread-ids #{c1}}
|
||||
(pcm/rollback-targets plan)))))
|
||||
|
||||
@ -10,12 +10,15 @@
|
||||
changes survive, and how the UI state follows. The pure plan behind it
|
||||
is tested in `frontend-tests.data.persistence-commits-test`."
|
||||
(:require
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.time :as ct]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.modal :as-alias modal]
|
||||
[app.main.data.persistence :as dps]
|
||||
[app.main.data.workspace :as dw]
|
||||
[app.main.data.workspace.comments :as dwcm]
|
||||
[app.main.data.workspace.undo :as dwu]
|
||||
[app.main.errors :as errors]
|
||||
[app.util.i18n :as i18n]
|
||||
@ -692,6 +695,85 @@
|
||||
(t/is (nil? (get-in @store [:workspace-global :persistence-lock])))
|
||||
(t/is (= "Restored" (thp/shape-name store file-id page-id shape-id))))))))
|
||||
|
||||
(defn- comment-thread
|
||||
"A comment thread of `file-id` on `page-id`, pinned on `frame-id`."
|
||||
[file-id page-id frame-id position]
|
||||
{:id (uuid/next) :page-id page-id :file-id file-id :project-id (uuid/next)
|
||||
:owner-id (uuid/next) :file-name "File" :seqn 1 :content "Note"
|
||||
:participants #{} :created-at (ct/now) :modified-at (ct/now)
|
||||
:position position :frame-id frame-id})
|
||||
|
||||
;; Scenario: comment #1 sits on board Y. During a save Y is moved, which
|
||||
;; moves pin #1 in the same action, and board R is drawn and gets comment
|
||||
;; #2. The server rejects the move, so the move, the pin move and R are
|
||||
;; rolled back. Comment positions are saved by their own requests, so the
|
||||
;; server already holds the moved pin and #2 on R. Proves: pin #1 goes back
|
||||
;; to its spot on screen and on the server, and #2, whose board is gone,
|
||||
;; moves to the page root, as deleting the board does.
|
||||
(t/deftest ^:async a-rollback-puts-comment-pins-back-and-detaches-them-from-removed-boards
|
||||
(let [answer (rx/subject)
|
||||
respond (fn [cmd _]
|
||||
(if (= :update-file cmd)
|
||||
(->> answer (rx/take 1) (rx/observe-on :async))
|
||||
(rx/of nil)))]
|
||||
(await
|
||||
(thp/with-persistence
|
||||
(^:async fn [{:keys [file-id requests store]}]
|
||||
(let [page-id (uuid/next)
|
||||
shape-id (uuid/next)
|
||||
board-y (cts/setup-shape {:id (uuid/next) :type :frame :name "Y"
|
||||
:parent-id uuid/zero :frame-id uuid/zero})
|
||||
board-r (cts/setup-shape {:id (uuid/next) :type :frame :name "R"
|
||||
:parent-id uuid/zero :frame-id uuid/zero})
|
||||
[p0 p1 p2] [(gpt/point 610 10) (gpt/point 760 10) (gpt/point 20 20)]
|
||||
thread-1 (comment-thread file-id page-id (:id board-y) p1)
|
||||
thread-2 (comment-thread file-id page-id (:id board-r) p2)
|
||||
group (uuid/next)
|
||||
move (grouped (thp/rename-commit file-id page-id (:id board-y) "Y" "Y moved") group)
|
||||
pin (grouped (ptk/data-event
|
||||
::dch/commit
|
||||
{:id (uuid/next) :file-id file-id :file-revn 0 :file-vern 0
|
||||
:source :local :features #{}
|
||||
:redo-changes [{:type :set-comment-thread-position :page-id page-id
|
||||
:comment-thread-id (:id thread-1)
|
||||
:frame-id (:id board-y) :position p1}]
|
||||
:undo-changes [{:type :set-comment-thread-position :page-id page-id
|
||||
:comment-thread-id (:id thread-1)
|
||||
:frame-id (:id board-y) :position p0}]})
|
||||
group)
|
||||
draw (add-shape-commit file-id page-id board-r)
|
||||
data (-> (thp/file-data page-id shape-id "A")
|
||||
(thp/add-shape page-id (assoc board-y :name "Y moved"))
|
||||
(thp/add-shape page-id board-r)
|
||||
(assoc-in [:pages-index page-id :comment-thread-positions]
|
||||
{(:id thread-1) {:frame-id (:id board-y) :position p1}
|
||||
(:id thread-2) {:frame-id (:id board-r) :position p2}}))
|
||||
sent (fn [cmd] (into [] (comp (filter #(= cmd (first %))) (map second)) @requests))]
|
||||
;; The local data and the rendered pins already hold the edits
|
||||
(ptk/emit! store
|
||||
(thp/open-file file-id page-id data)
|
||||
#(assoc % :comment-threads {(:id thread-1) thread-1 (:id thread-2) thread-2})
|
||||
(dwcm/watch-comment-thread-position-changes (rx/subject)))
|
||||
(ptk/emit! store move pin draw ::dps/force-persist)
|
||||
(await (async/wait-for #(seq (sent :update-file)) "the edits are sent"))
|
||||
(rx/push! answer (thp/rejection (:id @move)))
|
||||
(await (async/wait-for #(seq (sent :update-comment-thread-frame))
|
||||
"the rollback updates the comments"))
|
||||
(t/is (nil? (thp/shape-attr store file-id page-id (:id board-r) :id)) "R is gone")
|
||||
(t/is (= p0 (get-in @store [:comment-threads (:id thread-1) :position]))
|
||||
"pin #1 is back on screen")
|
||||
(t/is (= [{:id (:id thread-1) :position p0 :frame-id (:id board-y)}]
|
||||
(sent :update-comment-thread-position))
|
||||
"and on the server")
|
||||
(t/is (= uuid/zero (get-in @store [:comment-threads (:id thread-2) :frame-id]))
|
||||
"comment #2 leaves the removed board")
|
||||
(t/is (= p2 (get-in @store [:comment-threads (:id thread-2) :position]))
|
||||
"and keeps its spot")
|
||||
(t/is (= [{:id (:id thread-2) :frame-id uuid/zero}]
|
||||
(sent :update-comment-thread-frame)))
|
||||
(rx/end! answer)))
|
||||
respond))))
|
||||
|
||||
|
||||
(defn- rejection-notice
|
||||
"Saves a rename of the open file and answers it with `answer` applied to
|
||||
|
||||
87
frontend/test/frontend_tests/data/workspace_focus_test.cljs
Normal file
87
frontend/test/frontend_tests/data/workspace_focus_test.cljs
Normal file
@ -0,0 +1,87 @@
|
||||
;; 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 SUBSIDIARY SL
|
||||
|
||||
(ns frontend-tests.data.workspace-focus-test
|
||||
"Tests for focus mode when the focused shapes go away."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.main.data.workspace.selection :as dws]
|
||||
[beicon.v2.core :as rx]
|
||||
[cljs.test :as t :include-macros true]
|
||||
[frontend-tests.helpers.async :as async]
|
||||
[potok.v2.core :as ptk]))
|
||||
|
||||
(defn- focus-store
|
||||
"A store on a page holding `ids`, in focus mode on `focused`, entered
|
||||
with `selected` selected."
|
||||
[ids focused selected]
|
||||
(let [file-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
objects (into {uuid/zero (cts/setup-shape {:id uuid/zero :type :frame :shapes (vec ids)})}
|
||||
(map (fn [id]
|
||||
[id (cts/setup-shape {:id id :type :rect :name "Rect"
|
||||
:parent-id uuid/zero :frame-id uuid/zero})]))
|
||||
ids)
|
||||
local {:selected (into (d/ordered-set) selected) :zoom 1}]
|
||||
(ptk/store {:state {:current-file-id file-id
|
||||
:current-page-id page-id
|
||||
:files {file-id {:id file-id
|
||||
:data {:pages [page-id]
|
||||
:pages-index {page-id {:id page-id :objects objects}}}}}
|
||||
:workspace-local (assoc local :selected (d/ordered-set))
|
||||
:workspace-pre-focus local
|
||||
:workspace-focus-selected (set focused)}
|
||||
:on-error #(t/is false (str %))})))
|
||||
|
||||
(defn- remove-shape
|
||||
"Takes `id` out of the page of `store`, as a delete or a rollback does."
|
||||
[store id]
|
||||
(let [{:keys [current-file-id current-page-id]} @store]
|
||||
(ptk/emit! store #(update-in % [:files current-file-id :data :pages-index current-page-id :objects]
|
||||
dissoc id))))
|
||||
|
||||
;; Scenario: focus mode is on R, entered with R selected, and R goes away
|
||||
;; (a rollback or a delete); later a new shape is drawn. Proves: focus mode
|
||||
;; ends when its last shape goes, the selection from before it comes back
|
||||
;; without R, and the new shape does not bring focus mode back.
|
||||
(t/deftest ^:async focus-mode-ends-when-its-last-shape-is-removed
|
||||
(let [[r s n] (repeatedly 3 uuid/next)
|
||||
store (focus-store [r s] [r] [r])
|
||||
left (atom 0)
|
||||
sub (->> (ptk/input-stream store)
|
||||
(rx/filter (ptk/type? ::dws/focus-mode-left))
|
||||
(rx/subs! (fn [_] (swap! left inc))))]
|
||||
(remove-shape store r)
|
||||
(ptk/emit! store (dws/update-focus-shapes #{} #{r}))
|
||||
(await (async/settle))
|
||||
(t/is (nil? (:workspace-focus-selected @store)) "focus mode is off")
|
||||
(t/is (nil? (:workspace-pre-focus @store)))
|
||||
(t/is (empty? (get-in @store [:workspace-local :selected]))
|
||||
"the removed shape is not selected again")
|
||||
(t/is (= 1 (:zoom (:workspace-local @store))) "the viewport from before comes back")
|
||||
(ptk/emit! store (dws/update-focus-shapes #{n} #{}))
|
||||
(await (async/settle))
|
||||
(t/is (nil? (:workspace-focus-selected @store)) "a new shape does not turn it on")
|
||||
(t/is (= 1 @left) "the focus watcher is told to stop once")
|
||||
(rx/dispose! sub)))
|
||||
|
||||
;; Scenario: focus mode is on R and S, entered with both selected; R goes
|
||||
;; away and the user leaves focus mode with F. Proves: focus mode stays on
|
||||
;; while a focused shape is left, and leaving it selects only the shapes
|
||||
;; that still exist.
|
||||
(t/deftest ^:async leaving-focus-mode-selects-only-shapes-that-exist
|
||||
(let [[r s] (repeatedly 2 uuid/next)
|
||||
store (focus-store [r s] [r s] [r s])]
|
||||
(remove-shape store r)
|
||||
(ptk/emit! store (dws/update-focus-shapes #{} #{r}))
|
||||
(await (async/settle))
|
||||
(t/is (= #{s} (:workspace-focus-selected @store)))
|
||||
(ptk/emit! store (dws/toggle-focus-mode))
|
||||
(await (async/settle))
|
||||
(t/is (nil? (:workspace-focus-selected @store)))
|
||||
(t/is (= [s] (vec (get-in @store [:workspace-local :selected]))))))
|
||||
@ -95,7 +95,7 @@
|
||||
;; Scenario: the save of the sync's commits fails for good, so waiting for
|
||||
;; it ends with no value; later an edit of the same file is rejected.
|
||||
;; Proves: the library is not marked as synced, its sync date goes back,
|
||||
;; and the sync stops listening, so the later rollback leaves the date alone.
|
||||
;; and the later rollback leaves the date alone.
|
||||
(t/deftest ^:async a-failed-sync-save-ends-the-sync
|
||||
(let [saved (rx/subject)]
|
||||
(await
|
||||
@ -112,3 +112,35 @@
|
||||
(await (async/settle))
|
||||
(t/is (= later (get-in @store [:files library-id :synced-at]))
|
||||
"a later rollback does not wake the ended sync")))))))
|
||||
|
||||
;; Scenario: the user becomes a viewer right after "Update", so the save of
|
||||
;; the sync's commits fails for good; when edit rights come back, the
|
||||
;; release of the failed save undoes the sync. Proves: the update notice is
|
||||
;; checked again, as after a rejected sync, instead of waiting for a reload.
|
||||
(t/deftest ^:async a-released-sync-checks-the-update-notice-again
|
||||
(let [file-id (uuid/next)
|
||||
library-id (uuid/next)
|
||||
store (library-store file-id library-id)
|
||||
waits (atom 0)
|
||||
notified (atom 0)]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{rx/timer (mock/stub (fn [& _] (rx/of 0)))
|
||||
;; The first wait ends with the failed save, the next ones saved
|
||||
dps/wait-persisted (mock/stub (fn [& _]
|
||||
(if (= 1 (swap! waits inc))
|
||||
(rx/empty)
|
||||
(rx/of :saved))))
|
||||
dwl/notify-sync-file (mock/stub (fn []
|
||||
(swap! notified inc)
|
||||
(ptk/data-event ::notified {})))
|
||||
rp/cmd! (mock/stub (fn [_ _] (rx/of nil)))}
|
||||
(ptk/emit! store (dwl/sync-file file-id library-id))
|
||||
(await (async/wait-for #(= 1 @waits) "the save of the sync fails"))
|
||||
(await (async/settle))
|
||||
(t/is (= synced-at (get-in @store [:files library-id :synced-at])))
|
||||
(t/is (zero? @notified) "a failed save alone shows no notice")
|
||||
|
||||
;; The role change that brings edit rights back releases the save
|
||||
(ptk/emit! store (ptk/data-event ::dps/rolled-back {:file-ids #{file-id}}))
|
||||
(await (async/wait-for #(= 1 @notified) "the release checks the update notice"))))))
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
[app.common.test-helpers.files :as cthf]
|
||||
[app.common.test-helpers.ids-map :as cthi]
|
||||
[app.common.test-helpers.shapes :as cths]
|
||||
[app.main.data.changes :as dch]
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace.libraries :as dwl]
|
||||
[app.main.data.workspace.pages :as dwpg]
|
||||
@ -98,3 +99,34 @@
|
||||
|
||||
(ptk/emit! store (dwpg/finalize-page (:id file) (cthf/current-page-id file)))
|
||||
(await (async/settle))))))
|
||||
|
||||
;; Scenario: a rollback reverts the opacity of the main's child. A rollback
|
||||
;; is not a commit, so the change watch sees no component change.
|
||||
;; Proves: the component's thumbnail is rendered again and uploaded, so
|
||||
;; neither the tab nor the server keeps a thumbnail of the rejected data.
|
||||
(t/deftest ^:async a-rollback-of-a-main-renders-and-uploads-its-thumbnail
|
||||
(let [file (setup-file)
|
||||
store (ths/setup-store file)
|
||||
page-id (cthf/current-page-id file)
|
||||
renders (atom [])]
|
||||
(await
|
||||
(mock/with-mocks*
|
||||
{refs/workspace-data (l/derived dsh/lookup-file-data store)
|
||||
st/state store
|
||||
dwt.wasm/render-thumbnail (fn [_ _ frame-id & {:as opts}]
|
||||
(swap! renders conj [frame-id opts])
|
||||
(noop-render-thumbnail))}
|
||||
|
||||
(ptk/emit! store (dwl/watch-component-changes))
|
||||
(ptk/emit! store (dch/rebase-commits
|
||||
(:id file)
|
||||
{:undo []
|
||||
:remote [{:type :mod-obj :id (cthi/id :main-child) :page-id page-id
|
||||
:operations [{:type :set :attr :opacity :val 0.5}]}]
|
||||
:redo []}))
|
||||
|
||||
(await (async/wait-for #(seq @renders) "the thumbnail to render"))
|
||||
(t/is (= [[(cthi/id :main-root) {:persist? true}]] @renders))
|
||||
|
||||
(ptk/emit! store (dwpg/finalize-page (:id file) page-id))
|
||||
(await (async/settle))))))
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
[frontend-tests.data.workspace-colors-test]
|
||||
[frontend-tests.data.workspace-comments-test]
|
||||
[frontend-tests.data.workspace-context-menu-test]
|
||||
[frontend-tests.data.workspace-focus-test]
|
||||
[frontend-tests.data.workspace-interactions-test]
|
||||
[frontend-tests.data.workspace-libraries-sync-test]
|
||||
[frontend-tests.data.workspace-mcp-test]
|
||||
@ -227,6 +228,7 @@
|
||||
'frontend-tests.data.workspace-colors-test
|
||||
'frontend-tests.data.workspace-comments-test
|
||||
'frontend-tests.data.workspace-context-menu-test
|
||||
'frontend-tests.data.workspace-focus-test
|
||||
'frontend-tests.data.workspace-interactions-test
|
||||
'frontend-tests.data.workspace-libraries-sync-test
|
||||
'frontend-tests.data.workspace-mcp-test
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user