Add initial table [SCI-9680]

This commit is contained in:
Anton 2023-11-10 13:34:36 +01:00
parent 8a0dd0258e
commit 38024d563a
19 changed files with 463 additions and 49 deletions

View file

@ -568,11 +568,6 @@ li.module-hover {
// New projects page
.projects-index {
--content-header-size: 9em;
.content-header {
height: var(--content-header-size);
}
.project-users-list {
hr {
margin: .5em 0;

View file

@ -3,7 +3,6 @@
// scss-lint:disable SelectorFormat
.cards-wrapper {
--content-header-size: 9em;
--card-min-width: 200px;
--list-columns-number: 5;
align-items: center;

View file

@ -146,7 +146,7 @@ class LabelTemplatesController < ApplicationController
actions:
Toolbars::LabelTemplatesService.new(
current_user,
label_template_ids: params[:item_ids].split(',')
label_template_ids: JSON.parse(params[:items]).map { |i| i['id'] }
).actions
}
end

View file

@ -30,13 +30,21 @@ class ProjectsController < ApplicationController
before_action :set_current_projects_view_type, only: %i(index cards)
layout 'fluid'
def index; end
def index
respond_to do |format|
format.json do
projects = Lists::ProjectsService.new(current_team, current_user, current_folder, params).call
render json: projects, each_serializer: Lists::ProjectAndFolderSerializer, user: current_user, meta: pagination_dict(projects)
end
format.html do; end
end
end
def cards
overview_service = ProjectsOverviewService.new(current_team, current_user, current_folder, params)
title = params[:view_mode] == 'archived' ? t('projects.index.head_title_archived') : t('projects.index.head_title')
if filters_included?
if false && filters_included?
render json: {
toolbar_html: render_to_string(partial: 'projects/index/toolbar'),
filtered: true,
@ -385,8 +393,7 @@ class ProjectsController < ApplicationController
actions:
Toolbars::ProjectsService.new(
current_user,
project_ids: params[:project_ids].split(','),
project_folder_ids: params[:project_folder_ids].split(',')
items: JSON.parse(params[:items]),
).actions
}
end

View file

@ -1,12 +1,10 @@
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import LabelTemplatesTable from '../../vue/label_template/table.vue';
import { handleTurbolinks } from './helpers/turbolinks.js';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp();
app.component('LabelTemplatesTable', LabelTemplatesTable);
app.config.globalProperties.i18n = window.I18n;
app.use(PerfectScrollbar);
app.mount('#labelTemplatesTable');
handleTurbolinks(app);
mountWithTurbolinks(app, '#labelTemplatesTable');

View file

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

View file

@ -0,0 +1,100 @@
<template>
<div class="h-full">
<DataTable :columnDefs="columnDefs"
tableId="ProjectsList"
:dataUrl="dataSource"
:reloadingTable="reloadingTable"
:toolbarActions="toolbarActions"
:actionsUrl="actionsUrl"
:withRowMenu="true"
@tableReloaded="reloadingTable = false"
/>
</div>
</template>
<script>
import axios from '../../packs/custom_axios.js';
import DataTable from '../shared/datatable/table.vue'
import UsersRenderer from './renderers/users.vue'
export default {
name: 'ProjectsList',
components: {
DataTable,
UsersRenderer,
},
props: {
dataSource: {
type: String,
required: true
},
actionsUrl: {
type: String,
required: true
},
createUrl: {
type: String,
},
createFolderUrl: {
type: String,
}
},
data() {
return {
reloadingTable: false,
columnDefs: [
{ field: "name", flex: 1, headerName: this.i18n.t('projects.index.card.name'), sortable: true, cellRenderer: this.nameRenderer },
{ 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 }
]
}
},
computed: {
toolbarActions() {
let left = []
if (this.createUrl) {
left.push({
name: 'create',
icon: 'sn-icon sn-icon-new-task',
label: this.i18n.t('projects.index.new'),
type: 'emit',
path: this.createUrl,
buttonStyle: 'btn btn-primary'
})
}
if (this.createFolderUrl) {
left.push({
name: 'create_folder',
icon: 'sn-icon sn-icon-folder',
label: this.i18n.t('projects.index.new_folder'),
type: 'emit',
path: this.createFolderUrl,
buttonStyle: 'btn btn-light',
})
}
return {
left: left,
right: []
}
}
},
methods: {
nameRenderer(params) {
let showUrl = params.data.urls.show;
return `<a href="${showUrl}" class="flex items-center gap-1">
${params.data.folder ? 'sn-icon mini sn-icon-mini-folder-left' : ''}
${params.data.name}
</a>`
},
visibiltyRenderer(params) {
if (params.data.type !== 'projects') return ''
return params.data.hidden ? this.i18n.t('projects.index.hidden') : this.i18n.t('projects.index.visible');
},
}
}
</script>

View file

@ -0,0 +1,39 @@
<template>
<div class="flex items-center gap-1 cursor pointer">
<div v-for="(user, i) in visibleUsers" :key="i" :title="user.full_name">
<img :src="user.avatar" class="w-7 h-7" />
</div>
<div v-if="hiddenUsers.length > 0" :title="hiddenUsersTitle" class="flex shrink-0 items-center justify-center w-7 h-7 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 bg-sn-light-grey text-sn-dark-grey">
<i class="sn-icon sn-icon-new-task"></i>
</div>
</div>
</template>
<script>
export default {
name: 'UsersRenderer',
props: {
params: {
required: true
}
},
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")
}
}
}
</script>

View file

@ -19,7 +19,7 @@
</template>
<script>
export default {
name: 'deleteStepModal',
name: 'confirmationModal',
props: {
title: {
type: String,

View file

@ -0,0 +1,62 @@
<template>
<div class="flex items-center">
<MenuDropdown
:listItems="this.formattedList"
:btnClasses="'btn btn-light icon-btn'"
:position="'right'"
:alwaysShow="true"
:btnIcon="'sn-icon sn-icon-more-hori'"
@open="loadActions"
></MenuDropdown>
</div>
</template>
<script>
import MenuDropdown from '../menu_dropdown.vue'
import axios from '../../../packs/custom_axios.js';
export default {
name: 'RowMenuRenderer',
props: {
params: {
required: true
}
},
data() {
return {
actionsMenu: []
}
},
components: {
MenuDropdown
},
computed: {
formattedList() {
return this.actionsMenu.map((item) => {
let newItem = { text: item.label }
if (item.type == 'emit') {
newItem.emit = item.name
}
if (item.type == 'link') {
newItem.url = item.path
}
return newItem
})
}
},
methods: {
loadActions() {
if (this.actionsMenu.length > 0) return
axios.get(this.params.data.urls.actions)
.then((response) => {
this.actionsMenu = response.data.actions
})
.catch((error) => {
console.log(error)
})
}
}
}
</script>

View file

@ -1,21 +1,24 @@
<template>
<div class="flex flex-col h-full">
<div class="relative flex flex-col flex-grow">
<div class="relative flex flex-col flex-grow z-10">
<Toolbar :toolbarActions="toolbarActions" @toolbar:action="emitAction" :searchValue="searchValue" @search:change="setSearchValue" />
<ag-grid-vue
class="ag-theme-alpine w-full flex-grow h-full"
class="ag-theme-alpine w-full flex-grow h-full z-10"
:class="{'opacity-0': initializing}"
:columnDefs="columnDefs"
:rowData="rowData"
:defaultColDef="defaultColDef"
:rowSelection="'multiple'"
:suppressRowTransform="true"
:gridOptions="gridOptions"
:suppressRowClickSelection="true"
@grid-ready="onGridReady"
@first-data-rendered="onFirstDataRendered"
@sortChanged="setOrder"
@columnResized="saveColumnsState"
@columnMoved="saveColumnsState"
@rowSelected="setSelectedRows"
@cellClicked="clickCell"
:CheckboxSelectionCallback="withCheckboxes"
>
</ag-grid-vue>
@ -52,6 +55,7 @@ import Pagination from './pagination.vue';
import CustomHeader from './tableHeader';
import ActionToolbar from './action_toolbar.vue';
import Toolbar from './toolbar.vue';
import RowMenuRenderer from './row_menu_renderer.vue';
export default {
name: "App",
@ -60,6 +64,10 @@ export default {
type: Boolean,
default: true,
},
withRowMenu: {
type: Boolean,
default: false,
},
tableId: {
type: String,
required: true,
@ -108,7 +116,8 @@ export default {
Pagination,
agColumnHeader: CustomHeader,
ActionToolbar,
Toolbar
Toolbar,
RowMenuRenderer
},
computed: {
perPageOptions() {
@ -121,7 +130,7 @@ export default {
},
actionsParams() {
return {
item_ids: this.selectedRows.map(row => row.id).join(',')
items: JSON.stringify(this.selectedRows.map(row => { return {id: row.id, type: row.type} }))
}
},
gridOptions() {
@ -139,7 +148,23 @@ export default {
checkboxSelection: true,
width: 48,
minWidth: 48,
resizable: false
resizable: false,
pinned: 'left'
});
}
if (this.withRowMenu) {
this.columnDefs.push({
field: "rowMenu",
headerName: '',
width: 72,
minWidth: 72,
resizable: false,
sortable: false,
cellRenderer: 'RowMenuRenderer',
cellStyle: {overflow: 'visible'},
pinned: 'right'
});
}
},
@ -159,7 +184,7 @@ export default {
},
methods: {
formatData(data) {
return data.map( (item) => Object.assign({}, item.attributes, { id: item.id }) );
return data.map( (item) => Object.assign({}, item.attributes, { id: item.id, type: item.type }) );
},
resize() {
if (this.tableState) return;
@ -234,6 +259,11 @@ export default {
setSearchValue(value) {
this.searchValue = value;
this.loadData();
},
clickCell(e) {
if (e.column.colId !== 'rowMenu') {
e.node.setSelected(true);
}
}
}
};

View file

@ -1,5 +1,5 @@
<template>
<div class="relative" v-if="listItems.length > 0" v-click-outside="closeMenu">
<div class="relative" v-if="listItems.length > 0 || alwaysShow" >
<button ref="openBtn" :class="btnClasses" @click="showMenu = !showMenu">
<i v-if="btnIcon" :class="btnIcon"></i>
{{ btnText }}
@ -23,7 +23,7 @@
: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"
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 }}
@ -49,7 +49,7 @@
: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"
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 }}
@ -75,6 +75,7 @@ export default {
btnText: { type: String, required: false },
btnIcon: { type: String, required: false },
caret: { type: Boolean, default: false },
alwaysShow: { type: Boolean, default: false }
},
data() {
return {
@ -93,6 +94,7 @@ export default {
this.$refs.flyout.style.marginBottom = `${this.$refs.openBtn.offsetHeight}px`;
this.updateOpenDirectoin();
})
this.$emit('open');
}
}
},

View file

@ -39,6 +39,11 @@ class UserAssignment < ApplicationRecord
user_assignments.none?
end
def user_name_with_role
"#{user.name} - #{user_role.name}"
end
private
def set_assignable_team

View file

@ -0,0 +1,51 @@
module Lists
class ProjectAndFolderSerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
attributes :name, :code, :created_at, :archived_on, :users, :hidden, :urls, :folder
def folder
!project?
end
def code
object.code if project?
end
def created_at
I18n.l(object.created_at, format: :full) if project?
end
def archived_on
I18n.l(object.archived_on, format: :full) if project? && object.archived_on
end
def hidden
object.hidden? if project?
end
def users
if project?
object.user_assignments.map do |ua|
{
avatar: avatar_path(ua.user, :icon_small),
full_name: ua.user_name_with_role
}
end
end
end
def urls
{
show: project? ? project_path(object) : project_folder_path(object),
actions: actions_toolbar_projects_path(items: [{id: object.id, type: project? ? 'projects' : 'project_folders'}].to_json)
}
end
private
def project?
object.class == Project
end
end
end

View file

@ -0,0 +1,110 @@
module Lists
class ProjectsService < BaseService
def initialize(team, user, folder, params)
@team = team
@user = user
@current_folder = folder
@params = params
@view_mode = ''
end
def call
projects = fetch_projects
folders = fetch_project_folders
projects = filter_project_records(projects)
folders = filter_project_folder_records(folders)
projects = projects.where(project_folder: @current_folder)
folders = folders.where(parent_folder: @current_folder)
records = projects + folders
records = sort_records(records)
paginate_records(records)
end
private
def fetch_projects
@team.projects
.includes(:team, user_assignments: %i(user user_role))
.includes(:project_comments, experiments: { my_modules: { my_module_status: :my_module_status_implications } })
.visible_to(@user, @team)
.left_outer_joins(:project_comments)
.select('projects.*')
.select('COUNT(DISTINCT comments.id) AS comment_count')
.group('projects.id')
end
def fetch_project_folders
project_folders = @team.project_folders
.includes(:team)
.joins('LEFT OUTER JOIN project_folders child_folders
ON child_folders.parent_folder_id = project_folders.id')
.left_outer_joins(:projects)
project_folders.select('project_folders.*')
.select('COUNT(DISTINCT projects.id) AS projects_count')
.select('COUNT(DISTINCT child_folders.id) AS folders_count')
.group('project_folders.id')
end
def filter_project_records(records)
return records
records = records.archived if @view_mode == 'archived'
records = records.active if @view_mode == 'active'
if @params[:search].present?
records = records.where_attributes_like(['projects.name', Project::PREFIXED_ID_SQL], @params[:search])
end
if @params[:members].present?
records = records.joins(:user_assignments).where(user_assignments: { user_id: @params[:members] })
end
records = records.where('projects.created_at > ?', @params[:created_on_from]) if @params[:created_on_from].present?
records = records.where('projects.created_at < ?', @params[:created_on_to]) if @params[:created_on_to].present?
records = records.where('projects.archived_on < ?', @params[:archived_on_to]) if @params[:archived_on_to].present?
if @params[:archived_on_from].present?
records = records.where('projects.archived_on > ?', @params[:archived_on_from])
end
records
end
def filter_project_folder_records(records)
return records
records = records.archived if @view_mode == 'archived'
records = records.active if @view_mode == 'active'
records = records.where_attributes_like('project_folders.name', @params[:search]) if @params[:search].present?
records
end
def sort_records(records)
return records unless @params[:order]
sort = "#{order_params[:column]}_#{sort_direction(order_params)}"
case sort
when 'created_at_ASC'
records.sort_by(&:created_at).reverse!
when 'created_at_DESC'
records.sort_by(&:created_at)
when 'name_ASC'
records.sort_by { |c| c.name.downcase }
when 'name_DESC'
records.sort_by { |c| c.name.downcase }.reverse!
when 'code_ASC'
records.sort_by(&:id)
when 'code_DESC'
records.sort_by(&:id).reverse!
when 'archived_on_ASC'
records.sort_by(&:archived_on)
when 'archived_on_DESC'
records.sort_by(&:archived_on).reverse!
end
end
def paginate_records(records)
Kaminari.paginate_array(records).page(@params[:page]).per(@params[:per_page])
end
end
end

View file

@ -7,8 +7,11 @@ module Toolbars
include Canaid::Helpers::PermissionsHelper
include Rails.application.routes.url_helpers
def initialize(current_user, project_ids: [], project_folder_ids: [])
def initialize(current_user, items: [])
@current_user = current_user
project_ids = items.select { |i| i['type'] == 'projects' }.map { |i| i['id'] }
project_folder_ids = items.select { |i| i['type'] == 'project_folders' }.map { |i| i['id'] }
@projects = current_user.current_team.projects.where(id: project_ids)
@project_folders = current_user.current_team.project_folders.where(id: project_folder_ids)
@ -59,7 +62,7 @@ module Toolbars
icon: 'sn-icon sn-icon-edit',
button_class: 'edit-btn',
path: edit_project_path(project),
type: :legacy
type: :emit
}
else
project_folder = @items.first
@ -72,7 +75,7 @@ module Toolbars
icon: 'sn-icon sn-icon-edit',
button_class: 'edit-btn',
path: edit_project_folder_path(project_folder),
type: :legacy
type: :emit
}
end
end
@ -96,9 +99,8 @@ module Toolbars
name: 'access',
label: I18n.t('general.access'),
icon: 'sn-icon sn-icon-project-member-access',
button_class: 'access-btn',
path: path,
type: 'remote-modal'
type: :emit
}
end
@ -110,9 +112,8 @@ module Toolbars
name: 'move',
label: I18n.t('projects.index.move_button'),
icon: 'sn-icon sn-icon-move',
button_class: 'move-projects-btn',
path: move_to_modal_project_folders_path,
type: :legacy
type: :emit
}
end
@ -123,9 +124,8 @@ module Toolbars
name: 'export',
label: I18n.t('projects.export_projects.export_button'),
icon: 'sn-icon sn-icon-export',
button_class: 'export-projects-btn',
path: export_projects_modal_team_path(@items.first.team),
type: :legacy
type: :emit
}
end
@ -138,10 +138,8 @@ module Toolbars
name: 'archive',
label: I18n.t('projects.index.archive_button'),
icon: 'sn-icon sn-icon-archive',
button_class: 'archive-projects-btn',
path: archive_group_projects_path,
type: :request,
request_method: :post
type: :emit,
}
end
@ -156,8 +154,7 @@ module Toolbars
icon: 'sn-icon sn-icon-restore',
button_class: 'restore-projects-btn',
path: restore_group_projects_path,
type: :request,
request_method: :post
type: :emit
}
end
@ -170,9 +167,8 @@ module Toolbars
name: 'delete_folders',
label: I18n.t('general.delete'),
icon: 'sn-icon sn-icon-delete',
button_class: 'delete-folders-btn',
path: destroy_modal_project_folders_path(project_folder_ids: @items.map(&:id)),
type: 'remote-modal'
type: :emit
}
end
@ -189,10 +185,7 @@ module Toolbars
name: 'comments',
label: I18n.t('Comments'),
icon: 'sn-icon sn-icon-comments',
button_class: 'open-comments-sidebar',
item_type: 'Project',
item_id: project.id,
type: :legacy
type: :emit
}
end

