Resolve "Improve handling of projects shared with a group"

This commit is contained in:
Dennis Tang 2018-09-07 06:09:13 +00:00 committed by Mike Greiling
parent 53fae9ad84
commit 5b74a1aebc
35 changed files with 593 additions and 249 deletions

View file

@ -2,14 +2,15 @@
/* global Flash */
import $ from 'jquery';
import { s__ } from '~/locale';
import { s__, sprintf } from '~/locale';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { HIDDEN_CLASS } from '~/lib/utils/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
import { COMMON_STR } from '../constants';
import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants';
import groupsComponent from './groups.vue';
export default {
@ -19,6 +20,16 @@ export default {
groupsComponent,
},
props: {
action: {
type: String,
required: false,
default: '',
},
containerId: {
type: String,
required: false,
default: '',
},
store: {
type: Object,
required: true,
@ -56,31 +67,28 @@ export default {
? COMMON_STR.GROUP_SEARCH_EMPTY
: COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
eventHub.$on('fetchPage', this.fetchPage);
eventHub.$on('toggleChildren', this.toggleChildren);
eventHub.$on('showLeaveGroupModal', this.showLeaveGroupModal);
eventHub.$on('updatePagination', this.updatePagination);
eventHub.$on('updateGroups', this.updateGroups);
eventHub.$on(`${this.action}fetchPage`, this.fetchPage);
eventHub.$on(`${this.action}toggleChildren`, this.toggleChildren);
eventHub.$on(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal);
eventHub.$on(`${this.action}updatePagination`, this.updatePagination);
eventHub.$on(`${this.action}updateGroups`, this.updateGroups);
},
mounted() {
this.fetchAllGroups();
if (this.containerId) {
this.containerEl = document.getElementById(this.containerId);
}
},
beforeDestroy() {
eventHub.$off('fetchPage', this.fetchPage);
eventHub.$off('toggleChildren', this.toggleChildren);
eventHub.$off('showLeaveGroupModal', this.showLeaveGroupModal);
eventHub.$off('updatePagination', this.updatePagination);
eventHub.$off('updateGroups', this.updateGroups);
eventHub.$off(`${this.action}fetchPage`, this.fetchPage);
eventHub.$off(`${this.action}toggleChildren`, this.toggleChildren);
eventHub.$off(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal);
eventHub.$off(`${this.action}updatePagination`, this.updatePagination);
eventHub.$off(`${this.action}updateGroups`, this.updateGroups);
},
methods: {
fetchGroups({
parentId,
page,
filterGroupsBy,
sortBy,
archived,
updatePagination,
}) {
fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
return this.service
.getGroups(parentId, page, filterGroupsBy, sortBy, archived)
.then(res => {
@ -165,13 +173,13 @@ export default {
}
},
showLeaveGroupModal(group, parentGroup) {
const { fullName } = group;
this.targetGroup = group;
this.targetParentGroup = parentGroup;
this.showModal = true;
this.groupLeaveConfirmationMessage = s__(
`GroupsTree|Are you sure you want to leave the "${
group.fullName
}" group?`,
this.groupLeaveConfirmationMessage = sprintf(
s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'),
{ fullName },
);
},
hideLeaveGroupModal() {
@ -197,16 +205,35 @@ export default {
this.targetGroup.isBeingRemoved = false;
});
},
showEmptyState() {
const { containerEl } = this;
const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS);
const emptyStateEl = containerEl.querySelector('.empty-state');
if (contentListEl) {
contentListEl.remove();
}
if (emptyStateEl) {
emptyStateEl.classList.remove(HIDDEN_CLASS);
}
},
updatePagination(headers) {
this.store.setPaginationInfo(headers);
},
updateGroups(groups, fromSearch) {
this.isSearchEmpty = groups ? groups.length === 0 : false;
const hasGroups = groups && groups.length > 0;
this.isSearchEmpty = !hasGroups;
if (fromSearch) {
this.store.setSearchedGroups(groups);
} else {
this.store.setGroups(groups);
}
if (this.action && !hasGroups && !fromSearch) {
this.showEmptyState();
}
},
},
};
@ -226,6 +253,7 @@ export default {
:search-empty="isSearchEmpty"
:search-empty-message="searchEmptyMessage"
:page-info="pageInfo"
:action="action"
/>
<deprecated-modal
v-show="showModal"

View file

@ -11,8 +11,12 @@ export default {
},
groups: {
type: Array,
required: true,
},
action: {
type: String,
required: false,
default: () => ([]),
default: '',
},
},
computed: {
@ -37,6 +41,7 @@ export default {
:key="index"
:group="group"
:parent-group="parentGroup"
:action="action"
/>
<li
v-if="hasMoreChildren"

View file

@ -30,6 +30,11 @@ export default {
type: Object,
required: true,
},
action: {
type: String,
required: false,
default: '',
},
},
computed: {
groupDomId() {
@ -56,10 +61,12 @@ export default {
methods: {
onClickRowGroup(e) {
const NO_EXPAND_CLS = 'no-expand';
if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
const targetClasses = e.target.classList;
const parentElClasses = e.target.parentElement.classList;
if (!(targetClasses.contains(NO_EXPAND_CLS) || parentElClasses.contains(NO_EXPAND_CLS))) {
if (this.hasChildren) {
eventHub.$emit('toggleChildren', this.group);
eventHub.$emit(`${this.action}toggleChildren`, this.group);
} else {
visitUrl(this.group.relativePath);
}
@ -158,6 +165,7 @@ export default {
v-if="group.isOpen && hasChildren"
:parent-group="group"
:groups="group.children"
:action="action"
/>
</li>
</template>

View file

@ -1,39 +1,44 @@
<script>
import tablePagination from '~/vue_shared/components/table_pagination.vue';
import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils';
import tablePagination from '~/vue_shared/components/table_pagination.vue';
import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils';
export default {
components: {
tablePagination,
export default {
components: {
tablePagination,
},
props: {
groups: {
type: Array,
required: true,
},
props: {
groups: {
type: Array,
required: true,
},
pageInfo: {
type: Object,
required: true,
},
searchEmpty: {
type: Boolean,
required: true,
},
searchEmptyMessage: {
type: String,
required: true,
},
pageInfo: {
type: Object,
required: true,
},
methods: {
change(page) {
const filterGroupsParam = getParameterByName('filter_groups');
const sortParam = getParameterByName('sort');
const archivedParam = getParameterByName('archived');
eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
},
searchEmpty: {
type: Boolean,
required: true,
},
};
searchEmptyMessage: {
type: String,
required: true,
},
action: {
type: String,
required: false,
default: '',
},
},
methods: {
change(page) {
const filterGroupsParam = getParameterByName('filter_groups');
const sortParam = getParameterByName('sort');
const archivedParam = getParameterByName('archived');
eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam);
},
},
};
</script>
<template>
@ -47,6 +52,7 @@
<group-folder
v-if="!searchEmpty"
:groups="groups"
:action="action"
/>
<table-pagination
v-if="!searchEmpty"

View file

@ -21,6 +21,11 @@ export default {
type: Object,
required: true,
},
action: {
type: String,
required: false,
default: '',
},
},
computed: {
leaveBtnTitle() {
@ -32,7 +37,7 @@ export default {
},
methods: {
onLeaveGroup() {
eventHub.$emit('showLeaveGroupModal', this.group, this.parentGroup);
eventHub.$emit(`${this.action}showLeaveGroupModal`, this.group, this.parentGroup);
},
},
};

View file

@ -2,13 +2,23 @@ import { __, s__ } from '../locale';
export const MAX_CHILDREN_COUNT = 20;
export const ACTIVE_TAB_SUBGROUPS_AND_PROJECTS = 'subgroups_and_projects';
export const ACTIVE_TAB_SHARED = 'shared';
export const ACTIVE_TAB_ARCHIVED = 'archived';
export const GROUPS_LIST_HOLDER_CLASS = '.js-groups-list-holder';
export const GROUPS_FILTER_FORM_CLASS = '.js-group-filter-form';
export const CONTENT_LIST_CLASS = '.content-list';
export const COMMON_STR = {
FAILURE: __('An error occurred. Please try again.'),
LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'),
LEAVE_FORBIDDEN: s__(
'GroupsTree|Failed to leave the group. Please make sure you are not the only owner.',
),
LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'),
GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'),
GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'),
GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'),
};
export const ITEM_TYPE = {
@ -17,8 +27,12 @@ export const ITEM_TYPE = {
};
export const GROUP_VISIBILITY_TYPE = {
public: __('Public - The group and any public projects can be viewed without any authentication.'),
internal: __('Internal - The group and any internal projects can be viewed by any logged in user.'),
public: __(
'Public - The group and any public projects can be viewed without any authentication.',
),
internal: __(
'Internal - The group and any internal projects can be viewed by any logged in user.',
),
private: __('Private - The group and its projects can only be viewed by members.'),
};

View file

@ -4,13 +4,23 @@ import eventHub from './event_hub';
import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils';
export default class GroupFilterableList extends FilterableList {
constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
constructor({
form,
filter,
holder,
filterEndpoint,
pagePath,
dropdownSel,
filterInputField,
action,
}) {
super(form, filter, holder, filterInputField);
this.form = form;
this.filterEndpoint = filterEndpoint;
this.pagePath = pagePath;
this.filterInputField = filterInputField;
this.$dropdown = $(dropdownSel);
this.action = action;
}
getFilterEndpoint() {
@ -20,15 +30,16 @@ export default class GroupFilterableList extends FilterableList {
getPagePath(queryData) {
const params = queryData ? $.param(queryData) : '';
const queryString = params ? `?${params}` : '';
return `${this.pagePath}${queryString}`;
const path = this.pagePath || window.location.pathname;
return `${path}${queryString}`;
}
bindEvents() {
super.bindEvents();
this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
this.onFilterOptionClickWrapper = this.onOptionClick.bind(this);
this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
this.$dropdown.on('click', 'a', this.onFilterOptionClickWrapper);
}
onFilterInput() {
@ -53,7 +64,12 @@ export default class GroupFilterableList extends FilterableList {
}
setDefaultFilterOption() {
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text());
const defaultOption = $.trim(
this.$dropdown
.find('.dropdown-menu li.js-filter-sort-order a')
.first()
.text(),
);
this.$dropdown.find('.dropdown-label').text(defaultOption);
}
@ -65,11 +81,19 @@ export default class GroupFilterableList extends FilterableList {
// Get type of option selected from dropdown
const currentTargetClassList = e.currentTarget.parentElement.classList;
const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order');
const isOptionFilterByArchivedProjects = currentTargetClassList.contains('js-filter-archived-projects');
const isOptionFilterByArchivedProjects = currentTargetClassList.contains(
'js-filter-archived-projects',
);
// Get option query param, also preserve currently applied query param
const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href);
const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href);
const sortParam = getParameterByName(
'sort',
isOptionFilterBySort ? e.currentTarget.href : window.location.href,
);
const archivedParam = getParameterByName(
'archived',
isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href,
);
if (sortParam) {
queryData.sort = sortParam;
@ -86,7 +110,9 @@ export default class GroupFilterableList extends FilterableList {
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active');
} else if (isOptionFilterByArchivedProjects) {
this.$dropdown.find('.dropdown-menu li.js-filter-archived-projects a').removeClass('is-active');
this.$dropdown
.find('.dropdown-menu li.js-filter-archived-projects a')
.removeClass('is-active');
}
$(e.target).addClass('is-active');
@ -98,11 +124,19 @@ export default class GroupFilterableList extends FilterableList {
onFilterSuccess(res, queryData) {
const currentPath = this.getPagePath(queryData);
window.history.replaceState({
page: currentPath,
}, document.title, currentPath);
window.history.replaceState(
{
page: currentPath,
},
document.title,
currentPath,
);
eventHub.$emit('updateGroups', res.data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
eventHub.$emit('updatePagination', normalizeHeaders(res.headers));
eventHub.$emit(
`${this.action}updateGroups`,
res.data,
Object.prototype.hasOwnProperty.call(queryData, this.filterInputField),
);
eventHub.$emit(`${this.action}updatePagination`, normalizeHeaders(res.headers));
}
}

View file

@ -7,18 +7,26 @@ import GroupsService from './service/groups_service';
import groupsApp from './components/app.vue';
import groupFolderComponent from './components/group_folder.vue';
import groupItemComponent from './components/group_item.vue';
import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants';
Vue.use(Translate);
export default () => {
const el = document.getElementById('js-groups-tree');
export default (containerId = 'js-groups-tree', endpoint, action = '') => {
const containerEl = document.getElementById(containerId);
let dataEl;
// Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL
if (!el) {
if (!containerEl) {
return;
}
const el = action ? containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS) : containerEl;
if (action) {
dataEl = containerEl.querySelector(CONTENT_LIST_CLASS);
}
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
@ -29,20 +37,26 @@ export default () => {
groupsApp,
},
data() {
const { dataset } = this.$options.el;
const { dataset } = dataEl || this.$options.el;
const hideProjects = dataset.hideProjects === 'true';
const service = new GroupsService(endpoint || dataset.endpoint);
const store = new GroupsStore(hideProjects);
const service = new GroupsService(dataset.endpoint);
return {
action,
store,
service,
hideProjects,
loading: true,
containerId,
};
},
beforeMount() {
const { dataset } = this.$options.el;
if (this.action) {
return;
}
const { dataset } = dataEl || this.$options.el;
let groupFilterList = null;
const form = document.querySelector(dataset.formSel);
const filter = document.querySelector(dataset.filterSel);
@ -52,10 +66,11 @@ export default () => {
form,
filter,
holder,
filterEndpoint: dataset.endpoint,
filterEndpoint: endpoint || dataset.endpoint,
pagePath: dataset.path,
dropdownSel: dataset.dropdownSel,
filterInputField: 'filter',
action: this.action,
};
groupFilterList = new GroupFilterableList(opts);
@ -64,9 +79,11 @@ export default () => {
render(createElement) {
return createElement('groups-app', {
props: {
action: this.action,
store: this.store,
service: this.service,
hideProjects: this.hideProjects,
containerId: this.containerId,
},
});
},

View file

@ -47,9 +47,9 @@ export function removeParamQueryString(url, param) {
return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&');
}
export function removeParams(params) {
export function removeParams(params, source = window.location.href) {
const url = document.createElement('a');
url.href = window.location.href;
url.href = source;
params.forEach(param => {
url.search = removeParamQueryString(url.search, param);

View file

@ -1,3 +1,5 @@
import initGroupsList from '~/groups';
document.addEventListener('DOMContentLoaded', initGroupsList);
document.addEventListener('DOMContentLoaded', () => {
initGroupsList();
});

View file

@ -0,0 +1,136 @@
import $ from 'jquery';
import { removeParams } from '~/lib/utils/url_utility';
import createGroupTree from '~/groups';
import {
ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
ACTIVE_TAB_SHARED,
ACTIVE_TAB_ARCHIVED,
CONTENT_LIST_CLASS,
GROUPS_LIST_HOLDER_CLASS,
GROUPS_FILTER_FORM_CLASS,
} from '~/groups/constants';
import UserTabs from '~/pages/users/user_tabs';
import GroupFilterableList from '~/groups/groups_filterable_list';
export default class GroupTabs extends UserTabs {
constructor({ defaultAction = 'subgroups_and_projects', action, parentEl }) {
super({ defaultAction, action, parentEl });
}
bindEvents() {
this.$parentEl
.off('shown.bs.tab', '.nav-links a[data-toggle="tab"]')
.on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', event => this.tabShown(event));
}
tabShown(event) {
const $target = $(event.target);
const action = $target.data('action') || $target.data('targetSection');
const source = $target.attr('href') || $target.data('targetPath');
document.querySelector(GROUPS_FILTER_FORM_CLASS).action = source;
this.setTab(action);
return this.setCurrentAction(source);
}
setTab(action) {
const loadableActions = [
ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
ACTIVE_TAB_SHARED,
ACTIVE_TAB_ARCHIVED,
];
this.enableSearchBar(action);
this.action = action;
if (this.loaded[action]) {
return;
}
if (loadableActions.includes(action)) {
this.cleanFilterState();
this.loadTab(action);
}
}
loadTab(action) {
const elId = `js-groups-${action}-tree`;
const endpoint = this.getEndpoint(action);
this.toggleLoading(true);
createGroupTree(elId, endpoint, action);
this.loaded[action] = true;
this.toggleLoading(false);
}
getEndpoint(action) {
const { endpointsDefault, endpointsShared } = this.$parentEl.data();
let endpoint;
switch (action) {
case ACTIVE_TAB_ARCHIVED:
endpoint = `${endpointsDefault}?archived=only`;
break;
case ACTIVE_TAB_SHARED:
endpoint = endpointsShared;
break;
default:
// ACTIVE_TAB_SUBGROUPS_AND_PROJECTS
endpoint = endpointsDefault;
break;
}
return endpoint;
}
enableSearchBar(action) {
const containerEl = document.getElementById(action);
const form = document.querySelector(GROUPS_FILTER_FORM_CLASS);
const filter = form.querySelector('.js-groups-list-filter');
const holder = containerEl.querySelector(GROUPS_LIST_HOLDER_CLASS);
const dataEl = containerEl.querySelector(CONTENT_LIST_CLASS);
const endpoint = this.getEndpoint(action);
if (!dataEl) {
return;
}
const { dataset } = dataEl;
const opts = {
form,
filter,
holder,
filterEndpoint: endpoint || dataset.endpoint,
pagePath: null,
dropdownSel: '.js-group-filter-dropdown-wrap',
filterInputField: 'filter',
action,
};
if (!this.loaded[action]) {
const filterableList = new GroupFilterableList(opts);
filterableList.initSearch();
}
}
cleanFilterState() {
const values = Object.values(this.loaded);
const loadedTabs = values.filter(e => e === true);
if (!loadedTabs.length) {
return;
}
const newState = removeParams(['page'], window.location.search);
window.history.replaceState(
{
url: newState,
},
document.title,
newState,
);
}
}

View file

@ -1,14 +1,22 @@
/* eslint-disable no-new */
import { getPagePath } from '~/lib/utils/common_utils';
import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
import NewGroupChild from '~/groups/new_group_child';
import notificationsDropdown from '~/notifications_dropdown';
import NotificationsForm from '~/notifications_form';
import ProjectsList from '~/projects_list';
import ShortcutsNavigation from '~/shortcuts_navigation';
import initGroupsList from '~/groups';
import GroupTabs from './group_tabs';
document.addEventListener('DOMContentLoaded', () => {
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED];
const paths = window.location.pathname.split('/');
const subpath = paths[paths.length - 1];
const action = loadableActions.includes(subpath) ? subpath : getPagePath(1);
new GroupTabs({ parentEl: '.groups-listing', action });
new ShortcutsNavigation();
new NotificationsForm();
notificationsDropdown();
@ -17,6 +25,4 @@ document.addEventListener('DOMContentLoaded', () => {
if (newGroupChildWrapper) {
new NewGroupChild(newGroupChildWrapper);
}
initGroupsList();
});

View file

@ -3,7 +3,6 @@
}
.dashboard .side .card .card-header .input-group {
.form-control {
height: 42px;
}
@ -30,14 +29,15 @@
}
}
.group-nav-container .group-search,
.group-nav-container .nav-controls {
display: flex;
align-items: flex-start;
padding: $gl-padding-top 0;
border-bottom: 1px solid $border-color;
padding: $gl-padding-top 0 0;
.group-filter-form {
flex: 1;
flex: 1 1 auto;
margin-right: $gl-padding-8;
}
.dropdown-menu-right {
@ -136,6 +136,10 @@
flex: 1;
}
.dropdown-toggle {
width: auto;
}
.dropdown-menu {
width: 100%;
max-width: inherit;
@ -145,38 +149,14 @@
}
}
.groups-empty-state {
padding: 50px 100px;
overflow: hidden;
.group-nav-container .group-search {
padding: $gl-padding 0;
border-bottom: 1px solid $border-color;
}
@include media-breakpoint-down(sm) {
padding: 50px 0;
}
svg {
float: right;
@include media-breakpoint-down(sm) {
float: none;
display: block;
width: 250px;
position: relative;
left: 50%;
margin-left: -125px;
}
}
.text-content {
float: left;
width: 460px;
margin-top: 120px;
@include media-breakpoint-down(sm) {
float: none;
margin-top: 60px;
width: auto;
text-align: center;
}
.groups-listing {
.group-list-tree .group-row:first-child {
border-top: 0;
}
}
@ -278,7 +258,7 @@
}
&::after {
content: "";
content: '';
position: absolute;
height: 100%;
width: 100%;
@ -346,7 +326,7 @@
position: relative;
&::before {
content: "";
content: '';
display: block;
width: 10px;
height: 0;

View file

@ -17,7 +17,7 @@ class GroupsController < Groups::ApplicationController
before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests]
before_action :event_filter, only: [:activity]
before_action :user_actions, only: [:show, :subgroups]
before_action :user_actions, only: [:show]
skip_cross_project_access_check :index, :new, :create, :edit, :update,
:destroy, :projects
@ -53,11 +53,7 @@ class GroupsController < Groups::ApplicationController
def show
respond_to do |format|
format.html do
@has_children = GroupDescendantsFinder.new(current_user: current_user,
parent_group: @group,
params: params).has_children?
end
format.html
format.atom do
load_events

View file

@ -134,7 +134,7 @@ class GroupDescendantsFinder
end
def direct_child_projects
GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params, options: { only_owned: true })
.execute
end

View file

@ -0,0 +1,8 @@
#js-groups-archived-tree
.empty-state.text-center.hidden
%p= _("There are no archived projects yet")
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
.loading-container.text-center
= icon('spinner spin 2x', class: 'loading-animation prepend-top-20')

View file

@ -1,4 +0,0 @@
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
.loading-container.text-center
= icon('spinner spin 2x', class: 'loading-animation prepend-top-20')

View file

@ -0,0 +1,8 @@
#js-groups-shared-tree
.empty-state.text-center.hidden
%p= _("There are no projects shared with this group yet")
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
.loading-container.text-center
= icon('spinner spin 2x', class: 'loading-animation prepend-top-20')

View file

@ -0,0 +1,8 @@
#js-groups-subgroups_and_projects-tree
.empty-state.hidden
= render "shared/groups/empty_state"
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
.loading-container.text-center
= icon('spinner spin 2x', class: 'loading-animation prepend-top-20')

View file

@ -7,11 +7,10 @@
= render 'groups/home_panel'
.groups-header{ class: container_class }
.group-nav-container
.nav-controls.clearfix
.groups-listing{ class: container_class, data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
.top-area.group-nav-container
.group-search
= render "shared/groups/search_form"
= render "shared/groups/dropdown", show_archive_options: true
- if can? current_user, :create_projects, @group
- new_project_label = _("New project")
- new_subgroup_label = _("New subgroup")
@ -39,7 +38,29 @@
- else
= link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
- if params[:filter].blank? && !@has_children
= render "shared/groups/empty_state"
- else
= render "children", children: @children, group: @group
.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
%ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs
%li.js-subgroups_and_projects-tab
= link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do
= _("Subgroups and projects")
%li.js-shared-tab
= link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do
= _("Shared projects")
%li.js-archived-tab
= link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do
= _("Archived projects")
.nav-controls
= render "shared/groups/dropdown"
.tab-content
#subgroups_and_projects.tab-pane
= render "subgroups_and_projects", group: @group
#shared.tab-pane
= render "shared_projects", group: @group
#archived.tab-pane
= render "archived_projects", group: @group

View file

@ -2,19 +2,19 @@
.col-sm-12
= form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do
.form-group
= label_tag :link_group_id, "Select a group to share with", class: "label-bold"
= label_tag :link_group_id, _("Select a group to invite"), class: "label-bold"
= groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp", required: true)
.form-group
= label_tag :link_group_access, "Max access level", class: "label-bold"
= label_tag :link_group_access, _("Max access level"), class: "label-bold"
.select-wrapper
= select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
= icon('chevron-down')
.form-text.text-muted.append-bottom-10
= link_to "Read more", help_page_path("user/permissions"), class: "vlink"
= link_to _("Read more"), help_page_path("user/permissions"), class: "vlink"
about role permissions
.form-group
= label_tag :expires_at, 'Access expiration date', class: 'label-bold'
= label_tag :expires_at, _('Access expiration date'), class: 'label-bold'
.clearable-input
= text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: 'Expiration date', id: 'expires_at_groups'
= text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date-groups', placeholder: _('Expiration date'), id: 'expires_at_groups'
%i.clear-icon.js-clear-input
= submit_tag "Share", class: "btn btn-create"
= submit_tag _("Invite"), class: "btn btn-create"

View file

@ -6,9 +6,9 @@
Project members
- if can?(current_user, :admin_project_member, @project)
%p
You can add a new member to
You can invite a new member to
%strong= @project.name
or share it with another group.
or invite another group.
- else
%p
Members can be added by project
@ -19,16 +19,16 @@
- if can?(current_user, :admin_project_member, @project)
%ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' }
%a.nav-link.active{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member
%a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Invite member
- if @project.allowed_to_share_with_group?
%li.nav-tab{ role: 'presentation' }
%a.nav-link{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group
%a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' } Invite group
.tab-content.gitlab-tab-content
.tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_member', tab_title: 'Add member'
.tab-pane{ id: 'share-with-group-pane', role: 'tabpanel' }
= render 'projects/project_members/new_shared_group', tab_title: 'Share with group'
.tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_member', tab_title: 'Invite member'
.tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_group', tab_title: 'Invite group'
= render 'shared/members/requests', membership_source: @project, requesters: @requesters
.clearfix

View file

@ -1,7 +1,8 @@
.groups-empty-state.qa-groups-empty-state
= custom_icon("icon_empty_groups")
.group-empty-state.row.align-items-center.justify-content-center.qa-groups-empty-state
.icon.text-center.order-md-2
= custom_icon("icon_empty_groups")
.text-content
.text-content.m-0.order-md-1
%h4= s_("GroupsEmptyState|A group is a collection of several projects.")
%p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.")
%p= s_("GroupsEmptyState|You can manage your group members permissions and access to each project in the group.")

View file

@ -1,2 +1,2 @@
= form_tag request.path, method: :get, class: 'group-filter-form append-right-10', id: 'group-filter-form' do |f|
= search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Filter by name...'), class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"
= form_tag request.path, method: :get, class: "group-filter-form js-group-filter-form", id: 'group-filter-form' do |f|
= search_field_tag :filter, params[:filter], placeholder: s_('GroupsTree|Search by name'), class: 'group-filter-form-field form-control js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2"

View file

@ -0,0 +1,5 @@
---
title: Overhaul listing of projects in the group overview page
merge_request: 20262
author:
type: added

View file

@ -14,6 +14,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
get :projects, as: :projects_group
get :activity, as: :activity_group
put :transfer, as: :transfer_group
# TODO: Remove as part of refactor in https://gitlab.com/gitlab-org/gitlab-ce/issues/49693
get 'shared', action: :show, as: :group_shared
get 'archived', action: :show, as: :group_archived
end
get '/', action: :show, as: :group_canonical

View file

@ -295,6 +295,9 @@ msgstr ""
msgid "Access denied! Please verify you can add deploy keys to this repository."
msgstr ""
msgid "Access expiration date"
msgstr ""
msgid "Access to failing storages has been temporarily disabled to allow the mount to recover. Reset storage information after the issue has been resolved to allow access again."
msgstr ""
@ -604,6 +607,9 @@ msgstr ""
msgid "Archived project! Repository and other project resources are read-only"
msgstr ""
msgid "Archived projects"
msgstr ""
msgid "Are you sure you want to delete this pipeline schedule?"
msgstr ""
@ -2622,6 +2628,9 @@ msgstr ""
msgid "Expand sidebar"
msgstr ""
msgid "Expiration date"
msgstr ""
msgid "Explore"
msgstr ""
@ -2985,6 +2994,9 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group members permissions and access to each project in the group."
msgstr ""
msgid "GroupsTree|Are you sure you want to leave the \"%{fullName}\" group?"
msgstr ""
msgid "GroupsTree|Create a project in this group."
msgstr ""
@ -2997,19 +3009,19 @@ msgstr ""
msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
msgstr ""
msgid "GroupsTree|Filter by name..."
msgstr ""
msgid "GroupsTree|Leave this group"
msgstr ""
msgid "GroupsTree|Loading groups"
msgstr ""
msgid "GroupsTree|Sorry, no groups matched your search"
msgid "GroupsTree|No groups matched your search"
msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgid "GroupsTree|No groups or projects matched your search"
msgstr ""
msgid "GroupsTree|Search by name"
msgstr ""
msgid "Health Check"
@ -3245,6 +3257,9 @@ msgstr ""
msgid "Introducing Cycle Analytics"
msgstr ""
msgid "Invite"
msgstr ""
msgid "Issue Boards"
msgstr ""
@ -3582,6 +3597,9 @@ msgstr ""
msgid "Markdown enabled"
msgstr ""
msgid "Max access level"
msgstr ""
msgid "Maximum git storage failures"
msgstr ""
@ -5072,6 +5090,9 @@ msgstr ""
msgid "Select Archive Format"
msgstr ""
msgid "Select a group to invite"
msgstr ""
msgid "Select a namespace to fork the project"
msgstr ""
@ -5171,6 +5192,9 @@ msgstr ""
msgid "Shared Runners"
msgstr ""
msgid "Shared projects"
msgstr ""
msgid "Sherlock Transactions"
msgstr ""
@ -5449,6 +5473,9 @@ msgstr ""
msgid "Subgroups"
msgstr ""
msgid "Subgroups and projects"
msgstr ""
msgid "Submit as spam"
msgstr ""
@ -5691,6 +5718,9 @@ msgstr ""
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr ""
msgid "There are no archived projects yet"
msgstr ""
msgid "There are no issues to show"
msgstr ""
@ -5700,6 +5730,9 @@ msgstr ""
msgid "There are no merge requests to show"
msgstr ""
msgid "There are no projects shared with this group yet"
msgstr ""
msgid "There are problems accessing Git storage: "
msgstr ""

View file

@ -7,7 +7,7 @@ module QA
def self.included(base)
base.view 'app/views/shared/groups/_search_form.html.haml' do
element :groups_filter, 'search_field_tag :filter'
element :groups_filter_placeholder, 'Filter by name...'
element :groups_filter_placeholder, 'Search by name'
end
base.view 'app/views/shared/groups/_empty_state.html.haml' do
@ -27,7 +27,7 @@ module QA
page.has_css?(element_selector_css(:groups_list_tree_container))
end
fill_in 'Filter by name...', with: name
fill_in 'Search by name', with: name
end
end
end

View file

@ -4,6 +4,11 @@ module QA
class Groups < Page::Base
include Page::Component::GroupsFilter
view 'app/views/shared/groups/_search_form.html.haml' do
element :groups_filter, 'search_field_tag :filter'
element :groups_filter_placeholder, 'Search by name'
end
view 'app/views/dashboard/_groups_head.html.haml' do
element :new_group_button, 'link_to _("New group")'
end

View file

@ -16,7 +16,7 @@ module QA
end
view 'app/assets/javascripts/groups/constants.js' do
element :no_result_text, 'Sorry, no groups or projects matched your search'
element :no_result_text, 'No groups or projects matched your search'
end
def go_to_subgroup(name)
@ -30,7 +30,7 @@ module QA
def has_subgroup?(name)
filter_by_name(name)
page.has_text?(/#{name}|Sorry, no groups or projects matched your search/, wait: 60)
page.has_text?(/#{name}|No groups or projects matched your search/, wait: 60)
page.has_text?(name, wait: 0)
end

View file

@ -38,14 +38,6 @@ describe GroupsController do
project
end
context 'as html' do
it 'assigns whether or not a group has children' do
get :show, id: group.to_param
expect(assigns(:has_children)).to be_truthy
end
end
context 'as atom' do
it 'assigns events for all the projects in the group' do
create(:event, project: project)

View file

@ -1,6 +1,6 @@
require 'spec_helper'
describe 'Project > Members > Share with Group', :js do
describe 'Project > Members > Invite group', :js do
include Select2Helper
include ActionView::Helpers::DateHelper
@ -8,17 +8,17 @@ describe 'Project > Members > Share with Group', :js do
describe 'Share with group lock' do
shared_examples 'the project can be shared with groups' do
it 'the "Share with group" tab exists' do
it 'the "Invite group" tab exists' do
visit project_settings_members_path(project)
expect(page).to have_selector('#share-with-group-tab')
expect(page).to have_selector('#invite-group-tab')
end
end
shared_examples 'the project cannot be shared with groups' do
it 'the "Share with group" tab does not exist' do
it 'the "Invite group" tab does not exist' do
visit project_settings_members_path(project)
expect(page).to have_selector('#add-member-tab')
expect(page).not_to have_selector('#share-with-group-tab')
expect(page).to have_selector('#invite-member-tab')
expect(page).not_to have_selector('#invite-group-tab')
end
end
@ -31,13 +31,13 @@ describe 'Project > Members > Share with Group', :js do
sign_in(maintainer)
end
context 'when the group has "Share with group lock" disabled' do
context 'when the group has "Invite group lock" disabled' do
it_behaves_like 'the project can be shared with groups'
it 'the project can be shared with another group' do
visit project_settings_members_path(project)
click_on 'share-with-group-tab'
click_on 'invite-group-tab'
select2 group_to_share_with.id, from: '#link_group_id'
page.find('body').click
@ -49,7 +49,7 @@ describe 'Project > Members > Share with Group', :js do
end
end
context 'when the group has "Share with group lock" enabled' do
context 'when the group has "Invite group lock" enabled' do
before do
project.namespace.update_column(:share_with_group_lock, true)
end
@ -69,12 +69,12 @@ describe 'Project > Members > Share with Group', :js do
sign_in(maintainer)
end
context 'when the root_group has "Share with group lock" disabled' do
context 'when the subgroup has "Share with group lock" disabled' do
context 'when the root_group has "Invite group lock" disabled' do
context 'when the subgroup has "Invite group lock" disabled' do
it_behaves_like 'the project can be shared with groups'
end
context 'when the subgroup has "Share with group lock" enabled' do
context 'when the subgroup has "Invite group lock" enabled' do
before do
subgroup.update_column(:share_with_group_lock, true)
end
@ -83,16 +83,16 @@ describe 'Project > Members > Share with Group', :js do
end
end
context 'when the root_group has "Share with group lock" enabled' do
context 'when the root_group has "Invite group lock" enabled' do
before do
root_group.update_column(:share_with_group_lock, true)
end
context 'when the subgroup has "Share with group lock" disabled (parent overridden)' do
context 'when the subgroup has "Invite group lock" disabled (parent overridden)' do
it_behaves_like 'the project can be shared with groups'
end
context 'when the subgroup has "Share with group lock" enabled' do
context 'when the subgroup has "Invite group lock" enabled' do
before do
subgroup.update_column(:share_with_group_lock, true)
end
@ -117,12 +117,12 @@ describe 'Project > Members > Share with Group', :js do
visit project_settings_members_path(project)
click_on 'share-with-group-tab'
click_on 'invite-group-tab'
select2 group.id, from: '#link_group_id'
fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d')
click_on 'share-with-group-tab'
click_on 'invite-group-tab'
find('.btn-create').click
end
@ -150,7 +150,7 @@ describe 'Project > Members > Share with Group', :js do
visit project_settings_members_path(project)
click_link 'Share with group'
click_link 'Invite group'
find('.ajax-groups-select.select2-container')
@ -183,7 +183,7 @@ describe 'Project > Members > Share with Group', :js do
it 'the groups dropdown does not show ancestors', :nested_groups do
visit project_settings_members_path(project)
click_on 'share-with-group-tab'
click_on 'invite-group-tab'
click_link 'Search for a group'
page.within '.select2-drop' do

View file

@ -26,13 +26,13 @@ describe 'Projects > Settings > User manages group links' do
end
end
it 'shares a project with a group', :js do
click_link('Share with group')
it 'invites a group to a project', :js do
click_link('Invite group')
select2(group_market.id, from: '#link_group_id')
select('Maintainer', from: 'link_group_access')
click_button('Share')
click_button('Invite')
page.within('.project-members-groups') do
expect(page).to have_content('Market')

View file

@ -108,6 +108,15 @@ describe GroupDescendantsFinder do
end
end
end
it 'does not include projects shared with the group' do
project = create(:project, namespace: group)
other_project = create(:project)
other_project.project_group_links.create(group: group,
group_access: ProjectGroupLink::MASTER)
expect(finder.execute).to contain_exactly(project)
end
end
context 'with nested groups', :nested_groups do

View file

@ -9,9 +9,14 @@ import GroupsStore from '~/groups/store/groups_store';
import GroupsService from '~/groups/service/groups_service';
import {
mockEndpoint, mockGroups, mockSearchedGroups,
mockRawPageInfo, mockParentGroupItem, mockRawChildren,
mockChildren, mockPageInfo,
mockEndpoint,
mockGroups,
mockSearchedGroups,
mockRawPageInfo,
mockParentGroupItem,
mockRawChildren,
mockChildren,
mockPageInfo,
} from '../mock_data';
const createComponent = (hideProjects = false) => {
@ -28,22 +33,23 @@ const createComponent = (hideProjects = false) => {
});
};
const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
if (failed) {
reject(data);
} else {
resolve({
json() {
return data;
},
});
}
});
const returnServicePromise = (data, failed) =>
new Promise((resolve, reject) => {
if (failed) {
reject(data);
} else {
resolve({
json() {
return data;
},
});
}
});
describe('AppComponent', () => {
let vm;
beforeEach((done) => {
beforeEach(done => {
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
@ -94,7 +100,7 @@ describe('AppComponent', () => {
});
describe('fetchGroups', () => {
it('should call `getGroups` with all the params provided', (done) => {
it('should call `getGroups` with all the params provided', done => {
spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups));
vm.fetchGroups({
@ -110,8 +116,10 @@ describe('AppComponent', () => {
}, 0);
});
it('should set headers to store for building pagination info when called with `updatePagination`', (done) => {
spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise({ headers: mockRawPageInfo }));
it('should set headers to store for building pagination info when called with `updatePagination`', done => {
spyOn(vm.service, 'getGroups').and.returnValue(
returnServicePromise({ headers: mockRawPageInfo }),
);
spyOn(vm, 'updatePagination');
vm.fetchGroups({ updatePagination: true });
@ -122,7 +130,7 @@ describe('AppComponent', () => {
}, 0);
});
it('should show flash error when request fails', (done) => {
it('should show flash error when request fails', done => {
spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true));
spyOn($, 'scrollTo');
spyOn(window, 'Flash');
@ -138,7 +146,7 @@ describe('AppComponent', () => {
});
describe('fetchAllGroups', () => {
it('should fetch default set of groups', (done) => {
it('should fetch default set of groups', done => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
spyOn(vm, 'updatePagination').and.callThrough();
spyOn(vm, 'updateGroups').and.callThrough();
@ -153,7 +161,7 @@ describe('AppComponent', () => {
}, 0);
});
it('should fetch matching set of groups when app is loaded with search query', (done) => {
it('should fetch matching set of groups when app is loaded with search query', done => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups));
spyOn(vm, 'updateGroups').and.callThrough();
@ -173,7 +181,7 @@ describe('AppComponent', () => {
});
describe('fetchPage', () => {
it('should fetch groups for provided page details and update window state', (done) => {
it('should fetch groups for provided page details and update window state', done => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
spyOn(vm, 'updateGroups').and.callThrough();
const mergeUrlParams = spyOnDependency(appComponent, 'mergeUrlParams').and.callThrough();
@ -193,9 +201,13 @@ describe('AppComponent', () => {
expect(vm.isLoading).toBe(false);
expect($.scrollTo).toHaveBeenCalledWith(0);
expect(mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String));
expect(window.history.replaceState).toHaveBeenCalledWith({
page: jasmine.any(String),
}, jasmine.any(String), jasmine.any(String));
expect(window.history.replaceState).toHaveBeenCalledWith(
{
page: jasmine.any(String),
},
jasmine.any(String),
jasmine.any(String),
);
expect(vm.updateGroups).toHaveBeenCalled();
done();
}, 0);
@ -211,7 +223,7 @@ describe('AppComponent', () => {
groupItem.isChildrenLoading = false;
});
it('should fetch children of given group and expand it if group is collapsed and children are not loaded', (done) => {
it('should fetch children of given group and expand it if group is collapsed and children are not loaded', done => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren));
spyOn(vm.store, 'setGroupChildren');
@ -244,7 +256,7 @@ describe('AppComponent', () => {
expect(groupItem.isOpen).toBe(false);
});
it('should set `isChildrenLoading` back to `false` if load request fails', (done) => {
it('should set `isChildrenLoading` back to `false` if load request fails', done => {
spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true));
vm.toggleChildren(groupItem);
@ -272,7 +284,9 @@ describe('AppComponent', () => {
expect(vm.groupLeaveConfirmationMessage).toBe('');
vm.showLeaveGroupModal(group, mockParentGroupItem);
expect(vm.showModal).toBe(true);
expect(vm.groupLeaveConfirmationMessage).toBe(`Are you sure you want to leave the "${group.fullName}" group?`);
expect(vm.groupLeaveConfirmationMessage).toBe(
`Are you sure you want to leave the "${group.fullName}" group?`,
);
});
});
@ -299,7 +313,7 @@ describe('AppComponent', () => {
vm.targetParentGroup = groupItem;
});
it('hides modal confirmation leave group and remove group item from tree', (done) => {
it('hides modal confirmation leave group and remove group item from tree', done => {
const notice = `You left the "${childGroupItem.fullName}" group.`;
spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice }));
spyOn(vm.store, 'removeGroup').and.callThrough();
@ -318,9 +332,11 @@ describe('AppComponent', () => {
}, 0);
});
it('should show error flash message if request failed to leave group', (done) => {
it('should show error flash message if request failed to leave group', done => {
const message = 'An error occurred. Please try again.';
spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 500 }, true));
spyOn(vm.service, 'leaveGroup').and.returnValue(
returnServicePromise({ status: 500 }, true),
);
spyOn(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash');
@ -335,9 +351,11 @@ describe('AppComponent', () => {
}, 0);
});
it('should show appropriate error flash message if request forbids to leave group', (done) => {
it('should show appropriate error flash message if request forbids to leave group', done => {
const message = 'Failed to leave the group. Please make sure you are not the only owner.';
spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 403 }, true));
spyOn(vm.service, 'leaveGroup').and.returnValue(
returnServicePromise({ status: 403 }, true),
);
spyOn(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash');
@ -388,7 +406,7 @@ describe('AppComponent', () => {
});
describe('created', () => {
it('should bind event listeners on eventHub', (done) => {
it('should bind event listeners on eventHub', done => {
spyOn(eventHub, '$on');
const newVm = createComponent();
@ -405,21 +423,21 @@ describe('AppComponent', () => {
});
});
it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', (done) => {
it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', done => {
const newVm = createComponent();
newVm.$mount();
Vue.nextTick(() => {
expect(newVm.searchEmptyMessage).toBe('Sorry, no groups or projects matched your search');
expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search');
newVm.$destroy();
done();
});
});
it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', (done) => {
it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', done => {
const newVm = createComponent(true);
newVm.$mount();
Vue.nextTick(() => {
expect(newVm.searchEmptyMessage).toBe('Sorry, no groups matched your search');
expect(newVm.searchEmptyMessage).toBe('No groups matched your search');
newVm.$destroy();
done();
});
@ -427,7 +445,7 @@ describe('AppComponent', () => {
});
describe('beforeDestroy', () => {
it('should unbind event listeners on eventHub', (done) => {
it('should unbind event listeners on eventHub', done => {
spyOn(eventHub, '$off');
const newVm = createComponent();
@ -454,7 +472,7 @@ describe('AppComponent', () => {
vm.$destroy();
});
it('should render loading icon', (done) => {
it('should render loading icon', done => {
vm.isLoading = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
@ -463,7 +481,7 @@ describe('AppComponent', () => {
});
});
it('should render groups tree', (done) => {
it('should render groups tree', done => {
vm.store.state.groups = [mockParentGroupItem];
vm.isLoading = false;
vm.store.state.pageInfo = mockPageInfo;
@ -473,7 +491,7 @@ describe('AppComponent', () => {
});
});
it('renders modal confirmation dialog', (done) => {
it('renders modal confirmation dialog', done => {
vm.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?';
vm.showModal = true;
Vue.nextTick(() => {