Merge pull request #6742 from aignatov-bio/ai-sci-9680-replace-projects-table

Finalize projects table
This commit is contained in:
aignatov-bio 2023-12-01 10:35:10 +01:00 committed by GitHub
commit 5acec04794
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 996 additions and 300 deletions

View file

@ -1,6 +1,9 @@
$flyout-shadow: 0px 1px 4px rgba(35, 31, 32, 0.15);
$modal-shadow: 0px 4px 16px rgba(35, 31, 32, 0.15);
.sn-shadow-flyout {
box-shadow: $flyout-shadow;
}
.sn-shadow-menu-sm {
box-shadow: 0px 16px 32px 0px rgba(16, 24, 40, 0.07);

View file

@ -77,4 +77,13 @@
.sci-input-container-v2 .history-flyout li:hover {
@apply bg-sn-super-light-grey;
}
.sci-input-container-v2.error input {
@apply border-sn-alert-passion;
}
.sci-input-container-v2.error::after {
@apply absolute -bottom-5 text-sn-alert-passion text-xs;
content: attr(data-error);
}
}

View file

@ -13,6 +13,10 @@ class ProjectFoldersController < ApplicationController
before_action :check_create_permissions, only: %i(new create)
before_action :check_manage_permissions, only: %i(archive move_to)
def tree
render json: folders_tree(current_team, current_user)
end
def new
@project_folder =
current_team.project_folders.new(parent_folder: current_folder, archived: projects_view_mode_archived?)
@ -48,11 +52,11 @@ class ProjectFoldersController < ApplicationController
move_projects(destination_folder)
move_folders(destination_folder)
end
render json: { flash: I18n.t('projects.move.success_flash') }
render json: { message: I18n.t('projects.move.success_flash') }
rescue StandardError => e
Rails.logger.error e.message
Rails.logger.error e.backtrace.join("\n")
render json: { flash: I18n.t('projects.move.error_flash') }, status: :bad_request
render json: { error: I18n.t('projects.move.error_flash') }, status: :bad_request
end
def move_to_modal
@ -109,7 +113,7 @@ class ProjectFoldersController < ApplicationController
if counter.positive?
render json: { message: t('projects.delete_folders.success_flash', number: counter) }
else
render json: { message: t('projects.delete_folders.error_flash') }, status: :unprocessable_entity
render json: { error: t('projects.delete_folders.error_flash') }, status: :unprocessable_entity
end
end
@ -132,13 +136,9 @@ class ProjectFoldersController < ApplicationController
end
def move_params
parsed_params = ActionController::Parameters.new(
movables: JSON.parse(params[:movables]),
destination_folder_id: params[:destination_folder_id]
)
parsed_params.require(:destination_folder_id)
parsed_params.require(:movables)
parsed_params.permit(:destination_folder_id, movables: %i(id type))
params.require(:destination_folder_id)
params.require(:movables)
params.permit(:destination_folder_id, movables: %i(id type))
end
def check_create_permissions
@ -150,7 +150,7 @@ class ProjectFoldersController < ApplicationController
end
def move_projects(destination_folder)
project_ids = move_params[:movables].collect { |movable| movable[:id] if movable[:type] == 'project' }.compact
project_ids = move_params[:movables].collect { |movable| movable[:id] if movable[:type] == 'projects' }.compact
return if project_ids.blank?
current_team.projects.where(id: project_ids).each do |project|
@ -167,7 +167,7 @@ class ProjectFoldersController < ApplicationController
end
def move_folders(destination_folder)
folder_ids = move_params[:movables].collect { |movable| movable[:id] if movable[:type] == 'project_folder' }.compact
folder_ids = move_params[:movables].collect { |movable| movable[:id] if movable[:type] == 'project_folders' }.compact
return if folder_ids.blank?
current_team.project_folders.where(id: folder_ids).each do |folder|

View file