View file

@ -9,6 +9,20 @@
<div id="projectsWrapper" class="content-pane flexible projects-index <%= projects_view_mode %>" data-view-mode="<%= projects_view_mode %>" data-e2e="e2e-projects-container">
<%= render partial: 'projects/index/header', locals: { current_folder: current_folder} %>
<div id="ProjectsList" class="fixed-content-body">
<projects-list
actions-url="<%= actions_toolbar_projects_url %>"
data-source="<%= projects_path(format: :json) %>"
create-url="<%= projects_path if can_create_projects?(current_team) %>"
create-folder-url="<%= project_folders_path if can_create_project_folders?(current_team) %>"
/>
</div>
<%= javascript_include_tag 'vue_projects_list' %>
<div id="toolbarWrapper" class="toolbar-row" data-width-breakpoint="750">
<%= render partial: 'projects/index/toolbar' %>
</div>
<span style="display: none;" data-hook="projects-index-html"></span>
<%= render partial: 'projects/index/modals/edit_modal' %>

View file

@ -19,7 +19,4 @@
<span class="projects-title name-readonly-placeholder"><%= current_folder&.name || t('projects.index.head_title_archived') %></span>
</h1>
</div>
<div id="toolbarWrapper" class="toolbar-row" data-width-breakpoint="750">
<%= render partial: 'projects/index/toolbar' %>
</div>
</div>

View file

@ -46,7 +46,8 @@ const entryList = {
vue_components_export_stock_consumption_modal: './app/javascript/packs/vue/export_stock_consumption_modal.js',
vue_components_manage_stock_value_modal: './app/javascript/packs/vue/manage_stock_value_modal.js',
vue_legacy_datetime_picker: './app/javascript/packs/vue/legacy/datetime_picker.js',
vue_label_templates_table: './app/javascript/packs/vue/label_templates_table.js'
vue_label_templates_table: './app/javascript/packs/vue/label_templates_table.js',
vue_projects_list: './app/javascript/packs/vue/projects_list.js',
}
// Engine pack loading based on https://github.com/rails/webpacker/issues/348#issuecomment-635480949