Add user groups table [SCI-11955]

This commit is contained in:
Anton 2025-06-17 11:46:58 +02:00
parent 1184767a62
commit 935b1f5cd8
18 changed files with 722 additions and 14 deletions

View file

@ -17,6 +17,10 @@
.fixed-content-body {
height: calc(100vh - var(--content-header-size) - var(--navbar-height));
width: 100%;
&.user-groups-table-container {
height: calc(100vh - var(--content-header-size) - var(--navbar-height) - 72px);
}
}
.content-header {

View file

@ -11,7 +11,21 @@ module Users
def show; end
def create; end
def create
ActiveRecord::Base.transaction do
new_users = @team.users.where(id: params[:user_ids])
new_users.each do |user|
@user_group.user_group_memberships.create!(user: user, created_by: current_user)
end
render json: { message: :success }, status: :created
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error e.message
head :unprocessable_entity
raise ActiveRecord::Rollback
end
end
def destroy; end
@ -25,12 +39,12 @@ module Users
@user_group = @team.user_groups.find(params[:user_group_id])
end
def load_user_group
def load_user_group_membership
@user_group_membership = @user_group.user_group_memberships.find(params[:id])
end
def check_manage_permissions
render_403 unless can_manage_team(@team)
render_403 unless can_manage_team?(@team)
end
end
end

View file

@ -4,22 +4,75 @@ module Users
module Settings
class UserGroupsController < ApplicationController
before_action :load_team
before_action :load_user_group, except: :index
before_action :check_manage_permissions, except: %i(index show)
before_action :set_breadcrumbs_items
before_action :load_user_group, except: %i(index unassigned_users actions_toolbar create)
before_action :check_manage_permissions, except: %i(index show unassigned_users actions_toolbar)
before_action :set_breadcrumbs_items, only: %i(index show)
def index
@active_tab = :user_groups
respond_to do |format|
format.html do
@active_tab = :user_groups
end
format.json do
user_groups = Lists::UserGroupsService.new(@team.user_groups, params).call
render json: user_groups, each_serializer: Lists::UserGroupSerializer, user: current_user, meta: pagination_dict(user_groups)
end
end
end
def actions_toolbar
render json: {
actions:
Toolbars::UserGroupsService.new(
current_user,
@team,
user_group_ids: JSON.parse(params[:items]).pluck('id')
).actions
}
end
def unassigned_users
@unassigned_users = @team.users.search(false, params[:query])
if params[:user_group_id].present?
@user_group = @team.user_groups.find(params[:user_group_id])
@unassigned_users = @unassigned_users.where.not(id: @user_group.users.select(:id))
end
end
def show; end
def create; end
def create
@user_group = @team.user_groups.new
@user_group.created_by = current_user
@user_group.last_modified_by = current_user
@user_group.assign_attributes(user_group_params)
def destroy; end
if @user_group.save
render json: { message: t('user_groups.create.success') }, status: :created
else
render json: { errors: t('user_groups.create.error') }, status: :unprocessable_entity
end
end
def update; end
def destroy
if @user_group.destroy
render json: { message: t('user_groups.delete.success') }, status: :ok
else
render json: { errors: t('user_groups.delete.error') }, status: :unprocessable_entity
end
end
private
def user_group_params
params.require(:user_group).permit(
:name,
user_group_memberships_attributes: %i(id user_id)
)
end
def load_team
@team = Team.find(params[:team_id])
end
@ -29,7 +82,7 @@ module Users
end
def check_manage_permissions
render_403 unless can_manage_team(@team)
render_403 unless can_manage_team?(@team)
end
def set_breadcrumbs_items

View file

@ -0,0 +1,10 @@
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import UserGroupsTable from '../../vue/user_groups/index.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp();
app.component('UserGroupsTable', UserGroupsTable);
app.config.globalProperties.i18n = window.I18n;
app.use(PerfectScrollbar);
mountWithTurbolinks(app, '#userGroupsTable');

View file

@ -0,0 +1,140 @@
<template>
<div class="h-full">
<DataTable :columnDefs="columnDefs"
:tableId="'UserGroups'"
:dataUrl="dataSource"
:reloadingTable="reloadingTable"
:toolbarActions="toolbarActions"
:actionsUrl="actionsUrl"
@tableReloaded="reloadingTable = false"
@assignUsers="assignUsers"
@create="newGroup = true"
@delete="deleteGroup"
/>
</div>
<CreateModal v-if="newGroup" :createUrl="createUrl" :usersUrl="usersUrl"
@close="newGroup = false" @create="reloadingTable = true; newGroup = false" />
<DeleteModal
:title="i18n.t('user_groups.index.delete_modal.title')"
:description="deleteDescription"
confirmClass="btn btn-danger"
:confirmText="i18n.t('user_groups.index.delete_modal.confirm')"
ref="deleteModal"
></DeleteModal>
</template>
<script>
/* global HelperModule */
import axios from '../../packs/custom_axios.js';
import DataTable from '../shared/datatable/table.vue';
import NameRenderer from './renderers/name.vue';
import MembersRenderer from './renderers/members.vue';
import CreateModal from './modal/create_group.vue';
import DeleteModal from '../shared/confirmation_modal.vue';
export default {
name: 'UserGroupsTable',
components: {
DataTable,
NameRenderer,
MembersRenderer,
CreateModal,
DeleteModal
},
props: {
dataSource: {
type: String,
required: true
},
actionsUrl: {
type: String,
required: true
},
createUrl: {
type: String
},
usersUrl: {
type: String
},
},
data() {
return {
reloadingTable: false,
newGroup: false,
deleteDescription: '',
columnDefs: [
{
field: 'name',
headerName: this.i18n.t('user_groups.index.group_name'),
sortable: true,
cellRenderer: 'NameRenderer'
}, {
field: 'members',
headerName: this.i18n.t('user_groups.index.members'),
sortable: true,
cellRenderer: 'MembersRenderer',
minWidth: 210,
notSelectable: true
}, {
field: 'created_by',
headerName: this.i18n.t('user_groups.index.created_by'),
sortable: true
}, {
field: 'created_at',
headerName: this.i18n.t('user_groups.index.created_on'),
sortable: true
}, {
field: 'updated_at',
headerName: this.i18n.t('user_groups.index.updated_on'),
sortable: true,
}
]
};
},
computed: {
toolbarActions() {
const left = [];
if (this.createUrl) {
left.push({
name: 'create',
icon: 'sn-icon sn-icon-new-task',
label: this.i18n.t('user_groups.index.new_group'),
type: 'emit',
path: this.createUrl,
buttonStyle: 'btn btn-primary'
});
}
return {
left,
right: []
};
}
},
methods: {
assignUsers(params, selectedUsers) {
axios.post(params.data.urls.assign_users, { user_ids: selectedUsers })
.then(() => {
this.reloadingTable = true;
})
},
async deleteGroup(event, rows) {
const sanitizedName = rows[0].name.replace(/<\/?[^>]+(>|$)/g, "")
const description = this.i18n.t('user_groups.index.delete_modal.description_html', { group: sanitizedName });
this.deleteDescription = description;
const ok = await this.$refs.deleteModal.show();
if (ok) {
axios.delete(event.path).then((response) => {
this.reloadingTable = true;
HelperModule.flashAlertMsg(response.data.message, 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
},
}
};
</script>

View file

@ -0,0 +1,137 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<form @submit.prevent="submit">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block">
{{ i18n.t('user_groups.index.create_modal.title') }}
</h4>
</div>
<div class="modal-body">
<p class="mb-4">
{{ i18n.t('user_groups.index.create_modal.description') }}
</p>
<div class="mb-6">
<label class="sci-label">{{ i18n.t('user_groups.index.create_modal.name') }}</label>
<div class="sci-input-container-v2">
<input type="text" v-model="name" class="sci-input-field"
autofocus="true" ref="input"
:placeholder="i18n.t('user_groups.index.create_modal.name_placeholder')"
/>
</div>
</div>
<div class="mt-6">
<label class="sci-label">{{ i18n.t('user_groups.index.create_modal.select_members') }}</label>
<SelectDropdown
:optionsUrl="usersUrl"
@change="changeUsers"
:withCheckboxes="true"
:option-renderer="usersRenderer"
:label-renderer="usersRenderer"
:multiple="true"
:placeholder="i18n.t('user_groups.index.create_modal.select_members_placeholder')"
/>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-dismiss="modal"
>
{{ i18n.t('general.cancel') }}
</button>
<button
class="btn btn-primary"
type="submit"
:disabled="submitting || !validName"
>
{{ i18n.t('user_groups.index.create_modal.create_button') }}
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
import SelectDropdown from '../../shared/select_dropdown.vue';
import axios from '../../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin';
export default {
name: 'ProjectFormModal',
props: {
usersUrl: String,
createUrl: String
},
mixins: [modalMixin],
components: {
SelectDropdown,
},
computed: {
validName() {
return this.name.length >= GLOBAL_CONSTANTS.NAME_MIN_LENGTH;
},
modalHeader() {
if (this.createUrl) {
return this.i18n.t('projects.index.modal_new_project.modal_title');
}
return this.i18n.t('projects.index.modal_edit_project.modal_title', { project: this.project?.name });
},
submitButtonLabel() {
if (this.createUrl) {
return this.i18n.t('projects.index.modal_new_project.create');
}
return this.i18n.t('projects.index.modal_edit_project.submit');
}
},
data() {
return {
name: this.project?.name || '',
users: [],
submitting: false
};
},
methods: {
changeUsers(selectedUsers) {
this.users = selectedUsers;
},
async submit() {
this.submitting = true;
const userGroupData = {
name: this.name,
user_group_memberships_attributes: this.users.map((user) => ({
user_id: user
}))
};
await axios.post(this.createUrl, {
user_group: userGroupData
}).then((data) => {
HelperModule.flashAlertMsg(data.data.message, 'success');
this.$emit('create');
}).catch((data) => {
console.log(data);
HelperModule.flashAlertMsg(data.response.data.error, 'danger');
});
this.submitting = false;
},
usersRenderer(user) {
return `<div class="flex items-center gap-2 truncate">
<img class="w-6 h-6 rounded-full" src="${user[2].avatar}">
<span title="${user[1]}" class="truncate">${user[1]}</span>
</div>`;
}
}
};
</script>

View file

@ -0,0 +1,128 @@
<template>
<div>
<div v-if="params.data.team_users_count > users.length && params.data.permissions.manage">
<GeneralDropdown @open="loadUsers" @close="closeFlyout" position="right">
<template v-slot:field>
<div class="flex items-center gap-1 cursor-pointer h-9">
<div v-for="(user, i) in visibleUsers" :key="i" :title="user.full_name">
<img :src="user.avatar" class="w-7 h-7 rounded-full" />
</div>
<div v-if="hiddenUsers.length > 0" :title="hiddenUsersTitle"
class="flex shrink-0 items-center justify-center w-7 h-7 text-xs rounded-full bg-sn-dark-grey text-sn-white">
+{{ hiddenUsers.length }}
</div>
<div class="flex items-center shrink-0 justify-center w-7 h-7 rounded-full border-dashed bg-sn-white text-sn-sleepy-grey border-sn-sleepy-grey">
<i class="sn-icon sn-icon-new-task"></i>
</div>
</div>
</template>
<template v-slot:flyout>
<div class="px-2">
<div class="sci-input-container-v2 left-icon mb-1 -mx-2.5">
<input type="text"
v-model="query"
class="sci-input-field"
autofocus="true"
:placeholder="i18n.t('general.search')" />
<i class="sn-icon sn-icon-search"></i>
</div>
</div>
<div class="flex flex-col relative max-h-80 overflow-y-auto max-w-[280px] pt-1 -mx-2.5 px-2 gap-y-px">
<div v-for="user in unassginedUsers"
:key="user.value"
@click="selectUser(user)"
:class="{
'!bg-sn-super-light-blue': selectedUsers.some(({ value }) => value === user[0])
}"
class="whitespace-nowrap rounded py-2.5 flex items-center gap-2
hover:no-underline leading-5 cursor-pointer hover:bg-sn-super-light-grey px-3"
>
<div class="sci-checkbox-container">
<input type="checkbox" class="sci-checkbox" :checked="selectedUsers.some(({ value }) => value === user[0])" />
<label class="sci-checkbox-label"></label>
</div>
<img :src="user[2].avatar" class="w-6 h-6 rounded-full" />
<span class="truncate">{{ user[1] }}</span>
</div>
</div>
</template>
</GeneralDropdown>
</div>
<div v-else>
<div class="flex items-center gap-1 cursor-pointer h-9">
<div v-for="(user, i) in visibleUsers" :key="i" :title="user.full_name">
<img :src="user.avatar" class="w-7 h-7 rounded-full" />
</div>
<div v-if="hiddenUsers.length > 0" :title="hiddenUsersTitle"
class="flex shrink-0 items-center justify-center w-7 h-7 text-xs rounded-full bg-sn-dark-grey text-sn-white">
+{{ hiddenUsers.length }}
</div>
</div>
</div>
</div>
</template>
<script>
import GeneralDropdown from '../../shared/general_dropdown.vue';
import axios from '../../../packs/custom_axios.js';
export default {
name: 'MembersRenderer',
props: {
params: {
required: true
}
},
components: {
GeneralDropdown
},
data() {
return {
query: '',
unassginedUsers: [],
selectedUsers: []
};
},
watch: {
query() {
this.loadUsers();
}
},
computed: {
users() {
return this.params.value || [];
},
visibleUsers() {
return this.users.slice(0, 4);
},
hiddenUsers() {
return this.users.slice(4);
},
hiddenUsersTitle() {
return this.hiddenUsers.map((user) => user.full_name).join('\u000d');
}
},
methods: {
selectUser(user) {
const index = this.selectedUsers.findIndex(({ value }) => value === user[0]);
if (index > -1) {
this.selectedUsers.splice(index, 1);
} else {
this.selectedUsers.push(user[0]);
}
},
loadUsers() {
axios.get(this.params.data.urls.unassigned_users, { params: { query: this.query } })
.then((response) => {
this.unassginedUsers = response.data.data
})
},
closeFlyout() {
if (this.selectedUsers.length > 0) {
this.params.dtComponent.$emit('assignUsers', this.params, this.selectedUsers);
}
}
}
};
</script>

View file

@ -0,0 +1,16 @@
<template>
<a :href="params.data.urls.show" :title="params.data.name">
{{ params.data.name }}
</a>
</template>
<script>
export default {
props: {
params: {
type: Object,
required: true
}
}
};
</script>

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class UserGroup < ApplicationRecord
include SearchableModel
validates :name,
presence: true,
length: { minimum: Constants::NAME_MIN_LENGTH,
@ -12,4 +14,13 @@ class UserGroup < ApplicationRecord
belongs_to :last_modified_by, class_name: 'User', optional: true
has_many :user_group_memberships, dependent: :destroy
has_many :users, through: :user_group_memberships, dependent: :destroy
accepts_nested_attributes_for :user_group_memberships
def user_group_memberships_attributes=(attributes)
attributes.each do |membership|
membership[:created_by_id] = last_modified_by_id
user_group_memberships.build(membership)
end
end
end

View file

@ -4,7 +4,6 @@ class UserGroupMembership < ApplicationRecord
belongs_to :user_group
belongs_to :user
belongs_to :created_by, class_name: 'User'
belongs_to :last_modified_by, class_name: 'User'
validates :user, uniqueness: { scope: :user_group }
end

View file

@ -0,0 +1,50 @@
# frozen_string_literal: true
module Lists
class UserGroupSerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
include Canaid::Helpers::PermissionsHelper
attributes :id, :name, :members, :created_by, :created_at, :updated_at, :urls, :team_users_count, :permissions
def members
object.users.map do |u|
{
id: u.id,
avatar: avatar_path(u, :icon_small),
full_name: u.name
}
end
end
def team_users_count
object.team.users.size
end
def created_by
object.created_by.name
end
def created_at
I18n.l(object.created_at, format: :full_date)
end
def updated_at
I18n.l(object.updated_at, format: :full_date)
end
def permissions
{
manage: can_manage_team?(object.team)
}
end
def urls
{
show: users_settings_team_user_group_path(object.team, object),
unassigned_users: unassigned_users_users_settings_team_user_groups_path(object.team, user_group_id: object),
assign_users: users_settings_team_user_group_user_group_memberships_path(object.team, object)
}
end
end
end

View file

@ -0,0 +1,50 @@
# frozen_string_literal: true
module Lists
class UserGroupsService < BaseService
private
def fetch_records
@records = @raw_data.joins(
'LEFT OUTER JOIN users AS creators ' \
'ON user_groups.created_by_id = creators.id'
).left_joins(:user_group_memberships).includes(:users)
.select('user_groups.* as user_groups')
.select('creators.full_name AS created_by_user')
.select('COUNT(user_groups.id) AS members_count')
.group('user_groups.id, creators.full_name')
end
def filter_records
return unless @params[:search]
@records = @records.where_attributes_like(
['user_groups.name'],
@params[:search]
)
end
def sort_records
return unless @params[:order]
sorted_column = sortable_columns[order_params[:column].to_sym]
if sorted_column == 'user_groups.members'
sort_by = "members_count #{sort_direction(order_params)}"
@records = @records.order(Arel.sql(sort_by))
else
super
end
end
def sortable_columns
@sortable_columns ||= {
name: 'user_groups.name',
members: 'user_groups.members',
updated_at: 'user_groups.updated_at',
created_by: 'created_by_user',
created_at: 'user_groups.created_at'
}
end
end
end

View file

@ -0,0 +1,42 @@
# frozen_string_literal: true
module Toolbars
class UserGroupsService
attr_reader :current_user
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
def initialize(current_user, team, user_group_ids: [])
@current_user = current_user
@team = team
@user_groups = team.user_groups.where(id: user_group_ids)
@single = @user_groups.length == 1
end
def actions
return [] if @user_groups.none?
[
delete_action
].compact
end
private
def delete_action
return unless @single
return unless can_manage_team?(@team)
{
name: 'delete',
label: I18n.t('user_groups.index.toolbar.delete'),
icon: 'sn-icon sn-icon-delete',
path: users_settings_team_user_group_path(@team, @user_groups.first),
type: :emit
}
end
end
end

View file

@ -7,4 +7,13 @@
<div class="content-pane flexible with-grey-background">
<%= render partial: 'users/settings/teams/header' %>
<div id="userGroupsTable" class="fixed-content-body user-groups-table-container">
<user-groups-table
actions-url="<%= actions_toolbar_users_settings_team_user_groups_path(@team) %>"
data-source="<%= users_settings_team_user_groups_path(@team, format: :json) %>"
users-url="<%= unassigned_users_users_settings_team_user_groups_path(@team) %>"
create-url="<%= users_settings_team_user_groups_path(@team) if can_manage_team?(@team) %>"
/>
</div>
</div>
<%= javascript_include_tag 'vue_user_groups_table' %>

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
json.data do
json.array! @unassigned_users do |user|
json.array! [
user.id,
user.name,
{
avatar: avatar_path(user, :icon_small)
}
]
end
end

View file

@ -4189,7 +4189,34 @@ en:
edit:
head_title: "Edit protocol"
no_keywords: "—"
user_groups:
create:
success: "Group created successfully."
error: "There was a problem creating group. Please try again."
delete:
success: "Group deleted successfully."
error: "There was a problem deleting group. Please try again."
index:
new_group: "New group"
group_name: "Group name"
members: "Members"
created_by: "Created by"
created_on: "Created on"
updated_on: "Updated on"
create_modal:
title: "Create a new group"
description: "You can add as many members from this workspace as you wish."
name: "Group name"
name_placeholder: "Add a name for your group"
select_members: "Select members"
select_members_placeholder: "Select for members"
create_button: "Create group"
delete_modal:
title: "Delete group"
description_html: "You are about to delete <b>%{group}</b>. All members added to this group will lose their access to all related content in this workspace.<br><br>Access added to members outside of this group will not be affected.<br><br>This action cannot be undone. <b>Are you sure you want to delete this group?</b>"
confirm: "Delete group"
toolbar:
delete: "Delete"
invite_users:
to_team:
title: "Invite members to %{team}"

View file

@ -149,8 +149,12 @@ Rails.application.routes.draw do
resource :user_settings, only: %i(show update)
resources :teams, only: [] do
resources :user_groups, only: %i(index create update destroy) do
resources :user_groups, only: %i(index create update destroy show) do
resources :user_group_memberships, only: %i(index create update destroy)
collection do
get :unassigned_users
post :actions_toolbar
end
end
member do

View file

@ -74,7 +74,8 @@ const entryList = {
vue_design_system_inputs: './app/javascript/packs/vue/design_system/inputs.js',
vue_design_system_table: './app/javascript/packs/vue/design_system/table.js',
vue_favorites_widget: './app/javascript/packs/vue/favorites_widget.js',
vue_experiment_description_modal: './app/javascript/packs/vue/experiment_description_modal.js'
vue_experiment_description_modal: './app/javascript/packs/vue/experiment_description_modal.js',
vue_user_groups_table: './app/javascript/packs/vue/user_groups_table.js'
};
// Engine pack loading based on https://github.com/rails/webpacker/issues/348#issuecomment-635480949