diff options
author | Stan Hu <stanhu@gmail.com> | 2017-10-17 16:02:32 -0700 |
---|---|---|
committer | Stan Hu <stanhu@gmail.com> | 2017-10-17 16:02:32 -0700 |
commit | 891a9ce8b0839fb478ca5704022b2e921097fe27 (patch) | |
tree | ec314e86f62f046cb96650be6d076276f23ff453 /app/assets | |
parent | bd46c8abfd5ee964c47eff0ace021e45cbbe6687 (diff) | |
parent | b3f749036ea919de3982c81b157ab2d790ecb4c5 (diff) | |
download | gitlab-ce-891a9ce8b0839fb478ca5704022b2e921097fe27.tar.gz |
Merge branch 'master' into sh-security-fix-backports-master
Diffstat (limited to 'app/assets')
52 files changed, 1535 insertions, 1069 deletions
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js index 38eea38f949..97e80afa3f8 100644 --- a/app/assets/javascripts/boards/services/board_service.js +++ b/app/assets/javascripts/boards/services/board_service.js @@ -7,7 +7,7 @@ class BoardService { this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, { issues: { method: 'GET', - url: `${gon.relative_url_root}/boards/${boardId}/issues.json`, + url: `${gon.relative_url_root}/-/boards/${boardId}/issues.json`, } }); this.lists = Vue.resource(`${listsEndpoint}{/id}`, {}, { @@ -16,7 +16,7 @@ class BoardService { url: `${listsEndpoint}/generate.json` } }); - this.issue = Vue.resource(`${gon.relative_url_root}/boards/${boardId}/issues{/id}`, {}); + this.issue = Vue.resource(`${gon.relative_url_root}/-/boards/${boardId}/issues{/id}`, {}); this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, { bulkUpdate: { method: 'POST', diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js index 50dbeb06362..180aa30e98c 100644 --- a/app/assets/javascripts/clusters.js +++ b/app/assets/javascripts/clusters.js @@ -3,7 +3,8 @@ import Visibility from 'visibilityjs'; import axios from 'axios'; import Poll from './lib/utils/poll'; import { s__ } from './locale'; -import './flash'; +import initSettingsPanels from './settings_panels'; +import Flash from './flash'; /** * Cluster page has 2 separate parts: @@ -24,6 +25,8 @@ class ClusterService { export default class Clusters { constructor() { + initSettingsPanels(); + const dataset = document.querySelector('.js-edit-cluster-form').dataset; this.state = { diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index cd53bc1e2dd..b66652db33b 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -73,6 +73,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'; @@ -168,9 +169,6 @@ import memberExpirationDate from './member_expiration_date'; const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); filteredSearchManager.setup(); } - if (page === 'projects:merge_requests:index') { - new UserCallout({ setCalloutPerProject: true }); - } const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_'; IssuableIndex.init(pagePrefix); @@ -352,7 +350,10 @@ import memberExpirationDate from './member_expiration_date'; case 'projects:show': shortcut_handler = new ShortcutsNavigation(); new NotificationsForm(); - new UserCallout({ setCalloutPerProject: true }); + new UserCallout({ + setCalloutPerProject: true, + className: 'js-autodevops-banner', + }); if ($('#tree-slider').length) new TreeView(); if ($('.blob-viewer').length) new BlobViewer(); @@ -372,9 +373,6 @@ import memberExpirationDate from './member_expiration_date'; case 'projects:pipelines:new': new NewBranchForm($('.js-new-pipeline-form')); break; - case 'projects:pipelines:index': - new UserCallout({ setCalloutPerProject: true }); - break; case 'projects:pipelines:builds': case 'projects:pipelines:failures': case 'projects:pipelines:show': @@ -395,10 +393,15 @@ import memberExpirationDate from './member_expiration_date'; 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': memberExpirationDate(); @@ -432,7 +435,6 @@ import memberExpirationDate from './member_expiration_date'; new TreeView(); new BlobViewer(); new NewCommitForm($('.js-create-dir-form')); - new UserCallout({ setCalloutPerProject: true }); $('#tree-slider').waitForImages(function() { ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath); }); 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..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> 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..2db233b09da 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() { @@ -24,30 +25,34 @@ export default class GroupFilterableList extends FilterableList { bindEvents() { super.bindEvents(); - this.onFormSubmitWrapper = this.onFormSubmit.bind(this); this.onFilterOptionClikWrapper = this.onOptionClick.bind(this); - this.filterForm.addEventListener('submit', this.onFormSubmitWrapper); this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper); } - onFormSubmit(e) { - e.preventDefault(); - - const $form = $(this.form); - const filterGroupsParam = $form.find('[name="filter_groups"]').val(); + onFilterInput() { const queryData = {}; + const $form = $(this.form); + const archivedParam = getParameterByName('archived', window.location.href); + const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val(); if (filterGroupsParam) { - queryData.filter_groups = filterGroupsParam; + queryData[this.filterInputField] = filterGroupsParam; + } + + if (archivedParam) { + queryData.archived = archivedParam; } this.filterResults(queryData); - this.setDefaultFilterOption(); + + if (this.setDefaultFilterOption) { + this.setDefaultFilterOption(); + } } setDefaultFilterOption() { - const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text()); + const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text()); this.$dropdown.find('.dropdown-label').text(defaultOption); } @@ -55,23 +60,42 @@ export default class GroupFilterableList extends FilterableList { e.preventDefault(); const queryData = {}; - const sortParam = getParameterByName('sort', e.currentTarget.href); + + // 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'); + + // 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); if (sortParam) { queryData.sort = sortParam; } + if (archivedParam) { + queryData.archived = archivedParam; + } + this.filterResults(queryData); // Active selected option - this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text)); + if (isOptionFilterBySort) { + 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'); + } + + $(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 +106,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..8b850765a1b 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -1,16 +1,17 @@ import Vue from 'vue'; -import Flash from '../flash'; +import Translate from '../vue_shared/translate'; 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 +19,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..639410384c2 100644 --- a/app/assets/javascripts/groups/services/groups_service.js +++ b/app/assets/javascripts/groups/service/groups_service.js @@ -8,7 +8,7 @@ export default class GroupsService { this.groups = Vue.resource(endpoint); } - getGroups(parentId, page, filterGroups, sort) { + getGroups(parentId, page, filterGroups, sort, archived) { const data = {}; if (parentId) { @@ -20,12 +20,16 @@ export default class GroupsService { } if (filterGroups) { - data.filter_groups = filterGroups; + data.filter = filterGroups; } if (sort) { data.sort = sort; } + + if (archived) { + data.archived = archived; + } } return this.groups.get(data); 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/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index eecb56cb185..d1aa83ea57f 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -24,6 +24,11 @@ export default { required: true, type: Boolean, }, + showInlineEditButton: { + type: Boolean, + required: false, + default: false, + }, issuableRef: { type: String, required: true, @@ -222,20 +227,25 @@ export default { <div v-else> <title-component :issuable-ref="issuableRef" + :can-update="canUpdate" :title-html="state.titleHtml" - :title-text="state.titleText" /> + :title-text="state.titleText" + :show-inline-edit-button="showInlineEditButton" + /> <description-component v-if="state.descriptionHtml" :can-update="canUpdate" :description-html="state.descriptionHtml" :description-text="state.descriptionText" :updated-at="state.updatedAt" - :task-status="state.taskStatus" /> + :task-status="state.taskStatus" + /> <edited-component v-if="hasUpdated" :updated-at="state.updatedAt" :updated-by-name="state.updatedByName" - :updated-by-path="state.updatedByPath" /> + :updated-by-path="state.updatedByPath" + /> </div> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index a9dabd4cff1..00002709ac6 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -1,5 +1,8 @@ <script> import animateMixin from '../mixins/animate'; + import eventHub from '../event_hub'; + import tooltip from '../../vue_shared/directives/tooltip'; + import { spriteIcon } from '../../lib/utils/common_utils'; export default { mixins: [animateMixin], @@ -15,6 +18,11 @@ type: String, required: true, }, + canUpdate: { + required: false, + type: Boolean, + default: false, + }, titleHtml: { type: String, required: true, @@ -23,6 +31,14 @@ type: String, required: true, }, + showInlineEditButton: { + type: Boolean, + required: false, + default: false, + }, + }, + directives: { + tooltip, }, watch: { titleHtml() { @@ -30,24 +46,46 @@ this.animateChange(); }, }, + computed: { + pencilIcon() { + return spriteIcon('pencil', 'link-highlight'); + }, + }, methods: { setPageTitle() { const currentPageTitleScope = this.titleEl.innerText.split('·'); currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; this.titleEl.textContent = currentPageTitleScope.join('·'); }, + edit() { + eventHub.$emit('open.form'); + }, }, }; </script> <template> - <h2 - class="title" - :class="{ - 'issue-realtime-pre-pulse': preAnimation, - 'issue-realtime-trigger-pulse': pulseAnimation - }" - v-html="titleHtml" - > - </h2> + <div class="title-container"> + <h2 + class="title" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation + }" + v-html="titleHtml" + > + </h2> + <button + v-tooltip + v-if="showInlineEditButton && canUpdate" + type="button" + class="btn-blank btn-edit note-action-button" + v-html="pencilIcon" + title="Edit title and description" + data-placement="bottom" + data-container="body" + @click="edit" + > + </button> + </div> </template> diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 9f05cf16967..07899777a1e 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -403,7 +403,11 @@ export const setCiStatusFavicon = (pageUrl) => { }); }; -export const spriteIcon = icon => `<svg><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`; +export const spriteIcon = (icon, className = '') => { + const classAttribute = className.length > 0 ? `class="${className}"` : ''; + + return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`; +}; export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`; diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue index cc60aa5939c..0a89a9f16cb 100644 --- a/app/assets/javascripts/repo/components/repo.vue +++ b/app/assets/javascripts/repo/components/repo.vue @@ -11,7 +11,9 @@ import Helper from '../helpers/repo_helper'; import MonacoLoaderHelper from '../helpers/monaco_loader_helper'; export default { - data: () => Store, + data() { + return Store; + }, mixins: [RepoMixin], components: { RepoSidebar, diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue index c0dc4c8cd8b..185cd90ac06 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/repo/components/repo_commit_section.vue @@ -9,7 +9,9 @@ import { visitUrl } from '../../lib/utils/url_utility'; export default { mixins: [RepoMixin], - data: () => Store, + data() { + return Store; + }, components: { PopupDialog, diff --git a/app/assets/javascripts/repo/components/repo_edit_button.vue b/app/assets/javascripts/repo/components/repo_edit_button.vue index 353142edeb7..e6e8b2e5205 100644 --- a/app/assets/javascripts/repo/components/repo_edit_button.vue +++ b/app/assets/javascripts/repo/components/repo_edit_button.vue @@ -3,7 +3,9 @@ import Store from '../stores/repo_store'; import RepoMixin from '../mixins/repo_mixin'; export default { - data: () => Store, + data() { + return Store; + }, mixins: [RepoMixin], computed: { buttonLabel() { diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue index 02d9c775046..4639bee6d66 100644 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ b/app/assets/javascripts/repo/components/repo_editor.vue @@ -5,7 +5,9 @@ import Service from '../services/repo_service'; import Helper from '../helpers/repo_helper'; const RepoEditor = { - data: () => Store, + data() { + return Store; + }, destroyed() { if (Helper.monacoInstance) { @@ -22,7 +24,8 @@ const RepoEditor = { const monacoInstance = Helper.monaco.editor.create(this.$el, { model: null, readOnly: false, - contextmenu: false, + contextmenu: true, + scrollBeyondLastLine: false, }); Helper.monacoInstance = monacoInstance; @@ -92,7 +95,7 @@ const RepoEditor = { }, blobRaw() { - if (Helper.monacoInstance && !this.isTree) { + if (Helper.monacoInstance) { this.setupEditor(); } }, diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue index 8b9cbd23456..c7e69340f17 100644 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ b/app/assets/javascripts/repo/components/repo_file.vue @@ -1,107 +1,78 @@ <script> -import TimeAgoMixin from '../../vue_shared/mixins/timeago'; + import timeAgoMixin from '../../vue_shared/mixins/timeago'; + import eventHub from '../event_hub'; + import repoMixin from '../mixins/repo_mixin'; -const RepoFile = { - mixins: [TimeAgoMixin], - props: { - file: { - type: Object, - required: true, + export default { + mixins: [ + repoMixin, + timeAgoMixin, + ], + props: { + file: { + type: Object, + required: true, + }, }, - isMini: { - type: Boolean, - required: false, - default: false, + computed: { + fileIcon() { + const classObj = { + 'fa-spinner fa-spin': this.file.loading, + [this.file.icon]: !this.file.loading, + 'fa-folder-open': !this.file.loading && this.file.opened, + }; + return classObj; + }, + levelIndentation() { + return { + marginLeft: `${this.file.level * 16}px`, + }; + }, }, - loading: { - type: Object, - required: false, - default() { return { tree: false }; }, + methods: { + linkClicked(file) { + eventHub.$emit('fileNameClicked', file); + }, }, - hasFiles: { - type: Boolean, - required: false, - default: false, - }, - activeFile: { - type: Object, - required: true, - }, - }, - - computed: { - canShowFile() { - return !this.loading.tree || this.hasFiles; - }, - - fileIcon() { - const classObj = { - 'fa-spinner fa-spin': this.file.loading, - [this.file.icon]: !this.file.loading, - }; - return classObj; - }, - - fileIndentation() { - return { - 'margin-left': `${this.file.level * 10}px`, - }; - }, - - activeFileClass() { - return { - active: this.activeFile.url === this.file.url, - }; - }, - }, - - methods: { - linkClicked(file) { - this.$emit('linkclicked', file); - }, - }, -}; - -export default RepoFile; + }; </script> <template> -<tr - v-if="canShowFile" - class="file" - :class="activeFileClass" - @click.prevent="linkClicked(file)"> - <td> - <i - class="fa fa-fw file-icon" - :class="fileIcon" - :style="fileIndentation" - aria-label="file icon"> - </i> - <a - :href="file.url" - class="repo-file-name" - :title="file.url"> - {{file.name}} - </a> - </td> + <tr + class="file" + @click.prevent="linkClicked(file)"> + <td> + <i + class="fa fa-fw file-icon" + :class="fileIcon" + :style="levelIndentation" + aria-hidden="true" + > + </i> + <a + :href="file.url" + class="repo-file-name" + > + {{ file.name }} + </a> + </td> - <template v-if="!isMini"> - <td class="hidden-sm hidden-xs"> - <div class="commit-message"> - <a @click.stop :href="file.lastCommitUrl"> - {{file.lastCommitMessage}} + <template v-if="!isMini"> + <td class="hidden-sm hidden-xs"> + <a + @click.stop + :href="file.lastCommit.url" + class="commit-message" + > + {{ file.lastCommit.message }} </a> - </div> - </td> + </td> - <td class="hidden-xs text-right"> - <span - class="commit-update" - :title="tooltipTitle(file.lastCommitUpdate)"> - {{timeFormated(file.lastCommitUpdate)}} - </span> - </td> - </template> -</tr> + <td class="commit-update hidden-xs text-right"> + <span :title="tooltipTitle(file.lastCommit.updatedAt)"> + {{ timeFormated(file.lastCommit.updatedAt) }} + </span> + </td> + </template> + </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue index e43ef366f47..03cd219e718 100644 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue @@ -4,7 +4,9 @@ import Helper from '../helpers/repo_helper'; import RepoMixin from '../mixins/repo_mixin'; const RepoFileButtons = { - data: () => Store, + data() { + return Store; + }, mixins: [RepoMixin], diff --git a/app/assets/javascripts/repo/components/repo_file_options.vue b/app/assets/javascripts/repo/components/repo_file_options.vue deleted file mode 100644 index 6a15755f029..00000000000 --- a/app/assets/javascripts/repo/components/repo_file_options.vue +++ /dev/null @@ -1,25 +0,0 @@ -<script> -const RepoFileOptions = { - props: { - isMini: { - type: Boolean, - required: false, - default: false, - }, - projectName: { - type: String, - required: true, - }, - }, -}; - -export default RepoFileOptions; -</script> - -<template> - <tr v-if="isMini" class="repo-file-options"> - <td> - <span class="title">{{projectName}}</span> - </td> - </tr> -</template> diff --git a/app/assets/javascripts/repo/components/repo_loading_file.vue b/app/assets/javascripts/repo/components/repo_loading_file.vue index bc8c64c8362..832b45b2b29 100644 --- a/app/assets/javascripts/repo/components/repo_loading_file.vue +++ b/app/assets/javascripts/repo/components/repo_loading_file.vue @@ -1,43 +1,23 @@ <script> -const RepoLoadingFile = { - props: { - loading: { - type: Object, - required: false, - default: {}, - }, - hasFiles: { - type: Boolean, - required: false, - default: false, - }, - isMini: { - type: Boolean, - required: false, - default: false, - }, - }, - - computed: { - showGhostLines() { - return this.loading.tree && !this.hasFiles; - }, - }, + import repoMixin from '../mixins/repo_mixin'; - methods: { - lineOfCode(n) { - return `skeleton-line-${n}`; + export default { + mixins: [ + repoMixin, + ], + methods: { + lineOfCode(n) { + return `skeleton-line-${n}`; + }, }, - }, -}; - -export default RepoLoadingFile; + }; </script> <template> <tr - v-if="showGhostLines" - class="loading-file"> + class="loading-file" + aria-label="Loading files" + > <td> <div class="animation-container animation-container-small"> @@ -48,29 +28,28 @@ export default RepoLoadingFile; </div> </div> </td> - - <td - v-if="!isMini" - class="hidden-sm hidden-xs"> - <div class="animation-container"> - <div - v-for="n in 6" - :key="n" - :class="lineOfCode(n)"> + <template v-if="!isMini"> + <td + class="hidden-sm hidden-xs"> + <div class="animation-container"> + <div + v-for="n in 6" + :key="n" + :class="lineOfCode(n)"> + </div> </div> - </div> - </td> + </td> - <td - v-if="!isMini" - class="hidden-xs"> - <div class="animation-container animation-container-small"> - <div - v-for="n in 6" - :key="n" - :class="lineOfCode(n)"> + <td + class="hidden-xs"> + <div class="animation-container animation-container-small animation-container-right"> + <div + v-for="n in 6" + :key="n" + :class="lineOfCode(n)"> + </div> </div> - </div> - </td> + </td> + </template> </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_prev_directory.vue b/app/assets/javascripts/repo/components/repo_prev_directory.vue index bbdbdc61e38..c4bf6dcdec2 100644 --- a/app/assets/javascripts/repo/components/repo_prev_directory.vue +++ b/app/assets/javascripts/repo/components/repo_prev_directory.vue @@ -1,38 +1,38 @@ <script> -import RepoMixin from '../mixins/repo_mixin'; + import eventHub from '../event_hub'; + import repoMixin from '../mixins/repo_mixin'; -const RepoPreviousDirectory = { - props: { - prevUrl: { - type: String, - required: true, + export default { + mixins: [ + repoMixin, + ], + props: { + prevUrl: { + type: String, + required: true, + }, }, - }, - - mixins: [RepoMixin], - - computed: { - colSpanCondition() { - return this.isMini ? undefined : 3; + computed: { + colSpanCondition() { + return this.isMini ? undefined : 3; + }, }, - }, - - methods: { - linkClicked(file) { - this.$emit('linkclicked', file); + methods: { + linkClicked(file) { + eventHub.$emit('goToPreviousDirectoryClicked', file); + }, }, - }, -}; - -export default RepoPreviousDirectory; + }; </script> <template> -<tr class="prev-directory"> - <td - :colspan="colSpanCondition" - @click.prevent="linkClicked(prevUrl)"> - <a :href="prevUrl">..</a> - </td> -</tr> + <tr class="file prev-directory"> + <td + :colspan="colSpanCondition" + class="table-cell" + @click.prevent="linkClicked(prevUrl)" + > + <a :href="prevUrl">...</a> + </td> + </tr> </template> diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue index a87bef6084a..b5be771d539 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -4,7 +4,9 @@ import Store from '../stores/repo_store'; export default { - data: () => Store, + data() { + return Store; + }, computed: { html() { return this.activeFile.html; diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue index e0f3c33003a..5832e603907 100644 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -1,9 +1,10 @@ <script> +import _ from 'underscore'; import Service from '../services/repo_service'; import Helper from '../helpers/repo_helper'; import Store from '../stores/repo_store'; +import eventHub from '../event_hub'; import RepoPreviousDirectory from './repo_prev_directory.vue'; -import RepoFileOptions from './repo_file_options.vue'; import RepoFile from './repo_file.vue'; import RepoLoadingFile from './repo_loading_file.vue'; import RepoMixin from '../mixins/repo_mixin'; @@ -11,21 +12,35 @@ import RepoMixin from '../mixins/repo_mixin'; export default { mixins: [RepoMixin], components: { - 'repo-file-options': RepoFileOptions, 'repo-previous-directory': RepoPreviousDirectory, 'repo-file': RepoFile, 'repo-loading-file': RepoLoadingFile, }, - created() { window.addEventListener('popstate', this.checkHistory); }, destroyed() { + eventHub.$off('fileNameClicked', this.fileClicked); + eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked); window.removeEventListener('popstate', this.checkHistory); }, + mounted() { + eventHub.$on('fileNameClicked', this.fileClicked); + eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked); + }, + data() { + return Store; + }, + computed: { + flattendFiles() { + const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)])); - data: () => Store, - + return _.chain(this.files) + .map(arr => [arr, mapFiles(arr)]) + .flatten() + .value(); + }, + }, methods: { checkHistory() { let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1); @@ -52,21 +67,21 @@ export default { }, fileClicked(clickedFile, lineNumber) { - let file = clickedFile; + const file = clickedFile; + if (file.loading) return; - file.loading = true; if (file.type === 'tree' && file.opened) { - file = Store.removeChildFilesOfTree(file); - file.loading = false; + Helper.setDirectoryToClosed(file); Store.setActiveLine(lineNumber); } else { const openFile = Helper.getFileFromPath(file.url); + if (openFile) { - file.loading = false; Store.setActiveFiles(openFile); Store.setActiveLine(lineNumber); } else { + file.loading = true; Service.url = file.url; Helper.getContent(file) .then(() => { @@ -81,7 +96,7 @@ export default { goToPreviousDirectoryClicked(prevURL) { Service.url = prevURL; - Helper.getContent(null) + Helper.getContent(null, true) .then(() => Helper.scrollTabsRight()) .catch(Helper.loadingError); }, @@ -92,38 +107,43 @@ export default { <template> <div id="sidebar" :class="{'sidebar-mini' : isMini}"> <table class="table"> - <thead v-if="!isMini"> + <thead> <tr> - <th class="name">Name</th> - <th class="hidden-sm hidden-xs last-commit">Last commit</th> - <th class="hidden-xs last-update text-right">Last update</th> + <th + v-if="isMini" + class="repo-file-options title" + > + <strong class="clgray"> + {{ projectName }} + </strong> + </th> + <template v-else> + <th class="name"> + Name + </th> + <th class="hidden-sm hidden-xs last-commit"> + Last commit + </th> + <th class="hidden-xs last-update text-right"> + Last update + </th> + </template> </tr> </thead> <tbody> - <repo-file-options - :is-mini="isMini" - :project-name="projectName" - /> <repo-previous-directory - v-if="isRoot" + v-if="!isRoot && !loading.tree" :prev-url="prevURL" - @linkclicked="goToPreviousDirectoryClicked(prevURL)"/> + /> <repo-loading-file + v-if="!flattendFiles.length && loading.tree" v-for="n in 5" :key="n" - :loading="loading" - :has-files="!!files.length" - :is-mini="isMini" /> <repo-file - v-for="file in files" + v-for="file in flattendFiles" :key="file.id" :file="file" - :is-mini="isMini" - @linkclicked="fileClicked(file)" - :is-tree="isTree" - :has-files="!!files.length" - :active-file="activeFile" /> </tbody> </table> diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue index 0d0c34ec741..098715915b0 100644 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ b/app/assets/javascripts/repo/components/repo_tab.vue @@ -26,11 +26,13 @@ const RepoTab = { }, methods: { - tabClicked: Store.setActiveFiles, - + tabClicked(file) { + Store.setActiveFiles(file); + }, closeTab(file) { if (file.changed) return; - this.$emit('tabclosed', file); + + Store.removeFromOpenedFiles(file); }, }, }; @@ -39,25 +41,28 @@ export default RepoTab; </script> <template> -<li @click="tabClicked(tab)"> - <a - href="#0" - class="close" - @click.stop.prevent="closeTab(tab)" - :aria-label="closeLabel"> - <i - class="fa" - :class="changedClass" - aria-hidden="true"> - </i> - </a> + <li + :class="{ active : tab.active }" + @click="tabClicked(tab)" + > + <button + type="button" + class="close-btn" + @click.stop.prevent="closeTab(tab)" + :aria-label="closeLabel"> + <i + class="fa" + :class="changedClass" + aria-hidden="true"> + </i> + </button> - <a - href="#" - class="repo-tab" - :title="tab.url" - @click.prevent="tabClicked(tab)"> - {{tab.name}} - </a> -</li> + <a + href="#" + class="repo-tab" + :title="tab.url" + @click.prevent="tabClicked(tab)"> + {{tab.name}} + </a> + </li> </template> diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue index 9c5bfc5d0cf..b57cd0960de 100644 --- a/app/assets/javascripts/repo/components/repo_tabs.vue +++ b/app/assets/javascripts/repo/components/repo_tabs.vue @@ -1,36 +1,29 @@ <script> -import Store from '../stores/repo_store'; -import RepoTab from './repo_tab.vue'; -import RepoMixin from '../mixins/repo_mixin'; + import Store from '../stores/repo_store'; + import RepoTab from './repo_tab.vue'; + import RepoMixin from '../mixins/repo_mixin'; -const RepoTabs = { - mixins: [RepoMixin], - - components: { - 'repo-tab': RepoTab, - }, - - data: () => Store, - - methods: { - tabClosed(file) { - Store.removeFromOpenedFiles(file); + export default { + mixins: [RepoMixin], + components: { + 'repo-tab': RepoTab, }, - }, -}; - -export default RepoTabs; + data() { + return Store; + }, + }; </script> <template> -<ul id="tabs"> - <repo-tab - v-for="tab in openedFiles" - :key="tab.id" - :tab="tab" - :class="{'active' : tab.active}" - @tabclosed="tabClosed" - /> - <li class="tabs-divider" /> -</ul> + <ul + id="tabs" + class="list-unstyled" + > + <repo-tab + v-for="tab in openedFiles" + :key="tab.id" + :tab="tab" + /> + <li class="tabs-divider" /> + </ul> </template> diff --git a/app/assets/javascripts/repo/event_hub.js b/app/assets/javascripts/repo/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/repo/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js index 46204598e1d..dfaf9caaee7 100644 --- a/app/assets/javascripts/repo/helpers/repo_helper.js +++ b/app/assets/javascripts/repo/helpers/repo_helper.js @@ -1,3 +1,4 @@ +import { convertPermissionToBoolean } from '../../lib/utils/common_utils'; import Service from '../services/repo_service'; import Store from '../stores/repo_store'; import Flash from '../../flash'; @@ -25,10 +26,6 @@ const RepoHelper = { key: '', - isTree(data) { - return Object.hasOwnProperty.call(data, 'blobs'); - }, - Time: window.performance && window.performance.now ? window.performance @@ -58,13 +55,20 @@ const RepoHelper = { }, setDirectoryOpen(tree, title) { - const file = tree; - if (!file) return undefined; + if (!tree) return; + + Object.assign(tree, { + opened: true, + }); + + RepoHelper.updateHistoryEntry(tree.url, title); + }, - file.opened = true; - file.icon = 'fa-folder-open'; - RepoHelper.updateHistoryEntry(file.url, title); - return file; + setDirectoryToClosed(entry) { + Object.assign(entry, { + opened: false, + files: [], + }); }, isRenderable() { @@ -81,63 +85,23 @@ const RepoHelper = { .catch(RepoHelper.loadingError); }, - // when you open a directory you need to put the directory files under - // the directory... This will merge the list of the current directory and the new list. - getNewMergedList(inDirectory, currentList, newList) { - const newListSorted = newList.sort(this.compareFilesCaseInsensitive); - if (!inDirectory) return newListSorted; - const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url); - if (!indexOfFile) return newListSorted; - return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile); - }, - - // within the get new merged list this does the merging of the current list of files - // and the new list of files. The files are never "in" another directory they just - // appear like they are because of the margin. - mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) { - newList.reverse().forEach((newFile) => { - const fileIndex = indexOfFile + 1; - const file = newFile; - file.level = inDirectory.level + 1; - oldList.splice(fileIndex, 0, file); - }); - - return oldList; - }, - - compareFilesCaseInsensitive(a, b) { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - if (a.level > 0) return 0; - if (aName < bName) { return -1; } - if (aName > bName) { return 1; } - return 0; - }, + getContent(treeOrFile, emptyFiles = false) { + let file = treeOrFile; - isRoot(url) { - // the url we are requesting -> split by the project URL. Grab the right side. - const isRoot = !!url.split(Store.projectUrl)[1] - // remove the first "/" - .slice(1) - // split this by "/" - .split('/') - // remove the first two items of the array... usually /tree/master. - .slice(2) - // we want to know the length of the array. - // If greater than 0 not root. - .length; - return isRoot; - }, + if (!Store.files.length) { + Store.loading.tree = true; + } - getContent(treeOrFile) { - let file = treeOrFile; return Service.getContent() .then((response) => { const data = response.data; if (response.headers && response.headers['page-title']) data.pageTitle = response.headers['page-title']; + if (response.headers && response.headers['is-root'] && !Store.isInitialRoot) { + Store.isRoot = convertPermissionToBoolean(response.headers['is-root']); + Store.isInitialRoot = Store.isRoot; + } - Store.isTree = RepoHelper.isTree(data); - if (!Store.isTree) { + if (file && file.type === 'blob') { if (!file) file = data; Store.binary = data.binary; @@ -145,38 +109,40 @@ const RepoHelper = { // file might be undefined RepoHelper.setBinaryDataAsBase64(data); Store.setViewToPreview(); - } else if (!Store.isPreviewView()) { - if (!data.render_error) { - Service.getRaw(data.raw_path) - .then((rawResponse) => { - Store.blobRaw = rawResponse.data; - data.plain = rawResponse.data; - RepoHelper.setFile(data, file); - }).catch(RepoHelper.loadingError); - } + } else if (!Store.isPreviewView() && !data.render_error) { + Service.getRaw(data.raw_path) + .then((rawResponse) => { + Store.blobRaw = rawResponse.data; + data.plain = rawResponse.data; + RepoHelper.setFile(data, file); + }).catch(RepoHelper.loadingError); } if (Store.isPreviewView()) { RepoHelper.setFile(data, file); } + } else { + Store.loading.tree = false; + RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name); - // if the file tree is empty - if (Store.files.length === 0) { - const parentURL = Service.blobURLtoParentTree(Service.url); - Service.url = parentURL; - RepoHelper.getContent(); + if (emptyFiles) { + Store.files = []; } - } else { - // it's a tree - if (!file) Store.isRoot = RepoHelper.isRoot(Service.url); - file = RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name); - const newDirectory = RepoHelper.dataToListOfFiles(data); - Store.addFilesToDirectory(file, Store.files, newDirectory); + + this.addToDirectory(file, data); + Store.prevURL = Service.blobURLtoParentTree(Service.url); } }).catch(RepoHelper.loadingError); }, + addToDirectory(file, data) { + const tree = file || Store; + const files = tree.files.concat(this.dataToListOfFiles(data, file ? file.level + 1 : 0)); + + tree.files = files; + }, + setFile(data, file) { const newFile = data; newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh. @@ -190,57 +156,39 @@ const RepoHelper = { Store.setActiveFiles(newFile); }, - serializeBlob(blob) { - const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob); - simpleBlob.lastCommitMessage = blob.last_commit.message; - simpleBlob.lastCommitUpdate = blob.last_commit.committed_date; - simpleBlob.loading = false; - - return simpleBlob; - }, - - serializeTree(tree) { - return RepoHelper.serializeRepoEntity('tree', tree); - }, - - serializeSubmodule(submodule) { - return RepoHelper.serializeRepoEntity('submodule', submodule); - }, - - serializeRepoEntity(type, entity) { + serializeRepoEntity(type, entity, level = 0) { const { url, name, icon, last_commit } = entity; - const returnObj = { + + return { type, name, url, + level, icon: `fa-${icon}`, - level: 0, + files: [], loading: false, + opened: false, + // eslint-disable-next-line camelcase + lastCommit: last_commit ? { + url: `${Store.projectUrl}/commit/${last_commit.id}`, + message: last_commit.message, + updatedAt: last_commit.committed_date, + } : {}, }; - - if (entity.last_commit) { - returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`; - } else { - returnObj.lastCommitUrl = ''; - } - return returnObj; }, scrollTabsRight() { - // wait for the transition. 0.1 seconds. - setTimeout(() => { - const tabs = document.getElementById('tabs'); - if (!tabs) return; - tabs.scrollLeft = tabs.scrollWidth; - }, 200); + const tabs = document.getElementById('tabs'); + if (!tabs) return; + tabs.scrollLeft = tabs.scrollWidth; }, - dataToListOfFiles(data) { + dataToListOfFiles(data, level) { const { blobs, trees, submodules } = data; return [ - ...blobs.map(blob => RepoHelper.serializeBlob(blob)), - ...trees.map(tree => RepoHelper.serializeTree(tree)), - ...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)), + ...trees.map(tree => RepoHelper.serializeRepoEntity('tree', tree, level)), + ...submodules.map(submodule => RepoHelper.serializeRepoEntity('submodule', submodule, level)), + ...blobs.map(blob => RepoHelper.serializeRepoEntity('blob', blob, level)), ]; }, diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js index 1a09f411b22..65dee7d5fd1 100644 --- a/app/assets/javascripts/repo/index.js +++ b/app/assets/javascripts/repo/index.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; +import { convertPermissionToBoolean } from '../lib/utils/common_utils'; import Service from './services/repo_service'; import Store from './stores/repo_store'; import Repo from './components/repo.vue'; @@ -33,6 +34,8 @@ function setInitialStore(data) { Store.onTopOfBranch = data.onTopOfBranch; Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl); Store.customBranchURL = decodeURIComponent(data.blobUrl); + Store.isRoot = convertPermissionToBoolean(data.root); + Store.isInitialRoot = convertPermissionToBoolean(data.root); Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref'); Store.checkIsCommitable(); Store.setBranchHash(); diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js index f8d29af7ffe..49d7317a17e 100644 --- a/app/assets/javascripts/repo/stores/repo_store.js +++ b/app/assets/javascripts/repo/stores/repo_store.js @@ -2,14 +2,13 @@ import Helper from '../helpers/repo_helper'; import Service from '../services/repo_service'; const RepoStore = { - monaco: {}, monacoLoading: false, service: '', canCommit: false, onTopOfBranch: false, editMode: false, - isTree: false, - isRoot: false, + isRoot: null, + isInitialRoot: null, prevURL: '', projectId: '', projectName: '', @@ -39,23 +38,11 @@ const RepoStore = { newMrTemplateUrl: '', branchChanged: false, commitMessage: '', - binaryTypes: { - png: false, - md: false, - svg: false, - unknown: false, - }, loading: { tree: false, blob: false, }, - resetBinaryTypes() { - Object.keys(RepoStore.binaryTypes).forEach((key) => { - RepoStore.binaryTypes[key] = false; - }); - }, - setBranchHash() { return Service.getBranch() .then((data) => { @@ -72,10 +59,6 @@ const RepoStore = { RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit; }, - addFilesToDirectory(inDirectory, currentList, newList) { - RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList); - }, - toggleRawPreview() { RepoStore.activeFile.raw = !RepoStore.activeFile.raw; RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source'; @@ -129,30 +112,6 @@ const RepoStore = { RepoStore.activeFileLabel = 'Display source'; }, - removeChildFilesOfTree(tree) { - let foundTree = false; - const treeToClose = tree; - let canStopSearching = false; - RepoStore.files = RepoStore.files.filter((file) => { - const isItTheTreeWeWant = file.url === treeToClose.url; - // if it's the next tree - if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) { - canStopSearching = true; - return true; - } - if (canStopSearching) return true; - - if (isItTheTreeWeWant) foundTree = true; - - if (foundTree) return file.level <= treeToClose.level; - return true; - }); - - treeToClose.opened = false; - treeToClose.icon = 'fa-folder'; - return treeToClose; - }, - removeFromOpenedFiles(file) { if (file.type === 'tree') return; let foundIndex; @@ -186,6 +145,7 @@ const RepoStore = { if (openedFilesAlreadyExists) return; openFile.changed = false; + openFile.active = true; RepoStore.openedFiles.push(openFile); }, diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 0d7a5cba928..aa61ddc6a2c 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -7,6 +7,7 @@ @import "framework/animations"; @import "framework/avatar"; @import "framework/asciidoctor"; +@import "framework/banner"; @import "framework/blocks"; @import "framework/buttons"; @import "framework/badges"; diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index f0e6b23757f..374988bb590 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -198,6 +198,13 @@ a { height: 12px; } + &.animation-container-right { + .skeleton-line-2 { + left: 0; + right: 150px; + } + } + &::before { animation-duration: 1s; animation-fill-mode: forwards; diff --git a/app/assets/stylesheets/framework/banner.scss b/app/assets/stylesheets/framework/banner.scss new file mode 100644 index 00000000000..6433b0c7855 --- /dev/null +++ b/app/assets/stylesheets/framework/banner.scss @@ -0,0 +1,25 @@ +.banner-callout { + display: flex; + position: relative; + flex-wrap: wrap; + + .banner-close { + position: absolute; + top: 10px; + right: 10px; + opacity: 1; + + .dismiss-icon { + color: $gl-text-color; + font-size: $gl-font-size; + } + } + + .banner-graphic { + margin: 20px auto; + } + + &.banner-non-empty-state { + border-bottom: 1px solid $border-color; + } +} diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 588ec1ff3bc..5833ef939e9 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -10,6 +10,10 @@ border: 0; } + &.file-holder-bottom-radius { + border-radius: 0 0 $border-radius-small $border-radius-small; + } + &.readme-holder { margin: $gl-padding 0; 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/framework/new-sidebar.scss b/app/assets/stylesheets/framework/new-sidebar.scss index 78972717932..17fa31c450d 100644 --- a/app/assets/stylesheets/framework/new-sidebar.scss +++ b/app/assets/stylesheets/framework/new-sidebar.scss @@ -466,7 +466,7 @@ $new-sidebar-collapsed-width: 50px; @media (max-width: $screen-xs-max) { + .breadcrumbs-links { - padding-left: 17px; + padding-left: $gl-padding; border-left: 1px solid $gl-text-color-quaternary; } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 089a67a7c98..d5ca23ff870 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -233,6 +233,7 @@ $container-text-max-width: 540px; $gl-avatar-size: 40px; $error-exclamation-point: $red-500; $border-radius-default: 4px; +$border-radius-small: 2px; $settings-icon-size: 18px; $provider-btn-not-active-color: $blue-500; $link-underline-blue: $blue-500; diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 5538e46a6c4..8d6f30e3b84 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -4,6 +4,6 @@ } .alert-block { - margin-bottom: 20px; + margin-bottom: 10px; } } diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index d3cd4d507de..edfafa79c44 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -4,7 +4,7 @@ border-right: 1px solid $border-color; border-left: 1px solid $border-color; border-bottom: none; - border-radius: 2px; + border-radius: $border-radius-small $border-radius-small 0 0; background: $gray-normal; } 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; + } } } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index c93c4e93af5..48532503263 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -72,12 +72,22 @@ } } + .title-container { + display: flex; + } + .title { padding: 0; margin-bottom: 16px; border-bottom: none; } + .btn-edit { + margin-left: auto; + // Set height to match title height + height: 2em; + } + // Border around images in issue and MR descriptions. .description img:not(.emoji) { border: 1px solid $white-normal; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 96b7db3b85d..ebad429c2ba 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -531,14 +531,13 @@ ul.notes { padding: 0; min-width: 16px; color: $gray-darkest; + fill: $gray-darkest; .fa { position: relative; font-size: 16px; } - - svg { height: 16px; width: 16px; @@ -566,6 +565,7 @@ ul.notes { .link-highlight { color: $gl-link-color; + fill: $gl-link-color; svg { fill: $gl-link-color; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index c36fe25f74d..ea37ccf5e3d 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -153,28 +153,13 @@ overflow-x: auto; li { - animation: swipeRightAppear ease-in 0.1s; - animation-iteration-count: 1; - transform-origin: 0% 50%; - list-style-type: none; + position: relative; background: $gray-normal; - display: inline-block; padding: #{$gl-padding / 2} $gl-padding; border-right: 1px solid $white-dark; border-bottom: 1px solid $white-dark; - white-space: nowrap; cursor: pointer; - &.remove { - animation: swipeRightDissapear ease-in 0.1s; - animation-iteration-count: 1; - transform-origin: 0% 50%; - - a { - width: 0; - } - } - &.active { background: $white-light; border-bottom: none; @@ -182,17 +167,21 @@ a { @include str-truncated(100px); - color: $black; + color: $gl-text-color; vertical-align: middle; text-decoration: none; margin-right: 12px; + } - &.close { - width: auto; - font-size: 15px; - opacity: 1; - margin-right: -6px; - } + .close-btn { + position: absolute; + right: 8px; + top: 50%; + padding: 0; + background: none; + border: 0; + font-size: $gl-font-size; + transform: translateY(-50%); } .close-icon:hover { @@ -201,9 +190,6 @@ .close-icon, .unsaved-icon { - float: right; - margin-top: 3px; - margin-left: 15px; color: $gray-darkest; } @@ -222,9 +208,7 @@ #repo-file-buttons { background-color: $white-light; - border-bottom: 1px solid $white-normal; padding: 5px 10px; - position: relative; border-top: 1px solid $white-normal; } @@ -287,37 +271,23 @@ overflow: auto; } - table { + .table { margin-bottom: 0; } tr { - animation: fadein 0.5s; - cursor: pointer; - - &.repo-file-options td { - padding: 0; - border-top: none; - background: $gray-light; + .repo-file-options { + padding: 2px 16px; width: 100%; - display: inline-block; - - &:first-child { - border-top-left-radius: 2px; - } + } - .title { - display: inline-block; - font-size: 10px; - text-transform: uppercase; - font-weight: $gl-font-weight-bold; - color: $gray-darkest; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - vertical-align: middle; - padding: 2px 16px; - } + .title { + font-size: 10px; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; } .file-icon { @@ -329,11 +299,13 @@ } } + .file { + cursor: pointer; + } + a { @include str-truncated(250px); color: $almost-black; - display: inline-block; - vertical-align: middle; } } } |