0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-04-16 00:41:25 -05:00

Complete backend behavior when duplicate and move

This commit is contained in:
Andrey Antukh 2021-03-02 21:13:19 +01:00 committed by Andrés Moya
parent 044f1f63c0
commit 6a345c4b8a
11 changed files with 269 additions and 299 deletions

View file

@ -16,6 +16,7 @@
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.db :as db]
[app.rpc.permissions :as perms]
[app.rpc.queries.files :as files]
[app.rpc.queries.projects :as proj]
[app.tasks :as tasks]
@ -47,14 +48,13 @@
(proj/check-edition-permissions! conn profile-id project-id)
(create-file conn params)))
(defn- create-file-profile
[conn {:keys [profile-id file-id] :as params}]
(db/insert! conn :file-profile-rel
{:profile-id profile-id
:file-id file-id
:is-owner true
:is-admin true
:can-edit true}))
(defn create-file-role
[conn {:keys [file-id profile-id role]}]
(let [params {:file-id file-id
:profile-id profile-id}]
(->> (perms/assign-role-flags params role)
(db/insert! conn :file-profile-rel))))
(defn create-file
[conn {:keys [id name project-id is-shared]
@ -68,8 +68,8 @@
:name name
:is-shared is-shared
:data (blob/encode data)})]
(->> (assoc params :file-id id)
(create-file-profile conn))
(->> (assoc params :file-id id :role :owner)
(create-file-role conn))
(assoc file :data data)))

View file

