diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index ab2ace94da..c46b56806c 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -373,7 +373,7 @@ ptk/WatchEvent (watch [_ _ _] - (let [params {:id id :name name}] + (let [params (with-meta {:id id :name name} (meta params))] (->> (rp/cmd! :rename-project params) (rx/ignore)))))) @@ -435,7 +435,7 @@ ptk/WatchEvent (watch [_ _ _] - (let [params (select-keys params [:id :name])] + (let [params (with-meta (select-keys params [:id :name]) (meta params))] (->> (rp/cmd! :rename-file params) (rx/ignore)))))) @@ -460,15 +460,18 @@ ptk/WatchEvent (watch [_ _ _] - (let [params {:id id :is-shared is-shared}] + (let [mdata (meta params) + origin (::ev/origin mdata "dashboard")] (rx/concat - (->> (rp/cmd! :set-file-shared params) + (->> (rp/cmd! :set-file-shared (with-meta {:id id :is-shared is-shared} mdata)) (rx/ignore)) (when is-shared (->> (rp/cmd! :get-file-summary {:id id}) (rx/map (fn [summary] (when (-> summary :variants :count pos?) - (ev/event {::ev/name "set-file-variants-shared" ::ev/origin "dashboard"}))))))))))) + (ev/event (merge {::ev/name "set-file-variants-shared" + ::ev/origin origin} + (select-keys mdata [::ev/context]))))))))))))) (defn set-file-thumbnail [file-id thumbnail-id] diff --git a/frontend/src/app/plugins/management.cljs b/frontend/src/app/plugins/management.cljs index 91671991f8..fe1662a11f 100644 --- a/frontend/src/app/plugins/management.cljs +++ b/frontend/src/app/plugins/management.cljs @@ -46,6 +46,14 @@ (update-fn state) state)))) +;; Dashboard events are logged by the backend, which reads the origin from +;; the `x-event-origin` header that `rp/cmd!` builds from the params meta. +(defn- with-origin + "Tags RPC `params` with the plugin or MCP origin, as `se/add-event` does + for frontend events." + [plugin-id params] + (se/add-event params plugin-id)) + (defn- refresh-restored-project [team-id project-id] (if (dashboard-of-team? @st/state team-id) @@ -65,8 +73,9 @@ (defn- restore-deleted-files "Uses the dashboard's Trash operation and waits for its final result." - [team-id ids] - (->> (rp/cmd! ::sse/restore-deleted-team-files {:team-id team-id :ids ids}) + [plugin-id team-id ids] + (->> (rp/cmd! ::sse/restore-deleted-team-files + (with-origin plugin-id {:team-id team-id :ids ids})) (rx/filter sse/end-of-stream?) (rx/take 1) (rx/map sse/get-payload) @@ -252,7 +261,7 @@ [plugin-id team-id options] (if-let [name (valid-name (obj/get options "name"))] (request plugin-id "manage:projects" - #(->> (rp/cmd! :create-project {:team-id team-id :name name}) + #(->> (rp/cmd! :create-project (with-origin plugin-id {:team-id team-id :name name})) (rx/map (fn [project] (assoc project :count 0))) (rx/tap (fn [project] (update-dashboard team-id @@ -309,7 +318,7 @@ :else (set-property plugin-id data :name name - (dtm/update-team {:id (:id @data) :name name})))))} + (dtm/update-team (with-origin plugin-id {:id (:id @data) :name name}))))))} :listProjects (fn [] (list-projects plugin-id (:id @data))) @@ -355,7 +364,7 @@ :else (set-property plugin-id data :name name - (dd/rename-project {:id (:id @data) :name name})))))} + (dd/rename-project (with-origin plugin-id {:id (:id @data) :name name}))))))} :pinned {:get #(boolean (:is-pinned @data)) @@ -370,9 +379,10 @@ :else (set-property plugin-id data :is-pinned value - (set-project-pin {:id (:id @data) - :team-id (:team-id @data) - :is-pinned value}))))} + (set-project-pin (with-origin plugin-id + {:id (:id @data) + :team-id (:team-id @data) + :is-pinned value})))))} :listFiles (fn [] @@ -395,7 +405,7 @@ (let [ids (into #{} (comp (filter (fn [file] (= id (:project-id file)))) (map :id)) files)] (if (seq ids) - (restore-deleted-files team-id ids) + (restore-deleted-files plugin-id team-id ids) (rx/throw (js/Error. "Cannot restore a project without recoverable files")))))) (rx/tap (fn [_] (swap! data dissoc :deleted-at))) (rx/mapcat (fn [_] (refresh-restored-project team-id id))))))) @@ -406,7 +416,7 @@ (request plugin-id "content:write" #(let [{:keys [id team-id]} @data features (set/difference (:features @st/state #{}) cfeat/frontend-only-features)] - (->> (rp/cmd! :create-file {:project-id id :name name :features features}) + (->> (rp/cmd! :create-file (with-origin plugin-id {:project-id id :name name :features features})) (rx/map (fn [file] (-> (dissoc file :data) (assoc :team-id team-id)))) (rx/tap (fn [file] (update-dashboard team-id (fn [state] (ptk/update (dd/file-created file) state))))) @@ -420,8 +430,9 @@ (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) (request plugin-id "manage:projects" #(let [{:keys [id team-id]} @data] - (->> (rp/cmd! :duplicate-project (cond-> {:project-id id} - (some? name) (assoc :name (valid-name name)))) + (->> (rp/cmd! :duplicate-project (with-origin plugin-id + (cond-> {:project-id id} + (some? name) (assoc :name (valid-name name))))) (rx/mapcat dd/project-with-file-count) (rx/tap (fn [project] (update-dashboard team-id (fn [state] (ptk/update (dd/project-duplicated project) state))))) @@ -432,7 +443,7 @@ (if-let [team-id (uuid/parse* (obj/get team "id"))] (request plugin-id "manage:teams" #(let [{:keys [id] :as project} @data] - (->> (rp/cmd! :move-project {:project-id id :team-id team-id}) + (->> (rp/cmd! :move-project (with-origin plugin-id {:project-id id :team-id team-id})) (rx/tap (fn [_] (swap! data assoc :team-id team-id) (update-dashboard (:team-id project) (fn [state] (update state :projects dissoc id))))) @@ -443,7 +454,7 @@ (fn [] (request plugin-id "manage:delete" #(let [{:keys [id team-id]} @data] - (->> (rp/cmd! :delete-project {:id id}) + (->> (rp/cmd! :delete-project (with-origin plugin-id {:id id})) (rx/tap (fn [_] (update-dashboard team-id (fn [state] (update state :projects dissoc id))))) (rx/map (constantly nil))))))))) @@ -480,7 +491,7 @@ :else (set-property plugin-id data :name name - (dd/rename-file {:id (:id @data) :name name})))))} + (dd/rename-file (with-origin plugin-id {:id (:id @data) :name name}))))))} :shared {:get #(boolean (:is-shared @data)) @@ -495,7 +506,7 @@ :else (set-property plugin-id data :is-shared value - (dd/set-file-shared {:id (:id @data) :is-shared value}))))} + (dd/set-file-shared (with-origin plugin-id {:id (:id @data) :is-shared value})))))} :open (fn [] @@ -506,7 +517,7 @@ :restore (fn [] (request plugin-id "content:write" - #(->> (restore-deleted-files (:team-id @data) #{(:id @data)}) + #(->> (restore-deleted-files plugin-id (:team-id @data) #{(:id @data)}) (rx/tap (fn [_] (swap! data dissoc :deleted-at :will-be-deleted-at))) (rx/mapcat (fn [_] (refresh-restored-project (:team-id @data) (:project-id @data))))))) @@ -519,7 +530,7 @@ (->> (rp/cmd! :get-project {:id project-id}) (rx/mapcat (fn [target] (if (= team-id (:team-id target)) - (rp/cmd! :move-files {:ids #{id} :project-id project-id}) + (rp/cmd! :move-files (with-origin plugin-id {:ids #{id} :project-id project-id})) (rx/throw (js/Error. "Cannot move a file to a project in another team"))))) (rx/tap (fn [_] (update-dashboard team-id (fn [state] (ptk/update (dd/move-files {:ids #{id} :project-id project-id}) state))) @@ -534,8 +545,9 @@ (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")) (request plugin-id "content:write" #(let [{:keys [id team-id]} @data] - (->> (rp/cmd! :duplicate-file (cond-> {:file-id id} - (some? name) (assoc :name (valid-name name)))) + (->> (rp/cmd! :duplicate-file (with-origin plugin-id + (cond-> {:file-id id} + (some? name) (assoc :name (valid-name name))))) (rx/map (fn [file] (-> (dissoc file :data) (assoc :team-id team-id)))) (rx/tap (fn [file] (update-dashboard team-id (fn [state] (ptk/update (dd/file-created file) state))))) @@ -545,7 +557,7 @@ (fn [] (request plugin-id "manage:delete" #(let [{:keys [id project-id team-id]} @data] - (->> (rp/cmd! :delete-file {:id id}) + (->> (rp/cmd! :delete-file (with-origin plugin-id {:id id})) (rx/tap (fn [_] (update-dashboard team-id (fn [state] @@ -576,7 +588,7 @@ (fn [options] (if-let [name (valid-name (obj/get options "name"))] (request plugin-id "manage:teams" - #(->> (rp/cmd! :create-team {:name name :features features/global-enabled-features}) + #(->> (rp/cmd! :create-team (with-origin plugin-id {:name name :features features/global-enabled-features})) (rx/tap (fn [_] (st/emit! (dtm/fetch-teams)))) (rx/map (partial team-proxy plugin-id)))) (js/Promise.reject (js/Error. "Expected a name with 1 to 250 characters")))) diff --git a/frontend/test/frontend_tests/plugins/management_test.cljs b/frontend/test/frontend_tests/plugins/management_test.cljs index 15d04f8f7f..e9637d4d88 100644 --- a/frontend/test/frontend_tests/plugins/management_test.cljs +++ b/frontend/test/frontend_tests/plugins/management_test.cljs @@ -8,6 +8,7 @@ (:require [app.common.uuid :as uuid] [app.main.data.dashboard :as dd] + [app.main.data.event :as ev] [app.main.data.team :as dtm] [app.main.repo :as rp] [app.main.store :as st] @@ -80,6 +81,15 @@ ([command] (swap! requests conj [command]) (respond command nil)) ([command params] (swap! requests conj [command params]) (respond command params))))) +(defn- origins + "Maps each recorded command that is not a read to the origin of its params." + [requests] + (into {} + (keep (fn [[command params]] + (when-not (re-find #"^get-" (name command)) + [command (::ev/origin (meta params))]))) + requests)) + (defn- emit-stub "Mock `st/emit!` applying update functions to `state` and recording events." [state events] @@ -231,18 +241,20 @@ (props team* ["id" "name" "isDefault"]))) (t/is (= [[:create-team "Brand"]] (mapv (fn [[command params]] [command (:name params)]) @requests))) + (t/is (= {:create-team "plugin"} (origins @requests))) (t/is (= 1 (count (filter #(= ::dtm/fetch-teams (ptk/type %)) @events)))) (t/is (= "Expected a name with 1 to 250 characters" (await (message (.createTeam (management/create-context plugin-id) #js {:name ""})))))))))) (t/deftest ^:async setters-update-values-after-the-backend-succeeds - (let [state (atom (throwing dashboard)) - events (atom [])] + (let [state (atom (throwing dashboard)) + events (atom []) + requests (atom [])] (await (mock/with-mocks* {st/state state st/emit! (emit-stub state events) - rp/cmd! (mock/stub (fn [& _] (->> (rx/of {}) (rx/observe-on :async))))} + rp/cmd! (cmd-stub requests (fn [& _] (->> (rx/of {}) (rx/observe-on :async))))} (let [team* (management/team-proxy plugin-id team) project* (management/project-proxy plugin-id project) file* (management/file-proxy plugin-id file)] @@ -260,7 +272,10 @@ (t/is (true? (obj/get project* "pinned"))) (t/is (= "Login" (obj/get file* "name"))) (t/is (true? (obj/get file* "shared"))) - (t/is (= 5 (count @events)))))))) + (t/is (= 5 (count @events))) + (t/is (= {:update-team "plugin" :rename-project "plugin" :update-project-pin "plugin" + :rename-file "plugin" :set-file-shared "plugin"} + (origins @requests)))))))) (t/deftest ^:async stale-object-setter-errors-stay-out-of-the-global-handler (doseq [[constructor property value original] @@ -340,6 +355,7 @@ (t/is (= {"name" "Login" "teamId" (str team-id)} (props file* ["name" "teamId"]))) (t/is (= 2 (obj/get parent "fileCount"))) (t/is (= [:create-project {:team-id team-id :name "Icons"}] (first @requests))) + (t/is (= {:create-project "plugin" :create-file "plugin"} (origins @requests))) (if (= initial dashboard) (do (t/is (contains? (:projects @state) (:id created-project))) (t/is (contains? (:files @state) (:id created-file)))) @@ -383,7 +399,10 @@ [:move-project {:project-id other-id :team-id other-id}] [:delete-file {:id file-id}] [:delete-project {:id other-id}]] - @requests))))))) + @requests)) + (t/is (= {:duplicate-file "plugin" :duplicate-project "plugin" :move-files "plugin" + :move-project "plugin" :delete-file "plugin" :delete-project "plugin"} + (origins @requests)))))))) (t/deftest ^:async methods-reject-invalid-input-and-missing-permissions (let [requests (atom [])] @@ -475,7 +494,8 @@ (doseq [proxy [(management/project-proxy r/mcp-plugin-id project) (management/file-proxy r/mcp-plugin-id file)]] (t/is (nil? (await (.remove proxy))))) - (t/is (= [[:delete-project {:id project-id}] [:delete-file {:id file-id}]] @requests)))))) + (t/is (= [[:delete-project {:id project-id}] [:delete-file {:id file-id}]] @requests)) + (t/is (= {:delete-project "mcp" :delete-file "mcp"} (origins @requests))))))) (t/deftest ^:async deleted-objects-can-be-listed-and-restored (let [state (atom ready) @@ -532,6 +552,7 @@ rp/cmd! (mock/stub (fn [command params] (t/is (= ::sse/restore-deleted-team-files command)) (t/is (= {:team-id team-id :ids #{file-id}} params)) + (t/is (= "plugin" (::ev/origin (meta params)))) (rx/observe-on :async response)))} (let [promise (.then (.restore (management/file-proxy plugin-id file)) #(reset! completed? true))]