diff --git a/frontend/src/app/main/data/workspace/interactions.cljs b/frontend/src/app/main/data/workspace/interactions.cljs index bd97f0eec6..673697a74d 100644 --- a/frontend/src/app/main/data/workspace/interactions.cljs +++ b/frontend/src/app/main/data/workspace/interactions.cljs @@ -129,6 +129,20 @@ (or (some ctsi/flow-origin? (map :interactions children)) (some #(ctsi/flow-to? % frame-id) (map :interactions (vals objects)))))) +(defn- new-flow-event + "Returns an `add-flow` event that creates an implicit flow for the root + frame containing `shape-id`, or nil when that frame is already part of a + flow. Mirrors the editor behavior of creating a flow when an interaction + is added outside of any existing flow." + [state page-id shape-id] + (let [page (dsh/lookup-page state page-id) + objects (get page :objects) + frame (cfh/get-root-frame objects shape-id) + flow (ctp/get-frame-flow (get page :flows) (:id frame))] + (when (and (not (connected-frame? objects (:id frame))) + (nil? flow)) + (add-flow (:id frame))))) + (defn add-interaction [page-id shape-id interaction] (ptk/reify ::add-interaction @@ -143,7 +157,9 @@ (when (:destination interaction) (dwsh/update-shapes [(:destination interaction)] cls/show-in-viewer - {:page-id page-id}))))))) + {:page-id page-id})) + (when (ctsi/flow-origin? [interaction]) + (new-flow-event state page-id shape-id))))))) (defn add-new-interaction ([shape] (add-new-interaction shape nil)) @@ -154,11 +170,8 @@ (let [page-id (:current-page-id state) page (dsh/lookup-page state page-id) objects (get page :objects) - frame (cfh/get-root-frame objects (:id shape)) - first? (not-any? #(seq (:interactions %)) (vals objects)) - flows (get page :flows) - flow (ctp/get-frame-flow flows (:id frame))] + first? (not-any? #(seq (:interactions %)) (vals objects))] (rx/concat (rx/of (dwsh/update-shapes [(:id shape)] @@ -171,9 +184,8 @@ (when destination (rx/of (dwsh/update-shapes [destination] cls/show-in-viewer))) - (when (and (not (connected-frame? objects (:id frame))) - (nil? flow)) - (rx/of (add-flow (:id frame)))) + (when-let [flow-event (new-flow-event state page-id (:id shape))] + (rx/of flow-event)) (if first? ;; When the first interaction of the page is created we emit the event "create-prototype" (rx/of (ev/event {::ev/name "create-prototype"})) diff --git a/frontend/test/frontend_tests/data/workspace_interactions_test.cljs b/frontend/test/frontend_tests/data/workspace_interactions_test.cljs new file mode 100644 index 0000000000..2d21651991 --- /dev/null +++ b/frontend/test/frontend_tests/data/workspace_interactions_test.cljs @@ -0,0 +1,119 @@ +;; 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-interactions-test + (: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.types.shape.interactions :as ctsi] + [app.common.uuid :as uuid] + [app.main.data.workspace.interactions :as dwi] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths])) + +(t/use-fixtures :each + {:before cthi/reset-idmap!}) + +;; --------------------------------------------------------------------------- +;; Helpers +;; --------------------------------------------------------------------------- + +(defn- make-file + "A file with two boards (frames)." + [] + (-> (cthf/sample-file :file1) + (ctho/add-frame :board1 :name "Board 1") + (ctho/add-frame :board2 :name "Board 2"))) + +(defn- navigate-interaction + "A navigate interaction from `origin-id` to `dest-id` (a flow origin)." + [origin-id dest-id] + (-> ctsi/default-interaction + (ctsi/set-destination dest-id) + (assoc :position-relative-to origin-id))) + +(defn- page-flows + "The flows map of the current page in `state`." + [state] + (let [file-id (:current-file-id state) + page-id (:current-page-id state)] + (get-in state [:files file-id :data :pages-index page-id :flows]))) + +;; --------------------------------------------------------------------------- +;; add-interaction (plugin API path): implicit flow creation must match the +;; editor behavior of creating a flow when an interaction is added outside of +;; any existing flow. +;; --------------------------------------------------------------------------- + +(t/deftest add-interaction-creates-implicit-flow-outside-flow + (t/async + done + (let [file (make-file) + board1-id (:id (cths/get-shape file :board1)) + board2-id (:id (cths/get-shape file :board2)) + page-id (cthf/current-page-id file) + interaction (navigate-interaction board1-id board2-id) + store (ths/setup-store file) + events [(dwi/add-interaction page-id board1-id interaction)]] + + (ths/run-store + store done events + (fn [new-state] + (let [flows (vals (page-flows new-state))] + (t/is (= 1 (count flows)) + "an implicit flow is created for the origin board") + (t/is (= board1-id (:starting-frame (first flows))) + "the implicit flow starts at the origin board"))))))) + +(t/deftest add-interaction-does-not-duplicate-flow-for-same-frame + (t/async + done + (let [file (make-file) + board1-id (:id (cths/get-shape file :board1)) + board2-id (:id (cths/get-shape file :board2)) + page-id (cthf/current-page-id file) + flow-id (uuid/next) + ;; board1 is already the starting frame of an explicit flow + file (assoc-in file + [:data :pages-index page-id :flows flow-id] + {:id flow-id + :name "My flow" + :starting-frame board1-id}) + + interaction (navigate-interaction board1-id board2-id) + store (ths/setup-store file) + events [(dwi/add-interaction page-id board1-id interaction)]] + + (ths/run-store + store done events + (fn [new-state] + (let [flows (vals (page-flows new-state))] + (t/is (= 1 (count flows)) + "no duplicate flow is created when the frame is already in a flow") + (t/is (= "My flow" (:name (first flows))) + "the existing explicit flow is preserved"))))))) + +(t/deftest add-interaction-without-destination-does-not-create-flow + (t/async + done + (let [file (make-file) + board1-id (:id (cths/get-shape file :board1)) + page-id (cthf/current-page-id file) + ;; An open-url interaction is not a flow origin (no destination) + interaction (-> ctsi/default-interaction + (assoc :action-type :open-url + :url "https://example.com" + :destination nil)) + store (ths/setup-store file) + events [(dwi/add-interaction page-id board1-id interaction)]] + + (ths/run-store + store done events + (fn [new-state] + (t/is (empty? (page-flows new-state)) + "non flow-origin interactions do not create a flow")))))) diff --git a/frontend/test/frontend_tests/plugins/interactions_test.cljs b/frontend/test/frontend_tests/plugins/interactions_test.cljs new file mode 100644 index 0000000000..0afd39e05c --- /dev/null +++ b/frontend/test/frontend_tests/plugins/interactions_test.cljs @@ -0,0 +1,82 @@ +;; 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.plugins.interactions-test + (:require + [app.common.test-helpers.files :as cthf] + [app.main.store :as st] + [app.plugins.api :as api] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.state :as ths] + [frontend-tests.helpers.wasm :as thw])) + +(defn- flows-of + "The vals of the current page flows from the store." + [store ^js context ^js page] + (let [file-id (aget (. context -currentFile) "$id") + page-id (aget page "$id")] + (vals (get-in @store [:files file-id :data :pages-index page-id :flows])))) + +(t/deftest add-interaction-creates-implicit-flow + (thw/with-wasm-mocks* + (fn [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + ^js context (api/create-context "00000000-0000-0000-0000-000000000000") + _ (set! st/state store) + ^js page (. context -currentPage) + ^js board1 (.createBoard context) + ^js board2 (.createBoard context)] + + (t/testing "a page has no flows before any interaction is added" + (t/is (empty? (flows-of store context page)))) + + (t/testing "addInteraction outside a flow creates an implicit flow" + (.addInteraction board1 "click" #js {:type "navigate-to" :destination board2}) + (let [flows (flows-of store context page)] + (t/is (= 1 (count flows)) + "an implicit flow is created for the origin board") + (t/is (= (aget board1 "$id") (:starting-frame (first flows))) + "the implicit flow starts at the origin board"))) + + (t/testing "adding another interaction from the same board does not duplicate the flow" + (.addInteraction board1 "click" #js {:type "navigate-to" :destination board2}) + (t/is (= 1 (count (flows-of store context page))) + "no duplicate flow is created")))))) + +(t/deftest add-interaction-does-not-duplicate-explicit-flow + (thw/with-wasm-mocks* + (fn [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + ^js context (api/create-context "00000000-0000-0000-0000-000000000000") + _ (set! st/state store) + ^js page (. context -currentPage) + ^js board1 (.createBoard context) + ^js board2 (.createBoard context)] + + ;; board1 is already the starting frame of an explicitly created flow + (.createFlow page "My flow" board1) + + (t/testing "addInteraction from a board already in a flow keeps a single flow" + (.addInteraction board1 "click" #js {:type "navigate-to" :destination board2}) + (let [flows (flows-of store context page)] + (t/is (= 1 (count flows)) + "no duplicate flow is created alongside the explicit one") + (t/is (= "My flow" (:name (first flows))) + "the explicit flow is preserved"))))))) + +(t/deftest add-interaction-without-destination-does-not-create-flow + (thw/with-wasm-mocks* + (fn [] + (let [store (ths/setup-store (cthf/sample-file :file1 :page-label :page1)) + ^js context (api/create-context "00000000-0000-0000-0000-000000000000") + _ (set! st/state store) + ^js page (. context -currentPage) + ^js board1 (.createBoard context)] + + (t/testing "a non flow-origin interaction (open-url) creates no flow" + (.addInteraction board1 "click" #js {:type "open-url" :url "https://example.com"}) + (t/is (empty? (flows-of store context page)) + "open-url interactions do not create a flow")))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index f52364e2ed..e625900a35 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -12,6 +12,7 @@ [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] [frontend-tests.data.workspace-colors-test] + [frontend-tests.data.workspace-interactions-test] [frontend-tests.data.workspace-mcp-test] [frontend-tests.data.workspace-media-test] [frontend-tests.data.workspace-shortcuts-test] @@ -27,6 +28,7 @@ [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] + [frontend-tests.plugins.interactions-test] [frontend-tests.plugins.page-active-validation-test] [frontend-tests.plugins.page-test] [frontend-tests.plugins.parser-test] @@ -67,6 +69,7 @@ frontend-tests.data.uploads-test frontend-tests.data.viewer-test frontend-tests.data.workspace-colors-test + frontend-tests.data.workspace-interactions-test frontend-tests.data.workspace-mcp-test frontend-tests.data.workspace-media-test frontend-tests.data.workspace-shortcuts-test @@ -81,6 +84,7 @@ frontend-tests.logic.pasting-in-containers-test frontend-tests.plugins.context-shapes-test frontend-tests.plugins.page-active-validation-test + frontend-tests.plugins.interactions-test frontend-tests.plugins.page-test frontend-tests.plugins.parser-test frontend-tests.plugins.tokens-test diff --git a/plugins/libs/plugin-types/index.d.ts b/plugins/libs/plugin-types/index.d.ts index 8b41bec813..c999682c3f 100644 --- a/plugins/libs/plugin-types/index.d.ts +++ b/plugins/libs/plugin-types/index.d.ts @@ -3969,6 +3969,11 @@ export interface ShapeBase extends PluginData { /** * Adds a new interaction to the shape. + * + * If the interaction starts a flow (for example a `navigate-to` action) and + * the shape's board is not already part of any flow, a new flow starting at + * that board is created automatically, matching the behavior of the editor. + * * @param trigger defines the conditions under which the action will be triggered * @param action defines what will be executed when the trigger happens * @param delay for the type of trigger `after-delay` will specify the time after triggered. Ignored otherwise.