From 54d392890d98b33fb06ca5fe0b4b600ed8f2e66e Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Thu, 3 Jul 2025 14:08:32 +0200 Subject: [PATCH 01/20] fix(Groups page): Moved to a new page --- code/src/cljs/sixsq/nuvla/ui/main/spec.cljs | 6 +- .../sixsq/nuvla/ui/pages/groups/views.cljs | 218 +++++++++++++++++ .../sixsq/nuvla/ui/pages/profile/views.cljs | 231 ------------------ .../cljs/sixsq/nuvla/ui/routing/router.cljs | 7 +- .../cljs/sixsq/nuvla/ui/routing/routes.cljs | 1 + 5 files changed, 230 insertions(+), 233 deletions(-) create mode 100644 code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs diff --git a/code/src/cljs/sixsq/nuvla/ui/main/spec.cljs b/code/src/cljs/sixsq/nuvla/ui/main/spec.cljs index b4eb4f0b9..5e3ed330f 100644 --- a/code/src/cljs/sixsq/nuvla/ui/main/spec.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/main/spec.cljs @@ -107,9 +107,13 @@ :label-kw :infra-service-short :icon icons/i-cloud :order 80} + "groups" {:key routes/groups + :label-kw :groups + :icon icons/i-users + :order 90} "api" {:key routes/api :label-kw :api :icon icons/i-code - :order 90}} + :order 100}} ::open-modal nil ::stripe nil}) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs new file mode 100644 index 000000000..a49c1b362 --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -0,0 +1,218 @@ +(ns sixsq.nuvla.ui.pages.groups.views + (:require ["@stripe/react-stripe-js" :as react-stripe] + [clojure.string :as str] + [re-frame.core :refer [dispatch subscribe]] + [reagent.core :as r] + [sixsq.nuvla.ui.common-components.acl.views :as acl-views] + [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] + [sixsq.nuvla.ui.main.events :as main-events] + [sixsq.nuvla.ui.pages.profile.events :as events] + [sixsq.nuvla.ui.pages.profile.subs :as subs] + [sixsq.nuvla.ui.session.subs :as session-subs] + [sixsq.nuvla.ui.utils.general :as utils-general] + [sixsq.nuvla.ui.utils.icons :as icons] + [sixsq.nuvla.ui.utils.semantic-ui :as ui] + [sixsq.nuvla.ui.utils.semantic-ui-extensions :as uix] + [sixsq.nuvla.ui.utils.ui-callback :as ui-callback])) + + +(def group-changed! (r/atom {})) +(defn set-group-changed! [id] (swap! group-changed! assoc id true)) +(defn disable-changes-protection! + [id] + (swap! group-changed! assoc id false) + (when-not (some true? (vals @group-changed!)) + (dispatch [::main-events/reset-changes-protection]))) + +(defn GroupMember + [id principal members editable?] + (let [principal-name (subscribe [::session-subs/resolve-principal principal])] + [ui/ListItem + [ui/ListContent + [ui/ListHeader + [acl-views/PrincipalIcon principal] + utils-general/nbsp + @principal-name + utils-general/nbsp + (when editable? + [icons/CloseIcon {:link true + :size "small" + :color "red" + :on-click (fn [] + (reset! members (-> @members set (disj principal) vec)) + (dispatch [::main-events/changes-protection? true]) + (set-group-changed! id))}])]]])) + +(defn DropdownPrincipals + [_add-user _opts _members] + (let [peers (subscribe [::session-subs/peers-options]) + peers-opts (r/atom @peers)] + (fn [add-user + {:keys [fluid placeholder] + :or {fluid false + placeholder ""}} + members] + (let [used-principals (set members)] + [ui/Dropdown {:placeholder placeholder + :fluid fluid + :allow-additions true + :on-add-item (ui-callback/value + #(swap! peers-opts conj {:key %, :value %, :text %})) + :search true + :value @add-user + :on-change (ui-callback/value #(reset! add-user %)) + :options (remove + #(used-principals (:key %)) + @peers-opts) + :selection true + :style {:width "250px"} + :upward false}])))) + +(defn GroupMembers + [group] + (let [tr (subscribe [::i18n-subs/tr]) + editable? (utils-general/editable? group false) + users (:users group) + members (r/atom users) + acl (r/atom (:acl group)) + changed? (r/cursor group-changed! [(:id group)]) + show-acl? (r/atom false) + invite-user (r/atom nil) + add-user (r/atom nil)] + (fn [group] + (let [{:keys [id name description]} group] + [ui/Table {:columns 4} + [ui/TableHeader {:fullWidth true} + [ui/TableRow + [ui/TableHeaderCell + [ui/HeaderSubheader {:as :h3} + name " (" id ")"] + (when description [:p description])] + (when (and @acl editable?) + [ui/TableHeaderCell + [acl-views/AclButtonOnly {:default-value @acl + :read-only (not editable?) + :active? show-acl?}]])] + (when @show-acl? + [ui/TableRow + [ui/TableCell {:colSpan 4} + [acl-views/AclSection {:default-value @acl + :read-only (not editable?) + :active? show-acl? + :on-change #(do + (reset! acl %) + (set-group-changed! id) + (dispatch [::main-events/changes-protection? true]))}]]])] + [ui/TableBody + [ui/TableRow + [ui/TableCell + (if (empty? @members) + [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message + :empty-group-or-no-access-message)]] + [ui/ListSA + (for [m @members] + ^{:key m} + [GroupMember id m members editable?])])]] + (when editable? + [ui/TableRow + [ui/TableCell + [:div {:style {:display "flex"}} + [DropdownPrincipals + add-user + {:placeholder (@tr [:add-group-members]) + :fluid true} @members] + [:span utils-general/nbsp] + [uix/Button {:text (@tr [:add]) + :icon "add user" + :disabled (str/blank? @add-user) + :on-click #(do + (swap! members conj @add-user) + (reset! add-user nil) + (set-group-changed! id) + (dispatch [::main-events/changes-protection? true]))}] + [:span utils-general/nbsp] + [:span utils-general/nbsp] + [ui/Input {:placeholder (@tr [:invite-by-email]) + :style {:width "250px"} + :value (or @invite-user "") + :on-change (ui-callback/value #(reset! invite-user %))}] + [:span utils-general/nbsp] + [uix/Button {:text (@tr [:send]) + :icon "send" + :disabled (str/blank? @invite-user) + :on-click #(do + (dispatch [::events/invite-to-group id @invite-user]) + (reset! invite-user nil))}]]] + [ui/TableCell {:textAlign "right"} + [uix/Button {:primary true + :text (@tr [:save]) + :icon "save" + :disabled (not @changed?) + :on-click #(do (dispatch [::events/edit-group (assoc group :users @members, :acl @acl)]) + (disable-changes-protection! id))}]]])]])))) + +(defn GroupMembersSegment + [] + (let [tr (subscribe [::i18n-subs/tr]) + loading? (subscribe [::subs/loading? :group]) + group (subscribe [::subs/group])] + (dispatch [::events/get-group]) + (fn [] + [ui/Segment {:padded true + :color "green" + :loading @loading? + :style {:height "100%"}} + [ui/Header {:as :h2 :dividing true} (@tr [:group-members])] + ^{:key (random-uuid)} + [GroupMembers @group]]))) + +(defn Group + [] + (let [collapsed (r/atom true)] + (fn [{:keys [id name description children] :as _group}] + [ui/ListItem {:on-click #(do (swap! collapsed not) + (.stopPropagation %))} + [ui/ListIcon {:name "group"}] + [ui/ListContent + [ui/ListHeader (or name id)] + (when description [ui/ListDescription description]) + (when (and (not @collapsed) (seq children)) + [ui/ListList + (for [child children] + ^{:key (:id child)} + [Group child])])]]))) + +(defn GroupHierarchySegment + [] + (let [groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] + [ui/Segment {:padded true + :color "purple"} + [ui/Header {:as :h2 :dividing true} "Group Hierarchy"] + [ui/ListSA {:celled true + :style {:cursor :pointer}} + (for [group-hierarchy groups-hierarchy] + ^{:key (:id group-hierarchy)} + [Group group-hierarchy])]])) + +(defn GroupsViewPage + [] + (let [tr (subscribe [::i18n-subs/tr]) + groups (subscribe [::session-subs/groups]) + is-group? (subscribe [::session-subs/is-group?]) + is-admin? (subscribe [::session-subs/is-admin?])] + (fn [] + (let [remove-groups #{"group/nuvla-nuvlabox" "group/nuvla-anon" "group/nuvla-user" + (when-not @is-admin? "group/nuvla-admin")} + sorted-groups (->> @groups + (remove (comp remove-groups :id)) + (sort-by :id))] + [:<> + (when @is-group? + [ui/GridColumn + [GroupMembersSegment]]) + [GroupHierarchySegment] + [ui/Segment {:padded true, :color "blue"} + [ui/Header {:as :h2} (str/capitalize (@tr [:groups]))] + (for [group sorted-groups] + ^{:key (str "group-" group)} + [GroupMembers group])]])))) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs index 7a6871468..ee450044e 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs @@ -63,14 +63,6 @@ 1)) -(def group-changed! (r/atom {})) -(defn set-group-changed! [id] (swap! group-changed! assoc id true)) -(defn disable-changes-protection! - [id] - (swap! group-changed! assoc id false) - (when-not (some true? (vals @group-changed!)) - (dispatch [::main-events/reset-changes-protection]))) - (defn AddGroupButton [] (let [tr (subscribe [::i18n-subs/tr]) @@ -1264,206 +1256,6 @@ [ui/TableCell {:width 5} [:b (str/capitalize (@tr [:balance]))]] [ui/TableCell {:width 11} (format-currency currency balance)]]]]])))) - -(defn DropdownPrincipals - [_add-user _opts _members] - (let [peers (subscribe [::session-subs/peers-options]) - peers-opts (r/atom @peers)] - (fn [add-user - {:keys [fluid placeholder] - :or {fluid false - placeholder ""}} - members] - (let [used-principals (set members)] - [ui/Dropdown {:placeholder placeholder - :fluid fluid - :allow-additions true - :on-add-item (ui-callback/value - #(swap! peers-opts conj {:key %, :value %, :text %})) - :search true - :value @add-user - :on-change (ui-callback/value #(reset! add-user %)) - :options (remove - #(used-principals (:key %)) - @peers-opts) - :selection true - :style {:width "250px"} - :upward false}])))) - - -(defn GroupMember - [id principal members editable?] - (let [principal-name (subscribe [::session-subs/resolve-principal principal])] - [ui/ListItem - [ui/ListContent - [ui/ListHeader - [acl-views/PrincipalIcon principal] - utils-general/nbsp - @principal-name - utils-general/nbsp - (when editable? - [icons/CloseIcon {:link true - :size "small" - :color "red" - :on-click (fn [] - (reset! members (-> @members set (disj principal) vec)) - (dispatch [::main-events/changes-protection? true]) - (set-group-changed! id))}])]]])) - - -(defn GroupMembers - [group] - (let [tr (subscribe [::i18n-subs/tr]) - editable? (utils-general/editable? group false) - users (:users group) - members (r/atom users) - acl (r/atom (:acl group)) - changed? (r/cursor group-changed! [(:id group)]) - show-acl? (r/atom false) - invite-user (r/atom nil) - add-user (r/atom nil)] - (fn [group] - (let [{:keys [id name description]} group] - [ui/Table {:columns 4} - [ui/TableHeader {:fullWidth true} - [ui/TableRow - [ui/TableHeaderCell - [ui/HeaderSubheader {:as :h3} - name " (" id ")"] - (when description [:p description])] - (when (and @acl editable?) - [ui/TableHeaderCell - [acl-views/AclButtonOnly {:default-value @acl - :read-only (not editable?) - :active? show-acl?}]])] - (when @show-acl? - [ui/TableRow - [ui/TableCell {:colSpan 4} - [acl-views/AclSection {:default-value @acl - :read-only (not editable?) - :active? show-acl? - :on-change #(do - (reset! acl %) - (set-group-changed! id) - (dispatch [::main-events/changes-protection? true]))}]]])] - [ui/TableBody - [ui/TableRow - [ui/TableCell - (if (empty? @members) - [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message - :empty-group-or-no-access-message)]] - [ui/ListSA - (for [m @members] - ^{:key m} - [GroupMember id m members editable?])])]] - (when editable? - [ui/TableRow - [ui/TableCell - [:div {:style {:display "flex"}} - [DropdownPrincipals - add-user - {:placeholder (@tr [:add-group-members]) - :fluid true} @members] - [:span utils-general/nbsp] - [uix/Button {:text (@tr [:add]) - :icon "add user" - :disabled (str/blank? @add-user) - :on-click #(do - (swap! members conj @add-user) - (reset! add-user nil) - (set-group-changed! id) - (dispatch [::main-events/changes-protection? true]))}] - [:span utils-general/nbsp] - [:span utils-general/nbsp] - [ui/Input {:placeholder (@tr [:invite-by-email]) - :style {:width "250px"} - :value (or @invite-user "") - :on-change (ui-callback/value #(reset! invite-user %))}] - [:span utils-general/nbsp] - [uix/Button {:text (@tr [:send]) - :icon "send" - :disabled (str/blank? @invite-user) - :on-click #(do - (dispatch [::events/invite-to-group id @invite-user]) - (reset! invite-user nil))}]]] - [ui/TableCell {:textAlign "right"} - [uix/Button {:primary true - :text (@tr [:save]) - :icon "save" - :disabled (not @changed?) - :on-click #(do (dispatch [::events/edit-group (assoc group :users @members, :acl @acl)]) - (disable-changes-protection! id))}]]])]])))) - - -(defn GroupMembersSegment - [] - (let [tr (subscribe [::i18n-subs/tr]) - loading? (subscribe [::subs/loading? :group]) - group (subscribe [::subs/group])] - (dispatch [::events/get-group]) - (fn [] - [ui/Segment {:padded true - :color "green" - :loading @loading? - :style {:height "100%"}} - [ui/Header {:as :h2 :dividing true} (@tr [:group-members])] - ^{:key (random-uuid)} - [GroupMembers @group]]))) - - - -(defn Group - [] - (let [collapsed (r/atom true)] - (fn [{:keys [id name description children] :as _group}] - [ui/ListItem {:on-click #(do (swap! collapsed not) - (.stopPropagation %))} - [ui/ListIcon {:name "group"}] - [ui/ListContent - [ui/ListHeader (or name id)] - (when description [ui/ListDescription description]) - (when (and (not @collapsed) (seq children)) - [ui/ListList - (for [child children] - ^{:key (:id child)} - [Group child])])]]))) - - -(defn GroupHierarchySegment - [] - (let [groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] - [ui/Segment {:padded true - :color "purple"} - [ui/Header {:as :h2 :dividing true} "Group Hierarchy"] - [ui/ListSA {:celled true - :style {:cursor :pointer}} - (for [group-hierarchy groups-hierarchy] - ^{:key (:id group-hierarchy)} - [Group group-hierarchy])]])) - -(defn Groups - [] - (let [tr (subscribe [::i18n-subs/tr]) - groups (subscribe [::session-subs/groups]) - is-group? (subscribe [::session-subs/is-group?]) - is-admin? (subscribe [::session-subs/is-admin?])] - (fn [] - (let [remove-groups #{"group/nuvla-nuvlabox" "group/nuvla-anon" "group/nuvla-user" - (when-not @is-admin? "group/nuvla-admin")} - sorted-groups (->> @groups - (remove (comp remove-groups :id)) - (sort-by :id))] - [:<> - (when @is-group? - [ui/GridColumn - [GroupMembersSegment]]) - [GroupHierarchySegment] - [ui/Segment {:padded true, :color "blue"} - [ui/Header {:as :h2} (str/capitalize (@tr [:groups]))] - (for [group sorted-groups] - ^{:key (str "group-" group)} - [GroupMembers group])]])))) - (defn MsgNoSubscription [] [uix/MsgNoItemsToShow [uix/TR :no-subscription-information]]) @@ -1584,25 +1376,6 @@ [] [Billing]) - -(defn TabMenuGroups - [] - (let [tr (subscribe [::i18n-subs/tr])] - [:span (str/capitalize (@tr [:groups]))])) - - -(defn GroupsPane - [] - (let [device (subscribe [::main-subs/device])] - [ui/Grid {:columns (grid-columns @device) - :stackable true - :padded true - :centered true} - [ui/GridRow {:columns (grid-columns @device)} - [ui/GridColumn - [Groups]]]])) - - (defn TabMenuDetails [] (let [tr (subscribe [::i18n-subs/tr])] @@ -1631,10 +1404,6 @@ :key :billing :icon icons/i-credit-card} :render #(r/as-element [BillingPane])} - {:menuItem {:content (r/as-element [TabMenuGroups]) - :key :groups - :icon icons/i-users} - :render #(r/as-element [GroupsPane])} {:menuItem {:content (r/as-element [TabMenuDetails]) :key :details :icon icons/i-info} diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs index 1ca492539..44c01b13a 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs @@ -24,6 +24,7 @@ [sixsq.nuvla.ui.pages.edges.views :refer [DetailedViewPage edges-view]] [sixsq.nuvla.ui.pages.edges.views-cluster :as views-cluster] [sixsq.nuvla.ui.pages.profile.views :refer [profile]] + [sixsq.nuvla.ui.pages.groups.views :refer [GroupsViewPage]] [sixsq.nuvla.ui.pages.welcome.views :refer [home-view]] [sixsq.nuvla.ui.routing.events :as events] [sixsq.nuvla.ui.routing.routes :as routes] @@ -218,7 +219,11 @@ ["profile" {:name ::routes/profile :layout #'LayoutPage - :view #'profile}]] + :view #'profile}] + ["groups" + {:name ::routes/groups + :layout #'LayoutPage + :view #'GroupsViewPage}]] ["/*" {:name ::routes/catch-all :layout #'LayoutPage diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs index 31470d86b..9965c5577 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs @@ -49,5 +49,6 @@ (def api ::api) (def api-slashed ::api-slashed) (def api-sub-page ::api-sub-page) +(def groups ::groups) (def profile ::profile) (def catch-all ::catch-all) From 9526e33c3546833cc83cd0fcee1e9596f7c86d4d Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Thu, 3 Jul 2025 16:55:26 +0200 Subject: [PATCH 02/20] fix(Groups-page): Show hierarchy next to group edition --- code/resources/public/ui/css/nuvla-ui.css | 4 + .../sixsq/nuvla/ui/pages/groups/views.cljs | 76 ++++++++++--------- .../src/cljs/sixsq/nuvla/ui/session/subs.cljs | 12 ++- 3 files changed, 55 insertions(+), 37 deletions(-) diff --git a/code/resources/public/ui/css/nuvla-ui.css b/code/resources/public/ui/css/nuvla-ui.css index cb65fec1d..4b75be54b 100644 --- a/code/resources/public/ui/css/nuvla-ui.css +++ b/code/resources/public/ui/css/nuvla-ui.css @@ -1253,3 +1253,7 @@ table.ui i.icon.sort.ascending, table.ui i.icon.sort.descending { opacity: 0.8; } + +.nuvla-group-item:hover { + background: rgba(0,0,0,0.05); +} \ No newline at end of file diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index a49c1b362..4c2c6445f 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -5,6 +5,7 @@ [reagent.core :as r] [sixsq.nuvla.ui.common-components.acl.views :as acl-views] [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] + [sixsq.nuvla.ui.common-components.plugins.full-text-search :as full-text-search-plugin] [sixsq.nuvla.ui.main.events :as main-events] [sixsq.nuvla.ui.pages.profile.events :as events] [sixsq.nuvla.ui.pages.profile.subs :as subs] @@ -166,53 +167,60 @@ ^{:key (random-uuid)} [GroupMembers @group]]))) +(def selected-group (r/atom nil)) + (defn Group [] (let [collapsed (r/atom true)] - (fn [{:keys [id name description children] :as _group}] - [ui/ListItem {:on-click #(do (swap! collapsed not) - (.stopPropagation %))} - [ui/ListIcon {:name "group"}] - [ui/ListContent - [ui/ListHeader (or name id)] - (when description [ui/ListDescription description]) + (fn [{:keys [id name children] :as _group}] + [ui/ListItem {:active true + :on-click #(do + (reset! selected-group id) + (.stopPropagation %)) + :style {:cursor :pointer}} + [ui/ListIcon {:style (cond-> {:padding 5 + :min-width "17px"} + (seq children) (assoc :cursor :pointer)) + :on-click #(do (swap! collapsed not) + (.stopPropagation %)) + :name (if (seq children) + (if @collapsed "angle right" "angle down") + "")}] + [ui/ListContent {:className "nuvla-group-item" + :style (cond-> {:padding 5 + :border-radius 5} + (= @selected-group id) (assoc :background-color "lightgray"))} + [ui/ListHeader (when (not= @selected-group id) {:style {:font-weight 400}}) + (or name id)] (when (and (not @collapsed) (seq children)) [ui/ListList - (for [child children] + (for [child (sort-by (juxt :id :name) children)] ^{:key (:id child)} [Group child])])]]))) (defn GroupHierarchySegment [] (let [groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] - [ui/Segment {:padded true - :color "purple"} - [ui/Header {:as :h2 :dividing true} "Group Hierarchy"] - [ui/ListSA {:celled true - :style {:cursor :pointer}} - (for [group-hierarchy groups-hierarchy] + [ui/Segment {:raised true :style {:min-height "100%"}} + [ui/Header {:as :h3} "Groups"] + [full-text-search-plugin/FullTextSearch + {:db-path [::deployments-search] + :change-event [:a] + :style {:width "100%"}}] + [ui/ListSA + (for [group-hierarchy (sort-by (juxt :id :name) groups-hierarchy)] ^{:key (:id group-hierarchy)} [Group group-hierarchy])]])) (defn GroupsViewPage [] - (let [tr (subscribe [::i18n-subs/tr]) - groups (subscribe [::session-subs/groups]) - is-group? (subscribe [::session-subs/is-group?]) - is-admin? (subscribe [::session-subs/is-admin?])] - (fn [] - (let [remove-groups #{"group/nuvla-nuvlabox" "group/nuvla-anon" "group/nuvla-user" - (when-not @is-admin? "group/nuvla-admin")} - sorted-groups (->> @groups - (remove (comp remove-groups :id)) - (sort-by :id))] - [:<> - (when @is-group? - [ui/GridColumn - [GroupMembersSegment]]) - [GroupHierarchySegment] - [ui/Segment {:padded true, :color "blue"} - [ui/Header {:as :h2} (str/capitalize (@tr [:groups]))] - (for [group sorted-groups] - ^{:key (str "group-" group)} - [GroupMembers group])]])))) + [ui/Grid {:columns 2} + [ui/GridColumn {:width 4 :stretched true :style {:background-color "light-gray" + :padding-right 0}} + [GroupHierarchySegment]] + [ui/GridColumn {:width 12 :stretched true :style {:background-color "light-gray" + :padding-right 0}} + [ui/Segment {:style {:min-height "100%"}} + (if @selected-group + [GroupMembers @(subscribe [::session-subs/group @selected-group])] + [:i "Select a group"])]]]) diff --git a/code/src/cljs/sixsq/nuvla/ui/session/subs.cljs b/code/src/cljs/sixsq/nuvla/ui/session/subs.cljs index 18299297f..b17e3cf49 100644 --- a/code/src/cljs/sixsq/nuvla/ui/session/subs.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/session/subs.cljs @@ -219,14 +219,20 @@ :<- [::groups] (fn [groups] (->> groups - (map (juxt :id :name)) + (map (juxt :id identity)) (into {})))) +(reg-sub + ::group + :<- [::groups-mapping] + (fn [groups-mapping [_ id]] + (get groups-mapping id))) + (reg-sub ::groups-options :<- [::groups-mapping] (fn [groups-mapping] - (map (fn [[id name]] {:key id, :value id, :text (or name (utils/remove-group-prefix id))}) groups-mapping))) + (map (fn [[id {:keys [name]}]] {:key id, :value id, :text (or name (utils/remove-group-prefix id))}) groups-mapping))) (reg-sub ::resolve-principal @@ -237,7 +243,7 @@ (fn [[current-user-id identifier peers groups] [_ id]] (if (string? id) (if (str/starts-with? id "group/") - (or (get groups id) (utils/remove-group-prefix id)) + (or (get-in groups [id :name]) (utils/remove-group-prefix id)) (utils/resolve-user current-user-id identifier peers id)) id))) From 2a50310032001beb9f8cac24e5b8d75c39b2b8fe Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Fri, 4 Jul 2025 13:57:22 +0200 Subject: [PATCH 03/20] wip members list --- .../sixsq/nuvla/ui/pages/groups/views.cljs | 222 ++++++++++-------- .../sixsq/nuvla/ui/utils/semantic_ui.cljs | 2 +- 2 files changed, 128 insertions(+), 96 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index 4c2c6445f..8da587e39 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -29,20 +29,19 @@ [id principal members editable?] (let [principal-name (subscribe [::session-subs/resolve-principal principal])] [ui/ListItem + [ui/ListContent - [ui/ListHeader - [acl-views/PrincipalIcon principal] - utils-general/nbsp - @principal-name - utils-general/nbsp - (when editable? - [icons/CloseIcon {:link true - :size "small" - :color "red" - :on-click (fn [] - (reset! members (-> @members set (disj principal) vec)) - (dispatch [::main-events/changes-protection? true]) - (set-group-changed! id))}])]]])) + [ui/ListIcon {:name icons/i-user :size "large" :verticalAlign "middle"}] + @principal-name + utils-general/nbsp + (when editable? + [icons/CloseIcon {:link true + :size "small" + :color "red" + :on-click (fn [] + (reset! members (-> @members set (disj principal) vec)) + (dispatch [::main-events/changes-protection? true]) + (set-group-changed! id))}])]])) (defn DropdownPrincipals [_add-user _opts _members] @@ -82,75 +81,96 @@ add-user (r/atom nil)] (fn [group] (let [{:keys [id name description]} group] - [ui/Table {:columns 4} - [ui/TableHeader {:fullWidth true} - [ui/TableRow - [ui/TableHeaderCell - [ui/HeaderSubheader {:as :h3} - name " (" id ")"] - (when description [:p description])] - (when (and @acl editable?) - [ui/TableHeaderCell - [acl-views/AclButtonOnly {:default-value @acl - :read-only (not editable?) - :active? show-acl?}]])] - (when @show-acl? - [ui/TableRow - [ui/TableCell {:colSpan 4} - [acl-views/AclSection {:default-value @acl - :read-only (not editable?) - :active? show-acl? - :on-change #(do - (reset! acl %) - (set-group-changed! id) - (dispatch [::main-events/changes-protection? true]))}]]])] - [ui/TableBody - [ui/TableRow - [ui/TableCell - (if (empty? @members) - [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message - :empty-group-or-no-access-message)]] - [ui/ListSA - (for [m @members] - ^{:key m} - [GroupMember id m members editable?])])]] - (when editable? - [ui/TableRow - [ui/TableCell - [:div {:style {:display "flex"}} - [DropdownPrincipals - add-user - {:placeholder (@tr [:add-group-members]) - :fluid true} @members] - [:span utils-general/nbsp] - [uix/Button {:text (@tr [:add]) - :icon "add user" - :disabled (str/blank? @add-user) - :on-click #(do - (swap! members conj @add-user) - (reset! add-user nil) - (set-group-changed! id) - (dispatch [::main-events/changes-protection? true]))}] - [:span utils-general/nbsp] - [:span utils-general/nbsp] - [ui/Input {:placeholder (@tr [:invite-by-email]) - :style {:width "250px"} - :value (or @invite-user "") - :on-change (ui-callback/value #(reset! invite-user %))}] - [:span utils-general/nbsp] - [uix/Button {:text (@tr [:send]) - :icon "send" - :disabled (str/blank? @invite-user) - :on-click #(do - (dispatch [::events/invite-to-group id @invite-user]) - (reset! invite-user nil))}]]] - [ui/TableCell {:textAlign "right"} - [uix/Button {:primary true - :text (@tr [:save]) - :icon "save" - :disabled (not @changed?) - :on-click #(do (dispatch [::events/edit-group (assoc group :users @members, :acl @acl)]) - (disable-changes-protection! id))}]]])]])))) + [:<> + [ui/Header {:as :h3} + [icons/UserGroupIcon] + [ui/HeaderContent + (or name id) + [ui/HeaderSubheader description " (" id ")"]]] + [:div + + [ui/Button {:basic true :floated "right"} "Add Subgroup"]] + [ui/Header {:as :h3 :dividing true} "Members"] + (if (empty? @members) + [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message + :empty-group-or-no-access-message)]] + [ui/ListSA {:relaxed "true" :vertical-align "middle"} + (for [m @members] + ^{:key m} + [GroupMember id m members editable?])]) + [ui/Input {:placeholder (@tr [:invite-by-email]) + :style {:width "250px"} + :value (or @invite-user "") + :on-change (ui-callback/value #(reset! invite-user %))}] + + [ui/Table {:columns 4} + [ui/TableHeader {:fullWidth true} + [ui/TableRow + [ui/TableHeaderCell + [ui/HeaderSubheader {:as :h3} name]] + (when description [:p description]) + (when (and @acl editable?) + [ui/TableHeaderCell + [acl-views/AclButtonOnly {:default-value @acl + :read-only (not editable?) + :active? show-acl?}]])] + (when @show-acl? + [ui/TableRow + [ui/TableCell {:colSpan 4} + [acl-views/AclSection {:default-value @acl + :read-only (not editable?) + :active? show-acl? + :on-change #(do + (reset! acl %) + (set-group-changed! id) + (dispatch [::main-events/changes-protection? true]))}]]])] + [ui/TableBody + [ui/TableRow + [ui/TableCell + (if (empty? @members) + [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message + :empty-group-or-no-access-message)]] + [ui/ListSA + (for [m @members] + ^{:key m} + [GroupMember id m members editable?])])]] + (when editable? + [ui/TableRow + [ui/TableCell + [:div {:style {:display "flex"}} + [DropdownPrincipals + add-user + {:placeholder (@tr [:add-group-members]) + :fluid true} @members] + [:span utils-general/nbsp] + [uix/Button {:text (@tr [:add]) + :icon "add user" + :disabled (str/blank? @add-user) + :on-click #(do + (swap! members conj @add-user) + (reset! add-user nil) + (set-group-changed! id) + (dispatch [::main-events/changes-protection? true]))}] + [:span utils-general/nbsp] + [:span utils-general/nbsp] + [ui/Input {:placeholder (@tr [:invite-by-email]) + :style {:width "250px"} + :value (or @invite-user "") + :on-change (ui-callback/value #(reset! invite-user %))}] + [:span utils-general/nbsp] + [uix/Button {:text (@tr [:send]) + :icon "send" + :disabled (str/blank? @invite-user) + :on-click #(do + (dispatch [::events/invite-to-group id @invite-user]) + (reset! invite-user nil))}]]] + [ui/TableCell {:textAlign "right"} + [uix/Button {:primary true + :text (@tr [:save]) + :icon "save" + :disabled (not @changed?) + :on-click #(do (dispatch [::events/edit-group (assoc group :users @members, :acl @acl)]) + (disable-changes-protection! id))}]]])]]])))) (defn GroupMembersSegment [] @@ -173,7 +193,7 @@ [] (let [collapsed (r/atom true)] (fn [{:keys [id name children] :as _group}] - [ui/ListItem {:active true + [ui/ListItem {:active true :on-click #(do (reset! selected-group id) (.stopPropagation %)) @@ -186,11 +206,13 @@ :name (if (seq children) (if @collapsed "angle right" "angle down") "")}] - [ui/ListContent {:className "nuvla-group-item" - :style (cond-> {:padding 5 - :border-radius 5} - (= @selected-group id) (assoc :background-color "lightgray"))} - [ui/ListHeader (when (not= @selected-group id) {:style {:font-weight 400}}) + [ui/ListContent + [ui/ListHeader + {:className "nuvla-group-item" + :style (cond-> {:padding 5 + :border-radius 5} + (= @selected-group id) (assoc :background-color "lightgray") + (not= @selected-group id) (assoc :font-weight 400))} (or name id)] (when (and (not @collapsed) (seq children)) [ui/ListList @@ -201,7 +223,8 @@ (defn GroupHierarchySegment [] (let [groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] - [ui/Segment {:raised true :style {:min-height "100%"}} + [ui/Segment {:raised true :style {:overflow-x :auto + :min-height "100%"}} [ui/Header {:as :h3} "Groups"] [full-text-search-plugin/FullTextSearch {:db-path [::deployments-search] @@ -214,13 +237,22 @@ (defn GroupsViewPage [] - [ui/Grid {:columns 2} - [ui/GridColumn {:width 4 :stretched true :style {:background-color "light-gray" - :padding-right 0}} + [ui/Grid {:stackable false} + [ui/GridColumn {:stretched true + :computer 4 + :tablet 6 + :mobile 8 + :style {:background-color "light-gray" + :padding-right 0}} [GroupHierarchySegment]] - [ui/GridColumn {:width 12 :stretched true :style {:background-color "light-gray" - :padding-right 0}} - [ui/Segment {:style {:min-height "100%"}} + [ui/GridColumn {:stretched true + :tablet 10 + :computer 12 + :mobile 8 + :style {:background-color "light-gray" + :padding-right 0}} + [ui/Segment {:style {:min-height "100%" + :overflow-x :auto}} (if @selected-group [GroupMembers @(subscribe [::session-subs/group @selected-group])] [:i "Select a group"])]]]) diff --git a/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui.cljs b/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui.cljs index 8180e9f3f..b9603dbe8 100644 --- a/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui.cljs @@ -96,7 +96,7 @@ (def Input (r/adapt-react-class semantic/Input)) (def Header (r/adapt-react-class semantic/Header)) -;;(def HeaderContent (r/adapt-react-class semantic/HeaderContent)) +(def HeaderContent (r/adapt-react-class semantic/HeaderContent)) (def HeaderSubheader (r/adapt-react-class semantic/HeaderSubheader)) (def Label (r/adapt-react-class semantic/Label)) From bad6c5c3d2be056009288bd633bc63f091b329a3 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Fri, 4 Jul 2025 14:20:15 +0200 Subject: [PATCH 04/20] hierarchy selection without css --- code/resources/public/ui/css/nuvla-ui.css | 4 -- .../sixsq/nuvla/ui/pages/groups/views.cljs | 52 +++++++++---------- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/code/resources/public/ui/css/nuvla-ui.css b/code/resources/public/ui/css/nuvla-ui.css index 4b75be54b..cb65fec1d 100644 --- a/code/resources/public/ui/css/nuvla-ui.css +++ b/code/resources/public/ui/css/nuvla-ui.css @@ -1253,7 +1253,3 @@ table.ui i.icon.sort.ascending, table.ui i.icon.sort.descending { opacity: 0.8; } - -.nuvla-group-item:hover { - background: rgba(0,0,0,0.05); -} \ No newline at end of file diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index 8da587e39..b10285077 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -193,32 +193,32 @@ [] (let [collapsed (r/atom true)] (fn [{:keys [id name children] :as _group}] - [ui/ListItem {:active true - :on-click #(do - (reset! selected-group id) - (.stopPropagation %)) - :style {:cursor :pointer}} - [ui/ListIcon {:style (cond-> {:padding 5 - :min-width "17px"} - (seq children) (assoc :cursor :pointer)) - :on-click #(do (swap! collapsed not) + (let [selected? (= @selected-group id) + children? (boolean (seq children))] + [ui/ListItem {:on-click #(do + (reset! selected-group id) + (.stopPropagation %))} + [ui/ListIcon {:style {:padding 5 + :min-width "17px"} + :on-click #(when children? + (swap! collapsed not) (.stopPropagation %)) - :name (if (seq children) - (if @collapsed "angle right" "angle down") - "")}] - [ui/ListContent - [ui/ListHeader - {:className "nuvla-group-item" - :style (cond-> {:padding 5 - :border-radius 5} - (= @selected-group id) (assoc :background-color "lightgray") - (not= @selected-group id) (assoc :font-weight 400))} - (or name id)] - (when (and (not @collapsed) (seq children)) - [ui/ListList - (for [child (sort-by (juxt :id :name) children)] - ^{:key (:id child)} - [Group child])])]]))) + :name (if (seq children) + (if @collapsed "angle right" "angle down") + "")}] + [ui/ListContent + [ui/ListHeader + {:className "nuvla-group-item" + :style (cond-> {:padding 5 + :border-radius 5} + selected? (assoc :background-color "lightgray") + (not selected?) (assoc :font-weight 400))} + (or name id)] + (when (and (not @collapsed) (seq children)) + [ui/ListList + (for [child (sort-by (juxt :id :name) children)] + ^{:key (:id child)} + [Group child])])]])))) (defn GroupHierarchySegment [] @@ -230,7 +230,7 @@ {:db-path [::deployments-search] :change-event [:a] :style {:width "100%"}}] - [ui/ListSA + [ui/ListSA {:selection true} (for [group-hierarchy (sort-by (juxt :id :name) groups-hierarchy)] ^{:key (:id group-hierarchy)} [Group group-hierarchy])]])) From b41bdfe372042f40fe2be5fc5519a85eb3f9b4c2 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Fri, 4 Jul 2025 16:20:34 +0200 Subject: [PATCH 05/20] wip remove member --- .../sixsq/nuvla/ui/pages/groups/views.cljs | 268 ++++++++++-------- code/src/cljs/sixsq/nuvla/ui/utils/icons.cljs | 10 + 2 files changed, 154 insertions(+), 124 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index b10285077..34bd76b02 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -10,6 +10,7 @@ [sixsq.nuvla.ui.pages.profile.events :as events] [sixsq.nuvla.ui.pages.profile.subs :as subs] [sixsq.nuvla.ui.session.subs :as session-subs] + [sixsq.nuvla.ui.utils.forms :as forms] [sixsq.nuvla.ui.utils.general :as utils-general] [sixsq.nuvla.ui.utils.icons :as icons] [sixsq.nuvla.ui.utils.semantic-ui :as ui] @@ -26,22 +27,26 @@ (dispatch [::main-events/reset-changes-protection]))) (defn GroupMember - [id principal members editable?] - (let [principal-name (subscribe [::session-subs/resolve-principal principal])] + [id group-name principal members editable?] + (let [tr (subscribe [::i18n-subs/tr]) + principal-name (subscribe [::session-subs/resolve-principal principal])] [ui/ListItem [ui/ListContent - [ui/ListIcon {:name icons/i-user :size "large" :verticalAlign "middle"}] + [ui/ListIcon {:className icons/i-user :size "large" :verticalAlign "middle"}] @principal-name utils-general/nbsp (when editable? - [icons/CloseIcon {:link true - :size "small" - :color "red" - :on-click (fn [] - (reset! members (-> @members set (disj principal) vec)) - (dispatch [::main-events/changes-protection? true]) - (set-group-changed! id))}])]])) + [uix/ModalDanger + {:button-text (@tr [:yes]) + :on-confirm (fn [] + (reset! members (-> @members set (disj principal) vec)) + (dispatch [::main-events/changes-protection? true]) + (set-group-changed! id)) + :trigger (r/as-element [ui/MenuItem + [icons/TrashIcon]]) + :header "Remove member" + :content [:span "Do you want to remove " [:b @principal-name] " from " [:b group-name] " group?"]}])]])) (defn DropdownPrincipals [_add-user _opts _members] @@ -79,113 +84,125 @@ show-acl? (r/atom false) invite-user (r/atom nil) add-user (r/atom nil)] - (fn [group] - (let [{:keys [id name description]} group] + (fn [{:keys [id name description]}] + (let [invite-fn #(do + (when-not (str/blank? @invite-user) + (dispatch [::events/invite-to-group id @invite-user]) + (reset! invite-user nil))) + group-name (or name id)] [:<> - [ui/Header {:as :h3} - [icons/UserGroupIcon] - [ui/HeaderContent - (or name id) - [ui/HeaderSubheader description " (" id ")"]]] - [:div - - [ui/Button {:basic true :floated "right"} "Add Subgroup"]] + [ui/Grid {:columns 2} + [ui/GridColumn {:floated :left} + [ui/Header {:as :h3} + [icons/UserGroupIcon] + [ui/HeaderContent + group-name + [ui/HeaderSubheader description " (" id ")"]]]] + [ui/GridColumn {:floated :right} + [ui/Button {:basic true :floated :right} [:b "Add Subgroup"]]]] [ui/Header {:as :h3 :dividing true} "Members"] (if (empty? @members) [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message :empty-group-or-no-access-message)]] - [ui/ListSA {:relaxed "true" :vertical-align "middle"} + [ui/ListSA {:relaxed true :vertical-align "middle"} (for [m @members] ^{:key m} - [GroupMember id m members editable?])]) - [ui/Input {:placeholder (@tr [:invite-by-email]) - :style {:width "250px"} - :value (or @invite-user "") - :on-change (ui-callback/value #(reset! invite-user %))}] + [GroupMember id group-name m members editable?])]) + (when (utils-general/can-operation? "invite" group) + [ui/Input {:placeholder (@tr [:invite-by-email]) + :icon (r/as-element + [icons/PaperPlaneIcon {:style {:cursor :pointer :font-size "unset"} + :link true + :circular true + :onClick invite-fn}]) + :style {:width "280px" :cursor :pointer} + :on-key-press (partial forms/on-return-key invite-fn) + :value (or @invite-user "") + :on-change (ui-callback/value #(reset! invite-user %))}]) - [ui/Table {:columns 4} - [ui/TableHeader {:fullWidth true} - [ui/TableRow - [ui/TableHeaderCell - [ui/HeaderSubheader {:as :h3} name]] - (when description [:p description]) - (when (and @acl editable?) - [ui/TableHeaderCell - [acl-views/AclButtonOnly {:default-value @acl - :read-only (not editable?) - :active? show-acl?}]])] - (when @show-acl? + #_[ui/Table {:columns 4} + [ui/TableHeader {:fullWidth true} [ui/TableRow - [ui/TableCell {:colSpan 4} - [acl-views/AclSection {:default-value @acl - :read-only (not editable?) - :active? show-acl? - :on-change #(do - (reset! acl %) - (set-group-changed! id) - (dispatch [::main-events/changes-protection? true]))}]]])] - [ui/TableBody - [ui/TableRow - [ui/TableCell - (if (empty? @members) - [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message - :empty-group-or-no-access-message)]] - [ui/ListSA - (for [m @members] - ^{:key m} - [GroupMember id m members editable?])])]] - (when editable? + [ui/TableHeaderCell + [ui/HeaderSubheader {:as :h3} name]] + (when description [:p description]) + (when (and @acl editable?) + [ui/TableHeaderCell + [acl-views/AclButtonOnly {:default-value @acl + :read-only (not editable?) + :active? show-acl?}]])] + (when @show-acl? + [ui/TableRow + [ui/TableCell {:colSpan 4} + [acl-views/AclSection {:default-value @acl + :read-only (not editable?) + :active? show-acl? + :on-change #(do + (reset! acl %) + (set-group-changed! id) + (dispatch [::main-events/changes-protection? true]))}]]])] + [ui/TableBody [ui/TableRow [ui/TableCell - [:div {:style {:display "flex"}} - [DropdownPrincipals - add-user - {:placeholder (@tr [:add-group-members]) - :fluid true} @members] - [:span utils-general/nbsp] - [uix/Button {:text (@tr [:add]) - :icon "add user" - :disabled (str/blank? @add-user) - :on-click #(do - (swap! members conj @add-user) - (reset! add-user nil) - (set-group-changed! id) - (dispatch [::main-events/changes-protection? true]))}] - [:span utils-general/nbsp] - [:span utils-general/nbsp] - [ui/Input {:placeholder (@tr [:invite-by-email]) - :style {:width "250px"} - :value (or @invite-user "") - :on-change (ui-callback/value #(reset! invite-user %))}] - [:span utils-general/nbsp] - [uix/Button {:text (@tr [:send]) - :icon "send" - :disabled (str/blank? @invite-user) - :on-click #(do - (dispatch [::events/invite-to-group id @invite-user]) - (reset! invite-user nil))}]]] - [ui/TableCell {:textAlign "right"} - [uix/Button {:primary true - :text (@tr [:save]) - :icon "save" - :disabled (not @changed?) - :on-click #(do (dispatch [::events/edit-group (assoc group :users @members, :acl @acl)]) - (disable-changes-protection! id))}]]])]]])))) + (if (empty? @members) + [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message + :empty-group-or-no-access-message)]] + [ui/ListSA + (for [m @members] + ^{:key m} + [GroupMember id m members editable?])])]] + (when editable? + [ui/TableRow + [ui/TableCell + [:div {:style {:display "flex"}} + [DropdownPrincipals + add-user + {:placeholder (@tr [:add-group-members]) + :fluid true} @members] + [:span utils-general/nbsp] + [uix/Button {:text (@tr [:add]) + :icon "add user" + :disabled (str/blank? @add-user) + :on-click #(do + (swap! members conj @add-user) + (reset! add-user nil) + (set-group-changed! id) + (dispatch [::main-events/changes-protection? true]))}] + [:span utils-general/nbsp] + [:span utils-general/nbsp] + [ui/Input {:placeholder (@tr [:invite-by-email]) + :style {:width "250px"} + :value (or @invite-user "") + :on-change (ui-callback/value #(reset! invite-user %))}] + [:span utils-general/nbsp] + [uix/Button {:text (@tr [:send]) + :icon "send" + :disabled (str/blank? @invite-user) + :on-click #(do + (dispatch [::events/invite-to-group id @invite-user]) + (reset! invite-user nil))}]]] + [ui/TableCell {:textAlign "right"} + [uix/Button {:primary true + :text (@tr [:save]) + :icon "save" + :disabled (not @changed?) + :on-click #(do (dispatch [::events/edit-group (assoc group :users @members, :acl @acl)]) + (disable-changes-protection! id))}]]])]]])))) -(defn GroupMembersSegment - [] - (let [tr (subscribe [::i18n-subs/tr]) - loading? (subscribe [::subs/loading? :group]) - group (subscribe [::subs/group])] - (dispatch [::events/get-group]) - (fn [] - [ui/Segment {:padded true - :color "green" - :loading @loading? - :style {:height "100%"}} - [ui/Header {:as :h2 :dividing true} (@tr [:group-members])] - ^{:key (random-uuid)} - [GroupMembers @group]]))) +;(defn GroupMembersSegment +; [] +; (let [tr (subscribe [::i18n-subs/tr]) +; loading? (subscribe [::subs/loading? :group]) +; group (subscribe [::subs/group])] +; (dispatch [::events/get-group]) +; (fn [] +; [ui/Segment {:padded true +; :color "green" +; :loading @loading? +; :style {:height "100%"}} +; [ui/Header {:as :h2 :dividing true} (@tr [:group-members])] +; ^{:key (random-uuid)} +; [GroupMembers @group]]))) (def selected-group (r/atom nil)) @@ -237,22 +254,25 @@ (defn GroupsViewPage [] - [ui/Grid {:stackable false} - [ui/GridColumn {:stretched true - :computer 4 - :tablet 6 - :mobile 8 - :style {:background-color "light-gray" - :padding-right 0}} - [GroupHierarchySegment]] - [ui/GridColumn {:stretched true - :tablet 10 - :computer 12 - :mobile 8 - :style {:background-color "light-gray" - :padding-right 0}} - [ui/Segment {:style {:min-height "100%" - :overflow-x :auto}} - (if @selected-group - [GroupMembers @(subscribe [::session-subs/group @selected-group])] - [:i "Select a group"])]]]) + (let [group (when @selected-group + @(subscribe [::session-subs/group @selected-group]))] + [ui/Grid {:stackable false} + [ui/GridColumn {:stretched true + :computer 4 + :tablet 6 + :mobile 8 + :style {:background-color "light-gray" + :padding-right 0}} + [GroupHierarchySegment]] + [ui/GridColumn {:stretched true + :tablet 10 + :computer 12 + :mobile 8 + :style {:background-color "light-gray" + :padding-right 0}} + [ui/Segment {:style {:min-height "100%" + :overflow-x :auto}} + (if @selected-group + ^{:key group} + [GroupMembers group] + [uix/MsgNoItemsToShow [uix/TR "Select a Group"]])]]])) diff --git a/code/src/cljs/sixsq/nuvla/ui/utils/icons.cljs b/code/src/cljs/sixsq/nuvla/ui/utils/icons.cljs index 6d5573704..b3619758b 100644 --- a/code/src/cljs/sixsq/nuvla/ui/utils/icons.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/utils/icons.cljs @@ -717,3 +717,13 @@ (def i-cloud-download "fal fa-cloud-download") (def i-cloud-upload "fal fa-cloud-upload") + +(def i-ellipsis "fas fa-ellipsis") +(defn EllipsisIcon + [opts] + [I opts i-ellipsis]) + +(def i-paper-plane "fal fa-paper-plane") +(defn PaperPlaneIcon + [opts] + [I opts i-paper-plane]) From ad63203266bb253f2810e9a3ca92470ce57b7b52 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Mon, 14 Jul 2025 13:37:48 +0200 Subject: [PATCH 06/20] Logic over buttons and send invitation --- .../sixsq/nuvla/ui/pages/groups/views.cljs | 82 +++++++++++++------ 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index 34bd76b02..0b7535d65 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -1,5 +1,6 @@ (ns sixsq.nuvla.ui.pages.groups.views (:require ["@stripe/react-stripe-js" :as react-stripe] + [clojure.set :as set] [clojure.string :as str] [re-frame.core :refer [dispatch subscribe]] [reagent.core :as r] @@ -27,26 +28,56 @@ (dispatch [::main-events/reset-changes-protection]))) (defn GroupMember - [id group-name principal members editable?] - (let [tr (subscribe [::i18n-subs/tr]) - principal-name (subscribe [::session-subs/resolve-principal principal])] + [id group-name principal members editable? {:keys [owners manage view-data view-acl] :as acl}] + (let [tr (subscribe [::i18n-subs/tr]) + principal-name (subscribe [::session-subs/resolve-principal principal]) + manager? (boolean ((set (concat owners manage)) principal)) + can-view-members? (boolean ((set (concat owners view-data view-acl)) principal))] [ui/ListItem - - [ui/ListContent - [ui/ListIcon {:className icons/i-user :size "large" :verticalAlign "middle"}] - @principal-name - utils-general/nbsp - (when editable? + (when editable? + [ui/ListContent {:floated :right} + (if manager? + [ui/Popup {:content "Remove manager" + :trigger (r/as-element + [ui/Button {:icon true :basic true} + [ui/IconGroup + [icons/Icon {:name "fal fa-crown"}] + [icons/Icon {:name "fal fa-slash"}]]])}] + [:<> + (if can-view-members? + [ui/Popup {:content "Limit member’s view to only the group name and description" + :trigger (r/as-element + [ui/Button {:icon true :basic true} + [icons/Icon {:name "far fa-eye-slash"}]])}] + [ui/Popup {:content "Extend user view to member's list" + :trigger (r/as-element + [ui/Button {:icon true :basic true} + [icons/Icon {:name "far fa-eye"}]])}]) + [ui/Popup {:content "Make manager" + :trigger (r/as-element + [ui/Button {:icon true :basic true} [icons/Icon {:name "fal fa-crown"}]])}]]) [uix/ModalDanger {:button-text (@tr [:yes]) :on-confirm (fn [] (reset! members (-> @members set (disj principal) vec)) (dispatch [::main-events/changes-protection? true]) (set-group-changed! id)) - :trigger (r/as-element [ui/MenuItem - [icons/TrashIcon]]) + :trigger (r/as-element + [:span [ui/Popup {:content "Remove member" + :trigger (r/as-element + [ui/Button {:icon true + :basic true} + [icons/TrashIcon]])}]]) :header "Remove member" - :content [:span "Do you want to remove " [:b @principal-name] " from " [:b group-name] " group?"]}])]])) + :content [:span "Do you want to remove " [:b @principal-name] " from " [:b group-name] " group?"]}]]) + + + [ui/ListContent {:style {:display :flex :align-items :flex-end}} + [ui/IconGroup + [ui/Icon {:className icons/i-user :size "large"}] + (when manager? [ui/Icon {:className "fa-solid fa-crown" :corner true}])] + @principal-name] + ])) (defn DropdownPrincipals [_add-user _opts _members] @@ -84,35 +115,38 @@ show-acl? (r/atom false) invite-user (r/atom nil) add-user (r/atom nil)] - (fn [{:keys [id name description]}] - (let [invite-fn #(do - (when-not (str/blank? @invite-user) - (dispatch [::events/invite-to-group id @invite-user]) - (reset! invite-user nil))) + (fn [{:keys [id name description acl]}] + (let [invite-fn #(do + (when-not (str/blank? @invite-user) + (dispatch [::events/invite-to-group id @invite-user]) + (reset! invite-user nil))) group-name (or name id)] [:<> - [ui/Grid {:columns 2} - [ui/GridColumn {:floated :left} + [ui/Grid {:columns 2 :stackable true} + [ui/GridColumn {:floated :left :width 13} [ui/Header {:as :h3} [icons/UserGroupIcon] [ui/HeaderContent group-name [ui/HeaderSubheader description " (" id ")"]]]] - [ui/GridColumn {:floated :right} + [ui/GridColumn {:floated :right :width 3} [ui/Button {:basic true :floated :right} [:b "Add Subgroup"]]]] [ui/Header {:as :h3 :dividing true} "Members"] (if (empty? @members) [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message :empty-group-or-no-access-message)]] - [ui/ListSA {:relaxed true :vertical-align "middle"} + + [ui/ListSA {:divided true :vertical-align "middle"} (for [m @members] ^{:key m} - [GroupMember id group-name m members editable?])]) + [GroupMember id group-name m members editable? acl])]) (when (utils-general/can-operation? "invite" group) [ui/Input {:placeholder (@tr [:invite-by-email]) + :type :email :icon (r/as-element - [icons/PaperPlaneIcon {:style {:cursor :pointer :font-size "unset"} - :link true + [icons/PaperPlaneIcon {:style {:font-size "unset"} + :link (not (str/blank? @invite-user)) + :color (when (not (str/blank? @invite-user)) "blue") :circular true :onClick invite-fn}]) :style {:width "280px" :cursor :pointer} From 55883b495cba58802650c7820588dfdacf8c8dd0 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Tue, 15 Jul 2025 13:29:16 +0300 Subject: [PATCH 07/20] implementation of action --- .../sixsq/nuvla/ui/pages/groups/views.cljs | 279 ++++++++---------- .../cljs/sixsq/nuvla/ui/utils/general.cljs | 20 ++ 2 files changed, 140 insertions(+), 159 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index 0b7535d65..9c967a827 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -1,15 +1,11 @@ (ns sixsq.nuvla.ui.pages.groups.views (:require ["@stripe/react-stripe-js" :as react-stripe] - [clojure.set :as set] [clojure.string :as str] [re-frame.core :refer [dispatch subscribe]] [reagent.core :as r] - [sixsq.nuvla.ui.common-components.acl.views :as acl-views] [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] [sixsq.nuvla.ui.common-components.plugins.full-text-search :as full-text-search-plugin] - [sixsq.nuvla.ui.main.events :as main-events] [sixsq.nuvla.ui.pages.profile.events :as events] - [sixsq.nuvla.ui.pages.profile.subs :as subs] [sixsq.nuvla.ui.session.subs :as session-subs] [sixsq.nuvla.ui.utils.forms :as forms] [sixsq.nuvla.ui.utils.general :as utils-general] @@ -18,17 +14,93 @@ [sixsq.nuvla.ui.utils.semantic-ui-extensions :as uix] [sixsq.nuvla.ui.utils.ui-callback :as ui-callback])) +(def selected-group (r/atom nil)) + +(defn ConfirmActionModal + [{:keys [on-confirm header Content Icon]}] + (let [tr (subscribe [::i18n-subs/tr])] + [uix/ModalDanger + {:button-text (@tr [:yes]) + :on-confirm on-confirm + :trigger (r/as-element + [:span [ui/Popup {:content header + :trigger (r/as-element + [ui/Button {:icon true :basic true} + Icon])}]]) + :header header + :content Content}])) + +(defn RemoveManagerButton + [group principal principal-name group-name] + [ConfirmActionModal {:on-confirm (fn [] + (dispatch [::events/edit-group + (-> group + (utils-general/acl-append-resource :owners "group/nuvla-admin") + (utils-general/acl-remove-resource :owners principal) + (utils-general/acl-remove-resource :edit-meta principal) + (utils-general/acl-remove-resource :edit-data principal) + (utils-general/acl-remove-resource :edit-acl principal) + (utils-general/acl-remove-resource :manage principal))])) + :header "Remove manager" + :Content [:span "Do you want to remove " [:b @principal-name] " from manager's of group " [:b group-name] "?"] + :Icon [ui/IconGroup + [icons/Icon {:name "fal fa-crown"}] + [icons/Icon {:name "fal fa-slash"}]]}]) + +(defn MakeManagerButton + [group principal principal-name group-name] + [ConfirmActionModal {:on-confirm #(dispatch [::events/edit-group + (-> group + (utils-general/acl-append-resource :edit-acl principal) + (utils-general/acl-append-resource :manage principal))]) + :header "Make manager" + :Content [:span "Do you want to make " [:b @principal-name] " a manager of group " [:b group-name] "?"] + :Icon [icons/Icon {:name "fal fa-crown"}]}]) -(def group-changed! (r/atom {})) -(defn set-group-changed! [id] (swap! group-changed! assoc id true)) -(defn disable-changes-protection! - [id] - (swap! group-changed! assoc id false) - (when-not (some true? (vals @group-changed!)) - (dispatch [::main-events/reset-changes-protection]))) +(defn RemoveMemberButton + [group principal principal-name group-name] + [ConfirmActionModal {:on-confirm (fn [] + (dispatch [::events/edit-group + (-> group + (update :users (partial remove #{principal})) + (utils-general/acl-append-resource :owners "group/nuvla-admin") + (utils-general/acl-remove-resource :edit-acl principal) + (utils-general/acl-remove-resource :edit-data principal) + (utils-general/acl-remove-resource :edit-meta principal) + (utils-general/acl-remove-resource :view-acl principal) + (utils-general/acl-remove-resource :view-data principal) + (utils-general/acl-remove-resource :view-meta principal) + (utils-general/acl-remove-resource :manage principal))])) + :header "Remove member" + :Content [:span "Do you want to remove " [:b @principal-name] " from " [:b group-name] " group?"] + :Icon [icons/TrashIcon]}]) + +(defn LimitMemberViewButton + [group principal] + [ConfirmActionModal {:on-confirm (fn [] + (dispatch [::events/edit-group + (-> group + (utils-general/acl-remove-resource :edit-meta principal) + (utils-general/acl-remove-resource :edit-data principal) + (utils-general/acl-remove-resource :edit-acl principal) + (utils-general/acl-remove-resource :view-acl principal) + (utils-general/acl-remove-resource :view-data principal) + )])) + :header "Limit member’s view" + :Content "Limit member’s view to only the group name and description" + :Icon [icons/Icon {:name "far fa-eye-slash"}]}]) + +(defn ExtendMemberViewButton + [group principal] + [ConfirmActionModal {:on-confirm (fn [] + (dispatch [::events/edit-group + (utils-general/acl-append-resource group :view-acl principal)])) + :header "Extend user view" + :Content "Extend user view to member's list" + :Icon [icons/Icon {:name "far fa-eye"}]}]) (defn GroupMember - [id group-name principal members editable? {:keys [owners manage view-data view-acl] :as acl}] + [id group-name principal editable? {{:keys [owners manage view-data view-acl] :as acl} :acl :as group}] (let [tr (subscribe [::i18n-subs/tr]) principal-name (subscribe [::session-subs/resolve-principal principal]) manager? (boolean ((set (concat owners manage)) principal)) @@ -37,39 +109,13 @@ (when editable? [ui/ListContent {:floated :right} (if manager? - [ui/Popup {:content "Remove manager" - :trigger (r/as-element - [ui/Button {:icon true :basic true} - [ui/IconGroup - [icons/Icon {:name "fal fa-crown"}] - [icons/Icon {:name "fal fa-slash"}]]])}] + [RemoveManagerButton group principal @principal-name group-name] [:<> (if can-view-members? - [ui/Popup {:content "Limit member’s view to only the group name and description" - :trigger (r/as-element - [ui/Button {:icon true :basic true} - [icons/Icon {:name "far fa-eye-slash"}]])}] - [ui/Popup {:content "Extend user view to member's list" - :trigger (r/as-element - [ui/Button {:icon true :basic true} - [icons/Icon {:name "far fa-eye"}]])}]) - [ui/Popup {:content "Make manager" - :trigger (r/as-element - [ui/Button {:icon true :basic true} [icons/Icon {:name "fal fa-crown"}]])}]]) - [uix/ModalDanger - {:button-text (@tr [:yes]) - :on-confirm (fn [] - (reset! members (-> @members set (disj principal) vec)) - (dispatch [::main-events/changes-protection? true]) - (set-group-changed! id)) - :trigger (r/as-element - [:span [ui/Popup {:content "Remove member" - :trigger (r/as-element - [ui/Button {:icon true - :basic true} - [icons/TrashIcon]])}]]) - :header "Remove member" - :content [:span "Do you want to remove " [:b @principal-name] " from " [:b group-name] " group?"]}]]) + [LimitMemberViewButton group principal] + [ExtendMemberViewButton group principal]) + [MakeManagerButton group principal principal-name group-name]]) + [RemoveMemberButton group principal principal-name group-name]]) [ui/ListContent {:style {:display :flex :align-items :flex-end}} @@ -104,23 +150,34 @@ :style {:width "250px"} :upward false}])))) -(defn GroupMembers - [group] +(defn InviteInput + [{:keys [id] :as _group}] (let [tr (subscribe [::i18n-subs/tr]) - editable? (utils-general/editable? group false) - users (:users group) - members (r/atom users) - acl (r/atom (:acl group)) - changed? (r/cursor group-changed! [(:id group)]) - show-acl? (r/atom false) invite-user (r/atom nil) - add-user (r/atom nil)] - (fn [{:keys [id name description acl]}] - (let [invite-fn #(do - (when-not (str/blank? @invite-user) - (dispatch [::events/invite-to-group id @invite-user]) - (reset! invite-user nil))) - group-name (or name id)] + invite-fn #(do + (when-not (str/blank? @invite-user) + (dispatch [::events/invite-to-group id @invite-user]) + (reset! invite-user nil)))] + (fn [group] + (when (utils-general/can-operation? "invite" group) + [ui/Input {:placeholder (@tr [:invite-by-email]) + :type :email + :icon (r/as-element + [icons/PaperPlaneIcon {:style {:font-size "unset"} + :link (not (str/blank? @invite-user)) + :color (when (not (str/blank? @invite-user)) "blue") + :circular true + :onClick invite-fn}]) + :style {:width "280px" :cursor :pointer} + :on-key-press (partial forms/on-return-key invite-fn) + :value (or @invite-user "") + :on-change (ui-callback/value #(reset! invite-user %))}])))) + +(defn GroupMembers + [group] + (let [editable? (utils-general/editable? group false)] + (fn [{:keys [id name description users] :as group}] + (let [group-name (or name id)] [:<> [ui/Grid {:columns 2 :stackable true} [ui/GridColumn {:floated :left :width 13} @@ -132,113 +189,17 @@ [ui/GridColumn {:floated :right :width 3} [ui/Button {:basic true :floated :right} [:b "Add Subgroup"]]]] [ui/Header {:as :h3 :dividing true} "Members"] - (if (empty? @members) + (if (empty? users) [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message :empty-group-or-no-access-message)]] [ui/ListSA {:divided true :vertical-align "middle"} - (for [m @members] + (for [m users] ^{:key m} - [GroupMember id group-name m members editable? acl])]) - (when (utils-general/can-operation? "invite" group) - [ui/Input {:placeholder (@tr [:invite-by-email]) - :type :email - :icon (r/as-element - [icons/PaperPlaneIcon {:style {:font-size "unset"} - :link (not (str/blank? @invite-user)) - :color (when (not (str/blank? @invite-user)) "blue") - :circular true - :onClick invite-fn}]) - :style {:width "280px" :cursor :pointer} - :on-key-press (partial forms/on-return-key invite-fn) - :value (or @invite-user "") - :on-change (ui-callback/value #(reset! invite-user %))}]) + [GroupMember id group-name m editable? group])]) + [InviteInput group] - #_[ui/Table {:columns 4} - [ui/TableHeader {:fullWidth true} - [ui/TableRow - [ui/TableHeaderCell - [ui/HeaderSubheader {:as :h3} name]] - (when description [:p description]) - (when (and @acl editable?) - [ui/TableHeaderCell - [acl-views/AclButtonOnly {:default-value @acl - :read-only (not editable?) - :active? show-acl?}]])] - (when @show-acl? - [ui/TableRow - [ui/TableCell {:colSpan 4} - [acl-views/AclSection {:default-value @acl - :read-only (not editable?) - :active? show-acl? - :on-change #(do - (reset! acl %) - (set-group-changed! id) - (dispatch [::main-events/changes-protection? true]))}]]])] - [ui/TableBody - [ui/TableRow - [ui/TableCell - (if (empty? @members) - [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message - :empty-group-or-no-access-message)]] - [ui/ListSA - (for [m @members] - ^{:key m} - [GroupMember id m members editable?])])]] - (when editable? - [ui/TableRow - [ui/TableCell - [:div {:style {:display "flex"}} - [DropdownPrincipals - add-user - {:placeholder (@tr [:add-group-members]) - :fluid true} @members] - [:span utils-general/nbsp] - [uix/Button {:text (@tr [:add]) - :icon "add user" - :disabled (str/blank? @add-user) - :on-click #(do - (swap! members conj @add-user) - (reset! add-user nil) - (set-group-changed! id) - (dispatch [::main-events/changes-protection? true]))}] - [:span utils-general/nbsp] - [:span utils-general/nbsp] - [ui/Input {:placeholder (@tr [:invite-by-email]) - :style {:width "250px"} - :value (or @invite-user "") - :on-change (ui-callback/value #(reset! invite-user %))}] - [:span utils-general/nbsp] - [uix/Button {:text (@tr [:send]) - :icon "send" - :disabled (str/blank? @invite-user) - :on-click #(do - (dispatch [::events/invite-to-group id @invite-user]) - (reset! invite-user nil))}]]] - [ui/TableCell {:textAlign "right"} - [uix/Button {:primary true - :text (@tr [:save]) - :icon "save" - :disabled (not @changed?) - :on-click #(do (dispatch [::events/edit-group (assoc group :users @members, :acl @acl)]) - (disable-changes-protection! id))}]]])]]])))) - -;(defn GroupMembersSegment -; [] -; (let [tr (subscribe [::i18n-subs/tr]) -; loading? (subscribe [::subs/loading? :group]) -; group (subscribe [::subs/group])] -; (dispatch [::events/get-group]) -; (fn [] -; [ui/Segment {:padded true -; :color "green" -; :loading @loading? -; :style {:height "100%"}} -; [ui/Header {:as :h2 :dividing true} (@tr [:group-members])] -; ^{:key (random-uuid)} -; [GroupMembers @group]]))) - -(def selected-group (r/atom nil)) + ])))) (defn Group [] diff --git a/code/src/cljs/sixsq/nuvla/ui/utils/general.cljs b/code/src/cljs/sixsq/nuvla/ui/utils/general.cljs index ecbcf7d10..9d350b109 100644 --- a/code/src/cljs/sixsq/nuvla/ui/utils/general.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/utils/general.cljs @@ -482,3 +482,23 @@ (if (not= c 0) c (recur (rest rest-orders))))))) + +(defn acl-append + [acl right-kw user-id] + (if user-id + (update acl right-kw (comp vec set conj) user-id) + acl)) + +(defn acl-remove + [acl right-kw user-id] + (if user-id + (update acl right-kw (fn [user-ids] (vec (remove #{user-id} user-ids)))) + acl)) + +(defn acl-append-resource + [resource right-kw user-id] + (update resource :acl acl-append right-kw user-id)) + +(defn acl-remove-resource + [resource right-kw user-id] + (update resource :acl acl-remove right-kw user-id)) From 061d19c8b13265b401d02fd0464f0b25e3b35cc4 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Tue, 15 Jul 2025 16:16:39 +0300 Subject: [PATCH 08/20] navigation by url and remove local atoms for selected group --- .../sixsq/nuvla/ui/pages/groups/views.cljs | 41 ++++++++++--------- .../sixsq/nuvla/ui/pages/profile/events.cljs | 10 +++-- .../cljs/sixsq/nuvla/ui/routing/router.cljs | 23 ++++++++--- .../cljs/sixsq/nuvla/ui/routing/routes.cljs | 1 + .../cljs/sixsq/nuvla/ui/routing/utils.cljs | 3 +- .../cljs/sixsq/nuvla/ui/session/events.cljs | 2 +- 6 files changed, 49 insertions(+), 31 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index 9c967a827..7c1a2c66e 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -6,6 +6,8 @@ [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] [sixsq.nuvla.ui.common-components.plugins.full-text-search :as full-text-search-plugin] [sixsq.nuvla.ui.pages.profile.events :as events] + [sixsq.nuvla.ui.routing.routes :as routes] + [sixsq.nuvla.ui.routing.events :as routing-events] [sixsq.nuvla.ui.session.subs :as session-subs] [sixsq.nuvla.ui.utils.forms :as forms] [sixsq.nuvla.ui.utils.general :as utils-general] @@ -14,8 +16,6 @@ [sixsq.nuvla.ui.utils.semantic-ui-extensions :as uix] [sixsq.nuvla.ui.utils.ui-callback :as ui-callback])) -(def selected-group (r/atom nil)) - (defn ConfirmActionModal [{:keys [on-confirm header Content Icon]}] (let [tr (subscribe [::i18n-subs/tr])] @@ -88,7 +88,7 @@ )])) :header "Limit member’s view" :Content "Limit member’s view to only the group name and description" - :Icon [icons/Icon {:name "far fa-eye-slash"}]}]) + :Icon [icons/Icon {:className "far fa-eye-slash"}]}]) (defn ExtendMemberViewButton [group principal] @@ -97,7 +97,7 @@ (utils-general/acl-append-resource group :view-acl principal)])) :header "Extend user view" :Content "Extend user view to member's list" - :Icon [icons/Icon {:name "far fa-eye"}]}]) + :Icon [icons/Icon {:className "far fa-eye"}]}]) (defn GroupMember [id group-name principal editable? {{:keys [owners manage view-data view-acl] :as acl} :acl :as group}] @@ -109,7 +109,7 @@ (when editable? [ui/ListContent {:floated :right} (if manager? - [RemoveManagerButton group principal @principal-name group-name] + [RemoveManagerButton group principal principal-name group-name] [:<> (if can-view-members? [LimitMemberViewButton group principal] @@ -202,13 +202,13 @@ ])))) (defn Group - [] - (let [collapsed (r/atom true)] - (fn [{:keys [id name children] :as _group}] - (let [selected? (= @selected-group id) + [{:keys [id] :as _group} {:keys [parents] :as _selected-group}] + (let [collapsed (r/atom (not ((set parents) id)))] + (fn [{:keys [id name children] :as _group} selected-group] + (let [selected? (= (:id selected-group) id) children? (boolean (seq children))] [ui/ListItem {:on-click #(do - (reset! selected-group id) + (dispatch [::routing-events/navigate routes/groups-details {:uuid (utils-general/id->uuid id)}]) (.stopPropagation %))} [ui/ListIcon {:style {:padding 5 :min-width "17px"} @@ -230,10 +230,10 @@ [ui/ListList (for [child (sort-by (juxt :id :name) children)] ^{:key (:id child)} - [Group child])])]])))) + [Group child selected-group])])]])))) (defn GroupHierarchySegment - [] + [selected-group] (let [groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] [ui/Segment {:raised true :style {:overflow-x :auto :min-height "100%"}} @@ -245,12 +245,13 @@ [ui/ListSA {:selection true} (for [group-hierarchy (sort-by (juxt :id :name) groups-hierarchy)] ^{:key (:id group-hierarchy)} - [Group group-hierarchy])]])) + [Group group-hierarchy selected-group])]])) (defn GroupsViewPage - [] - (let [group (when @selected-group - @(subscribe [::session-subs/group @selected-group]))] + [{path :path}] + (let [[_ uuid] path + selected-group (when uuid + @(subscribe [::session-subs/group (str "group/" uuid)]))] [ui/Grid {:stackable false} [ui/GridColumn {:stretched true :computer 4 @@ -258,7 +259,7 @@ :mobile 8 :style {:background-color "light-gray" :padding-right 0}} - [GroupHierarchySegment]] + [GroupHierarchySegment selected-group]] [ui/GridColumn {:stretched true :tablet 10 :computer 12 @@ -267,7 +268,7 @@ :padding-right 0}} [ui/Segment {:style {:min-height "100%" :overflow-x :auto}} - (if @selected-group - ^{:key group} - [GroupMembers group] + (if selected-group + ^{:key selected-group} + [GroupMembers selected-group] [uix/MsgNoItemsToShow [uix/TR "Select a Group"]])]]])) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs index 6a788814f..f73b37750 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs @@ -98,10 +98,12 @@ status (str " (" status ")")) :content message :type :error}])) - (dispatch [::messages-events/add - {:header "Group updated" - :content "Group updated successfully." - :type :info}]))]}))) + (do + (dispatch [::session-events/search-groups]) + (dispatch [::messages-events/add + {:header "Group updated" + :content "Group updated successfully." + :type :info}])))]}))) (reg-event-fx ::invite-to-group diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs index 44c01b13a..d84bb8da2 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs @@ -61,6 +61,22 @@ :view #'views-cluster/ClusterViewPage}]]) (utils/canonical->all-page-names "edges"))) +(def groups-routes + (mapv (fn [page-alias] + [page-alias + {:name (create-route-name page-alias) + :layout #'LayoutPage + :view #'GroupsViewPage + :protected? true + :dict-key :groups} + [""] + ["/" (create-route-name page-alias "-slashed")] + ["/:uuid" + {:name (create-route-name page-alias "-details") + :layout #'LayoutPage + :view #'GroupsViewPage}]]) + (utils/canonical->all-page-names "groups"))) + (def cloud-routes (mapv (fn [page-alias] [page-alias @@ -122,6 +138,7 @@ cloud-routes deployment-routes deployment-group-routes + groups-routes ["sign-up" {:name ::routes/sign-up :layout #'LayoutAuthentication @@ -219,11 +236,7 @@ ["profile" {:name ::routes/profile :layout #'LayoutPage - :view #'profile}] - ["groups" - {:name ::routes/groups - :layout #'LayoutPage - :view #'GroupsViewPage}]] + :view #'profile}]] ["/*" {:name ::routes/catch-all :layout #'LayoutPage diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs index 9965c5577..d30b9ce8c 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs @@ -50,5 +50,6 @@ (def api-slashed ::api-slashed) (def api-sub-page ::api-sub-page) (def groups ::groups) +(def groups-details ::groups-details) (def profile ::profile) (def catch-all ::catch-all) diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/utils.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/utils.cljs index 494d71f28..1ba7ac56e 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/utils.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/utils.cljs @@ -101,7 +101,8 @@ "infrastructures" "clouds" "deployment" "deployments" "deployment-set" "deployment-groups" - "deployment-set-details" "deployment-groups-details"}) + "deployment-set-details" "deployment-groups-details" + "group" "groups"}) (defn ->canonical-route-name [route-name] diff --git a/code/src/cljs/sixsq/nuvla/ui/session/events.cljs b/code/src/cljs/sixsq/nuvla/ui/session/events.cljs index 8b618e94e..58af01019 100644 --- a/code/src/cljs/sixsq/nuvla/ui/session/events.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/session/events.cljs @@ -236,7 +236,7 @@ (reg-event-fx ::search-groups (fn [] - {::cimi-api-fx/search [:group {:select "id, name, acl, users, description, operations" + {::cimi-api-fx/search [:group {:select "id, name, acl, users, description, operations, parents" :last 10000 :orderby "name:asc,id:asc"} #(dispatch [::set-groups %])] From 686c49cb0a707cd14928faae63dcf31da6c142b6 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Wed, 23 Jul 2025 11:35:15 +0300 Subject: [PATCH 09/20] Add group modal --- .../ui/common_components/i18n/dictionary.cljs | 2 +- .../sixsq/nuvla/ui/pages/groups/views.cljs | 102 ++++++++++++++++-- .../sixsq/nuvla/ui/pages/profile/views.cljs | 60 ----------- 3 files changed, 92 insertions(+), 72 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs b/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs index 1cfc2e86e..62cf494d7 100644 --- a/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs @@ -73,7 +73,7 @@ :add-dropdown "Add:" :add-edges "Add edges" :add-first-tag "Add your first tag" - :add-group "add group" + :add-group "Add Group" :add-group-members "add group members" :add-license "Add a license" :add-payment-method "Add payment method" diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index 7c1a2c66e..1c5f2f3ca 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -1,11 +1,13 @@ (ns sixsq.nuvla.ui.pages.groups.views (:require ["@stripe/react-stripe-js" :as react-stripe] + [cljs.spec.alpha :as s] [clojure.string :as str] [re-frame.core :refer [dispatch subscribe]] [reagent.core :as r] [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] [sixsq.nuvla.ui.common-components.plugins.full-text-search :as full-text-search-plugin] [sixsq.nuvla.ui.pages.profile.events :as events] + [sixsq.nuvla.ui.pages.profile.spec :as spec] [sixsq.nuvla.ui.routing.routes :as routes] [sixsq.nuvla.ui.routing.events :as routing-events] [sixsq.nuvla.ui.session.subs :as session-subs] @@ -14,6 +16,7 @@ [sixsq.nuvla.ui.utils.icons :as icons] [sixsq.nuvla.ui.utils.semantic-ui :as ui] [sixsq.nuvla.ui.utils.semantic-ui-extensions :as uix] + [sixsq.nuvla.ui.utils.style :as style] [sixsq.nuvla.ui.utils.ui-callback :as ui-callback])) (defn ConfirmActionModal @@ -179,15 +182,20 @@ (fn [{:keys [id name description users] :as group}] (let [group-name (or name id)] [:<> - [ui/Grid {:columns 2 :stackable true} - [ui/GridColumn {:floated :left :width 13} - [ui/Header {:as :h3} - [icons/UserGroupIcon] - [ui/HeaderContent - group-name - [ui/HeaderSubheader description " (" id ")"]]]] - [ui/GridColumn {:floated :right :width 3} - [ui/Button {:basic true :floated :right} [:b "Add Subgroup"]]]] + [:div {:style {:display :flex + :align-items :flex-start + :justify-content :space-between + :flex-wrap :wrap + :padding-bottom "1em"}} + [ui/Header {:as :h3} + [icons/UserGroupIcon] + [ui/HeaderContent + group-name + [ui/HeaderSubheader description " (" id ")"]]] + (when (utils-general/can-operation? "add-subgroup" group) + [ui/Button {:secondary true :size "small" :icon true} + [icons/PlusSquareIcon] + "Add Subgroup"])] [ui/Header {:as :h3 :dividing true} "Members"] (if (empty? users) [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message @@ -232,12 +240,84 @@ ^{:key (:id child)} [Group child selected-group])])]])))) +(defn sanitize-name [name] + (when name + (str/lower-case + (str/replace + (str/trim + (str/join "" (re-seq #"[a-zA-Z0-9-_\ ]" name))) + " " "-")))) + +(defn AddGroupButton + [] + (let [tr (subscribe [::i18n-subs/tr]) + show? (r/atom false) + group-name (r/atom "") + group-desc (r/atom "") + validate? (r/atom false) + loading? (r/atom false) + close-fn #(reset! show? false)] + (fn [] + (let [group-identifier (sanitize-name @group-name) + form-valid? (and (s/valid? ::spec/group-name @group-name) + (s/valid? ::spec/group-description @group-desc))] + [ui/Modal + {:open @show? + :close-icon true + :on-close close-fn + :trigger (r/as-element + [ui/Button {:primary true + :size "small" + :icon true + :on-click #(reset! show? true)} + [icons/PlusSquareIcon] (@tr [:add-group])])} + [uix/ModalHeader {:header (@tr [:add-group])}] + [ui/ModalContent + [ui/Message {:hidden (not (and @validate? (not form-valid?))) + :error true} + [ui/MessageHeader (@tr [:validation-error])] + [ui/MessageContent (@tr [:validation-error-message])]] + (when-not (str/blank? group-identifier) + [:i {:style {:padding-left "1ch" + :color :grey}} + [:b "id : "] + (str "group/" group-identifier)]) + [ui/Table style/definition + [ui/TableBody + [uix/TableRowField (@tr [:name]), :required? true, :default-value @group-name, + :validate-form? @validate?, :spec ::spec/group-name, + :on-change #(reset! group-name %)] + [uix/TableRowField (@tr [:description]), :required? true, + :spec ::spec/group-description, :validate-form? @validate?, + :default-value @group-desc, :on-change #(reset! group-desc %)]]]] + [ui/ModalActions + [uix/Button + {:text (@tr [:create]) + :primary true + :disabled (and @validate? (not form-valid?)) + :icon icons/i-info-full + :loading @loading? + :on-click #(if (not form-valid?) + (reset! validate? true) + (do + (reset! show? false) + (dispatch + [::events/add-group group-identifier @group-name @group-desc loading?])))}]]])))) + (defn GroupHierarchySegment [selected-group] (let [groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] [ui/Segment {:raised true :style {:overflow-x :auto :min-height "100%"}} - [ui/Header {:as :h3} "Groups"] + + [:div {:style {:display :flex + :align-items :baseline + :justify-content :space-between + :flex-wrap :wrap + :padding-bottom "1em"}} + [ui/Header {:as :h3} "Groups"] + [AddGroupButton]] + [full-text-search-plugin/FullTextSearch {:db-path [::deployments-search] :change-event [:a] @@ -251,7 +331,7 @@ [{path :path}] (let [[_ uuid] path selected-group (when uuid - @(subscribe [::session-subs/group (str "group/" uuid)]))] + @(subscribe [::session-subs/group (str "group/" uuid)]))] [ui/Grid {:stackable false} [ui/GridColumn {:stretched true :computer 4 diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs index ee450044e..18982e5e4 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/profile/views.cljs @@ -63,65 +63,6 @@ 1)) -(defn AddGroupButton - [] - (let [tr (subscribe [::i18n-subs/tr]) - show? (r/atom false) - group-name (r/atom "") - group-desc (r/atom "") - validate? (r/atom false) - loading? (r/atom false) - close-fn #(reset! show? false)] - (fn [] - (let [group-identifier (utils-general/sanitize-name @group-name) - form-valid? (and (s/valid? ::spec/group-name @group-name) - (s/valid? ::spec/group-description @group-desc))] - [ui/Modal - {:open @show? - :close-icon true - :on-close close-fn - :trigger (r/as-element - [ui/MenuItem {:on-click #(reset! show? true)} - [icons/UsersIcon] - (str/capitalize (@tr [:add-group]))])} - [uix/ModalHeader {:header (str/capitalize (@tr [:add-group]))}] - [ui/ModalContent - [ui/Message {:hidden (not (and @validate? (not form-valid?))) - :error true} - [ui/MessageHeader (@tr [:validation-error])] - [ui/MessageContent (@tr [:validation-error-message])]] - [ui/Input {:name "name" - :placeholder (@tr [:name]) - :type :text - :error (when (and @validate? - (not (s/valid? ::spec/group-name @group-name))) true) - :fluid true - :on-change (ui-callback/input-callback - #(reset! group-name %))}] - [:br] - [ui/Input {:name "description" - :placeholder (@tr [:description]) - :type :text - :error (when (and @validate? - (not (s/valid? ::spec/group-description @group-desc))) true) - :fluid true - :on-change (ui-callback/input-callback - #(reset! group-desc %))}]] - [ui/ModalActions - [uix/Button - {:text (@tr [:create]) - :primary true - :disabled (and @validate? (not form-valid?)) - :icon icons/i-info-full - :loading @loading? - :on-click #(if (not form-valid?) - (reset! validate? true) - (do - (reset! show? false) - (dispatch - [::events/add-group group-identifier @group-name @group-desc loading?])))}]]])))) - - (defn ModalChangePassword [] (let [open? (subscribe [::subs/modal-open? :change-password]) error (subscribe [::subs/error-message]) @@ -1442,7 +1383,6 @@ :icon "user secret" :content (str/capitalize (@tr [:change-password])) :on-click #(dispatch [::events/open-modal :change-password])}] - [AddGroupButton] (when can-enable-2fa? [ui/MenuItem {:icon "shield" From 595f5b8b7cbd20dddd84ee1c2c9eb82a1daf5a1e Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Wed, 23 Jul 2025 11:52:43 +0300 Subject: [PATCH 10/20] move events to groups --- .../sixsq/nuvla/ui/pages/groups/events.cljs | 65 +++++++++++++++ .../sixsq/nuvla/ui/pages/groups/views.cljs | 23 ++--- .../sixsq/nuvla/ui/pages/profile/events.cljs | 83 +------------------ .../sixsq/nuvla/ui/pages/profile/spec.cljs | 9 +- .../sixsq/nuvla/ui/pages/profile/subs.cljs | 5 -- 5 files changed, 79 insertions(+), 106 deletions(-) create mode 100644 code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs new file mode 100644 index 000000000..10d8b66df --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs @@ -0,0 +1,65 @@ +(ns sixsq.nuvla.ui.pages.groups.events + (:require [day8.re-frame.http-fx] + [re-frame.core :refer [dispatch reg-event-db reg-event-fx]] + [sixsq.nuvla.ui.cimi-api.effects :as cimi-api-fx] + [sixsq.nuvla.ui.common-components.messages.events :as messages-events] + [sixsq.nuvla.ui.config :as config] + [sixsq.nuvla.ui.session.events :as session-events] + [sixsq.nuvla.ui.session.spec :as session-spec] + [sixsq.nuvla.ui.utils.response :as response])) + +(reg-event-fx + ::add-group + (fn [{_db :db} [_ id name description loading?]] + (let [user {:template {:href "group-template/generic" + :group-identifier id} + :name name + :description description}] + {::cimi-api-fx/add + ["group" user + #(let [{:keys [status message resource-id]} (response/parse %)] + (dispatch [::session-events/search-groups]) + (dispatch [::messages-events/add + {:header (cond-> (str "added " resource-id) + status (str " (" status ")")) + :content message + :type :success}]) + (reset! loading? false))]}))) + +(reg-event-fx + ::edit-group + (fn [_ [_ group]] + (let [id (:id group)] + {::cimi-api-fx/edit [id group #(if (instance? js/Error %) + (let [{:keys [status message]} (response/parse-ex-info %)] + (dispatch [::messages-events/add + {:header (cond-> "Group update failed" + status (str " (" status ")")) + :content message + :type :error}])) + (do + (dispatch [::session-events/search-groups]) + (dispatch [::messages-events/add + {:header "Group updated" + :content "Group updated successfully." + :type :info}])))]}))) + +(reg-event-fx + ::invite-to-group + (fn [{db :db} [_ group-id username]] + (let [on-error #(let [{:keys [status message]} (response/parse-ex-info %)] + (dispatch [::messages-events/add + {:header (cond-> (str "Invitation to " group-id " for " username " failed!") + status (str " (" status ")")) + :content message + :type :error}])) + on-success #(dispatch [::messages-events/add + {:header "Invitation successfully sent to user" + :content (str "User will appear in " group-id + " when he accept the invitation sent to his email address.") + :type :info}]) + data {:username username + :redirect-url (str (::session-spec/server-redirect-uri db) + "?message=join-group-accepted") + :set-password-url (str @config/path-prefix "/set-password")}] + {::cimi-api-fx/operation [group-id "invite" on-success :on-error on-error :data data]}))) \ No newline at end of file diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index 1c5f2f3ca..218ec1e89 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -1,13 +1,11 @@ (ns sixsq.nuvla.ui.pages.groups.views - (:require ["@stripe/react-stripe-js" :as react-stripe] - [cljs.spec.alpha :as s] + (:require [cljs.spec.alpha :as s] [clojure.string :as str] [re-frame.core :refer [dispatch subscribe]] [reagent.core :as r] [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] [sixsq.nuvla.ui.common-components.plugins.full-text-search :as full-text-search-plugin] - [sixsq.nuvla.ui.pages.profile.events :as events] - [sixsq.nuvla.ui.pages.profile.spec :as spec] + [sixsq.nuvla.ui.pages.groups.events :as events] [sixsq.nuvla.ui.routing.routes :as routes] [sixsq.nuvla.ui.routing.events :as routing-events] [sixsq.nuvla.ui.session.subs :as session-subs] @@ -16,9 +14,13 @@ [sixsq.nuvla.ui.utils.icons :as icons] [sixsq.nuvla.ui.utils.semantic-ui :as ui] [sixsq.nuvla.ui.utils.semantic-ui-extensions :as uix] + [sixsq.nuvla.ui.utils.spec :as us] [sixsq.nuvla.ui.utils.style :as style] [sixsq.nuvla.ui.utils.ui-callback :as ui-callback])) +(s/def ::group-name us/nonblank-string) +(s/def ::group-description us/nonblank-string) + (defn ConfirmActionModal [{:keys [on-confirm header Content Icon]}] (let [tr (subscribe [::i18n-subs/tr])] @@ -259,8 +261,8 @@ close-fn #(reset! show? false)] (fn [] (let [group-identifier (sanitize-name @group-name) - form-valid? (and (s/valid? ::spec/group-name @group-name) - (s/valid? ::spec/group-description @group-desc))] + form-valid? (and (s/valid? ::group-name @group-name) + (s/valid? ::group-description @group-desc))] [ui/Modal {:open @show? :close-icon true @@ -279,23 +281,22 @@ [ui/MessageContent (@tr [:validation-error-message])]] (when-not (str/blank? group-identifier) [:i {:style {:padding-left "1ch" - :color :grey}} + :color :grey}} [:b "id : "] (str "group/" group-identifier)]) [ui/Table style/definition [ui/TableBody [uix/TableRowField (@tr [:name]), :required? true, :default-value @group-name, - :validate-form? @validate?, :spec ::spec/group-name, + :validate-form? @validate?, :spec ::group-name, :on-change #(reset! group-name %)] [uix/TableRowField (@tr [:description]), :required? true, - :spec ::spec/group-description, :validate-form? @validate?, + :spec ::group-description, :validate-form? @validate?, :default-value @group-desc, :on-change #(reset! group-desc %)]]]] [ui/ModalActions [uix/Button - {:text (@tr [:create]) + {:text (str/capitalize (@tr [:add])) :primary true :disabled (and @validate? (not form-valid?)) - :icon icons/i-info-full :loading @loading? :on-click #(if (not form-valid?) (reset! validate? true) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs index f73b37750..a368b55d8 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/profile/events.cljs @@ -8,13 +8,11 @@ [sixsq.nuvla.ui.common-components.i18n.spec :as i18n-spec] [sixsq.nuvla.ui.common-components.messages.events :as messages-events] [sixsq.nuvla.ui.common-components.plugins.audit-log :as audit-log-plugin] - [sixsq.nuvla.ui.config :as config] [sixsq.nuvla.ui.main.spec :as main-spec] [sixsq.nuvla.ui.pages.profile.effects :as fx] [sixsq.nuvla.ui.pages.profile.spec :as spec] [sixsq.nuvla.ui.routing.events :as routing-events] [sixsq.nuvla.ui.routing.routes :as routes] - [sixsq.nuvla.ui.session.events :as session-events] [sixsq.nuvla.ui.session.spec :as session-spec] [sixsq.nuvla.ui.session.utils :as session-utils] [sixsq.nuvla.ui.utils.general :as general-utils] @@ -31,16 +29,6 @@ [:dispatch [::audit-log-plugin/load-events [::spec/events] {:event-name event-names} false]]]})) -(reg-event-db - ::add-group-member - (fn [db [_ member]] - (update-in db [::spec/group :users] #(conj % member)))) - -(reg-event-db - ::remove-group-member - (fn [db [_ member]] - (update-in db [::spec/group :users] #(vec (disj (set %) member))))) - (reg-event-db ::set-user (fn [{:keys [::spec/loading] :as db} [_ user]] @@ -52,79 +40,10 @@ (fn [{{:keys [::session-spec/session] :as db} :db} _] (when-let [user (session-utils/get-user-id session)] (let [is-group? (-> session session-utils/get-active-claim session-utils/is-group?)] - (cond-> {:fx [(when is-group? [:dispatch [::get-group]])]} + (cond-> {} (not is-group?) (assoc ::cimi-api-fx/get [user #(do (dispatch [::set-user %]))] :db (update db ::spec/loading conj :user))))))) -(reg-event-fx - ::add-group - (fn [{_db :db} [_ id name description loading?]] - (let [user {:template {:href "group-template/generic" - :group-identifier id} - :name name - :description description}] - {::cimi-api-fx/add - ["group" user - #(let [{:keys [status message resource-id]} (response/parse %)] - (dispatch [::session-events/search-groups]) - (dispatch [::messages-events/add - {:header (cond-> (str "added " resource-id) - status (str " (" status ")")) - :content message - :type :success}]) - (reset! loading? false))]}))) - -(reg-event-db - ::set-group - (fn [{:keys [::spec/loading] :as db} [_ group]] - (assoc db ::spec/group group - ::spec/loading (disj loading :group)))) - -(reg-event-fx - ::get-group - (fn [{{:keys [::session-spec/session] :as db} :db}] - (when-let [group (session-utils/get-active-claim session)] - {:db (update db ::spec/loading conj :group) - ::cimi-api-fx/get [group #(dispatch [::set-group %])]}))) - -(reg-event-fx - ::edit-group - (fn [_ [_ group]] - (let [id (:id group)] - {::cimi-api-fx/edit [id group #(if (instance? js/Error %) - (let [{:keys [status message]} (response/parse-ex-info %)] - (dispatch [::messages-events/add - {:header (cond-> "Group update failed" - status (str " (" status ")")) - :content message - :type :error}])) - (do - (dispatch [::session-events/search-groups]) - (dispatch [::messages-events/add - {:header "Group updated" - :content "Group updated successfully." - :type :info}])))]}))) - -(reg-event-fx - ::invite-to-group - (fn [{db :db} [_ group-id username]] - (let [on-error #(let [{:keys [status message]} (response/parse-ex-info %)] - (dispatch [::messages-events/add - {:header (cond-> (str "Invitation to " group-id " for " username " failed!") - status (str " (" status ")")) - :content message - :type :error}])) - on-success #(dispatch [::messages-events/add - {:header "Invitation successfully sent to user" - :content (str "User will appear in " group-id - " when he accept the invitation sent to his email address.") - :type :info}]) - data {:username username - :redirect-url (str (::session-spec/server-redirect-uri db) - "?message=join-group-accepted") - :set-password-url (str @config/path-prefix "/set-password")}] - {::cimi-api-fx/operation [group-id "invite" on-success :on-error on-error :data data]}))) - (reg-event-fx ::get-customer (fn [{db :db} [_ id]] diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/profile/spec.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/profile/spec.cljs index 820fb3502..b49b5125d 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/profile/spec.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/profile/spec.cljs @@ -1,8 +1,7 @@ (ns sixsq.nuvla.ui.pages.profile.spec (:require [clojure.spec.alpha :as s] [sixsq.nuvla.ui.common-components.plugins.audit-log :as audit-log-plugin] - [sixsq.nuvla.ui.common-components.plugins.nav-tab :as nav-tab] - [sixsq.nuvla.ui.utils.spec :as us])) + [sixsq.nuvla.ui.common-components.plugins.nav-tab :as nav-tab])) (s/def ::user any?) (s/def ::customer any?) @@ -17,11 +16,6 @@ (s/def ::loading set?) (s/def ::setup-intent any?) (s/def ::vendor any?) -(s/def ::group any?) -(s/def ::group-name us/nonblank-string) -(s/def ::group-description us/nonblank-string) -(s/def ::group-form (s/keys :req [::group-name - ::group-description])) (s/def ::tab any?) (s/def ::two-factor-step (s/nilable string?)) (s/def ::two-factor-enable? boolean?) @@ -43,7 +37,6 @@ ::error-message nil ::loading #{} ::vendor nil - ::group nil ::tab (nav-tab/build-spec) ::two-factor-step :install-app ::two-factor-enable? true diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/profile/subs.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/profile/subs.cljs index b5ce01b68..d3e267c87 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/profile/subs.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/profile/subs.cljs @@ -198,11 +198,6 @@ (fn [db] (::spec/vendor db))) -(reg-sub - ::group - (fn [db] - (::spec/group db))) - (reg-sub ::two-factor-step (fn [db] From ee8b7296cbc44e568d523622267b86c467a36da1 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Thu, 24 Jul 2025 07:45:28 +0300 Subject: [PATCH 11/20] Add group and add subgroup --- .../sixsq/nuvla/ui/pages/groups/events.cljs | 36 +++-- .../sixsq/nuvla/ui/pages/groups/views.cljs | 141 +++++++++--------- 2 files changed, 94 insertions(+), 83 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs index 10d8b66df..ef7000eb9 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs @@ -10,21 +10,27 @@ (reg-event-fx ::add-group - (fn [{_db :db} [_ id name description loading?]] - (let [user {:template {:href "group-template/generic" - :group-identifier id} - :name name - :description description}] - {::cimi-api-fx/add - ["group" user - #(let [{:keys [status message resource-id]} (response/parse %)] - (dispatch [::session-events/search-groups]) - (dispatch [::messages-events/add - {:header (cond-> (str "added " resource-id) - status (str " (" status ")")) - :content message - :type :success}]) - (reset! loading? false))]}))) + (fn [{_db :db} [_ {:keys [parent-group group-identifier group-name group-desc loading?]}]] + (let [on-success #(let [{:keys [status message resource-id]} (response/parse %)] + (dispatch [::session-events/search-groups]) + (dispatch [::messages-events/add + {:header (cond-> (str "added " resource-id) + status (str " (" status ")")) + :content message + :type :success}]) + (reset! loading? false))] + (if parent-group + {::cimi-api-fx/operation + [(:id parent-group) "add-subgroup" + on-success + :data {:group-identifier group-identifier + :name group-name + :description group-desc}]} + {::cimi-api-fx/add + ["group" {:template {:href "group-template/generic" + :group-identifier group-identifier} + :name group-name + :description group-desc} on-success]})))) (reg-event-fx ::edit-group diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index 218ec1e89..b09741e3e 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -178,6 +178,74 @@ :value (or @invite-user "") :on-change (ui-callback/value #(reset! invite-user %))}])))) +(defn sanitize-name [name] + (when name + (str/lower-case + (str/replace + (str/trim + (str/join "" (re-seq #"[a-zA-Z0-9-_\ ]" name))) + " " "-")))) + +(defn AddGroupButton + [_opts] + (let [tr (subscribe [::i18n-subs/tr]) + show? (r/atom false) + group-name (r/atom "") + group-desc (r/atom "") + validate? (r/atom false) + loading? (r/atom false) + close-fn #(reset! show? false)] + (fn [{:keys [parent-group header]}] + (let [group-identifier (sanitize-name @group-name) + form-valid? (and (s/valid? ::group-name @group-name) + (s/valid? ::group-description @group-desc))] + [ui/Modal + {:open @show? + :close-icon true + :on-close close-fn + :trigger (r/as-element + [ui/Button {:secondary true + :size "small" + :icon true + :on-click #(reset! show? true)} + [icons/PlusSquareIcon] + header])} + [uix/ModalHeader {:header header}] + [ui/ModalContent + [ui/Message {:hidden (not (and @validate? (not form-valid?))) + :error true} + [ui/MessageHeader (@tr [:validation-error])] + [ui/MessageContent (@tr [:validation-error-message])]] + (when-not (str/blank? group-identifier) + [:i {:style {:padding-left "1ch" + :color :grey}} + [:b "id : "] + (str "group/" group-identifier)]) + [ui/Table style/definition + [ui/TableBody + [uix/TableRowField (@tr [:name]), :required? true, :default-value @group-name, + :validate-form? @validate?, :spec ::group-name, + :on-change #(reset! group-name %)] + [uix/TableRowField (@tr [:description]), :required? true, + :spec ::group-description, :validate-form? @validate?, + :default-value @group-desc, :on-change #(reset! group-desc %)]]]] + [ui/ModalActions + [uix/Button + {:text (str/capitalize (@tr [:add])) + :primary true + :disabled (and @validate? (not form-valid?)) + :loading @loading? + :on-click #(if (not form-valid?) + (reset! validate? true) + (do + (reset! show? false) + (dispatch + [::events/add-group {:parent-group parent-group + :group-identifier group-identifier + :group-name @group-name + :group-desc @group-desc + :loading? loading?}])))}]]])))) + (defn GroupMembers [group] (let [editable? (utils-general/editable? group false)] @@ -195,9 +263,8 @@ group-name [ui/HeaderSubheader description " (" id ")"]]] (when (utils-general/can-operation? "add-subgroup" group) - [ui/Button {:secondary true :size "small" :icon true} - [icons/PlusSquareIcon] - "Add Subgroup"])] + [AddGroupButton {:header "Add Subgroup" + :parent-group group}])] [ui/Header {:as :h3 :dividing true} "Members"] (if (empty? users) [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message @@ -242,72 +309,10 @@ ^{:key (:id child)} [Group child selected-group])])]])))) -(defn sanitize-name [name] - (when name - (str/lower-case - (str/replace - (str/trim - (str/join "" (re-seq #"[a-zA-Z0-9-_\ ]" name))) - " " "-")))) - -(defn AddGroupButton - [] - (let [tr (subscribe [::i18n-subs/tr]) - show? (r/atom false) - group-name (r/atom "") - group-desc (r/atom "") - validate? (r/atom false) - loading? (r/atom false) - close-fn #(reset! show? false)] - (fn [] - (let [group-identifier (sanitize-name @group-name) - form-valid? (and (s/valid? ::group-name @group-name) - (s/valid? ::group-description @group-desc))] - [ui/Modal - {:open @show? - :close-icon true - :on-close close-fn - :trigger (r/as-element - [ui/Button {:primary true - :size "small" - :icon true - :on-click #(reset! show? true)} - [icons/PlusSquareIcon] (@tr [:add-group])])} - [uix/ModalHeader {:header (@tr [:add-group])}] - [ui/ModalContent - [ui/Message {:hidden (not (and @validate? (not form-valid?))) - :error true} - [ui/MessageHeader (@tr [:validation-error])] - [ui/MessageContent (@tr [:validation-error-message])]] - (when-not (str/blank? group-identifier) - [:i {:style {:padding-left "1ch" - :color :grey}} - [:b "id : "] - (str "group/" group-identifier)]) - [ui/Table style/definition - [ui/TableBody - [uix/TableRowField (@tr [:name]), :required? true, :default-value @group-name, - :validate-form? @validate?, :spec ::group-name, - :on-change #(reset! group-name %)] - [uix/TableRowField (@tr [:description]), :required? true, - :spec ::group-description, :validate-form? @validate?, - :default-value @group-desc, :on-change #(reset! group-desc %)]]]] - [ui/ModalActions - [uix/Button - {:text (str/capitalize (@tr [:add])) - :primary true - :disabled (and @validate? (not form-valid?)) - :loading @loading? - :on-click #(if (not form-valid?) - (reset! validate? true) - (do - (reset! show? false) - (dispatch - [::events/add-group group-identifier @group-name @group-desc loading?])))}]]])))) - (defn GroupHierarchySegment [selected-group] - (let [groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] + (let [tr @(subscribe [::i18n-subs/tr]) + groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] [ui/Segment {:raised true :style {:overflow-x :auto :min-height "100%"}} @@ -317,7 +322,7 @@ :flex-wrap :wrap :padding-bottom "1em"}} [ui/Header {:as :h3} "Groups"] - [AddGroupButton]] + [AddGroupButton {:header (tr [:add-group])}]] [full-text-search-plugin/FullTextSearch {:db-path [::deployments-search] From 94f2bdae37fe9d76c600fadc1c928ff8c026fbb6 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Fri, 8 Aug 2025 15:11:03 +0200 Subject: [PATCH 12/20] feat(groups page): Pending invitations feat(groups page): Revoke invitation --- code/src/cljs/sixsq/nuvla/ui/db/spec.cljs | 2 + .../sixsq/nuvla/ui/pages/groups/events.cljs | 39 ++++++++-- .../sixsq/nuvla/ui/pages/groups/spec.cljs | 4 + .../sixsq/nuvla/ui/pages/groups/subs.cljs | 7 ++ .../sixsq/nuvla/ui/pages/groups/views.cljs | 76 ++++++++++++++----- 5 files changed, 102 insertions(+), 26 deletions(-) create mode 100644 code/src/cljs/sixsq/nuvla/ui/pages/groups/spec.cljs create mode 100644 code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs diff --git a/code/src/cljs/sixsq/nuvla/ui/db/spec.cljs b/code/src/cljs/sixsq/nuvla/ui/db/spec.cljs index 3cbde4c3b..c461b150d 100644 --- a/code/src/cljs/sixsq/nuvla/ui/db/spec.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/db/spec.cljs @@ -27,6 +27,7 @@ [sixsq.nuvla.ui.pages.edges-detail.spec :as edges-detail] [sixsq.nuvla.ui.pages.edges.spec :as edges] [sixsq.nuvla.ui.pages.profile.spec :as profile] + [sixsq.nuvla.ui.pages.groups.spec :as groups] [sixsq.nuvla.ui.routing.router :refer [router]] [sixsq.nuvla.ui.session.spec :as session])) @@ -69,4 +70,5 @@ resource-log/defaults deployment-sets/defaults deployment-sets-detail/defaults + groups/defaults {:router router})) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs index ef7000eb9..26d1e8e2d 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs @@ -6,6 +6,7 @@ [sixsq.nuvla.ui.config :as config] [sixsq.nuvla.ui.session.events :as session-events] [sixsq.nuvla.ui.session.spec :as session-spec] + [sixsq.nuvla.ui.pages.groups.spec :as spec] [sixsq.nuvla.ui.utils.response :as response])) (reg-event-fx @@ -59,13 +60,39 @@ status (str " (" status ")")) :content message :type :error}])) - on-success #(dispatch [::messages-events/add - {:header "Invitation successfully sent to user" - :content (str "User will appear in " group-id - " when he accept the invitation sent to his email address.") - :type :info}]) + on-success #(do (dispatch [::messages-events/add + {:header "Invitation successfully sent to user" + :content (str "User will appear in " group-id + " when he accept the invitation sent to his email address.") + :type :info}]) + (dispatch [::get-pending-invitations group-id])) data {:username username :redirect-url (str (::session-spec/server-redirect-uri db) "?message=join-group-accepted") :set-password-url (str @config/path-prefix "/set-password")}] - {::cimi-api-fx/operation [group-id "invite" on-success :on-error on-error :data data]}))) \ No newline at end of file + {::cimi-api-fx/operation [group-id "invite" on-success :on-error on-error :data data]}))) + +(reg-event-fx + ::get-pending-invitations + (fn [{db :db} [_ group-id]] + (let [on-success #(dispatch [::set-pending-invitations %])] + {:db (assoc db ::spec/pending-invitations nil) + ::cimi-api-fx/operation + [group-id "get-pending-invitations" on-success]}))) + +(reg-event-db + ::set-pending-invitations + (fn [db [_ pending-invitations]] + (assoc db ::spec/pending-invitations pending-invitations))) + +(reg-event-fx + ::revoke + (fn [_ [_ {group-id :id :as _group} invited-email]] + (let [on-success #(do (dispatch [::messages-events/add + {:header "Invitation successfully revoked" + :content (str "Invitation successfully revoked for " + invited-email " from " group-id ".") + :type :info}]) + (dispatch [::get-pending-invitations group-id]))] + {::cimi-api-fx/operation [group-id "revoke-invitation" on-success + :data {:email invited-email}]}))) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/spec.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/spec.cljs new file mode 100644 index 000000000..0c95111ad --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/spec.cljs @@ -0,0 +1,4 @@ +(ns sixsq.nuvla.ui.pages.groups.spec + (:require [clojure.spec.alpha :as s])) + +(def defaults {::pending-invitations nil}) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs new file mode 100644 index 000000000..473c1a56e --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs @@ -0,0 +1,7 @@ +(ns sixsq.nuvla.ui.pages.groups.subs + (:require [re-frame.core :refer [reg-sub]] + [sixsq.nuvla.ui.pages.groups.spec :as spec])) + +(reg-sub + ::pending-invitations + :-> ::spec/pending-invitations) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index b09741e3e..d4cd4e114 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -9,6 +9,7 @@ [sixsq.nuvla.ui.routing.routes :as routes] [sixsq.nuvla.ui.routing.events :as routing-events] [sixsq.nuvla.ui.session.subs :as session-subs] + [sixsq.nuvla.ui.pages.groups.subs :as subs] [sixsq.nuvla.ui.utils.forms :as forms] [sixsq.nuvla.ui.utils.general :as utils-general] [sixsq.nuvla.ui.utils.icons :as icons] @@ -104,6 +105,14 @@ :Content "Extend user view to member's list" :Icon [icons/Icon {:className "far fa-eye"}]}]) +(defn RevokeInvitationButton + [group invited-email] + (let [group-name (or (:name group) (:id group))] + [ConfirmActionModal {:on-confirm #(dispatch [::events/revoke group invited-email]) + :header "Revoke invitation" + :Content [:span "Do you want to revoke the invitation of " [:b invited-email] " from " [:b group-name] " group?"] + :Icon [icons/TrashIcon]}])) + (defn GroupMember [id group-name principal editable? {{:keys [owners manage view-data view-acl] :as acl} :acl :as group}] (let [tr (subscribe [::i18n-subs/tr]) @@ -158,25 +167,28 @@ (defn InviteInput [{:keys [id] :as _group}] (let [tr (subscribe [::i18n-subs/tr]) - invite-user (r/atom nil) + reset-key (r/atom (random-uuid)) + invite-user (r/atom "") invite-fn #(do (when-not (str/blank? @invite-user) (dispatch [::events/invite-to-group id @invite-user]) - (reset! invite-user nil)))] + (reset! reset-key (random-uuid)) + (reset! invite-user "")))] (fn [group] (when (utils-general/can-operation? "invite" group) - [ui/Input {:placeholder (@tr [:invite-by-email]) - :type :email - :icon (r/as-element - [icons/PaperPlaneIcon {:style {:font-size "unset"} - :link (not (str/blank? @invite-user)) - :color (when (not (str/blank? @invite-user)) "blue") - :circular true - :onClick invite-fn}]) - :style {:width "280px" :cursor :pointer} - :on-key-press (partial forms/on-return-key invite-fn) - :value (or @invite-user "") - :on-change (ui-callback/value #(reset! invite-user %))}])))) + ^{:key @reset-key} + [ui/Input {:placeholder (@tr [:invite-by-email]) + :type :email + :icon (r/as-element + [icons/PaperPlaneIcon {:style {:font-size "unset"} + :link (not (str/blank? @invite-user)) + :color (when (not (str/blank? @invite-user)) "blue") + :circular true + :onClick invite-fn}]) + :style {:width "280px" :cursor :pointer} + :on-key-press (partial forms/on-return-key invite-fn) + :default-value @invite-user + :on-change (ui-callback/value #(reset! invite-user %))}])))) (defn sanitize-name [name] (when name @@ -263,9 +275,9 @@ group-name [ui/HeaderSubheader description " (" id ")"]]] (when (utils-general/can-operation? "add-subgroup" group) - [AddGroupButton {:header "Add Subgroup" + [AddGroupButton {:header "Add Subgroup" :parent-group group}])] - [ui/Header {:as :h3 :dividing true} "Members"] + [ui/Header {:as :h3} "Members"] (if (empty? users) [uix/MsgNoItemsToShow [uix/TR (if editable? :empty-group-message :empty-group-or-no-access-message)]] @@ -274,9 +286,29 @@ (for [m users] ^{:key m} [GroupMember id group-name m editable? group])]) - [InviteInput group] + [InviteInput group]])))) - ])))) +(defn GroupPendingInvitations + [group] + (let [pending-invitations (subscribe [::subs/pending-invitations (:id group)])] + (dispatch [::events/get-pending-invitations (:id group) pending-invitations]) + (fn [group] + [:<> + [ui/Header {:as :h3} "Pending invitations"] + (if (empty? @pending-invitations) + [uix/MsgNoItemsToShow "No pending invitations"] + [ui/ListSA {:divided true :vertical-align "middle"} + (for [pi @pending-invitations] + ^{:key (:invited-email pi)} + [ui/ListItem + [ui/ListContent {:floated :right} + (when (utils-general/can-operation? "revoke-invitation" group) + [RevokeInvitationButton group (:invited-email pi)])] + [ui/ListContent {:style {:display :flex :align-items :flex-end}} + [ui/IconGroup + [ui/Icon {:className icons/i-user :size "large"}]] + (:invited-email pi)] + ])])]))) (defn Group [{:keys [id] :as _group} {:keys [parents] :as _selected-group}] @@ -311,7 +343,7 @@ (defn GroupHierarchySegment [selected-group] - (let [tr @(subscribe [::i18n-subs/tr]) + (let [tr @(subscribe [::i18n-subs/tr]) groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] [ui/Segment {:raised true :style {:overflow-x :auto :min-height "100%"}} @@ -356,5 +388,9 @@ :overflow-x :auto}} (if selected-group ^{:key selected-group} - [GroupMembers selected-group] + [:<> + [GroupMembers selected-group] + (when (utils-general/can-operation? "get-pending-invitations" selected-group) + ^{:key (:id selected-group)} + [GroupPendingInvitations selected-group])] [uix/MsgNoItemsToShow [uix/TR "Select a Group"]])]]])) From b5224d602cef88d6bf8d4149ae767f7bc3811947 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Wed, 13 Aug 2025 17:29:47 +0200 Subject: [PATCH 13/20] search implemented --- .../sixsq/nuvla/ui/pages/groups/subs.cljs | 61 ++++++++++++++++++- .../sixsq/nuvla/ui/pages/groups/views.cljs | 45 +++++++------- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs index 473c1a56e..0acd7a162 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/subs.cljs @@ -1,7 +1,64 @@ (ns sixsq.nuvla.ui.pages.groups.subs - (:require [re-frame.core :refer [reg-sub]] - [sixsq.nuvla.ui.pages.groups.spec :as spec])) + (:require [clojure.string :as str] + [re-frame.core :refer [reg-sub]] + [sixsq.nuvla.ui.pages.groups.spec :as spec] + [sixsq.nuvla.ui.session.subs :as session-subs] + [sixsq.nuvla.ui.utils.general :as general-utils])) (reg-sub ::pending-invitations :-> ::spec/pending-invitations) + +(reg-sub + ::indexed-groups + :<- [::session-subs/groups] + :<- [::session-subs/peers] + (fn [[groups peers]] + (map (fn [{:keys [id name users] :as _group}] + {:id id + :keywords (remove nil? + (concat + [id name] + users + (map #(get peers %) users)))}) + groups))) + + +(defn matching-group + [pattern {:keys [keywords]}] + (some #(re-matches pattern %) keywords)) + +(defn filter-groups + [allowed-ids group] + (let [children (:children group) + filtered-children (when children + (->> children + (map #(filter-groups allowed-ids %)) + (remove nil?)))] + ;; keep this group if its id is allowed OR it has any kept children + (when (or (allowed-ids (:id group)) + (seq filtered-children)) + (cond-> (assoc group :children filtered-children) + (empty? filtered-children) (dissoc :children))))) + +(defn filter-groups-tree + [allowed-ids groups] + (->> groups + (map #(filter-groups allowed-ids %)) + (remove nil?))) + + +(reg-sub + ::filter-groups-hierarchy + :<- [::indexed-groups] + :<- [::session-subs/groups-hierarchies] + (fn [[indexed-groups groups-hierarchies] [_ search]] + (let [groups-hierarchies (filter (fn [root-group] (not (#{"group/nuvla-user" "group/nuvla-nuvlabox" "group/nuvla-anon" "group/nuvla-vpn"} (:id root-group)))) groups-hierarchies)] + (if (str/blank? search) + groups-hierarchies + (let [pattern (re-pattern (str "(?i).*" (general-utils/regex-escape search) ".*")) + allowed-ids (->> indexed-groups + (keep #(when (matching-group pattern %) (:id %))) + set)] + (filter-groups-tree allowed-ids groups-hierarchies)) + )))) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index d4cd4e114..a33db9afa 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -4,13 +4,13 @@ [re-frame.core :refer [dispatch subscribe]] [reagent.core :as r] [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] - [sixsq.nuvla.ui.common-components.plugins.full-text-search :as full-text-search-plugin] [sixsq.nuvla.ui.pages.groups.events :as events] [sixsq.nuvla.ui.routing.routes :as routes] [sixsq.nuvla.ui.routing.events :as routing-events] [sixsq.nuvla.ui.session.subs :as session-subs] [sixsq.nuvla.ui.pages.groups.subs :as subs] [sixsq.nuvla.ui.utils.forms :as forms] + [sixsq.nuvla.ui.utils.general :as general-utils] [sixsq.nuvla.ui.utils.general :as utils-general] [sixsq.nuvla.ui.utils.icons :as icons] [sixsq.nuvla.ui.utils.semantic-ui :as ui] @@ -343,27 +343,30 @@ (defn GroupHierarchySegment [selected-group] - (let [tr @(subscribe [::i18n-subs/tr]) - groups-hierarchy @(subscribe [::session-subs/groups-hierarchies])] - [ui/Segment {:raised true :style {:overflow-x :auto - :min-height "100%"}} + (let [tr (subscribe [::i18n-subs/tr]) + search (r/atom "")] + (fn [] + (let [filtered-groups-hierarch @(subscribe [::subs/filter-groups-hierarchy @search])] + [ui/Segment {:raised true :style {:overflow-x :auto + :min-height "100%"}} - [:div {:style {:display :flex - :align-items :baseline - :justify-content :space-between - :flex-wrap :wrap - :padding-bottom "1em"}} - [ui/Header {:as :h3} "Groups"] - [AddGroupButton {:header (tr [:add-group])}]] - - [full-text-search-plugin/FullTextSearch - {:db-path [::deployments-search] - :change-event [:a] - :style {:width "100%"}}] - [ui/ListSA {:selection true} - (for [group-hierarchy (sort-by (juxt :id :name) groups-hierarchy)] - ^{:key (:id group-hierarchy)} - [Group group-hierarchy selected-group])]])) + [:div {:style {:display :flex + :align-items :baseline + :justify-content :space-between + :flex-wrap :wrap + :padding-bottom "1em"}} + [ui/Header {:as :h3} "Groups"] + [AddGroupButton {:header (@tr [:add-group])}]] + [ui/Input + {:style {:width "100%"} + :placeholder (str (@tr [:search]) "...") + :icon "search" + :default-value @search + :on-change (ui-callback/input-callback #(reset! search %))}] + [ui/ListSA {:selection true} + (for [group-hierarchy (sort-by (juxt :id :name) filtered-groups-hierarch)] + ^{:key (:id group-hierarchy)} + [Group group-hierarchy selected-group])]])))) (defn GroupsViewPage [{path :path}] From 0f107d111f45880d91e2ca9ae2fe26e73f87d540 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Thu, 14 Aug 2025 09:38:42 +0200 Subject: [PATCH 14/20] bugfix --- code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index a33db9afa..5ac1ec270 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -345,7 +345,7 @@ [selected-group] (let [tr (subscribe [::i18n-subs/tr]) search (r/atom "")] - (fn [] + (fn [selected-group] (let [filtered-groups-hierarch @(subscribe [::subs/filter-groups-hierarchy @search])] [ui/Segment {:raised true :style {:overflow-x :auto :min-height "100%"}} From df06c35e3c2c0ecb08db5b0fd1111d670fc72ff4 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Thu, 14 Aug 2025 09:42:52 +0200 Subject: [PATCH 15/20] add a message when no groups found --- code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index 5ac1ec270..18d2c0db4 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -363,10 +363,12 @@ :icon "search" :default-value @search :on-change (ui-callback/input-callback #(reset! search %))}] - [ui/ListSA {:selection true} - (for [group-hierarchy (sort-by (juxt :id :name) filtered-groups-hierarch)] - ^{:key (:id group-hierarchy)} - [Group group-hierarchy selected-group])]])))) + (if (seq filtered-groups-hierarch) + [ui/ListSA {:selection true} + (for [group-hierarchy (sort-by (juxt :id :name) filtered-groups-hierarch)] + ^{:key (:id group-hierarchy)} + [Group group-hierarchy selected-group])] + [uix/MsgNoItemsToShow [uix/TR "No groups found"]])])))) (defn GroupsViewPage [{path :path}] From 94782ea25a2f9494c8f9dbbebad587f0bbbe7da3 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Thu, 14 Aug 2025 11:22:11 +0200 Subject: [PATCH 16/20] hide error when user cannot get pending invitations --- code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs index 26d1e8e2d..13c0e30fd 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/events.cljs @@ -78,7 +78,7 @@ (let [on-success #(dispatch [::set-pending-invitations %])] {:db (assoc db ::spec/pending-invitations nil) ::cimi-api-fx/operation - [group-id "get-pending-invitations" on-success]}))) + [group-id "get-pending-invitations" on-success :on-error #()]}))) (reg-event-db ::set-pending-invitations From ec451642823b45a2ddd43ab65543df494f6f53e0 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Thu, 14 Aug 2025 12:07:54 +0200 Subject: [PATCH 17/20] feat(groups page): Make possible to update group name and description feat(groups page): Search/filter groups hierarchy by group name, group id, username --- .../sixsq/nuvla/ui/pages/groups/views.cljs | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index 18d2c0db4..d5069d015 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -258,6 +258,52 @@ :group-desc @group-desc :loading? loading?}])))}]]])))) +(defn EditGroupButton + [{:keys [name description] :as group}] + (let [tr (subscribe [::i18n-subs/tr]) + show? (r/atom false) + group-name (r/atom name) + group-desc (r/atom description) + validate? (r/atom false) + close-fn #(reset! show? false)] + (fn [_group] + (let [form-valid? (and (s/valid? ::group-name @group-name) + (s/valid? ::group-description @group-desc))] + [ui/Modal + {:open @show? + :close-icon true + :on-close close-fn + :trigger (r/as-element + [icons/PencilIcon {:on-click #(reset! show? true) + :style {:cursor :pointer}}])} + [uix/ModalHeader {:header "Edit group"}] + [ui/ModalContent + [ui/Message {:hidden (not (and @validate? (not form-valid?))) + :error true} + [ui/MessageHeader (@tr [:validation-error])] + [ui/MessageContent (@tr [:validation-error-message])]] + [ui/Table style/definition + [ui/TableBody + [uix/TableRowField (@tr [:name]), :required? true, :default-value @group-name, + :validate-form? @validate?, :spec ::group-name, + :on-change #(reset! group-name %)] + [uix/TableRowField (@tr [:description]), :required? true, + :spec ::group-description, :validate-form? @validate?, + :default-value @group-desc, :on-change #(reset! group-desc %)]]]] + [ui/ModalActions + [uix/Button + {:text (str/capitalize (@tr [:save])) + :primary true + :disabled (and @validate? (not form-valid?)) + :on-click #(if (not form-valid?) + (reset! validate? true) + (do + (reset! show? false) + (dispatch + [::events/edit-group (assoc group + :name @group-name + :description @group-desc)])))}]]])))) + (defn GroupMembers [group] (let [editable? (utils-general/editable? group false)] @@ -272,7 +318,8 @@ [ui/Header {:as :h3} [icons/UserGroupIcon] [ui/HeaderContent - group-name + group-name " " (when editable? + [EditGroupButton group]) [ui/HeaderSubheader description " (" id ")"]]] (when (utils-general/can-operation? "add-subgroup" group) [AddGroupButton {:header "Add Subgroup" @@ -365,9 +412,9 @@ :on-change (ui-callback/input-callback #(reset! search %))}] (if (seq filtered-groups-hierarch) [ui/ListSA {:selection true} - (for [group-hierarchy (sort-by (juxt :id :name) filtered-groups-hierarch)] - ^{:key (:id group-hierarchy)} - [Group group-hierarchy selected-group])] + (for [group-hierarchy (sort-by (juxt :id :name) filtered-groups-hierarch)] + ^{:key (:id group-hierarchy)} + [Group group-hierarchy selected-group])] [uix/MsgNoItemsToShow [uix/TR "No groups found"]])])))) (defn GroupsViewPage From 713d1be3a61f22919191fc0b153b184c08f74308 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Fri, 15 Aug 2025 11:59:34 +0200 Subject: [PATCH 18/20] feat(Callback page): Implement a callback layout and page to protect callbacks execution from email scanners --- code/src/cljs/sixsq/nuvla/ui/app/view.cljs | 20 +++++++++++++++++-- .../ui/common_components/i18n/dictionary.cljs | 2 ++ .../sixsq/nuvla/ui/pages/callback/views.cljs | 20 +++++++++++++++++++ .../cljs/sixsq/nuvla/ui/routing/router.cljs | 8 +++++++- .../cljs/sixsq/nuvla/ui/routing/routes.cljs | 1 + 5 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 code/src/cljs/sixsq/nuvla/ui/pages/callback/views.cljs diff --git a/code/src/cljs/sixsq/nuvla/ui/app/view.cljs b/code/src/cljs/sixsq/nuvla/ui/app/view.cljs index 5c7e0d259..304ec8781 100644 --- a/code/src/cljs/sixsq/nuvla/ui/app/view.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/app/view.cljs @@ -1,6 +1,7 @@ (ns sixsq.nuvla.ui.app.view (:require [re-frame.core :refer [dispatch subscribe]] [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] + [sixsq.nuvla.ui.common-components.i18n.views :as i18n-views] [sixsq.nuvla.ui.common-components.intercom.views :as intercom] [sixsq.nuvla.ui.main.components :as main-components] [sixsq.nuvla.ui.main.subs :as subs] @@ -36,8 +37,8 @@ [:<> (let [session @(subscribe [::session-subs/session])] (if session - [FollowRedirect] - [WatcherRedirectProtectedPage]))]) + [FollowRedirect] + [WatcherRedirectProtectedPage]))]) (defn RouterView [] (let [CurrentView @(subscribe [::route-subs/current-view]) @@ -77,6 +78,21 @@ [main-views/SubscriptionRequiredModal] [main-views/Footer]]]]]) +(defn LayoutCallback [] + [:div {:class "login-left"} + [:div {:style {:background-color "#C10E12"}} + [ui/Image {:alt "logo" + :src "/ui/images/nuvla-logo.png" + :size "small" + :centered true}]] + [:div {:style {:float "right"}} [i18n-views/LocaleDropdown]] + [:div {:style {:margin-top "10%" + :padding "1em" + :background-color "rgba(0,0,0,0.5)" + :box-shadow "0px 0px 50px black" + }} + [RouterView]]]) + (defn Loader [] (let [tr (subscribe [::i18n-subs/tr]) error? (subscribe [::api-subs/cloud-entry-point-error?])] diff --git a/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs b/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs index 1cfc2e86e..4a526f9e0 100644 --- a/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs @@ -3,6 +3,7 @@ (def dictionary {:en { + :please-click-to-proceed "Please click here to proceed" :stop-deployment-remove-volumes "Remove volumes" :stop-deployment-remove-images "Remove images" :stop-deployment-remove-opts-require-ne-2.19 "The selected options require NuvlaEdge version 2.19 or higher to be taken into account." @@ -1207,6 +1208,7 @@ } :fr { + :please-click-to-proceed "Veuillez cliquer ici pour continuer" :stop-deployment-remove-volumes "Supprimer les volumes" :stop-deployment-remove-images "Supprimer les images" :stop-deployment-remove-opts-require-ne-2.19 "Les options sélectionnées nécessitent la version 2.19 ou supérieure de NuvlaEdge pour être prises en compte." diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/callback/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/callback/views.cljs new file mode 100644 index 000000000..2bef03f9c --- /dev/null +++ b/code/src/cljs/sixsq/nuvla/ui/pages/callback/views.cljs @@ -0,0 +1,20 @@ +(ns sixsq.nuvla.ui.pages.callback.views + (:require [clojure.string :as str] + [re-frame.core :refer [dispatch subscribe]] + [sixsq.nuvla.ui.common-components.i18n.subs :as i18n-subs] + [sixsq.nuvla.ui.main.components :as main-components] + [sixsq.nuvla.ui.main.subs :as main-subs] + [sixsq.nuvla.ui.utils.semantic-ui :as ui] + [sixsq.nuvla.ui.routing.subs :as route-subs] + [sixsq.nuvla.ui.utils.semantic-ui-extensions :as uix])) + +(defn CallbackView + [{{:keys [callback-url]} :query-params :as f}] + (let [tr @(subscribe [::i18n-subs/tr])] + [:div {:style {:display :flex + :justify-content :center}} + [ui/Button {:size "large" + :primary true + :href callback-url + :data-reitit-handle-click "false"} + [:b (tr [:please-click-to-proceed])]]])) diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs index 1ca492539..141d845df 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/router.cljs @@ -6,7 +6,8 @@ [reitit.frontend.easy :as rfe] [reitit.frontend.history :as rfh] [sixsq.nuvla.ui.app.view :refer [LayoutAuthentication - LayoutPage]] + LayoutPage + LayoutCallback]] [sixsq.nuvla.ui.common-components.notifications.views :refer [notifications-view]] [sixsq.nuvla.ui.config :refer [base-path]] [sixsq.nuvla.ui.pages.about.views :refer [About]] @@ -32,6 +33,7 @@ [sixsq.nuvla.ui.session.set-password-views :as set-password-views] [sixsq.nuvla.ui.session.sign-in-views :as sign-in-views] [sixsq.nuvla.ui.session.sign-up-views :as sign-up-views] + [sixsq.nuvla.ui.pages.callback.views :refer [CallbackView]] [sixsq.nuvla.ui.unknown-resource :refer [UnknownResource]])) (defn- create-route-name @@ -151,6 +153,10 @@ :layout #'LayoutPage :view #'About :link-text "About"}] + ["callback" + {:name ::routes/callback + :layout #'LayoutCallback + :view #'CallbackView}] ["welcome" {:name ::routes/home :link-text "home"}] diff --git a/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs b/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs index 31470d86b..62c08d7b2 100644 --- a/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/routing/routes.cljs @@ -50,4 +50,5 @@ (def api-slashed ::api-slashed) (def api-sub-page ::api-sub-page) (def profile ::profile) +(def callback ::callback) (def catch-all ::catch-all) From 79085d520e3b89781973c9bf7bc29f06e4eb1c46 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Wed, 20 Aug 2025 11:56:07 +0200 Subject: [PATCH 19/20] fix(UIX): TableRowCell is directly in error when value doesn't match the spec even if validate-form? is false fix(Group page): Align spec and validation with group name of the server --- .../ui/common_components/i18n/dictionary.cljs | 4 +++ .../sixsq/nuvla/ui/pages/groups/views.cljs | 16 +++++++--- .../ui/utils/semantic_ui_extensions.cljs | 31 +++++++++---------- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs b/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs index 62cf494d7..7aef679ff 100644 --- a/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/common_components/i18n/dictionary.cljs @@ -3,6 +3,8 @@ (def dictionary {:en { + :group-name-validation-error "Group name should start with an alphanumeric character. Space, dash and underscore characters are allowed but not at the end of the group name." + :group-descr-validation-error "Description should not be an empty string." :stop-deployment-remove-volumes "Remove volumes" :stop-deployment-remove-images "Remove images" :stop-deployment-remove-opts-require-ne-2.19 "The selected options require NuvlaEdge version 2.19 or higher to be taken into account." @@ -1207,6 +1209,8 @@ } :fr { + :group-name-validation-error "Le nom du groupe doit commencer par un caractère alphanumérique. Les espaces, les tirets et les traits de soulignement sont autorisés, mais pas à la fin du nom de groupe." + :group-descr-validation-error "La description ne doit pas être une chaîne de caractères vide." :stop-deployment-remove-volumes "Supprimer les volumes" :stop-deployment-remove-images "Supprimer les images" :stop-deployment-remove-opts-require-ne-2.19 "Les options sélectionnées nécessitent la version 2.19 ou supérieure de NuvlaEdge pour être prises en compte." diff --git a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs index d5069d015..eacb3a009 100644 --- a/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/pages/groups/views.cljs @@ -10,7 +10,6 @@ [sixsq.nuvla.ui.session.subs :as session-subs] [sixsq.nuvla.ui.pages.groups.subs :as subs] [sixsq.nuvla.ui.utils.forms :as forms] - [sixsq.nuvla.ui.utils.general :as general-utils] [sixsq.nuvla.ui.utils.general :as utils-general] [sixsq.nuvla.ui.utils.icons :as icons] [sixsq.nuvla.ui.utils.semantic-ui :as ui] @@ -19,7 +18,12 @@ [sixsq.nuvla.ui.utils.style :as style] [sixsq.nuvla.ui.utils.ui-callback :as ui-callback])) -(s/def ::group-name us/nonblank-string) +(defn acceptable-group-name? + [group-name] + (and (string? group-name) + (re-matches #"^([a-zA-Z0-9]([a-zA-Z0-9\s_-]*[a-zA-Z0-9])?)?$" group-name))) + +(s/def ::group-name acceptable-group-name?) (s/def ::group-description us/nonblank-string) (defn ConfirmActionModal @@ -227,7 +231,9 @@ [ui/Message {:hidden (not (and @validate? (not form-valid?))) :error true} [ui/MessageHeader (@tr [:validation-error])] - [ui/MessageContent (@tr [:validation-error-message])]] + [ui/MessageContent + (str/join " " [(when-not (s/valid? ::group-name @group-name) (@tr [:group-name-validation-error])) + (when-not (s/valid? ::group-description @group-desc) (@tr [:group-descr-validation-error]))])]] (when-not (str/blank? group-identifier) [:i {:style {:padding-left "1ch" :color :grey}} @@ -281,7 +287,9 @@ [ui/Message {:hidden (not (and @validate? (not form-valid?))) :error true} [ui/MessageHeader (@tr [:validation-error])] - [ui/MessageContent (@tr [:validation-error-message])]] + [ui/MessageContent + (str/join " " [(when-not (s/valid? ::group-name @group-name) (@tr [:group-name-validation-error])) + (when-not (s/valid? ::group-description @group-desc) (@tr [:group-descr-validation-error]))])]] [ui/Table style/definition [ui/TableBody [uix/TableRowField (@tr [:name]), :required? true, :default-value @group-name, diff --git a/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui_extensions.cljs b/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui_extensions.cljs index 97922c424..e455b5778 100644 --- a/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui_extensions.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/utils/semantic_ui_extensions.cljs @@ -371,7 +371,9 @@ editable? validate-form? type input-help-msg style options input-extra-options] :or {editable? true, spec any?, type :input show-pencil? true}}] - (let [validate? (boolean (or @local-validate? validate-form?)) + (let [validate? (if (some? validate-form?) + validate-form? + @local-validate?) error? (and validate? (not (s/valid? spec default-value))) input-cbk (ui-callback/input-callback #(let [text (when-not (str/blank? %) %)] @@ -447,22 +449,17 @@ (defn TableRowField [_name & {:keys [_key _placeholder _default-value _spec _on-change _on-validation _style _required? _editable? _validate-form? _type _help-popup]}] - (let [local-validate? (r/atom false)] - (fn [name & {:keys [key _placeholder default-value spec _on-change on-validation - required? editable? validate-form? _type help-popup _style] - :or {editable? true, spec any?} - :as options}] - (let [validate? (boolean (or @local-validate? validate-form?)) - error? (and validate? (not (s/valid? spec default-value)))] - (when on-validation - (dispatch [on-validation key error?])) - [ui/TableRow - [ui/TableCell {:collapsing true} - [FieldLabel {:name name - :required? (and editable? required?) - :help-popup help-popup}]] - ^{:key (or key name)} - [TableRowCell options]])))) + (fn [name & {:keys [key _placeholder _default-value spec _on-change _on-validation + required? editable? _validate-form? _type help-popup _style] + :or {editable? true, spec any?} + :as options}] + [ui/TableRow + [ui/TableCell {:collapsing true} + [FieldLabel {:name name + :required? (and editable? required?) + :help-popup help-popup}]] + ^{:key (or key name)} + [TableRowCell options]])) (defn LinkIcon [{:keys [name on-click color class aria-label]}] From dc4037cbd0e46323975588527e55b86503efffc4 Mon Sep 17 00:00:00 2001 From: khaled basbous Date: Thu, 21 Aug 2025 11:19:48 +0200 Subject: [PATCH 20/20] callback page put i18n dropdown in top bar --- code/src/cljs/sixsq/nuvla/ui/app/view.cljs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/code/src/cljs/sixsq/nuvla/ui/app/view.cljs b/code/src/cljs/sixsq/nuvla/ui/app/view.cljs index 304ec8781..2acfd7e1a 100644 --- a/code/src/cljs/sixsq/nuvla/ui/app/view.cljs +++ b/code/src/cljs/sixsq/nuvla/ui/app/view.cljs @@ -81,13 +81,15 @@ (defn LayoutCallback [] [:div {:class "login-left"} [:div {:style {:background-color "#C10E12"}} + [:div {:style {:position :absolute + :right 10 + :padding 10}} [i18n-views/LocaleDropdown]] [ui/Image {:alt "logo" :src "/ui/images/nuvla-logo.png" :size "small" :centered true}]] - [:div {:style {:float "right"}} [i18n-views/LocaleDropdown]] - [:div {:style {:margin-top "10%" - :padding "1em" + [:div {:style {:margin-top "10%" + :padding "1em" :background-color "rgba(0,0,0,0.5)" :box-shadow "0px 0px 50px black" }}