Add backend for navigator [SCI-8233]

This commit is contained in:
Anton 2023-04-21 15:25:52 +02:00
parent 39de270e89
commit cb6b2c3855
20 changed files with 487 additions and 47 deletions

View file

@ -12,11 +12,6 @@ var Sidebar = (function() {
function reloadSidebar(params) {
let url = $(SIDEBAR_CONTAINER).data('sidebar-url');
$.get(url, params, function(result) {
$(SIDEBAR_CONTAINER).find('.sidebar-body').html(result.html);
showSelectedLeaf();
$(SIDEBAR_CONTAINER).data('scrollBar').update();
});
}
function initSideBar() {

View file

@ -9,7 +9,6 @@ var SideBarToggle = (function() {
$(SIDEBAR_CONTAINER).removeClass('collapsed');
$(WRAPPER).css('paddingLeft', 'var(--wrapper-width)');
$('.navbar-secondary').removeClass("navbar-without-sidebar");
$.post($(LAYOUT).data('navitgator-state-url'), {state: 'open'});
$(WRAPPER).trigger('sideBar::show');
$(WRAPPER).one("transitionend", function() {
$(WRAPPER).trigger('sideBar::shown');
@ -21,7 +20,6 @@ var SideBarToggle = (function() {
$(SIDEBAR_CONTAINER).addClass('collapsed');
$(WRAPPER).css('paddingLeft', '0');
$('.navbar-secondary').addClass("navbar-without-sidebar");
$.post($(LAYOUT).data('navitgator-state-url'), {state: 'collapsed'});
$(WRAPPER).trigger('sideBar::hide');
$(WRAPPER).one("transitionend", function() {
$(WRAPPER).trigger('sideBar::hidden');

View file

@ -116,3 +116,7 @@ $color-dd-hover: #f5f5f5;
.sn-color-primary {
color: var(--sn-blue);
}
.sn-background-background-violet {
background-color: var(--sn-light-grey);
}

View file

@ -663,6 +663,9 @@ class ExperimentsController < ApplicationController
end
def set_navigator
@navigator = true
@navigator = {
url: tree_navigator_experiment_path(@experiment),
id: @experiment.code
}
end
end

View file

@ -612,6 +612,9 @@ class MyModulesController < ApplicationController
end
def set_navigator
@navigator = true
@navigator = {
url: tree_navigator_my_module_path(@my_module),
id: @my_module.code
}
end
end

View file

@ -0,0 +1,155 @@
# frozen_string_literal: true
module Navigator
class BaseController < ApplicationController
private
def project_serializer(project)
{
id: project.code,
name: project.name,
url: project_path(project),
archived: project.archived,
type: :project,
has_children: project.has_children,
children_url: navigator_project_path(project)
}
end
def folder_serializer(folder)
{
id: folder.code,
name: folder.name,
url: projects_path(project_folder_id: folder.id),
archived: folder.archived,
type: :folder,
has_children: folder.has_children,
children_url: navigator_project_folder_path(folder)
}
end
def experiment_serializer(experiment)
{
id: experiment.code,
name: experiment.name,
url: canvas_experiment_path(experiment),
archived: experiment.archived,
type: :experiment,
has_children: experiment.has_children,
children_url: navigator_experiment_path(experiment)
}
end
def my_module_serializer(my_module)
{
id: my_module.code,
name: my_module.name,
type: :my_module,
url: protocols_my_module_path(my_module),
archived: my_module.archived,
has_children: false
}
end
def fetch_projects(folder = nil, archived = false)
has_children_sql = if !archived
'SUM(CASE WHEN experiments.archived IS FALSE THEN 1 ELSE 0 END) > 0 AS has_children'
else
'SUM(CASE WHEN experiments.archived IS TRUE OR my_modules.archived IS TRUE
THEN 1 ELSE 0 END) > 0 AS has_children'
end
current_team.projects
.where(project_folder_id: folder)
.viewable_by_user(current_user, current_team)
.with_children_viewable_by_user(current_user)
.where('
projects.archived = :archived OR
(
(
experiments.archived = :archived OR
my_modules.archived = :archived
) AND
:archived IS TRUE
)
', archived: archived)
.select(
'projects.id',
'projects.name',
'projects.archived',
has_children_sql
).group('projects.id')
end
def fetch_project_folders(folder = nil, archived = false)
current_team.project_folders.where(parent_folder: folder)
.left_outer_joins(projects: { user_assignments: :user_role }, project_folders: {})
.where(project_folders: { archived: archived })
.where('
user_assignments.user_id = ? AND
user_roles.permissions @> ARRAY[?]::varchar[] OR
projects.id IS NULL
', current_user.id, ProjectPermissions::READ)
.select(
'project_folders.id',
'project_folders.name',
'project_folders.archived',
'SUM(CASE WHEN projects.id IS NOT NULL OR project_folders_project_folders.id IS NOT NULL
THEN 1 ELSE 0 END) > 0 AS has_children'
).group('project_folders.id')
end
def fetch_experiments(project, archived = false)
has_children_sql = if !archived
'SUM(CASE WHEN my_modules.archived IS FALSE THEN 1 ELSE 0 END) > 0 AS has_children'
else
'SUM(CASE WHEN my_modules.archived IS TRUE THEN 1 ELSE 0 END) > 0 AS has_children'
end
project.experiments
.viewable_by_user(current_user, current_team)
.with_children_viewable_by_user(current_user)
.where('
experiments.archived = :archived OR
my_modules.archived = :archived AND
:archived IS TRUE
', archived: archived)
.select(
'experiments.id',
'experiments.name',
'experiments.archived',
has_children_sql
).group('experiments.id')
end
def fetch_my_modules(experiment, archived = false)
experiment.my_modules
.viewable_by_user(current_user, current_team)
.where(archived: archived)
end
def build_folder_tree(folder, children, archived = false)
parent_folder = folder.parent_folder
tree = fetch_projects(parent_folder, archived).map { |i| project_serializer(i) } +
fetch_project_folders(parent_folder, archived).map { |i| folder_serializer(i) }
tree.find { |i| i[:id] == folder.code }[:children] = children
tree = build_folder_tree(parent_folder, tree, archived) if parent_folder.present?
tree
end
def project_level_branch(folder = nil, archived = false)
fetch_projects(folder, archived)
.map { |i| project_serializer(i) } +
fetch_project_folders(folder, archived)
.map { |i| folder_serializer(i) }
end
def experiment_level_branch(project, archived = false)
fetch_experiments(project, archived)
.map { |i| experiment_serializer(i) }
end
def my_module_level_branch(experiment, archived = false)
fetch_my_modules(experiment, archived)
.map { |i| my_module_serializer(i) }
end
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
module Navigator
class ExperimentsController < BaseController
before_action :load_experiment
before_action :check_read_permissions
def show
my_modules = my_module_level_branch(@experiment, params[:archived] == 'true')
render json: { items: my_modules }
end
def tree
my_modules = my_module_level_branch(@experiment, params[:archived] == 'true')
experiments = experiment_level_branch(@experiment.project, params[:archived] == 'true')
experiments.find { |i| i[:id] == @experiment.code }[:children] = my_modules
tree = project_level_branch(@experiment.project.project_folder, params[:archived] == 'true')
tree.find { |i| i[:id] == @experiment.project.code }[:children] = experiments
tree = build_folder_tree(@experiment.project.project_folder, tree) if @experiment.project.project_folder
render json: { items: tree }
end
private
def load_experiment
@experiment = Experiment.find_by(id: params[:id])
end
def check_read_permissions
render_403 and return unless can_read_experiment?(@experiment)
end
end
end

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
module Navigator
class MyModulesController < BaseController
before_action :load_my_module
before_action :check_read_permissions
def tree
my_modules = my_module_level_branch(@experiment, params[:archived] == 'true')
experiments = experiment_level_branch(@experiment.project, params[:archived] == 'true')
experiments.find { |i| i[:id] == @experiment.code }[:children] = my_modules
tree = project_level_branch(@experiment.project.project_folder, params[:archived] == 'true')
tree.find { |i| i[:id] == @experiment.project.code }[:children] = experiments
tree = build_folder_tree(@experiment.project.project_folder, tree) if @experiment.project.project_folder
render json: { items: tree }
end
private
def load_my_module
@my_module = MyModule.find_by(id: params[:id])
@experiment = @my_module.experiment
end
def check_read_permissions
render_403 and return unless can_read_my_module?(@my_module)
end
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Navigator
class ProjectFoldersController < BaseController
before_action :load_project_folder
def show
folder = project_level_branch(@project_folder, params[:archived] == 'true')
render json: { items: folder }
end
def tree
tree = folder_tree_branch(@project_folder, tree)
render json: { items: tree }
end
private
def load_project_folder
@project_folder = current_team.project_folders.find_by(id: params[:id])
end
end
end

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
module Navigator
class ProjectsController < BaseController
before_action :load_project
before_action :check_read_permissions, except: :index
def index
project_and_folders = project_level_branch(nil, params[:archived] == 'true')
render json: { items: project_and_folders }
end
def show
experiments = experiment_level_branch(@project, params[:archived] == 'true')
render json: { items: experiments }
end
def tree
experiments = experiment_level_branch(@project, params[:archived] == 'true')
tree = project_level_branch(@project.project_folder, params[:archived] == 'true')
tree.find { |i| i[:id] == @project.code }[:children] = experiments
tree = build_folder_tree(@project.project_folder, tree) if @project.project_folder
render json: { items: tree }
end
private
def load_project
@project = current_team.projects.find_by(id: params[:id])
end
def check_read_permissions
render_403 and return unless can_read_project?(@project)
end
end
end

View file

@ -482,6 +482,20 @@ class ProjectsController < ApplicationController
end
def set_navigator
@navigator = true
@navigator = if @project
{
url: tree_navigator_project_path(@project),
id: @project.code
}
elsif current_folder
{
url: tree_navigator_project_folder_path(current_folder),
id: current_folder.code
}
else
{
url: navigator_projects_path
}
end
end
end

View file

@ -8,7 +8,10 @@ Vue.use(PerfectScrollbar);
Vue.prototype.i18n = window.I18n;
window.addEventListener('DOMContentLoaded', () => {
window.addEventListener('turbolinks:load', () => {
if ($('#sciNavigationNavigatorContainer').length === 0) return;
if ($('.navigator-container').length > 0) return;
const navigator = new Vue({
el: '#sciNavigationNavigatorContainer',
components: {

View file

@ -1,5 +1,5 @@
<template>
<div class="w-72 h-full border rounded bg-white flex flex-col right-0 absolute">
<div class="w-72 h-full border rounded bg-white flex flex-col right-0 absolute navigator-container">
<div class="p-3 flex items-center">
<i class="fas fa-bars p-2 cursor-pointer"></i>
<div class="font-bold text-base">
@ -7,9 +7,9 @@
</div>
<i @click="$emit('navigator:colapse')" class="fas fa-times ml-auto cursor-pointer"></i>
</div>
<div class="grow px-2 py-4">
<NavigatorItem v-for="item in sortedMenuItems" :key="item.id" :item="item" />
</div>
<perfect-scrollbar class="grow px-2 py-4 relative">
<NavigatorItem v-for="item in sortedMenuItems" :key="item.id" :currentItemId="currentItemId" :item="item" />
</perfect-scrollbar>
</div>
</template>
@ -25,28 +25,39 @@ export default {
data() {
return {
menuItems: [],
navigatorCollapsed: false
navigatorCollapsed: false,
navigatorUrl: null,
currentItemId: null
}
},
computed: {
sortedMenuItems() {
return this.menuItems.sort((a, b) => a.name - b.name)
}
},
created() {
this.menuItems = [
{id: 'p1', name: 'Project 1', url: '/', archive: false, children: [
{id: 'e1', name: 'Experiment 1', url: '/', archive: false}
]},
{id: 'f1', name: 'Folder', url: '/', archive: false, icon: 'fas fa-folder', children: [
{id: 'p2', name: 'Project 2', url: '/', archive: false, children: [
{id: 'e2', name: 'Experiment 2', url: '/', archive: false, children: [
{id: 't1', name: 'Task', url: '/', archive: false}
]}
]}
]}
]
}
this.changePage();
this.loadTree();
$(document).on('turbolinks:load', () => {
this.changePage();
if ($(`[navigator-item-id="${this.currentItemId}"]`).length === 0) {
this.loadTree();
}
});
},
methods: {
changePage() {
this.navigatorUrl = $('#active_navigator_url').val();
this.currentItemId = $('#active_navigator_item').val();
},
loadTree() {
if (!this.navigatorUrl) return;
$.get(this.navigatorUrl, {archived: false}, (data) => {
this.menuItems = data.items
})
}
},
}
</script>

View file

@ -1,15 +1,19 @@
<template>
<div class="sn-color-primary pl-7 pt-4 flex items-center flex-wrap">
<div class="w-5 mr-2 flex justify-start">
<i v-if="haveChildren"
class="fas cursor-pointer"
:class="{'fa-chevron-right': !childrenOpen, 'fa-chevron-down': childrenOpen }"
@click="childrenOpen = !childrenOpen"></i>
<div class="sn-color-primary pl-7 w-64 flex justify-center flex-col" :navigator-item-id="item.id">
<div class="p-2 flex items-center whitespace-nowrap" :class="{ 'sn-background-background-violet': activeItem }">
<div class="w-5 mr-2 flex justify-start shrink-0">
<i v-if="hasChildren"
class="fas cursor-pointer"
:class="{'fa-chevron-right': !childrenExpanded, 'fa-chevron-down': childrenExpanded }"
@click="toggleChildren"></i>
</div>
<i v-if="itemIcon" class="mr-2" :class="itemIcon"></i>
<a :href="item.url" class="text-ellipsis overflow-hidden">
{{ item.name }}
</a>
</div>
<i v-if="item.icon" class="mr-2" :class="item.icon"></i>
{{ item.name }}
<div v-if="childrenOpen" class="basis-full">
<NavigatorItem v-for="item in sortedMenuItems" :key="item.id" :item="item" />
<div class="basis-full" :class="{'hidden': !childrenExpanded}">
<NavigatorItem v-for="item in sortedMenuItems" @item:expand="treeExpand" :key="item.id" :currentItemId="currentItemId" :item="item" />
</div>
</div>
</template>
@ -18,20 +22,67 @@
export default {
name: 'NavigatorItem',
props: {
item: Object
item: Object,
currentItemId: String
},
data: function() {
return {
childrenOpen: false
}
childrenExpanded: false,
children: []
};
},
computed: {
haveChildren: function() {
return this.item.children && this.item.children.length > 0
hasChildren: function() {
return this.item.has_children;
},
sortedMenuItems: function() {
if (!this.haveChildren) return []
return this.item.children.sort((a, b) => a.name - b.name)
return this.children.sort((a, b) => a.name - b.name);
},
activeItem: function() {
return this.item.id == this.currentItemId;
},
itemIcon: function() {
switch(this.item.type) {
case 'folder':
return 'fas fa-folder';
default:
return null;
}
}
},
created: function() {
if (this.item.children) this.children = this.item.children;
},
mounted: function() {
this.selectItem();
},
watch: {
currentItemId: function() {
this.selectItem();
}
},
methods: {
toggleChildren: function() {
this.childrenExpanded = !this.childrenExpanded;
if (this.childrenExpanded) this.loadChildren();
},
loadChildren: function() {
$.get(this.item.children_url, {archived: false}, (data) => {
this.children = data.items;
});
},
treeExpand: function() {
this.childrenExpanded = true;
this.$emit('item:expand');
},
selectItem: function() {
if (this.activeItem && !this.childrenExpanded) {
this.$emit('item:expand');
if (this.hasChildren) {
this.childrenExpanded = true;
this.loadChildren();
}
}
}
},
}

View file

@ -89,6 +89,21 @@ class Experiment < ApplicationRecord
.where(project: Project.viewable_by_user(user, teams))
end
def self.with_children_viewable_by_user(user)
joins("
LEFT OUTER JOIN my_modules ON my_modules.experiment_id = experiments.id
LEFT OUTER JOIN user_assignments my_module_user_assignments
ON my_module_user_assignments.assignable_id = my_modules.id AND
my_module_user_assignments.assignable_type = 'MyModule'
LEFT OUTER JOIN user_roles my_module_user_roles
ON my_module_user_roles.id = my_module_user_assignments.user_role_id
")
.where('
(my_module_user_assignments.user_id = ? AND my_module_user_roles.permissions @> ARRAY[?]::varchar[]
OR my_modules.id IS NULL)
', user.id, MyModulePermissions::READ)
end
def self.filter_by_teams(teams = [])
return self if teams.blank?

View file

@ -109,6 +109,29 @@ class Project < ApplicationRecord
.or(projects.with_granted_permissions(user, ProjectPermissions::READ)).distinct
end
def self.with_children_viewable_by_user(user)
joins("
LEFT OUTER JOIN experiments ON experiments.project_id = projects.id
LEFT OUTER JOIN user_assignments experiment_user_assignments
ON experiment_user_assignments.assignable_id = experiments.id AND
experiment_user_assignments.assignable_type = 'Experiment'
LEFT OUTER JOIN user_roles experiment_user_roles
ON experiment_user_roles.id = experiment_user_assignments.user_role_id
LEFT OUTER JOIN my_modules ON my_modules.experiment_id = experiments.id
LEFT OUTER JOIN user_assignments my_module_user_assignments
ON my_module_user_assignments.assignable_id = my_modules.id AND
my_module_user_assignments.assignable_type = 'MyModule'
LEFT OUTER JOIN user_roles my_module_user_roles
ON my_module_user_roles.id = my_module_user_assignments.user_role_id
")
.where('
(experiment_user_assignments.user_id = ? AND experiment_user_roles.permissions @> ARRAY[?]::varchar[]
OR experiments.id IS NULL) AND
(my_module_user_assignments.user_id = ? AND my_module_user_roles.permissions @> ARRAY[?]::varchar[]
OR my_modules.id IS NULL)
', user.id, ExperimentPermissions::READ, user.id, MyModulePermissions::READ)
end
def self.filter_by_teams(teams = [])
teams.blank? ? self : where(team: teams)
end

View file

@ -1,6 +1,9 @@
# frozen_string_literal: true
class ProjectFolder < ApplicationRecord
ID_PREFIX = 'PF'
include PrefixedIdModel
include ArchivableModel
include SearchableModel
include SearchableByNameModel

View file

@ -1,4 +1,6 @@
<% if @navigator %>
<%= hidden_field_tag :active_navigator_item, @navigator[:id] %>
<%= hidden_field_tag :active_navigator_url, @navigator[:url] %>
<div id="sciNavigationNavigatorContainer"
class="contents"
data-navigator-state-url="<%= navigator_state_navigations_path %>"

View file

@ -292,6 +292,32 @@ Rails.application.routes.draw do
resources :my_modules, only: %i(show update edit)
end
namespace :navigator do
resources :project_folders, only: %i(show) do
member do
get :tree
end
end
resources :projects, only: %i(show index) do
member do
get :tree
end
end
resources :experiments, only: %i(show) do
member do
get :tree
end
end
resources :my_modules, only: %i(show) do
member do
get :tree
end
end
end
resources :projects, except: [:destroy] do
resources :project_comments,
path: '/comments',

View file

@ -15,6 +15,10 @@ module.exports = {
},
},
},
blocklist: [
'collapse',
'container'
],
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/aspect-ratio'),