Commit d5cc0c8d authored by Mike Greiling's avatar Mike Greiling

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

Resolve "Improve handling of projects shared with a group"

Closes #44005

See merge request gitlab-org/gitlab-ce!20262
parents 53fae9ad 5b74a1ae
......@@ -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 {
export default {
components: {
tablePagination,
},
......@@ -24,16 +24,21 @@
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('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
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({
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));
},
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;
}
......@@ -30,14 +29,15 @@
}
}
.group-nav-container .group-search,
.group-nav-container .nav-controls {
display: flex;
align-items: flex-start;
padding: $gl-padding-top 0;
border-bottom: 1px solid $border-color;
padding: $gl-padding-top 0 0;
.group-filter-form {
flex: 1;
flex: 1 1 auto;
margin-right: $gl-padding-8;
}
.dropdown-menu-right {
......@@ -136,6 +136,10 @@
flex: 1;
}
.dropdown-toggle {
width: auto;
}
.dropdown-menu {
width: 100%;
max-width: inherit;
......@@ -145,38 +149,14 @@
}
}
.groups-empty-state {
padding: 50px 100px;
overflow: hidden;
@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;
}
}
......@@ -278,7 +258,7 @@
}
&::after {
content: "";
content: '';
position: absolute;
height: 100%;
width: 100%;
......@@ -346,7 +326,7 @@
position: relative;
&::before {
content: "";
content: '';
display: block;
width: 10px;
height: 0;
......
......@@ -17,7 +17,7 @@ class GroupsController < Groups::ApplicationController
before_action :group_projects, only: [:projects, :activity, :issues, :merge_requests]
before_action :event_filter, only: [:activity]
before_action :user_actions, only: [:show, :subgroups]
before_action :user_actions, only: [:show]
skip_cross_project_access_check :index, :new, :create, :edit, :update,
:destroy, :projects
......@@ -53,11 +53,7 @@ class GroupsController < Groups::ApplicationController
def show
respond_to do |format|
format.html do
@has_children = GroupDescendantsFinder.new(current_user: current_user,
parent_group: @group,
params: params).has_children?
end
format.html
format.atom do
load_events
......
......@@ -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"
......@@ -6,9 +6,9 @@
Project members
- if can?(current_user, :admin_project_member, @project)
%p
You can add a new member to
You can invite a new member to
%strong= @project.name
or share it with another group.
or invite another group.
- else
%p
Members can be added by project
......@@ -19,16 +19,16 @@
- if can?(current_user, :admin_project_member, @project)
%ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' }
%a.nav-link.active{ href: '#add-member-pane', id: 'add-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member
%a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Invite member
- if @project.allowed_to_share_with_group?
%li.nav-tab{ role: 'presentation' }
%a.nav-link{ href: '#share-with-group-pane', id: 'share-with-group-tab', data: { toggle: 'tab' }, role: 'tab' } Share with group
%a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' } Invite group
.tab-content.gitlab-tab-content
.tab-pane.active{ id: 'add-member-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_member', tab_title: 'Add member'
.tab-pane{ id: 'share-with-group-pane', role: 'tabpanel' }
= render 'projects/project_members/new_shared_group', tab_title: 'Share with group'
.tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_member', tab_title: 'Invite member'
.tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_group', tab_title: 'Invite group'
= render 'shared/members/requests', membership_source: @project, requesters: @requesters
.clearfix
......
.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")
.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
......@@ -14,6 +14,9 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
get :projects, as: :projects_group
get :activity, as: :activity_group
put :transfer, as: :transfer_group
# TODO: Remove as part of refactor in https://gitlab.com/gitlab-org/gitlab-ce/issues/49693
get 'shared', action: :show, as: :group_shared
get 'archived', action: :show, as: :group_archived
end
get '/', action: :show, as: :group_canonical
......
......@@ -295,6 +295,9 @@ msgstr ""
msgid "Access denied! Please verify you can add deploy keys to this repository."
msgstr ""
msgid "Access expiration date"
msgstr ""
msgid "Access to failing storages has been temporarily disabled to allow the mount to recover. Reset storage information after the issue has been resolved to allow access again."
msgstr ""
......@@ -604,6 +607,9 @@ msgstr ""
msgid "Archived project! Repository and other project resources are read-only"
msgstr ""
msgid "Archived projects"
msgstr ""
msgid "Are you sure you want to delete this pipeline schedule?"
msgstr ""
......@@ -2622,6 +2628,9 @@ msgstr ""
msgid "Expand sidebar"
msgstr ""
msgid "Expiration date"
msgstr ""
msgid "Explore"
msgstr ""
......@@ -2985,6 +2994,9 @@ msgstr ""
msgid "GroupsEmptyState|You can manage your group 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 ""
......@@ -2997,19 +3009,19 @@ msgstr ""
msgid "GroupsTree|Failed to leave the group. Please make sure you are not the only owner."
msgstr ""
msgid "GroupsTree|Filter by name..."
msgstr ""
msgid "GroupsTree|Leave this group"
msgstr ""
msgid "GroupsTree|Loading groups"
msgstr ""
msgid "GroupsTree|Sorry, no groups matched your search"
msgid "GroupsTree|No groups matched your search"
msgstr ""
msgid "GroupsTree|Sorry, no groups or projects matched your search"
msgid "GroupsTree|No groups or projects matched your search"
msgstr ""
msgid "GroupsTree|Search by name"
msgstr ""
msgid "Health Check"
......@@ -3245,6 +3257,9 @@ msgstr ""
msgid "Introducing Cycle Analytics"
msgstr ""
msgid "Invite"
msgstr ""
msgid "Issue Boards"
msgstr ""
......@@ -3582,6 +3597,9 @@ msgstr ""
msgid "Markdown enabled"
msgstr ""
msgid "Max access level"
msgstr ""
msgid "Maximum git storage failures"
msgstr ""
......@@ -5072,6 +5090,9 @@ msgstr ""
msgid "Select Archive Format"
msgstr ""
msgid "Select a group to invite"
msgstr ""
msgid "Select a namespace to fork the project"
msgstr ""
......@@ -5171,6 +5192,9 @@ msgstr ""
msgid "Shared Runners"
msgstr ""
msgid "Shared projects"
msgstr ""
msgid "Sherlock Transactions"
msgstr ""
......@@ -5449,6 +5473,9 @@ msgstr ""
msgid "Subgroups"
msgstr ""
msgid "Subgroups and projects"
msgstr ""
msgid "Submit as spam"
msgstr ""
......@@ -5691,6 +5718,9 @@ msgstr ""
msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."
msgstr ""
msgid "There are no archived projects yet"
msgstr ""
msgid "There are no issues to show"
msgstr ""
......@@ -5700,6 +5730,9 @@ msgstr ""
msgid "There are no merge requests to show"
msgstr ""
msgid "There are no projects shared with this group yet"
msgstr ""
msgid "There are problems accessing Git storage: "
msgstr ""
......
......@@ -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
......@@ -8,17 +8,17 @@ describe 'Project > Members > Share with Group', :js do
describe 'Share with group lock' do
shared_examples 'the project can be shared with groups' do
it 'the "Share with group" tab exists' do
it 'the "Invite group" tab exists' do
visit project_settings_members_path(project)
expect(page).to have_selector('#share-with-group-tab')
expect(page).to have_selector('#invite-group-tab')
end
end
shared_examples 'the project cannot be shared with groups' do
it 'the "Share with group" tab does not exist' do
it 'the "Invite group" tab does not exist' do
visit project_settings_members_path(project)
expect(page).to have_selector('#add-member-tab')
expect(page).not_to have_selector('#share-with-group-tab')
expect(page).to have_selector('#invite-member-tab')
expect(page).not_to have_selector('#invite-group-tab')
end
end
......@@ -31,13 +31,13 @@ describe 'Project > Members > Share with Group', :js do
sign_in(maintainer)
end
context 'when the group has "Share with group lock" disabled' do
context 'when the group has "Invite group lock" disabled' do
it_behaves_like 'the project can be shared with groups'
it 'the project can be shared with another group' do
visit project_settings_members_path(project)
click_on 'share-with-group-tab'
click_on 'invite-group-tab'
select2 group_to_share_with.id, from: '#link_group_id'
page.find('body').click
......@@ -49,7 +49,7 @@ describe 'Project > Members > Share with Group', :js do
end
end
context 'when the group has "Share with group lock" enabled' do
context 'when the group has "Invite group lock" enabled' do
before do
project.namespace.update_column(:share_with_group_lock, true)
end
......@@ -69,12 +69,12 @@ describe 'Project > Members > Share with Group', :js do
sign_in(maintainer)
end
context 'when the root_group has "Share with group lock" disabled' do
context 'when the subgroup has "Share with group lock" disabled' do
context 'when the root_group has "Invite group lock" disabled' do
context 'when the subgroup has "Invite group lock" disabled' do
it_behaves_like 'the project can be shared with groups'
end
context 'when the subgroup has "Share with group lock" enabled' do
context 'when the subgroup has "Invite group lock" enabled' do
before do
subgroup.update_column(:share_with_group_lock, true)
end
......@@ -83,16 +83,16 @@ describe 'Project > Members > Share with Group', :js do
end
end
context 'when the root_group has "Share with group lock" enabled' do
context 'when the root_group has "Invite group lock" enabled' do
before do
root_group.update_column(:share_with_group_lock, true)
end
context 'when the subgroup has "Share with group lock" disabled (parent overridden)' do
context 'when the subgroup has "Invite group lock" disabled (parent overridden)' do
it_behaves_like 'the project can be shared with groups'
end
context 'when the subgroup has "Share with group lock" enabled' do
context 'when the subgroup has "Invite group lock" enabled' do
before do
subgroup.update_column(:share_with_group_lock, true)
end
......@@ -117,12 +117,12 @@ describe 'Project > Members > Share with Group', :js do
visit project_settings_members_path(project)
click_on 'share-with-group-tab'
click_on 'invite-group-tab'
select2 group.id, from: '#link_group_id'
fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d')
click_on 'share-with-group-tab'
click_on 'invite-group-tab'
find('.btn-create').click
end
......@@ -150,7 +150,7 @@ describe 'Project > Members > Share with Group', :js do
visit project_settings_members_path(project)
click_link 'Share with group'
click_link 'Invite group'
find('.ajax-groups-select.select2-container')
......@@ -183,7 +183,7 @@ describe 'Project > Members > Share with Group', :js do
it 'the groups dropdown does not show ancestors', :nested_groups do
visit project_settings_members_path(project)
click_on 'share-with-group-tab'
click_on 'invite-group-tab'
click_link 'Search for a group'
page.within '.select2-drop' do
......
......@@ -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