@ -8,6 +8,7 @@ class ProjectsController < ApplicationController
include CardsViewHelper
include ExperimentsHelper
include Breadcrumbs
include UserRolesHelper
attr_reader :current_folder
@ -19,7 +20,7 @@ class ProjectsController < ApplicationController
before_action :load_current_folder, only: %i(index cards new show)
before_action :check_view_permissions, except: %i(index cards new create edit update archive_group restore_group
users_filter actions_dropdown inventory_assigning_project_filter
actions_toolbar)
actions_toolbar user_roles)
before_action :check_create_permissions, only: %i(new create)
before_action :check_manage_permissions, only: :edit
before_action :load_exp_sort_var, only: :show
@ -363,10 +364,10 @@ class ProjectsController < ApplicationController
def users_filter
users = current_team.users.search(false, params[:query]).map do |u|
{ value: u.id, label: escape_input(u.name), params: { avatar_url: avatar_path(u, :icon_small) } }
[u.id, u.name, { avatar_url: avatar_path(u, :icon_small) }]
end
render json: users, status: :ok
render json: { data: users }, status: :ok
end
def view_type
@ -388,6 +389,11 @@ class ProjectsController < ApplicationController
end
end
def user_roles
render json: { data: user_roles_collection(Project.new).map(&:reverse) }
end
def actions_toolbar
render json: {
actions:

View file

@ -30,34 +30,13 @@ module ProjectsHelper
conns.to_s[1..-2]
end
def sidebar_folders_tree(team, user, sort, folders_only: false)
def folders_tree(team, user)
sort ||= team.current_view_state(user).state.dig('projects', 'active', 'sort')
if projects_view_mode_archived?
records = ProjectFolder.archived.inner_folders(team)
records += team.projects.archived.visible_to(user, team) unless folders_only
records = ProjectFolder.archived.inner_folders(team).order(:name).select(:id, :name, :parent_folder_id)
else
records = ProjectFolder.active.inner_folders(team)
records += team.projects.active.visible_to(user, team) unless folders_only
sort = 'new' if %w(archived_old archived_new).include?(sort)
records = ProjectFolder.active.inner_folders(team).order(:name).select(:id, :name, :parent_folder_id)
end
records = case sort
when 'new'
records.sort_by(&:created_at).reverse!
when 'old'
records.sort_by(&:created_at)
when 'atoz'
records.sort_by { |c| c.name.downcase }
when 'ztoa'
records.sort_by { |c| c.name.downcase }.reverse!
when 'id_asc'
records.sort_by(&:id)
when 'id_desc'
records.sort_by(&:id).reverse!
when 'archived_old'
records.sort_by(&:archived_on)
when 'archived_new'
records.sort_by(&:archived_on).reverse!
end
folders_recursive_builder(nil, records)
end

View file

@ -0,0 +1,76 @@
<template>
<div v-if="!params.folder" class="p-4 rounded sn-shadow-flyout flex flex-col">
<div class="flex items-center gap-2 mb-2">
<div class="sci-checkbox-container">
<input
type="checkbox"
class="sci-checkbox"
@change="itemSelected"
/>
<label :for="params.id" class="sci-checkbox-label"></label>
</div>
<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">
{{ params.name }}
</a>
<div class="grid gap-2 grid-cols-[80px_auto] mt-auto">
<span class="text-sn-grey">{{ i18n.t('projects.index.card.start_date') }}</span>
<span class="font-bold">{{ params.created_at }}</span>
<template v-if="params.archived_on">
<span class="text-sn-grey">{{ i18n.t('projects.index.card.archived_date') }}</span>
<span class="font-bold">{{ params.archived_on }}</span>
</template>
<span class="text-sn-grey">{{ i18n.t('projects.index.card.visibility') }}</span>
<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" />
</div>
</div>
<div v-else class="p-4 rounded sn-shadow-flyout flex flex-col">
<div class="flex items-center gap-2 mb-2">
<div class="sci-checkbox-container">
<input
type="checkbox"
class="sci-checkbox"
@change="itemSelected"
/>
<label :for="params.id" class="sci-checkbox-label"></label>
</div>
<RowMenuRenderer :params="{data: params, dtComponent: dtComponent}" class="ml-auto"/>
</div>
<div class="flex-grow flex items-center justify-center min-h-[6rem] text-sn-blue">
<i class="sn-icon sn-icon-folder"></i>
</div>
<a :href="params.urls.show" class="flex items-center justify-center gap-1 font-bold mb-2 text-sn-black hover:no-underline hover:text-sn-black">
<i class="sn-icon mini sn-icon-mini-folder-left"></i>
{{ params.name }}
</a>
<div class="flex items-center justify-center">
{{ params.folder_info }}
</div>
</div>
</template>
<script>
import RowMenuRenderer from '../shared/datatable/row_menu_renderer.vue'
import UsersRenderer from './renderers/users.vue'
import CardSelectorMixin from '../shared/datatable/mixins/card_selector.js'
export default {
name: "ProjectCard",
props: {
params: Object,
dtComponent: Object
},
components: {
RowMenuRenderer,
UsersRenderer
},
mixins: [CardSelectorMixin]
}
</script>

View file

@ -1,19 +1,58 @@
<template>
<div class="h-full">
<DataTable :columnDefs="columnDefs"
tableId="ProjectsList"
:dataUrl="dataSource"
:reloadingTable="reloadingTable"
:toolbarActions="toolbarActions"
:actionsUrl="actionsUrl"
:withRowMenu="true"
:activePageUrl="activePageUrl"
:archivedPageUrl="archivedPageUrl"
:currentViewMode="currentViewMode"
:filters="filters"
@tableReloaded="reloadingTable = false"
/>
</div>
<DataTable :columnDefs="columnDefs"
tableId="ProjectsList"
:dataUrl="dataSource"
:reloadingTable="reloadingTable"
:toolbarActions="toolbarActions"
:actionsUrl="actionsUrl"
:withRowMenu="true"
:activePageUrl="activePageUrl"
:archivedPageUrl="archivedPageUrl"
:currentViewMode="currentViewMode"
:filters="filters"
:viewRenders="viewRenders"
@tableReloaded="reloadingTable = false"
@comments="openComments"
@archive="archive"
@restore="restore"
@edit="edit"
@create="create"
@create_folder="createFolder"
@delete_folders="deleteFolder"
@export="exportProjects"
@move="move"
>
<template #card="data">
<ProjectCard :params="data.params" :dtComponent="data.dtComponent" ></ProjectCard>
</template>
</DataTable>
<a href="#" ref="commentButton" class="open-comments-sidebar hidden" data-turbolinks="false" data-object-type="Project" data-object-id=""></a>
<ConfirmationModal
:title="i18n.t('projects.index.archive_confirm_title')"
:description="i18n.t('projects.index.archive_confirm')"
:confirmClass="'btn btn-primary'"
:confirmText="i18n.t('general.archive')"
ref="archiveModal"
></ConfirmationModal>
<ConfirmationModal
:title="i18n.t('projects.index.modal_delete_folders.title')"
:description="folderDeleteDescription"
: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'"
:confirmText="i18n.t('projects.export_projects.export_button')"
ref="exportModal"
></ConfirmationModal>
<EditProjectModal v-if="editProject" :userRolesUrl="userRolesUrl" :project="editProject" @close="editProject = null" @update="updateTable" />
<EditFolderModal v-if="editFolder" :folder="editFolder" @close="editFolder = null" @update="updateTable" />
<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" />
</template>
<script>
@ -21,42 +60,51 @@ import axios from '../../packs/custom_axios.js';
import DataTable from '../shared/datatable/table.vue'
import UsersRenderer from './renderers/users.vue'
import ProjectCard from './card.vue'
import ConfirmationModal from '../shared/confirmation_modal.vue'
import EditProjectModal from './modals/edit.vue'
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'
export default {
name: 'ProjectsList',
components: {
DataTable,
UsersRenderer,
ProjectCard,
ConfirmationModal,
EditProjectModal,
EditFolderModal,
NewProjectModal,
NewFolderModal,
MoveModal
},
props: {
dataSource: {
type: String,
required: true
},
actionsUrl: {
type: String,
required: true
},
createUrl: {
type: String,
},
createFolderUrl: {
type: String,
},
activePageUrl: {
type: String,
},
archivedPageUrl: {
type: String,
},
currentViewMode: {
type: String,
required: true
}
dataSource: { type: String, required: true },
actionsUrl: { type: String, required: true },
createUrl: { type: String },
createFolderUrl: { type: String },
activePageUrl: { type: String },
archivedPageUrl: { type: String },
currentViewMode: { type: String, required: true },
usersFilterUrl: { type: String },
userRolesUrl: { type: String },
currentFolderId: { type: String },
foldersTreeUrl: { type: String },
moveToUrl: { type: String },
},
data() {
return {
newProject: false,
newFolder: false,
editProject: null,
editFolder: null,
objectsToMove: null,
reloadingTable: false,
folderDeleteDescription: '',
exportDescription: '',
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 },
@ -67,9 +115,15 @@ export default {
}
},
computed: {
viewRenders() {
return [
{type: 'table'},
{type: 'cards'}
]
},
toolbarActions() {
let left = []
if (this.createUrl) {
if (this.createUrl && this.currentViewMode !== 'archived') {
left.push({
name: 'create',
icon: 'sn-icon sn-icon-new-task',
@ -113,6 +167,16 @@ export default {
})
}
filters.push({
key: 'members',
type: 'Select',
optionsUrl: this.usersFilterUrl,
optionRenderer: this.usersFilterRenderer,
labelRenderer: this.usersFilterRenderer,
label: this.i18n.t("projects.index.filters_modal.members.label"),
placeholder: this.i18n.t("projects.index.filters_modal.members.placeholder"),
})
filters.push({
key: 'folder_search',
type: 'Checkbox',
@ -123,6 +187,12 @@ export default {
}
},
methods: {
usersFilterRenderer(option) {
return `<div class="flex items-center gap-2">
<img src="${option[2].avatar_url}" class="rounded-full w-6 h-6" />
<span>${option[1]}</span>
</div>`
},
nameRenderer(params) {
let showUrl = params.data.urls.show;
return `<a href="${showUrl}" class="flex items-center gap-1">
@ -132,9 +202,85 @@ export default {
},
visibiltyRenderer(params) {
if (params.data.type !== 'projects') return ''
return params.data.hidden ? this.i18n.t('projects.index.hidden') : this.i18n.t('projects.index.visible');
},
openComments(_params, rows) {
this.$refs.commentButton.dataset.objectId = rows[0].id;
this.$refs.commentButton.click();
},
async archive(event, rows) {
const ok = await this.$refs.archiveModal.show()
if (ok) {
axios.post(event.path, { project_ids: rows.map((row) => row.id) }).then((response) => {
this.reloadingTable = true
HelperModule.flashAlertMsg(response.data.message, 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
},
restore(event, rows) {
axios.post(event.path, { project_ids: rows.map((row) => row.id) }).then((response) => {
this.reloadingTable = true
HelperModule.flashAlertMsg(response.data.message, 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
},
edit(event, rows) {
if (rows[0].folder) {
this.editFolder = rows[0]
return
}
this.editProject = rows[0]
},
create() {
this.newProject = true;
},
createFolder() {
this.newFolder = true;
},
updateTable() {
this.editProject = null;
this.editFolder = null;
this.newProject = false;
this.newFolder = false;
this.objectsToMove = null;
this.reloadingTable = true;
},
async deleteFolder(event, rows) {
const description =`
<p>${this.i18n.t('projects.index.modal_delete_folders.description_1_html', {number: rows.length}) }</p>
<p>${this.i18n.t('projects.index.modal_delete_folders.description_2')}</p>`
this.folderDeleteDescription = description;
const ok = await this.$refs.deleteFolderModal.show();
if (ok) {
axios.post(event.path, { project_folder_ids: rows.map((row) => row.id) }).then((response) => {
this.reloadingTable = true
HelperModule.flashAlertMsg(response.data.message, 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
},
async exportProjects(event, rows) {
this.exportDescription = event.message;
const ok = await this.$refs.exportModal.show()
if (ok) {
axios.post(event.path, {
project_ids: rows.filter((row) => !row.folder).map((row) => row.id),
project_folder_ids: rows.filter((row) => row.folder).map((row) => row.id),
}).then((response) => {
this.reloadingTable = true
HelperModule.flashAlertMsg(response.data.message, 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
},
move(event, rows) {
this.objectsToMove = rows;
}
}
}

View file

@ -0,0 +1,83 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><i class="sn-icon sn-icon-close"></i></button>
<h4 class="modal-title truncate !block" id="edit-project-modal-label" :title="project.name">
{{ i18n.t('projects.index.modal_edit_project.modal_title', {project: project.name}) }}
</h4>
</div>
<div class="modal-body">
<div class="mb-6">
<label class="sci-label">{{ i18n.t("projects.index.modal_new_project.name") }}</label>
<div class="sci-input-container-v2" :class="{'error': error}" :data-error="error">
<input type="text" v-model="name" class="sci-input-field" autofocus="true" :placeholder="i18n.t('projects.index.modal_new_project.name_placeholder')" />
</div>
</div>
<div class="flex gap-2 text-xs items-center">
<div class="sci-checkbox-container">
<input type="checkbox" class="sci-checkbox" v-model="visible" value="visible"/>
<span class="sci-checkbox-label"></span>
</div>
<span v-html="i18n.t('projects.index.modal_new_project.visibility_html')"></span>
</div>
<div class="mt-6" :class="{'hidden': !visible}">
<label class="sci-label">{{ i18n.t("user_assignment.select_default_user_role") }}</label>
<SelectDropdown :optionsUrl="userRolesUrl" :value="defaultRole" @change="changeRole" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" @click="submit" type="submit">{{ i18n.t('projects.index.modal_edit_project.submit') }}</button>
</div>
</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";
export default {
name: "EditProjectModal",
props: {
project: Object,
userRolesUrl: String,
},
mixins: [modal_mixin],
components: {
SelectDropdown,
},
data() {
return {
name: this.project.name,
visible: this.project.visible,
defaultRole: this.project.default_public_user_role_id,
error: null,
};
},
methods: {
submit() {
axios.put(this.project.urls.update, {
project: {
name: this.name,
visibility: (this.visible ? 'visible' : 'hidden'),
default_public_user_role_id: this.defaultRole,
}
}).then(() => {
this.error = null;
this.$emit('update');
}).catch((error) => {
this.error = error.response.data.errors.name;
})
},
changeRole(role) {
this.defaultRole = role;
},
}
}
</script>

View file

@ -0,0 +1,64 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><i class="sn-icon sn-icon-close"></i></button>
<h4 class="modal-title truncate !block" id="edit-project-modal-label" :title="folder.name">
{{ i18n.t('projects.index.modal_edit_folder.title', {folder: folder.name}) }}
</h4>
</div>
<div class="modal-body">
<div class="mb-6">
<label class="sci-label">{{ i18n.t("projects.index.modal_edit_folder.folder_name_field") }}</label>
<div class="sci-input-container-v2" :class="{'error': error}" :data-error="error">
<input type="text" v-model="name" class="sci-input-field" autofocus="true" :placeholder="i18n.t('projects.index.modal_new_project.name_placeholder')" />
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" @click="submit" type="submit">{{ i18n.t('projects.index.modal_edit_folder.submit') }}</button>
</div>
</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";
export default {
name: "EditFolderModal",
props: {
folder: Object,
},
mixins: [modal_mixin],
components: {
SelectDropdown,
},
data() {
return {
name: this.folder.name,
error: null,
};
},
methods: {
submit() {
axios.put(this.folder.urls.update, {
project_folder: {
name: this.name,
}
}).then(() => {
this.error = null;
this.$emit('update');
}).catch((error) => {
this.error = error.response.data.errors.name;
})
}
}
}
</script>

View file

@ -0,0 +1,125 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><i class="sn-icon sn-icon-close"></i></button>
<h4 class="modal-title truncate !block" id="edit-project-modal-label">
{{ this.title }}
</h4>
</div>
<div class="modal-body">
<div class="mb-4">{{ this.description }}</div>
<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('projects.index.modal_move_folder.find_folder')" />
<i class="sn-icon sn-icon-search"></i>
</div>
</div>
<div class="max-h-80 overflow-y-auto">
<div class="p-2 flex items-center gap-2 cursor-pointer text-sn-blue hover:bg-sn-super-light-grey"
@click="selectFolder(null)"
:class="{'!bg-sn-super-light-blue': selectedFolderId == null}">
<i class="sn-icon sn-icon-projects"></i>
{{ i18n.t('projects.index.modal_move_folder.projects') }}
</div>
<MoveTree :objects="filteredFoldersTree" :value="selectedFolderId" @selectFolder="selectFolder" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" @click="submit" type="submit">{{ i18n.t('projects.index.modal_move_folder.submit') }}</button>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from '../../../packs/custom_axios.js';
import modal_mixin from "../../shared/modal_mixin";
import MoveTree from './move_tree.vue';
export default {
name: "NewProjectModal",
props: {
selectedObjects: Array,
foldersTreeUrl: String,
moveToUrl: String,
},
mixins: [modal_mixin],
data() {
return {
selectedFolderId: null,
foldersTree: [],
query: '',
};
},
components: {
MoveTree
},
mounted() {
axios.get(this.foldersTreeUrl).then((response) => {
this.foldersTree = response.data;
});
},
computed: {
itemsName() {
return this.i18n.t('projects.index.modal_move_folder.items.' + this.itemsType);
},
title() {
return this.i18n.t('projects.index.modal_move_folder.title', {items: this.itemsName} );
},
description() {
return this.i18n.t('projects.index.modal_move_folder.description', {items: this.itemsName} );
},
itemsType() {
const allTypes = this.selectedObjects.map(obj => obj.type);
const uniqueTypes = [...new Set(allTypes)];
if (uniqueTypes.length == 1) {
return uniqueTypes[0];
} else {
return 'projects_and_folders';
}
},
filteredFoldersTree() {
if (this.query == '') {
return this.foldersTree;
} else {
return this.foldersTree.map((folder) => {
return {
folder: folder.folder,
children: folder.children.filter((child) => {
return child.folder.name.toLowerCase().includes(this.query.toLowerCase());
})
}
}).filter((folder) => {
return folder.folder.name.toLowerCase().includes(this.query.toLowerCase()) || folder.children.length > 0;
});
}
}
},
methods: {
selectFolder(folderId) {
this.selectedFolderId = folderId;
},
submit() {
axios.post(this.moveToUrl, {
destination_folder_id: this.selectedFolderId || 'root_folder',
movables: this.selectedObjects.map((obj) => {
return {
id: obj.id,
type: obj.type,
}
})
}).then((response) => {
this.$emit('move');
HelperModule.flashAlertMsg(response.data.message, 'success');
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.message, 'danger');
})
},
}
}
</script>

View file

@ -0,0 +1,37 @@
<template>
<div class="pl-6" v-for="object in objects" :key="object.folder.id">
<div class="flex items-center">
<i v-if="object.children.length > 0"
:class="{'sn-icon-up': opendedFolders[object.folder.id], 'sn-icon-down': !opendedFolders[object.folder.id]}"
@click="opendedFolders[object.folder.id] = !opendedFolders[object.folder.id]"
class="sn-icon p-2 pr-1 cursor-pointer"></i>
<i v-else class="sn-icon sn-icon-up p-2 pr-1 opacity-0"></i>
<div @click="$emit('selectFolder', object.folder.id)" class="cursor-pointer flex items-center pl-1 flex-1 gap-2 text-sn-blue hover:bg-sn-super-light-grey" :class="{'!bg-sn-super-light-blue': object.folder.id == value}">
<i class="sn-icon sn-icon-folder"></i>
<div class="flex-1 truncate p-2 pl-0" :title="object.folder.name">
{{ object.folder.name }}
</div>
</div>
</div>
<MoveTree v-if="opendedFolders[object.folder.id]" :objects="object.children" :value="value" @selectFolder="$emit('selectFolder', $event)" />
</div>
</template>
<script>
export default {
name: 'MoveTree',
emits: ['selectFolder'],
props: {
objects: Array,
value: Number,
},
components: {
MoveTree: () => import('./move_tree.vue')
},
data() {
return {
opendedFolders: {},
}
},
}
</script>

View file

@ -0,0 +1,85 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><i class="sn-icon sn-icon-close"></i></button>
<h4 class="modal-title truncate !block" id="edit-project-modal-label">
{{ i18n.t('projects.index.modal_new_project.modal_title') }}
</h4>
</div>
<div class="modal-body">
<div class="mb-6">
<label class="sci-label">{{ i18n.t("projects.index.modal_new_project.name") }}</label>
<div class="sci-input-container-v2" :class="{'error': error}" :data-error="error">
<input type="text" v-model="name" class="sci-input-field" autofocus="true" :placeholder="i18n.t('projects.index.modal_new_project.name_placeholder')" />
</div>
</div>
<div class="flex gap-2 text-xs items-center">
<div class="sci-checkbox-container">
<input type="checkbox" class="sci-checkbox" v-model="visible" value="visible"/>
<span class="sci-checkbox-label"></span>
</div>
<span v-html="i18n.t('projects.index.modal_new_project.visibility_html')"></span>
</div>
<div class="mt-6" :class="{'hidden': !visible}">
<label class="sci-label">{{ i18n.t("user_assignment.select_default_user_role") }}</label>
<SelectDropdown :optionsUrl="userRolesUrl" :value="defaultRole" @change="changeRole" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" @click="submit" type="submit">{{ i18n.t('projects.index.modal_new_project.create') }}</button>
</div>
</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";
export default {
name: "NewProjectModal",
props: {
createUrl: String,
userRolesUrl: String,
currentFolderId: String,
},
mixins: [modal_mixin],
components: {
SelectDropdown,
},
data() {
return {
name: '',
visible: false,
defaultRole: null,
error: null,
};
},
methods: {
submit() {
axios.post(this.createUrl, {
project: {
name: this.name,
visibility: (this.visible ? 'visible' : 'hidden'),
default_public_user_role_id: this.defaultRole,
project_folder_id: this.currentFolderId,
}
}).then(() => {
this.error = null;
this.$emit('create');
}).catch((error) => {
this.error = error.response.data.name;
})
},
changeRole(role) {
this.defaultRole = role;
},
}
}
</script>

View file

@ -0,0 +1,72 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><i class="sn-icon sn-icon-close"></i></button>
<h4 class="modal-title truncate !block" id="edit-project-modal-label">
{{ i18n.t('projects.index.modal_new_project_folder.modal_title') }}
</h4>
</div>
<div class="modal-body">
<div class="mb-6">
<label class="sci-label">{{ i18n.t('projects.index.modal_new_project_folder.name') }}</label>
<div class="sci-input-container-v2" :class="{'error': error}" :data-error="error">
<input type="text" v-model="name" class="sci-input-field" autofocus="true" :placeholder="i18n.t('projects.index.modal_new_project.name_placeholder')" />
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" @click="submit" type="submit">{{ i18n.t('projects.index.modal_new_project.create') }}</button>
</div>
</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";
export default {
name: "NewProjectModal",
props: {
createFolderUrl: String,
userRolesUrl: String,
viewMode: String,
currentFolderId: String,
},
mixins: [modal_mixin],
components: {
SelectDropdown,
},
data() {
return {
name: '',
error: null,
};
},
methods: {
submit() {
axios.post(this.createFolderUrl, {
project_folder: {
name: this.name,
parent_folder_id: this.currentFolderId,
archived: (this.viewMode === 'archived')
}
}).then(() => {
this.error = null;
this.$emit('create');
}).catch((error) => {
this.error = error.response.data.name;
})
},
changeRole(role) {
this.defaultRole = role;
},
}
}
</script>

View file

@ -35,7 +35,7 @@
</template>
<script>
import SelectSearch from "../../shared/select_search.vue";
import SelectSearch from "../../shared/legacy/select_search.vue";
import repositoryValueMixin from "./mixins/repository_value.js";
export default {

View file

@ -36,7 +36,7 @@
</template>
<script>
import SelectSearch from "../../shared/select_search.vue";
import SelectSearch from "../../shared/legacy/select_search.vue";
import repositoryValueMixin from "./mixins/repository_value.js";
import twemoji from "twemoji";

View file

@ -0,0 +1,17 @@
export default {
methods: {
itemSelected() {
let item = this.dtComponent.selectedRows.find((item) => {
return item.id == this.params.id;
});
if (item) {
this.dtComponent.selectedRows = this.dtComponent.selectedRows.filter((item) => {
return item.id != this.params.id;
});
} else {
this.dtComponent.selectedRows.push(this.params);
}
}
}
}

View file

@ -1,5 +1,5 @@
<template>
<div class="items-center -ml-1.5 my-2">
<div>
<MenuDropdown
:listItems="this.formattedList"
btnClasses="bg-transparent w-6 h-6 border-0 p-0 flex"
@ -7,6 +7,7 @@
:alwaysShow="true"
:btnIcon="'sn-icon sn-icon-more-hori'"
@open="loadActions"
@dtEvent="handleEvents"
></MenuDropdown>
</div>
</template>
@ -41,6 +42,8 @@ export default {
newItem.url = item.path
}
newItem.params = item
return newItem
})
}
@ -53,9 +56,10 @@ export default {
.then((response) => {
this.actionsMenu = response.data.actions
})
.catch((error) => {
console.log(error)
})
},
handleEvents(event, option) {
const dt = this.params.dtComponent
dt.$emit(event, option.params, [this.params.data])
}
}
}

View file

@ -9,10 +9,20 @@
:activePageUrl="activePageUrl"
:archivedPageUrl="archivedPageUrl"
:currentViewMode="currentViewMode"
:currentViewRender="currentViewRender"
:viewRenders="viewRenders"
:filters="filters"
@applyFilters="applyFilters"
@setTableView="switchViewRender('table')"
@setCardsView="switchViewRender('cards')"
/>
<div v-if="currentViewRender === 'cards'" class="flex-grow basis-64 overflow-y-auto overflow-x-visible p-2 -ml-2">
<div class="grid grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
<slot v-for="element in rowData" :key="element.id" name="card" :dtComponent="this" :params="element"></slot>
</div>
</div>
<ag-grid-vue
v-if="currentViewRender === 'table'"
class="ag-theme-alpine w-full flex-grow h-full z-10"
:class="{'opacity-0': initializing}"
:columnDefs="columnDefs"
@ -111,11 +121,13 @@ export default {
type: String,
default: 'active'
},
viewRenders: {
type: Object,
},
filters: {
type: Array,
default: () => []
},
}
},
data() {
return {
@ -132,7 +144,9 @@ export default {
selectedRows: [],
searchValue: '',
initializing: true,
activeFilters: {}
activeFilters: {},
currentViewRender: 'table',
cardCheckboxes: []
};
},
components: {
@ -188,8 +202,11 @@ export default {
resizable: false,
sortable: false,
cellRenderer: 'RowMenuRenderer',
cellStyle: {overflow: 'visible'},
pinned: 'right'
cellRendererParams: {
dtComponent: this
},
pinned: 'right',
cellStyle: {padding: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', overflow: 'visible'}
});
}
@ -232,6 +249,7 @@ export default {
.then((response) => {
this.selectedRows = [];
this.gridApi.setRowData(this.formatData(response.data.data));
this.rowData = this.formatData(response.data.data);
this.totalPage = response.data.meta.total_pages;
this.$emit('tableReloaded');
})
@ -255,6 +273,7 @@ export default {
},
setPerPage(value) {
this.perPage = value;
this.page = 1;
this.loadData();
},
setPage(page) {
@ -296,6 +315,13 @@ export default {
applyFilters(filters) {
this.activeFilters = filters;
this.loadData();
},
switchViewRender(view) {
if (this.currentViewRender === view) return;
this.currentViewRender = view;
this.initializing = true;
this.selectedRows = [];
}
}
};

View file

@ -10,14 +10,24 @@
</a>
</div>
<div>
<div v-if="archivedPageUrl" class="flex items-center gap-4">
<div class="flex items-center gap-4">
<MenuDropdown
:listItems="this.viewModes"
v-if="archivedPageUrl"
:listItems="this.viewRendersMenu"
:btnClasses="'btn btn-light icon-btn'"
:btnText="i18n.t(`toolbar.${currentViewRender}_view`)"
:caret="true"
:position="'right'"
@setCardsView="$emit('setCardsView')"
@setTableView="$emit('setTableView')"
></MenuDropdown>
<MenuDropdown
v-if="archivedPageUrl"
:listItems="this.viewModesMenu"
:btnClasses="'btn btn-light icon-btn'"
:btnText="i18n.t(`projects.index.${currentViewMode}`)"
:caret="true"
:position="'right'"
@open="loadActions"
></MenuDropdown>
</div>
</div>
@ -74,6 +84,14 @@ export default {
filters: {
type: Array,
default: () => []
},
viewRenders: {
type: Array,
required: true
},
currentViewRender: {
type: String,
required: true
}
},
components: {
@ -81,11 +99,24 @@ export default {
FilterDropdown
},
computed: {
viewModes() {
viewModesMenu() {
return [
{ text: this.i18n.t('projects.index.active'), url: this.activePageUrl},
{ text: this.i18n.t('projects.index.archived'), url: this.archivedPageUrl }
]
},
viewRendersMenu() {
return this.viewRenders.map((view) => {
const type = view.type;
switch (type) {
case 'cards':
return { text: this.i18n.t('toolbar.cards_view'), emit: 'setCardsView'};
case 'table':
return { text: this.i18n.t('toolbar.table_view'), emit: 'setTableView'};
default:
return view;
}
})
}
},
data() {

View file

@ -1,15 +1,46 @@
<template>
<div class="mb-6">TODO</div>
<div class="mb-6">
<label class="sci-label">{{ filter.label }}</label>
<SelectDropdown
:optionsUrl="filter.optionsUrl"
:selectedValue="value"
:multiple="true"
:with-checkboxes="true"
:placeholder="filter.placeholder"
:optionRenderer="filter.optionRenderer"
:labelRenderer="filter.labelRenderer"
@change="change"
> </SelectDropdown>
</div>
</template>
<script>
import Select from '../../legacy/select.vue';
import SelectDropdown from '../../select_dropdown.vue';
export default {
name: 'SelectFilter',
props: {
filter: { type: Object, required: true }
},
components: { Select }
data: function() {
return {
value: []
}
},
watch: {
value: function() {
let value = this.value;
if (this.value.length == 0) {
value = null;
}
this.$emit('update', { key: this.filter.key, value: value });
}
},
components: { SelectDropdown },
methods: {
change: function(value) {
this.value = value;
}
},
}
</script>

View file

@ -114,7 +114,8 @@ export default {
}
if (item.emit) {
this.$emit(item.emit, item.params)
this.$emit(item.emit, item.params);
this.$emit('dtEvent', item.emit, item);
}
this.closeMenu();

View file

@ -0,0 +1,17 @@
export default {
mounted() {
$(this.$refs.modal).modal('show');
$(this.$refs.modal).on('hidden.bs.modal', () => {
this.$emit('close');
});
},
beforeUnmount() {
$(this.$refs.modal).modal('hide');
},
methods: {
close() {
this.$emit('close');
$(this.$refs.modal).modal('hide');
}
},
}

View file

@ -153,6 +153,13 @@ export default {
return `${this.newValue.length} ${this.fewOptionsPlaceholder || this.i18n.t('general.select_dropdown.few_options_placeholder')}`
}
},
valueChanged() {
if (this.multiple) {
return !this.compareArrays(this.newValue, this.value)
} else {
return this.newValue != this.value
}
}
},
mounted() {
document.addEventListener('scroll', this.setPosition);
@ -205,8 +212,10 @@ export default {
this.$emit('change', this.newValue)
},
close() {
if (!this.isOpen) return;
this.isOpen = false
if (this.newValue != this.value) {
if (this.valueChanged) {
this.$emit('change', this.newValue)
}
this.query = '';
@ -274,6 +283,15 @@ export default {
})
})
}
},
compareArrays(arr1, arr2) {
if (!arr1 || !arr2) return false;
if (arr1.length !== arr2.length) return false;
for (let i = 0; i < arr1.length; i++) {
if (!arr2.includes(arr1[i])) return false;
}
return true;
}
},
}

View file

@ -1,104 +0,0 @@
<template>
<Select
class="sn-select sn-select--search"
:className="className"
:optionsClassName="optionsClassName"
:withEditCursor="withEditCursor"
:withClearButton="withClearButton"
:value="value"
:options="currentOptions"
:placeholder="placeholder"
:noOptionsPlaceholder="isLoading ? i18n.t('general.loading') : noOptionsPlaceholder"
v-bind:disabled="disabled"
@change="change"
@blur="blur"
@open="open"
@close="close"
>
<input ref="focusElement" v-model="query" type="text" class="sn-select__search-input" :placeholder="searchPlaceholder" />
<span class="sn-select__value">{{ valueLabel || (placeholder || i18n.t('general.select')) }}</span>
<i class="sn-icon" :class="{ 'sn-icon-down': !isOpen, 'sn-icon-up': isOpen}"></i>
</Select>
</template>
<script>
import Select from './legacy/select.vue'
export default {
name: 'SelectSearch',
props: {
withClearButton: { type: Boolean, default: false },
withEditCursor: { type: Boolean, default: false },
value: { type: [String, Number] },
options: { type: Array, default: () => [] },
optionsUrl: { type: String },
placeholder: { type: String },
searchPlaceholder: { type: String },
noOptionsPlaceholder: { type: String },
disabled: { type: Boolean },
isLoading: { type: Boolean, default: false },
className: { type: String, default: '' },
optionsClassName: { type: String, default: '' }
},
components: { Select },
data() {
return {
query: null,
currentOptions: null,
isOpen: false
}
},
created() {
this.currentOptions = this.options;
},
watch: {
query() {
if(!this.query) {
this.currentOptions = this.options;
return;
}
if (this.optionsUrl) {
this.fetchOptions();
} else {
this.currentOptions = this.options.filter((o) => o[1].toLowerCase().includes(this.query.toLowerCase()));
}
},
options() {
this.currentOptions = this.options;
}
},
computed: {
valueLabel() {
let option = this.currentOptions.find((o) => o[0] === this.value);
return option && option[1];
}
},
methods: {
blur() {
this.isOpen = false;
this.$emit('blur');
},
change(value) {
this.isOpen = false;
this.$emit('change', value);
},
open() {
this.isOpen = true;
this.$emit('open');
},
close() {
this.query = '';
this.isOpen = false;
this.$emit('close');
},
fetchOptions() {
$.get(`${this.optionsUrl}?query=${this.query || ''}`,
(data) => {
this.currentOptions = data;
}
);
}
}
}
</script>

View file

@ -2,12 +2,16 @@ module Lists
class ProjectAndFolderSerializer < ActiveModel::Serializer
include Rails.application.routes.url_helpers
attributes :name, :code, :created_at, :archived_on, :users, :hidden, :urls, :folder
attributes :name, :code, :created_at, :archived_on, :users, :hidden, :urls, :folder, :folder_info, :default_public_user_role_id
def folder
!project?
end
def default_public_user_role_id
object.default_public_user_role_id if project?
end
def code
object.code if project?
end
@ -36,11 +40,26 @@ module Lists
end
def urls
{
urls_list = {
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)
}
if project?
urls_list[:update] = project_path(object)
else
urls_list[:update] = project_folder_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)
end
end
private

View file

@ -80,8 +80,8 @@ module Lists
end
def filter_project_folder_records(records)
records = records.archived if @view_mode == 'archived'
records = records.active if @view_mode == 'active'
records = records.archived if @params[:view_mode] == 'archived'
records = records.active if @params[:view_mode] == 'active'
records = records.where_attributes_like('project_folders.name', @filters[:query]) if @filters[:query].present?
records
end

View file

@ -51,33 +51,21 @@ module Toolbars
def edit_action
return unless @single
action = {
name: 'edit',
label: I18n.t('projects.index.edit_option'),
icon: 'sn-icon sn-icon-edit',
button_class: 'edit-btn',
type: :emit
}
if @items.first.is_a?(Project)
project = @items.first
return unless can_manage_project?(project)
{
name: 'edit',
label: I18n.t('projects.index.edit_option'),
icon: 'sn-icon sn-icon-edit',
button_class: 'edit-btn',
path: edit_project_path(project),
type: :emit
}
return unless can_manage_project?(@items.first)
else
project_folder = @items.first
return unless can_create_project_folders?(project_folder.team)
{
name: 'edit',
label: I18n.t('projects.index.edit_option'),
icon: 'sn-icon sn-icon-edit',
button_class: 'edit-btn',
path: edit_project_folder_path(project_folder),
type: :emit
}
return unless can_create_project_folders?(@items.first.team)
end
action
end
def access_action
@ -120,11 +108,24 @@ module Toolbars
def export_action
return unless @items.all? { |item| item.is_a?(Project) ? can_export_project?(item) : true }
num_projects = @items.length
limit = TeamZipExport.exports_limit
num_of_requests_left = @current_user.exports_left - 1
team = @items.first.team
message = "<p>#{I18n.t('projects.export_projects.modal_text_p1_html', num_projects: num_projects, team: team)}</p>
<p>#{I18n.t('projects.export_projects.modal_text_p2_html')}</p>"
unless limit.zero?
message += "<p><i>#{I18n.t('projects.export_projects.modal_text_p3_html', limit: limit, num: num_of_requests_left)}</i></p>"
end
{
items: @items,
name: 'export',
label: I18n.t('projects.export_projects.export_button'),
icon: 'sn-icon sn-icon-export',
path: export_projects_modal_team_path(@items.first.team),
message: message,
path: export_projects_team_path(team),
type: :emit
}
end
@ -167,7 +168,7 @@ module Toolbars
name: 'delete_folders',
label: I18n.t('general.delete'),
icon: 'sn-icon sn-icon-delete',
path: destroy_modal_project_folders_path(project_folder_ids: @items.map(&:id)),
path: destroy_project_folders_path,
type: :emit
}
end

View file

@ -7,74 +7,21 @@
<div id="ProjectsList" class="fixed-content-body">
<projects-list
actions-url="<%= actions_toolbar_projects_url %>"
data-source="<%= projects_path(format: :json) %>"
active-page-url="<%= projects_path(view_mode: :active) %>"
archived-page-url="<%= projects_path(view_mode: :archived) %>"
actions-url="<%= actions_toolbar_projects_path %>"
users-filter-url="<%= users_filter_projects_path %>"
data-source="<%= projects_path(project_folder_id: current_folder&.id, format: :json) %>"
active-page-url="<%= projects_path(roject_folder_id: current_folder&.id, view_mode: :active) %>"
archived-page-url="<%= projects_path(roject_folder_id: current_folder&.id, view_mode: :archived) %>"
current-view-mode="<%= params[:view_mode] || :active %>"
current-folder-id="<%= current_folder&.id %>"
create-url="<%= projects_path if can_create_projects?(current_team) %>"
create-folder-url="<%= project_folders_path if can_create_project_folders?(current_team) %>"
user-roles-url="<%= user_roles_projects_path %>"
folders-tree-url="<%= tree_project_folders_path(view_mode: params[:view_mode]) %>"
move-to-url="<%= move_to_project_folders_path %>"
/>
</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' %>
<%= render partial: 'projects/index/modals/move_to_modal' %>
<%= render partial: 'projects/index/modals/manage_users' %>
<%= render partial: 'projects/index/modals/export_projects' %>
<div class="projects-container">
<div class="cards-wrapper <%= cards_view_type_class(@current_view_type) %>"
id="cardsWrapper"
data-projects-cards-url="<%= @current_folder ? project_folder_cards_url(@current_folder) : cards_projects_url %>">
<div class="table-header">
<div class="table-header-cell select-all-checkboxes">
<div class="sci-checkbox-container">
<input value="1" type="checkbox" class="sci-checkbox select-all">
<span class="sci-checkbox-label"></span>
</div>
</div>
<div class="table-header-cell"><%= t('.card.name') %></div>
<div class="table-header-cell"><%= t('.card.id') %></div>
<div class="table-header-cell"><%= t('.card.start_date') %></div>
<div class="table-header-cell" data-view-mode="archived"><%= t('.card.archived_date') %></div>
<div class="table-header-cell"><%= t('.card.visibility') %></div>
<div class="table-header-cell"><%= t('.card.users') %></div>
<div class="table-header-cell"></div>
</div>
</div>
<div id="actionToolbar" data-behaviour="vue">
<action-toolbar actions-url="<%= actions_toolbar_projects_url %>" />
</div>
</div>
</div>
<template id="projectPlaceholder">
<div class="project-placeholder card">
<% 4.times do |i| %>
<div class="placeholder-element line-<%= i %>"></div>
<% end %>
<% 3.times do |i| %>
<div class="placeholder-element circle circle-<%= i %>"></div>
<% end %>
</div>
</template>
<template id="projectEndOfList">
<div class="project-list-end-placeholder">
<i class="fas fa-flag-checkered"></i>
<span><%= t('.end_of_list_placeholder') %></span>
<i class="fas fa-flag-checkered"></i>
</div>
</template>
<%= javascript_include_tag "vue_components_action_toolbar" %>
<%= javascript_include_tag "projects/index" %>

View file

@ -19,8 +19,6 @@
<ul>
<li class="jstree-open" id="root_folder" data-jstree='{"icon":"sn-icon sn-icon-projects root-folder"}'>
<a class="jstree-clicked" href="#">Projects</a>
<%= render partial: 'projects/index/modals/move_to_folders_tree',
locals: { records: sidebar_folders_tree(current_team, current_user, @current_sort, folders_only: true) } %>
</li>
</ul>
</div>

View file

@ -6,7 +6,6 @@
<% end %>
</li>
<% end %>
<%= render partial: 'shared/sidebar/projects_tree_branch', locals: { records: sidebar_folders_tree(team, current_user, sort) } %>
<% if !projects_view_mode_archived? %>
<li class="sidebar-leaf">
<%= link_to projects_path(view_mode: :archived), class: "sidebar-link" do %>

View file

@ -552,6 +552,7 @@ en:
delete_button: "Delete"
edit_option: "Edit"
archive_option: "Archive"
archive_confirm_title: "Archive project"
archive_confirm: "Are you sure you want to archive this project?"
restore_option: "Restore"
project_members_access: "Access"
@ -598,9 +599,11 @@ en:
title: "Move %{items}"
description: "Select where you want to move your %{items}"
submit: "Move"
find_folder: "Find folder"
projects: "Projects"
items:
projects: 'projects'
folders: 'folders'
project_folders: 'folders'
projects_and_folders: 'projects & folders'
modal_delete_folders:
title: "Delete project folder(s)"
@ -3740,6 +3743,7 @@ en:
selected: "Selected"
loading: "Loading..."
replace: "Replace"
archive: "Archive"
# In order to use the strings 'yes' and 'no' as keys, you need to wrap them with quotes
'yes': "Yes"
'no': "No"

View file

@ -369,6 +369,7 @@ Rails.application.routes.draw do
post 'restore_group'
put 'view_type', to: 'teams#view_type'
get 'actions_toolbar'
get :user_roles
end
end
@ -376,6 +377,7 @@ Rails.application.routes.draw do
get 'cards', to: 'projects#cards'
collection do
get :tree
post 'move_to', to: 'project_folders#move_to', defaults: { format: 'json' }
get 'move_to_modal', to: 'project_folders#move_to_modal', defaults: { format: 'json' }
post 'destroy', to: 'project_folders#destroy', as: 'destroy', defaults: { format: 'json' }