Commit 3b70be8d authored by Mike Greiling's avatar Mike Greiling

Merge branch 'ee-44005-improve-handling-of-projects-shared-with-a-group' into 'master'

Port 44005-improve-handling-of-projects-shared-with-a-group to EE

See merge request gitlab-org/gitlab-ee!6963
parents 8185c6f5 cd311488
......@@ -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"
......
......@@ -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"
......
......@@ -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>
<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"
......
......@@ -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);
},
},
};
......
......@@ -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.'),
};
......
......@@ -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);
eventHub.$emit('updateGroups', res.data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
eventHub.$emit('updatePagination', normalizeHeaders(res.headers));
window.history.replaceState(
{
page: currentPath,
},
document.title,
currentPath,
);
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';
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,
},
});
},
......
......@@ -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);
......
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 */
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();
});
......@@ -3,7 +3,6 @@
}
.dashboard .side .card .card-header .input-group {
.form-control {
height: 42px;
}
......@@ -35,14 +34,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 {
......@@ -141,6 +141,10 @@
flex: 1;
}
.dropdown-toggle {
width: auto;
}
.dropdown-menu {
width: 100%;
max-width: inherit;
......@@ -150,38 +154,14 @@
}
}
.groups-empty-state {
padding: 50px 100px;
overflow: hidden;
@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;
.group-nav-container .group-search {
padding: $gl-padding 0;
border-bottom: 1px solid $border-color;
}
@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;
}
}
......@@ -327,7 +307,7 @@ table.pipeline-project-metrics tr td {
}
&::after {
content: "";
content: '';
position: absolute;
height: 100%;
width: 100%;
......@@ -395,7 +375,7 @@ table.pipeline-project-metrics tr td {
position: relative;
&::before {
content: "";
content: '';
display: block;
width: 10px;
height: 0;
......
......@@ -18,7 +18,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
......@@ -54,11 +54,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
......
......@@ -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
......
#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 @@
= 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
......@@ -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"
......@@ -20,19 +20,19 @@
- if !membership_locked? && @project.allowed_to_share_with_group?
%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' } Add member
%li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) }
%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' }
.tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_member', tab_title: 'Add member'
.tab-pane{ id: 'share-with-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) }
= render 'projects/project_members/new_shared_group', tab_title: 'Share with group'
.tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) }
= render 'projects/project_members/new_project_group', tab_title: 'Invite group'
- elsif !membership_locked?
.add-member= render 'projects/project_members/new_project_member', tab_title: 'Add member'
.invite-member= render 'projects/project_members/new_project_member', tab_title: 'Add member'
- elsif @project.allowed_to_share_with_group?
.share-with-group= render 'projects/project_members/new_shared_group', tab_title: 'Share with group'
.invite-group= render 'projects/project_members/new_project_group', tab_title: 'Invite group'
= render 'shared/members/requests', membership_source: @project, requesters: @requesters
.clearfix
......
.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 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|
= 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"
---
title: Overhaul listing of projects in the group overview page
merge_request: 20262
author:
type: added
......@@ -15,6 +15,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
get :activity, as: :activity_group
get :subgroups, as: :subgroups_group ## EE-specific
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
......
......@@ -113,13 +113,13 @@ module EE
share_with_group = @project.allowed_to_share_with_group?
share_with_members = !membership_locked?
project_name = content_tag(:strong, @project.name)
member_message = "You can add a new member to #{project_name}"
member_message = "You can invite a new member to #{project_name}"
description =
if share_with_group && share_with_members
"#{member_message} or share it with another group."
"#{member_message} or invite another group."
elsif share_with_group
"You can share #{project_name} with another group."
"You can invite another group to #{project_name}."
elsif share_with_members
"#{member_message}."
end
......
......@@ -366,6 +366,9 @@ msgstr ""
msgid "Access denied! Please verify you can add deploy keys to this repository."
msgstr ""
msgid "Access expiration date"
msgstr ""
msgid "Access to '%{classification_label}' not allowed"
msgstr ""
......@@ -729,6 +732,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 ""
......@@ -3080,6 +3086,9 @@ msgstr ""
msgid "Expand sidebar"
msgstr ""
msgid "Expiration date"
msgstr ""
msgid "Explore"
msgstr ""
......@@ -3812,6 +3821,9 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group member’s 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 ""
......@@ -3824,19 +3836,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 "Have your users email"
......@@ -4116,6 +4128,9 @@ msgstr ""
msgid "Introducing Cycle Analytics"
msgstr ""
msgid "Invite"
msgstr ""
msgid "Issue Boards"
msgstr ""
......@@ -4543,6 +4558,9 @@ msgstr ""
msgid "Maven package"
msgstr ""
msgid "Max access level"
msgstr ""
msgid "Maximum git storage failures"
msgstr ""
......@@ -6455,6 +6473,9 @@ msgstr ""
msgid "Select Archive Format"
msgstr ""
msgid "Select a group to invite"
msgstr ""
msgid "Select a namespace to fork the project"
msgstr ""
......@@ -6575,6 +6596,9 @@ msgstr ""
msgid "Shared Runners"
msgstr ""
msgid "Shared projects"
msgstr ""
msgid "SharedRunnersMinutesSettings|By resetting the pipeline minutes for this namespace, the currently used minutes will be set to zero."
msgstr ""
......@@ -6916,6 +6940,9 @@ msgstr ""
msgid "Subgroups"
msgstr ""
msgid "Subgroups and projects"
msgstr ""
msgid "Submit as spam"
msgstr ""
......@@ -7194,6 +7221,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 ""
......@@ -7203,6 +7233,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 ""
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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)
......
require 'spec_helper'
describe 'Project > Members > Share with Group', :js do
describe 'Project > Members > Invite Group', :js do
include Select2Helper
include ActionView::Helpers::DateHelper
let(:maintainer) { create(:user) }
describe 'Share with group lock' do
describe 'Invite group lock' do
shared_examples 'the project cannot be shared with groups' do
it 'user is only able to share with members' do
visit project_settings_members_path(project)
expect(page).not_to have_selector('#add-member-tab')
expect(page).not_to have_selector('#share-with-group-tab')
expect(page).not_to have_selector('.share-with-group')
expect(page).to have_selector('.add-member')
expect(page).not_to have_selector('#invite-member-tab')
expect(page).not_to have_selector('#invite-group-tab')
expect(page).not_to have_selector('.invite-group')
expect(page).to have_selector('.invite-member')
end
end
......@@ -22,10 +22,10 @@ describe 'Project > Members > Share with Group', :js do
it 'user is only able to share with groups' do
visit project_settings_members_path(project)
expect(page).not_to have_selector('#add-member-tab')
expect(page).not_to have_selector('#share-with-group-tab')
expect(page).not_to have_selector('.add-member')
expect(page).to have_selector('.share-with-group')
expect(page).not_to have_selector('#invite-member-tab')
expect(page).not_to have_selector('#invite-group-tab')
expect(page).not_to have_selector('.invite-member')
expect(page).to have_selector('.invite-group')
end
end
......@@ -33,10 +33,10 @@ describe 'Project > Members > Share with Group', :js do
it 'no tabs or share content exists' do
visit project_settings_members_path(project)
expect(page).not_to have_selector('#add-member-tab')
expect(page).not_to have_selector('#share-with-group-tab')
expect(page).not_to have_selector('.add-member')
expect(page).not_to have_selector('.share-with-group')
expect(page).not_to have_selector('#invite-member-tab')
expect(page).not_to have_selector('#invite-group-tab')
expect(page).not_to have_selector('.invite-member')
expect(page).not_to have_selector('.invite-group')
end
end
......@@ -44,10 +44,10 @@ describe 'Project > Members > Share with Group', :js do
it 'both member and group tabs exist' do
visit project_settings_members_path(project)
expect(page).not_to have_selector('.add-member')
expect(page).not_to have_selector('.share-with-group')
expect(page).to have_selector('#add-member-tab')
expect(page).to have_selector('#share-with-group-tab')
expect(page).not_to have_selector('.invite-member')
expect(page).not_to have_selector('.invite-group')
expect(page).to have_selector('#invite-member-tab')
expect(page).to have_selector('#invite-group-tab')
end
end
......@@ -66,7 +66,7 @@ describe 'Project > Members > Share with Group', :js do
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
......@@ -204,12 +204,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
......@@ -237,7 +237,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')
......@@ -270,7 +270,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
......
......@@ -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')
......
......@@ -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
......
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