@ -16,10 +16,12 @@
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.db :as db]
[app.rpc.mutations.projects :refer [create-project-role create-project]]
[app.rpc.queries.projects :as proj]
[app.rpc.queries.teams :as teams]
[app.util.blob :as blob]
[app.util.services :as sv]
[app.util.time :as dt]
[clojure.spec.alpha :as s]
[clojure.walk :as walk]))
@ -28,7 +30,7 @@
(s/def ::project-id ::us/uuid)
(s/def ::file-id ::us/uuid)
(s/def ::team-id ::us/uuid)
(s/def ::new-name ::us/string)
(s/def ::name ::us/string)
(defn- remap-id
[item index key]
@ -38,15 +40,24 @@
(defn- process-file
[file index]
(letfn [;; A function responsible to analize all file data and
(letfn [(process-form [form]
(cond-> form
;; Relink Components
(and (map? form)
(uuid? (:component-file form)))
(update :component-file #(get index % %))
;; Relink Image Shapes
(and (map? form)
(map? (:metadata form))
(= :image (:type form)))
(update-in [:metadata :id] #(get index % %))))
;; A function responsible to analize all file data and
;; replace the old :component-file reference with the new
;; ones, using the provided file-index
(relink-components [data]
(walk/postwalk (fn [form]
(cond-> form
(and (map? form) (uuid? (:component-file form)))
(update :component-file #(get index % %))))
data))
(relink-shapes [data]
(walk/postwalk process-form data))
;; A function responsible of process the :media attr of file
;; data and remap the old ids with the new ones.
@ -65,45 +76,59 @@
(fn [data]
(-> data
(blob/decode)
(assoc :id (:id file))
(pmg/migrate-data)
(update :pages-index relink-components)
(update :components relink-components)
(update :pages-index relink-shapes)
(update :components relink-shapes)
(update :media relink-media)
(d/without-nils)
(blob/encode))))))
(defn- duplicate-file
[conn {:keys [profile-id file index project-id new-name]} {:keys [reset-shared-flag] :as opts}]
(let [flibs (db/query conn :file-library-rel {:file-id (:id file)})
fmeds (db/query conn :file-media-object {:file-id (:id file)})
(defn duplicate-file
[conn {:keys [profile-id file index project-id name]} {:keys [reset-shared-flag] :as opts}]
(let [flibs (db/query conn :file-library-rel {:file-id (:id file)})
fmeds (db/query conn :file-media-object {:file-id (:id file)})
;; Remap all file-librar-rel rows to the new file id
flibs (map #(remap-id % index :file-id) flibs)
;; memo uniform creation/modification date
now (dt/now)
ignore (dt/plus now (dt/duration {:seconds 5}))
;; Add to the index all non-local file media objects
index (reduce #(assoc %1 (:id %2) (uuid/next))
index
(remove :is-local fmeds))
;; add to the index all file media objects.
index (reduce #(assoc %1 (:id %2) (uuid/next)) index fmeds)
;; Remap all file-media-object rows and assing correct new id
;; to each row
fmeds (->> fmeds
(map #(assoc % :id (or (get index (:id %)) (uuid/next))))
(map #(remap-id % index :file-id)))
flibs-xf (comp
(map #(remap-id % index :file-id))
(map #(remap-id % index :library-file-id))
(map #(assoc % :synced-at now))
(map #(assoc % :created-at now)))
file (cond-> file
(some? project-id)
(assoc :project-id project-id)
;; remap all file-library-rel row
flibs (sequence flibs-xf flibs)
(some? new-name)
(assoc :name new-name)
fmeds-xf (comp
(map #(assoc % :id (get index (:id %))))
(map #(assoc % :created-at now))
(map #(remap-id % index :file-id)))
(true? reset-shared-flag)
(assoc :is-shared false))
;; remap all file-media-object rows
fmeds (sequence fmeds-xf fmeds)
file (-> file
(update :id #(get index %))
(process-file index))]
file (cond-> file
(some? project-id)
(assoc :project-id project-id)
(some? name)
(assoc :name name)
(true? reset-shared-flag)
(assoc :is-shared false))
file (-> file
(assoc :created-at now)
(assoc :modified-at now)
(assoc :ignore-sync-until ignore)
(update :id #(get index %))
(process-file index))]
(db/insert! conn :file file)
(db/insert! conn :file-profile-rel
@ -128,15 +153,16 @@
(s/def ::duplicate-file
(s/keys :req-un [::profile-id ::file-id]
:opt-un [::new-name]))
:opt-un [::name]))
(sv/defmethod ::duplicate-file
[{:keys [pool] :as cfg} {:keys [profile-id file-id new-name] :as params}]
[{:keys [pool] :as cfg} {:keys [profile-id file-id name] :as params}]
(db/with-atomic [conn pool]
(let [file (db/get-by-id conn :file file-id)
index {file-id (uuid/next)}
params (assoc params :index index :file file)]
(proj/check-edition-permissions! conn profile-id (:project-id file))
(-> (duplicate-file conn params {:reset-shared-flag true})
(update :data blob/decode)))))
@ -147,53 +173,73 @@
(s/def ::duplicate-project
(s/keys :req-un [::profile-id ::project-id]
:opt-un [::new-name]))
:opt-un [::name]))
(sv/defmethod ::duplicate-project
[{:keys [pool] :as cfg} {:keys [profile-id project-id new-name] :as params}]
[{:keys [pool] :as cfg} {:keys [profile-id project-id name] :as params}]
(db/with-atomic [conn pool]
(let [project (db/get-by-id conn :project project-id)]
(teams/check-edition-permissions! conn profile-id (:team-id project))
(duplicate-project conn (assoc params :project project)))))
(defn duplicate-project
[conn {:keys [profile-id project new-name] :as params}]
[conn {:keys [profile-id project name] :as params}]
(let [files (db/query conn :file
{:project-id (:id project)}
{:columns [:id]})
index (reduce #(assoc %1 (:id %2) (uuid/next)) {} files)
project (cond-> project
new-name
(assoc :name new-name)
(string? name)
(assoc :name name)
:always
(assoc :id (uuid/next)))
params (assoc params
:project-id (:id project)
:index index)]
(assoc :id (uuid/next)))]
(db/insert! conn :project project)
(db/insert! conn :project-profile-rel {:project-id (:id project)
:profile-id profile-id
:is-owner true
:is-admin true
:can-edit true})
(doseq [{:keys [id]} files]
(let [file (db/get-by-id conn :file id)
params (-> params
(assoc :file file)
(dissoc :new-name))]
(duplicate-file conn params {:reset-shared-flag false
:remap-libraries true})))
;; create the duplicated project and assign the current profile as
;; a project owner
(create-project conn project)
(create-project-role conn {:project-id (:id project)
:profile-id profile-id
:role :owner})
;; duplicate all files
(let [index (reduce #(assoc %1 (:id %2) (uuid/next)) {} files)
params (-> params
(dissoc :name)
(assoc :project-id (:id project))
(assoc :index index))]
(doseq [{:keys [id]} files]
(let [file (db/get-by-id conn :file id)
params (assoc params :file file)
opts {:reset-shared-flag false}]
(duplicate-file conn params opts))))
;; return the created project
project))
;; --- MUTATION: Move file
(declare sql:retrieve-files)
(declare sql:move-files)
(declare sql:delete-broken-relations)
(def sql:retrieve-files
"select id, project_id from file where id = ANY(?)")
(def sql:move-files
"update file set project_id = ? where id = ANY(?)")
(def sql:delete-broken-relations
"with broken as (
(select * from file_library_rel as flr
inner join file as f on (flr.file_id = f.id)
inner join project as p on (f.project_id = p.id)
inner join file as lf on (flr.library_file_id = lf.id)
inner join project as lp on (lf.project_id = lp.id)
where p.id = ANY(?)
and lp.team_id != p.team_id)
)
delete from file_library_rel as rel
using broken as br
where rel.file_id = br.file_id
and rel.library_file_id = br.library_file_id")
(s/def ::ids (s/every ::us/uuid :kind set?))
(s/def ::move-files
@ -228,27 +274,6 @@
nil)))
(def sql:retrieve-files
"select id, project_id from file where id = ANY(?)")
(def sql:move-files
"update file set project_id = ? where id = ANY(?)")
(def sql:delete-broken-relations
"with broken as (
(select * from file_library_rel as flr
inner join file as f on (flr.file_id = f.id)
inner join project as p on (f.project_id = p.id)
inner join file as lf on (flr.library_file_id = lf.id)
inner join project as lp on (lf.project_id = lp.id)
where p.id = ANY(?)
and lp.team_id != p.team_id)
)
delete from file_library_rel as rel
using broken as br
where rel.file_id = br.file_id
and rel.library_file_id = br.library_file_id")
;; --- MUTATION: Move project

View file

@ -196,21 +196,25 @@
(defn create-profile-relations
[conn profile]
(let [team (teams/create-team conn {:profile-id (:id profile)
:name "Default"
:default? true})
proj (projects/create-project conn {:profile-id (:id profile)
:team-id (:id team)
:name "Drafts"
:default? true})]
(teams/create-team-profile conn {:team-id (:id team)
:profile-id (:id profile)})
(projects/create-project-profile conn {:project-id (:id proj)
:profile-id (:id profile)})
(let [team (teams/create-team conn {:profile-id (:id profile)
:name "Default"
:is-default true})
project (projects/create-project conn {:profile-id (:id profile)
:team-id (:id team)
:name "Drafts"
:is-default true})
params {:team-id (:id team)
:profile-id (:id profile)
:project-id (:id project)
:role :owner}]
(merge (profile/strip-private-attrs profile)
{:default-team-id (:id team)
:default-project-id (:id proj)})))
(teams/create-team-role conn params)
(projects/create-project-role conn params)
(-> profile
(profile/strip-private-attrs)
(assoc :default-team-id (:id team))
(assoc :default-project-id (:id project)))))
;; --- Mutation: Login

View file

@ -13,6 +13,7 @@
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.db :as db]
[app.rpc.permissions :as perms]
[app.rpc.queries.projects :as proj]
[app.rpc.queries.teams :as teams]
[app.tasks :as tasks]
@ -30,7 +31,7 @@
;; --- Mutation: Create Project
(declare create-project)
(declare create-project-profile)
(declare create-project-role)
(declare create-team-project-profile)
(s/def ::team-id ::us/uuid)
@ -43,30 +44,31 @@
(db/with-atomic [conn pool]
(teams/check-edition-permissions! conn profile-id team-id)
(let [project (create-project conn params)
params (assoc params :project-id (:id project))]
(create-project-profile conn params)
params (assoc params
:project-id (:id project)
:role :owner)]
(create-project-role conn params)
(create-team-project-profile conn params)
(assoc project :is-pinned true))))
(defn create-project
[conn {:keys [id team-id name default?] :as params}]
(let [id (or id (uuid/next))
default? (if (boolean? default?) default? false)]
[conn {:keys [id team-id name is-default] :as params}]
(let [id (or id (uuid/next))
is-default (if (boolean? is-default) is-default false)]
(db/insert! conn :project
{:id id
:team-id team-id
:name name
:is-default default?})))
:team-id team-id
:is-default is-default})))
(defn create-project-profile
[conn {:keys [project-id profile-id] :as params}]
(db/insert! conn :project-profile-rel
{:project-id project-id
:profile-id profile-id
:is-owner true
:is-admin true
:can-edit true}))
(defn create-project-role
[conn {:keys [project-id profile-id role]}]
(let [params {:project-id project-id
:profile-id profile-id}]
(->> (perms/assign-role-flags params role)
(db/insert! conn :project-profile-rel))))
;; TODO: pending to be refactored
(defn create-team-project-profile
[conn {:keys [team-id project-id profile-id] :as params}]
(db/insert! conn :team-project-profile-rel

View file

@ -18,6 +18,7 @@
[app.emails :as emails]
[app.media :as media]
[app.rpc.mutations.projects :as projects]
[app.rpc.permissions :as perms]
[app.rpc.queries.profile :as profile]
[app.rpc.queries.teams :as teams]
[app.storage :as sto]
@ -36,7 +37,7 @@
;; --- Mutation: Create Team
(declare create-team)
(declare create-team-profile)
(declare create-team-role)
(declare create-team-default-project)
(s/def ::create-team
@ -47,37 +48,39 @@
[{:keys [pool] :as cfg} params]
(db/with-atomic [conn pool]
(let [team (create-team conn params)
params (assoc params :team-id (:id team))]
(create-team-profile conn params)
params (assoc params
:team-id (:id team)
:role :owner)]
(create-team-role conn params)
(create-team-default-project conn params)
team)))
(defn create-team
[conn {:keys [id name default?] :as params}]
(let [id (or id (uuid/next))
default? (if (boolean? default?) default? false)]
[conn {:keys [id name is-default] :as params}]
(let [id (or id (uuid/next))
is-default (if (boolean? is-default) is-default false)]
(db/insert! conn :team
{:id id
:name name
:is-default default?})))
:is-default is-default})))
(defn create-team-profile
[conn {:keys [team-id profile-id] :as params}]
(db/insert! conn :team-profile-rel
{:team-id team-id
:profile-id profile-id
:is-owner true
:is-admin true
:can-edit true}))
(defn create-team-role
[conn {:keys [team-id profile-id role] :as params}]
(let [params {:team-id team-id
:profile-id profile-id}]
(->> (perms/assign-role-flags params role)
(db/insert! conn :team-profile-rel))))
(defn create-team-default-project
[conn {:keys [team-id profile-id] :as params}]
(let [proj (projects/create-project conn {:team-id team-id
:name "Drafts"
:default? true})]
(projects/create-project-profile conn {:project-id (:id proj)
:profile-id profile-id})))
(let [project {:id (uuid/next)
:team-id team-id
:name "Drafts"
:is-default true}]
(projects/create-project conn project)
(projects/create-project-role conn {:project-id (:id project)
:profile-id profile-id
:role :owner})))
;; --- Mutation: Update Team

View file

@ -11,7 +11,34 @@
"A permission checking helper factories."
(:require
[app.common.exceptions :as ex]
[app.common.spec :as us]))
[app.common.spec :as us]
[clojure.spec.alpha :as s]))
(s/def ::role #{:admin :owner :editor :viewer})
(defn assign-role-flags
[params role]
(us/verify ::role role)
(cond-> params
(= role :owner)
(assoc :is-owner true
:is-admin true
:can-edit true)
(= role :admin)
(assoc :is-owner false
:is-admin true
:can-edit true)
(= role :editor)
(assoc :is-owner false
:is-admin false
:can-edit true)
(= role :viewer)
(assoc :is-owner false
:is-admin false
:can-edit false)))
(defn make-edition-check-fn
"A simple factory for edition permission check functions."

View file

@ -10,16 +10,12 @@
(ns app.setup.initial-data
(:refer-clojure :exclude [load])
(:require
[app.common.data :as d]
[app.common.pages.migrations :as pmg]
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.db :as db]
[app.rpc.mutations.projects :as projects]
[app.rpc.queries.profile :as profile]
[app.util.blob :as blob]
[app.util.time :as dt]
[clojure.walk :as walk]))
[app.rpc.mutations.management :refer [duplicate-file]]
[app.rpc.mutations.projects :refer [create-project create-project-role]]
[app.rpc.queries.profile :as profile]))
;; --- DUMP GENERATION
@ -62,58 +58,6 @@
;; --- DUMP LOADING
(defn- process-file
[file index]
(letfn [(process-form [form]
(cond-> form
;; Relink Components
(and (map? form)
(uuid? (:component-file form)))
(update :component-file #(get index % %))
;; Relink Image Shapes
(and (map? form)
(map? (:metadata form))
(= :image (:type form)))
(update-in [:metadata :id] #(get index % %))))
;; A function responsible to analize all file data and
;; replace the old :component-file reference with the new
;; ones, using the provided file-index
(relink-shapes [data]
(walk/postwalk process-form data))
;; A function responsible of process the :media attr of file
;; data and remap the old ids with the new ones.
(relink-media [media]
(reduce-kv (fn [res k v]
(let [id (get index k)]
(if (uuid? id)
(-> res
(assoc id (assoc v :id id))
(dissoc k))
res)))
media
media))]
(update file :data
(fn [data]
(-> data
(blob/decode)
(assoc :id (:id file))
(pmg/migrate-data)
(update :pages-index relink-shapes)
(update :components relink-shapes)
(update :media relink-media)
(d/without-nils)
(blob/encode))))))
(defn- remap-id
[item index key]
(cond-> item
(contains? item key)
(assoc key (get index (get item key) (get item key)))))
(defn- retrieve-data
[conn skey]
(when-let [row (db/exec-one! conn ["select content from server_prop where id = ?" skey])]
@ -127,60 +71,24 @@
(let [skey (or (:skey opts) (cfg/get :initial-project-skey))
data (retrieve-data conn skey)]
(when data
(let [project (projects/create-project conn {:profile-id (:id profile)
:team-id (:default-team-id profile)
:name (:project-name data)})
(let [index (reduce #(assoc %1 (:id %2) (uuid/next)) {} (:files data))
project {:id (uuid/next)
:profile-id (:id profile)
:team-id (:default-team-id profile)
:name (:project-name data)}]
now (dt/now)
ignore (dt/plus now (dt/duration {:seconds 5}))
index (as-> {} index
(reduce #(assoc %1 (:id %2) (uuid/next)) index (:files data))
(reduce #(assoc %1 (:id %2) (uuid/next)) index (:fmeds data)))
(create-project conn project)
(create-project-role conn {:project-id (:id project)
:profile-id (:id profile)
:role :owner})
flibs (->> (:flibs data)
(map #(remap-id % index :file-id))
(map #(remap-id % index :library-file-id))
(map #(assoc % :synced-at now))
(map #(assoc % :created-at now)))
files (->> (:files data)
(map #(assoc % :id (get index (:id %))))
(map #(assoc % :project-id (:id project)))
(map #(assoc % :created-at now))
(map #(assoc % :modified-at now))
(map #(assoc % :ignore-sync-until ignore))
(map #(process-file % index)))
fmeds (->> (:fmeds data)
(map #(assoc % :id (get index (:id %))))
(map #(assoc % :created-at now))
(map #(remap-id % index :file-id)))
fprofs (map #(array-map :file-id (:id %)
:profile-id (:id profile)
:is-owner true
:is-admin true
:can-edit true) files)]
(projects/create-project-profile conn {:project-id (:id project)
:profile-id (:id profile)})
(projects/create-team-project-profile conn {:team-id (:default-team-id profile)
:project-id (:id project)
:profile-id (:id profile)})
;; Re-insert into the database
(doseq [params files]
(db/insert! conn :file params))
(doseq [params fprofs]
(db/insert! conn :file-profile-rel params))
(doseq [params flibs]
(db/insert! conn :file-library-rel params))
(doseq [params fmeds]
(db/insert! conn :file-media-object params)))))))
(doseq [file (:files data)]
(let [params {:profile-id (:id profile)
:project-id (:id project)
:file file
:index index}
opts {:reset-shared-flag false}]
(duplicate-file conn params opts))))))))
(defn load
[system {:keys [email] :as opts}]

View file

@ -154,12 +154,10 @@
team (#'teams/create-team conn {:id id
:profile-id profile-id
:name (str "team" i)})]
(#'teams/create-team-profile conn
{:team-id id
:profile-id profile-id
:is-owner true
:is-admin true
:can-edit true})
(#'teams/create-team-role conn
{:team-id id
:profile-id profile-id
:role :owner})
team)))
@ -198,37 +196,26 @@
:created-at (or created-at (dt/now))
:content (db/tjson {})}))
(defn create-team-role*
([params] (create-team-role* *pool* params))
([conn {:keys [team-id profile-id role] :or {role :owner}}]
(#'teams/create-team-role conn {:team-id team-id
:profile-id profile-id
:role role})))
(defn create-team-permission*
([params] (create-team-permission* *pool* params))
([conn {:keys [team-id profile-id is-owner is-admin can-edit]
:or {is-owner true is-admin true can-edit true}}]
(db/insert! conn :team-profile-rel {:team-id team-id
:profile-id profile-id
:is-owner is-owner
:is-admin is-admin
:can-edit can-edit})))
(defn create-project-permission*
([params] (create-project-permission* *pool* params))
([conn {:keys [project-id profile-id is-owner is-admin can-edit]
:or {is-owner true is-admin true can-edit true}}]
(db/insert! conn :project-profile-rel {:project-id project-id
:profile-id profile-id
:is-owner is-owner
:is-admin is-admin
:can-edit can-edit})))
(defn create-file-permission*
([params] (create-file-permission* *pool* params))
([conn {:keys [file-id profile-id is-owner is-admin can-edit]
:or {is-owner true is-admin true can-edit true}}]
(db/insert! conn :project-profile-rel {:file-id file-id
:profile-id profile-id
:is-owner is-owner
:is-admin is-admin
:can-edit can-edit})))
(defn create-project-role*
([params] (create-project-role* *pool* params))
([conn {:keys [project-id profile-id role] :or {role :owner}}]
(#'projects/create-project-role conn {:project-id project-id
:profile-id profile-id
:role role})))
(defn create-file-role*
([params] (create-file-role* *pool* params))
([conn {:keys [file-id profile-id role] :or {role :owner}}]
(#'files/create-file-role conn {:file-id file-id
:profile-id profile-id
:role role})))
(defn update-file*
([params] (update-file* *pool* params))

View file

@ -50,7 +50,7 @@
(let [data {::th/type :duplicate-file
:profile-id (:id profile)
:file-id (:id file1)
:new-name "file 1 (copy)"}
:name "file 1 (copy)"}
out (th/mutation! data)]
;; (th/print-result! out)
@ -122,7 +122,7 @@
(let [data {::th/type :duplicate-project
:profile-id (:id profile)
:project-id (:id project)
:new-name "project 1 (copy)"}
:name "project 1 (copy)"}
out (th/mutation! data)]
;; Check tha tresult is correct
@ -157,8 +157,8 @@
(:data fb)))))
(when (= (:id fa) (:id file2))
(t/is (true? (b/equals? (:data fa)
(:data fb))))))
(t/is (false? (b/equals? (:data fa)
(:data fb))))))
)))))

View file

@ -50,7 +50,21 @@
(t/is project-id (get-in result [0 :id]))
(t/is (= "test project" (get-in result [0 :name])))))
;; rename project"
;; query all projects of a user
(let [data {::th/type :all-projects
:profile-id (:id profile)}
out (th/query! data)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= 2 (count result)))
(t/is (not= project-id (get-in result [0 :id])))
(t/is (= "Drafts" (get-in result [0 :name])))
(t/is project-id (get-in result [1 :id]))
(t/is (= "test project" (get-in result [1 :name])))))
;; rename project
(let [data {::th/type :rename-project
:id project-id
:name "renamed project"

View file

@ -365,7 +365,7 @@
new-name (str name " " (tr "dashboard.copy-suffix"))]
(->> (rp/mutation! :duplicate-project {:project-id id
:new-name new-name})
:name new-name})
(rx/tap on-success)
(rx/map #(partial duplicated %))
(rx/catch on-error)))))))
@ -533,7 +533,7 @@
new-name (str name " " (tr "dashboard.copy-suffix"))]
(->> (rp/mutation! :duplicate-file {:file-id id
:new-name new-name})
:name new-name})
(rx/tap on-success)
(rx/map file-created)
(rx/catch on-error))))))