From e97159842c6e9e6d0b8b0a0c80ee706ad58d59ca Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 20 Oct 2026 16:19:56 +0200 Subject: [PATCH] :bug: Fix small save bugs found by the rollback test plan - Ending a drag inside the frame the shapes already belong to sent an empty reparent save and bumped the revision. The move commits only when some shape changes parent. - When a user restored a version, other sessions reloaded the file with no notice. They now see a dialog naming who restored it. - Exports drawn in the browser (PDF, SVG, and PNG when the WASM renderer draws it) and duplicating a file from the dashboard started at once, so an export could show changes a pending save then rejected, and a copy could miss queued edits. Both wait for the file's pending saves. A client export warns that it includes unsaved changes; a copy warns that it misses them. --- .../workspace-state-persistence-subtleties.md | 1 + frontend/src/app/main/data/dashboard.cljs | 28 ++++++- .../src/app/main/data/exports/assets.cljs | 18 +++-- .../main/data/workspace/notifications.cljs | 19 ++++- .../app/main/data/workspace/transforms.cljs | 3 +- frontend/src/app/main/ui/workspace.cljs | 1 + .../ui/workspace/file_restored_modal.cljs | 44 +++++++++++ .../ui/workspace/file_restored_modal.scss | 34 +++++++++ .../frontend_tests/data/dashboard_test.cljs | 26 +++++++ .../data/exports_assets_test.cljs | 37 ++++++++++ .../data/workspace_notifications_test.cljs | 74 +++++++++++++++++++ .../logic/move_shapes_to_frame_test.cljs | 65 ++++++++++++++++ frontend/test/frontend_tests/runner.cljs | 4 + frontend/translations/en.po | 20 +++++ frontend/translations/es.po | 20 +++++ 15 files changed, 379 insertions(+), 15 deletions(-) create mode 100644 frontend/src/app/main/ui/workspace/file_restored_modal.cljs create mode 100644 frontend/src/app/main/ui/workspace/file_restored_modal.scss create mode 100644 frontend/test/frontend_tests/data/workspace_notifications_test.cljs create mode 100644 frontend/test/frontend_tests/logic/move_shapes_to_frame_test.cljs diff --git a/.serena/memories/frontend/workspace-state-persistence-subtleties.md b/.serena/memories/frontend/workspace-state-persistence-subtleties.md index b60d6d9605..26e7470d35 100644 --- a/.serena/memories/frontend/workspace-state-persistence-subtleties.md +++ b/.serena/memories/frontend/workspace-state-persistence-subtleties.md @@ -36,6 +36,7 @@ - `save-request-timeout-ms` is 7 minutes and wraps the `update-file` request in `rx/timeout`, failing it as a transient `:network` error so the retry path resends instead of blocking the queue forever. It is deliberately longer than `saving-stall-timeout-ms` (5 min) so the stalled report fires before the retry. - `sync-unload-guard` keeps `:persistence` in `st/ongoing-tasks` while edits are buffered or queued, which is what holds the browser leave-page warning on. It reads `[:persistence :buffer]` and `:queue` and syncs on `::buffer-commit`, `::append-commit`, `::discard-commit` and `::release-failed-file` (every buffer and queue change). It is the one effect that must stay in `store.cljs`'s `omitset`, and `store.cljs`'s `ongoing-tasks` watcher is guarded by `exists? js/window` because tests run without a window. - `leave-signals` (document `visibilitychange`→hidden, window `blur`) emits `persist-buffered`, which forces a save when `[:persistence :buffer]` is not empty, so leaving the tab/window sends the 3 s batch at once; tests mock `dps/leave-signals` BEFORE `initialize-persistence` runs. pagehide/beforeunload are too late for an async save. +- Exports (exporter and client/WASM PDF/SVG/PNG alike), the dashboard `duplicate-file` and the `.penpot` download call `force-persist-files-and-wait file-ids timeout` first. A client export draws local data, so its warning says the export includes unsaved changes (`workspace.export.unsaved-changes-included`); exporter exports and the duplicate copy server data and warn they miss them. - Exports and the `.penpot` download call `force-persist-files-and-wait file-ids timeout` (waits only for those files, ends on `:error` or timeout, never raises) and then `unsaved-changes? state file-ids` to warn; it reads `refs/persistence`, so tests mock it with `(l/derived :persistence store)`. - `persistence-failed` emits a `save-failed` audit event (`:cause-type :code :retrying :queued-commits`) whenever the user is notified (`notify-failure?`, same rule as the toast), including delegated auth failures. - `start-undo-transaction` stamps the transaction with a fresh `:commit-group` (dropped from the history entry); `commit-changes` uses `(or undo-group (:undo-group tx) (:commit-group tx) (uuid/next))`, so all commits of one transaction are one rollback unit. Pre-setting `:undo-group` instead breaks entries whose group is set later (sync transactions). diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index b0e47eafea..798d15e67a 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -23,6 +23,7 @@ [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.team :as dtm] [app.main.data.websocket :as dws] [app.main.repo :as rp] @@ -529,6 +530,21 @@ ;; --- EVENT: duplicate-file +;; Longest wait for the pending changes of a file to be saved before copying it. +(def ^:private save-wait-timeout-ms 10000) + +(defn- warn-unsaved-copy + "Warns that the copy of `file-id` misses the changes not saved yet." + [file-id] + (ptk/reify ::warn-unsaved-copy + ptk/WatchEvent + (watch [_ state _] + (when (dps/unsaved-changes? state #{file-id}) + (rx/of (ntf/show {:content (tr "dashboard.duplicate-unsaved-changes") + :type :toast + :level :warning + :timeout 8000})))))) + (defn duplicate-file [{:keys [id name] :as params}] (dm/assert! (uuid? id)) @@ -546,10 +562,14 @@ (when (> copy-count 1) (str " " copy-count)))) new-name (cfh/generate-unique-name name unames :suffix-fn suffix-fn)] - (->> (rp/cmd! :duplicate-file {:file-id id :name new-name}) - (rx/tap on-success) - (rx/map file-created) - (rx/catch on-error)))))) + ;; The copy is made from the server's data: send the pending changes first. + (rx/concat + (dps/force-persist-files-and-wait #{id} save-wait-timeout-ms) + (rx/of (warn-unsaved-copy id)) + (->> (rp/cmd! :duplicate-file {:file-id id :name new-name}) + (rx/tap on-success) + (rx/map file-created) + (rx/catch on-error))))))) ;; --- EVENT: move-files diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 5ca4f3e983..5b859fc400 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -36,13 +36,13 @@ (into #{} (keep :file-id) exports)) (defn- warn-unsaved-changes - "Warns that the export may miss the changes of `file-ids` not saved yet." - [file-ids] + "Shows `message` when some changes of `file-ids` are not saved yet." + [file-ids message] (ptk/reify ::warn-unsaved-changes ptk/WatchEvent (watch [_ state _] (when (dwp/unsaved-changes? state file-ids) - (rx/of (ntf/show {:content (tr "workspace.export.unsaved-changes") + (rx/of (ntf/show {:content message :type :toast :level :warning :timeout 8000})))))) @@ -339,8 +339,13 @@ ptk/WatchEvent (watch [_ state _] (case @target + ;; Drawn from the local data, which a rejected save may still change :client - (rx/of (request-simple-export-wasm export)) + (rx/concat + (dwp/force-persist-files-and-wait (export-file-ids [export]) save-wait-timeout-ms) + (rx/of (warn-unsaved-changes (export-file-ids [export]) + (tr "workspace.export.unsaved-changes-included")) + (request-simple-export-wasm export))) :too-large (rx/of (ntf/error (tr "errors.export-too-large"))) @@ -353,7 +358,8 @@ :is-wasm (wasm-export-enabled? state)})] (rx/concat (dwp/force-persist-files-and-wait (export-file-ids [export]) save-wait-timeout-ms) - (rx/of (warn-unsaved-changes (export-file-ids [export]))) + (rx/of (warn-unsaved-changes (export-file-ids [export]) + (tr "workspace.export.unsaved-changes"))) (->> (rp/cmd! :export params) (rx/map (fn [{:keys [filename mtype uri]}] @@ -407,7 +413,7 @@ (rx/concat (dwp/force-persist-files-and-wait file-ids save-wait-timeout-ms) - (rx/of (warn-unsaved-changes file-ids)) + (rx/of (warn-unsaved-changes file-ids (tr "workspace.export.unsaved-changes"))) (rx/merge ;; Updates the export state with each progress message, until ;; shortly after the export ends or fails. diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index b3eead45ef..fb6fc70c65 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -297,8 +297,16 @@ (def ^:private check-file-restored-params (sm/check-fn schema:handle-file-restored)) +(defn- restorer-fullname + "Name of the user whose session restored the file, when known." + [state session-id] + (when-let [profile-id (dm/get-in state [:workspace-presence session-id :profile-id])] + (dm/get-in state [:profiles profile-id :fullname]))) + (defn handle-file-restored - [{:keys [file-id vern] :as msg}] + "Reloads the file a peer restored to an earlier version and tells the + user why it changed." + [{:keys [file-id vern session-id] :as msg}] (assert (check-file-restored-params msg) "expected valid parameters") @@ -310,9 +318,12 @@ file (dsh/lookup-file state curr-file-id) curr-vern (:vern file)] - (when (and (= file-id curr-file-id) - (not= vern curr-vern)) - (rx/of (ptk/event ::dw/reload-current-file))))))) + (if (and (= file-id curr-file-id) + (not= vern curr-vern)) + (rx/of (ptk/event ::dw/reload-current-file) + (modal/show :file-restored + {:fullname (restorer-fullname state session-id)})) + (rx/empty)))))) (def ^:private schema:handle-library-change [:map {:title "handle-library-change"} diff --git a/frontend/src/app/main/data/workspace/transforms.cljs b/frontend/src/app/main/data/workspace/transforms.cljs index 640a9fc11f..1551b459e7 100644 --- a/frontend/src/app/main/data/workspace/transforms.cljs +++ b/frontend/src/app/main/data/workspace/transforms.cljs @@ -1511,7 +1511,8 @@ :element-type (cfh/get-selected-type objects ids) :moved moved-count})))) - (when (and (some? frame-id) (d/not-empty? changes)) + ;; Shapes that already belong to the frame leave nothing to move + (when (and (some? frame-id) (seq ids)) (rx/of (dch/commit-changes changes) (dwc/expand-collapse frame-id))) (when add-component-to-variant? diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index c626b36a29..4b37d284a9 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -27,6 +27,7 @@ [app.main.ui.workspace.components-debugger :refer [components-debugger*]] [app.main.ui.workspace.context-menu :refer [context-menu*]] [app.main.ui.workspace.coordinates :as coordinates] + [app.main.ui.workspace.file-restored-modal] [app.main.ui.workspace.libraries] [app.main.ui.workspace.nudge] [app.main.ui.workspace.palette :refer [palette*]] diff --git a/frontend/src/app/main/ui/workspace/file_restored_modal.cljs b/frontend/src/app/main/ui/workspace/file_restored_modal.cljs new file mode 100644 index 0000000000..89a9eb7a0a --- /dev/null +++ b/frontend/src/app/main/ui/workspace/file_restored_modal.cljs @@ -0,0 +1,44 @@ +;; 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 app.main.ui.workspace.file-restored-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.main.data.modal :as modal] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.typography :as t] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.util.i18n :refer [tr]] + [rumext.v2 :as mf])) + +#_{:clojure-lsp/ignore [:clojure-lsp/unused-public-var]} +(mf/defc file-restored-modal* + "Tells a peer that another user restored an earlier version of the open + file, which was reloaded." + {::mf/register modal/components + ::mf/register-as :file-restored} + [{:keys [fullname]}] + (let [on-close (mf/use-fn #(st/emit! (modal/hide)))] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog) + :role "dialog" + :aria-modal true + :aria-labelledby "file-restored-title"} + [:> heading* {:id "file-restored-title" + :level 2 + :typography t/title-medium + :class (stl/css :modal-title)} + (tr "workspace.file-restored.title")] + [:> text* {:as "p" :typography t/body-large} + (if fullname + (tr "workspace.file-restored.message" fullname) + (tr "workspace.file-restored.message-anonymous"))] + [:footer {:class (stl/css :modal-footer)} + [:> button* {:on-click on-close + :variant "primary"} + (tr "ds.confirm-ok")]]]])) diff --git a/frontend/src/app/main/ui/workspace/file_restored_modal.scss b/frontend/src/app/main/ui/workspace/file_restored_modal.scss new file mode 100644 index 0000000000..568841a417 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/file_restored_modal.scss @@ -0,0 +1,34 @@ +// 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 + +@use "ds/_utils.scss" as *; +@use "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + @extend %modal-overlay-base; +} + +.modal-dialog { + @extend %modal-container-base; + + color: var(--color-foreground-secondary); + display: grid; + gap: var(--sp-s); + max-width: px2rem(482); // FIXME: This should be a token + + & > * { + margin: 0; // FIXME: This should be in reset styles + } +} + +.modal-title { + color: var(--color-foreground-primary); +} + +.modal-footer { + margin-block-start: var(--sp-m); + justify-self: end; +} diff --git a/frontend/test/frontend_tests/data/dashboard_test.cljs b/frontend/test/frontend_tests/data/dashboard_test.cljs index f59b5f83e7..f0ed1f7e78 100644 --- a/frontend/test/frontend_tests/data/dashboard_test.cljs +++ b/frontend/test/frontend_tests/data/dashboard_test.cljs @@ -11,6 +11,7 @@ [app.main.data.common :as dcm] [app.main.data.dashboard :as dd] [app.main.data.notifications :as ntf] + [app.main.data.persistence :as dps] [app.main.data.websocket :as dws] [app.main.repo :as rp] [app.main.router :as rt] @@ -172,3 +173,28 @@ (await (async/observe (ptk/watch (dd/initialize team-id) state stream) :on-next #(swap! processed conj (ptk/type %)))))) (t/is (= 1 (count (filter #{::dcm/handle-organization-change-sso} @processed)))))) + +;; Scenario: the user duplicates a file whose workspace still has a change +;; waiting to be saved. Proves: the copy is requested once the save is done, +;; and the user is told the copy misses what could not be saved. +(t/deftest ^:async duplicating-a-file-waits-for-its-pending-changes + (let [file-id (uuid/next) + commit-id (uuid/next) + calls (atom []) + store (ptk/store {:state {:files {} + :persistence {:queue (conj #queue [] commit-id) + :index {commit-id {:id commit-id :file-id file-id}}}}})] + (await + (mock/with-mocks* + {dps/force-persist-files-and-wait (mock/stub (fn [file-ids _] + (swap! calls conj [:wait file-ids]) + (rx/empty))) + rp/cmd! (mock/stub (fn [cmd _] + (swap! calls conj [cmd]) + (rx/of {:id (uuid/next)}))) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (dd/duplicate-file {:id file-id :name "File"})) + (await (async/wait-for #(= 2 (count @calls)) "the copy is requested")) + (t/is (= [[:wait #{file-id}] [:duplicate-file]] @calls)) + (t/is (= "translated:dashboard.duplicate-unsaved-changes" + (get-in @store [:notification :content]))))))) diff --git a/frontend/test/frontend_tests/data/exports_assets_test.cljs b/frontend/test/frontend_tests/data/exports_assets_test.cljs index a9c9393f25..c89e3245de 100644 --- a/frontend/test/frontend_tests/data/exports_assets_test.cljs +++ b/frontend/test/frontend_tests/data/exports_assets_test.cljs @@ -8,6 +8,7 @@ (:require [app.common.uuid :as uuid] [app.main.data.exports.assets :as de] + [app.main.data.exports.wasm :as wasm.exports] [app.main.data.persistence :as dwp] [app.main.errors :as errors] [app.main.repo :as repo] @@ -203,3 +204,39 @@ (await (async/wait-for #(true? @downloaded) "the export runs")) (await (async/settle)) (t/is (nil? (:notification @store))))))) + +;; Scenario: a PDF is drawn in the browser while its file has a change not +;; saved yet. Proves: the export waits for the save first, and the user is +;; told the export includes changes the server does not have. +(t/deftest ^:async client-export-waits-for-the-save-and-warns-of-unsaved-changes + (let [file-id (uuid/next) + calls (atom []) + store (export-store (assoc (unsaved-state file-id) :features #{"render-wasm/v1"}) + (atom []))] + (await + (mock/with-mocks* + {dwp/force-persist-files-and-wait (mock/stub (fn [file-ids _] + (swap! calls conj [:wait file-ids]) + (rx/empty))) + wasm.exports/export-pdf (mock/stub (fn [_] (swap! calls conj [:export]))) + i18n/tr (mock/stub #(str "translated:" %))} + (ptk/emit! store (de/request-simple-export {:export (assoc export :file-id file-id :type :pdf)})) + (await (async/wait-for #(= 2 (count @calls)) "the export runs")) + (t/is (= [[:wait #{file-id}] [:export]] @calls) + "the export starts once the save is done") + (t/is (= "translated:workspace.export.unsaved-changes-included" + (get-in @store [:notification :content]))))))) + +;; Scenario: a PDF is drawn in the browser with every change saved. Proves: +;; no warning is shown. +(t/deftest ^:async client-export-shows-no-warning-when-the-file-is-saved + (let [exported (atom false) + store (export-store (assoc (test-state) :features #{"render-wasm/v1"}) (atom []))] + (await + (mock/with-mocks* + {dwp/force-persist-files-and-wait (mock/stub (fn [_ _] (rx/empty))) + wasm.exports/export-pdf (mock/stub (fn [_] (reset! exported true)))} + (ptk/emit! store (de/request-simple-export {:export (assoc export :file-id (uuid/next) :type :pdf)})) + (await (async/wait-for #(true? @exported) "the export runs")) + (await (async/settle)) + (t/is (nil? (:notification @store))))))) diff --git a/frontend/test/frontend_tests/data/workspace_notifications_test.cljs b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs new file mode 100644 index 0000000000..382a852cfa --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_notifications_test.cljs @@ -0,0 +1,74 @@ +;; 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-notifications-test + (:require + [app.common.uuid :as uuid] + [app.main.data.modal :as modal] + [app.main.data.workspace :as-alias dw] + [app.main.data.workspace.notifications :as dwn] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.async :as async] + [potok.v2.core :as ptk])) + +(defn- restored-events + "Events the watch of `handle-file-restored` emits for `msg` on `state`." + [state msg] + (let [events (atom [])] + (-> (async/observe (ptk/watch (dwn/handle-file-restored msg) state nil) + :on-next #(swap! events conj %)) + (.then (fn [_] @events))))) + +(defn- shown-modal + [events] + (some #(when (= ::modal/show-modal (ptk/type %)) + (::modal/modal (ptk/update % {}))) + events)) + +;; Scenario: a peer restores an earlier version of the open file. The file +;; reloads and a modal names the peer. Proves: the reload is not silent. +(t/deftest ^:async a-peer-restore-reloads-and-names-the-peer + (let [file-id (uuid/next) + session-id (uuid/next) + profile-id (uuid/next) + state {:current-file-id file-id + :files {file-id {:id file-id :vern 1}} + :workspace-presence {session-id {:profile-id profile-id}} + :profiles {profile-id {:id profile-id :fullname "Ada"}}} + events (await (restored-events state {:type :file-restored + :file-id file-id + :session-id session-id + :vern 2})) + modal (shown-modal events)] + (t/is (some #(= ::dw/reload-current-file (ptk/type %)) events)) + (t/is (= :file-restored (:type modal))) + (t/is (= "Ada" (get-in modal [:props :fullname]))))) + +;; Scenario: the restoring session left before its message arrived, so it +;; has no presence. Proves: the modal still shows, without a name. +(t/deftest ^:async a-restore-from-an-unknown-session-shows-no-name + (let [file-id (uuid/next) + state {:current-file-id file-id + :files {file-id {:id file-id :vern 1}}} + events (await (restored-events state {:type :file-restored + :file-id file-id + :session-id (uuid/next) + :vern 2})) + modal (shown-modal events)] + (t/is (= :file-restored (:type modal))) + (t/is (nil? (get-in modal [:props :fullname]))))) + +;; Scenario: the file already shows the restored version. Proves: no reload +;; and no modal for a version the user already sees. +(t/deftest ^:async a-restore-of-the-loaded-version-does-nothing + (let [file-id (uuid/next) + state {:current-file-id file-id + :files {file-id {:id file-id :vern 2}}} + events (await (restored-events state {:type :file-restored + :file-id file-id + :session-id (uuid/next) + :vern 2}))] + (t/is (empty? events)))) diff --git a/frontend/test/frontend_tests/logic/move_shapes_to_frame_test.cljs b/frontend/test/frontend_tests/logic/move_shapes_to_frame_test.cljs new file mode 100644 index 0000000000..7dab714ad3 --- /dev/null +++ b/frontend/test/frontend_tests/logic/move_shapes_to_frame_test.cljs @@ -0,0 +1,65 @@ +;; 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.logic.move-shapes-to-frame-test + "Tests for the reparenting step that ends a move (`dwt/move-shapes-to-frame`)." + (:require + [app.common.test-helpers.compositions :as ctho] + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.common.uuid :as uuid] + [app.main.data.changes :as dch] + [app.main.data.workspace.transforms :as dwt] + [beicon.v2.core :as rx] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [potok.v2.core :as ptk])) + +(t/use-fixtures :each {:before cthi/reset-idmap!}) + +(defn- count-commits + "Runs `event` on a store of `file` and calls `check` with the number of + commits it made and the resulting file." + [done file event check] + (let [store (ths/setup-store file) + commits (atom 0) + sub (->> (ptk/input-stream store) + (rx/filter dch/commit?) + (rx/subs! (fn [_] (swap! commits inc))))] + (ths/run-store store done [event] + (fn [state] + (rx/dispose! sub) + (check @commits (ths/get-file-from-state state)))))) + +;; Scenario: a move ends over the frame the shape already belongs to. +;; Proves: nothing changes parent, so no commit (and no save) is made. +(t/deftest moving-a-shape-within-its-frame-commits-nothing + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-rect :rect1)) + rect (cths/get-shape file :rect1)] + (count-commits done file + (dwt/move-shapes-to-frame [(:id rect)] uuid/zero nil nil) + (fn [commits _] + (t/is (zero? commits))))))) + +;; Scenario: a move ends over another board. Proves: the shape is moved into +;; it with one commit. +(t/deftest moving-a-shape-into-a-board-commits-the-new-parent + (t/async + done + (let [file (-> (cthf/sample-file :file1) + (ctho/add-frame :frame1) + (ctho/add-rect :rect1)) + rect (cths/get-shape file :rect1) + frame (cths/get-shape file :frame1)] + (count-commits done file + (dwt/move-shapes-to-frame [(:id rect)] (:id frame) nil nil) + (fn [commits file'] + (t/is (= 1 commits)) + (t/is (= (:id frame) (:parent-id (cths/get-shape file' :rect1))))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index c9c60d98ed..718e027f42 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -34,6 +34,7 @@ [frontend-tests.data.workspace-libraries-sync-test] [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] + [frontend-tests.data.workspace-notifications-test] [frontend-tests.data.workspace-pages-test] [frontend-tests.data.workspace-path-edition-test] [frontend-tests.data.workspace-reflow-test] @@ -53,6 +54,7 @@ [frontend-tests.logic.copying-and-duplicating-test] [frontend-tests.logic.frame-guides-test] [frontend-tests.logic.groups-test] + [frontend-tests.logic.move-shapes-to-frame-test] [frontend-tests.logic.nudge-selected-shapes-test] [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.logic.path-actions-test] @@ -186,6 +188,7 @@ 'frontend-tests.data.workspace-libraries-sync-test 'frontend-tests.data.workspace-mcp-test 'frontend-tests.data.workspace-media-test + 'frontend-tests.data.workspace-notifications-test 'frontend-tests.data.workspace-pages-test 'frontend-tests.data.workspace-path-edition-test 'frontend-tests.data.workspace-reflow-test @@ -205,6 +208,7 @@ 'frontend-tests.logic.copying-and-duplicating-test 'frontend-tests.logic.frame-guides-test 'frontend-tests.logic.groups-test + 'frontend-tests.logic.move-shapes-to-frame-test 'frontend-tests.logic.nudge-selected-shapes-test 'frontend-tests.logic.path-actions-test 'frontend-tests.logic.path-clipboard-test diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 305a52019a..50edbf80fe 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -437,6 +437,10 @@ msgstr "Duplicate" msgid "dashboard.duplicate-multi" msgstr "Duplicate %s files" +#: src/app/main/data/dashboard.cljs +msgid "dashboard.duplicate-unsaved-changes" +msgstr "Some of your latest changes are not saved yet, so the copy will not include them." + #: src/app/main/ui/dashboard/files.cljs:233, src/app/main/ui/dashboard/projects.cljs:309 msgid "dashboard.empty-placeholder-drafts-subtitle" msgstr "Once a project member creates a draft, it will be displayed here." @@ -6461,6 +6465,22 @@ msgstr "Debugging tools" msgid "workspace.export.unsaved-changes" msgstr "Some of your latest changes are not saved yet, so this export may not include them." +#: src/app/main/data/exports/assets.cljs +msgid "workspace.export.unsaved-changes-included" +msgstr "Some of your latest changes are not saved yet. This export includes them." + +#: src/app/main/ui/workspace/file_restored_modal.cljs +msgid "workspace.file-restored.message" +msgstr "%s restored an earlier version of this file. You are now seeing that version." + +#: src/app/main/ui/workspace/file_restored_modal.cljs +msgid "workspace.file-restored.message-anonymous" +msgstr "A teammate restored an earlier version of this file. You are now seeing that version." + +#: src/app/main/ui/workspace/file_restored_modal.cljs +msgid "workspace.file-restored.title" +msgstr "This file was restored" + #: src/app/main/ui/workspace/sidebar/layers.cljs:907 msgid "workspace.focus.focus-mode" msgstr "Focus mode" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 7554cd16ed..5c3fdc3334 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -445,6 +445,10 @@ msgstr "Duplicar" msgid "dashboard.duplicate-multi" msgstr "Duplicar %s archivos" +#: src/app/main/data/dashboard.cljs +msgid "dashboard.duplicate-unsaved-changes" +msgstr "Algunos de tus últimos cambios aún no se han guardado, así que la copia no los incluirá." + #: src/app/main/ui/dashboard/files.cljs:233, src/app/main/ui/dashboard/projects.cljs:309 msgid "dashboard.empty-placeholder-drafts-subtitle" msgstr "Cuando un miembro del equipo cree algún borrador, este aparecerá aquí." @@ -6527,6 +6531,22 @@ msgstr "Herramientas de depuración" msgid "workspace.export.unsaved-changes" msgstr "Algunos de tus últimos cambios aún no se han guardado, así que puede que esta exportación no los incluya." +#: src/app/main/data/exports/assets.cljs +msgid "workspace.export.unsaved-changes-included" +msgstr "Algunos de tus últimos cambios aún no se han guardado. Esta exportación los incluye." + +#: src/app/main/ui/workspace/file_restored_modal.cljs +msgid "workspace.file-restored.message" +msgstr "%s ha restaurado una versión anterior de este archivo. Ahora ves esa versión." + +#: src/app/main/ui/workspace/file_restored_modal.cljs +msgid "workspace.file-restored.message-anonymous" +msgstr "Un miembro del equipo ha restaurado una versión anterior de este archivo. Ahora ves esa versión." + +#: src/app/main/ui/workspace/file_restored_modal.cljs +msgid "workspace.file-restored.title" +msgstr "Se ha restaurado este archivo" + #: src/app/main/ui/workspace/sidebar/layers.cljs:907 msgid "workspace.focus.focus-mode" msgstr "Modo foco"