Commit 5b74a1ae authored by Dennis Tang's avatar Dennis Tang Committed by Mike Greiling

Resolve "Improve handling of projects shared with a group"

parent 53fae9ad
...@@ -2,14 +2,15 @@ ...@@ -2,14 +2,15 @@
/* global Flash */ /* global Flash */
import $ from 'jquery'; import $ from 'jquery';
import { s__ } from '~/locale'; import { s__, sprintf } from '~/locale';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.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 { getParameterByName } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { COMMON_STR } from '../constants'; import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants';
import groupsComponent from './groups.vue'; import groupsComponent from './groups.vue';
export default { export default {
...@@ -19,6 +20,16 @@ export default { ...@@ -19,6 +20,16 @@ export default {
groupsComponent, groupsComponent,
}, },
props: { props: {
action: {
type: String,
required: false,
default: '',
},
containerId: {
type: String,
required: false,
default: '',
},
store: { store: {
type: Object, type: Object,
required: true, required: true,
...@@ -56,31 +67,28 @@ export default { ...@@ -56,31 +67,28 @@ export default {
? COMMON_STR.GROUP_SEARCH_EMPTY ? COMMON_STR.GROUP_SEARCH_EMPTY
: COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY; : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
eventHub.$on('fetchPage', this.fetchPage); eventHub.$on(`${this.action}fetchPage`, this.fetchPage);
eventHub.$on('toggleChildren', this.toggleChildren); eventHub.$on(`${this.action}toggleChildren`, this.toggleChildren);
eventHub.$on('showLeaveGroupModal', this.showLeaveGroupModal); eventHub.$on(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal);
eventHub.$on('updatePagination', this.updatePagination); eventHub.$on(`${this.action}updatePagination`, this.updatePagination);
eventHub.$on('updateGroups', this.updateGroups); eventHub.$on(`${this.action}updateGroups`, this.updateGroups);
}, },
mounted() { mounted() {
this.fetchAllGroups(); this.fetchAllGroups();
if (this.containerId) {
this.containerEl = document.getElementById(this.containerId);
}
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('fetchPage', this.fetchPage); eventHub.$off(`${this.action}fetchPage`, this.fetchPage);
eventHub.$off('toggleChildren', this.toggleChildren); eventHub.$off(`${this.action}toggleChildren`, this.toggleChildren);
eventHub.$off('showLeaveGroupModal', this.showLeaveGroupModal); eventHub.$off(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal);
eventHub.$off('updatePagination', this.updatePagination); eventHub.$off(`${this.action}updatePagination`, this.updatePagination);
eventHub.$off('updateGroups', this.updateGroups); eventHub.$off(`${this.action}updateGroups`, this.updateGroups);
}, },
methods: { methods: {
fetchGroups({ fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
parentId,
page,
filterGroupsBy,
sortBy,
archived,
updatePagination,
}) {
return this.service return this.service
.getGroups(parentId, page, filterGroupsBy, sortBy, archived) .getGroups(parentId, page, filterGroupsBy, sortBy, archived)
.then(res => { .then(res => {
...@@ -165,13 +173,13 @@ export default { ...@@ -165,13 +173,13 @@ export default {
} }
}, },
showLeaveGroupModal(group, parentGroup) { showLeaveGroupModal(group, parentGroup) {
const { fullName } = group;
this.targetGroup = group; this.targetGroup = group;
this.targetParentGroup = parentGroup; this.targetParentGroup = parentGroup;
this.showModal = true; this.showModal = true;
this.groupLeaveConfirmationMessage = s__( this.groupLeaveConfirmationMessage = sprintf(
`GroupsTree|Are you sure you want to leave the "${ s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'),
group.fullName { fullName },
}" group?`,
); );
}, },
hideLeaveGroupModal() { hideLeaveGroupModal() {
...@@ -197,16 +205,35 @@ export default { ...@@ -197,16 +205,35 @@ export default {
this.targetGroup.isBeingRemoved = false; 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) { updatePagination(headers) {
this.store.setPaginationInfo(headers); this.store.setPaginationInfo(headers);
}, },
updateGroups(groups, fromSearch) { updateGroups(groups, fromSearch) {
this.isSearchEmpty = groups ? groups.length === 0 : false; const hasGroups = groups && groups.length > 0;
this.isSearchEmpty = !hasGroups;
if (fromSearch) { if (fromSearch) {
this.store.setSearchedGroups(groups); this.store.setSearchedGroups(groups);
} else { } else {
this.store.setGroups(groups); this.store.setGroups(groups);
} }
if (this.action && !hasGroups && !fromSearch) {
this.showEmptyState();
}
}, },
}, },
}; };
...@@ -226,6 +253,7 @@ export default { ...@@ -226,6 +253,7 @@ export default {
:search-empty="isSearchEmpty" :search-empty="isSearchEmpty"
:search-empty-message="searchEmptyMessage" :search-empty-message="searchEmptyMessage"
:page-info="pageInfo" :page-info="pageInfo"
:action="action"
/> />
<deprecated-modal <deprecated-modal
v-show="showModal" v-show="showModal"
......
...@@ -11,8 +11,12 @@ export default { ...@@ -11,8 +11,12 @@ export default {
}, },
groups: { groups: {
type: Array, type: Array,
required: true,
},
action: {
type: String,
required: false, required: false,
default: () => ([]), default: '',
}, },
}, },
computed: { computed: {
...@@ -37,6 +41,7 @@ export default { ...@@ -37,6 +41,7 @@ export default {
:key="index" :key="index"
:group="group" :group="group"
:parent-group="parentGroup" :parent-group="parentGroup"
:action="action"
/> />
<li <li
v-if="hasMoreChildren" v-if="hasMoreChildren"
......
...@@ -30,6 +30,11 @@ export default { ...@@ -30,6 +30,11 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
action: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
groupDomId() { groupDomId() {
...@@ -56,10 +61,12 @@ export default { ...@@ -56,10 +61,12 @@ export default {
methods: { methods: {
onClickRowGroup(e) { onClickRowGroup(e) {
const NO_EXPAND_CLS = 'no-expand'; const NO_EXPAND_CLS = 'no-expand';
if (!(e.target.classList.contains(NO_EXPAND_CLS) || const targetClasses = e.target.classList;
e.target.parentElement.classList.contains(NO_EXPAND_CLS))) { const parentElClasses = e.target.parentElement.classList;
if (!(targetClasses.contains(NO_EXPAND_CLS) || parentElClasses.contains(NO_EXPAND_CLS))) {
if (this.hasChildren) { if (this.hasChildren) {
eventHub.$emit('toggleChildren', this.group); eventHub.$emit(`${this.action}toggleChildren`, this.group);
} else { } else {
visitUrl(this.group.relativePath); visitUrl(this.group.relativePath);
} }
...@@ -158,6 +165,7 @@ export default { ...@@ -158,6 +165,7 @@ export default {
v-if="group.isOpen && hasChildren" v-if="group.isOpen && hasChildren"
:parent-group="group" :parent-group="group"
:groups="group.children" :groups="group.children"
:action="action"
/> />
</li> </li>
</template> </template>
<script> <script>
import tablePagination from '~/vue_shared/components/table_pagination.vue'; import tablePagination from '~/vue_shared/components/table_pagination.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils'; import { getParameterByName } from '../../lib/utils/common_utils';
export default { export default {
components: { components: {
tablePagination, tablePagination,
}, },
...@@ -24,16 +24,21 @@ ...@@ -24,16 +24,21 @@
type: String, type: String,
required: true, required: true,
}, },
action: {
type: String,
required: false,
default: '',
},
}, },
methods: { methods: {
change(page) { change(page) {
const filterGroupsParam = getParameterByName('filter_groups'); const filterGroupsParam = getParameterByName('filter_groups');
const sortParam = getParameterByName('sort'); const sortParam = getParameterByName('sort');
const archivedParam = getParameterByName('archived'); const archivedParam = getParameterByName('archived');
eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam); eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam);
}, },
}, },
}; };
</script> </script>
<template> <template>
...@@ -47,6 +52,7 @@ ...@@ -47,6 +52,7 @@
<group-folder <group-folder
v-if="!searchEmpty" v-if="!searchEmpty"
:groups="groups" :groups="groups"
:action="action"
/> />
<table-pagination <table-pagination
v-if="!searchEmpty" v-if="!searchEmpty"
......
...@@ -21,6 +21,11 @@ export default { ...@@ -21,6 +21,11 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
action: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
leaveBtnTitle() { leaveBtnTitle() {
...@@ -32,7 +37,7 @@ export default { ...@@ -32,7 +37,7 @@ export default {
}, },
methods: { methods: {
onLeaveGroup() { onLeaveGroup() {
eventHub.$emit('showLeaveGroupModal', this.group, this.parentGroup); eventHub.$emit(`${this.action}showLeaveGroupModal`, this.group, this.parentGroup);
}, },
}, },
}; };
......
...@@ -2,13 +2,23 @@ import { __, s__ } from '../locale'; ...@@ -2,13 +2,23 @@ import { __, s__ } from '../locale';
export const MAX_CHILDREN_COUNT = 20; 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 = { export const COMMON_STR = {
FAILURE: __('An error occurred. Please try again.'), 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'), LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
EDIT_BTN_TITLE: s__('GroupsTree|Edit group'), EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'), GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'),
GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'), GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'),
}; };
export const ITEM_TYPE = { export const ITEM_TYPE = {
...@@ -17,8 +27,12 @@ export const ITEM_TYPE = { ...@@ -17,8 +27,12 @@ export const ITEM_TYPE = {
}; };
export const GROUP_VISIBILITY_TYPE = { export const GROUP_VISIBILITY_TYPE = {
public: __('Public - The group and any public projects can be viewed without any authentication.'), public: __(
internal: __('Internal - The group and any internal projects can be viewed by any logged in user.'), '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.'), private: __('Private - The group and its projects can only be viewed by members.'),
}; };
......
...@@ -4,13 +4,23 @@ import eventHub from './event_hub'; ...@@ -4,13 +4,23 @@ import eventHub from './event_hub';
import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils'; import { normalizeHeaders, getParameterByName } from '../lib/utils/common_utils';
export default class GroupFilterableList extends FilterableList { 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); super(form, filter, holder, filterInputField);
this.form = form; this.form = form;
this.filterEndpoint = filterEndpoint; this.filterEndpoint = filterEndpoint;
this.pagePath = pagePath; this.pagePath = pagePath;
this.filterInputField = filterInputField; this.filterInputField = filterInputField;
this.$dropdown = $(dropdownSel); this.$dropdown = $(dropdownSel);
this.action = action;
} }
getFilterEndpoint() { getFilterEndpoint() {
...@@ -20,15 +30,16 @@ export default class GroupFilterableList extends FilterableList { ...@@ -20,15 +30,16 @@ export default class GroupFilterableList extends FilterableList {
getPagePath(queryData) { getPagePath(queryData) {
const params = queryData ? $.param(queryData) : ''; const params = queryData ? $.param(queryData) : '';
const queryString = params ? `?${params}` : ''; const queryString = params ? `?${params}` : '';
return `${this.pagePath}${queryString}`; const path = this.pagePath || window.location.pathname;
return `${path}${queryString}`;
} }
bindEvents() { bindEvents() {
super.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() { onFilterInput() {
...@@ -53,7 +64,12 @@ export default class GroupFilterableList extends FilterableList { ...@@ -53,7 +64,12 @@ export default class GroupFilterableList extends FilterableList {
} }
setDefaultFilterOption() { 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); this.$dropdown.find('.dropdown-label').text(defaultOption);
} }
...@@ -65,11 +81,19 @@ export default class GroupFilterableList extends FilterableList { ...@@ -65,11 +81,19 @@ export default class GroupFilterableList extends FilterableList {
// Get type of option selected from dropdown // Get type of option selected from dropdown
const currentTargetClassList = e.currentTarget.parentElement.classList; const currentTargetClassList = e.currentTarget.parentElement.classList;
const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order'); 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 // Get option query param, also preserve currently applied query param
const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href); const sortParam = getParameterByName(
const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href); 'sort',
isOptionFilterBySort ? e.currentTarget.href : window.location.href,
);
const archivedParam = getParameterByName(
'archived',
isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href,
);
if (sortParam) { if (sortParam) {
queryData.sort = sortParam; queryData.sort = sortParam;
...@@ -86,7 +110,9 @@ export default class GroupFilterableList extends FilterableList { ...@@ -86,7 +110,9 @@ export default class GroupFilterableList extends FilterableList {
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text)); this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active'); this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active');
} else if (isOptionFilterByArchivedProjects) { } 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'); $(e.target).addClass('is-active');
...@@ -98,11 +124,19 @@ export default class GroupFilterableList extends FilterableList { ...@@ -98,11 +124,19 @@ export default class GroupFilterableList extends FilterableList {
onFilterSuccess(res, queryData) { onFilterSuccess(res, queryData) {
const currentPath = this.getPagePath(queryData); const currentPath = this.getPagePath(queryData);
window.history.replaceState({ window.history.replaceState(
{
page: currentPath, page: currentPath,
}, document.title, currentPath); },
document.title,
eventHub.$emit('updateGroups', res.data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField)); currentPath,
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));
} }
} }
...@@ -7,18 +7,26 @@ import GroupsService from './service/groups_service'; ...@@ -7,18 +7,26 @@ import GroupsService from './service/groups_service';
import groupsApp from './components/app.vue'; import groupsApp from './components/app.vue';
import groupFolderComponent from './components/group_folder.vue'; import groupFolderComponent from './components/group_folder.vue';
import groupItemComponent from './components/group_item.vue'; import groupItemComponent from './components/group_item.vue';
import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants';
Vue.use(Translate); Vue.use(Translate);
export default () => { export default (containerId = 'js-groups-tree', endpoint, action = '') => {
const el = document.getElementById('js-groups-tree'); const containerEl = document.getElementById(containerId);
let dataEl;
// Don't do anything if element doesn't exist (No groups) // Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL // This is for when the user enters directly to the page via URL
if (!el) { if (!containerEl) {
return; 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-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent); Vue.component('group-item', groupItemComponent);
...@@ -29,20 +37,26 @@ export default () => { ...@@ -29,20 +37,26 @@ export default () => {
groupsApp, groupsApp,
}, },
data() { data() {
const { dataset } = this.$options.el; const { dataset } = dataEl || this.$options.el;
const hideProjects = dataset.hideProjects === 'true'; const hideProjects = dataset.hideProjects === 'true';
const service = new GroupsService(endpoint || dataset.endpoint);
const store = new GroupsStore(hideProjects); const store = new GroupsStore(hideProjects);
const service = new GroupsService(dataset.endpoint);
return { return {
action,
store, store,
service, service,
hideProjects, hideProjects,
loading: true, loading: true,
containerId,
}; };
}, },
beforeMount() { beforeMount() {
const { dataset } = this.$options.el; if (this.action) {
return;
}
const { dataset } = dataEl || this.$options.el;
let groupFilterList = null; let groupFilterList = null;
const form = document.querySelector(dataset.formSel); const form = document.querySelector(dataset.formSel);
const filter = document.querySelector(dataset.filterSel); const filter = document.querySelector(dataset.filterSel);
...@@ -52,10 +66,11 @@ export default () => { ...@@ -52,10 +66,11 @@ export default () => {
form, form,
filter, filter,
holder, holder,
filterEndpoint: dataset.endpoint, filterEndpoint: endpoint || dataset.endpoint,
pagePath: dataset.path, pagePath: dataset.path,
dropdownSel: dataset.dropdownSel, dropdownSel: dataset.dropdownSel,
filterInputField: 'filter', filterInputField: 'filter',
action: this.action,
}; };
groupFilterList = new GroupFilterableList(opts); groupFilterList = new GroupFilterableList(opts);
...@@ -64,9 +79,11 @@ export default () => { ...@@ -64,9 +79,11 @@ export default () => {
render(createElement) { render(createElement) {
return createElement('groups-app', { return createElement('groups-app', {
props: { props: {
action: this.action,
store: this.store, store: this.store,
service: this.service, service: this.service,
hideProjects: this.hideProjects, hideProjects: this.hideProjects,
containerId: this.containerId,
}, },
}); });
}, },
......
...@@ -47,9 +47,9 @@ export function removeParamQueryString(url, param) { ...@@ -47,9 +47,9 @@ export function removeParamQueryString(url, param) {
return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&'); 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'); const url = document.createElement('a');
url.href = window.location.href; url.href = source;
params.forEach(param => { params.forEach(param => {
url.search = removeParamQueryString(url.search, param); url.search = removeParamQueryString(url.search, param);
......
import initGroupsList from '~/groups'; import initGroupsList from '~/groups';
document.addEventListener('DOMContentLoaded', initGroupsList); document.addEventListener('DOMContentLoaded', () => {
initGroupsList();
});
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,
);
}
}
/* eslint-disable no-new */ /* 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 NewGroupChild from '~/groups/new_group_child';
import notificationsDropdown from '~/notifications_dropdown'; import notificationsDropdown from '~/notifications_dropdown';
import NotificationsForm from '~/notifications_form'; import NotificationsForm from '~/notifications_form';
import ProjectsList from '~/projects_list'; import ProjectsList from '~/projects_list';
import ShortcutsNavigation from '~/shortcuts_navigation'; import ShortcutsNavigation from '~/shortcuts_navigation';
import initGroupsList from '~/groups'; import GroupTabs from './group_tabs';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); 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 ShortcutsNavigation();
new NotificationsForm(); new NotificationsForm();
notificationsDropdown(); notificationsDropdown();
...@@ -17,6 +25,4 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -17,6 +25,4 @@ document.addEventListener('DOMContentLoaded', () => {
if (newGroupChildWrapper) { if (newGroupChildWrapper) {
new NewGroupChild(newGroupChildWrapper); new NewGroupChild(newGroupChildWrapper);
} }
initGroupsList();
}); });
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
} }
.dashboard .side .card .card-header .input-group { .dashboard .side .card .card-header .input-group {
.form-control { .form-control {
height: 42px; height: 42px;
} }
...@@ -30,14 +29,15 @@ ...@@ -30,14 +29,15 @@
} }
} }
.group-nav-container .group-search,
.group-nav-container .nav-controls { .group-nav-container .nav-controls {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
padding: $gl-padding-top 0; padding: $gl-padding-top 0 0;
border-bottom: 1px solid $border-color;
.group-filter-form { .group-filter-form {
flex: 1; flex: 1 1 auto;
margin-right: $gl-padding-8;
} }
.dropdown-menu-right { .dropdown-menu-right {
...@@ -136,6 +136,10 @@ ...@@ -136,6 +136,10 @@
flex: 1; flex: 1;
} }
.dropdown-toggle {
width: auto;
}
.dropdown-menu { .dropdown-menu {
width: 100%; width: 100%;
max-width: inherit; max-width: inherit;
...@@ -145,38 +149,14 @@ ...@@ -145,38 +149,14 @@
} }
} }
.groups-empty-state { .group-nav-container .group-search {
padding: 50px 100px; padding: $gl-padding 0;
overflow: hidden; 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) { .groups-listing {
float: none; .group-list-tree .group-row:first-child {
margin-top: 60px; border-top: 0;
width: auto;
text-align: center;
}
} }
} }
...@@ -278,7 +258,7 @@ ...@@ -278,7 +258,7 @@
} }
&::after { &::after {
content: ""; content: '';
position: absolute; position: absolute;
height: 100%; height: 100%;
width: 100%; width: 100%;
...@@ -346,7 +326,7 @@ ...@@ -346,7 +326,7 @@
position: relative; position: relative;
&::before { &::before {
content: ""; content: '';
display: block; display: block;
width: 10px; width: 10px;
height: 0; height: 0;
......
...@@ -17,7 +17,7 @@ class GroupsController < Groups::ApplicationController ...@@ -17,7 +17,7 @@ class GroupsController < Groups::ApplicationController
before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests] before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests]
before_action :event_filter, only: [:activity] 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, skip_cross_project_access_check :index, :new, :create, :edit, :update,
:destroy, :projects :destroy, :projects
...@@ -53,11 +53,7 @@ class GroupsController < Groups::ApplicationController ...@@ -53,11 +53,7 @@ class GroupsController < Groups::ApplicationController
def show def show
respond_to do |format| respond_to do |format|
format.html do format.html
@has_children = GroupDescendantsFinder.new(current_user: current_user,
parent_group: @group,
params: params).has_children?
end
format.atom do format.atom do
load_events load_events
......
...@@ -134,7 +134,7 @@ class GroupDescendantsFinder ...@@ -134,7 +134,7 @@ class GroupDescendantsFinder
end end
def direct_child_projects 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 .execute
end end
......
#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')
.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')
#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')
#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')
...@@ -7,11 +7,10 @@ ...@@ -7,11 +7,10 @@
= render 'groups/home_panel' = render 'groups/home_panel'
.groups-header{ class: container_class } .groups-listing{ class: container_class, data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
.group-nav-container .top-area.group-nav-container
.nav-controls.clearfix .group-search
= render "shared/groups/search_form" = render "shared/groups/search_form"
= render "shared/groups/dropdown", show_archive_options: true
- if can? current_user, :create_projects, @group - if can? current_user, :create_projects, @group
- new_project_label = _("New project") - new_project_label = _("New project")
- new_subgroup_label = _("New subgroup") - new_subgroup_label = _("New subgroup")
...@@ -39,7 +38,29 @@ ...@@ -39,7 +38,29 @@
- else - else
= link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success" = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
- if params[:filter].blank? && !@has_children .scrolling-tabs-container.inner-page-scroll-tabs
= render "shared/groups/empty_state" .fade-left= icon('angle-left')
- else .fade-right= icon('angle-right')
= render "children", children: @children, group: @group %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
...@@ -2,19 +2,19 @@ ...@@ -2,19 +2,19 @@
.col-sm-12 .col-sm-12
= form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do = form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do
.form-group .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) = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp", required: true)
.form-group .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-wrapper
= select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control" = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
= icon('chevron-down') = icon('chevron-down')
.form-text.text-muted.append-bottom-10 .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 about role permissions
.form-group .form-group
= label_tag :expires_at, 'Access expiration date', class: 'label-bold' = label_tag :expires_at, _('Access expiration date'), class: 'label-bold'
.clearable-input .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 %i.clear-icon.js-clear-input
= submit_tag "Share", class: "btn btn-create" = submit_tag _("Invite"), class: "btn btn-create"
...@@ -6,9 +6,9 @@ ...@@ -6,9 +6,9 @@
Project members Project members
- if can?(current_user, :admin_project_member, @project) - if can?(current_user, :admin_project_member, @project)
%p %p
You can add a new member to You can invite a new member to
%strong= @project.name %strong= @project.name
or share it with another group. or invite another group.
- else - else
%p %p
Members can be added by project Members can be added by project
...@@ -19,16 +19,16 @@ ...@@ -19,16 +19,16 @@
- if can?(current_user, :admin_project_member, @project) - if can?(current_user, :admin_project_member, @project)
%ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' } %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? - if @project.allowed_to_share_with_group?
%li.nav-tab{ role: 'presentation' } %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-content.gitlab-tab-content
.tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' } .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_member', tab_title: 'Add member' = render 'projects/project_members/new_project_member', tab_title: 'Invite member'
.tab-pane{ id: 'share-with-group-pane', role: 'tabpanel' } .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
= render 'projects/project_members/new_shared_group', tab_title: 'Share with group' = render 'projects/project_members/new_project_group', tab_title: 'Invite group'
= render 'shared/members/requests', membership_source: @project, requesters: @requesters = render 'shared/members/requests', membership_source: @project, requesters: @requesters
.clearfix .clearfix
......
.groups-empty-state.qa-groups-empty-state .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") = 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.") %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|If you organize your projects under a group, it works like a folder.")
%p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.") %p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.")
= form_tag request.path, method: :get, class: 'group-filter-form append-right-10', id: 'group-filter-form' do |f| = 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|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" = 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"
---
title: Overhaul listing of projects in the group overview page
merge_request: 20262
author:
type: added
...@@ -14,6 +14,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -14,6 +14,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
get :projects, as: :projects_group get :projects, as: :projects_group
get :activity, as: :activity_group get :activity, as: :activity_group
put :transfer, as: :transfer_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 end
get '/', action: :show, as: :group_canonical get '/', action: :show, as: :group_canonical
......
...@@ -295,6 +295,9 @@ msgstr "" ...@@ -295,6 +295,9 @@ msgstr ""
msgid "Access denied! Please verify you can add deploy keys to this repository." msgid "Access denied! Please verify you can add deploy keys to this repository."
msgstr "" 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." 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 "" msgstr ""
...@@ -604,6 +607,9 @@ msgstr "" ...@@ -604,6 +607,9 @@ msgstr ""
msgid "Archived project! Repository and other project resources are read-only" msgid "Archived project! Repository and other project resources are read-only"
msgstr "" msgstr ""
msgid "Archived projects"
msgstr ""
msgid "Are you sure you want to delete this pipeline schedule?" msgid "Are you sure you want to delete this pipeline schedule?"
msgstr "" msgstr ""
...@@ -2622,6 +2628,9 @@ msgstr "" ...@@ -2622,6 +2628,9 @@ msgstr ""
msgid "Expand sidebar" msgid "Expand sidebar"
msgstr "" msgstr ""
msgid "Expiration date"
msgstr ""
msgid "Explore" msgid "Explore"
msgstr "" msgstr ""
...@@ -2985,6 +2994,9 @@ msgstr "" ...@@ -2985,6 +2994,9 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group." msgid "GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group."
msgstr "" msgstr ""
msgid "GroupsTree|Are you sure you want to leave the \"%{fullName}\" group?"
msgstr ""
msgid "GroupsTree|Create a project in this group." msgid "GroupsTree|Create a project in this group."
msgstr "" msgstr ""
...@@ -2997,19 +3009,19 @@ msgstr "" ...@@ -2997,19 +3009,19 @@ msgstr ""
msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner." msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
msgstr "" msgstr ""
msgid "GroupsTree|Filter by name..."
msgstr ""
msgid "GroupsTree|Leave this group" msgid "GroupsTree|Leave this group"
msgstr "" msgstr ""
msgid "GroupsTree|Loading groups" msgid "GroupsTree|Loading groups"
msgstr "" msgstr ""
msgid "GroupsTree|Sorry, no groups matched your search" msgid "GroupsTree|No groups matched your search"
msgstr "" 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 "" msgstr ""
msgid "Health Check" msgid "Health Check"
...@@ -3245,6 +3257,9 @@ msgstr "" ...@@ -3245,6 +3257,9 @@ msgstr ""
msgid "Introducing Cycle Analytics" msgid "Introducing Cycle Analytics"
msgstr "" msgstr ""
msgid "Invite"
msgstr ""
msgid "Issue Boards" msgid "Issue Boards"
msgstr "" msgstr ""
...@@ -3582,6 +3597,9 @@ msgstr "" ...@@ -3582,6 +3597,9 @@ msgstr ""
msgid "Markdown enabled" msgid "Markdown enabled"
msgstr "" msgstr ""
msgid "Max access level"
msgstr ""
msgid "Maximum git storage failures" msgid "Maximum git storage failures"
msgstr "" msgstr ""
...@@ -5072,6 +5090,9 @@ msgstr "" ...@@ -5072,6 +5090,9 @@ msgstr ""
msgid "Select Archive Format" msgid "Select Archive Format"
msgstr "" msgstr ""
msgid "Select a group to invite"
msgstr ""
msgid "Select a namespace to fork the project" msgid "Select a namespace to fork the project"
msgstr "" msgstr ""
...@@ -5171,6 +5192,9 @@ msgstr "" ...@@ -5171,6 +5192,9 @@ msgstr ""
msgid "Shared Runners" msgid "Shared Runners"
msgstr "" msgstr ""
msgid "Shared projects"
msgstr ""
msgid "Sherlock Transactions" msgid "Sherlock Transactions"
msgstr "" msgstr ""
...@@ -5449,6 +5473,9 @@ msgstr "" ...@@ -5449,6 +5473,9 @@ msgstr ""
msgid "Subgroups" msgid "Subgroups"
msgstr "" msgstr ""
msgid "Subgroups and projects"
msgstr ""
msgid "Submit as spam" msgid "Submit as spam"
msgstr "" msgstr ""
...@@ -5691,6 +5718,9 @@ 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." 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 "" msgstr ""
msgid "There are no archived projects yet"
msgstr ""
msgid "There are no issues to show" msgid "There are no issues to show"
msgstr "" msgstr ""
...@@ -5700,6 +5730,9 @@ msgstr "" ...@@ -5700,6 +5730,9 @@ msgstr ""
msgid "There are no merge requests to show" msgid "There are no merge requests to show"
msgstr "" msgstr ""
msgid "There are no projects shared with this group yet"
msgstr ""
msgid "There are problems accessing Git storage: " msgid "There are problems accessing Git storage: "
msgstr "" msgstr ""
......
...@@ -7,7 +7,7 @@ module QA ...@@ -7,7 +7,7 @@ module QA
def self.included(base) def self.included(base)
base.view 'app/views/shared/groups/_search_form.html.haml' do base.view 'app/views/shared/groups/_search_form.html.haml' do
element :groups_filter, 'search_field_tag :filter' element :groups_filter, 'search_field_tag :filter'
element :groups_filter_placeholder, 'Filter by name...' element :groups_filter_placeholder, 'Search by name'
end end
base.view 'app/views/shared/groups/_empty_state.html.haml' do base.view 'app/views/shared/groups/_empty_state.html.haml' do
...@@ -27,7 +27,7 @@ module QA ...@@ -27,7 +27,7 @@ module QA
page.has_css?(element_selector_css(:groups_list_tree_container)) page.has_css?(element_selector_css(:groups_list_tree_container))
end end
fill_in 'Filter by name...', with: name fill_in 'Search by name', with: name
end end
end end
end end
......
...@@ -4,6 +4,11 @@ module QA ...@@ -4,6 +4,11 @@ module QA
class Groups < Page::Base class Groups < Page::Base
include Page::Component::GroupsFilter 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 view 'app/views/dashboard/_groups_head.html.haml' do
element :new_group_button, 'link_to _("New group")' element :new_group_button, 'link_to _("New group")'
end end
......
...@@ -16,7 +16,7 @@ module QA ...@@ -16,7 +16,7 @@ module QA
end end
view 'app/assets/javascripts/groups/constants.js' do 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 end
def go_to_subgroup(name) def go_to_subgroup(name)
...@@ -30,7 +30,7 @@ module QA ...@@ -30,7 +30,7 @@ module QA
def has_subgroup?(name) def has_subgroup?(name)
filter_by_name(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) page.has_text?(name, wait: 0)
end end
......
...@@ -38,14 +38,6 @@ describe GroupsController do ...@@ -38,14 +38,6 @@ describe GroupsController do
project project
end 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 context 'as atom' do
it 'assigns events for all the projects in the group' do it 'assigns events for all the projects in the group' do
create(:event, project: project) create(:event, project: project)
......
require 'spec_helper' require 'spec_helper'
describe 'Project > Members > Share with Group', :js do describe 'Project > Members > Invite group', :js do
include Select2Helper include Select2Helper
include ActionView::Helpers::DateHelper include ActionView::Helpers::DateHelper
...@@ -8,17 +8,17 @@ describe 'Project > Members > Share with Group', :js do ...@@ -8,17 +8,17 @@ describe 'Project > Members > Share with Group', :js do
describe 'Share with group lock' do describe 'Share with group lock' do
shared_examples 'the project can be shared with groups' 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) 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
end end
shared_examples 'the project cannot be shared with groups' do 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) visit project_settings_members_path(project)
expect(page).to have_selector('#add-member-tab') expect(page).to have_selector('#invite-member-tab')
expect(page).not_to have_selector('#share-with-group-tab') expect(page).not_to have_selector('#invite-group-tab')
end end
end end
...@@ -31,13 +31,13 @@ describe 'Project > Members > Share with Group', :js do ...@@ -31,13 +31,13 @@ describe 'Project > Members > Share with Group', :js do
sign_in(maintainer) sign_in(maintainer)
end 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_behaves_like 'the project can be shared with groups'
it 'the project can be shared with another group' do it 'the project can be shared with another group' do
visit project_settings_members_path(project) 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' select2 group_to_share_with.id, from: '#link_group_id'
page.find('body').click page.find('body').click
...@@ -49,7 +49,7 @@ describe 'Project > Members > Share with Group', :js do ...@@ -49,7 +49,7 @@ describe 'Project > Members > Share with Group', :js do
end end
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 before do
project.namespace.update_column(:share_with_group_lock, true) project.namespace.update_column(:share_with_group_lock, true)
end end
...@@ -69,12 +69,12 @@ describe 'Project > Members > Share with Group', :js do ...@@ -69,12 +69,12 @@ describe 'Project > Members > Share with Group', :js do
sign_in(maintainer) sign_in(maintainer)
end end
context 'when the root_group has "Share with group lock" disabled' do context 'when the root_group has "Invite group lock" disabled' do
context 'when the subgroup has "Share with group lock" disabled' do context 'when the subgroup has "Invite group lock" disabled' do
it_behaves_like 'the project can be shared with groups' it_behaves_like 'the project can be shared with groups'
end end
context 'when the subgroup has "Share with group lock" enabled' do context 'when the subgroup has "Invite group lock" enabled' do
before do before do
subgroup.update_column(:share_with_group_lock, true) subgroup.update_column(:share_with_group_lock, true)
end end
...@@ -83,16 +83,16 @@ describe 'Project > Members > Share with Group', :js do ...@@ -83,16 +83,16 @@ describe 'Project > Members > Share with Group', :js do
end end
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 before do
root_group.update_column(:share_with_group_lock, true) root_group.update_column(:share_with_group_lock, true)
end 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' it_behaves_like 'the project can be shared with groups'
end end
context 'when the subgroup has "Share with group lock" enabled' do context 'when the subgroup has "Invite group lock" enabled' do
before do before do
subgroup.update_column(:share_with_group_lock, true) subgroup.update_column(:share_with_group_lock, true)
end end
...@@ -117,12 +117,12 @@ describe 'Project > Members > Share with Group', :js do ...@@ -117,12 +117,12 @@ describe 'Project > Members > Share with Group', :js do
visit project_settings_members_path(project) visit project_settings_members_path(project)
click_on 'share-with-group-tab' click_on 'invite-group-tab'
select2 group.id, from: '#link_group_id' select2 group.id, from: '#link_group_id'
fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d') 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 find('.btn-create').click
end end
...@@ -150,7 +150,7 @@ describe 'Project > Members > Share with Group', :js do ...@@ -150,7 +150,7 @@ describe 'Project > Members > Share with Group', :js do
visit project_settings_members_path(project) visit project_settings_members_path(project)
click_link 'Share with group' click_link 'Invite group'
find('.ajax-groups-select.select2-container') find('.ajax-groups-select.select2-container')
...@@ -183,7 +183,7 @@ describe 'Project > Members > Share with Group', :js do ...@@ -183,7 +183,7 @@ describe 'Project > Members > Share with Group', :js do
it 'the groups dropdown does not show ancestors', :nested_groups do it 'the groups dropdown does not show ancestors', :nested_groups do
visit project_settings_members_path(project) visit project_settings_members_path(project)
click_on 'share-with-group-tab' click_on 'invite-group-tab'
click_link 'Search for a group' click_link 'Search for a group'
page.within '.select2-drop' do page.within '.select2-drop' do
......
...@@ -26,13 +26,13 @@ describe 'Projects > Settings > User manages group links' do ...@@ -26,13 +26,13 @@ describe 'Projects > Settings > User manages group links' do
end end
end end
it 'shares a project with a group', :js do it 'invites a group to a project', :js do
click_link('Share with group') click_link('Invite group')
select2(group_market.id, from: '#link_group_id') select2(group_market.id, from: '#link_group_id')
select('Maintainer', from: 'link_group_access') select('Maintainer', from: 'link_group_access')
click_button('Share') click_button('Invite')
page.within('.project-members-groups') do page.within('.project-members-groups') do
expect(page).to have_content('Market') expect(page).to have_content('Market')
......
...@@ -108,6 +108,15 @@ describe GroupDescendantsFinder do ...@@ -108,6 +108,15 @@ describe GroupDescendantsFinder do
end end
end 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 end
context 'with nested groups', :nested_groups do context 'with nested groups', :nested_groups do
......
...@@ -9,9 +9,14 @@ import GroupsStore from '~/groups/store/groups_store'; ...@@ -9,9 +9,14 @@ import GroupsStore from '~/groups/store/groups_store';
import GroupsService from '~/groups/service/groups_service'; import GroupsService from '~/groups/service/groups_service';
import { import {
mockEndpoint, mockGroups, mockSearchedGroups, mockEndpoint,
mockRawPageInfo, mockParentGroupItem, mockRawChildren, mockGroups,
mockChildren, mockPageInfo, mockSearchedGroups,
mockRawPageInfo,
mockParentGroupItem,
mockRawChildren,
mockChildren,
mockPageInfo,
} from '../mock_data'; } from '../mock_data';
const createComponent = (hideProjects = false) => { const createComponent = (hideProjects = false) => {
...@@ -28,7 +33,8 @@ const createComponent = (hideProjects = false) => { ...@@ -28,7 +33,8 @@ const createComponent = (hideProjects = false) => {
}); });
}; };
const returnServicePromise = (data, failed) => new Promise((resolve, reject) => { const returnServicePromise = (data, failed) =>
new Promise((resolve, reject) => {
if (failed) { if (failed) {
reject(data); reject(data);
} else { } else {
...@@ -38,12 +44,12 @@ const returnServicePromise = (data, failed) => new Promise((resolve, reject) => ...@@ -38,12 +44,12 @@ const returnServicePromise = (data, failed) => new Promise((resolve, reject) =>
}, },
}); });
} }
}); });
describe('AppComponent', () => { describe('AppComponent', () => {
let vm; let vm;
beforeEach((done) => { beforeEach(done => {
Vue.component('group-folder', groupFolderComponent); Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent); Vue.component('group-item', groupItemComponent);
...@@ -94,7 +100,7 @@ describe('AppComponent', () => { ...@@ -94,7 +100,7 @@ describe('AppComponent', () => {
}); });
describe('fetchGroups', () => { 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)); spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups));
vm.fetchGroups({ vm.fetchGroups({
...@@ -110,8 +116,10 @@ describe('AppComponent', () => { ...@@ -110,8 +116,10 @@ describe('AppComponent', () => {
}, 0); }, 0);
}); });
it('should set headers to store for building pagination info when called with `updatePagination`', (done) => { 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.service, 'getGroups').and.returnValue(
returnServicePromise({ headers: mockRawPageInfo }),
);
spyOn(vm, 'updatePagination'); spyOn(vm, 'updatePagination');
vm.fetchGroups({ updatePagination: true }); vm.fetchGroups({ updatePagination: true });
...@@ -122,7 +130,7 @@ describe('AppComponent', () => { ...@@ -122,7 +130,7 @@ describe('AppComponent', () => {
}, 0); }, 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(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true));
spyOn($, 'scrollTo'); spyOn($, 'scrollTo');
spyOn(window, 'Flash'); spyOn(window, 'Flash');
...@@ -138,7 +146,7 @@ describe('AppComponent', () => { ...@@ -138,7 +146,7 @@ describe('AppComponent', () => {
}); });
describe('fetchAllGroups', () => { 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, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
spyOn(vm, 'updatePagination').and.callThrough(); spyOn(vm, 'updatePagination').and.callThrough();
spyOn(vm, 'updateGroups').and.callThrough(); spyOn(vm, 'updateGroups').and.callThrough();
...@@ -153,7 +161,7 @@ describe('AppComponent', () => { ...@@ -153,7 +161,7 @@ describe('AppComponent', () => {
}, 0); }, 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, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups));
spyOn(vm, 'updateGroups').and.callThrough(); spyOn(vm, 'updateGroups').and.callThrough();
...@@ -173,7 +181,7 @@ describe('AppComponent', () => { ...@@ -173,7 +181,7 @@ describe('AppComponent', () => {
}); });
describe('fetchPage', () => { 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, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups));
spyOn(vm, 'updateGroups').and.callThrough(); spyOn(vm, 'updateGroups').and.callThrough();
const mergeUrlParams = spyOnDependency(appComponent, 'mergeUrlParams').and.callThrough(); const mergeUrlParams = spyOnDependency(appComponent, 'mergeUrlParams').and.callThrough();
...@@ -193,9 +201,13 @@ describe('AppComponent', () => { ...@@ -193,9 +201,13 @@ describe('AppComponent', () => {
expect(vm.isLoading).toBe(false); expect(vm.isLoading).toBe(false);
expect($.scrollTo).toHaveBeenCalledWith(0); expect($.scrollTo).toHaveBeenCalledWith(0);
expect(mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String)); expect(mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String));
expect(window.history.replaceState).toHaveBeenCalledWith({ expect(window.history.replaceState).toHaveBeenCalledWith(
{
page: jasmine.any(String), page: jasmine.any(String),
}, jasmine.any(String), jasmine.any(String)); },
jasmine.any(String),
jasmine.any(String),
);
expect(vm.updateGroups).toHaveBeenCalled(); expect(vm.updateGroups).toHaveBeenCalled();
done(); done();
}, 0); }, 0);
...@@ -211,7 +223,7 @@ describe('AppComponent', () => { ...@@ -211,7 +223,7 @@ describe('AppComponent', () => {
groupItem.isChildrenLoading = false; 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, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren));
spyOn(vm.store, 'setGroupChildren'); spyOn(vm.store, 'setGroupChildren');
...@@ -244,7 +256,7 @@ describe('AppComponent', () => { ...@@ -244,7 +256,7 @@ describe('AppComponent', () => {
expect(groupItem.isOpen).toBe(false); 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)); spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true));
vm.toggleChildren(groupItem); vm.toggleChildren(groupItem);
...@@ -272,7 +284,9 @@ describe('AppComponent', () => { ...@@ -272,7 +284,9 @@ describe('AppComponent', () => {
expect(vm.groupLeaveConfirmationMessage).toBe(''); expect(vm.groupLeaveConfirmationMessage).toBe('');
vm.showLeaveGroupModal(group, mockParentGroupItem); vm.showLeaveGroupModal(group, mockParentGroupItem);
expect(vm.showModal).toBe(true); 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', () => { ...@@ -299,7 +313,7 @@ describe('AppComponent', () => {
vm.targetParentGroup = groupItem; 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.`; const notice = `You left the "${childGroupItem.fullName}" group.`;
spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice })); spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice }));
spyOn(vm.store, 'removeGroup').and.callThrough(); spyOn(vm.store, 'removeGroup').and.callThrough();
...@@ -318,9 +332,11 @@ describe('AppComponent', () => { ...@@ -318,9 +332,11 @@ describe('AppComponent', () => {
}, 0); }, 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.'; 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(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash'); spyOn(window, 'Flash');
...@@ -335,9 +351,11 @@ describe('AppComponent', () => { ...@@ -335,9 +351,11 @@ describe('AppComponent', () => {
}, 0); }, 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.'; 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(vm.store, 'removeGroup').and.callThrough();
spyOn(window, 'Flash'); spyOn(window, 'Flash');
...@@ -388,7 +406,7 @@ describe('AppComponent', () => { ...@@ -388,7 +406,7 @@ describe('AppComponent', () => {
}); });
describe('created', () => { describe('created', () => {
it('should bind event listeners on eventHub', (done) => { it('should bind event listeners on eventHub', done => {
spyOn(eventHub, '$on'); spyOn(eventHub, '$on');
const newVm = createComponent(); const newVm = createComponent();
...@@ -405,21 +423,21 @@ describe('AppComponent', () => { ...@@ -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(); const newVm = createComponent();
newVm.$mount(); newVm.$mount();
Vue.nextTick(() => { 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(); newVm.$destroy();
done(); 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); const newVm = createComponent(true);
newVm.$mount(); newVm.$mount();
Vue.nextTick(() => { Vue.nextTick(() => {
expect(newVm.searchEmptyMessage).toBe('Sorry, no groups matched your search'); expect(newVm.searchEmptyMessage).toBe('No groups matched your search');
newVm.$destroy(); newVm.$destroy();
done(); done();
}); });
...@@ -427,7 +445,7 @@ describe('AppComponent', () => { ...@@ -427,7 +445,7 @@ describe('AppComponent', () => {
}); });
describe('beforeDestroy', () => { describe('beforeDestroy', () => {
it('should unbind event listeners on eventHub', (done) => { it('should unbind event listeners on eventHub', done => {
spyOn(eventHub, '$off'); spyOn(eventHub, '$off');
const newVm = createComponent(); const newVm = createComponent();
...@@ -454,7 +472,7 @@ describe('AppComponent', () => { ...@@ -454,7 +472,7 @@ describe('AppComponent', () => {
vm.$destroy(); vm.$destroy();
}); });
it('should render loading icon', (done) => { it('should render loading icon', done => {
vm.isLoading = true; vm.isLoading = true;
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); expect(vm.$el.querySelector('.loading-animation')).toBeDefined();
...@@ -463,7 +481,7 @@ describe('AppComponent', () => { ...@@ -463,7 +481,7 @@ describe('AppComponent', () => {
}); });
}); });
it('should render groups tree', (done) => { it('should render groups tree', done => {
vm.store.state.groups = [mockParentGroupItem]; vm.store.state.groups = [mockParentGroupItem];
vm.isLoading = false; vm.isLoading = false;
vm.store.state.pageInfo = mockPageInfo; vm.store.state.pageInfo = mockPageInfo;
...@@ -473,7 +491,7 @@ describe('AppComponent', () => { ...@@ -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.groupLeaveConfirmationMessage = 'Are you sure you want to leave the "foo" group?';
vm.showModal = true; vm.showModal = true;
Vue.nextTick(() => { Vue.nextTick(() => {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment