Migrate access modal to VUE [SCI-9799]

This commit is contained in:
Anton 2023-12-04 20:59:16 +01:00
parent 5acec04794
commit 687bac024a
19 changed files with 763 additions and 282 deletions

View file

@ -9,17 +9,59 @@ module AccessPermissions
before_action :check_manage_permissions, except: %i(show)
before_action :available_users, only: %i(new create)
def new
@user_assignment = @project.user_assignments.new(
assigned_by: current_user,
team: current_team
)
def show
render json: @project.user_assignments.includes(:user_role, :user).order('users.full_name ASC'),
each_serializer: UserAssignmentSerializer
end
def show; end
def new
render json: @available_users, each_serializer: UserSerializer
end
def edit; end
def create
ActiveRecord::Base.transaction do
created_count = 0
if permitted_create_params[:user_id] == 'all'
@project.update!(visibility: :visible, default_public_user_role_id: permitted_create_params[:user_role_id])
log_activity(:project_grant_access_to_all_team_members,
{ visibility: t('projects.activity.visibility_visible'),
role: @project.default_public_user_role.name,
team: @project.team.id })
else
user_assignment = UserAssignment.find_or_initialize_by(
assignable: @project,
user_id: permitted_create_params[:user_id],
team: current_team
)
user_assignment.update!(
user_role_id: permitted_create_params[:user_role_id],
assigned_by: current_user,
assigned: :manually
)
log_activity(:assign_user_to_project, { user_target: user_assignment.user.id,
role: user_assignment.user_role.name })
created_count += 1
propagate_job(user_assignment)
end
@message = if created_count.zero?
t('access_permissions.create.success', member_name: t('access_permissions.all_team'))
else
t('access_permissions.create.success', member_name: escape_input(user_assignment.user.name))
end
render json: { message: @message }
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error e.message
errors = @project.errors.present? ? @project.errors&.map(&:message)&.join(',') : e.message
render json: { flash: errors }, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
def update
@user_assignment = @project.user_assignments.find_by(
user_id: permitted_update_params[:user_id],
@ -44,52 +86,6 @@ module AccessPermissions
render json: { flash: t('access_permissions.update.failure') }, status: :unprocessable_entity
end
def create
ActiveRecord::Base.transaction do
created_count = 0
permitted_create_params[:resource_members].each do |_k, user_assignment_params|
next unless user_assignment_params[:assign] == '1'
if user_assignment_params[:user_id] == 'all'
@project.update!(visibility: :visible, default_public_user_role_id: user_assignment_params[:user_role_id])
log_activity(:project_grant_access_to_all_team_members,
{ visibility: t('projects.activity.visibility_visible'),
role: @project.default_public_user_role.name,
team: @project.team.id })
else
user_assignment = UserAssignment.find_or_initialize_by(
assignable: @project,
user_id: user_assignment_params[:user_id],
team: current_team
)
user_assignment.update!(
user_role_id: user_assignment_params[:user_role_id],
assigned_by: current_user,
assigned: :manually
)
log_activity(:assign_user_to_project, { user_target: user_assignment.user.id,
role: user_assignment.user_role.name })
created_count += 1
propagate_job(user_assignment)
end
end
@message = if created_count.zero?
t('access_permissions.create.success', count: t('access_permissions.all_team'))
else
t('access_permissions.create.success', count: created_count)
end
render :edit
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error e.message
errors = @project.errors.present? ? @project.errors&.map(&:message)&.join(',') : e.message
render json: { flash: errors }, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
def destroy
user = @project.assigned_users.find(params[:user_id])
user_assignment = @project.user_assignments.find_by(user: user, team: current_team)
@ -100,12 +96,15 @@ module AccessPermissions
end
propagate_job(user_assignment, destroy: true)
user_assignment.destroy!
log_activity(:unassign_user_from_project, { user_target: user_assignment.user.id,
role: user_assignment.user_role.name })
render json: { flash: t('access_permissions.destroy.success', member_name: escape_input(user.full_name)) }
render json: { message: t('access_permissions.destroy.success', member_name: escape_input(user.full_name)) }
rescue ActiveRecord::RecordInvalid
render json: { flash: t('access_permissions.destroy.failure') },
render json: { message: t('access_permissions.destroy.failure') },
status: :unprocessable_entity
end
@ -123,7 +122,7 @@ module AccessPermissions
{ visibility: t('projects.activity.visibility_hidden'),
role: previous_user_role_name,
team: @project.team.id })
render json: { flash: t('access_permissions.update.revoke_all_team_members') }
render json: { message: t('access_permissions.update.revoke_all_team_members') }
else
# update all team members access
@project.visibility = :visible
@ -151,8 +150,8 @@ module AccessPermissions
end
def permitted_create_params
params.require(:access_permissions_new_user_form)
.permit(resource_members: %i(assign user_id user_role_id))
params.require(:user_assignment)
.permit(%i(user_id user_role_id))
end
def set_project
@ -185,7 +184,7 @@ module AccessPermissions
id: @project.user_assignments.automatically_assigned.select(:user_id)
).or(
current_team.users.where.not(id: @project.users.select(:id))
)
).order('users.full_name ASC')
end
def log_activity(type_of, message_items = {})

View file

