Commit 891a9ce8 authored by Stan Hu's avatar Stan Hu

Merge branch 'master' into sh-security-fix-backports-master

parents bd46c8ab b3f74903
...@@ -7,7 +7,7 @@ class BoardService { ...@@ -7,7 +7,7 @@ class BoardService {
this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, { this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
issues: { issues: {
method: 'GET', 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}`, {}, { this.lists = Vue.resource(`${listsEndpoint}{/id}`, {}, {
...@@ -16,7 +16,7 @@ class BoardService { ...@@ -16,7 +16,7 @@ class BoardService {
url: `${listsEndpoint}/generate.json` 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`, {}, { this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, {
bulkUpdate: { bulkUpdate: {
method: 'POST', method: 'POST',
......
...@@ -3,7 +3,8 @@ import Visibility from 'visibilityjs'; ...@@ -3,7 +3,8 @@ import Visibility from 'visibilityjs';
import axios from 'axios'; import axios from 'axios';
import Poll from './lib/utils/poll'; import Poll from './lib/utils/poll';
import { s__ } from './locale'; import { s__ } from './locale';
import './flash'; import initSettingsPanels from './settings_panels';
import Flash from './flash';
/** /**
* Cluster page has 2 separate parts: * Cluster page has 2 separate parts:
...@@ -24,6 +25,8 @@ class ClusterService { ...@@ -24,6 +25,8 @@ class ClusterService {
export default class Clusters { export default class Clusters {
constructor() { constructor() {
initSettingsPanels();
const dataset = document.querySelector('.js-edit-cluster-form').dataset; const dataset = document.querySelector('.js-edit-cluster-form').dataset;
this.state = { this.state = {
......
...@@ -73,6 +73,7 @@ import initProjectVisibilitySelector from './project_visibility'; ...@@ -73,6 +73,7 @@ import initProjectVisibilitySelector from './project_visibility';
import GpgBadges from './gpg_badges'; import GpgBadges from './gpg_badges';
import UserFeatureHelper from './helpers/user_feature_helper'; import UserFeatureHelper from './helpers/user_feature_helper';
import initChangesDropdown from './init_changes_dropdown'; import initChangesDropdown from './init_changes_dropdown';
import NewGroupChild from './groups/new_group_child';
import AbuseReports from './abuse_reports'; import AbuseReports from './abuse_reports';
import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils'; import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
import AjaxLoadingSpinner from './ajax_loading_spinner'; import AjaxLoadingSpinner from './ajax_loading_spinner';
...@@ -168,9 +169,6 @@ import memberExpirationDate from './member_expiration_date'; ...@@ -168,9 +169,6 @@ import memberExpirationDate from './member_expiration_date';
const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
filteredSearchManager.setup(); filteredSearchManager.setup();
} }
if (page === 'projects:merge_requests:index') {
new UserCallout({ setCalloutPerProject: true });
}
const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_'; const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_';
IssuableIndex.init(pagePrefix); IssuableIndex.init(pagePrefix);
...@@ -352,7 +350,10 @@ import memberExpirationDate from './member_expiration_date'; ...@@ -352,7 +350,10 @@ import memberExpirationDate from './member_expiration_date';
case 'projects:show': case 'projects:show':
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new NotificationsForm(); new NotificationsForm();
new UserCallout({ setCalloutPerProject: true }); new UserCallout({
setCalloutPerProject: true,
className: 'js-autodevops-banner',
});
if ($('#tree-slider').length) new TreeView(); if ($('#tree-slider').length) new TreeView();
if ($('.blob-viewer').length) new BlobViewer(); if ($('.blob-viewer').length) new BlobViewer();
...@@ -372,9 +373,6 @@ import memberExpirationDate from './member_expiration_date'; ...@@ -372,9 +373,6 @@ import memberExpirationDate from './member_expiration_date';
case 'projects:pipelines:new': case 'projects:pipelines:new':
new NewBranchForm($('.js-new-pipeline-form')); new NewBranchForm($('.js-new-pipeline-form'));
break; break;
case 'projects:pipelines:index':
new UserCallout({ setCalloutPerProject: true });
break;
case 'projects:pipelines:builds': case 'projects:pipelines:builds':
case 'projects:pipelines:failures': case 'projects:pipelines:failures':
case 'projects:pipelines:show': case 'projects:pipelines:show':
...@@ -395,10 +393,15 @@ import memberExpirationDate from './member_expiration_date'; ...@@ -395,10 +393,15 @@ import memberExpirationDate from './member_expiration_date';
new gl.Activities(); new gl.Activities();
break; break;
case 'groups:show': case 'groups:show':
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new NotificationsForm(); new NotificationsForm();
new NotificationsDropdown(); new NotificationsDropdown();
new ProjectsList(); new ProjectsList();
if (newGroupChildWrapper) {
new NewGroupChild(newGroupChildWrapper);
}
break; break;
case 'groups:group_members:index': case 'groups:group_members:index':
memberExpirationDate(); memberExpirationDate();
...@@ -432,7 +435,6 @@ import memberExpirationDate from './member_expiration_date'; ...@@ -432,7 +435,6 @@ import memberExpirationDate from './member_expiration_date';
new TreeView(); new TreeView();
new BlobViewer(); new BlobViewer();
new NewCommitForm($('.js-create-dir-form')); new NewCommitForm($('.js-create-dir-form'));
new UserCallout({ setCalloutPerProject: true });
$('#tree-slider').waitForImages(function() { $('#tree-slider').waitForImages(function() {
ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath); ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
}); });
......
...@@ -6,10 +6,11 @@ import _ from 'underscore'; ...@@ -6,10 +6,11 @@ import _ from 'underscore';
*/ */
export default class FilterableList { export default class FilterableList {
constructor(form, filter, holder) { constructor(form, filter, holder, filterInputField = 'filter_groups') {
this.filterForm = form; this.filterForm = form;
this.listFilterElement = filter; this.listFilterElement = filter;
this.listHolderElement = holder; this.listHolderElement = holder;
this.filterInputField = filterInputField;
this.isBusy = false; this.isBusy = false;
} }
...@@ -32,10 +33,10 @@ export default class FilterableList { ...@@ -32,10 +33,10 @@ export default class FilterableList {
onFilterInput() { onFilterInput() {
const $form = $(this.filterForm); const $form = $(this.filterForm);
const queryData = {}; const queryData = {};
const filterGroupsParam = $form.find('[name="filter_groups"]').val(); const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) { if (filterGroupsParam) {
queryData.filter_groups = filterGroupsParam; queryData[this.filterInputField] = filterGroupsParam;
} }
this.filterResults(queryData); this.filterResults(queryData);
......
<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>
<script> <script>
import { n__ } from '../../locale';
import { MAX_CHILDREN_COUNT } from '../constants';
export default { export default {
props: { props: {
groups: { parentGroup: {
type: Object,
required: true,
},
baseGroup: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), 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> </script>
...@@ -20,8 +32,20 @@ export default { ...@@ -20,8 +32,20 @@ export default {
v-for="(group, index) in groups" v-for="(group, index) in groups"
:key="index" :key="index"
:group="group" :group="group"
:base-group="baseGroup" :parent-group="parentGroup"
:collection="groups" />
<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> </ul>
</template> </template>
...@@ -2,49 +2,28 @@ ...@@ -2,49 +2,28 @@
import identicon from '../../vue_shared/components/identicon.vue'; import identicon from '../../vue_shared/components/identicon.vue';
import eventHub from '../event_hub'; 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 { export default {
components: { components: {
identicon, identicon,
itemCaret,
itemTypeIcon,
itemStats,
itemActions,
}, },
props: { props: {
group: { parentGroup: {
type: Object,
required: true,
},
baseGroup: {
type: Object, type: Object,
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
collection: { group: {
type: Object, type: Object,
required: false, required: true,
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);
}, },
}, },
computed: { computed: {
...@@ -53,51 +32,33 @@ export default { ...@@ -53,51 +32,33 @@ export default {
}, },
rowClass() { rowClass() {
return { return {
'group-row': true,
'is-open': this.group.isOpen, 'is-open': this.group.isOpen,
'has-subgroups': this.group.hasSubgroups, 'has-children': this.hasChildren,
'no-description': !this.group.description, 'has-description': this.group.description,
'being-removed': this.group.isBeingRemoved,
}; };
}, },
visibilityIcon() { hasChildren() {
return { return this.group.childrenCount > 0;
fa: true,
'fa-globe': this.group.visibility === 'public',
'fa-shield': this.group.visibility === 'internal',
'fa-lock': this.group.visibility === 'private',
};
}, },
fullPath() { hasAvatar() {
let fullPath = ''; return this.group.avatarUrl !== null;
},
if (this.group.isOrphan) { isGroup() {
// check if current group is baseGroup return this.group.type === 'group';
if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) { },
// Remove baseGroup prefix from our current group.fullName. e.g: },
// baseGroup.fullName: `level1` methods: {
// group.fullName: `level1 / level2 / level3` onClickRowGroup(e) {
// Result: `level2 / level3` const NO_EXPAND_CLS = 'no-expand';
const gfn = this.group.fullName; if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
const bfn = this.baseGroup.fullName; e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
const length = bfn.length; if (this.hasChildren) {
const start = gfn.indexOf(bfn); eventHub.$emit('toggleChildren', this.group);
const extraPrefixChars = 3;
fullPath = gfn.substr(start + length + extraPrefixChars);
} else { } 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 { ...@@ -108,98 +69,36 @@ export default {
@click.stop="onClickRowGroup" @click.stop="onClickRowGroup"
:id="groupDomId" :id="groupDomId"
:class="rowClass" :class="rowClass"
class="group-row"
> >
<div <div
class="group-row-contents"> class="group-row-contents">
<div <item-actions
class="controls"> v-if="isGroup"
<a :group="group"
v-if="group.canEdit" :parent-group="parentGroup"
class="edit-group btn" />
:href="group.editPath"> <item-stats
<i :item="group"
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>
<div <div
class="folder-toggle-wrap"> class="folder-toggle-wrap">
<span <item-caret
class="folder-caret" :is-group-open="group.isOpen"
v-if="group.hasSubgroups"> />
<i <item-type-icon
v-if="group.isOpen" :item-type="group.type"
class="fa fa-caret-down" :is-group-open="group.isOpen"
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>
</div> </div>
<div <div
class="avatar-container s40 hidden-xs"> class="avatar-container s40 hidden-xs"
:class="{ 'content-loading': group.isChildrenLoading }"
>
<a <a
:href="group.groupPath"> :href="group.relativePath"
class="no-expand"
>
<img <img
v-if="hasAvatar" v-if="hasAvatar"
class="avatar s40" class="avatar s40"
...@@ -215,19 +114,22 @@ export default { ...@@ -215,19 +114,22 @@ export default {
<div <div
class="title"> class="title">
<a <a
:href="group.groupPath">{{fullPath}}</a> :href="group.relativePath"
<template v-if="group.permissions.humanGroupAccess"> class="no-expand">{{group.fullName}}</a>
as <span
<span class="access-type">{{group.permissions.humanGroupAccess}}</span> v-if="group.permission"
</template> class="access-type"
>
{{s__('GroupsTreeRole|as')}} {{group.permission}}
</span>
</div> </div>
<div <div
class="description">{{group.description}}</div> class="description">{{group.description}}</div>
</div> </div>
<group-folder <group-folder
v-if="group.isOpen && hasGroups" v-if="group.isOpen && hasChildren"
:groups="group.subGroups" :parent-group="group"
:baseGroup="group" :groups="group.children"
/> />
</li> </li>
</template> </template>
...@@ -4,24 +4,33 @@ import eventHub from '../event_hub'; ...@@ -4,24 +4,33 @@ import eventHub from '../event_hub';
import { getParameterByName } from '../../lib/utils/common_utils'; import { getParameterByName } from '../../lib/utils/common_utils';
export default { export default {
components: {
tablePagination,
},
props: { props: {
groups: { groups: {
type: Object, type: Array,
required: true, required: true,
}, },
pageInfo: { pageInfo: {
type: Object, type: Object,
required: true, required: true,
}, },
searchEmpty: {
type: Boolean,
required: true,
},
searchEmptyMessage: {
type: String,
required: true,
}, },
components: {
tablePagination,
}, },
methods: { methods: {
change(page) { change(page) {
const filterGroupsParam = getParameterByName('filter_groups'); const filterGroupsParam = getParameterByName('filter_groups');
const sortParam = getParameterByName('sort'); 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 { ...@@ -29,10 +38,17 @@ export default {
<template> <template>
<div class="groups-list-tree-container"> <div class="groups-list-tree-container">
<div
v-if="searchEmpty"
class="has-no-search-results">
{{searchEmptyMessage}}
</div>
<group-folder <group-folder
v-if="!searchEmpty"
:groups="groups" :groups="groups"
/> />
<table-pagination <table-pagination
v-if="!searchEmpty"
:change="change" :change="change"
:pageInfo="pageInfo" :pageInfo="pageInfo"
/> />
......
<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>
<script> <script>
const RepoFileOptions = { export default {
props: { props: {
isMini: { isGroupOpen: {
type: Boolean, type: Boolean,
required: false, required: true,
default: false, default: false,
}, },
projectName: { },
type: String, computed: {
required: true, iconClass() {
return this.isGroupOpen ? 'fa-caret-down' : 'fa-caret-right';
}, },
}, },
}; };
export default RepoFileOptions;
</script> </script>
<template> <template>
<tr v-if="isMini" class="repo-file-options"> <span class="folder-caret">
<td> <i
<span class="title">{{projectName}}</span> :class="iconClass"
</td> class="fa"
</tr> aria-hidden="true"/>
</span>
</template> </template>
<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>
<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>
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',
};
...@@ -3,12 +3,13 @@ import eventHub from './event_hub'; ...@@ -3,12 +3,13 @@ import eventHub from './event_hub';
import { getParameterByName } from '../lib/utils/common_utils'; import { getParameterByName } from '../lib/utils/common_utils';
export default class GroupFilterableList extends FilterableList { export default class GroupFilterableList extends FilterableList {
constructor({ form, filter, holder, filterEndpoint, pagePath }) { constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
super(form, filter, holder); super(form, filter, holder, filterInputField);
this.form = form; this.form = form;
this.filterEndpoint = filterEndpoint; this.filterEndpoint = filterEndpoint;
this.pagePath = pagePath; this.pagePath = pagePath;
this.$dropdown = $('.js-group-filter-dropdown-wrap'); this.filterInputField = filterInputField;
this.$dropdown = $(dropdownSel);
} }
getFilterEndpoint() { getFilterEndpoint() {
...@@ -24,30 +25,34 @@ export default class GroupFilterableList extends FilterableList { ...@@ -24,30 +25,34 @@ export default class GroupFilterableList extends FilterableList {
bindEvents() { bindEvents() {
super.bindEvents(); super.bindEvents();
this.onFormSubmitWrapper = this.onFormSubmit.bind(this);
this.onFilterOptionClikWrapper = this.onOptionClick.bind(this); this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
this.filterForm.addEventListener('submit', this.onFormSubmitWrapper);
this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper); this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
} }
onFormSubmit(e) { onFilterInput() {
e.preventDefault();
const $form = $(this.form);
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
const queryData = {}; const queryData = {};
const $form = $(this.form);
const archivedParam = getParameterByName('archived', window.location.href);
const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
if (filterGroupsParam) { if (filterGroupsParam) {
queryData.filter_groups = filterGroupsParam; queryData[this.filterInputField] = filterGroupsParam;
}
if (archivedParam) {
queryData.archived = archivedParam;
} }
this.filterResults(queryData); this.filterResults(queryData);
if (this.setDefaultFilterOption) {
this.setDefaultFilterOption(); this.setDefaultFilterOption();
} }
}
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); this.$dropdown.find('.dropdown-label').text(defaultOption);
} }
...@@ -55,23 +60,42 @@ export default class GroupFilterableList extends FilterableList { ...@@ -55,23 +60,42 @@ export default class GroupFilterableList extends FilterableList {
e.preventDefault(); e.preventDefault();
const queryData = {}; 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) { if (sortParam) {
queryData.sort = sortParam; queryData.sort = sortParam;
} }
if (archivedParam) {
queryData.archived = archivedParam;
}
this.filterResults(queryData); this.filterResults(queryData);
// Active selected option // Active selected option
if (isOptionFilterBySort) {
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text)); 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 // Clear current value on search form
this.form.querySelector('[name="filter_groups"]').value = ''; this.form.querySelector(`[name="${this.filterInputField}"]`).value = '';
} }
onFilterSuccess(data, xhr, queryData) { onFilterSuccess(data, xhr, queryData) {
super.onFilterSuccess(data, xhr, queryData); const currentPath = this.getPagePath(queryData);
const paginationData = { const paginationData = {
'X-Per-Page': xhr.getResponseHeader('X-Per-Page'), 'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
...@@ -82,7 +106,11 @@ export default class GroupFilterableList extends FilterableList { ...@@ -82,7 +106,11 @@ export default class GroupFilterableList extends FilterableList {
'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'), '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); eventHub.$emit('updatePagination', paginationData);
} }
} }
import Vue from 'vue'; import Vue from 'vue';
import Flash from '../flash'; import Translate from '../vue_shared/translate';
import GroupFilterableList from './groups_filterable_list'; import GroupFilterableList from './groups_filterable_list';
import GroupsComponent from './components/groups.vue'; import GroupsStore from './store/groups_store';
import GroupFolder from './components/group_folder.vue'; import GroupsService from './service/groups_service';
import GroupItem from './components/group_item.vue';
import GroupsStore from './stores/groups_store'; import groupsApp from './components/app.vue';
import GroupsService from './services/groups_service'; import groupFolderComponent from './components/group_folder.vue';
import eventHub from './event_hub'; import groupItemComponent from './components/group_item.vue';
import { getParameterByName } from '../lib/utils/common_utils';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => { 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) // Don't do anything if element doesn't exist (No groups)
// This is for when the user enters directly to the page via URL // This is for when the user enters directly to the page via URL
...@@ -18,176 +19,56 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -18,176 +19,56 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
Vue.component('groups-component', GroupsComponent); Vue.component('group-folder', groupFolderComponent);
Vue.component('group-folder', GroupFolder); Vue.component('group-item', groupItemComponent);
Vue.component('group-item', GroupItem);
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
components: {
groupsApp,
},
data() { data() {
this.store = new GroupsStore(); const dataset = this.$options.el.dataset;
this.service = new GroupsService(el.dataset.endpoint); const hideProjects = dataset.hideProjects === 'true';
const store = new GroupsStore(hideProjects);
const service = new GroupsService(dataset.endpoint);
return { return {
store: this.store, store,
isLoading: true, service,
state: this.store.state, hideProjects,
loading: true, 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() { beforeMount() {
const dataset = this.$options.el.dataset;
let groupFilterList = null; let groupFilterList = null;
const form = document.querySelector('form#group-filter-form'); const form = document.querySelector(dataset.formSel);
const filter = document.querySelector('.js-groups-list-filter'); const filter = document.querySelector(dataset.filterSel);
const holder = document.querySelector('.js-groups-list-holder'); const holder = document.querySelector(dataset.holderSel);
const opts = { const opts = {
form, form,
filter, filter,
holder, holder,
filterEndpoint: el.dataset.endpoint, filterEndpoint: dataset.endpoint,
pagePath: el.dataset.path, pagePath: dataset.path,
dropdownSel: dataset.dropdownSel,
filterInputField: 'filter',
}; };
groupFilterList = new GroupFilterableList(opts); groupFilterList = new GroupFilterableList(opts);
groupFilterList.initSearch(); groupFilterList.initSearch();
}, },
mounted() { render(createElement) {
this.fetchGroups() return createElement('groups-app', {
.then((response) => { props: {
this.updatePagination(response.headers); store: this.store,
this.isLoading = false; service: this.service,
}) hideProjects: this.hideProjects,
.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);
}, },
}); });
}); });
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);
}
}
}
...@@ -8,7 +8,7 @@ export default class GroupsService { ...@@ -8,7 +8,7 @@ export default class GroupsService {
this.groups = Vue.resource(endpoint); this.groups = Vue.resource(endpoint);
} }
getGroups(parentId, page, filterGroups, sort) { getGroups(parentId, page, filterGroups, sort, archived) {
const data = {}; const data = {};
if (parentId) { if (parentId) {
...@@ -20,12 +20,16 @@ export default class GroupsService { ...@@ -20,12 +20,16 @@ export default class GroupsService {
} }
if (filterGroups) { if (filterGroups) {
data.filter_groups = filterGroups; data.filter = filterGroups;
} }
if (sort) { if (sort) {
data.sort = sort; data.sort = sort;
} }
if (archived) {
data.archived = archived;
}
} }
return this.groups.get(data); return this.groups.get(data);
......
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);
}
}
}
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;
}
}
...@@ -24,6 +24,11 @@ export default { ...@@ -24,6 +24,11 @@ export default {
required: true, required: true,
type: Boolean, type: Boolean,
}, },
showInlineEditButton: {
type: Boolean,
required: false,
default: false,
},
issuableRef: { issuableRef: {
type: String, type: String,
required: true, required: true,
...@@ -222,20 +227,25 @@ export default { ...@@ -222,20 +227,25 @@ export default {
<div v-else> <div v-else>
<title-component <title-component
:issuable-ref="issuableRef" :issuable-ref="issuableRef"
:can-update="canUpdate"
:title-html="state.titleHtml" :title-html="state.titleHtml"
:title-text="state.titleText" /> :title-text="state.titleText"
:show-inline-edit-button="showInlineEditButton"
/>
<description-component <description-component
v-if="state.descriptionHtml" v-if="state.descriptionHtml"
:can-update="canUpdate" :can-update="canUpdate"
:description-html="state.descriptionHtml" :description-html="state.descriptionHtml"
:description-text="state.descriptionText" :description-text="state.descriptionText"
:updated-at="state.updatedAt" :updated-at="state.updatedAt"
:task-status="state.taskStatus" /> :task-status="state.taskStatus"
/>
<edited-component <edited-component
v-if="hasUpdated" v-if="hasUpdated"
:updated-at="state.updatedAt" :updated-at="state.updatedAt"
:updated-by-name="state.updatedByName" :updated-by-name="state.updatedByName"
:updated-by-path="state.updatedByPath" /> :updated-by-path="state.updatedByPath"
/>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import animateMixin from '../mixins/animate'; 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 { export default {
mixins: [animateMixin], mixins: [animateMixin],
...@@ -15,6 +18,11 @@ ...@@ -15,6 +18,11 @@
type: String, type: String,
required: true, required: true,
}, },
canUpdate: {
required: false,
type: Boolean,
default: false,
},
titleHtml: { titleHtml: {
type: String, type: String,
required: true, required: true,
...@@ -23,6 +31,14 @@ ...@@ -23,6 +31,14 @@
type: String, type: String,
required: true, required: true,
}, },
showInlineEditButton: {
type: Boolean,
required: false,
default: false,
},
},
directives: {
tooltip,
}, },
watch: { watch: {
titleHtml() { titleHtml() {
...@@ -30,17 +46,26 @@ ...@@ -30,17 +46,26 @@
this.animateChange(); this.animateChange();
}, },
}, },
computed: {
pencilIcon() {
return spriteIcon('pencil', 'link-highlight');
},
},
methods: { methods: {
setPageTitle() { setPageTitle() {
const currentPageTitleScope = this.titleEl.innerText.split('·'); const currentPageTitleScope = this.titleEl.innerText.split('·');
currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
this.titleEl.textContent = currentPageTitleScope.join('·'); this.titleEl.textContent = currentPageTitleScope.join('·');
}, },
edit() {
eventHub.$emit('open.form');
},
}, },
}; };
</script> </script>
<template> <template>
<div class="title-container">
<h2 <h2
class="title" class="title"
:class="{ :class="{
...@@ -50,4 +75,17 @@ ...@@ -50,4 +75,17 @@
v-html="titleHtml" v-html="titleHtml"
> >
</h2> </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> </template>
...@@ -403,7 +403,11 @@ export const setCiStatusFavicon = (pageUrl) => { ...@@ -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}`; export const imagePath = imgUrl => `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
......
...@@ -11,7 +11,9 @@ import Helper from '../helpers/repo_helper'; ...@@ -11,7 +11,9 @@ import Helper from '../helpers/repo_helper';
import MonacoLoaderHelper from '../helpers/monaco_loader_helper'; import MonacoLoaderHelper from '../helpers/monaco_loader_helper';
export default { export default {
data: () => Store, data() {
return Store;
},
mixins: [RepoMixin], mixins: [RepoMixin],
components: { components: {
RepoSidebar, RepoSidebar,
......
...@@ -9,7 +9,9 @@ import { visitUrl } from '../../lib/utils/url_utility'; ...@@ -9,7 +9,9 @@ import { visitUrl } from '../../lib/utils/url_utility';
export default { export default {
mixins: [RepoMixin], mixins: [RepoMixin],
data: () => Store, data() {
return Store;
},
components: { components: {
PopupDialog, PopupDialog,
......
...@@ -3,7 +3,9 @@ import Store from '../stores/repo_store'; ...@@ -3,7 +3,9 @@ import Store from '../stores/repo_store';
import RepoMixin from '../mixins/repo_mixin'; import RepoMixin from '../mixins/repo_mixin';
export default { export default {
data: () => Store, data() {
return Store;
},
mixins: [RepoMixin], mixins: [RepoMixin],
computed: { computed: {
buttonLabel() { buttonLabel() {
......
...@@ -5,7 +5,9 @@ import Service from '../services/repo_service'; ...@@ -5,7 +5,9 @@ import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper'; import Helper from '../helpers/repo_helper';
const RepoEditor = { const RepoEditor = {
data: () => Store, data() {
return Store;
},
destroyed() { destroyed() {
if (Helper.monacoInstance) { if (Helper.monacoInstance) {
...@@ -22,7 +24,8 @@ const RepoEditor = { ...@@ -22,7 +24,8 @@ const RepoEditor = {
const monacoInstance = Helper.monaco.editor.create(this.$el, { const monacoInstance = Helper.monaco.editor.create(this.$el, {
model: null, model: null,
readOnly: false, readOnly: false,
contextmenu: false, contextmenu: true,
scrollBeyondLastLine: false,
}); });
Helper.monacoInstance = monacoInstance; Helper.monacoInstance = monacoInstance;
...@@ -92,7 +95,7 @@ const RepoEditor = { ...@@ -92,7 +95,7 @@ const RepoEditor = {
}, },
blobRaw() { blobRaw() {
if (Helper.monacoInstance && !this.isTree) { if (Helper.monacoInstance) {
this.setupEditor(); this.setupEditor();
} }
}, },
......
<script> <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 = { export default {
mixins: [TimeAgoMixin], mixins: [
repoMixin,
timeAgoMixin,
],
props: { props: {
file: { file: {
type: Object, type: Object,
required: true, required: true,
}, },
isMini: {
type: Boolean,
required: false,
default: false,
}, },
loading: {
type: Object,
required: false,
default() { return { tree: false }; },
},
hasFiles: {
type: Boolean,
required: false,
default: false,
},
activeFile: {
type: Object,
required: true,
},
},
computed: { computed: {
canShowFile() {
return !this.loading.tree || this.hasFiles;
},
fileIcon() { fileIcon() {
const classObj = { const classObj = {
'fa-spinner fa-spin': this.file.loading, 'fa-spinner fa-spin': this.file.loading,
[this.file.icon]: !this.file.loading, [this.file.icon]: !this.file.loading,
'fa-folder-open': !this.file.loading && this.file.opened,
}; };
return classObj; return classObj;
}, },
levelIndentation() {
fileIndentation() {
return {
'margin-left': `${this.file.level * 10}px`,
};
},
activeFileClass() {
return { return {
active: this.activeFile.url === this.file.url, marginLeft: `${this.file.level * 16}px`,
}; };
}, },
}, },
methods: { methods: {
linkClicked(file) { linkClicked(file) {
this.$emit('linkclicked', file); eventHub.$emit('fileNameClicked', file);
}, },
}, },
}; };
export default RepoFile;
</script> </script>
<template> <template>
<tr <tr
v-if="canShowFile"
class="file" class="file"
:class="activeFileClass"
@click.prevent="linkClicked(file)"> @click.prevent="linkClicked(file)">
<td> <td>
<i <i
class="fa fa-fw file-icon" class="fa fa-fw file-icon"
:class="fileIcon" :class="fileIcon"
:style="fileIndentation" :style="levelIndentation"
aria-label="file icon"> aria-hidden="true"
>
</i> </i>
<a <a
:href="file.url" :href="file.url"
class="repo-file-name" class="repo-file-name"
:title="file.url"> >
{{file.name}} {{ file.name }}
</a> </a>
</td> </td>
<template v-if="!isMini"> <template v-if="!isMini">
<td class="hidden-sm hidden-xs"> <td class="hidden-sm hidden-xs">
<div class="commit-message"> <a
<a @click.stop :href="file.lastCommitUrl"> @click.stop
{{file.lastCommitMessage}} :href="file.lastCommit.url"
class="commit-message"
>
{{ file.lastCommit.message }}
</a> </a>
</div>
</td> </td>
<td class="hidden-xs text-right"> <td class="commit-update hidden-xs text-right">
<span <span :title="tooltipTitle(file.lastCommit.updatedAt)">
class="commit-update" {{ timeFormated(file.lastCommit.updatedAt) }}
:title="tooltipTitle(file.lastCommitUpdate)">
{{timeFormated(file.lastCommitUpdate)}}
</span> </span>
</td> </td>
</template> </template>
</tr> </tr>
</template> </template>
...@@ -4,7 +4,9 @@ import Helper from '../helpers/repo_helper'; ...@@ -4,7 +4,9 @@ import Helper from '../helpers/repo_helper';
import RepoMixin from '../mixins/repo_mixin'; import RepoMixin from '../mixins/repo_mixin';
const RepoFileButtons = { const RepoFileButtons = {
data: () => Store, data() {
return Store;
},
mixins: [RepoMixin], mixins: [RepoMixin],
......
<script> <script>
const RepoLoadingFile = { import repoMixin from '../mixins/repo_mixin';
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;
},
},
export default {
mixins: [
repoMixin,
],
methods: { methods: {
lineOfCode(n) { lineOfCode(n) {
return `skeleton-line-${n}`; return `skeleton-line-${n}`;
}, },
}, },
}; };
export default RepoLoadingFile;
</script> </script>
<template> <template>
<tr <tr
v-if="showGhostLines" class="loading-file"
class="loading-file"> aria-label="Loading files"
>
<td> <td>
<div <div
class="animation-container animation-container-small"> class="animation-container animation-container-small">
...@@ -48,9 +28,8 @@ export default RepoLoadingFile; ...@@ -48,9 +28,8 @@ export default RepoLoadingFile;
</div> </div>
</div> </div>
</td> </td>
<template v-if="!isMini">
<td <td
v-if="!isMini"
class="hidden-sm hidden-xs"> class="hidden-sm hidden-xs">
<div class="animation-container"> <div class="animation-container">
<div <div
...@@ -62,9 +41,8 @@ export default RepoLoadingFile; ...@@ -62,9 +41,8 @@ export default RepoLoadingFile;
</td> </td>
<td <td
v-if="!isMini"
class="hidden-xs"> class="hidden-xs">
<div class="animation-container animation-container-small"> <div class="animation-container animation-container-small animation-container-right">
<div <div
v-for="n in 6" v-for="n in 6"
:key="n" :key="n"
...@@ -72,5 +50,6 @@ export default RepoLoadingFile; ...@@ -72,5 +50,6 @@ export default RepoLoadingFile;
</div> </div>
</div> </div>
</td> </td>
</template>
</tr> </tr>
</template> </template>
<script> <script>
import RepoMixin from '../mixins/repo_mixin'; import eventHub from '../event_hub';
import repoMixin from '../mixins/repo_mixin';
const RepoPreviousDirectory = { export default {
mixins: [
repoMixin,
],
props: { props: {
prevUrl: { prevUrl: {
type: String, type: String,
required: true, required: true,
}, },
}, },
mixins: [RepoMixin],
computed: { computed: {
colSpanCondition() { colSpanCondition() {
return this.isMini ? undefined : 3; return this.isMini ? undefined : 3;
}, },
}, },
methods: { methods: {
linkClicked(file) { linkClicked(file) {
this.$emit('linkclicked', file); eventHub.$emit('goToPreviousDirectoryClicked', file);
}, },
}, },
}; };
export default RepoPreviousDirectory;
</script> </script>
<template> <template>
<tr class="prev-directory"> <tr class="file prev-directory">
<td <td
:colspan="colSpanCondition" :colspan="colSpanCondition"
@click.prevent="linkClicked(prevUrl)"> class="table-cell"
<a :href="prevUrl">..</a> @click.prevent="linkClicked(prevUrl)"
>
<a :href="prevUrl">...</a>
</td> </td>
</tr> </tr>
</template> </template>
...@@ -4,7 +4,9 @@ ...@@ -4,7 +4,9 @@
import Store from '../stores/repo_store'; import Store from '../stores/repo_store';
export default { export default {
data: () => Store, data() {
return Store;
},
computed: { computed: {
html() { html() {
return this.activeFile.html; return this.activeFile.html;
......
<script> <script>
import _ from 'underscore';
import Service from '../services/repo_service'; import Service from '../services/repo_service';
import Helper from '../helpers/repo_helper'; import Helper from '../helpers/repo_helper';
import Store from '../stores/repo_store'; import Store from '../stores/repo_store';
import eventHub from '../event_hub';
import RepoPreviousDirectory from './repo_prev_directory.vue'; import RepoPreviousDirectory from './repo_prev_directory.vue';
import RepoFileOptions from './repo_file_options.vue';
import RepoFile from './repo_file.vue'; import RepoFile from './repo_file.vue';
import RepoLoadingFile from './repo_loading_file.vue'; import RepoLoadingFile from './repo_loading_file.vue';
import RepoMixin from '../mixins/repo_mixin'; import RepoMixin from '../mixins/repo_mixin';
...@@ -11,21 +12,35 @@ import RepoMixin from '../mixins/repo_mixin'; ...@@ -11,21 +12,35 @@ import RepoMixin from '../mixins/repo_mixin';
export default { export default {
mixins: [RepoMixin], mixins: [RepoMixin],
components: { components: {
'repo-file-options': RepoFileOptions,
'repo-previous-directory': RepoPreviousDirectory, 'repo-previous-directory': RepoPreviousDirectory,
'repo-file': RepoFile, 'repo-file': RepoFile,
'repo-loading-file': RepoLoadingFile, 'repo-loading-file': RepoLoadingFile,
}, },
created() { created() {
window.addEventListener('popstate', this.checkHistory); window.addEventListener('popstate', this.checkHistory);
}, },
destroyed() { destroyed() {
eventHub.$off('fileNameClicked', this.fileClicked);
eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
window.removeEventListener('popstate', this.checkHistory); 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: { methods: {
checkHistory() { checkHistory() {
let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1); let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1);
...@@ -52,21 +67,21 @@ export default { ...@@ -52,21 +67,21 @@ export default {
}, },
fileClicked(clickedFile, lineNumber) { fileClicked(clickedFile, lineNumber) {
let file = clickedFile; const file = clickedFile;
if (file.loading) return; if (file.loading) return;
file.loading = true;
if (file.type === 'tree' && file.opened) { if (file.type === 'tree' && file.opened) {
file = Store.removeChildFilesOfTree(file); Helper.setDirectoryToClosed(file);
file.loading = false;
Store.setActiveLine(lineNumber); Store.setActiveLine(lineNumber);
} else { } else {
const openFile = Helper.getFileFromPath(file.url); const openFile = Helper.getFileFromPath(file.url);
if (openFile) { if (openFile) {
file.loading = false;
Store.setActiveFiles(openFile); Store.setActiveFiles(openFile);
Store.setActiveLine(lineNumber); Store.setActiveLine(lineNumber);
} else { } else {
file.loading = true;
Service.url = file.url; Service.url = file.url;
Helper.getContent(file) Helper.getContent(file)
.then(() => { .then(() => {
...@@ -81,7 +96,7 @@ export default { ...@@ -81,7 +96,7 @@ export default {
goToPreviousDirectoryClicked(prevURL) { goToPreviousDirectoryClicked(prevURL) {
Service.url = prevURL; Service.url = prevURL;
Helper.getContent(null) Helper.getContent(null, true)
.then(() => Helper.scrollTabsRight()) .then(() => Helper.scrollTabsRight())
.catch(Helper.loadingError); .catch(Helper.loadingError);
}, },
...@@ -92,38 +107,43 @@ export default { ...@@ -92,38 +107,43 @@ export default {
<template> <template>
<div id="sidebar" :class="{'sidebar-mini' : isMini}"> <div id="sidebar" :class="{'sidebar-mini' : isMini}">
<table class="table"> <table class="table">
<thead v-if="!isMini"> <thead>
<tr> <tr>
<th class="name">Name</th> <th
<th class="hidden-sm hidden-xs last-commit">Last commit</th> v-if="isMini"
<th class="hidden-xs last-update text-right">Last update</th> 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> </tr>
</thead> </thead>
<tbody> <tbody>
<repo-file-options
:is-mini="isMini"
:project-name="projectName"
/>
<repo-previous-directory <repo-previous-directory
v-if="isRoot" v-if="!isRoot && !loading.tree"
:prev-url="prevURL" :prev-url="prevURL"
@linkclicked="goToPreviousDirectoryClicked(prevURL)"/> />
<repo-loading-file <repo-loading-file
v-if="!flattendFiles.length && loading.tree"
v-for="n in 5" v-for="n in 5"
:key="n" :key="n"
:loading="loading"
:has-files="!!files.length"
:is-mini="isMini"
/> />
<repo-file <repo-file
v-for="file in files" v-for="file in flattendFiles"
:key="file.id" :key="file.id"
:file="file" :file="file"
:is-mini="isMini"
@linkclicked="fileClicked(file)"
:is-tree="isTree"
:has-files="!!files.length"
:active-file="activeFile"
/> />
</tbody> </tbody>
</table> </table>
......
...@@ -26,11 +26,13 @@ const RepoTab = { ...@@ -26,11 +26,13 @@ const RepoTab = {
}, },
methods: { methods: {
tabClicked: Store.setActiveFiles, tabClicked(file) {
Store.setActiveFiles(file);
},
closeTab(file) { closeTab(file) {
if (file.changed) return; if (file.changed) return;
this.$emit('tabclosed', file);
Store.removeFromOpenedFiles(file);
}, },
}, },
}; };
...@@ -39,10 +41,13 @@ export default RepoTab; ...@@ -39,10 +41,13 @@ export default RepoTab;
</script> </script>
<template> <template>
<li @click="tabClicked(tab)"> <li
<a :class="{ active : tab.active }"
href="#0" @click="tabClicked(tab)"
class="close" >
<button
type="button"
class="close-btn"
@click.stop.prevent="closeTab(tab)" @click.stop.prevent="closeTab(tab)"
:aria-label="closeLabel"> :aria-label="closeLabel">
<i <i
...@@ -50,7 +55,7 @@ export default RepoTab; ...@@ -50,7 +55,7 @@ export default RepoTab;
:class="changedClass" :class="changedClass"
aria-hidden="true"> aria-hidden="true">
</i> </i>
</a> </button>
<a <a
href="#" href="#"
...@@ -59,5 +64,5 @@ export default RepoTab; ...@@ -59,5 +64,5 @@ export default RepoTab;
@click.prevent="tabClicked(tab)"> @click.prevent="tabClicked(tab)">
{{tab.name}} {{tab.name}}
</a> </a>
</li> </li>
</template> </template>
<script> <script>
import Store from '../stores/repo_store'; import Store from '../stores/repo_store';
import RepoTab from './repo_tab.vue'; import RepoTab from './repo_tab.vue';
import RepoMixin from '../mixins/repo_mixin'; import RepoMixin from '../mixins/repo_mixin';
const RepoTabs = { export default {
mixins: [RepoMixin], mixins: [RepoMixin],
components: { components: {
'repo-tab': RepoTab, 'repo-tab': RepoTab,
}, },
data() {
data: () => Store, return Store;
methods: {
tabClosed(file) {
Store.removeFromOpenedFiles(file);
}, },
}, };
};
export default RepoTabs;
</script> </script>
<template> <template>
<ul id="tabs"> <ul
id="tabs"
class="list-unstyled"
>
<repo-tab <repo-tab
v-for="tab in openedFiles" v-for="tab in openedFiles"
:key="tab.id" :key="tab.id"
:tab="tab" :tab="tab"
:class="{'active' : tab.active}"
@tabclosed="tabClosed"
/> />
<li class="tabs-divider" /> <li class="tabs-divider" />
</ul> </ul>
</template> </template>
import Vue from 'vue';
export default new Vue();
import { convertPermissionToBoolean } from '../../lib/utils/common_utils';
import Service from '../services/repo_service'; import Service from '../services/repo_service';
import Store from '../stores/repo_store'; import Store from '../stores/repo_store';
import Flash from '../../flash'; import Flash from '../../flash';
...@@ -25,10 +26,6 @@ const RepoHelper = { ...@@ -25,10 +26,6 @@ const RepoHelper = {
key: '', key: '',
isTree(data) {
return Object.hasOwnProperty.call(data, 'blobs');
},
Time: window.performance Time: window.performance
&& window.performance.now && window.performance.now
? window.performance ? window.performance
...@@ -58,13 +55,20 @@ const RepoHelper = { ...@@ -58,13 +55,20 @@ const RepoHelper = {
}, },
setDirectoryOpen(tree, title) { setDirectoryOpen(tree, title) {
const file = tree; if (!tree) return;
if (!file) return undefined;
Object.assign(tree, {
opened: true,
});
RepoHelper.updateHistoryEntry(tree.url, title);
},
file.opened = true; setDirectoryToClosed(entry) {
file.icon = 'fa-folder-open'; Object.assign(entry, {
RepoHelper.updateHistoryEntry(file.url, title); opened: false,
return file; files: [],
});
}, },
isRenderable() { isRenderable() {
...@@ -81,63 +85,23 @@ const RepoHelper = { ...@@ -81,63 +85,23 @@ const RepoHelper = {
.catch(RepoHelper.loadingError); .catch(RepoHelper.loadingError);
}, },
// when you open a directory you need to put the directory files under getContent(treeOrFile, emptyFiles = false) {
// the directory... This will merge the list of the current directory and the new list. let file = treeOrFile;
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;
},
isRoot(url) { if (!Store.files.length) {
// the url we are requesting -> split by the project URL. Grab the right side. Store.loading.tree = true;
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;
},
getContent(treeOrFile) {
let file = treeOrFile;
return Service.getContent() return Service.getContent()
.then((response) => { .then((response) => {
const data = response.data; const data = response.data;
if (response.headers && response.headers['page-title']) data.pageTitle = response.headers['page-title']; 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 (file && file.type === 'blob') {
if (!Store.isTree) {
if (!file) file = data; if (!file) file = data;
Store.binary = data.binary; Store.binary = data.binary;
...@@ -145,8 +109,7 @@ const RepoHelper = { ...@@ -145,8 +109,7 @@ const RepoHelper = {
// file might be undefined // file might be undefined
RepoHelper.setBinaryDataAsBase64(data); RepoHelper.setBinaryDataAsBase64(data);
Store.setViewToPreview(); Store.setViewToPreview();
} else if (!Store.isPreviewView()) { } else if (!Store.isPreviewView() && !data.render_error) {
if (!data.render_error) {
Service.getRaw(data.raw_path) Service.getRaw(data.raw_path)
.then((rawResponse) => { .then((rawResponse) => {
Store.blobRaw = rawResponse.data; Store.blobRaw = rawResponse.data;
...@@ -154,29 +117,32 @@ const RepoHelper = { ...@@ -154,29 +117,32 @@ const RepoHelper = {
RepoHelper.setFile(data, file); RepoHelper.setFile(data, file);
}).catch(RepoHelper.loadingError); }).catch(RepoHelper.loadingError);
} }
}
if (Store.isPreviewView()) { if (Store.isPreviewView()) {
RepoHelper.setFile(data, file); RepoHelper.setFile(data, file);
} }
} else {
Store.loading.tree = false;
RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
// if the file tree is empty if (emptyFiles) {
if (Store.files.length === 0) { Store.files = [];
const parentURL = Service.blobURLtoParentTree(Service.url);
Service.url = parentURL;
RepoHelper.getContent();
} }
} else {
// it's a tree this.addToDirectory(file, data);
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);
Store.prevURL = Service.blobURLtoParentTree(Service.url); Store.prevURL = Service.blobURLtoParentTree(Service.url);
} }
}).catch(RepoHelper.loadingError); }).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) { setFile(data, file) {
const newFile = data; const newFile = data;
newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh. newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh.
...@@ -190,57 +156,39 @@ const RepoHelper = { ...@@ -190,57 +156,39 @@ const RepoHelper = {
Store.setActiveFiles(newFile); Store.setActiveFiles(newFile);
}, },
serializeBlob(blob) { serializeRepoEntity(type, entity, level = 0) {
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) {
const { url, name, icon, last_commit } = entity; const { url, name, icon, last_commit } = entity;
const returnObj = {
return {
type, type,
name, name,
url, url,
level,
icon: `fa-${icon}`, icon: `fa-${icon}`,
level: 0, files: [],
loading: false, 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() { scrollTabsRight() {
// wait for the transition. 0.1 seconds.
setTimeout(() => {
const tabs = document.getElementById('tabs'); const tabs = document.getElementById('tabs');
if (!tabs) return; if (!tabs) return;
tabs.scrollLeft = tabs.scrollWidth; tabs.scrollLeft = tabs.scrollWidth;
}, 200);
}, },
dataToListOfFiles(data) { dataToListOfFiles(data, level) {
const { blobs, trees, submodules } = data; const { blobs, trees, submodules } = data;
return [ return [
...blobs.map(blob => RepoHelper.serializeBlob(blob)), ...trees.map(tree => RepoHelper.serializeRepoEntity('tree', tree, level)),
...trees.map(tree => RepoHelper.serializeTree(tree)), ...submodules.map(submodule => RepoHelper.serializeRepoEntity('submodule', submodule, level)),
...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)), ...blobs.map(blob => RepoHelper.serializeRepoEntity('blob', blob, level)),
]; ];
}, },
......
import $ from 'jquery'; import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import { convertPermissionToBoolean } from '../lib/utils/common_utils';
import Service from './services/repo_service'; import Service from './services/repo_service';
import Store from './stores/repo_store'; import Store from './stores/repo_store';
import Repo from './components/repo.vue'; import Repo from './components/repo.vue';
...@@ -33,6 +34,8 @@ function setInitialStore(data) { ...@@ -33,6 +34,8 @@ function setInitialStore(data) {
Store.onTopOfBranch = data.onTopOfBranch; Store.onTopOfBranch = data.onTopOfBranch;
Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl); Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl);
Store.customBranchURL = decodeURIComponent(data.blobUrl); 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.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
Store.checkIsCommitable(); Store.checkIsCommitable();
Store.setBranchHash(); Store.setBranchHash();
......
...@@ -2,14 +2,13 @@ import Helper from '../helpers/repo_helper'; ...@@ -2,14 +2,13 @@ import Helper from '../helpers/repo_helper';
import Service from '../services/repo_service'; import Service from '../services/repo_service';
const RepoStore = { const RepoStore = {
monaco: {},
monacoLoading: false, monacoLoading: false,
service: '', service: '',
canCommit: false, canCommit: false,
onTopOfBranch: false, onTopOfBranch: false,
editMode: false, editMode: false,
isTree: false, isRoot: null,
isRoot: false, isInitialRoot: null,
prevURL: '', prevURL: '',
projectId: '', projectId: '',
projectName: '', projectName: '',
...@@ -39,23 +38,11 @@ const RepoStore = { ...@@ -39,23 +38,11 @@ const RepoStore = {
newMrTemplateUrl: '', newMrTemplateUrl: '',
branchChanged: false, branchChanged: false,
commitMessage: '', commitMessage: '',
binaryTypes: {
png: false,
md: false,
svg: false,
unknown: false,
},
loading: { loading: {
tree: false, tree: false,
blob: false, blob: false,
}, },
resetBinaryTypes() {
Object.keys(RepoStore.binaryTypes).forEach((key) => {
RepoStore.binaryTypes[key] = false;
});
},
setBranchHash() { setBranchHash() {
return Service.getBranch() return Service.getBranch()
.then((data) => { .then((data) => {
...@@ -72,10 +59,6 @@ const RepoStore = { ...@@ -72,10 +59,6 @@ const RepoStore = {
RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit; RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit;
}, },
addFilesToDirectory(inDirectory, currentList, newList) {
RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList);
},
toggleRawPreview() { toggleRawPreview() {
RepoStore.activeFile.raw = !RepoStore.activeFile.raw; RepoStore.activeFile.raw = !RepoStore.activeFile.raw;
RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source'; RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source';
...@@ -129,30 +112,6 @@ const RepoStore = { ...@@ -129,30 +112,6 @@ const RepoStore = {
RepoStore.activeFileLabel = 'Display source'; 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) { removeFromOpenedFiles(file) {
if (file.type === 'tree') return; if (file.type === 'tree') return;
let foundIndex; let foundIndex;
...@@ -186,6 +145,7 @@ const RepoStore = { ...@@ -186,6 +145,7 @@ const RepoStore = {
if (openedFilesAlreadyExists) return; if (openedFilesAlreadyExists) return;
openFile.changed = false; openFile.changed = false;
openFile.active = true;
RepoStore.openedFiles.push(openFile); RepoStore.openedFiles.push(openFile);
}, },
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
@import "framework/animations"; @import "framework/animations";
@import "framework/avatar"; @import "framework/avatar";
@import "framework/asciidoctor"; @import "framework/asciidoctor";
@import "framework/banner";
@import "framework/blocks"; @import "framework/blocks";
@import "framework/buttons"; @import "framework/buttons";
@import "framework/badges"; @import "framework/badges";
......
...@@ -198,6 +198,13 @@ a { ...@@ -198,6 +198,13 @@ a {
height: 12px; height: 12px;
} }
&.animation-container-right {
.skeleton-line-2 {
left: 0;
right: 150px;
}
}
&::before { &::before {
animation-duration: 1s; animation-duration: 1s;
animation-fill-mode: forwards; animation-fill-mode: forwards;
......
.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;
}
}
...@@ -10,6 +10,10 @@ ...@@ -10,6 +10,10 @@
border: 0; border: 0;
} }
&.file-holder-bottom-radius {
border-radius: 0 0 $border-radius-small $border-radius-small;
}
&.readme-holder { &.readme-holder {
margin: $gl-padding 0; margin: $gl-padding 0;
......
...@@ -281,6 +281,57 @@ ul.indent-list { ...@@ -281,6 +281,57 @@ ul.indent-list {
// Specific styles for tree 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 { .group-list-tree {
.folder-toggle-wrap { .folder-toggle-wrap {
float: left; float: left;
...@@ -293,7 +344,7 @@ ul.indent-list { ...@@ -293,7 +344,7 @@ ul.indent-list {
} }
.folder-caret, .folder-caret,
.folder-icon { .item-type-icon {
display: inline-block; display: inline-block;
} }
...@@ -301,11 +352,11 @@ ul.indent-list { ...@@ -301,11 +352,11 @@ ul.indent-list {
width: 15px; width: 15px;
} }
.folder-icon { .item-type-icon {
width: 20px; width: 20px;
} }
> .group-row:not(.has-subgroups) { > .group-row:not(.has-children) {
.folder-caret .fa { .folder-caret .fa {
opacity: 0; opacity: 0;
} }
...@@ -351,12 +402,23 @@ ul.indent-list { ...@@ -351,12 +402,23 @@ ul.indent-list {
top: 30px; top: 30px;
bottom: 0; bottom: 0;
} }
&.being-removed {
opacity: 0.5;
}
} }
} }
.group-row { .group-row {
padding: 0; padding: 0;
border: none;
&.has-children {
border-top: none;
}
&:first-child {
border-top: 1px solid $white-normal;
}
&:last-of-type { &:last-of-type {
.group-row-contents:not(:hover) { .group-row-contents:not(:hover) {
...@@ -379,6 +441,25 @@ ul.indent-list { ...@@ -379,6 +441,25 @@ ul.indent-list {
.avatar-container > a { .avatar-container > a {
width: 100%; 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;
}
} }
} }
......
...@@ -466,7 +466,7 @@ $new-sidebar-collapsed-width: 50px; ...@@ -466,7 +466,7 @@ $new-sidebar-collapsed-width: 50px;
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
+ .breadcrumbs-links { + .breadcrumbs-links {
padding-left: 17px; padding-left: $gl-padding;
border-left: 1px solid $gl-text-color-quaternary; border-left: 1px solid $gl-text-color-quaternary;
} }
} }
......
...@@ -233,6 +233,7 @@ $container-text-max-width: 540px; ...@@ -233,6 +233,7 @@ $container-text-max-width: 540px;
$gl-avatar-size: 40px; $gl-avatar-size: 40px;
$error-exclamation-point: $red-500; $error-exclamation-point: $red-500;
$border-radius-default: 4px; $border-radius-default: 4px;
$border-radius-small: 2px;
$settings-icon-size: 18px; $settings-icon-size: 18px;
$provider-btn-not-active-color: $blue-500; $provider-btn-not-active-color: $blue-500;
$link-underline-blue: $blue-500; $link-underline-blue: $blue-500;
......
...@@ -4,6 +4,6 @@ ...@@ -4,6 +4,6 @@
} }
.alert-block { .alert-block {
margin-bottom: 20px; margin-bottom: 10px;
} }
} }
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
border-right: 1px solid $border-color; border-right: 1px solid $border-color;
border-left: 1px solid $border-color; border-left: 1px solid $border-color;
border-bottom: none; border-bottom: none;
border-radius: 2px; border-radius: $border-radius-small $border-radius-small 0 0;
background: $gray-normal; background: $gray-normal;
} }
......
...@@ -26,14 +26,117 @@ ...@@ -26,14 +26,117 @@
} }
} }
.groups-header { .group-nav-container .nav-controls {
@media (min-width: $screen-sm-min) { display: flex;
.nav-links { align-items: flex-start;
width: 35%; padding: $gl-padding-top 0;
border-bottom: 1px solid $border-color;
.group-filter-form {
flex: 1;
} }
.nav-controls { .dropdown-menu-align-right {
width: 65%; margin-top: 0;
}
.new-project-subgroup {
.dropdown-primary {
min-width: 115px;
}
.dropdown-toggle {
.dropdown-btn-icon {
pointer-events: none;
color: inherit;
margin-left: 0;
}
}
.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;
}
} }
} }
} }
......
...@@ -72,12 +72,22 @@ ...@@ -72,12 +72,22 @@
} }
} }
.title-container {
display: flex;
}
.title { .title {
padding: 0; padding: 0;
margin-bottom: 16px; margin-bottom: 16px;
border-bottom: none; border-bottom: none;
} }
.btn-edit {
margin-left: auto;
// Set height to match title height
height: 2em;
}
// Border around images in issue and MR descriptions. // Border around images in issue and MR descriptions.
.description img:not(.emoji) { .description img:not(.emoji) {
border: 1px solid $white-normal; border: 1px solid $white-normal;
......
...@@ -531,14 +531,13 @@ ul.notes { ...@@ -531,14 +531,13 @@ ul.notes {
padding: 0; padding: 0;
min-width: 16px; min-width: 16px;
color: $gray-darkest; color: $gray-darkest;
fill: $gray-darkest;
.fa { .fa {
position: relative; position: relative;
font-size: 16px; font-size: 16px;
} }
svg { svg {
height: 16px; height: 16px;
width: 16px; width: 16px;
...@@ -566,6 +565,7 @@ ul.notes { ...@@ -566,6 +565,7 @@ ul.notes {
.link-highlight { .link-highlight {
color: $gl-link-color; color: $gl-link-color;
fill: $gl-link-color;
svg { svg {
fill: $gl-link-color; fill: $gl-link-color;
......
...@@ -153,28 +153,13 @@ ...@@ -153,28 +153,13 @@
overflow-x: auto; overflow-x: auto;
li { li {
animation: swipeRightAppear ease-in 0.1s; position: relative;
animation-iteration-count: 1;
transform-origin: 0% 50%;
list-style-type: none;
background: $gray-normal; background: $gray-normal;
display: inline-block;
padding: #{$gl-padding / 2} $gl-padding; padding: #{$gl-padding / 2} $gl-padding;
border-right: 1px solid $white-dark; border-right: 1px solid $white-dark;
border-bottom: 1px solid $white-dark; border-bottom: 1px solid $white-dark;
white-space: nowrap;
cursor: pointer; cursor: pointer;
&.remove {
animation: swipeRightDissapear ease-in 0.1s;
animation-iteration-count: 1;
transform-origin: 0% 50%;
a {
width: 0;
}
}
&.active { &.active {
background: $white-light; background: $white-light;
border-bottom: none; border-bottom: none;
...@@ -182,17 +167,21 @@ ...@@ -182,17 +167,21 @@
a { a {
@include str-truncated(100px); @include str-truncated(100px);
color: $black; color: $gl-text-color;
vertical-align: middle; vertical-align: middle;
text-decoration: none; text-decoration: none;
margin-right: 12px; 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 { .close-icon:hover {
...@@ -201,9 +190,6 @@ ...@@ -201,9 +190,6 @@
.close-icon, .close-icon,
.unsaved-icon { .unsaved-icon {
float: right;
margin-top: 3px;
margin-left: 15px;
color: $gray-darkest; color: $gray-darkest;
} }
...@@ -222,9 +208,7 @@ ...@@ -222,9 +208,7 @@
#repo-file-buttons { #repo-file-buttons {
background-color: $white-light; background-color: $white-light;
border-bottom: 1px solid $white-normal;
padding: 5px 10px; padding: 5px 10px;
position: relative;
border-top: 1px solid $white-normal; border-top: 1px solid $white-normal;
} }
...@@ -287,37 +271,23 @@ ...@@ -287,37 +271,23 @@
overflow: auto; overflow: auto;
} }
table { .table {
margin-bottom: 0; margin-bottom: 0;
} }
tr { tr {
animation: fadein 0.5s; .repo-file-options {
cursor: pointer; padding: 2px 16px;
&.repo-file-options td {
padding: 0;
border-top: none;
background: $gray-light;
width: 100%; width: 100%;
display: inline-block;
&:first-child {
border-top-left-radius: 2px;
} }
.title { .title {
display: inline-block;
font-size: 10px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
font-weight: $gl-font-weight-bold;
color: $gray-darkest;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
vertical-align: middle; vertical-align: middle;
padding: 2px 16px;
}
} }
.file-icon { .file-icon {
...@@ -329,11 +299,13 @@ ...@@ -329,11 +299,13 @@
} }
} }
.file {
cursor: pointer;
}
a { a {
@include str-truncated(250px); @include str-truncated(250px);
color: $almost-black; color: $almost-black;
display: inline-block;
vertical-align: middle;
} }
} }
} }
......
module GroupTree
def render_group_tree(groups)
@groups = if params[:filter].present?
Gitlab::GroupHierarchy.new(groups.search(params[:filter]))
.base_and_ancestors
else
# Only show root groups if no parent-id is given
groups.where(parent_id: params[:parent_id])
end
@groups = @groups.with_selects_for_list(archived: params[:archived])
.sort(@sort = params[:sort])
.page(params[:page])
respond_to do |format|
format.html
format.json do
serializer = GroupChildSerializer.new(current_user: current_user)
.with_pagination(request, response)
serializer.expand_hierarchy if params[:filter].present?
render json: serializer.represent(@groups)
end
end
end
end
class Dashboard::GroupsController < Dashboard::ApplicationController class Dashboard::GroupsController < Dashboard::ApplicationController
def index include GroupTree
@sort = params[:sort] || 'created_desc'
@groups =
if params[:parent_id] && Group.supports_nested_groups?
parent = Group.find_by(id: params[:parent_id])
if can?(current_user, :read_group, parent)
GroupsFinder.new(current_user, parent: parent).execute
else
Group.none
end
else
current_user.groups
end
@groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present? def index
@groups = @groups.includes(:route) groups = GroupsFinder.new(current_user, all_available: false).execute
@groups = @groups.sort(@sort) render_group_tree(groups)
@groups = @groups.page(params[:page])
respond_to do |format|
format.html
format.json do
render json: GroupSerializer
.new(current_user: @current_user)
.with_pagination(request, response)
.represent(@groups)
end
end
end end
end end
class Explore::GroupsController < Explore::ApplicationController class Explore::GroupsController < Explore::ApplicationController
def index include GroupTree
@groups = GroupsFinder.new(current_user).execute
@groups = @groups.search(params[:filter_groups]) if params[:filter_groups].present?
@groups = @groups.sort(@sort = params[:sort])
@groups = @groups.page(params[:page])
respond_to do |format| def index
format.html render_group_tree GroupsFinder.new(current_user).execute
format.json do
render json: {
html: view_to_html_string("explore/groups/_groups", locals: { groups: @groups })
}
end
end
end end
end end
module Groups
class ChildrenController < Groups::ApplicationController
before_action :group
def index
parent = if params[:parent_id].present?
GroupFinder.new(current_user).execute(id: params[:parent_id])
else
@group
end
if parent.nil?
render_404
return
end
setup_children(parent)
respond_to do |format|
format.json do
serializer = GroupChildSerializer
.new(current_user: current_user)
.with_pagination(request, response)
serializer.expand_hierarchy(parent) if params[:filter].present?
render json: serializer.represent(@children)
end
end
end
protected
def setup_children(parent)
@children = GroupDescendantsFinder.new(current_user: current_user,
parent_group: parent,
params: params).execute
@children = @children.page(params[:page])
end
end
end
...@@ -46,15 +46,11 @@ class GroupsController < Groups::ApplicationController ...@@ -46,15 +46,11 @@ class GroupsController < Groups::ApplicationController
end end
def show def show
setup_projects
respond_to do |format| respond_to do |format|
format.html format.html do
@has_children = GroupDescendantsFinder.new(current_user: current_user,
format.json do parent_group: @group,
render json: { params: params).has_children?
html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
}
end end
format.atom do format.atom do
...@@ -64,13 +60,6 @@ class GroupsController < Groups::ApplicationController ...@@ -64,13 +60,6 @@ class GroupsController < Groups::ApplicationController
end end
end end
def subgroups
return not_found unless Group.supports_nested_groups?
@nested_groups = GroupsFinder.new(current_user, parent: group).execute
@nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present?
end
def activity def activity
respond_to do |format| respond_to do |format|
format.html format.html
...@@ -107,20 +96,6 @@ class GroupsController < Groups::ApplicationController ...@@ -107,20 +96,6 @@ class GroupsController < Groups::ApplicationController
protected protected
def setup_projects
set_non_archived_param
params[:sort] ||= 'latest_activity_desc'
@sort = params[:sort]
options = {}
options[:only_owned] = true if params[:shared] == '0'
options[:only_shared] = true if params[:shared] == '1'
@projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user).execute
@projects = @projects.includes(:namespace)
@projects = @projects.page(params[:page]) if params[:name].blank?
end
def authorize_create_group! def authorize_create_group!
allowed = if params[:parent_id].present? allowed = if params[:parent_id].present?
parent = Group.find_by(id: params[:parent_id]) parent = Group.find_by(id: params[:parent_id])
......
...@@ -36,6 +36,7 @@ class Projects::TreeController < Projects::ApplicationController ...@@ -36,6 +36,7 @@ class Projects::TreeController < Projects::ApplicationController
format.json do format.json do
page_title @path.presence || _("Files"), @ref, @project.name_with_namespace page_title @path.presence || _("Files"), @ref, @project.name_with_namespace
response.header['is-root'] = @path.empty?
# n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261 # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261
Gitlab::GitalyClient.allow_n_plus_1_calls do Gitlab::GitalyClient.allow_n_plus_1_calls do
......
...@@ -126,7 +126,7 @@ class ProjectsController < Projects::ApplicationController ...@@ -126,7 +126,7 @@ class ProjectsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :remove_project, @project) return access_denied! unless can?(current_user, :remove_project, @project)
::Projects::DestroyService.new(@project, current_user, {}).async_execute ::Projects::DestroyService.new(@project, current_user, {}).async_execute
flash[:alert] = _("Project '%{project_name}' will be deleted.") % { project_name: @project.name_with_namespace } flash[:notice] = _("Project '%{project_name}' is in the process of being deleted.") % { project_name: @project.name_with_namespace }
redirect_to dashboard_projects_path, status: 302 redirect_to dashboard_projects_path, status: 302
rescue Projects::DestroyService::DestroyError => ex rescue Projects::DestroyService::DestroyError => ex
......
# GroupDescendantsFinder
#
# Used to find and filter all subgroups and projects of a passed parent group
# visible to a specified user.
#
# When passing a `filter` param, the search is performed over all nested levels
# of the `parent_group`. All ancestors for a search result are loaded
#
# Arguments:
# current_user: The user for which the children should be visible
# parent_group: The group to find children of
# params:
# Supports all params that the `ProjectsFinder` and `GroupProjectsFinder`
# support.
#
# filter: string - is aliased to `search` for consistency with the frontend
# archived: string - `only` or `true`.
# `non_archived` is passed to the `ProjectFinder`s if none
# was given.
class GroupDescendantsFinder
attr_reader :current_user, :parent_group, :params
def initialize(current_user: nil, parent_group:, params: {})
@current_user = current_user
@parent_group = parent_group
@params = params.reverse_merge(non_archived: params[:archived].blank?)
end
def execute
# The children array might be extended with the ancestors of projects when
# filtering. In that case, take the maximum so the array does not get limited
# Otherwise, allow paginating through all results
#
all_required_elements = children
all_required_elements |= ancestors_for_projects if params[:filter]
total_count = [all_required_elements.size, paginator.total_count].max
Kaminari.paginate_array(all_required_elements, total_count: total_count)
end
def has_children?
projects.any? || subgroups.any?
end
private
def children
@children ||= paginator.paginate(params[:page])
end
def paginator
@paginator ||= Gitlab::MultiCollectionPaginator.new(subgroups, projects,
per_page: params[:per_page])
end
def direct_child_groups
GroupsFinder.new(current_user,
parent: parent_group,
all_available: true).execute
end
def all_visible_descendant_groups
groups_table = Group.arel_table
visible_to_user = groups_table[:visibility_level]
.in(Gitlab::VisibilityLevel.levels_for_user(current_user))
if current_user
authorized_groups = GroupsFinder.new(current_user,
all_available: false)
.execute.as('authorized')
authorized_to_user = groups_table.project(1).from(authorized_groups)
.where(authorized_groups[:id].eq(groups_table[:id]))
.exists
visible_to_user = visible_to_user.or(authorized_to_user)
end
hierarchy_for_parent
.descendants
.where(visible_to_user)
end
def subgroups_matching_filter
all_visible_descendant_groups
.search(params[:filter])
end
# When filtering we want all to preload all the ancestors upto the specified
# parent group.
#
# - root
# - subgroup
# - nested-group
# - project
#
# So when searching 'project', on the 'subgroup' page we want to preload
# 'nested-group' but not 'subgroup' or 'root'
def ancestors_for_groups(base_for_ancestors)
Gitlab::GroupHierarchy.new(base_for_ancestors)
.base_and_ancestors(upto: parent_group.id)
end
def ancestors_for_projects
projects_to_load_ancestors_of = projects.where.not(namespace: parent_group)
groups_to_load_ancestors_of = Group.where(id: projects_to_load_ancestors_of.select(:namespace_id))
ancestors_for_groups(groups_to_load_ancestors_of)
.with_selects_for_list(archived: params[:archived])
end
def subgroups
return Group.none unless Group.supports_nested_groups?
# When filtering subgroups, we want to find all matches withing the tree of
# descendants to show to the user
groups = if params[:filter]
ancestors_for_groups(subgroups_matching_filter)
else
direct_child_groups
end
groups.with_selects_for_list(archived: params[:archived]).order_by(sort)
end
def direct_child_projects
GroupProjectsFinder.new(group: parent_group, current_user: current_user, params: params)
.execute
end
# Finds all projects nested under `parent_group` or any of its descendant
# groups
def projects_matching_filter
projects_nested_in_group = Project.where(namespace_id: hierarchy_for_parent.base_and_descendants.select(:id))
params_with_search = params.merge(search: params[:filter])
ProjectsFinder.new(params: params_with_search,
current_user: current_user,
project_ids_relation: projects_nested_in_group).execute
end
def projects
projects = if params[:filter]
projects_matching_filter
else
direct_child_projects
end
projects.with_route.order_by(sort)
end
def sort
params.fetch(:sort, 'id_asc')
end
def hierarchy_for_parent
@hierarchy ||= Gitlab::GroupHierarchy.new(Group.where(id: parent_group.id))
end
end
...@@ -34,7 +34,6 @@ class GroupProjectsFinder < ProjectsFinder ...@@ -34,7 +34,6 @@ class GroupProjectsFinder < ProjectsFinder
else else
collection_without_user collection_without_user
end end
union(projects) union(projects)
end end
......
...@@ -108,6 +108,34 @@ module ApplicationSettingsHelper ...@@ -108,6 +108,34 @@ module ApplicationSettingsHelper
options_for_select(Sidekiq::Queue.all.map(&:name), @application_setting.sidekiq_throttling_queues) options_for_select(Sidekiq::Queue.all.map(&:name), @application_setting.sidekiq_throttling_queues)
end end
def circuitbreaker_failure_count_help_text
health_link = link_to(s_('AdminHealthPageLink|health page'), admin_health_check_path)
api_link = link_to(s_('CircuitBreakerApiLink|circuitbreaker api'), help_page_path("api/repository_storage_health"))
message = _("The number of failures of after which GitLab will completely "\
"prevent access to the storage. The number of failures can be "\
"reset in the admin interface: %{link_to_health_page} or using "\
"the %{api_documentation_link}.")
message = message % { link_to_health_page: health_link, api_documentation_link: api_link }
message.html_safe
end
def circuitbreaker_failure_wait_time_help_text
_("When access to a storage fails. GitLab will prevent access to the "\
"storage for the time specified here. This allows the filesystem to "\
"recover. Repositories on failing shards are temporarly unavailable")
end
def circuitbreaker_failure_reset_time_help_text
_("The time in seconds GitLab will keep failure information. When no "\
"failures occur during this time, information about the mount is reset.")
end
def circuitbreaker_storage_timeout_help_text
_("The time in seconds GitLab will try to access storage. After this time a "\
"timeout error will be raised.")
end
def visible_attributes def visible_attributes
[ [
:admin_notification_email, :admin_notification_email,
...@@ -116,6 +144,10 @@ module ApplicationSettingsHelper ...@@ -116,6 +144,10 @@ module ApplicationSettingsHelper
:akismet_api_key, :akismet_api_key,
:akismet_enabled, :akismet_enabled,
:auto_devops_enabled, :auto_devops_enabled,
:circuitbreaker_failure_count_threshold,
:circuitbreaker_failure_reset_time,
:circuitbreaker_failure_wait_time,
:circuitbreaker_storage_timeout,
:clientside_sentry_dsn, :clientside_sentry_dsn,
:clientside_sentry_enabled, :clientside_sentry_enabled,
:container_registry_token_expire_delay, :container_registry_token_expire_delay,
......
...@@ -42,6 +42,17 @@ module SortingHelper ...@@ -42,6 +42,17 @@ module SortingHelper
options options
end end
def groups_sort_options_hash
options = {
sort_value_recently_created => sort_title_recently_created,
sort_value_oldest_created => sort_title_oldest_created,
sort_value_recently_updated => sort_title_recently_updated,
sort_value_oldest_updated => sort_title_oldest_updated
}
options
end
def member_sort_options_hash def member_sort_options_hash
{ {
sort_value_access_level_asc => sort_title_access_level_asc, sort_value_access_level_asc => sort_title_access_level_asc,
......
...@@ -33,6 +33,8 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -33,6 +33,8 @@ class ApplicationSetting < ActiveRecord::Base
attr_accessor :domain_whitelist_raw, :domain_blacklist_raw attr_accessor :domain_whitelist_raw, :domain_blacklist_raw
default_value_for :id, 1
validates :uuid, presence: true validates :uuid, presence: true
validates :session_expire_delay, validates :session_expire_delay,
...@@ -151,6 +153,13 @@ class ApplicationSetting < ActiveRecord::Base ...@@ -151,6 +153,13 @@ class ApplicationSetting < ActiveRecord::Base
presence: true, presence: true,
numericality: { greater_than_or_equal_to: 0 } numericality: { greater_than_or_equal_to: 0 }
validates :circuitbreaker_failure_count_threshold,
:circuitbreaker_failure_wait_time,
:circuitbreaker_failure_reset_time,
:circuitbreaker_storage_timeout,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
SUPPORTED_KEY_TYPES.each do |type| SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end end
......
module GroupDescendant
# Returns the hierarchy of a project or group in the from of a hash upto a
# given top.
#
# > project.hierarchy
# => { parent_group => { child_group => project } }
def hierarchy(hierarchy_top = nil, preloaded = nil)
preloaded ||= ancestors_upto(hierarchy_top)
expand_hierarchy_for_child(self, self, hierarchy_top, preloaded)
end
# Merges all hierarchies of the given groups or projects into an array of
# hashes. All ancestors need to be loaded into the given `descendants` to avoid
# queries down the line.
#
# > GroupDescendant.merge_hierarchy([project, child_group, child_group2, parent])
# => { parent => [{ child_group => project}, child_group2] }
def self.build_hierarchy(descendants, hierarchy_top = nil)
descendants = Array.wrap(descendants).uniq
return [] if descendants.empty?
unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) }
raise ArgumentError.new('element is not a hierarchy')
end
all_hierarchies = descendants.map do |descendant|
descendant.hierarchy(hierarchy_top, descendants)
end
Gitlab::Utils::MergeHash.merge(all_hierarchies)
end
private
def expand_hierarchy_for_child(child, hierarchy, hierarchy_top, preloaded)
parent = hierarchy_top if hierarchy_top && child.parent_id == hierarchy_top.id
parent ||= preloaded.detect { |possible_parent| possible_parent.is_a?(Group) && possible_parent.id == child.parent_id }
if parent.nil? && !child.parent_id.nil?
raise ArgumentError.new('parent was not preloaded')
end
if parent.nil? && hierarchy_top.present?
raise ArgumentError.new('specified top is not part of the tree')
end
if parent && parent != hierarchy_top
expand_hierarchy_for_child(parent,
{ parent => hierarchy },
hierarchy_top,
preloaded)
else
hierarchy
end
end
end
module LoadedInGroupList
extend ActiveSupport::Concern
module ClassMethods
def with_counts(archived:)
selects_including_counts = [
'namespaces.*',
"(#{project_count_sql(archived).to_sql}) AS preloaded_project_count",
"(#{member_count_sql.to_sql}) AS preloaded_member_count",
"(#{subgroup_count_sql.to_sql}) AS preloaded_subgroup_count"
]
select(selects_including_counts)
end
def with_selects_for_list(archived: nil)
with_route.with_counts(archived: archived)
end
private
def project_count_sql(archived = nil)
projects = Project.arel_table
namespaces = Namespace.arel_table
base_count = projects.project(Arel.star.count.as('preloaded_project_count'))
.where(projects[:namespace_id].eq(namespaces[:id]))
if archived == 'only'
base_count.where(projects[:archived].eq(true))
elsif Gitlab::Utils.to_boolean(archived)
base_count
else
base_count.where(projects[:archived].not_eq(true))
end
end
def subgroup_count_sql
namespaces = Namespace.arel_table
children = namespaces.alias('children')
namespaces.project(Arel.star.count.as('preloaded_subgroup_count'))
.from(children)
.where(children[:parent_id].eq(namespaces[:id]))
end
def member_count_sql
members = Member.arel_table
namespaces = Namespace.arel_table
members.project(Arel.star.count.as('preloaded_member_count'))
.where(members[:source_type].eq(Namespace.name))
.where(members[:source_id].eq(namespaces[:id]))
.where(members[:requested_at].eq(nil))
end
end
def children_count
@children_count ||= project_count + subgroup_count
end
def project_count
@project_count ||= try(:preloaded_project_count) || projects.non_archived.count
end
def subgroup_count
@subgroup_count ||= try(:preloaded_subgroup_count) || children.count
end
def member_count
@member_count ||= try(:preloaded_member_count) || users.count
end
end
...@@ -6,6 +6,8 @@ class Group < Namespace ...@@ -6,6 +6,8 @@ class Group < Namespace
include Avatarable include Avatarable
include Referable include Referable
include SelectForProjectAuthorization include SelectForProjectAuthorization
include LoadedInGroupList
include GroupDescendant
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members alias_method :members, :group_members
......
...@@ -162,6 +162,13 @@ class Namespace < ActiveRecord::Base ...@@ -162,6 +162,13 @@ class Namespace < ActiveRecord::Base
.base_and_ancestors .base_and_ancestors
end end
# returns all ancestors upto but excluding the the given namespace
# when no namespace is given, all ancestors upto the top are returned
def ancestors_upto(top = nil)
Gitlab::GroupHierarchy.new(self.class.where(id: id))
.ancestors(upto: top)
end
def self_and_ancestors def self_and_ancestors
return self.class.where(id: id) unless parent_id return self.class.where(id: id) unless parent_id
......
...@@ -17,6 +17,7 @@ class Project < ActiveRecord::Base ...@@ -17,6 +17,7 @@ class Project < ActiveRecord::Base
include ProjectFeaturesCompatibility include ProjectFeaturesCompatibility
include SelectForProjectAuthorization include SelectForProjectAuthorization
include Routable include Routable
include GroupDescendant
extend Gitlab::ConfigHelper extend Gitlab::ConfigHelper
extend Gitlab::CurrentSettings extend Gitlab::CurrentSettings
...@@ -81,6 +82,8 @@ class Project < ActiveRecord::Base ...@@ -81,6 +82,8 @@ class Project < ActiveRecord::Base
belongs_to :creator, class_name: 'User' belongs_to :creator, class_name: 'User'
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id' belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
belongs_to :namespace belongs_to :namespace
alias_method :parent, :namespace
alias_attribute :parent_id, :namespace_id
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
has_many :boards, before_add: :validate_board_limit has_many :boards, before_add: :validate_board_limit
...@@ -479,6 +482,13 @@ class Project < ActiveRecord::Base ...@@ -479,6 +482,13 @@ class Project < ActiveRecord::Base
end end
end end
# returns all ancestor-groups upto but excluding the given namespace
# when no namespace is given, all ancestors upto the top are returned
def ancestors_upto(top = nil)
Gitlab::GroupHierarchy.new(Group.where(id: namespace_id))
.base_and_ancestors(upto: top)
end
def lfs_enabled? def lfs_enabled?
return namespace.lfs_enabled? if self[:lfs_enabled].nil? return namespace.lfs_enabled? if self[:lfs_enabled].nil?
...@@ -1262,7 +1272,7 @@ class Project < ActiveRecord::Base ...@@ -1262,7 +1272,7 @@ class Project < ActiveRecord::Base
# self.forked_from_project will be nil before the project is saved, so # self.forked_from_project will be nil before the project is saved, so
# we need to go through the relation # we need to go through the relation
original_project = forked_project_link.forked_from_project original_project = forked_project_link&.forked_from_project
return true unless original_project return true unless original_project
level <= original_project.visibility_level level <= original_project.visibility_level
...@@ -1549,10 +1559,6 @@ class Project < ActiveRecord::Base ...@@ -1549,10 +1559,6 @@ class Project < ActiveRecord::Base
map.public_path_for_source_path(path) map.public_path_for_source_path(path)
end end
def parent
namespace
end
def parent_changed? def parent_changed?
namespace_id_changed? namespace_id_changed?
end end
......
class BaseSerializer class BaseSerializer
def initialize(parameters = {}) attr_reader :params
@request = EntityRequest.new(parameters)
def initialize(params = {})
@params = params
@request = EntityRequest.new(params)
end end
def represent(resource, opts = {}, entity_class = nil) def represent(resource, opts = {}, entity_class = nil)
......
module WithPagination
attr_accessor :paginator
def with_pagination(request, response)
tap { self.paginator = Gitlab::Serializer::Pagination.new(request, response) }
end
def paginated?
paginator.present?
end
# super is `BaseSerializer#represent` here.
#
# we shouldn't try to paginate single resources
def represent(resource, opts = {})
if paginated? && resource.respond_to?(:page)
super(@paginator.paginate(resource), opts)
else
super(resource, opts)
end
end
end
class EnvironmentSerializer < BaseSerializer class EnvironmentSerializer < BaseSerializer
include WithPagination
Item = Struct.new(:name, :size, :latest) Item = Struct.new(:name, :size, :latest)
entity EnvironmentEntity entity EnvironmentEntity
...@@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer ...@@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer
tap { @itemize = true } tap { @itemize = true }
end end
def with_pagination(request, response)
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
end
def itemized? def itemized?
@itemize @itemize
end end
def paginated?
@paginator.present?
end
def represent(resource, opts = {}) def represent(resource, opts = {})
if itemized? if itemized?
itemize(resource).map do |item| itemize(resource).map do |item|
...@@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer ...@@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer
latest: super(item.latest, opts) } latest: super(item.latest, opts) }
end end
else else
resource = @paginator.paginate(resource) if paginated?
super(resource, opts) super(resource, opts)
end end
end end
......
class GroupChildEntity < Grape::Entity
include ActionView::Helpers::NumberHelper
include RequestAwareEntity
expose :id, :name, :description, :visibility, :full_name,
:created_at, :updated_at, :avatar_url
expose :type do |instance|
type
end
expose :can_edit do |instance|
return false unless request.respond_to?(:current_user)
can?(request.current_user, "admin_#{type}", instance)
end
expose :edit_path do |instance|
# We know `type` will be one either `project` or `group`.
# The `edit_polymorphic_path` helper would try to call the path helper
# with a plural: `edit_groups_path(instance)` or `edit_projects_path(instance)`
# while our methods are `edit_group_path` or `edit_group_path`
public_send("edit_#{type}_path", instance) # rubocop:disable GitlabSecurity/PublicSend
end
expose :relative_path do |instance|
polymorphic_path(instance)
end
expose :permission do |instance|
membership&.human_access
end
# Project only attributes
expose :star_count,
if: lambda { |_instance, _options| project? }
# Group only attributes
expose :children_count, :parent_id, :project_count, :subgroup_count,
unless: lambda { |_instance, _options| project? }
expose :leave_path, unless: lambda { |_instance, _options| project? } do |instance|
leave_group_members_path(instance)
end
expose :can_leave, unless: lambda { |_instance, _options| project? } do |instance|
if membership
can?(request.current_user, :destroy_group_member, membership)
else
false
end
end
expose :number_projects_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
number_with_delimiter(instance.project_count)
end
expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
number_with_delimiter(instance.member_count)
end
private
def membership
return unless request.current_user
@membership ||= request.current_user.members.find_by(source: object)
end
def project?
object.is_a?(Project)
end
def type
object.class.name.downcase
end
end
class GroupChildSerializer < BaseSerializer
include WithPagination
attr_reader :hierarchy_root, :should_expand_hierarchy
entity GroupChildEntity
def expand_hierarchy(hierarchy_root = nil)
@hierarchy_root = hierarchy_root
@should_expand_hierarchy = true
self
end
def represent(resource, opts = {}, entity_class = nil)
if should_expand_hierarchy
paginator.paginate(resource) if paginated?
represent_hierarchies(resource, opts)
else
super(resource, opts)
end
end
protected
def represent_hierarchies(children, opts)
if children.is_a?(GroupDescendant)
represent_hierarchy(children.hierarchy(hierarchy_root), opts).first
else
hierarchies = GroupDescendant.build_hierarchy(children, hierarchy_root)
# When an array was passed, we always want to represent an array.
# Even if the hierarchy only contains one element
represent_hierarchy(Array.wrap(hierarchies), opts)
end
end
def represent_hierarchy(hierarchy, opts)
serializer = self.class.new(params)
if hierarchy.is_a?(Hash)
hierarchy.map do |parent, children|
serializer.represent(parent, opts)
.merge(children: Array.wrap(serializer.represent_hierarchy(children, opts)))
end
elsif hierarchy.is_a?(Array)
hierarchy.flat_map { |child| serializer.represent_hierarchy(child, opts) }
else
serializer.represent(hierarchy, opts)
end
end
end
class GroupSerializer < BaseSerializer class GroupSerializer < BaseSerializer
entity GroupEntity include WithPagination
def with_pagination(request, response)
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
end
def paginated? entity GroupEntity
@paginator.present?
end
def represent(resource, opts = {})
if paginated?
super(@paginator.paginate(resource), opts)
else
super(resource, opts)
end
end
end end
class PipelineSerializer < BaseSerializer class PipelineSerializer < BaseSerializer
include WithPagination
InvalidResourceError = Class.new(StandardError) InvalidResourceError = Class.new(StandardError)
entity PipelineDetailsEntity entity PipelineDetailsEntity
def with_pagination(request, response)
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
end
def paginated?
@paginator.present?
end
def represent(resource, opts = {}) def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation) if resource.is_a?(ActiveRecord::Relation)
......
...@@ -15,8 +15,8 @@ module Projects ...@@ -15,8 +15,8 @@ module Projects
refresh_forks_count(@project.forked_from_project) refresh_forks_count(@project.forked_from_project)
@project.forked_project_link.destroy
@project.fork_network_member.destroy @project.fork_network_member.destroy
@project.forked_project_link.destroy
end end
def refresh_forks_count(project) def refresh_forks_count(project)
......
...@@ -530,6 +530,32 @@ ...@@ -530,6 +530,32 @@
= succeed "." do = succeed "." do
= link_to "repository storages documentation", help_page_path("administration/repository_storages") = link_to "repository storages documentation", help_page_path("administration/repository_storages")
%fieldset
%legend Git Storage Circuitbreaker settings
.form-group
= f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :circuitbreaker_failure_count_threshold, class: 'form-control'
.help-block
= circuitbreaker_failure_count_help_text
.form-group
= f.label :circuitbreaker_failure_wait_time, _('Seconds to wait after a storage failure'), class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :circuitbreaker_failure_wait_time, class: 'form-control'
.help-block
= circuitbreaker_failure_wait_time_help_text
.form-group
= f.label :circuitbreaker_failure_reset_time, _('Seconds before reseting failure information'), class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :circuitbreaker_failure_reset_time, class: 'form-control'
.help-block
= circuitbreaker_failure_reset_time_help_text
.form-group
= f.label :circuitbreaker_storage_timeout, _('Seconds to wait for a storage access attempt'), class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :circuitbreaker_storage_timeout, class: 'form-control'
.help-block
= circuitbreaker_storage_timeout_help_text
%fieldset %fieldset
%legend Repository Checks %legend Repository Checks
......
.top-area .top-area
%ul.nav-links %ul.nav-links
= nav_link(page: dashboard_groups_path) do = nav_link(page: dashboard_groups_path) do
= link_to dashboard_groups_path, title: 'Your groups' do = link_to dashboard_groups_path, title: _("Your groups") do
Your groups Your groups
= nav_link(page: explore_groups_path) do = nav_link(page: explore_groups_path) do
= link_to explore_groups_path, title: 'Explore public groups' do = link_to explore_groups_path, title: _("Explore public groups") do
Explore public groups Explore public groups
.nav-controls .nav-controls
= render 'shared/groups/search_form' = render 'shared/groups/search_form'
= render 'shared/groups/dropdown' = render 'shared/groups/dropdown'
- if current_user.can_create_group? - if current_user.can_create_group?
= link_to "New group", new_group_path, class: "btn btn-new" = link_to _("New group"), new_group_path, class: "btn btn-new"
.groups-empty-state
= custom_icon("icon_empty_groups")
.text-content
%h4 A group is a collection of several projects.
%p If you organize your projects under a group, it works like a folder.
%p You can manage your group member’s permissions and access to each project in the group.
.js-groups-list-holder .js-groups-list-holder
#dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } } #js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
.groups-list-loading
= icon('spinner spin', 'v-show' => 'isLoading')
%template{ 'v-if' => '!isLoading && isEmpty' }
%div{ 'v-cloak' => true }
= render 'empty_state'
%template{ 'v-else-if' => '!isLoading && !isEmpty' }
%groups-component{ ':groups' => 'state.groups', ':page-info' => 'state.pageInfo' }
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
= webpack_bundle_tag 'common_vue' = webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'groups' = webpack_bundle_tag 'groups'
- if @groups.empty? - if params[:filter].blank? && @groups.empty?
= render 'empty_state' = render 'shared/groups/empty_state'
- else - else
= render 'groups' = render 'groups'
.js-groups-list-holder .js-groups-list-holder
%ul.content-list #js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
- @groups.each do |group|
= render 'shared/groups/group', group: group
= paginate @groups, theme: 'gitlab'
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
- page_title "Groups" - page_title "Groups"
- header_title "Groups", dashboard_groups_path - header_title "Groups", dashboard_groups_path
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'groups'
- if current_user - if current_user
= render 'dashboard/groups_head' = render 'dashboard/groups_head'
- else - else
...@@ -17,7 +20,7 @@ ...@@ -17,7 +20,7 @@
%p Below you will find all the groups that are public. %p Below you will find all the groups that are public.
%p You can easily contribute to them by requesting to join these groups. %p You can easily contribute to them by requesting to join these groups.
- if @groups.present? - if params[:filter].blank? && @groups.empty?
= render 'groups'
- else
.nothing-here-block No public groups .nothing-here-block No public groups
- else
= render 'groups'
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'groups'
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'false', group_id: group.id, endpoint: group_children_path(group, format: :json), path: group_path(group), form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
%ul.nav-links
= nav_link(page: group_path(@group)) do
= link_to group_path(@group) do
Projects
- if Group.supports_nested_groups?
= nav_link(page: subgroups_group_path(@group)) do
= link_to subgroups_group_path(@group) do
Subgroups
- @no_container = true - @no_container = true
- breadcrumb_title "Details" - breadcrumb_title "Details"
- can_create_subgroups = can?(current_user, :create_subgroup, @group)
= content_for :meta_tags do = content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity") = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
...@@ -7,13 +8,38 @@ ...@@ -7,13 +8,38 @@
= render 'groups/home_panel' = render 'groups/home_panel'
.groups-header{ class: container_class } .groups-header{ class: container_class }
.top-area .group-nav-container
= render 'groups/show_nav' .nav-controls.clearfix
.nav-controls = render "shared/groups/search_form"
= render 'shared/projects/search_form' = render "shared/groups/dropdown", show_archive_options: true
= render 'shared/projects/dropdown'
- if can? current_user, :create_projects, @group - if can? current_user, :create_projects, @group
= link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do - new_project_label = _("New project")
New Project - new_subgroup_label = _("New subgroup")
- if can_create_subgroups
.btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
%input.btn.btn-success.dropdown-primary.js-new-group-child{ type: "button", value: new_project_label, data: { action: "new-project" } }
%button.btn.btn-success.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown" } }
= icon("caret-down", class: "dropdown-btn-icon")
%ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
%li.droplab-item-selected{ role: "button", data: { value: "new-project", text: new_project_label } }
.menu-item
.icon-container
= icon("check", class: "list-item-checkmark")
.description
%strong= new_project_label
%span= s_("GroupsTree|Create a project in this group.")
%li.divider.droplap-item-ignore
%li{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
.menu-item
.icon-container
= icon("check", class: "list-item-checkmark")
.description
%strong= new_subgroup_label
%span= s_("GroupsTree|Create a subgroup in this group.")
- else
= link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
= render "projects", projects: @projects - if params[:filter].blank? && !@has_children
= render "shared/groups/empty_state"
- else
= render "children", children: @children, group: @group
- breadcrumb_title "Details"
- @no_container = true
= render 'groups/home_panel'
.groups-header{ class: container_class }
.top-area
= render 'groups/show_nav'
.nav-controls
= form_tag request.path, method: :get do |f|
= search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false
- if can?(current_user, :create_subgroup, @group)
= link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do
New Subgroup
- if @nested_groups.present?
%ul.content-list
= render partial: 'shared/groups/group', collection: @nested_groups, locals: { full_name: false }
- else
.nothing-here-block
There are no subgroups to show.
- action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create' - action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create'
.file-holder.file.append-bottom-default .file-holder-bottom-radius.file-holder.file.append-bottom-default
.js-file-title.file-title.clearfix{ data: { current_action: action } } .js-file-title.file-title.clearfix{ data: { current_action: action } }
.editor-ref .editor-ref
= icon('code-fork') = icon('code-fork')
......
- if can?(current_user, :admin_cluster, @cluster)
.append-bottom-20
%label.append-bottom-10
= s_('ClusterIntegration|Google Container Engine')
%p
- link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
= s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
.well.form-group
%label.text-danger
= s_('ClusterIntegration|Remove cluster integration')
%p
= s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.')
= link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"})
- @content_class = "limit-container-width" unless fluid_layout
- breadcrumb_title "Cluster" - breadcrumb_title "Cluster"
- page_title _("Cluster") - page_title _("Cluster")
- expanded = Rails.env.test?
- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) && @cluster.on_creation? - status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) && @cluster.on_creation?
.row.prepend-top-default.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path, .edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
toggle_status: @cluster.enabled? ? 'true': 'false', toggle_status: @cluster.enabled? ? 'true': 'false',
cluster_status: @cluster.status_name, cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason } } cluster_status_reason: @cluster.status_reason } }
.col-sm-4
= render 'sidebar' %section.settings
.col-sm-8 %h4= s_('ClusterIntegration|Enable cluster integration')
%label.append-bottom-10{ for: 'enable-cluster-integration' } .settings-content.expanded
= s_('ClusterIntegration|Enable cluster integration')
.hidden.js-cluster-error.alert.alert-danger.alert-block.append-bottom-10{ role: 'alert' }
= s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
%p.js-error-reason
.hidden.js-cluster-creating.alert.alert-info.alert-block.append-bottom-10{ role: 'alert' }
= s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
.hidden.js-cluster-success.alert.alert-success.alert-block.append-bottom-10{ role: 'alert' }
= s_('ClusterIntegration|Cluster was successfully created on Google Container Engine')
%p %p
- if @cluster.enabled? - if @cluster.enabled?
- if can?(current_user, :update_cluster, @cluster) - if can?(current_user, :update_cluster, @cluster)
...@@ -36,22 +49,14 @@ ...@@ -36,22 +49,14 @@
.form-group .form-group
= field.submit s_('ClusterIntegration|Save'), class: 'btn btn-success' = field.submit s_('ClusterIntegration|Save'), class: 'btn btn-success'
- if can?(current_user, :admin_cluster, @cluster) %section.settings#js-cluster-details
%label.append-bottom-10{ for: 'google-container-engine' } .settings-header
= s_('ClusterIntegration|Google Container Engine') %h4= s_('ClusterIntegration|Cluster details')
%p %button.btn.js-settings-toggle
- link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer') = expanded ? 'Collapse' : 'Expand'
= s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke } %p= s_('ClusterIntegration|See and edit the details for your cluster')
.hidden.js-cluster-error.alert.alert-danger.alert-block{ role: 'alert' } .settings-content.no-animate{ class: ('expanded' if expanded) }
= s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
%p.js-error-reason
.hidden.js-cluster-creating.alert.alert-info.alert-block{ role: 'alert' }
= s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
.hidden.js-cluster-success.alert.alert-success.alert-block{ role: 'alert' }
= s_('ClusterIntegration|Cluster was successfully created on Google Container Engine')
.form_group.append-bottom-20 .form_group.append-bottom-20
%label.append-bottom-10{ for: 'cluter-name' } %label.append-bottom-10{ for: 'cluter-name' }
...@@ -61,10 +66,11 @@ ...@@ -61,10 +66,11 @@
%span.input-group-addon.clipboard-addon %span.input-group-addon.clipboard-addon
= clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name')) = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
- if can?(current_user, :admin_cluster, @cluster) %section.settings#js-cluster-advanced-settings
.well.form_group .settings-header
%label.text-danger %h4= s_('ClusterIntegration|Advanced settings')
= s_('ClusterIntegration|Remove cluster integration') %button.btn.js-settings-toggle
%p = expanded ? 'Collapse' : 'Expand'
= s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.') %p= s_('ClusterIntegration|Manage Cluster integration on your GitLab project')
= link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"}) .settings-content.no-animate{ class: ('expanded' if expanded) }
= render 'advanced_settings'
...@@ -24,10 +24,15 @@ ...@@ -24,10 +24,15 @@
%p %p
You will need to be owner or have the master permission level for the initial push, as the master branch is automatically protected. You will need to be owner or have the master permission level for the initial push, as the master branch is automatically protected.
- if show_auto_devops_callout?(@project)
%p
- link = link_to(s_('AutoDevOps|Auto DevOps (Beta)'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'))
= s_('AutoDevOps|You can activate %{link_to_settings} for this project.').html_safe % { link_to_settings: link }
%p
= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
- if can?(current_user, :push_code, @project) - if can?(current_user, :push_code, @project)
%div{ class: container_class } %div{ class: container_class }
- if show_auto_devops_callout?(@project)
= render 'shared/auto_devops_callout'
.prepend-top-20 .prepend-top-20
.empty_wrapper .empty_wrapper
%h3.page-title-empty %h3.page-title-empty
......
...@@ -13,8 +13,6 @@ ...@@ -13,8 +13,6 @@
- if @project.merge_requests.exists? - if @project.merge_requests.exists?
%div{ class: container_class } %div{ class: container_class }
- if show_auto_devops_callout?(@project)
= render 'shared/auto_devops_callout'
.top-area .top-area
= render 'shared/issuable/nav', type: :merge_requests = render 'shared/issuable/nav', type: :merge_requests
.nav-controls .nav-controls
......
...@@ -54,6 +54,10 @@ ...@@ -54,6 +54,10 @@
= f.label :visibility_level, class: 'label-light' do #the label here seems wrong = f.label :visibility_level, class: 'label-light' do #the label here seems wrong
Import project from Import project from
.import-buttons .import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body' } }
= link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
= icon('gitlab', text: 'GitLab export')
%div %div
- if github_import_enabled? - if github_import_enabled?
= link_to new_import_github_path, class: 'btn import_github' do = link_to new_import_github_path, class: 'btn import_github' do
...@@ -87,10 +91,6 @@ ...@@ -87,10 +91,6 @@
- if git_import_enabled? - if git_import_enabled?
%button.btn.js-toggle-button.import_git{ type: "button" } %button.btn.js-toggle-button.import_git{ type: "button" }
= icon('git', text: 'Repo by URL') = icon('git', text: 'Repo by URL')
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body' } }
= link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
= icon('gitlab', text: 'GitLab export')
.col-lg-12 .col-lg-12
.js-toggle-content.hide.toggle-import-form .js-toggle-content.hide.toggle-import-form
%hr %hr
......
...@@ -2,8 +2,6 @@ ...@@ -2,8 +2,6 @@
- page_title "Pipelines" - page_title "Pipelines"
%div{ 'class' => container_class } %div{ 'class' => container_class }
- if show_auto_devops_callout?(@project)
= render 'shared/auto_devops_callout'
#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json), #pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
"help-page-path" => help_page_path('ci/quick_start/README'), "help-page-path" => help_page_path('ci/quick_start/README'),
"help-auto-devops-path" => help_page_path('topics/autodevops/index.md'), "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
......
...@@ -12,7 +12,5 @@ ...@@ -12,7 +12,5 @@
= webpack_bundle_tag 'repo' = webpack_bundle_tag 'repo'
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } %div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
- if show_auto_devops_callout?(@project) && !show_new_repo?
= render 'shared/auto_devops_callout'
= render 'projects/last_push' = render 'projects/last_push'
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
.user-callout{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } } .js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
.bordered-box.landing.content-block .banner-graphic
%button.btn.btn-default.close.js-close-callout{ type: 'button',
'aria-label' => 'Dismiss Auto DevOps box' }
= icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
.svg-container
= custom_icon('icon_autodevops') = custom_icon('icon_autodevops')
.user-callout-copy
%h4= s_('AutoDevOps|Auto DevOps (Beta)') .prepend-top-10.prepend-left-10.append-bottom-10
%p= s_('AutoDevOps|Auto DevOps can be activated for this project. It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.') %h5= s_('AutoDevOps|Auto DevOps (Beta)')
%p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
%p %p
- link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer') - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
= s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link } = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
.prepend-top-10
= link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn js-close-callout'
= link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'js-general-pipeline-settings'), class: 'btn btn-primary js-close-callout' %button.btn-transparent.banner-close.close.js-close-callout{ type: 'button',
'aria-label' => 'Dismiss Auto DevOps box' }
= icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
.dropdown.inline.js-group-filter-dropdown-wrap - show_archive_options = local_assigns.fetch(:show_archive_options, false)
- if @sort.present?
- default_sort_by = @sort
- else
- if params[:sort]
- default_sort_by = params[:sort]
- else
- default_sort_by = sort_value_recently_created
.dropdown.inline.js-group-filter-dropdown-wrap.append-right-10
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.dropdown-label %span.dropdown-label
- if @sort.present? = sort_options_hash[default_sort_by]
= sort_options_hash[@sort]
- else
= sort_title_recently_created
= icon('chevron-down') = icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-align-right %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
%li %li.dropdown-header
= link_to filter_groups_path(sort: sort_value_recently_created) do = _("Sort by")
= sort_title_recently_created - groups_sort_options_hash.each do |value, title|
= link_to filter_groups_path(sort: sort_value_oldest_created) do %li.js-filter-sort-order
= sort_title_oldest_created = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do
= link_to filter_groups_path(sort: sort_value_recently_updated) do = title
= sort_title_recently_updated - if show_archive_options
= link_to filter_groups_path(sort: sort_value_oldest_updated) do %li.divider
= sort_title_oldest_updated %li.js-filter-archived-projects
= link_to group_children_path(@group, archived: nil), class: ("is-active" unless params[:archived].present?) do
Hide archived projects
%li.js-filter-archived-projects
= link_to group_children_path(@group, archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
Show archived projects
%li.js-filter-archived-projects
= link_to group_children_path(@group, archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
Show archived projects only
.groups-empty-state
= custom_icon("icon_empty_groups")
.text-content
%h4= s_("GroupsEmptyState|A group is a collection of several projects.")
%p= s_("GroupsEmptyState|If you organize your projects under a group, it works like a folder.")
%p= s_("GroupsEmptyState|You can manage your group member’s permissions and access to each project in the group.")
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
= link_to edit_group_path(group), class: "btn" do = link_to edit_group_path(group), class: "btn" do
= icon('cogs') = icon('cogs')
= link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: s_("GroupsTree|Leave this group") do
= icon('sign-out') = icon('sign-out')
.stats .stats
......
...@@ -3,4 +3,4 @@ ...@@ -3,4 +3,4 @@
- groups.each_with_index do |group, i| - groups.each_with_index do |group, i|
= render "shared/groups/group", group: group = render "shared/groups/group", group: group
- else - else
.nothing-here-block No groups found .nothing-here-block= s_("GroupsEmptyState|No groups found")
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment