From cb6b2c38551459ea532fff7a4aedaa8257f0f528 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 21 Apr 2023 15:25:52 +0200 Subject: [PATCH] Add backend for navigator [SCI-8233] --- app/assets/javascripts/sidebar.js | 5 - app/assets/javascripts/sidebar_toggle.js | 2 - .../shared_styles/constants/colors.scss | 4 + app/controllers/experiments_controller.rb | 5 +- app/controllers/my_modules_controller.rb | 5 +- app/controllers/navigator/base_controller.rb | 155 ++++++++++++++++++ .../navigator/experiments_controller.rb | 36 ++++ .../navigator/my_modules_controller.rb | 33 ++++ .../navigator/project_folders_controller.rb | 23 +++ .../navigator/projects_controller.rb | 38 +++++ app/controllers/projects_controller.rb | 16 +- .../packs/vue/navigation/navigator.js | 5 +- app/javascript/vue/navigation/navigator.vue | 49 +++--- .../vue/navigation/navigator_item.vue | 85 ++++++++-- app/models/experiment.rb | 15 ++ app/models/project.rb | 23 +++ app/models/project_folder.rb | 3 + .../shared/navigation/_navigator.html.erb | 2 + config/routes.rb | 26 +++ config/tailwind.config.js | 4 + 20 files changed, 487 insertions(+), 47 deletions(-) create mode 100644 app/controllers/navigator/base_controller.rb create mode 100644 app/controllers/navigator/experiments_controller.rb create mode 100644 app/controllers/navigator/my_modules_controller.rb create mode 100644 app/controllers/navigator/project_folders_controller.rb create mode 100644 app/controllers/navigator/projects_controller.rb diff --git a/app/assets/javascripts/sidebar.js b/app/assets/javascripts/sidebar.js index c3eafe46a..2d7924c66 100644 --- a/app/assets/javascripts/sidebar.js +++ b/app/assets/javascripts/sidebar.js @@ -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() { diff --git a/app/assets/javascripts/sidebar_toggle.js b/app/assets/javascripts/sidebar_toggle.js index 04976db09..593a4f32c 100644 --- a/app/assets/javascripts/sidebar_toggle.js +++ b/app/assets/javascripts/sidebar_toggle.js @@ -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'); diff --git a/app/assets/stylesheets/shared_styles/constants/colors.scss b/app/assets/stylesheets/shared_styles/constants/colors.scss index 05b6e7520..1310a6f8d 100644 --- a/app/assets/stylesheets/shared_styles/constants/colors.scss +++ b/app/assets/stylesheets/shared_styles/constants/colors.scss @@ -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); +} diff --git a/app/controllers/experiments_controller.rb b/app/controllers/experiments_controller.rb index 4b8e294de..b217b9b7a 100644 --- a/app/controllers/experiments_controller.rb +++ b/app/controllers/experiments_controller.rb @@ -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 diff --git a/app/controllers/my_modules_controller.rb b/app/controllers/my_modules_controller.rb index f356b35f7..89fa93a91 100644 --- a/app/controllers/my_modules_controller.rb +++ b/app/controllers/my_modules_controller.rb @@ -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 diff --git a/app/controllers/navigator/base_controller.rb b/app/controllers/navigator/base_controller.rb new file mode 100644 index 000000000..49e9272af --- /dev/null +++ b/app/controllers/navigator/base_controller.rb @@ -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 diff --git a/app/controllers/navigator/experiments_controller.rb b/app/controllers/navigator/experiments_controller.rb new file mode 100644 index 000000000..a18132ea6 --- /dev/null +++ b/app/controllers/navigator/experiments_controller.rb @@ -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 diff --git a/app/controllers/navigator/my_modules_controller.rb b/app/controllers/navigator/my_modules_controller.rb new file mode 100644 index 000000000..c700c8e3b --- /dev/null +++ b/app/controllers/navigator/my_modules_controller.rb @@ -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 diff --git a/app/controllers/navigator/project_folders_controller.rb b/app/controllers/navigator/project_folders_controller.rb new file mode 100644 index 000000000..306f390a0 --- /dev/null +++ b/app/controllers/navigator/project_folders_controller.rb @@ -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 diff --git a/app/controllers/navigator/projects_controller.rb b/app/controllers/navigator/projects_controller.rb new file mode 100644 index 000000000..35abca7fb --- /dev/null +++ b/app/controllers/navigator/projects_controller.rb @@ -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 diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index a0db722eb..8b163f817 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -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 diff --git a/app/javascript/packs/vue/navigation/navigator.js b/app/javascript/packs/vue/navigation/navigator.js index 9c3c224b9..df7ed3f73 100644 --- a/app/javascript/packs/vue/navigation/navigator.js +++ b/app/javascript/packs/vue/navigation/navigator.js @@ -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: { diff --git a/app/javascript/vue/navigation/navigator.vue b/app/javascript/vue/navigation/navigator.vue index 8a55ed7e9..ba80ef78f 100644 --- a/app/javascript/vue/navigation/navigator.vue +++ b/app/javascript/vue/navigation/navigator.vue @@ -1,5 +1,5 @@ @@ -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 + }) + } + }, } diff --git a/app/javascript/vue/navigation/navigator_item.vue b/app/javascript/vue/navigation/navigator_item.vue index 58aaf35f9..0ae406bb5 100644 --- a/app/javascript/vue/navigation/navigator_item.vue +++ b/app/javascript/vue/navigation/navigator_item.vue @@ -1,15 +1,19 @@ @@ -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(); + } + } } }, } diff --git a/app/models/experiment.rb b/app/models/experiment.rb index 0058cb125..ae5573e59 100644 --- a/app/models/experiment.rb +++ b/app/models/experiment.rb @@ -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? diff --git a/app/models/project.rb b/app/models/project.rb index 6b8b4e73d..e87dc7d08 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -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 diff --git a/app/models/project_folder.rb b/app/models/project_folder.rb index 4090a4f13..3f672700f 100644 --- a/app/models/project_folder.rb +++ b/app/models/project_folder.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class ProjectFolder < ApplicationRecord + ID_PREFIX = 'PF' + include PrefixedIdModel + include ArchivableModel include SearchableModel include SearchableByNameModel diff --git a/app/views/shared/navigation/_navigator.html.erb b/app/views/shared/navigation/_navigator.html.erb index e9890aa6f..f63515380 100644 --- a/app/views/shared/navigation/_navigator.html.erb +++ b/app/views/shared/navigation/_navigator.html.erb @@ -1,4 +1,6 @@ <% if @navigator %> + <%= hidden_field_tag :active_navigator_item, @navigator[:id] %> + <%= hidden_field_tag :active_navigator_url, @navigator[:url] %>