@ -6,6 +6,8 @@ module AccessPermissions
before_action :check_read_permissions, only: %i(show)
before_action :check_manage_permissions, except: %i(show)
def show; end
def new
@user_assignment = UserAssignment.new(
assignable: @protocol,
@ -14,32 +16,8 @@ module AccessPermissions
)
end
def show; end
def edit; end
def update
@user_assignment = @protocol.user_assignments.find_by(
user_id: permitted_update_params[:user_id],
team: current_team
)
# prevent role change if it would result in no manually assigned users having the user management permission
new_user_role = UserRole.find(permitted_update_params[:user_role_id])
if !new_user_role.has_permission?(ProtocolPermissions::USERS_MANAGE) &&
@user_assignment.last_with_permission?(ProtocolPermissions::USERS_MANAGE, assigned: :manually)
raise ActiveRecord::RecordInvalid
end
@user_assignment.update!(permitted_update_params)
log_activity(:protocol_template_access_changed, { user_target: @user_assignment.user.id,
role: @user_assignment.user_role.name })
render :protocol_member
rescue ActiveRecord::RecordInvalid
render json: { flash: t('access_permissions.update.failure') }, status: :unprocessable_entity
end
def create
ActiveRecord::Base.transaction do
created_count = 0
@ -83,6 +61,28 @@ module AccessPermissions
end
end
def update
@user_assignment = @protocol.user_assignments.find_by(
user_id: permitted_update_params[:user_id],
team: current_team
)
# prevent role change if it would result in no manually assigned users having the user management permission
new_user_role = UserRole.find(permitted_update_params[:user_role_id])
if !new_user_role.has_permission?(ProtocolPermissions::USERS_MANAGE) &&
@user_assignment.last_with_permission?(ProtocolPermissions::USERS_MANAGE, assigned: :manually)
raise ActiveRecord::RecordInvalid
end
@user_assignment.update!(permitted_update_params)
log_activity(:protocol_template_access_changed, { user_target: @user_assignment.user.id,
role: @user_assignment.user_role.name })
render :protocol_member
rescue ActiveRecord::RecordInvalid
render json: { flash: t('access_permissions.update.failure') }, status: :unprocessable_entity
end
def destroy
user = @protocol.assigned_users.find(params[:user_id])
user_assignment = @protocol.user_assignments.find_by(user: user, team: current_team)

View file

@ -12,7 +12,7 @@
<div>{{ params.code }}</div>
<RowMenuRenderer :params="{data: params, dtComponent: dtComponent}" class="ml-auto"/>
</div>
<a :href="params.urls.show" class="font-bold mb-4 text-sn-black hover:no-underline hover:text-sn-black">
<a :href="params.urls.show" :class="{'pointer-events-none text-sn-grey': !params.urls.show}" class="font-bold mb-4 text-sn-black hover:no-underline hover:text-sn-black">
{{ params.name }}
</a>
<div class="grid gap-2 grid-cols-[80px_auto] mt-auto">
@ -27,7 +27,7 @@
<span class="font-bold">{{ params.hidden ? i18n.t('projects.index.hidden') : i18n.t('projects.index.visible') }}</span>
<span class="text-sn-grey">{{ i18n.t('projects.index.card.users') }}</span>
<UsersRenderer :params="{data: params, value: params.users}" class="-mt-2.5" />
<UsersRenderer :params="{data: params, value: params.users, dtComponent: dtComponent}" class="-mt-2.5" />
</div>
</div>
<div v-else class="p-4 rounded sn-shadow-flyout flex flex-col">

View file

