diff options
Diffstat (limited to 'app/assets/javascripts/groups/components')
8 files changed, 561 insertions, 175 deletions
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue new file mode 100644 index 00000000000..2c0b6ab4ea8 --- /dev/null +++ b/app/assets/javascripts/groups/components/app.vue @@ -0,0 +1,194 @@ +<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, archived, updatePagination }) { + return this.service.getGroups(parentId, page, filterGroupsBy, sortBy, archived) + .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 archived = getParameterByName('archived') || null; + const filterGroupsBy = getParameterByName('filter') || null; + + this.isLoading = true; + // eslint-disable-next-line promise/catch-or-return + this.fetchGroups({ + page, + filterGroupsBy, + sortBy, + archived, + updatePagination: true, + }).then((res) => { + this.isLoading = false; + this.updateGroups(res, Boolean(filterGroupsBy)); + }); + }, + fetchPage(page, filterGroupsBy, sortBy, archived) { + this.isLoading = true; + + // eslint-disable-next-line promise/catch-or-return + this.fetchGroups({ + page, + filterGroupsBy, + sortBy, + archived, + 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..75a2bf34887 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -4,24 +4,33 @@ 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) { const filterGroupsParam = getParameterByName('filter_groups'); const sortParam = getParameterByName('sort'); - eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam); + const archivedParam = getParameterByName('archived'); + eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam); }, }, }; @@ -29,10 +38,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..7eff19e2e5a --- /dev/null +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -0,0 +1,93 @@ +<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?')" + :text="__('Are you sure you want to leave this group?')" + :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> |