diff options
Diffstat (limited to 'app/assets')
19 files changed, 1016 insertions, 520 deletions
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index d43eae79730..225cafbb7d4 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -76,6 +76,7 @@ import initProjectVisibilitySelector from './project_visibility'; import GpgBadges from './gpg_badges'; import UserFeatureHelper from './helpers/user_feature_helper'; import initChangesDropdown from './init_changes_dropdown'; +import NewGroupChild from './groups/new_group_child'; import AbuseReports from './abuse_reports'; import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils'; import AjaxLoadingSpinner from './ajax_loading_spinner'; @@ -390,10 +391,15 @@ import U2FAuthenticate from './u2f/authenticate'; new gl.Activities(); break; case 'groups:show': + const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); shortcut_handler = new ShortcutsNavigation(); new NotificationsForm(); new NotificationsDropdown(); new ProjectsList(); + + if (newGroupChildWrapper) { + new NewGroupChild(newGroupChildWrapper); + } break; case 'groups:group_members:index': new gl.MemberExpirationDate(); diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index 6d516a253bb..9e91f72b2ea 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -6,10 +6,11 @@ import _ from 'underscore'; */ export default class FilterableList { - constructor(form, filter, holder) { + constructor(form, filter, holder, filterInputField = 'filter_groups') { this.filterForm = form; this.listFilterElement = filter; this.listHolderElement = holder; + this.filterInputField = filterInputField; this.isBusy = false; } @@ -32,10 +33,10 @@ export default class FilterableList { onFilterInput() { const $form = $(this.filterForm); const queryData = {}; - const filterGroupsParam = $form.find('[name="filter_groups"]').val(); + const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val(); if (filterGroupsParam) { - queryData.filter_groups = filterGroupsParam; + queryData[this.filterInputField] = filterGroupsParam; } this.filterResults(queryData); diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue new file mode 100644 index 00000000000..fdec34f5dab --- /dev/null +++ b/app/assets/javascripts/groups/components/app.vue @@ -0,0 +1,191 @@ +<script> +/* global Flash */ + +import eventHub from '../event_hub'; +import { getParameterByName } from '../../lib/utils/common_utils'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import { COMMON_STR } from '../constants'; + +import groupsComponent from './groups.vue'; + +export default { + components: { + loadingIcon, + groupsComponent, + }, + props: { + store: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + hideProjects: { + type: Boolean, + required: true, + }, + }, + data() { + return { + isLoading: true, + isSearchEmpty: false, + searchEmptyMessage: '', + }; + }, + computed: { + groups() { + return this.store.getGroups(); + }, + pageInfo() { + return this.store.getPaginationInfo(); + }, + }, + methods: { + fetchGroups({ parentId, page, filterGroupsBy, sortBy, updatePagination }) { + return this.service.getGroups(parentId, page, filterGroupsBy, sortBy) + .then((res) => { + if (updatePagination) { + this.updatePagination(res.headers); + } + + return res; + }) + .then(res => res.json()) + .catch(() => { + this.isLoading = false; + $.scrollTo(0); + + Flash(COMMON_STR.FAILURE); + }); + }, + fetchAllGroups() { + const page = getParameterByName('page') || null; + const sortBy = getParameterByName('sort') || null; + const filterGroupsBy = getParameterByName('filter') || null; + + this.isLoading = true; + // eslint-disable-next-line promise/catch-or-return + this.fetchGroups({ + page, + filterGroupsBy, + sortBy, + updatePagination: true, + }).then((res) => { + this.isLoading = false; + this.updateGroups(res, Boolean(filterGroupsBy)); + }); + }, + fetchPage(page, filterGroupsBy, sortBy) { + this.isLoading = true; + + // eslint-disable-next-line promise/catch-or-return + this.fetchGroups({ + page, + filterGroupsBy, + sortBy, + updatePagination: true, + }).then((res) => { + this.isLoading = false; + $.scrollTo(0); + + const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href); + window.history.replaceState({ + page: currentPath, + }, document.title, currentPath); + + this.updateGroups(res); + }); + }, + toggleChildren(group) { + const parentGroup = group; + if (!parentGroup.isOpen) { + if (parentGroup.children.length === 0) { + parentGroup.isChildrenLoading = true; + // eslint-disable-next-line promise/catch-or-return + this.fetchGroups({ + parentId: parentGroup.id, + }).then((res) => { + this.store.setGroupChildren(parentGroup, res); + }).catch(() => { + parentGroup.isChildrenLoading = false; + }); + } else { + parentGroup.isOpen = true; + } + } else { + parentGroup.isOpen = false; + } + }, + leaveGroup(group, parentGroup) { + const targetGroup = group; + targetGroup.isBeingRemoved = true; + this.service.leaveGroup(targetGroup.leavePath) + .then(res => res.json()) + .then((res) => { + $.scrollTo(0); + this.store.removeGroup(targetGroup, parentGroup); + Flash(res.notice, 'notice'); + }) + .catch((err) => { + let message = COMMON_STR.FAILURE; + if (err.status === 403) { + message = COMMON_STR.LEAVE_FORBIDDEN; + } + Flash(message); + targetGroup.isBeingRemoved = false; + }); + }, + updatePagination(headers) { + this.store.setPaginationInfo(headers); + }, + updateGroups(groups, fromSearch) { + this.isSearchEmpty = groups ? groups.length === 0 : false; + if (fromSearch) { + this.store.setSearchedGroups(groups); + } else { + this.store.setGroups(groups); + } + }, + }, + created() { + this.searchEmptyMessage = this.hideProjects ? + COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY; + + eventHub.$on('fetchPage', this.fetchPage); + eventHub.$on('toggleChildren', this.toggleChildren); + eventHub.$on('leaveGroup', this.leaveGroup); + eventHub.$on('updatePagination', this.updatePagination); + eventHub.$on('updateGroups', this.updateGroups); + }, + mounted() { + this.fetchAllGroups(); + }, + beforeDestroy() { + eventHub.$off('fetchPage', this.fetchPage); + eventHub.$off('toggleChildren', this.toggleChildren); + eventHub.$off('leaveGroup', this.leaveGroup); + eventHub.$off('updatePagination', this.updatePagination); + eventHub.$off('updateGroups', this.updateGroups); + }, +}; +</script> + +<template> + <div> + <loading-icon + class="loading-animation prepend-top-20" + size="2" + v-if="isLoading" + :label="s__('GroupsTree|Loading groups')" + /> + <groups-component + v-if="!isLoading" + :groups="groups" + :search-empty="isSearchEmpty" + :search-empty-message="searchEmptyMessage" + :page-info="pageInfo" + /> + </div> +</template> diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue index 7cc6c4b0359..e60221fa08d 100644 --- a/app/assets/javascripts/groups/components/group_folder.vue +++ b/app/assets/javascripts/groups/components/group_folder.vue @@ -1,15 +1,27 @@ <script> +import { n__ } from '../../locale'; +import { MAX_CHILDREN_COUNT } from '../constants'; + export default { props: { - groups: { - type: Object, - required: true, - }, - baseGroup: { + parentGroup: { type: Object, required: false, default: () => ({}), }, + groups: { + type: Array, + required: false, + default: () => ([]), + }, + }, + computed: { + hasMoreChildren() { + return this.parentGroup.childrenCount > MAX_CHILDREN_COUNT; + }, + moreChildrenStats() { + return n__('One more item', '%d more items', this.parentGroup.childrenCount - this.parentGroup.children.length); + }, }, }; </script> @@ -20,8 +32,20 @@ export default { v-for="(group, index) in groups" :key="index" :group="group" - :base-group="baseGroup" - :collection="groups" + :parent-group="parentGroup" /> + <li + v-if="hasMoreChildren" + class="group-row"> + <a + :href="parentGroup.relativePath" + class="group-row-contents has-more-items"> + <i + class="fa fa-external-link" + aria-hidden="true" + /> + {{moreChildrenStats}} + </a> + </li> </ul> </template> diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 2060410e991..356a95c05ca 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -2,49 +2,28 @@ import identicon from '../../vue_shared/components/identicon.vue'; import eventHub from '../event_hub'; +import itemCaret from './item_caret.vue'; +import itemTypeIcon from './item_type_icon.vue'; +import itemStats from './item_stats.vue'; +import itemActions from './item_actions.vue'; + export default { components: { identicon, + itemCaret, + itemTypeIcon, + itemStats, + itemActions, }, props: { - group: { - type: Object, - required: true, - }, - baseGroup: { + parentGroup: { type: Object, required: false, default: () => ({}), }, - collection: { + group: { type: Object, - required: false, - default: () => ({}), - }, - }, - methods: { - onClickRowGroup(e) { - e.stopPropagation(); - - // Skip for buttons - if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) { - if (this.group.hasSubgroups) { - eventHub.$emit('toggleSubGroups', this.group); - } else { - window.location.href = this.group.groupPath; - } - } - }, - onLeaveGroup(e) { - e.preventDefault(); - - // eslint-disable-next-line no-alert - if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) { - this.leaveGroup(); - } - }, - leaveGroup() { - eventHub.$emit('leaveGroup', this.group, this.collection); + required: true, }, }, computed: { @@ -53,51 +32,33 @@ export default { }, rowClass() { return { - 'group-row': true, 'is-open': this.group.isOpen, - 'has-subgroups': this.group.hasSubgroups, - 'no-description': !this.group.description, + 'has-children': this.hasChildren, + 'has-description': this.group.description, + 'being-removed': this.group.isBeingRemoved, }; }, - visibilityIcon() { - return { - fa: true, - 'fa-globe': this.group.visibility === 'public', - 'fa-shield': this.group.visibility === 'internal', - 'fa-lock': this.group.visibility === 'private', - }; + hasChildren() { + return this.group.childrenCount > 0; }, - fullPath() { - let fullPath = ''; - - if (this.group.isOrphan) { - // check if current group is baseGroup - if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) { - // Remove baseGroup prefix from our current group.fullName. e.g: - // baseGroup.fullName: `level1` - // group.fullName: `level1 / level2 / level3` - // Result: `level2 / level3` - const gfn = this.group.fullName; - const bfn = this.baseGroup.fullName; - const length = bfn.length; - const start = gfn.indexOf(bfn); - const extraPrefixChars = 3; - - fullPath = gfn.substr(start + length + extraPrefixChars); + hasAvatar() { + return this.group.avatarUrl !== null; + }, + isGroup() { + return this.group.type === 'group'; + }, + }, + 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))) { + if (this.hasChildren) { + eventHub.$emit('toggleChildren', this.group); } else { - fullPath = this.group.fullName; + gl.utils.visitUrl(this.group.relativePath); } - } else { - fullPath = this.group.name; } - - return fullPath; - }, - hasGroups() { - return Object.keys(this.group.subGroups).length > 0; - }, - hasAvatar() { - return this.group.avatarUrl && this.group.avatarUrl.indexOf('/assets/no_group_avatar') === -1; }, }, }; @@ -108,98 +69,36 @@ export default { @click.stop="onClickRowGroup" :id="groupDomId" :class="rowClass" + class="group-row" > <div class="group-row-contents"> - <div - class="controls"> - <a - v-if="group.canEdit" - class="edit-group btn" - :href="group.editPath"> - <i - class="fa fa-cogs" - aria-hidden="true" - > - </i> - </a> - <a - @click="onLeaveGroup" - :href="group.leavePath" - class="leave-group btn" - title="Leave this group"> - <i - class="fa fa-sign-out" - aria-hidden="true" - > - </i> - </a> - </div> - <div - class="stats"> - <span - class="number-projects"> - <i - class="fa fa-bookmark" - aria-hidden="true" - > - </i> - {{group.numberProjects}} - </span> - <span - class="number-users"> - <i - class="fa fa-users" - aria-hidden="true" - > - </i> - {{group.numberUsers}} - </span> - <span - class="group-visibility"> - <i - :class="visibilityIcon" - aria-hidden="true" - > - </i> - </span> - </div> + <item-actions + v-if="isGroup" + :group="group" + :parent-group="parentGroup" + /> + <item-stats + :item="group" + /> <div class="folder-toggle-wrap"> - <span - class="folder-caret" - v-if="group.hasSubgroups"> - <i - v-if="group.isOpen" - class="fa fa-caret-down" - aria-hidden="true" - > - </i> - <i - v-if="!group.isOpen" - class="fa fa-caret-right" - aria-hidden="true" - > - </i> - </span> - <span class="folder-icon"> - <i - v-if="group.isOpen" - class="fa fa-folder-open" - aria-hidden="true" - > - </i> - <i - v-if="!group.isOpen" - class="fa fa-folder" - aria-hidden="true"> - </i> - </span> + <item-caret + :is-group-open="group.isOpen" + /> + <item-type-icon + :item-type="group.type" + :is-group-open="group.isOpen" + /> </div> <div - class="avatar-container s40 hidden-xs"> + class="avatar-container s40 hidden-xs" + :class="{ 'content-loading': group.isChildrenLoading }" + > <a - :href="group.groupPath"> + :href="group.relativePath" + class="no-expand" + > <img v-if="hasAvatar" class="avatar s40" @@ -215,19 +114,22 @@ export default { <div class="title"> <a - :href="group.groupPath">{{fullPath}}</a> - <template v-if="group.permissions.humanGroupAccess"> - as - <span class="access-type">{{group.permissions.humanGroupAccess}}</span> - </template> + :href="group.relativePath" + class="no-expand">{{group.fullName}}</a> + <span + v-if="group.permission" + class="access-type" + > + {{s__('GroupsTreeRole|as')}} {{group.permission}} + </span> </div> <div class="description">{{group.description}}</div> </div> <group-folder - v-if="group.isOpen && hasGroups" - :groups="group.subGroups" - :baseGroup="group" + v-if="group.isOpen && hasChildren" + :parent-group="group" + :groups="group.children" /> </li> </template> diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index d17a43b048a..d3482818183 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -4,18 +4,26 @@ import eventHub from '../event_hub'; import { getParameterByName } from '../../lib/utils/common_utils'; export default { + components: { + tablePagination, + }, props: { groups: { - type: Object, + type: Array, required: true, }, pageInfo: { type: Object, required: true, }, - }, - components: { - tablePagination, + searchEmpty: { + type: Boolean, + required: true, + }, + searchEmptyMessage: { + type: String, + required: true, + }, }, methods: { change(page) { @@ -29,10 +37,17 @@ export default { <template> <div class="groups-list-tree-container"> + <div + v-if="searchEmpty" + class="has-no-search-results"> + {{searchEmptyMessage}} + </div> <group-folder + v-if="!searchEmpty" :groups="groups" /> <table-pagination + v-if="!searchEmpty" :change="change" :pageInfo="pageInfo" /> diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue new file mode 100644 index 00000000000..ddb4febc3bd --- /dev/null +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -0,0 +1,92 @@ +<script> +import { s__ } from '../../locale'; +import tooltip from '../../vue_shared/directives/tooltip'; +import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; +import eventHub from '../event_hub'; +import { COMMON_STR } from '../constants'; + +export default { + components: { + PopupDialog, + }, + directives: { + tooltip, + }, + props: { + parentGroup: { + type: Object, + required: false, + default: () => ({}), + }, + group: { + type: Object, + required: true, + }, + }, + data() { + return { + dialogStatus: false, + }; + }, + computed: { + leaveBtnTitle() { + return COMMON_STR.LEAVE_BTN_TITLE; + }, + editBtnTitle() { + return COMMON_STR.EDIT_BTN_TITLE; + }, + leaveConfirmationMessage() { + return s__(`GroupsTree|Are you sure you want to leave the "${this.group.fullName}" group?`); + }, + }, + methods: { + onLeaveGroup() { + this.dialogStatus = true; + }, + leaveGroup(leaveConfirmed) { + this.dialogStatus = false; + if (leaveConfirmed) { + eventHub.$emit('leaveGroup', this.group, this.parentGroup); + } + }, + }, +}; +</script> + +<template> + <div class="controls"> + <a + v-tooltip + v-if="group.canEdit" + :href="group.editPath" + :title="editBtnTitle" + :aria-label="editBtnTitle" + data-container="body" + class="edit-group btn no-expand"> + <i + class="fa fa-cogs" + aria-hidden="true"/> + </a> + <a + v-tooltip + v-if="group.canLeave" + @click.prevent="onLeaveGroup" + :href="group.leavePath" + :title="leaveBtnTitle" + :aria-label="leaveBtnTitle" + data-container="body" + class="leave-group btn no-expand"> + <i + class="fa fa-sign-out" + aria-hidden="true"/> + </a> + <popup-dialog + v-show="dialogStatus" + :primary-button-label="__('Leave')" + kind="warning" + :title="__('Are you sure?')" + :body="leaveConfirmationMessage" + @submit="leaveGroup" + /> + </div> +</template> diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue new file mode 100644 index 00000000000..959b984816f --- /dev/null +++ b/app/assets/javascripts/groups/components/item_caret.vue @@ -0,0 +1,25 @@ +<script> +export default { + props: { + isGroupOpen: { + type: Boolean, + required: true, + default: false, + }, + }, + computed: { + iconClass() { + return this.isGroupOpen ? 'fa-caret-down' : 'fa-caret-right'; + }, + }, +}; +</script> + +<template> + <span class="folder-caret"> + <i + :class="iconClass" + class="fa" + aria-hidden="true"/> + </span> +</template> diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue new file mode 100644 index 00000000000..9f8ac138fc3 --- /dev/null +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -0,0 +1,98 @@ +<script> +import tooltip from '../../vue_shared/directives/tooltip'; +import { ITEM_TYPE, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, PROJECT_VISIBILITY_TYPE } from '../constants'; + +export default { + directives: { + tooltip, + }, + props: { + item: { + type: Object, + required: true, + }, + }, + computed: { + visibilityIcon() { + return VISIBILITY_TYPE_ICON[this.item.visibility]; + }, + visibilityTooltip() { + if (this.item.type === ITEM_TYPE.GROUP) { + return GROUP_VISIBILITY_TYPE[this.item.visibility]; + } + return PROJECT_VISIBILITY_TYPE[this.item.visibility]; + }, + isProject() { + return this.item.type === ITEM_TYPE.PROJECT; + }, + isGroup() { + return this.item.type === ITEM_TYPE.GROUP; + }, + }, +}; +</script> + +<template> + <div class="stats"> + <span + v-tooltip + v-if="isGroup" + :title="s__('Subgroups')" + class="number-subgroups" + data-placement="top" + data-container="body"> + <i + class="fa fa-folder" + aria-hidden="true" + /> + {{item.subgroupCount}} + </span> + <span + v-tooltip + v-if="isGroup" + :title="s__('Projects')" + class="number-projects" + data-placement="top" + data-container="body"> + <i + class="fa fa-bookmark" + aria-hidden="true" + /> + {{item.projectCount}} + </span> + <span + v-tooltip + v-if="isGroup" + :title="s__('Members')" + class="number-users" + data-placement="top" + data-container="body"> + <i + class="fa fa-users" + aria-hidden="true" + /> + {{item.memberCount}} + </span> + <span + v-if="isProject" + class="project-stars"> + <i + class="fa fa-star" + aria-hidden="true" + /> + {{item.starCount}} + </span> + <span + v-tooltip + :title="visibilityTooltip" + data-placement="left" + data-container="body" + class="item-visibility"> + <i + :class="visibilityIcon" + class="fa" + aria-hidden="true" + /> + </span> + </div> +</template> diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue new file mode 100644 index 00000000000..c02a8ad6d8c --- /dev/null +++ b/app/assets/javascripts/groups/components/item_type_icon.vue @@ -0,0 +1,34 @@ +<script> +import { ITEM_TYPE } from '../constants'; + +export default { + props: { + itemType: { + type: String, + required: true, + }, + isGroupOpen: { + type: Boolean, + required: true, + default: false, + }, + }, + computed: { + iconClass() { + if (this.itemType === ITEM_TYPE.GROUP) { + return this.isGroupOpen ? 'fa-folder-open' : 'fa-folder'; + } + return 'fa-bookmark'; + }, + }, +}; +</script> + +<template> + <span class="item-type-icon"> + <i + :class="iconClass" + class="fa" + aria-hidden="true"/> + </span> +</template> diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js new file mode 100644 index 00000000000..6fde41414b3 --- /dev/null +++ b/app/assets/javascripts/groups/constants.js @@ -0,0 +1,35 @@ +import { __, s__ } from '../locale'; + +export const MAX_CHILDREN_COUNT = 20; + +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_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'), +}; + +export const ITEM_TYPE = { + PROJECT: 'project', + GROUP: 'group', +}; + +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.'), + private: __('Private - The group and its projects can only be viewed by members.'), +}; + +export const PROJECT_VISIBILITY_TYPE = { + public: __('Public - The project can be accessed without any authentication.'), + internal: __('Internal - The project can be accessed by any logged in user.'), + private: __('Private - Project access must be granted explicitly to each user.'), +}; + +export const VISIBILITY_TYPE_ICON = { + public: 'fa-globe', + internal: 'fa-shield', + private: 'fa-lock', +}; diff --git a/app/assets/javascripts/groups/groups_filterable_list.js b/app/assets/javascripts/groups/groups_filterable_list.js index 83b102764ba..6a61a1ca355 100644 --- a/app/assets/javascripts/groups/groups_filterable_list.js +++ b/app/assets/javascripts/groups/groups_filterable_list.js @@ -3,12 +3,13 @@ import eventHub from './event_hub'; import { getParameterByName } from '../lib/utils/common_utils'; export default class GroupFilterableList extends FilterableList { - constructor({ form, filter, holder, filterEndpoint, pagePath }) { - super(form, filter, holder); + constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) { + super(form, filter, holder, filterInputField); this.form = form; this.filterEndpoint = filterEndpoint; this.pagePath = pagePath; - this.$dropdown = $('.js-group-filter-dropdown-wrap'); + this.filterInputField = filterInputField; + this.$dropdown = $(dropdownSel); } getFilterEndpoint() { @@ -35,11 +36,11 @@ export default class GroupFilterableList extends FilterableList { e.preventDefault(); const $form = $(this.form); - const filterGroupsParam = $form.find('[name="filter_groups"]').val(); + const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val(); const queryData = {}; if (filterGroupsParam) { - queryData.filter_groups = filterGroupsParam; + queryData[this.filterInputField] = filterGroupsParam; } this.filterResults(queryData); @@ -47,7 +48,7 @@ export default class GroupFilterableList extends FilterableList { } setDefaultFilterOption() { - const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text()); + const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a').first().text()); this.$dropdown.find('.dropdown-label').text(defaultOption); } @@ -65,13 +66,15 @@ export default class GroupFilterableList extends FilterableList { // Active selected option this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text)); + this.$dropdown.find('.dropdown-menu li a').removeClass('is-active'); + $(e.target).addClass('is-active'); // Clear current value on search form - this.form.querySelector('[name="filter_groups"]').value = ''; + this.form.querySelector(`[name="${this.filterInputField}"]`).value = ''; } onFilterSuccess(data, xhr, queryData) { - super.onFilterSuccess(data, xhr, queryData); + const currentPath = this.getPagePath(queryData); const paginationData = { 'X-Per-Page': xhr.getResponseHeader('X-Per-Page'), @@ -82,7 +85,11 @@ export default class GroupFilterableList extends FilterableList { 'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'), }; - eventHub.$emit('updateGroups', data); + window.history.replaceState({ + page: currentPath, + }, document.title, currentPath); + + eventHub.$emit('updateGroups', data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField)); eventHub.$emit('updatePagination', paginationData); } } diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index 600bae24b52..35b96620209 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -1,16 +1,18 @@ import Vue from 'vue'; +import Translate from '../vue_shared/translate'; import Flash from '../flash'; import GroupFilterableList from './groups_filterable_list'; -import GroupsComponent from './components/groups.vue'; -import GroupFolder from './components/group_folder.vue'; -import GroupItem from './components/group_item.vue'; -import GroupsStore from './stores/groups_store'; -import GroupsService from './services/groups_service'; -import eventHub from './event_hub'; -import { getParameterByName } from '../lib/utils/common_utils'; +import GroupsStore from './store/groups_store'; +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'; + +Vue.use(Translate); document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('dashboard-group-app'); + const el = document.getElementById('js-groups-tree'); // Don't do anything if element doesn't exist (No groups) // This is for when the user enters directly to the page via URL @@ -18,176 +20,56 @@ document.addEventListener('DOMContentLoaded', () => { return; } - Vue.component('groups-component', GroupsComponent); - Vue.component('group-folder', GroupFolder); - Vue.component('group-item', GroupItem); + Vue.component('group-folder', groupFolderComponent); + Vue.component('group-item', groupItemComponent); // eslint-disable-next-line no-new new Vue({ el, + components: { + groupsApp, + }, data() { - this.store = new GroupsStore(); - this.service = new GroupsService(el.dataset.endpoint); + const dataset = this.$options.el.dataset; + const hideProjects = dataset.hideProjects === 'true'; + const store = new GroupsStore(hideProjects); + const service = new GroupsService(dataset.endpoint); return { - store: this.store, - isLoading: true, - state: this.store.state, + store, + service, + hideProjects, loading: true, }; }, - computed: { - isEmpty() { - return Object.keys(this.state.groups).length === 0; - }, - }, - methods: { - fetchGroups(parentGroup) { - let parentId = null; - let getGroups = null; - let page = null; - let sort = null; - let pageParam = null; - let sortParam = null; - let filterGroups = null; - let filterGroupsParam = null; - - if (parentGroup) { - parentId = parentGroup.id; - } else { - this.isLoading = true; - } - - pageParam = getParameterByName('page'); - if (pageParam) { - page = pageParam; - } - - filterGroupsParam = getParameterByName('filter_groups'); - if (filterGroupsParam) { - filterGroups = filterGroupsParam; - } - - sortParam = getParameterByName('sort'); - if (sortParam) { - sort = sortParam; - } - - getGroups = this.service.getGroups(parentId, page, filterGroups, sort); - getGroups - .then(response => response.json()) - .then((response) => { - this.isLoading = false; - - this.updateGroups(response, parentGroup); - }) - .catch(this.handleErrorResponse); - - return getGroups; - }, - fetchPage(page, filterGroups, sort) { - this.isLoading = true; - - return this.service - .getGroups(null, page, filterGroups, sort) - .then((response) => { - this.isLoading = false; - $.scrollTo(0); - - const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href); - window.history.replaceState({ - page: currentPath, - }, document.title, currentPath); - - return response.json().then((data) => { - this.updateGroups(data); - this.updatePagination(response.headers); - }); - }) - .catch(this.handleErrorResponse); - }, - toggleSubGroups(parentGroup = null) { - if (!parentGroup.isOpen) { - this.store.resetGroups(parentGroup); - this.fetchGroups(parentGroup); - } - - this.store.toggleSubGroups(parentGroup); - }, - leaveGroup(group, collection) { - this.service.leaveGroup(group.leavePath) - .then(resp => resp.json()) - .then((response) => { - $.scrollTo(0); - - this.store.removeGroup(group, collection); - - // eslint-disable-next-line no-new - new Flash(response.notice, 'notice'); - }) - .catch((error) => { - let message = 'An error occurred. Please try again.'; - - if (error.status === 403) { - message = 'Failed to leave the group. Please make sure you are not the only owner'; - } - - // eslint-disable-next-line no-new - new Flash(message); - }); - }, - updateGroups(groups, parentGroup) { - this.store.setGroups(groups, parentGroup); - }, - updatePagination(headers) { - this.store.storePagination(headers); - }, - handleErrorResponse() { - this.isLoading = false; - $.scrollTo(0); - - // eslint-disable-next-line no-new - new Flash('An error occurred. Please try again.'); - }, - }, - created() { - eventHub.$on('fetchPage', this.fetchPage); - eventHub.$on('toggleSubGroups', this.toggleSubGroups); - eventHub.$on('leaveGroup', this.leaveGroup); - eventHub.$on('updateGroups', this.updateGroups); - eventHub.$on('updatePagination', this.updatePagination); - }, beforeMount() { + const dataset = this.$options.el.dataset; let groupFilterList = null; - const form = document.querySelector('form#group-filter-form'); - const filter = document.querySelector('.js-groups-list-filter'); - const holder = document.querySelector('.js-groups-list-holder'); + const form = document.querySelector(dataset.formSel); + const filter = document.querySelector(dataset.filterSel); + const holder = document.querySelector(dataset.holderSel); const opts = { form, filter, holder, - filterEndpoint: el.dataset.endpoint, - pagePath: el.dataset.path, + filterEndpoint: dataset.endpoint, + pagePath: dataset.path, + dropdownSel: dataset.dropdownSel, + filterInputField: 'filter', }; groupFilterList = new GroupFilterableList(opts); groupFilterList.initSearch(); }, - mounted() { - this.fetchGroups() - .then((response) => { - this.updatePagination(response.headers); - this.isLoading = false; - }) - .catch(this.handleErrorResponse); - }, - beforeDestroy() { - eventHub.$off('fetchPage', this.fetchPage); - eventHub.$off('toggleSubGroups', this.toggleSubGroups); - eventHub.$off('leaveGroup', this.leaveGroup); - eventHub.$off('updateGroups', this.updateGroups); - eventHub.$off('updatePagination', this.updatePagination); + render(createElement) { + return createElement('groups-app', { + props: { + store: this.store, + service: this.service, + hideProjects: this.hideProjects, + }, + }); }, }); }); diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js new file mode 100644 index 00000000000..8e273579aae --- /dev/null +++ b/app/assets/javascripts/groups/new_group_child.js @@ -0,0 +1,62 @@ +import DropLab from '../droplab/drop_lab'; +import ISetter from '../droplab/plugins/input_setter'; + +const InputSetter = Object.assign({}, ISetter); + +const NEW_PROJECT = 'new-project'; +const NEW_SUBGROUP = 'new-subgroup'; + +export default class NewGroupChild { + constructor(buttonWrapper) { + this.buttonWrapper = buttonWrapper; + this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child'); + this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle'); + this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu'); + + this.newGroupPath = this.buttonWrapper.dataset.projectPath; + this.subgroupPath = this.buttonWrapper.dataset.subgroupPath; + + this.init(); + } + + init() { + this.initDroplab(); + this.bindEvents(); + } + + initDroplab() { + this.droplab = new DropLab(); + this.droplab.init( + this.dropdownToggle, + this.dropdownList, + [InputSetter], + this.getDroplabConfig(), + ); + } + + getDroplabConfig() { + return { + InputSetter: [{ + input: this.newGroupChildButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, { + input: this.newGroupChildButton, + valueAttribute: 'data-text', + }], + }; + } + + bindEvents() { + this.newGroupChildButton + .addEventListener('click', this.onClickNewGroupChildButton.bind(this)); + } + + onClickNewGroupChildButton(e) { + if (e.target.dataset.action === NEW_PROJECT) { + gl.utils.visitUrl(this.newGroupPath); + } else if (e.target.dataset.action === NEW_SUBGROUP) { + gl.utils.visitUrl(this.subgroupPath); + } + } +} diff --git a/app/assets/javascripts/groups/services/groups_service.js b/app/assets/javascripts/groups/service/groups_service.js index 97e02fcb76d..1393c96aed6 100644 --- a/app/assets/javascripts/groups/services/groups_service.js +++ b/app/assets/javascripts/groups/service/groups_service.js @@ -20,7 +20,7 @@ export default class GroupsService { } if (filterGroups) { - data.filter_groups = filterGroups; + data.filter = filterGroups; } if (sort) { diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js new file mode 100644 index 00000000000..a1689f4c5cc --- /dev/null +++ b/app/assets/javascripts/groups/store/groups_store.js @@ -0,0 +1,105 @@ +import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils'; + +export default class GroupsStore { + constructor(hideProjects) { + this.state = {}; + this.state.groups = []; + this.state.pageInfo = {}; + this.hideProjects = hideProjects; + } + + setGroups(rawGroups) { + if (rawGroups && rawGroups.length) { + this.state.groups = rawGroups.map(rawGroup => this.formatGroupItem(rawGroup)); + } else { + this.state.groups = []; + } + } + + setSearchedGroups(rawGroups) { + const formatGroups = groups => groups.map((group) => { + const formattedGroup = this.formatGroupItem(group); + if (formattedGroup.children && formattedGroup.children.length) { + formattedGroup.children = formatGroups(formattedGroup.children); + } + return formattedGroup; + }); + + if (rawGroups && rawGroups.length) { + this.state.groups = formatGroups(rawGroups); + } else { + this.state.groups = []; + } + } + + setGroupChildren(parentGroup, children) { + const updatedParentGroup = parentGroup; + updatedParentGroup.children = children.map(rawChild => this.formatGroupItem(rawChild)); + updatedParentGroup.isOpen = true; + updatedParentGroup.isChildrenLoading = false; + } + + getGroups() { + return this.state.groups; + } + + setPaginationInfo(pagination = {}) { + let paginationInfo; + + if (Object.keys(pagination).length) { + const normalizedHeaders = normalizeHeaders(pagination); + paginationInfo = parseIntPagination(normalizedHeaders); + } else { + paginationInfo = pagination; + } + + this.state.pageInfo = paginationInfo; + } + + getPaginationInfo() { + return this.state.pageInfo; + } + + formatGroupItem(rawGroupItem) { + const groupChildren = rawGroupItem.children || []; + const groupIsOpen = (groupChildren.length > 0) || false; + const childrenCount = this.hideProjects ? + rawGroupItem.subgroup_count : + rawGroupItem.children_count; + + return { + id: rawGroupItem.id, + name: rawGroupItem.name, + fullName: rawGroupItem.full_name, + description: rawGroupItem.description, + visibility: rawGroupItem.visibility, + avatarUrl: rawGroupItem.avatar_url, + relativePath: rawGroupItem.relative_path, + editPath: rawGroupItem.edit_path, + leavePath: rawGroupItem.leave_path, + canEdit: rawGroupItem.can_edit, + canLeave: rawGroupItem.can_leave, + type: rawGroupItem.type, + permission: rawGroupItem.permission, + children: groupChildren, + isOpen: groupIsOpen, + isChildrenLoading: false, + isBeingRemoved: false, + parentId: rawGroupItem.parent_id, + childrenCount, + projectCount: rawGroupItem.project_count, + subgroupCount: rawGroupItem.subgroup_count, + memberCount: rawGroupItem.number_users_with_delimiter, + starCount: rawGroupItem.star_count, + }; + } + + removeGroup(group, parentGroup) { + const updatedParentGroup = parentGroup; + if (updatedParentGroup.children && updatedParentGroup.children.length) { + updatedParentGroup.children = parentGroup.children.filter(child => group.id !== child.id); + } else { + this.state.groups = this.state.groups.filter(child => group.id !== child.id); + } + } +} diff --git a/app/assets/javascripts/groups/stores/groups_store.js b/app/assets/javascripts/groups/stores/groups_store.js deleted file mode 100644 index f59ec677603..00000000000 --- a/app/assets/javascripts/groups/stores/groups_store.js +++ /dev/null @@ -1,167 +0,0 @@ -import Vue from 'vue'; -import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils'; - -export default class GroupsStore { - constructor() { - this.state = {}; - this.state.groups = {}; - this.state.pageInfo = {}; - } - - setGroups(rawGroups, parent) { - const parentGroup = parent; - const tree = this.buildTree(rawGroups, parentGroup); - - if (parentGroup) { - parentGroup.subGroups = tree; - } else { - this.state.groups = tree; - } - - return tree; - } - - // eslint-disable-next-line class-methods-use-this - resetGroups(parent) { - const parentGroup = parent; - parentGroup.subGroups = {}; - } - - storePagination(pagination = {}) { - let paginationInfo; - - if (Object.keys(pagination).length) { - const normalizedHeaders = normalizeHeaders(pagination); - paginationInfo = parseIntPagination(normalizedHeaders); - } else { - paginationInfo = pagination; - } - - this.state.pageInfo = paginationInfo; - } - - buildTree(rawGroups, parentGroup) { - const groups = this.decorateGroups(rawGroups); - const tree = {}; - const mappedGroups = {}; - const orphans = []; - - // Map groups to an object - groups.map((group) => { - mappedGroups[`id${group.id}`] = group; - mappedGroups[`id${group.id}`].subGroups = {}; - return group; - }); - - Object.keys(mappedGroups).map((key) => { - const currentGroup = mappedGroups[key]; - if (currentGroup.parentId) { - // If the group is not at the root level, add it to its parent array of subGroups. - const findParentGroup = mappedGroups[`id${currentGroup.parentId}`]; - if (findParentGroup) { - mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup; - mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups - } else if (parentGroup && parentGroup.id === currentGroup.parentId) { - tree[`id${currentGroup.id}`] = currentGroup; - } else { - // No parent found. We save it for later processing - orphans.push(currentGroup); - - // Add to tree to preserve original order - tree[`id${currentGroup.id}`] = currentGroup; - } - } else { - // If the group is at the top level, add it to first level elements array. - tree[`id${currentGroup.id}`] = currentGroup; - } - - return key; - }); - - if (orphans.length) { - orphans.map((orphan) => { - let found = false; - const currentOrphan = orphan; - - Object.keys(tree).map((key) => { - const group = tree[key]; - - if ( - group && - currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0 && - // Make sure the currently selected orphan is not the same as the group - // we are checking here otherwise it will end up in an infinite loop - currentOrphan.id !== group.id - ) { - group.subGroups[currentOrphan.id] = currentOrphan; - group.isOpen = true; - currentOrphan.isOrphan = true; - found = true; - - // Delete if group was put at the top level. If not the group will be displayed twice. - if (tree[`id${currentOrphan.id}`]) { - delete tree[`id${currentOrphan.id}`]; - } - } - - return key; - }); - - if (!found) { - currentOrphan.isOrphan = true; - - tree[`id${currentOrphan.id}`] = currentOrphan; - } - - return orphan; - }); - } - - return tree; - } - - decorateGroups(rawGroups) { - this.groups = rawGroups.map(this.decorateGroup); - return this.groups; - } - - // eslint-disable-next-line class-methods-use-this - decorateGroup(rawGroup) { - return { - id: rawGroup.id, - fullName: rawGroup.full_name, - fullPath: rawGroup.full_path, - avatarUrl: rawGroup.avatar_url, - name: rawGroup.name, - hasSubgroups: rawGroup.has_subgroups, - canEdit: rawGroup.can_edit, - description: rawGroup.description, - webUrl: rawGroup.web_url, - groupPath: rawGroup.group_path, - parentId: rawGroup.parent_id, - visibility: rawGroup.visibility, - leavePath: rawGroup.leave_path, - editPath: rawGroup.edit_path, - isOpen: false, - isOrphan: false, - numberProjects: rawGroup.number_projects_with_delimiter, - numberUsers: rawGroup.number_users_with_delimiter, - permissions: { - humanGroupAccess: rawGroup.permissions.human_group_access, - }, - subGroups: {}, - }; - } - - // eslint-disable-next-line class-methods-use-this - removeGroup(group, collection) { - Vue.delete(collection, `id${group.id}`); - } - - // eslint-disable-next-line class-methods-use-this - toggleSubGroups(toggleGroup) { - const group = toggleGroup; - group.isOpen = !group.isOpen; - return group; - } -} diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index badc7b0eba3..d43f998cb82 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -281,6 +281,57 @@ ul.indent-list { // Specific styles for tree list +@keyframes spin-avatar { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.groups-list-tree-container { + .has-no-search-results { + text-align: center; + padding: $gl-padding; + font-style: italic; + color: $well-light-text-color; + } + + > .group-list-tree > .group-row.has-children:first-child { + border-top: none; + } +} + +.group-list-tree .avatar-container.content-loading { + position: relative; + + > a, + > a .avatar { + height: 100%; + border-radius: 50%; + } + + > a { + padding: 2px; + } + + > a .avatar { + border: 2px solid $white-normal; + + &.identicon { + line-height: 30px; + } + } + + &::after { + content: ""; + position: absolute; + height: 100%; + width: 100%; + background-color: transparent; + border: 2px outset $kdb-border; + border-radius: 50%; + animation: spin-avatar 3s infinite linear; + } +} + .group-list-tree { .folder-toggle-wrap { float: left; @@ -293,7 +344,7 @@ ul.indent-list { } .folder-caret, - .folder-icon { + .item-type-icon { display: inline-block; } @@ -301,11 +352,11 @@ ul.indent-list { width: 15px; } - .folder-icon { + .item-type-icon { width: 20px; } - > .group-row:not(.has-subgroups) { + > .group-row:not(.has-children) { .folder-caret .fa { opacity: 0; } @@ -351,12 +402,23 @@ ul.indent-list { top: 30px; bottom: 0; } + + &.being-removed { + opacity: 0.5; + } } } .group-row { padding: 0; - border: none; + + &.has-children { + border-top: none; + } + + &:first-child { + border-top: 1px solid $white-normal; + } &:last-of-type { .group-row-contents:not(:hover) { @@ -379,6 +441,25 @@ ul.indent-list { .avatar-container > a { width: 100%; } + + &.has-more-items { + display: block; + padding: 20px 10px; + } + } +} + +ul.group-list-tree { + li.group-row { + &.has-description { + .title { + line-height: inherit; + } + } + + .title { + line-height: $list-text-height; + } } } diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 6f6c6839975..9b7dda9b648 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -26,14 +26,117 @@ } } -.groups-header { - @media (min-width: $screen-sm-min) { - .nav-links { - width: 35%; +.group-nav-container .nav-controls { + display: flex; + align-items: flex-start; + padding: $gl-padding-top 0; + border-bottom: 1px solid $border-color; + + .group-filter-form { + flex: 1; + } + + .dropdown-menu-align-right { + margin-top: 0; + } + + .new-project-subgroup { + .dropdown-primary { + min-width: 115px; + } + + .dropdown-toggle { + .dropdown-btn-icon { + pointer-events: none; + color: inherit; + margin-left: 0; + } } - .nav-controls { - width: 65%; + .dropdown-menu { + min-width: 280px; + margin-top: 2px; + } + + li:not(.divider) { + padding: 0; + + &.droplab-item-selected { + .icon-container { + .list-item-checkmark { + visibility: visible; + } + } + } + + .menu-item { + padding: 8px 4px; + + &:hover { + background-color: $gray-darker; + color: $theme-gray-900; + } + } + + .icon-container { + float: left; + padding-left: 6px; + + .list-item-checkmark { + visibility: hidden; + } + } + + .description { + font-size: 14px; + + strong { + display: block; + font-weight: $gl-font-weight-bold; + } + } + } + } + + @media (max-width: $screen-sm-max) { + &, + .dropdown, + .dropdown .dropdown-toggle, + .btn-new { + display: block; + } + + .group-filter-form, + .dropdown { + margin-bottom: 10px; + margin-right: 0; + } + + .group-filter-form, + .dropdown .dropdown-toggle, + .btn-new { + width: 100%; + } + + .dropdown .dropdown-toggle .fa-chevron-down { + position: absolute; + top: 11px; + right: 8px; + } + + .new-project-subgroup { + display: flex; + align-items: flex-start; + + .dropdown-primary { + flex: 1; + } + + .dropdown-menu { + width: 100%; + max-width: inherit; + min-width: inherit; + } } } } |