@ -21,6 +21,7 @@
@delete_folders="deleteFolder"
@export="exportProjects"
@move="move"
@access="access"
>
<template #card="data">
<ProjectCard :params="data.params" :dtComponent="data.dtComponent" ></ProjectCard>
@ -37,14 +38,14 @@
<ConfirmationModal
:title="i18n.t('projects.index.modal_delete_folders.title')"
:description="folderDeleteDescription"
:confirmClass="'btn btn-danger'"
confirmClass="btn btn-danger"
:confirmText="i18n.t('projects.index.modal_delete_folders.confirm_button')"
ref="deleteFolderModal"
></ConfirmationModal>
<ConfirmationModal
:title="i18n.t('projects.export_projects.modal_title')"
:description="exportDescription"
:confirmClass="'btn btn-primary'"
confirmClass="btn btn-primary"
:confirmText="i18n.t('projects.export_projects.export_button')"
ref="exportModal"
></ConfirmationModal>
@ -53,6 +54,7 @@
<NewProjectModal v-if="newProject" :createUrl="createUrl" :currentFolderId="currentFolderId" :userRolesUrl="userRolesUrl" @close="newProject = false" @create="updateTable" />
<NewFolderModal v-if="newFolder" :createFolderUrl="createFolderUrl" :currentFolderId="currentFolderId" :viewMode="currentViewMode" @close="newFolder = false" @create="updateTable" />
<MoveModal v-if="objectsToMove" :moveToUrl="moveToUrl" :selectedObjects="objectsToMove" :foldersTreeUrl="foldersTreeUrl" @close="objectsToMove = null" @move="updateTable" />
<AccessModal v-if="accessModalParams" :params="accessModalParams" @close="accessModalParams = null" @refresh="this.reloadingTable = true" />
</template>
<script>
@ -67,6 +69,7 @@ import EditFolderModal from './modals/edit_folder.vue'
import NewProjectModal from './modals/new.vue'
import NewFolderModal from './modals/new_folder.vue'
import MoveModal from './modals/move.vue'
import AccessModal from '../shared/access_modal/modal.vue'
export default {
name: 'ProjectsList',
@ -79,7 +82,8 @@ export default {
EditFolderModal,
NewProjectModal,
NewFolderModal,
MoveModal
MoveModal,
AccessModal
},
props: {
dataSource: { type: String, required: true },
@ -97,6 +101,7 @@ export default {
},
data() {
return {
accessModalParams: null,
newProject: false,
newFolder: false,
editProject: null,
@ -110,7 +115,7 @@ export default {
{ field: "code", headerName: this.i18n.t('projects.index.card.id'), sortable: true },
{ field: "created_at", headerName: this.i18n.t('projects.index.card.start_date'), sortable: true },
{ field: "hidden", headerName: this.i18n.t('projects.index.card.visibility'), cellRenderer: this.visibiltyRenderer, sortable: false },
{ field: "users", headerName: this.i18n.t('projects.index.card.users'), cellRenderer: 'UsersRenderer', sortable: false, minWidth: 210 }
{ field: "users", headerName: this.i18n.t('projects.index.card.users'), cellRenderer: 'UsersRenderer', sortable: false, minWidth: 210, notSelectable: true }
]
}
},
@ -195,7 +200,7 @@ export default {
},
nameRenderer(params) {
let showUrl = params.data.urls.show;
return `<a href="${showUrl}" class="flex items-center gap-1">
return `<a href="${showUrl}" class="flex items-center gap-1 hover:no-underline ${!showUrl ? 'pointer-events-none text-sn-grey' : ''}">
${params.data.folder ? '<i class="sn-icon mini sn-icon-mini-folder-left"></i>' : ''}
${params.data.name}
</a>`
@ -208,6 +213,12 @@ export default {
this.$refs.commentButton.dataset.objectId = rows[0].id;
this.$refs.commentButton.click();
},
access(event, rows) {
this.accessModalParams = {
object: rows[0],
roles_path: this.userRolesUrl,
}
},
async archive(event, rows) {
const ok = await this.$refs.archiveModal.show()
if (ok) {

View file

@ -1,5 +1,5 @@
<template>
<div v-if="!params.data.folder" class="flex items-center gap-1 cursor pointer h-10">
<div v-if="!params.data.folder" class="flex items-center gap-1 cursor-pointer h-10" @click="openAccessModal">
<div v-for="(user, i) in visibleUsers" :key="i" :title="user.full_name">
<img :src="user.avatar" class="w-7 h-7" />
</div>
@ -18,7 +18,7 @@ export default {
props: {
params: {
required: true
}
},
},
computed: {
users() {
@ -33,6 +33,11 @@ export default {
hiddenUsersTitle() {
return this.hiddenUsers.map((user) => user.full_name).join("\u000d")
}
},
methods: {
openAccessModal() {
this.params.dtComponent.$emit('access', {} ,[this.params.data]);
}
}
}

View file

@ -0,0 +1,178 @@
<template>
<div>
<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(`access_permissions.${params.object.type}.modals.edit_modal.title`, {resource_name: params.object.name}) }}
</h4>
</div>
<div class="modal-body">
<div class="h-[60vh] overflow-y-auto">
<div v-for="userAssignment in manuallyAssignedUsers" :key="userAssignment.id" class="p-2 flex items-center gap-2">
<div>
<img :src="userAssignment.attributes.user.avatar_url" class="rounded-full w-8 h-8">
</div>
<div>{{ userAssignment.attributes.user.name }}</div>
<MenuDropdown
v-if="!userAssignment.attributes.last_owner"
class="ml-auto"
:listItems="rolesFromatted"
:btnText="userAssignment.attributes.user_role.name"
:position="'right'"
:caret="true"
@setRole="(...args) => this.changeRole(userAssignment.attributes.user.id, ...args)"
@removeRole="() => this.removeRole(userAssignment.attributes.user.id)"
></MenuDropdown>
<div class="ml-auto btn btn-light pointer-events-none" v-else>
{{ userAssignment.attributes.user_role.name }}
<div class="h-6 w-6"></div>
</div>
</div>
<div v-if="roles.length > 0 && visible" class="p-2 flex items-center gap-2">
<div>
<img src="/images/icon/team.png" class="rounded-full w-8 h-8">
</div>
<div>
{{ i18n.t('access_permissions.everyone_else', { team_name: params.object.team }) }}
</div>
<i class="sn-icon sn-icon-info" :title='this.autoAssignedUsers.map((ua) => ua.attributes.user.name).join("\u000d")'></i>
<MenuDropdown
class="ml-auto"
:listItems="rolesFromatted"
:btnText="this.roles.find((role) => role[0] == default_role)[1]"
:position="'right'"
:caret="true"
@setRole="(...args) => this.changeDefaultRole(...args)"
@removeRole="() => this.changeDefaultRole()"
></MenuDropdown>
</div>
</div>
</div>
<div v-if="params.object.top_level_assignable" class="modal-footer">
<button class="btn-light ml-auto btn" @click="$emit('changeMode', 'newView')">
<i class="sn-icon sn-icon-new-task"></i>
{{ i18n.t('access_permissions.grant_access') }}
</button >
</div>
</div>
</template>
<script>
import MenuDropdown from "../../shared/menu_dropdown.vue";
import axios from '../../../packs/custom_axios.js';
export default {
props: {
params: {
type: Object,
required: true
},
visible: {
type: Boolean
},
default_role: {
type: Number
}
},
emits: ['changeMode', 'modified'],
mounted() {
this.getAssignedUsers();
this.getRoles();
},
components: {
MenuDropdown,
},
computed: {
rolesFromatted() {
let roles = this.roles.map((role) => {
return {
emit: 'setRole',
text: role[1],
params: role[0]
}
});
roles.push({
dividerBefore: true,
emit: 'removeRole',
text: this.i18n.t('access_permissions.remove_access'),
});
return roles
},
manuallyAssignedUsers() {
return this.assignedUsers.filter((user) => {
return user.attributes.assigned === 'manually';
});
},
autoAssignedUsers() {
return this.assignedUsers.filter((user) => {
return user.attributes.assigned === 'automatically';
});
},
},
data() {
return {
assignedUsers: [],
roles: [],
};
},
methods: {
getAssignedUsers() {
axios.get(this.params.object.urls.show_access)
.then((response) => {
this.assignedUsers = response.data.data;
})
},
getRoles() {
axios.get(this.params.roles_path)
.then((response) => {
this.roles = response.data.data;
})
},
changeRole(id, role_id) {
axios.put(this.params.object.urls.show_access, {
user_assignment: {
user_id: id,
user_role_id: role_id
}
}).then((response) => {
this.$emit('modified');
this.getAssignedUsers();
})
},
removeRole(id) {
axios.delete(this.params.object.urls.show_access, {
data: {
user_id: id
}
}).then((response) => {
this.$emit('modified');
HelperModule.flashAlertMsg(response.data.message, 'success');
this.getAssignedUsers();
})
},
changeDefaultRole(role_id) {
axios.put(this.params.object.urls.default_public_user_role_path, {
project: {
default_public_user_role_id: role_id || ''
}
}).then((response) => {
this.$emit('modified');
if (!role_id) {
this.$emit('changeVisibility', false, null);
} else {
this.$emit('changeVisibility', true, role_id);
}
if (response.data.message) {
HelperModule.flashAlertMsg(response.data.message, 'success');
}
})
},
removeDefaultRole() {
},
}
}
</script>

View file

@ -0,0 +1,68 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<component
:is="mode"
:params="params"
:visible="visible"
:default_role="default_role"
@changeMode="changeMode"
@modified="modified = true"
@changeVisibility="changeVisibility"
></component>
</div>
</div>
</div>
</template>
<script>
import SelectDropdown from "../../shared/select_dropdown.vue";
import axios from '../../../packs/custom_axios.js';
import modal_mixin from "../../shared/modal_mixin";
import editView from './edit.vue';
import newView from './new.vue';
export default {
name: "AccessModal",
props: {
params: {
type: Object,
required: true
},
},
mixins: [modal_mixin],
components: {
SelectDropdown,
editView,
newView
},
data() {
return {
mode: 'editView',
modified: false,
visible: false,
default_role: null,
};
},
mounted() {
this.visible = !this.params.object.hidden;
this.default_role = this.params.object.default_public_user_role_id;
},
beforeUnmount() {
if (this.modified) {
this.$emit('refresh');
}
},
methods: {
changeMode(mode) {
this.mode = mode;
},
changeVisibility(status, role) {
this.visible = status;
this.default_role = role;
},
}
}
</script>

View file

@ -0,0 +1,136 @@
<template>
<div>
<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 flex items-center gap-4">
{{ i18n.t('access_permissions.partials.new_assignments_form.title', {resource_name: params.object.name}) }}
<button class="close" @click="$emit('changeMode', 'editView')">
<i class="sn-icon sn-icon-left"></i>
</button>
</h4>
</div>
<div class="modal-body">
<div class="mb-4">
<div class="sci-input-container-v2 left-icon">
<input type="text" v-model="query" class="sci-input-field" autofocus="true" :placeholder="i18n.t('access_permissions.partials.new_assignments_form.find_people_html')" />
<i class="sn-icon sn-icon-search"></i>
</div>
</div>
<div class="h-[60vh] overflow-y-auto">
<div v-if="!visible && roles.length > 0" class="p-2 flex items-center gap-2">
<div>
<img src="/images/icon/team.png" class="rounded-full w-8 h-8">
</div>
<div>
{{ i18n.t('user_assignment.assign_all_team_members') }}
</div>
<MenuDropdown
class="ml-auto"
:listItems="rolesFromatted"
btnText="Assign"
:position="'right'"
:caret="true"
@setRole="(...args) => this.assignRole('all', ...args)"
></MenuDropdown>
</div>
<div v-for="user in filteredUsers" :key="user.id" class="p-2 flex items-center gap-2">
<div>
<img :src="user.attributes.avatar_url" class="rounded-full w-8 h-8">
</div>
<div>{{ user.attributes.name }}</div>
<MenuDropdown
class="ml-auto"
:listItems="rolesFromatted"
btnText="Assign"
:position="'right'"
:caret="true"
@setRole="(...args) => this.assignRole(user.id, ...args)"
></MenuDropdown>
</div>
</div>
</div>
</div>
</template>
<script>
import MenuDropdown from "../../shared/menu_dropdown.vue";
import axios from '../../../packs/custom_axios.js';
export default {
props: {
params: {
type: Object,
required: true
},
visible: {
type: Boolean
},
default_role: {
type: Number
},
},
emits: ['changeMode'],
mounted() {
this.getUnAssignedUsers();
this.getRoles();
},
components: {
MenuDropdown,
},
computed: {
rolesFromatted() {
return this.roles.map((role) => {
return {
emit: 'setRole',
text: role[1],
params: role[0]
}
});
},
filteredUsers() {
return this.unAssignedUsers.filter((user) => {
return user.attributes.name.toLowerCase().includes(this.query.toLowerCase());
});
}
},
data() {
return {
unAssignedUsers: [],
roles: [],
query: '',
};
},
methods: {
getUnAssignedUsers() {
axios.get(this.params.object.urls.new_access)
.then((response) => {
this.unAssignedUsers = response.data.data;
})
},
getRoles() {
axios.get(this.params.roles_path)
.then((response) => {
this.roles = response.data.data;
})
},
assignRole(id, role_id) {
axios.post(this.params.object.urls.create_access, {
user_assignment: {
user_id: id,
user_role_id: role_id
}
})
.then((response) => {
this.$emit('modified');
HelperModule.flashAlertMsg(response.data.message, 'success');
this.getUnAssignedUsers();
if (id === 'all') {
this.$emit('changeVisibility', true, role_id);
}
})
},
}
}
</script>

View file

@ -25,7 +25,7 @@
v-if="currentViewRender === 'table'"
class="ag-theme-alpine w-full flex-grow h-full z-10"
:class="{'opacity-0': initializing}"
:columnDefs="columnDefs"
:columnDefs="extendedColumnDefs"
:rowData="rowData"
:defaultColDef="defaultColDef"
:rowSelection="'multiple'"
@ -177,38 +177,48 @@ export default {
return {
suppressCellFocus: true
}
}
},
beforeMount() {
if (this.withCheckboxes) {
this.columnDefs.unshift({
field: "checkbox",
headerCheckboxSelection: true,
headerCheckboxSelectionFilteredOnly: true,
checkboxSelection: true,
width: 48,
minWidth: 48,
resizable: false,
pinned: 'left'
},
extendedColumnDefs() {
let columns = this.columnDefs.map(column => {
return {
...column,
cellRendererParams: {
dtComponent: this
}
}
});
}
if (this.withRowMenu) {
this.columnDefs.push({
field: "rowMenu",
headerName: '',
width: 42,
minWidth: 42,
resizable: false,
sortable: false,
cellRenderer: 'RowMenuRenderer',
cellRendererParams: {
dtComponent: this
},
pinned: 'right',
cellStyle: {padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', overflow: 'visible'}
if (this.withCheckboxes) {
columns.unshift({
field: "checkbox",
headerCheckboxSelection: true,
headerCheckboxSelectionFilteredOnly: true,
checkboxSelection: true,
width: 48,
minWidth: 48,
resizable: false,
pinned: 'left'
});
}
});
if (this.withRowMenu) {
columns.push({
field: "rowMenu",
headerName: '',
width: 42,
minWidth: 42,
resizable: false,
sortable: false,
cellRenderer: 'RowMenuRenderer',
cellRendererParams: {
dtComponent: this
},
pinned: 'right',
cellStyle: {padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', overflow: 'visible'}
});
}
return columns;
}
},
watch: {
@ -308,7 +318,7 @@ export default {
this.loadData();
},
clickCell(e) {
if (e.column.colId !== 'rowMenu') {
if (e.column.colId !== 'rowMenu' && e.column.userProvidedColDef.notSelectable !== true) {
e.node.setSelected(true);
}
},

View file

@ -1,69 +1,69 @@
<template>
<div class="relative" v-if="listItems.length > 0 || alwaysShow" v-click-outside="closeMenu" >
<button ref="openBtn" :class="btnClasses" @click="showMenu = !showMenu">
<button ref="field" :class="btnClasses" @click="isOpen = !isOpen">
<i v-if="btnIcon" :class="btnIcon"></i>
{{ btnText }}
<i v-if="caret && showMenu" class="sn-icon sn-icon-up"></i>
<i v-if="caret && isOpen" class="sn-icon sn-icon-up"></i>
<i v-else-if="caret" class="sn-icon sn-icon-down"></i>
</button>
<div ref="flyout"
class="absolute z-[150] bg-sn-white rounded p-2.5 sn-shadow-menu-sm min-w-full flex flex-col gap-[1px]"
:class="{
'right-0': position === 'right',
'left-0': position === 'left',
'bottom-0': openUp,
'!mb-0': !openUp,
}"
v-if="showMenu"
>
<span v-for="(item, i) in listItems" :key="i" class="contents">
<div v-if="item.dividerBefore" class="border-0 border-t border-solid border-sn-light-grey"></div>
<a :href="item.url" v-if="!item.submenu"
:target="item.url_target || '_self'"
:class="{ 'bg-sn-super-light-blue': item.active }"
:data-toggle="item.modalTarget && 'modal'"
:data-target="item.modalTarget"
class="block whitespace-nowrap rounded px-3 py-2.5 hover:!text-sn-blue hover:no-underline cursor-pointer hover:bg-sn-super-light-grey leading-5"
@click="handleClick($event, item)"
>
{{ item.text }}
</a>
<div v-else class="-mx-2.5 px-2.5 group relative">
<span
<teleport to="body">
<div ref="flyout"
class="fixed z-[3000] bg-sn-white inline-block rounded p-2.5 sn-shadow-menu-sm flex flex-col gap-[1px]"
:class="{
'right-0': position === 'right',
'left-0': position === 'left',
}"
v-if="isOpen"
>
<span v-for="(item, i) in listItems" :key="i" class="contents">
<div v-if="item.dividerBefore" class="border-0 border-t border-solid border-sn-light-grey"></div>
<a :href="item.url" v-if="!item.submenu"
:target="item.url_target || '_self'"
:class="{ 'bg-sn-super-light-blue': item.active }"
class="flex group items-center rounded relative text-sn-blue whitespace-nowrap px-3 py-2.5 hover:no-underline cursor-pointer group-hover:bg-sn-super-light-blue hover:!bg-sn-super-light-grey"
:data-toggle="item.modalTarget && 'modal'"
:data-target="item.modalTarget"
class="block whitespace-nowrap rounded px-3 py-2.5 hover:!text-sn-blue hover:no-underline cursor-pointer hover:bg-sn-super-light-grey leading-5"
@click="handleClick($event, item)"
>
{{ item.text }}
<i class="sn-icon sn-icon-right ml-auto"></i>
</span>
<div
class="absolute bg-sn-white rounded p-2.5 sn-shadow-menu-sm flex flex-col gap-[1px] tw-hidden group-hover:block"
:class="{
'left-0 ml-[100%]': item.position === 'right',
'right-0 mr-[100%]': item.position === 'left',
'bottom-0': openUp,
'top-0': !openUp,
}"
>
<a v-for="(sub_item, si) in item.submenu" :key="si"
:href="sub_item.url"
:traget="sub_item.url_target || '_self'"
</a>
<div v-else class="-mx-2.5 px-2.5 group relative">
<span
:class="{ 'bg-sn-super-light-blue': item.active }"
class="block whitespace-nowrap rounded px-3 py-2.5 hover:!text-sn-blue hover:no-underline cursor-pointer hover:bg-sn-super-light-grey leading-5"
@click="handleClick($event, sub_item)"
class="flex group items-center rounded relative text-sn-blue whitespace-nowrap px-3 py-2.5 hover:no-underline cursor-pointer group-hover:bg-sn-super-light-blue hover:!bg-sn-super-light-grey"
>
{{ sub_item.text }}
</a>
{{ item.text }}
<i class="sn-icon sn-icon-right ml-auto"></i>
</span>
<div
class="absolute bg-sn-white rounded p-2.5 sn-shadow-menu-sm flex flex-col gap-[1px] tw-hidden group-hover:block"
:class="{
'left-0 ml-[100%]': item.position === 'right',
'right-0 mr-[100%]': item.position === 'left',
'bottom-0': openUp,
'top-0': !openUp,
}"
>
<a v-for="(sub_item, si) in item.submenu" :key="si"
:href="sub_item.url"
:traget="sub_item.url_target || '_self'"
:class="{ 'bg-sn-super-light-blue': item.active }"
class="block whitespace-nowrap rounded px-3 py-2.5 hover:!text-sn-blue hover:no-underline cursor-pointer hover:bg-sn-super-light-grey leading-5"
@click="handleClick($event, sub_item)"
>
{{ sub_item.text }}
</a>
</div>
</div>
</div>
</span>
</div>
</span>
</div>
</teleport>
</div>
</template>
<script>
import isInViewPort from './isInViewPort.js';
import FixedFlyoutMixin from './mixins/fixed_flyout.js';
import { vOnClickOutside } from '@vueuse/components'
export default {
@ -79,34 +79,26 @@ export default {
},
data() {
return {
showMenu: false,
openUp: false
isOpen: false
}
},
directives: {
'click-outside': vOnClickOutside
},
mixins: [FixedFlyoutMixin],
watch: {
showMenu() {
if (this.showMenu) {
this.openUp = false;
this.$nextTick(() => {
this.$refs.flyout.style.marginBottom = `${this.$refs.openBtn.offsetHeight}px`;
this.updateOpenDirectoin();
})
isOpen() {
if (this.isOpen) {
this.$emit('open');
this.$nextTick(() => {
this.setPosition();
})
}
}
},
mounted() {
document.addEventListener('scroll', this.updateOpenDirectoin);
},
unmounted() {
document.removeEventListener('scroll', this.updateOpenDirectoin);
},
methods: {
closeMenu() {
this.showMenu = false;
this.isOpen = false;
},
handleClick(event, item) {
if (!item.url) {
@ -117,16 +109,7 @@ export default {
this.$emit(item.emit, item.params);
this.$emit('dtEvent', item.emit, item);
}
this.closeMenu();
},
updateOpenDirectoin() {
if (!this.showMenu) return;
this.openUp = false;
this.$nextTick(() => {
this.openUp = !isInViewPort(this.$refs.flyout);
});
}
}
}

View file

@ -0,0 +1,71 @@
export default {
data() {
return {
overflowContainerScrollTop: 0,
};
},
watch: {
isOpen() {
if (this.isOpen) {
this.$nextTick(() => {
this.overflowContainerListener();
});
}
},
},
methods: {
overflowContainerListener() {
const { field, flyout } = this.$refs;
if (!field || !flyout) return;
let fieldRect = field.getBoundingClientRect();
if (this.overflowContainerScrollTop !== fieldRect.top) {
this.setPosition();
}
this.overflowContainerScrollTop = fieldRect.top;
setTimeout(() => {
this.overflowContainerListener();
}, 10);
},
setPosition() {
const { field, flyout } = this.$refs;
if (!field || !flyout) return;
const rect = field.getBoundingClientRect();
const screenHeight = window.innerHeight;
const windowHasScroll = document.documentElement.scrollHeight > document.documentElement.clientHeight;
let rightScrollOffset = 0;
const { left, width } = rect;
const top = rect.top + rect.height;
const bottom = screenHeight - rect.bottom + rect.height;
const right = window.innerWidth - rect.right;
if (windowHasScroll) {
rightScrollOffset = 14;
}
if (this.fixedWidth) {
flyout.style.width = `${width}px`;
} else {
flyout.style.minWidth = `${width}px`;
}
if (this.position === 'right') {
flyout.style.right = `${right - rightScrollOffset}px`;
} else {
flyout.style.left = `${left}px`;
}
if (bottom < top) {
flyout.style.bottom = `${bottom}px`;
flyout.style.top = 'unset';
flyout.style.boxShadow = '0px -16px 32px 0px rgba(16, 24, 40, 0.07)';
} else {
flyout.style.top = `${top}px`;
flyout.style.bottom = 'unset';
flyout.style.boxShadow = '';
}
},
},
};

View file

@ -23,43 +23,46 @@
<i v-if="canClear" @click="clear" class="sn-icon ml-auto sn-icon-close"></i>
<i v-else class="sn-icon ml-auto" :class="{ 'sn-icon-down': !isOpen, 'sn-icon-up': isOpen, 'text-sn-grey': disabled}"></i>
</div>
<div v-if="isOpen" ref="flyout" class="bg-white sn-shadow-menu-sm rounded w-full fixed z-50">
<div v-if="multiple && withCheckboxes" class="p-2.5 pb-0">
<div @click="selectAll" :class="sizeClass" class="border-x-0 border-transparent border-solid border-b-sn-light-grey py-1.5 px-3 cursor-pointer flex items-center gap-2 shrink-0">
<div class="sn-checkbox-icon"
:class="selectAllState"
></div>
{{ i18n.t('general.select_all') }}
</div>
</div>
<perfect-scrollbar class="p-2.5 flex flex-col max-h-80 relative" :class="{ 'pt-0': withCheckboxes }">
<template v-for="option in filteredOptions" :key="option[0]">
<div
@click="setValue(option[0])"
class="py-1.5 px-3 rounded cursor-pointer flex items-center gap-2 shrink-0"
:class="[sizeClass, {'!bg-sn-super-light-blue': valueSelected(option[0])}]"
>
<div v-if="withCheckboxes"
class="sn-checkbox-icon"
:class="{
'checked': valueSelected(option[0]),
'unchecked': !valueSelected(option[0]),
}"
<teleport to="body">
<div v-if="isOpen" ref="flyout" class="bg-white inline-block sn-shadow-menu-sm rounded w-full fixed z-[3000]">
<div v-if="multiple && withCheckboxes" class="p-2.5 pb-0">
<div @click="selectAll" :class="sizeClass" class="border-x-0 border-transparent border-solid border-b-sn-light-grey py-1.5 px-3 cursor-pointer flex items-center gap-2 shrink-0">
<div class="sn-checkbox-icon"
:class="selectAllState"
></div>
<div v-if="optionRenderer" v-html="optionRenderer(option)"></div>
<div v-else >{{ option[1] }}</div>
{{ i18n.t('general.select_all') }}
</div>
</template>
<div v-if="filteredOptions.length === 0" class="text-sn-grey text-center py-2.5">
{{ noOptionsPlaceholder || this.i18n.t('general.select_dropdown.no_options_placeholder') }}
</div>
</perfect-scrollbar>
</div>
<perfect-scrollbar class="p-2.5 flex flex-col max-h-80 relative" :class="{ 'pt-0': withCheckboxes }">
<template v-for="option in filteredOptions" :key="option[0]">
<div
@click="setValue(option[0])"
class="py-1.5 px-3 rounded cursor-pointer flex items-center gap-2 shrink-0"
:class="[sizeClass, {'!bg-sn-super-light-blue': valueSelected(option[0])}]"
>
<div v-if="withCheckboxes"
class="sn-checkbox-icon"
:class="{
'checked': valueSelected(option[0]),
'unchecked': !valueSelected(option[0]),
}"
></div>
<div v-if="optionRenderer" v-html="optionRenderer(option)"></div>
<div v-else >{{ option[1] }}</div>
</div>
</template>
<div v-if="filteredOptions.length === 0" class="text-sn-grey text-center py-2.5">
{{ noOptionsPlaceholder || this.i18n.t('general.select_dropdown.no_options_placeholder') }}
</div>
</perfect-scrollbar>
</div>
</teleport>
</div>
</template>
<script>
import FixedFlyoutMixin from './mixins/fixed_flyout.js';
import { vOnClickOutside } from '@vueuse/components'
export default {
@ -91,8 +94,10 @@ export default {
fetchedOptions: [],
selectAllState: 'unchecked',
query: '',
fixedWidth: true
}
},
mixins: [FixedFlyoutMixin],
computed: {
sizeClass() {
switch (this.size) {
@ -162,16 +167,12 @@ export default {
}
},
mounted() {
document.addEventListener('scroll', this.setPosition);
this.newValue = this.value;
if (!this.newValue && this.multiple) {
this.newValue = []
}
this.fetchOptions();
},
beforeUnmount() {
document.removeEventListener('scroll', this.setPosition);
},
watch: {
isOpen() {
if (this.isOpen) {
@ -240,38 +241,6 @@ export default {
}
this.$emit('change', this.newValue)
},
setPosition() {
const field= this.$refs.field;
const flyout = this.$refs.flyout;
const rect = field.getBoundingClientRect();
const screenHeight = window.innerHeight;
if (!this.isOpen) return;
let width = rect.width;
let height = rect.height;
let top = rect.top + rect.height;
let bottom = screenHeight - rect.bottom + rect.height;
let left = rect.left;
const modal = field.closest('.modal-content');
if (modal) {
const modalRect = modal.getBoundingClientRect();
top -= modalRect.top;
left -= modalRect.left;
}
flyout.style.width = `${width}px`;
flyout.style.top = `${top}px`;
flyout.style.left = `${left}px`;
if (bottom < top) {
flyout.style.marginTop = `${(height + flyout.offsetHeight)* -1}px`;
flyout.style.boxShadow = '0px -16px 32px 0px rgba(16, 24, 40, 0.07)';
} else {
flyout.style.marginTop = '';
flyout.style.boxShadow = '';
}
},
fetchOptions() {
if (this.optionsUrl) {
fetch(`${this.optionsUrl}?query=${this.query || ''}`)

View file

@ -6,8 +6,8 @@ module Lists
include Rails.application.routes.url_helpers
attributes :name, :language_type, :urls, :type,
:default, :format, :modified_by, :created_by,
:created_at, :updated_at, :id, :icon_url, :description
:default, :format, :modified_by, :created_by,
:created_at, :updated_at, :id, :icon_url, :description
def icon_url
ActionController::Base.helpers.image_tag(

View file

@ -1,13 +1,23 @@
module Lists
class ProjectAndFolderSerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
include Canaid::Helpers::PermissionsHelper
attributes :name, :code, :created_at, :archived_on, :users, :hidden, :urls, :folder, :folder_info, :default_public_user_role_id
attributes :name, :code, :created_at, :archived_on, :users, :hidden, :urls, :folder,
:folder_info, :default_public_user_role_id, :team, :top_level_assignable
def team
object.team.name
end
def folder
!project?
end
def top_level_assignable
project?
end
def default_public_user_role_id
object.default_public_user_role_id if project?
end
@ -46,19 +56,29 @@ module Lists
type: project? ? 'projects' : 'project_folders' }].to_json)
}
if project?
urls_list[:update] = project_path(object)
else
urls_list[:update] = project_folder_path(object)
end
urls_list[:show] = nil if project? && !can_read_project?(object)
urls_list[:update] = if project?
project_path(object)
else
project_folder_path(object)
end
if project? && can_manage_project_users?(object)
urls_list[:show_access] = access_permissions_project_path(object)
urls_list[:new_access] = new_access_permissions_project_path(id: object.id)
urls_list[:create_access] = access_permissions_projects_path(id: object.id)
urls_list[:default_public_user_role_path] =
update_default_public_user_role_access_permissions_project_path(object)
end
urls_list
end
def folder_info
if folder
I18n.t('projects.index.folder.description', projects_count: object.projects_count, folders_count: object.folders_count)
I18n.t('projects.index.folder.description', projects_count: object.projects_count,
folders_count: object.folders_count)
end
end

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
class UserAssignmentSerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
attributes :id, :assigned, :assignable_type, :user, :user_role, :last_owner
def user
{
id: object.user.id,
name: object.user.name,
avatar_url: avatar_path(object, :icon_small)
}
end
def user_role
{
id: object.user_role.id,
name: object.user_role.name
}
end
def last_owner
object.last_with_permission?("#{object.assignable.class.name}Permissions".constantize::USERS_MANAGE, assigned: :manually)
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class UserSerializer < ActiveModel::Serializer
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
attributes :id, :name, :avatar_url
def avatar_url
avatar_path(object, :icon_small)
end
end

View file

@ -77,17 +77,9 @@ module Toolbars
return unless can_manage_team?(project.team) || can_read_project?(project)
path = if can_manage_project_users?(project)
edit_access_permissions_project_path(project)
else
access_permissions_project_path(project)
end
{
name: 'access',
label: I18n.t('general.access'),
icon: 'sn-icon sn-icon-project-member-access',
path: path,
type: :emit
}
end

View file

@ -3591,7 +3591,7 @@ en:
remove_access: "Remove access"
grant_access: "Grant new access"
create:
success: "You have successfully granted access to %{count} member(s)."
success: "You have successfully granted access to %{member_name}."
failure: "Something went wrong"
destroy:
success: "You have successfully removed %{member_name}."
@ -3621,9 +3621,9 @@ en:
my_module_member_field:
reset: "Inherit role"
reset_description: "The inherited role from project or experiment will be applied"
project: "Project"
project_tooltip: "This role was set on this project."
project_tooltip_inherit: "This role was inherited from the project."
projects: "Project"
projects_tooltip: "This role was set on this project."
projects_tooltip_inherit: "This role was inherited from the project."
protocol: "Protocol"
protocol_tooltip: "This role was set on this protocol."
protocol_tooltip_inherit: "This role was inherited from the protocol."

BIN
public/images/icon/team.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB