diff --git a/backend/src/app/rpc/queries/projects.clj b/backend/src/app/rpc/queries/projects.clj index 54eda7896..b3c16fa0d 100644 --- a/backend/src/app/rpc/queries/projects.clj +++ b/backend/src/app/rpc/queries/projects.clj @@ -82,6 +82,50 @@ (db/exec! conn [sql:projects profile-id team-id])) +;; --- Query: All projects + +(declare retrieve-all-projects) + +(s/def ::profile-id ::us/uuid) +(s/def ::all-projects + (s/keys :req-un [::profile-id])) + +(sv/defmethod ::all-projects + [{:keys [pool]} {:keys [profile-id]}] + (with-open [conn (db/open pool)] + (retrieve-all-projects conn profile-id))) + +(def sql:all-projects + "select p1.*, t.name as team_name + from project as p1 + inner join team as t + on t.id = p1.team_id + where t.id in (select team_id + from team_profile_rel as tpr + where tpr.profile_id = ? + and (tpr.can_edit = true or + tpr.is_owner = true or + tpr.is_admin = true)) + and p1.deleted_at is null + union + select p2.*, t.name as team_name + from project as p2 + inner join team as t + on t.id = p2.team_id + where p2.id in (select project_id + from project_profile_rel as ppr + where ppr.profile_id = ? + and (ppr.can_edit = true or + ppr.is_owner = true or + ppr.is_admin = true)) + and p2.deleted_at is null + order by team_name, name;") + +(defn retrieve-all-projects + [conn profile-id] + (db/exec! conn [sql:all-projects profile-id profile-id])) + + ;; --- Query: Project (s/def ::id ::us/uuid) @@ -94,3 +138,4 @@ (let [project (db/get-by-id conn :project id)] (check-read-permissions! conn profile-id id) project))) + diff --git a/backend/tests/app/tests/test_services_projects.clj b/backend/tests/app/tests/test_services_projects.clj index cbea68f0c..91148377d 100644 --- a/backend/tests/app/tests/test_services_projects.clj +++ b/backend/tests/app/tests/test_services_projects.clj @@ -24,7 +24,7 @@ team (th/create-team* 1 {:profile-id (:id profile)}) project-id (uuid/next)] - ;; crate project + ;; create project (let [data {::th/type :create-project :id project-id :profile-id (:id profile) @@ -37,7 +37,7 @@ (let [result (:result out)] (t/is (= (:name data) (:name result))))) - ;; query a list of projects + ;; query the list of projects of a team (let [data {::th/type :projects :team-id (:id team) :profile-id (:id profile)} diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index 61168ec02..758daca7d 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -507,6 +507,20 @@ }, "used-in" : [ "src/app/main/ui/dashboard/grid.cljs" ] }, + "dashboard.move-to" : { + "translations" : { + "en" : "Move to", + "es" : "Mover a" + }, + "used-in" : [ "src/app/main/ui/dashboard/file_menu.cljs" ] + }, + "dashboard.move-to-other-team" : { + "translations" : { + "en" : "Move to other team", + "es" : "Mover a otro equipo" + }, + "used-in" : [ "src/app/main/ui/dashboard/file_menu.cljs" ] + }, "dashboard.new-file" : { "translations" : { "ca" : "+ Nou Arxiu", diff --git a/frontend/resources/styles/main/partials/context-menu.scss b/frontend/resources/styles/main/partials/context-menu.scss index bd2f715b2..f76c201af 100644 --- a/frontend/resources/styles/main/partials/context-menu.scss +++ b/frontend/resources/styles/main/partials/context-menu.scss @@ -35,6 +35,12 @@ overflow: auto; position: absolute; top: $size-3; + + & .separator { + border-top: 1px solid $color-gray-10; + padding: 0px; + margin: 2px; + } } .context-menu-action { @@ -49,6 +55,34 @@ color: $color-black; background-color: $color-primary-lighter; } + + &.submenu { + display: flex; + align-items: center; + justify-content: space-between; + + & span { + margin-left: 0.5rem; + } + + & svg { + height: 10px; + width: 10px; + } + } + + &.submenu-back { + color: $color-gray-30; + display: flex; + align-items: center; + + & svg { + height: 10px; + width: 10px; + transform: rotate(180deg); + margin-right: $small; + } + } } .context-menu.is-selectable { diff --git a/frontend/resources/styles/main/partials/dashboard-grid.scss b/frontend/resources/styles/main/partials/dashboard-grid.scss index e877491e3..9a19ef4e7 100644 --- a/frontend/resources/styles/main/partials/dashboard-grid.scss +++ b/frontend/resources/styles/main/partials/dashboard-grid.scss @@ -195,13 +195,6 @@ width: 15px; height: 30px; - svg { - fill: $color-gray-20; - height: 18px; - margin-right: $x-small; - width: 18px; - } - span { color: $color-black; } @@ -218,13 +211,15 @@ align-items: flex-end; flex-direction: column; - svg { + > svg { fill: $color-gray-60; margin-right: 0; + height: 18px; + width: 18px; } &:hover { - svg { + > svg { fill: $color-primary-dark; } @@ -237,7 +232,7 @@ } .project-th-actions.force-display { - display: flex; + opacity: 1; } } diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 7143b27d3..93a27e214 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -523,6 +523,7 @@ (defn duplicate-file [{:keys [id name] :as params}] (us/assert ::us/uuid id) + (us/assert ::name name) (ptk/reify ::duplicate-file ptk/WatchEvent (watch [_ state stream] @@ -538,3 +539,21 @@ (rx/map file-created) (rx/catch on-error)))))) +;; --- Move File + +(defn move-file + [{:keys [id project-id] :as params}] + (us/assert ::us/uuid id) + (us/assert ::us/uuid project-id) + (ptk/reify ::move-file + ptk/WatchEvent + (watch [_ state stream] + (let [{:keys [on-success on-error] + :or {on-success identity + on-error identity}} (meta params)] + + (->> (rp/mutation! :move-files {:ids #{id} + :project-id project-id}) + (rx/tap on-success) + (rx/catch on-error)))))) + diff --git a/frontend/src/app/main/ui/components/context_menu.cljs b/frontend/src/app/main/ui/components/context_menu.cljs index 2c51f7f2e..4e8b34666 100644 --- a/frontend/src/app/main/ui/components/context_menu.cljs +++ b/frontend/src/app/main/ui/components/context_menu.cljs @@ -12,9 +12,11 @@ [rumext.alpha :as mf] [goog.object :as gobj] [app.main.ui.components.dropdown :refer [dropdown']] + [app.main.ui.icons :as i] [app.common.uuid :as uuid] [app.util.data :refer [classnames]] - [app.util.dom :as dom])) + [app.util.dom :as dom] + [app.util.object :as obj])) (mf/defc context-menu {::mf/wrap-props false} @@ -24,6 +26,7 @@ (assert (vector? (gobj/get props "options")) "missing `options` prop") (let [open? (gobj/get props "show") + on-close (gobj/get props "on-close") options (gobj/get props "options") is-selectable (gobj/get props "selectable") selected (gobj/get props "selected") @@ -31,11 +34,20 @@ left (gobj/get props "left" 0) fixed? (gobj/get props "fixed?" false) - offset (mf/use-state 0) + local (mf/use-state {:offset 0 + :levels [{:parent-option nil + :options options}]}) + + on-local-close + (mf/use-callback + (fn [] + (swap! local assoc :levels [{:parent-option nil + :options options}]) + (on-close))) check-menu-offscreen (mf/use-callback - (mf/deps top @offset) + (mf/deps top (:offset @local)) (fn [node] (when (and node (not fixed?)) (let [{node-height :height} (dom/get-bounding-rect node) @@ -44,19 +56,58 @@ (- node-height) 0)] - (if (not= target-offset @offset) - (reset! offset target-offset))))))] + (if (not= target-offset (:offset @local)) + (swap! local assoc :offset target-offset)))))) + + enter-submenu + (mf/use-callback + (mf/deps options) + (fn [option-name sub-options] + (fn [event] + (dom/stop-propagation event) + (swap! local update :levels + conj {:parent-option option-name + :options sub-options})))) + + exit-submenu + (mf/use-callback + (fn [event] + (dom/stop-propagation event) + (swap! local update :levels pop))) + + props (obj/merge props #js {:on-close on-local-close})] (when open? [:> dropdown' props [:div.context-menu {:class (classnames :is-open open? :fixed fixed? :is-selectable is-selectable) - :style {:top (+ top @offset) + :style {:top (+ top (:offset @local)) :left left}} - [:ul.context-menu-items {:ref check-menu-offscreen} - (for [[action-name action-handler] options] - [:li.context-menu-item {:class (classnames :is-selected (and selected (= action-name selected))) - :key action-name} - [:a.context-menu-action {:on-click action-handler} - action-name]])]]]))) + (let [level (-> @local :levels peek)] + [:ul.context-menu-items {:ref check-menu-offscreen} + (when-let [parent-option (:parent-option level)] + [:* + [:li.context-menu-item + [:a.context-menu-action.submenu-back + {:data-no-close true + :on-click exit-submenu} + [:span i/arrow-slide] + parent-option]] + [:li.separator]]) + (for [[option-name option-handler sub-options] (:options level)] + (if (= option-name :separator) + [:li.separator] + [:li.context-menu-item + {:class (classnames :is-selected (and selected + (= option-name selected))) + :key option-name} + (if-not sub-options + [:a.context-menu-action {:on-click option-handler} + option-name] + [:a.context-menu-action.submenu + {:data-no-close true + :on-click (enter-submenu option-name sub-options)} + option-name + [:span i/arrow-slide]]) + ]))])]]))) diff --git a/frontend/src/app/main/ui/components/dropdown.cljs b/frontend/src/app/main/ui/components/dropdown.cljs index db9013df3..b1efae131 100644 --- a/frontend/src/app/main/ui/components/dropdown.cljs +++ b/frontend/src/app/main/ui/components/dropdown.cljs @@ -17,12 +17,13 @@ on-click (fn [event] - (if ref - (let [target (dom/get-target event) - parent (mf/ref-val ref)] - (when-not (or (not parent) (.contains parent target)) - (on-close))) - (on-close))) + (let [target (dom/get-target event)] + (when-not (.-data-no-close ^js target) + (if ref + (let [parent (mf/ref-val ref)] + (when-not (or (not parent) (.contains parent target)) + (on-close))) + (on-close))))) on-keyup (fn [event] diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 702bc1c25..ce8a63c2b 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -16,6 +16,7 @@ [app.main.data.modal :as modal] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.context :as ctx] [app.main.ui.dashboard.files :refer [files-section]] [app.main.ui.dashboard.libraries :refer [libraries-page]] [app.main.ui.dashboard.projects :refer [projects-section]] @@ -105,18 +106,23 @@ (mf/deps team-id) (st/emitf (dd/fetch-bundle {:id team-id}))) - [:section.dashboard-layout - [:& sidebar {:team team - :projects projects - :project project - :profile profile - :section section - :search-term search-term}] - (when (and team (seq projects)) - [:& dashboard-content {:projects projects - :profile profile - :project project - :section section - :search-term search-term - :team team}])])) + [:& (mf/provider ctx/current-file-id) {:value nil} + [:& (mf/provider ctx/current-team-id) {:value team-id} + [:& (mf/provider ctx/current-project-id) {:value project-id} + [:& (mf/provider ctx/current-page-id) {:value nil} + + [:section.dashboard-layout + [:& sidebar {:team team + :projects projects + :project project + :profile profile + :section section + :search-term search-term}] + (when (and team (seq projects)) + [:& dashboard-content {:projects projects + :profile profile + :project project + :section section + :search-term search-term + :team team}])]]]]])) diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index 474202df7..e559fd236 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -11,11 +11,14 @@ (:require [app.main.data.dashboard :as dd] [app.main.data.modal :as modal] + [app.main.repo :as rp] [app.main.store :as st] + [app.main.ui.context :as ctx] [app.main.ui.components.context-menu :refer [context-menu]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] + [beicon.core :as rx] [rumext.alpha :as mf])) (mf/defc file-menu @@ -24,8 +27,14 @@ (assert (boolean? show?) "missing `show?` prop") (assert (fn? on-edit) "missing `on-edit` prop") (assert (fn? on-menu-close) "missing `on-menu-close` prop") - (let [top (or top 0) - left (or left 0) + (let [top (or top 0) + left (or left 0) + + current-team-id (mf/use-ctx ctx/current-team-id) + teams (mf/use-state nil) + current-team (get @teams current-team-id) + other-teams (remove #(= (:id %) current-team-id) + (vals @teams)) on-new-tab (mf/use-callback @@ -58,6 +67,20 @@ :accept-label (tr "modals.delete-file-confirm.accept") :on-accept delete-fn})))) + on-move + (mf/use-callback + (mf/deps file) + (fn [team-id project-id] + (let [data {:id (:id file) + :project-id project-id} + + mdata {:on-success + (st/emitf (rt/nav :dashboard-files + {:team-id team-id + :project-id project-id}))}] + + (st/emitf (dd/move-file (with-meta data mdata)))))) + add-shared (mf/use-callback (mf/deps file) @@ -85,29 +108,61 @@ on-del-shared (mf/use-callback - (mf/deps file) - (fn [event] - (dom/prevent-default event) - (dom/stop-propagation event) - (st/emit! (modal/show - {:type :confirm - :message "" - :title (tr "modals.remove-shared-confirm.message" (:name file)) - :hint (tr "modals.remove-shared-confirm.hint") - :cancel-label :omit - :accept-label (tr "modals.remove-shared-confirm.accept") - :on-accept del-shared}))))] + (mf/deps file) + (fn [event] + (dom/prevent-default event) + (dom/stop-propagation event) + (st/emit! (modal/show + {:type :confirm + :message "" + :title (tr "modals.remove-shared-confirm.message" (:name file)) + :hint (tr "modals.remove-shared-confirm.hint") + :cancel-label :omit + :accept-label (tr "modals.remove-shared-confirm.accept") + :on-accept del-shared}))))] - [:& context-menu {:on-close on-menu-close - :show show? - :fixed? (or (not= top 0) (not= left 0)) - :top top - :left left - :options [[(tr "dashboard.open-in-new-tab") on-new-tab] - [(tr "labels.rename") on-edit] - [(tr "dashboard.duplicate") on-duplicate] - [(tr "labels.delete") on-delete] - (if (:is-shared file) - [(tr "dashboard.remove-shared") on-del-shared] - [(tr "dashboard.add-shared") on-add-shared])]}])) + (mf/use-layout-effect + (mf/deps show?) + (fn [] + (let [group-by-team (fn [projects] + (reduce + (fn [teams project] + (update teams (:team-id project) + #(if (nil? %) + {:id (:team-id project) + :name (:team-name project) + :projects [project]} + (update % :projects conj project)))) + {} + projects))] + (if show? + (->> (rp/query! :all-projects) + (rx/map group-by-team) + (rx/subs #(reset! teams %))) + (reset! teams []))))) + + (when current-team + [:& context-menu {:on-close on-menu-close + :show show? + :fixed? (or (not= top 0) (not= left 0)) + :top top + :left left + :options [[(tr "dashboard.open-in-new-tab") on-new-tab] + [(tr "labels.rename") on-edit] + [(tr "dashboard.duplicate") on-duplicate] + [(tr "dashboard.move-to") nil + (conj (vec (for [project (:projects current-team)] + [(:name project) (on-move (:id current-team) + (:id project))])) + [(tr "dashboard.move-to-other-team") nil + (for [team other-teams] + [(:name team) nil + (for [sub-project (:projects team)] + [(:name sub-project) (on-move (:id team) + (:id sub-project))])])])] + (if (:is-shared file) + [(tr "dashboard.remove-shared") on-del-shared] + [(tr "dashboard.add-shared") on-add-shared]) + [:separator] + [(tr "labels.delete") on-delete]]}])))