Merge branch 'master' into sh-security-fix-backports-master
This commit is contained in:
commit
891a9ce8b0
|
@ -7,7 +7,7 @@ class BoardService {
|
|||
this.boards = Vue.resource(`${boardsEndpoint}{/id}.json`, {}, {
|
||||
issues: {
|
||||
method: 'GET',
|
||||
url: `${gon.relative_url_root}/boards/${boardId}/issues.json`,
|
||||
url: `${gon.relative_url_root}/-/boards/${boardId}/issues.json`,
|
||||
}
|
||||
});
|
||||
this.lists = Vue.resource(`${listsEndpoint}{/id}`, {}, {
|
||||
|
@ -16,7 +16,7 @@ class BoardService {
|
|||
url: `${listsEndpoint}/generate.json`
|
||||
}
|
||||
});
|
||||
this.issue = Vue.resource(`${gon.relative_url_root}/boards/${boardId}/issues{/id}`, {});
|
||||
this.issue = Vue.resource(`${gon.relative_url_root}/-/boards/${boardId}/issues{/id}`, {});
|
||||
this.issues = Vue.resource(`${listsEndpoint}{/id}/issues`, {}, {
|
||||
bulkUpdate: {
|
||||
method: 'POST',
|
||||
|
|
|
@ -3,7 +3,8 @@ import Visibility from 'visibilityjs';
|
|||
import axios from 'axios';
|
||||
import Poll from './lib/utils/poll';
|
||||
import { s__ } from './locale';
|
||||
import './flash';
|
||||
import initSettingsPanels from './settings_panels';
|
||||
import Flash from './flash';
|
||||
|
||||
/**
|
||||
* Cluster page has 2 separate parts:
|
||||
|
@ -24,6 +25,8 @@ class ClusterService {
|
|||
|
||||
export default class Clusters {
|
||||
constructor() {
|
||||
initSettingsPanels();
|
||||
|
||||
const dataset = document.querySelector('.js-edit-cluster-form').dataset;
|
||||
|
||||
this.state = {
|
||||
|
|
|
@ -73,6 +73,7 @@ import initProjectVisibilitySelector from './project_visibility';
|
|||
import GpgBadges from './gpg_badges';
|
||||
import UserFeatureHelper from './helpers/user_feature_helper';
|
||||
import initChangesDropdown from './init_changes_dropdown';
|
||||
import NewGroupChild from './groups/new_group_child';
|
||||
import AbuseReports from './abuse_reports';
|
||||
import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
|
||||
import AjaxLoadingSpinner from './ajax_loading_spinner';
|
||||
|
@ -168,9 +169,6 @@ import memberExpirationDate from './member_expiration_date';
|
|||
const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests');
|
||||
filteredSearchManager.setup();
|
||||
}
|
||||
if (page === 'projects:merge_requests:index') {
|
||||
new UserCallout({ setCalloutPerProject: true });
|
||||
}
|
||||
const pagePrefix = page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_';
|
||||
IssuableIndex.init(pagePrefix);
|
||||
|
||||
|
@ -352,7 +350,10 @@ import memberExpirationDate from './member_expiration_date';
|
|||
case 'projects:show':
|
||||
shortcut_handler = new ShortcutsNavigation();
|
||||
new NotificationsForm();
|
||||
new UserCallout({ setCalloutPerProject: true });
|
||||
new UserCallout({
|
||||
setCalloutPerProject: true,
|
||||
className: 'js-autodevops-banner',
|
||||
});
|
||||
|
||||
if ($('#tree-slider').length) new TreeView();
|
||||
if ($('.blob-viewer').length) new BlobViewer();
|
||||
|
@ -372,9 +373,6 @@ import memberExpirationDate from './member_expiration_date';
|
|||
case 'projects:pipelines:new':
|
||||
new NewBranchForm($('.js-new-pipeline-form'));
|
||||
break;
|
||||
case 'projects:pipelines:index':
|
||||
new UserCallout({ setCalloutPerProject: true });
|
||||
break;
|
||||
case 'projects:pipelines:builds':
|
||||
case 'projects:pipelines:failures':
|
||||
case 'projects:pipelines:show':
|
||||
|
@ -395,10 +393,15 @@ import memberExpirationDate from './member_expiration_date';
|
|||
new gl.Activities();
|
||||
break;
|
||||
case 'groups:show':
|
||||
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
|
||||
shortcut_handler = new ShortcutsNavigation();
|
||||
new NotificationsForm();
|
||||
new NotificationsDropdown();
|
||||
new ProjectsList();
|
||||
|
||||
if (newGroupChildWrapper) {
|
||||
new NewGroupChild(newGroupChildWrapper);
|
||||
}
|
||||
break;
|
||||
case 'groups:group_members:index':
|
||||
memberExpirationDate();
|
||||
|
@ -432,7 +435,6 @@ import memberExpirationDate from './member_expiration_date';
|
|||
new TreeView();
|
||||
new BlobViewer();
|
||||
new NewCommitForm($('.js-create-dir-form'));
|
||||
new UserCallout({ setCalloutPerProject: true });
|
||||
$('#tree-slider').waitForImages(function() {
|
||||
ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
|
||||
});
|
||||
|
|
|
@ -6,10 +6,11 @@ import _ from 'underscore';
|
|||
*/
|
||||
|
||||
export default class FilterableList {
|
||||
constructor(form, filter, holder) {
|
||||
constructor(form, filter, holder, filterInputField = 'filter_groups') {
|
||||
this.filterForm = form;
|
||||
this.listFilterElement = filter;
|
||||
this.listHolderElement = holder;
|
||||
this.filterInputField = filterInputField;
|
||||
this.isBusy = false;
|
||||
}
|
||||
|
||||
|
@ -32,10 +33,10 @@ export default class FilterableList {
|
|||
onFilterInput() {
|
||||
const $form = $(this.filterForm);
|
||||
const queryData = {};
|
||||
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
|
||||
const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
|
||||
|
||||
if (filterGroupsParam) {
|
||||
queryData.filter_groups = filterGroupsParam;
|
||||
queryData[this.filterInputField] = filterGroupsParam;
|
||||
}
|
||||
|
||||
this.filterResults(queryData);
|
||||
|
|
|
@ -0,0 +1,194 @@
|
|||
<script>
|
||||
/* global Flash */
|
||||
|
||||
import eventHub from '../event_hub';
|
||||
import { getParameterByName } from '../../lib/utils/common_utils';
|
||||
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
|
||||
import { COMMON_STR } from '../constants';
|
||||
|
||||
import groupsComponent from './groups.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
loadingIcon,
|
||||
groupsComponent,
|
||||
},
|
||||
props: {
|
||||
store: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
service: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hideProjects: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: true,
|
||||
isSearchEmpty: false,
|
||||
searchEmptyMessage: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
groups() {
|
||||
return this.store.getGroups();
|
||||
},
|
||||
pageInfo() {
|
||||
return this.store.getPaginationInfo();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchGroups({ parentId, page, filterGroupsBy, sortBy, archived, updatePagination }) {
|
||||
return this.service.getGroups(parentId, page, filterGroupsBy, sortBy, archived)
|
||||
.then((res) => {
|
||||
if (updatePagination) {
|
||||
this.updatePagination(res.headers);
|
||||
}
|
||||
|
||||
return res;
|
||||
})
|
||||
.then(res => res.json())
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
$.scrollTo(0);
|
||||
|
||||
Flash(COMMON_STR.FAILURE);
|
||||
});
|
||||
},
|
||||
fetchAllGroups() {
|
||||
const page = getParameterByName('page') || null;
|
||||
const sortBy = getParameterByName('sort') || null;
|
||||
const archived = getParameterByName('archived') || null;
|
||||
const filterGroupsBy = getParameterByName('filter') || null;
|
||||
|
||||
this.isLoading = true;
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
this.fetchGroups({
|
||||
page,
|
||||
filterGroupsBy,
|
||||
sortBy,
|
||||
archived,
|
||||
updatePagination: true,
|
||||
}).then((res) => {
|
||||
this.isLoading = false;
|
||||
this.updateGroups(res, Boolean(filterGroupsBy));
|
||||
});
|
||||
},
|
||||
fetchPage(page, filterGroupsBy, sortBy, archived) {
|
||||
this.isLoading = true;
|
||||
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
this.fetchGroups({
|
||||
page,
|
||||
filterGroupsBy,
|
||||
sortBy,
|
||||
archived,
|
||||
updatePagination: true,
|
||||
}).then((res) => {
|
||||
this.isLoading = false;
|
||||
$.scrollTo(0);
|
||||
|
||||
const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
|
||||
window.history.replaceState({
|
||||
page: currentPath,
|
||||
}, document.title, currentPath);
|
||||
|
||||
this.updateGroups(res);
|
||||
});
|
||||
},
|
||||
toggleChildren(group) {
|
||||
const parentGroup = group;
|
||||
if (!parentGroup.isOpen) {
|
||||
if (parentGroup.children.length === 0) {
|
||||
parentGroup.isChildrenLoading = true;
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
this.fetchGroups({
|
||||
parentId: parentGroup.id,
|
||||
}).then((res) => {
|
||||
this.store.setGroupChildren(parentGroup, res);
|
||||
}).catch(() => {
|
||||
parentGroup.isChildrenLoading = false;
|
||||
});
|
||||
} else {
|
||||
parentGroup.isOpen = true;
|
||||
}
|
||||
} else {
|
||||
parentGroup.isOpen = false;
|
||||
}
|
||||
},
|
||||
leaveGroup(group, parentGroup) {
|
||||
const targetGroup = group;
|
||||
targetGroup.isBeingRemoved = true;
|
||||
this.service.leaveGroup(targetGroup.leavePath)
|
||||
.then(res => res.json())
|
||||
.then((res) => {
|
||||
$.scrollTo(0);
|
||||
this.store.removeGroup(targetGroup, parentGroup);
|
||||
Flash(res.notice, 'notice');
|
||||
})
|
||||
.catch((err) => {
|
||||
let message = COMMON_STR.FAILURE;
|
||||
if (err.status === 403) {
|
||||
message = COMMON_STR.LEAVE_FORBIDDEN;
|
||||
}
|
||||
Flash(message);
|
||||
targetGroup.isBeingRemoved = false;
|
||||
});
|
||||
},
|
||||
updatePagination(headers) {
|
||||
this.store.setPaginationInfo(headers);
|
||||
},
|
||||
updateGroups(groups, fromSearch) {
|
||||
this.isSearchEmpty = groups ? groups.length === 0 : false;
|
||||
if (fromSearch) {
|
||||
this.store.setSearchedGroups(groups);
|
||||
} else {
|
||||
this.store.setGroups(groups);
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.searchEmptyMessage = this.hideProjects ?
|
||||
COMMON_STR.GROUP_SEARCH_EMPTY : COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
|
||||
|
||||
eventHub.$on('fetchPage', this.fetchPage);
|
||||
eventHub.$on('toggleChildren', this.toggleChildren);
|
||||
eventHub.$on('leaveGroup', this.leaveGroup);
|
||||
eventHub.$on('updatePagination', this.updatePagination);
|
||||
eventHub.$on('updateGroups', this.updateGroups);
|
||||
},
|
||||
mounted() {
|
||||
this.fetchAllGroups();
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventHub.$off('fetchPage', this.fetchPage);
|
||||
eventHub.$off('toggleChildren', this.toggleChildren);
|
||||
eventHub.$off('leaveGroup', this.leaveGroup);
|
||||
eventHub.$off('updatePagination', this.updatePagination);
|
||||
eventHub.$off('updateGroups', this.updateGroups);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<loading-icon
|
||||
class="loading-animation prepend-top-20"
|
||||
size="2"
|
||||
v-if="isLoading"
|
||||
:label="s__('GroupsTree|Loading groups')"
|
||||
/>
|
||||
<groups-component
|
||||
v-if="!isLoading"
|
||||
:groups="groups"
|
||||
:search-empty="isSearchEmpty"
|
||||
:search-empty-message="searchEmptyMessage"
|
||||
:page-info="pageInfo"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
|
@ -1,15 +1,27 @@
|
|||
<script>
|
||||
import { n__ } from '../../locale';
|
||||
import { MAX_CHILDREN_COUNT } from '../constants';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
groups: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
baseGroup: {
|
||||
parentGroup: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => ([]),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
hasMoreChildren() {
|
||||
return this.parentGroup.childrenCount > MAX_CHILDREN_COUNT;
|
||||
},
|
||||
moreChildrenStats() {
|
||||
return n__('One more item', '%d more items', this.parentGroup.childrenCount - this.parentGroup.children.length);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -20,8 +32,20 @@ export default {
|
|||
v-for="(group, index) in groups"
|
||||
:key="index"
|
||||
:group="group"
|
||||
:base-group="baseGroup"
|
||||
:collection="groups"
|
||||
:parent-group="parentGroup"
|
||||
/>
|
||||
<li
|
||||
v-if="hasMoreChildren"
|
||||
class="group-row">
|
||||
<a
|
||||
:href="parentGroup.relativePath"
|
||||
class="group-row-contents has-more-items">
|
||||
<i
|
||||
class="fa fa-external-link"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{moreChildrenStats}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
|
|
@ -2,50 +2,29 @@
|
|||
import identicon from '../../vue_shared/components/identicon.vue';
|
||||
import eventHub from '../event_hub';
|
||||
|
||||
import itemCaret from './item_caret.vue';
|
||||
import itemTypeIcon from './item_type_icon.vue';
|
||||
import itemStats from './item_stats.vue';
|
||||
import itemActions from './item_actions.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
identicon,
|
||||
itemCaret,
|
||||
itemTypeIcon,
|
||||
itemStats,
|
||||
itemActions,
|
||||
},
|
||||
props: {
|
||||
parentGroup: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
group: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
baseGroup: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
collection: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClickRowGroup(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
// Skip for buttons
|
||||
if (!(e.target.tagName === 'A') && !(e.target.tagName === 'I' && e.target.parentElement.tagName === 'A')) {
|
||||
if (this.group.hasSubgroups) {
|
||||
eventHub.$emit('toggleSubGroups', this.group);
|
||||
} else {
|
||||
window.location.href = this.group.groupPath;
|
||||
}
|
||||
}
|
||||
},
|
||||
onLeaveGroup(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// eslint-disable-next-line no-alert
|
||||
if (confirm(`Are you sure you want to leave the "${this.group.fullName}" group?`)) {
|
||||
this.leaveGroup();
|
||||
}
|
||||
},
|
||||
leaveGroup() {
|
||||
eventHub.$emit('leaveGroup', this.group, this.collection);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
groupDomId() {
|
||||
|
@ -53,51 +32,33 @@ export default {
|
|||
},
|
||||
rowClass() {
|
||||
return {
|
||||
'group-row': true,
|
||||
'is-open': this.group.isOpen,
|
||||
'has-subgroups': this.group.hasSubgroups,
|
||||
'no-description': !this.group.description,
|
||||
'has-children': this.hasChildren,
|
||||
'has-description': this.group.description,
|
||||
'being-removed': this.group.isBeingRemoved,
|
||||
};
|
||||
},
|
||||
visibilityIcon() {
|
||||
return {
|
||||
fa: true,
|
||||
'fa-globe': this.group.visibility === 'public',
|
||||
'fa-shield': this.group.visibility === 'internal',
|
||||
'fa-lock': this.group.visibility === 'private',
|
||||
};
|
||||
},
|
||||
fullPath() {
|
||||
let fullPath = '';
|
||||
|
||||
if (this.group.isOrphan) {
|
||||
// check if current group is baseGroup
|
||||
if (Object.keys(this.baseGroup).length > 0 && this.baseGroup !== this.group) {
|
||||
// Remove baseGroup prefix from our current group.fullName. e.g:
|
||||
// baseGroup.fullName: `level1`
|
||||
// group.fullName: `level1 / level2 / level3`
|
||||
// Result: `level2 / level3`
|
||||
const gfn = this.group.fullName;
|
||||
const bfn = this.baseGroup.fullName;
|
||||
const length = bfn.length;
|
||||
const start = gfn.indexOf(bfn);
|
||||
const extraPrefixChars = 3;
|
||||
|
||||
fullPath = gfn.substr(start + length + extraPrefixChars);
|
||||
} else {
|
||||
fullPath = this.group.fullName;
|
||||
}
|
||||
} else {
|
||||
fullPath = this.group.name;
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
},
|
||||
hasGroups() {
|
||||
return Object.keys(this.group.subGroups).length > 0;
|
||||
hasChildren() {
|
||||
return this.group.childrenCount > 0;
|
||||
},
|
||||
hasAvatar() {
|
||||
return this.group.avatarUrl && this.group.avatarUrl.indexOf('/assets/no_group_avatar') === -1;
|
||||
return this.group.avatarUrl !== null;
|
||||
},
|
||||
isGroup() {
|
||||
return this.group.type === 'group';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClickRowGroup(e) {
|
||||
const NO_EXPAND_CLS = 'no-expand';
|
||||
if (!(e.target.classList.contains(NO_EXPAND_CLS) ||
|
||||
e.target.parentElement.classList.contains(NO_EXPAND_CLS))) {
|
||||
if (this.hasChildren) {
|
||||
eventHub.$emit('toggleChildren', this.group);
|
||||
} else {
|
||||
gl.utils.visitUrl(this.group.relativePath);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -108,98 +69,36 @@ export default {
|
|||
@click.stop="onClickRowGroup"
|
||||
:id="groupDomId"
|
||||
:class="rowClass"
|
||||
class="group-row"
|
||||
>
|
||||
<div
|
||||
class="group-row-contents">
|
||||
<div
|
||||
class="controls">
|
||||
<a
|
||||
v-if="group.canEdit"
|
||||
class="edit-group btn"
|
||||
:href="group.editPath">
|
||||
<i
|
||||
class="fa fa-cogs"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</i>
|
||||
</a>
|
||||
<a
|
||||
@click="onLeaveGroup"
|
||||
:href="group.leavePath"
|
||||
class="leave-group btn"
|
||||
title="Leave this group">
|
||||
<i
|
||||
class="fa fa-sign-out"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</i>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="stats">
|
||||
<span
|
||||
class="number-projects">
|
||||
<i
|
||||
class="fa fa-bookmark"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</i>
|
||||
{{group.numberProjects}}
|
||||
</span>
|
||||
<span
|
||||
class="number-users">
|
||||
<i
|
||||
class="fa fa-users"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</i>
|
||||
{{group.numberUsers}}
|
||||
</span>
|
||||
<span
|
||||
class="group-visibility">
|
||||
<i
|
||||
:class="visibilityIcon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</i>
|
||||
</span>
|
||||
</div>
|
||||
<item-actions
|
||||
v-if="isGroup"
|
||||
:group="group"
|
||||
:parent-group="parentGroup"
|
||||
/>
|
||||
<item-stats
|
||||
:item="group"
|
||||
/>
|
||||
<div
|
||||
class="folder-toggle-wrap">
|
||||
<span
|
||||
class="folder-caret"
|
||||
v-if="group.hasSubgroups">
|
||||
<i
|
||||
v-if="group.isOpen"
|
||||
class="fa fa-caret-down"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</i>
|
||||
<i
|
||||
v-if="!group.isOpen"
|
||||
class="fa fa-caret-right"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</i>
|
||||
</span>
|
||||
<span class="folder-icon">
|
||||
<i
|
||||
v-if="group.isOpen"
|
||||
class="fa fa-folder-open"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</i>
|
||||
<i
|
||||
v-if="!group.isOpen"
|
||||
class="fa fa-folder"
|
||||
aria-hidden="true">
|
||||
</i>
|
||||
</span>
|
||||
<item-caret
|
||||
:is-group-open="group.isOpen"
|
||||
/>
|
||||
<item-type-icon
|
||||
:item-type="group.type"
|
||||
:is-group-open="group.isOpen"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="avatar-container s40 hidden-xs">
|
||||
class="avatar-container s40 hidden-xs"
|
||||
:class="{ 'content-loading': group.isChildrenLoading }"
|
||||
>
|
||||
<a
|
||||
:href="group.groupPath">
|
||||
:href="group.relativePath"
|
||||
class="no-expand"
|
||||
>
|
||||
<img
|
||||
v-if="hasAvatar"
|
||||
class="avatar s40"
|
||||
|
@ -215,19 +114,22 @@ export default {
|
|||
<div
|
||||
class="title">
|
||||
<a
|
||||
:href="group.groupPath">{{fullPath}}</a>
|
||||
<template v-if="group.permissions.humanGroupAccess">
|
||||
as
|
||||
<span class="access-type">{{group.permissions.humanGroupAccess}}</span>
|
||||
</template>
|
||||
:href="group.relativePath"
|
||||
class="no-expand">{{group.fullName}}</a>
|
||||
<span
|
||||
v-if="group.permission"
|
||||
class="access-type"
|
||||
>
|
||||
{{s__('GroupsTreeRole|as')}} {{group.permission}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="description">{{group.description}}</div>
|
||||
</div>
|
||||
<group-folder
|
||||
v-if="group.isOpen && hasGroups"
|
||||
:groups="group.subGroups"
|
||||
:baseGroup="group"
|
||||
v-if="group.isOpen && hasChildren"
|
||||
:parent-group="group"
|
||||
:groups="group.children"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
|
|
@ -4,24 +4,33 @@ import eventHub from '../event_hub';
|
|||
import { getParameterByName } from '../../lib/utils/common_utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
tablePagination,
|
||||
},
|
||||
props: {
|
||||
groups: {
|
||||
type: Object,
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
pageInfo: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
tablePagination,
|
||||
searchEmpty: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
searchEmptyMessage: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
change(page) {
|
||||
const filterGroupsParam = getParameterByName('filter_groups');
|
||||
const sortParam = getParameterByName('sort');
|
||||
eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam);
|
||||
const archivedParam = getParameterByName('archived');
|
||||
eventHub.$emit('fetchPage', page, filterGroupsParam, sortParam, archivedParam);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -29,10 +38,17 @@ export default {
|
|||
|
||||
<template>
|
||||
<div class="groups-list-tree-container">
|
||||
<div
|
||||
v-if="searchEmpty"
|
||||
class="has-no-search-results">
|
||||
{{searchEmptyMessage}}
|
||||
</div>
|
||||
<group-folder
|
||||
v-if="!searchEmpty"
|
||||
:groups="groups"
|
||||
/>
|
||||
<table-pagination
|
||||
v-if="!searchEmpty"
|
||||
:change="change"
|
||||
:pageInfo="pageInfo"
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
<script>
|
||||
import { s__ } from '../../locale';
|
||||
import tooltip from '../../vue_shared/directives/tooltip';
|
||||
import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
|
||||
import eventHub from '../event_hub';
|
||||
import { COMMON_STR } from '../constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PopupDialog,
|
||||
},
|
||||
directives: {
|
||||
tooltip,
|
||||
},
|
||||
props: {
|
||||
parentGroup: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
group: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialogStatus: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
leaveBtnTitle() {
|
||||
return COMMON_STR.LEAVE_BTN_TITLE;
|
||||
},
|
||||
editBtnTitle() {
|
||||
return COMMON_STR.EDIT_BTN_TITLE;
|
||||
},
|
||||
leaveConfirmationMessage() {
|
||||
return s__(`GroupsTree|Are you sure you want to leave the "${this.group.fullName}" group?`);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onLeaveGroup() {
|
||||
this.dialogStatus = true;
|
||||
},
|
||||
leaveGroup(leaveConfirmed) {
|
||||
this.dialogStatus = false;
|
||||
if (leaveConfirmed) {
|
||||
eventHub.$emit('leaveGroup', this.group, this.parentGroup);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="controls">
|
||||
<a
|
||||
v-tooltip
|
||||
v-if="group.canEdit"
|
||||
:href="group.editPath"
|
||||
:title="editBtnTitle"
|
||||
:aria-label="editBtnTitle"
|
||||
data-container="body"
|
||||
class="edit-group btn no-expand">
|
||||
<i
|
||||
class="fa fa-cogs"
|
||||
aria-hidden="true"/>
|
||||
</a>
|
||||
<a
|
||||
v-tooltip
|
||||
v-if="group.canLeave"
|
||||
@click.prevent="onLeaveGroup"
|
||||
:href="group.leavePath"
|
||||
:title="leaveBtnTitle"
|
||||
:aria-label="leaveBtnTitle"
|
||||
data-container="body"
|
||||
class="leave-group btn no-expand">
|
||||
<i
|
||||
class="fa fa-sign-out"
|
||||
aria-hidden="true"/>
|
||||
</a>
|
||||
<popup-dialog
|
||||
v-show="dialogStatus"
|
||||
:primary-button-label="__('Leave')"
|
||||
kind="warning"
|
||||
:title="__('Are you sure?')"
|
||||
:text="__('Are you sure you want to leave this group?')"
|
||||
:body="leaveConfirmationMessage"
|
||||
@submit="leaveGroup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,25 @@
|
|||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isGroupOpen: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
iconClass() {
|
||||
return this.isGroupOpen ? 'fa-caret-down' : 'fa-caret-right';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="folder-caret">
|
||||
<i
|
||||
:class="iconClass"
|
||||
class="fa"
|
||||
aria-hidden="true"/>
|
||||
</span>
|
||||
</template>
|
|
@ -0,0 +1,98 @@
|
|||
<script>
|
||||
import tooltip from '../../vue_shared/directives/tooltip';
|
||||
import { ITEM_TYPE, VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, PROJECT_VISIBILITY_TYPE } from '../constants';
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
tooltip,
|
||||
},
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
visibilityIcon() {
|
||||
return VISIBILITY_TYPE_ICON[this.item.visibility];
|
||||
},
|
||||
visibilityTooltip() {
|
||||
if (this.item.type === ITEM_TYPE.GROUP) {
|
||||
return GROUP_VISIBILITY_TYPE[this.item.visibility];
|
||||
}
|
||||
return PROJECT_VISIBILITY_TYPE[this.item.visibility];
|
||||
},
|
||||
isProject() {
|
||||
return this.item.type === ITEM_TYPE.PROJECT;
|
||||
},
|
||||
isGroup() {
|
||||
return this.item.type === ITEM_TYPE.GROUP;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats">
|
||||
<span
|
||||
v-tooltip
|
||||
v-if="isGroup"
|
||||
:title="s__('Subgroups')"
|
||||
class="number-subgroups"
|
||||
data-placement="top"
|
||||
data-container="body">
|
||||
<i
|
||||
class="fa fa-folder"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{item.subgroupCount}}
|
||||
</span>
|
||||
<span
|
||||
v-tooltip
|
||||
v-if="isGroup"
|
||||
:title="s__('Projects')"
|
||||
class="number-projects"
|
||||
data-placement="top"
|
||||
data-container="body">
|
||||
<i
|
||||
class="fa fa-bookmark"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{item.projectCount}}
|
||||
</span>
|
||||
<span
|
||||
v-tooltip
|
||||
v-if="isGroup"
|
||||
:title="s__('Members')"
|
||||
class="number-users"
|
||||
data-placement="top"
|
||||
data-container="body">
|
||||
<i
|
||||
class="fa fa-users"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{item.memberCount}}
|
||||
</span>
|
||||
<span
|
||||
v-if="isProject"
|
||||
class="project-stars">
|
||||
<i
|
||||
class="fa fa-star"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{item.starCount}}
|
||||
</span>
|
||||
<span
|
||||
v-tooltip
|
||||
:title="visibilityTooltip"
|
||||
data-placement="left"
|
||||
data-container="body"
|
||||
class="item-visibility">
|
||||
<i
|
||||
:class="visibilityIcon"
|
||||
class="fa"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,34 @@
|
|||
<script>
|
||||
import { ITEM_TYPE } from '../constants';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
itemType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isGroupOpen: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
iconClass() {
|
||||
if (this.itemType === ITEM_TYPE.GROUP) {
|
||||
return this.isGroupOpen ? 'fa-folder-open' : 'fa-folder';
|
||||
}
|
||||
return 'fa-bookmark';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="item-type-icon">
|
||||
<i
|
||||
:class="iconClass"
|
||||
class="fa"
|
||||
aria-hidden="true"/>
|
||||
</span>
|
||||
</template>
|
|
@ -0,0 +1,35 @@
|
|||
import { __, s__ } from '../locale';
|
||||
|
||||
export const MAX_CHILDREN_COUNT = 20;
|
||||
|
||||
export const COMMON_STR = {
|
||||
FAILURE: __('An error occurred. Please try again.'),
|
||||
LEAVE_FORBIDDEN: s__('GroupsTree|Failed to leave the group. Please make sure you are not the only owner.'),
|
||||
LEAVE_BTN_TITLE: s__('GroupsTree|Leave this group'),
|
||||
EDIT_BTN_TITLE: s__('GroupsTree|Edit group'),
|
||||
GROUP_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups matched your search'),
|
||||
GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|Sorry, no groups or projects matched your search'),
|
||||
};
|
||||
|
||||
export const ITEM_TYPE = {
|
||||
PROJECT: 'project',
|
||||
GROUP: 'group',
|
||||
};
|
||||
|
||||
export const GROUP_VISIBILITY_TYPE = {
|
||||
public: __('Public - The group and any public projects can be viewed without any authentication.'),
|
||||
internal: __('Internal - The group and any internal projects can be viewed by any logged in user.'),
|
||||
private: __('Private - The group and its projects can only be viewed by members.'),
|
||||
};
|
||||
|
||||
export const PROJECT_VISIBILITY_TYPE = {
|
||||
public: __('Public - The project can be accessed without any authentication.'),
|
||||
internal: __('Internal - The project can be accessed by any logged in user.'),
|
||||
private: __('Private - Project access must be granted explicitly to each user.'),
|
||||
};
|
||||
|
||||
export const VISIBILITY_TYPE_ICON = {
|
||||
public: 'fa-globe',
|
||||
internal: 'fa-shield',
|
||||
private: 'fa-lock',
|
||||
};
|
|
@ -3,12 +3,13 @@ import eventHub from './event_hub';
|
|||
import { getParameterByName } from '../lib/utils/common_utils';
|
||||
|
||||
export default class GroupFilterableList extends FilterableList {
|
||||
constructor({ form, filter, holder, filterEndpoint, pagePath }) {
|
||||
super(form, filter, holder);
|
||||
constructor({ form, filter, holder, filterEndpoint, pagePath, dropdownSel, filterInputField }) {
|
||||
super(form, filter, holder, filterInputField);
|
||||
this.form = form;
|
||||
this.filterEndpoint = filterEndpoint;
|
||||
this.pagePath = pagePath;
|
||||
this.$dropdown = $('.js-group-filter-dropdown-wrap');
|
||||
this.filterInputField = filterInputField;
|
||||
this.$dropdown = $(dropdownSel);
|
||||
}
|
||||
|
||||
getFilterEndpoint() {
|
||||
|
@ -24,30 +25,34 @@ export default class GroupFilterableList extends FilterableList {
|
|||
bindEvents() {
|
||||
super.bindEvents();
|
||||
|
||||
this.onFormSubmitWrapper = this.onFormSubmit.bind(this);
|
||||
this.onFilterOptionClikWrapper = this.onOptionClick.bind(this);
|
||||
|
||||
this.filterForm.addEventListener('submit', this.onFormSubmitWrapper);
|
||||
this.$dropdown.on('click', 'a', this.onFilterOptionClikWrapper);
|
||||
}
|
||||
|
||||
onFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const $form = $(this.form);
|
||||
const filterGroupsParam = $form.find('[name="filter_groups"]').val();
|
||||
onFilterInput() {
|
||||
const queryData = {};
|
||||
const $form = $(this.form);
|
||||
const archivedParam = getParameterByName('archived', window.location.href);
|
||||
const filterGroupsParam = $form.find(`[name="${this.filterInputField}"]`).val();
|
||||
|
||||
if (filterGroupsParam) {
|
||||
queryData.filter_groups = filterGroupsParam;
|
||||
queryData[this.filterInputField] = filterGroupsParam;
|
||||
}
|
||||
|
||||
if (archivedParam) {
|
||||
queryData.archived = archivedParam;
|
||||
}
|
||||
|
||||
this.filterResults(queryData);
|
||||
this.setDefaultFilterOption();
|
||||
|
||||
if (this.setDefaultFilterOption) {
|
||||
this.setDefaultFilterOption();
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultFilterOption() {
|
||||
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu a:first-child').text());
|
||||
const defaultOption = $.trim(this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').first().text());
|
||||
this.$dropdown.find('.dropdown-label').text(defaultOption);
|
||||
}
|
||||
|
||||
|
@ -55,23 +60,42 @@ export default class GroupFilterableList extends FilterableList {
|
|||
e.preventDefault();
|
||||
|
||||
const queryData = {};
|
||||
const sortParam = getParameterByName('sort', e.currentTarget.href);
|
||||
|
||||
// Get type of option selected from dropdown
|
||||
const currentTargetClassList = e.currentTarget.parentElement.classList;
|
||||
const isOptionFilterBySort = currentTargetClassList.contains('js-filter-sort-order');
|
||||
const isOptionFilterByArchivedProjects = currentTargetClassList.contains('js-filter-archived-projects');
|
||||
|
||||
// Get option query param, also preserve currently applied query param
|
||||
const sortParam = getParameterByName('sort', isOptionFilterBySort ? e.currentTarget.href : window.location.href);
|
||||
const archivedParam = getParameterByName('archived', isOptionFilterByArchivedProjects ? e.currentTarget.href : window.location.href);
|
||||
|
||||
if (sortParam) {
|
||||
queryData.sort = sortParam;
|
||||
}
|
||||
|
||||
if (archivedParam) {
|
||||
queryData.archived = archivedParam;
|
||||
}
|
||||
|
||||
this.filterResults(queryData);
|
||||
|
||||
// Active selected option
|
||||
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
|
||||
if (isOptionFilterBySort) {
|
||||
this.$dropdown.find('.dropdown-label').text($.trim(e.currentTarget.text));
|
||||
this.$dropdown.find('.dropdown-menu li.js-filter-sort-order a').removeClass('is-active');
|
||||
} else if (isOptionFilterByArchivedProjects) {
|
||||
this.$dropdown.find('.dropdown-menu li.js-filter-archived-projects a').removeClass('is-active');
|
||||
}
|
||||
|
||||
$(e.target).addClass('is-active');
|
||||
|
||||
// Clear current value on search form
|
||||
this.form.querySelector('[name="filter_groups"]').value = '';
|
||||
this.form.querySelector(`[name="${this.filterInputField}"]`).value = '';
|
||||
}
|
||||
|
||||
onFilterSuccess(data, xhr, queryData) {
|
||||
super.onFilterSuccess(data, xhr, queryData);
|
||||
const currentPath = this.getPagePath(queryData);
|
||||
|
||||
const paginationData = {
|
||||
'X-Per-Page': xhr.getResponseHeader('X-Per-Page'),
|
||||
|
@ -82,7 +106,11 @@ export default class GroupFilterableList extends FilterableList {
|
|||
'X-Prev-Page': xhr.getResponseHeader('X-Prev-Page'),
|
||||
};
|
||||
|
||||
eventHub.$emit('updateGroups', data);
|
||||
window.history.replaceState({
|
||||
page: currentPath,
|
||||
}, document.title, currentPath);
|
||||
|
||||
eventHub.$emit('updateGroups', data, Object.prototype.hasOwnProperty.call(queryData, this.filterInputField));
|
||||
eventHub.$emit('updatePagination', paginationData);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import Vue from 'vue';
|
||||
import Flash from '../flash';
|
||||
import Translate from '../vue_shared/translate';
|
||||
import GroupFilterableList from './groups_filterable_list';
|
||||
import GroupsComponent from './components/groups.vue';
|
||||
import GroupFolder from './components/group_folder.vue';
|
||||
import GroupItem from './components/group_item.vue';
|
||||
import GroupsStore from './stores/groups_store';
|
||||
import GroupsService from './services/groups_service';
|
||||
import eventHub from './event_hub';
|
||||
import { getParameterByName } from '../lib/utils/common_utils';
|
||||
import GroupsStore from './store/groups_store';
|
||||
import GroupsService from './service/groups_service';
|
||||
|
||||
import groupsApp from './components/app.vue';
|
||||
import groupFolderComponent from './components/group_folder.vue';
|
||||
import groupItemComponent from './components/group_item.vue';
|
||||
|
||||
Vue.use(Translate);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const el = document.getElementById('dashboard-group-app');
|
||||
const el = document.getElementById('js-groups-tree');
|
||||
|
||||
// Don't do anything if element doesn't exist (No groups)
|
||||
// This is for when the user enters directly to the page via URL
|
||||
|
@ -18,176 +19,56 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
return;
|
||||
}
|
||||
|
||||
Vue.component('groups-component', GroupsComponent);
|
||||
Vue.component('group-folder', GroupFolder);
|
||||
Vue.component('group-item', GroupItem);
|
||||
Vue.component('group-folder', groupFolderComponent);
|
||||
Vue.component('group-item', groupItemComponent);
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new Vue({
|
||||
el,
|
||||
components: {
|
||||
groupsApp,
|
||||
},
|
||||
data() {
|
||||
this.store = new GroupsStore();
|
||||
this.service = new GroupsService(el.dataset.endpoint);
|
||||
const dataset = this.$options.el.dataset;
|
||||
const hideProjects = dataset.hideProjects === 'true';
|
||||
const store = new GroupsStore(hideProjects);
|
||||
const service = new GroupsService(dataset.endpoint);
|
||||
|
||||
return {
|
||||
store: this.store,
|
||||
isLoading: true,
|
||||
state: this.store.state,
|
||||
store,
|
||||
service,
|
||||
hideProjects,
|
||||
loading: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isEmpty() {
|
||||
return Object.keys(this.state.groups).length === 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchGroups(parentGroup) {
|
||||
let parentId = null;
|
||||
let getGroups = null;
|
||||
let page = null;
|
||||
let sort = null;
|
||||
let pageParam = null;
|
||||
let sortParam = null;
|
||||
let filterGroups = null;
|
||||
let filterGroupsParam = null;
|
||||
|
||||
if (parentGroup) {
|
||||
parentId = parentGroup.id;
|
||||
} else {
|
||||
this.isLoading = true;
|
||||
}
|
||||
|
||||
pageParam = getParameterByName('page');
|
||||
if (pageParam) {
|
||||
page = pageParam;
|
||||
}
|
||||
|
||||
filterGroupsParam = getParameterByName('filter_groups');
|
||||
if (filterGroupsParam) {
|
||||
filterGroups = filterGroupsParam;
|
||||
}
|
||||
|
||||
sortParam = getParameterByName('sort');
|
||||
if (sortParam) {
|
||||
sort = sortParam;
|
||||
}
|
||||
|
||||
getGroups = this.service.getGroups(parentId, page, filterGroups, sort);
|
||||
getGroups
|
||||
.then(response => response.json())
|
||||
.then((response) => {
|
||||
this.isLoading = false;
|
||||
|
||||
this.updateGroups(response, parentGroup);
|
||||
})
|
||||
.catch(this.handleErrorResponse);
|
||||
|
||||
return getGroups;
|
||||
},
|
||||
fetchPage(page, filterGroups, sort) {
|
||||
this.isLoading = true;
|
||||
|
||||
return this.service
|
||||
.getGroups(null, page, filterGroups, sort)
|
||||
.then((response) => {
|
||||
this.isLoading = false;
|
||||
$.scrollTo(0);
|
||||
|
||||
const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href);
|
||||
window.history.replaceState({
|
||||
page: currentPath,
|
||||
}, document.title, currentPath);
|
||||
|
||||
return response.json().then((data) => {
|
||||
this.updateGroups(data);
|
||||
this.updatePagination(response.headers);
|
||||
});
|
||||
})
|
||||
.catch(this.handleErrorResponse);
|
||||
},
|
||||
toggleSubGroups(parentGroup = null) {
|
||||
if (!parentGroup.isOpen) {
|
||||
this.store.resetGroups(parentGroup);
|
||||
this.fetchGroups(parentGroup);
|
||||
}
|
||||
|
||||
this.store.toggleSubGroups(parentGroup);
|
||||
},
|
||||
leaveGroup(group, collection) {
|
||||
this.service.leaveGroup(group.leavePath)
|
||||
.then(resp => resp.json())
|
||||
.then((response) => {
|
||||
$.scrollTo(0);
|
||||
|
||||
this.store.removeGroup(group, collection);
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash(response.notice, 'notice');
|
||||
})
|
||||
.catch((error) => {
|
||||
let message = 'An error occurred. Please try again.';
|
||||
|
||||
if (error.status === 403) {
|
||||
message = 'Failed to leave the group. Please make sure you are not the only owner';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash(message);
|
||||
});
|
||||
},
|
||||
updateGroups(groups, parentGroup) {
|
||||
this.store.setGroups(groups, parentGroup);
|
||||
},
|
||||
updatePagination(headers) {
|
||||
this.store.storePagination(headers);
|
||||
},
|
||||
handleErrorResponse() {
|
||||
this.isLoading = false;
|
||||
$.scrollTo(0);
|
||||
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash('An error occurred. Please try again.');
|
||||
},
|
||||
},
|
||||
created() {
|
||||
eventHub.$on('fetchPage', this.fetchPage);
|
||||
eventHub.$on('toggleSubGroups', this.toggleSubGroups);
|
||||
eventHub.$on('leaveGroup', this.leaveGroup);
|
||||
eventHub.$on('updateGroups', this.updateGroups);
|
||||
eventHub.$on('updatePagination', this.updatePagination);
|
||||
},
|
||||
beforeMount() {
|
||||
const dataset = this.$options.el.dataset;
|
||||
let groupFilterList = null;
|
||||
const form = document.querySelector('form#group-filter-form');
|
||||
const filter = document.querySelector('.js-groups-list-filter');
|
||||
const holder = document.querySelector('.js-groups-list-holder');
|
||||
const form = document.querySelector(dataset.formSel);
|
||||
const filter = document.querySelector(dataset.filterSel);
|
||||
const holder = document.querySelector(dataset.holderSel);
|
||||
|
||||
const opts = {
|
||||
form,
|
||||
filter,
|
||||
holder,
|
||||
filterEndpoint: el.dataset.endpoint,
|
||||
pagePath: el.dataset.path,
|
||||
filterEndpoint: dataset.endpoint,
|
||||
pagePath: dataset.path,
|
||||
dropdownSel: dataset.dropdownSel,
|
||||
filterInputField: 'filter',
|
||||
};
|
||||
|
||||
groupFilterList = new GroupFilterableList(opts);
|
||||
groupFilterList.initSearch();
|
||||
},
|
||||
mounted() {
|
||||
this.fetchGroups()
|
||||
.then((response) => {
|
||||
this.updatePagination(response.headers);
|
||||
this.isLoading = false;
|
||||
})
|
||||
.catch(this.handleErrorResponse);
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventHub.$off('fetchPage', this.fetchPage);
|
||||
eventHub.$off('toggleSubGroups', this.toggleSubGroups);
|
||||
eventHub.$off('leaveGroup', this.leaveGroup);
|
||||
eventHub.$off('updateGroups', this.updateGroups);
|
||||
eventHub.$off('updatePagination', this.updatePagination);
|
||||
render(createElement) {
|
||||
return createElement('groups-app', {
|
||||
props: {
|
||||
store: this.store,
|
||||
service: this.service,
|
||||
hideProjects: this.hideProjects,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
import DropLab from '../droplab/drop_lab';
|
||||
import ISetter from '../droplab/plugins/input_setter';
|
||||
|
||||
const InputSetter = Object.assign({}, ISetter);
|
||||
|
||||
const NEW_PROJECT = 'new-project';
|
||||
const NEW_SUBGROUP = 'new-subgroup';
|
||||
|
||||
export default class NewGroupChild {
|
||||
constructor(buttonWrapper) {
|
||||
this.buttonWrapper = buttonWrapper;
|
||||
this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child');
|
||||
this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle');
|
||||
this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu');
|
||||
|
||||
this.newGroupPath = this.buttonWrapper.dataset.projectPath;
|
||||
this.subgroupPath = this.buttonWrapper.dataset.subgroupPath;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initDroplab();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
initDroplab() {
|
||||
this.droplab = new DropLab();
|
||||
this.droplab.init(
|
||||
this.dropdownToggle,
|
||||
this.dropdownList,
|
||||
[InputSetter],
|
||||
this.getDroplabConfig(),
|
||||
);
|
||||
}
|
||||
|
||||
getDroplabConfig() {
|
||||
return {
|
||||
InputSetter: [{
|
||||
input: this.newGroupChildButton,
|
||||
valueAttribute: 'data-value',
|
||||
inputAttribute: 'data-action',
|
||||
}, {
|
||||
input: this.newGroupChildButton,
|
||||
valueAttribute: 'data-text',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.newGroupChildButton
|
||||
.addEventListener('click', this.onClickNewGroupChildButton.bind(this));
|
||||
}
|
||||
|
||||
onClickNewGroupChildButton(e) {
|
||||
if (e.target.dataset.action === NEW_PROJECT) {
|
||||
gl.utils.visitUrl(this.newGroupPath);
|
||||
} else if (e.target.dataset.action === NEW_SUBGROUP) {
|
||||
gl.utils.visitUrl(this.subgroupPath);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@ export default class GroupsService {
|
|||
this.groups = Vue.resource(endpoint);
|
||||
}
|
||||
|
||||
getGroups(parentId, page, filterGroups, sort) {
|
||||
getGroups(parentId, page, filterGroups, sort, archived) {
|
||||
const data = {};
|
||||
|
||||
if (parentId) {
|
||||
|
@ -20,12 +20,16 @@ export default class GroupsService {
|
|||
}
|
||||
|
||||
if (filterGroups) {
|
||||
data.filter_groups = filterGroups;
|
||||
data.filter = filterGroups;
|
||||
}
|
||||
|
||||
if (sort) {
|
||||
data.sort = sort;
|
||||
}
|
||||
|
||||
if (archived) {
|
||||
data.archived = archived;
|
||||
}
|
||||
}
|
||||
|
||||
return this.groups.get(data);
|
|
@ -0,0 +1,105 @@
|
|||
import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils';
|
||||
|
||||
export default class GroupsStore {
|
||||
constructor(hideProjects) {
|
||||
this.state = {};
|
||||
this.state.groups = [];
|
||||
this.state.pageInfo = {};
|
||||
this.hideProjects = hideProjects;
|
||||
}
|
||||
|
||||
setGroups(rawGroups) {
|
||||
if (rawGroups && rawGroups.length) {
|
||||
this.state.groups = rawGroups.map(rawGroup => this.formatGroupItem(rawGroup));
|
||||
} else {
|
||||
this.state.groups = [];
|
||||
}
|
||||
}
|
||||
|
||||
setSearchedGroups(rawGroups) {
|
||||
const formatGroups = groups => groups.map((group) => {
|
||||
const formattedGroup = this.formatGroupItem(group);
|
||||
if (formattedGroup.children && formattedGroup.children.length) {
|
||||
formattedGroup.children = formatGroups(formattedGroup.children);
|
||||
}
|
||||
return formattedGroup;
|
||||
});
|
||||
|
||||
if (rawGroups && rawGroups.length) {
|
||||
this.state.groups = formatGroups(rawGroups);
|
||||
} else {
|
||||
this.state.groups = [];
|
||||
}
|
||||
}
|
||||
|
||||
setGroupChildren(parentGroup, children) {
|
||||
const updatedParentGroup = parentGroup;
|
||||
updatedParentGroup.children = children.map(rawChild => this.formatGroupItem(rawChild));
|
||||
updatedParentGroup.isOpen = true;
|
||||
updatedParentGroup.isChildrenLoading = false;
|
||||
}
|
||||
|
||||
getGroups() {
|
||||
return this.state.groups;
|
||||
}
|
||||
|
||||
setPaginationInfo(pagination = {}) {
|
||||
let paginationInfo;
|
||||
|
||||
if (Object.keys(pagination).length) {
|
||||
const normalizedHeaders = normalizeHeaders(pagination);
|
||||
paginationInfo = parseIntPagination(normalizedHeaders);
|
||||
} else {
|
||||
paginationInfo = pagination;
|
||||
}
|
||||
|
||||
this.state.pageInfo = paginationInfo;
|
||||
}
|
||||
|
||||
getPaginationInfo() {
|
||||
return this.state.pageInfo;
|
||||
}
|
||||
|
||||
formatGroupItem(rawGroupItem) {
|
||||
const groupChildren = rawGroupItem.children || [];
|
||||
const groupIsOpen = (groupChildren.length > 0) || false;
|
||||
const childrenCount = this.hideProjects ?
|
||||
rawGroupItem.subgroup_count :
|
||||
rawGroupItem.children_count;
|
||||
|
||||
return {
|
||||
id: rawGroupItem.id,
|
||||
name: rawGroupItem.name,
|
||||
fullName: rawGroupItem.full_name,
|
||||
description: rawGroupItem.description,
|
||||
visibility: rawGroupItem.visibility,
|
||||
avatarUrl: rawGroupItem.avatar_url,
|
||||
relativePath: rawGroupItem.relative_path,
|
||||
editPath: rawGroupItem.edit_path,
|
||||
leavePath: rawGroupItem.leave_path,
|
||||
canEdit: rawGroupItem.can_edit,
|
||||
canLeave: rawGroupItem.can_leave,
|
||||
type: rawGroupItem.type,
|
||||
permission: rawGroupItem.permission,
|
||||
children: groupChildren,
|
||||
isOpen: groupIsOpen,
|
||||
isChildrenLoading: false,
|
||||
isBeingRemoved: false,
|
||||
parentId: rawGroupItem.parent_id,
|
||||
childrenCount,
|
||||
projectCount: rawGroupItem.project_count,
|
||||
subgroupCount: rawGroupItem.subgroup_count,
|
||||
memberCount: rawGroupItem.number_users_with_delimiter,
|
||||
starCount: rawGroupItem.star_count,
|
||||
};
|
||||
}
|
||||
|
||||
removeGroup(group, parentGroup) {
|
||||
const updatedParentGroup = parentGroup;
|
||||
if (updatedParentGroup.children && updatedParentGroup.children.length) {
|
||||
updatedParentGroup.children = parentGroup.children.filter(child => group.id !== child.id);
|
||||
} else {
|
||||
this.state.groups = this.state.groups.filter(child => group.id !== child.id);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,167 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
|
||||
|
||||
export default class GroupsStore {
|
||||
constructor() {
|
||||
this.state = {};
|
||||
this.state.groups = {};
|
||||
this.state.pageInfo = {};
|
||||
}
|
||||
|
||||
setGroups(rawGroups, parent) {
|
||||
const parentGroup = parent;
|
||||
const tree = this.buildTree(rawGroups, parentGroup);
|
||||
|
||||
if (parentGroup) {
|
||||
parentGroup.subGroups = tree;
|
||||
} else {
|
||||
this.state.groups = tree;
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
resetGroups(parent) {
|
||||
const parentGroup = parent;
|
||||
parentGroup.subGroups = {};
|
||||
}
|
||||
|
||||
storePagination(pagination = {}) {
|
||||
let paginationInfo;
|
||||
|
||||
if (Object.keys(pagination).length) {
|
||||
const normalizedHeaders = normalizeHeaders(pagination);
|
||||
paginationInfo = parseIntPagination(normalizedHeaders);
|
||||
} else {
|
||||
paginationInfo = pagination;
|
||||
}
|
||||
|
||||
this.state.pageInfo = paginationInfo;
|
||||
}
|
||||
|
||||
buildTree(rawGroups, parentGroup) {
|
||||
const groups = this.decorateGroups(rawGroups);
|
||||
const tree = {};
|
||||
const mappedGroups = {};
|
||||
const orphans = [];
|
||||
|
||||
// Map groups to an object
|
||||
groups.map((group) => {
|
||||
mappedGroups[`id${group.id}`] = group;
|
||||
mappedGroups[`id${group.id}`].subGroups = {};
|
||||
return group;
|
||||
});
|
||||
|
||||
Object.keys(mappedGroups).map((key) => {
|
||||
const currentGroup = mappedGroups[key];
|
||||
if (currentGroup.parentId) {
|
||||
// If the group is not at the root level, add it to its parent array of subGroups.
|
||||
const findParentGroup = mappedGroups[`id${currentGroup.parentId}`];
|
||||
if (findParentGroup) {
|
||||
mappedGroups[`id${currentGroup.parentId}`].subGroups[`id${currentGroup.id}`] = currentGroup;
|
||||
mappedGroups[`id${currentGroup.parentId}`].isOpen = true; // Expand group if it has subgroups
|
||||
} else if (parentGroup && parentGroup.id === currentGroup.parentId) {
|
||||
tree[`id${currentGroup.id}`] = currentGroup;
|
||||
} else {
|
||||
// No parent found. We save it for later processing
|
||||
orphans.push(currentGroup);
|
||||
|
||||
// Add to tree to preserve original order
|
||||
tree[`id${currentGroup.id}`] = currentGroup;
|
||||
}
|
||||
} else {
|
||||
// If the group is at the top level, add it to first level elements array.
|
||||
tree[`id${currentGroup.id}`] = currentGroup;
|
||||
}
|
||||
|
||||
return key;
|
||||
});
|
||||
|
||||
if (orphans.length) {
|
||||
orphans.map((orphan) => {
|
||||
let found = false;
|
||||
const currentOrphan = orphan;
|
||||
|
||||
Object.keys(tree).map((key) => {
|
||||
const group = tree[key];
|
||||
|
||||
if (
|
||||
group &&
|
||||
currentOrphan.fullPath.lastIndexOf(group.fullPath) === 0 &&
|
||||
// Make sure the currently selected orphan is not the same as the group
|
||||
// we are checking here otherwise it will end up in an infinite loop
|
||||
currentOrphan.id !== group.id
|
||||
) {
|
||||
group.subGroups[currentOrphan.id] = currentOrphan;
|
||||
group.isOpen = true;
|
||||
currentOrphan.isOrphan = true;
|
||||
found = true;
|
||||
|
||||
// Delete if group was put at the top level. If not the group will be displayed twice.
|
||||
if (tree[`id${currentOrphan.id}`]) {
|
||||
delete tree[`id${currentOrphan.id}`];
|
||||
}
|
||||
}
|
||||
|
||||
return key;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
currentOrphan.isOrphan = true;
|
||||
|
||||
tree[`id${currentOrphan.id}`] = currentOrphan;
|
||||
}
|
||||
|
||||
return orphan;
|
||||
});
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
decorateGroups(rawGroups) {
|
||||
this.groups = rawGroups.map(this.decorateGroup);
|
||||
return this.groups;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
decorateGroup(rawGroup) {
|
||||
return {
|
||||
id: rawGroup.id,
|
||||
fullName: rawGroup.full_name,
|
||||
fullPath: rawGroup.full_path,
|
||||
avatarUrl: rawGroup.avatar_url,
|
||||
name: rawGroup.name,
|
||||
hasSubgroups: rawGroup.has_subgroups,
|
||||
canEdit: rawGroup.can_edit,
|
||||
description: rawGroup.description,
|
||||
webUrl: rawGroup.web_url,
|
||||
groupPath: rawGroup.group_path,
|
||||
parentId: rawGroup.parent_id,
|
||||
visibility: rawGroup.visibility,
|
||||
leavePath: rawGroup.leave_path,
|
||||
editPath: rawGroup.edit_path,
|
||||
isOpen: false,
|
||||
isOrphan: false,
|
||||
numberProjects: rawGroup.number_projects_with_delimiter,
|
||||
numberUsers: rawGroup.number_users_with_delimiter,
|
||||
permissions: {
|
||||
humanGroupAccess: rawGroup.permissions.human_group_access,
|
||||
},
|
||||
subGroups: {},
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
removeGroup(group, collection) {
|
||||
Vue.delete(collection, `id${group.id}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
toggleSubGroups(toggleGroup) {
|
||||
const group = toggleGroup;
|
||||
group.isOpen = !group.isOpen;
|
||||
return group;
|
||||
}
|
||||
}
|
|
@ -24,6 +24,11 @@ export default {
|
|||
required: true,
|
||||
type: Boolean,
|
||||
},
|
||||
showInlineEditButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
issuableRef: {
|
||||
type: String,
|
||||
required: true,
|
||||
|
@ -222,20 +227,25 @@ export default {
|
|||
<div v-else>
|
||||
<title-component
|
||||
:issuable-ref="issuableRef"
|
||||
:can-update="canUpdate"
|
||||
:title-html="state.titleHtml"
|
||||
:title-text="state.titleText" />
|
||||
:title-text="state.titleText"
|
||||
:show-inline-edit-button="showInlineEditButton"
|
||||
/>
|
||||
<description-component
|
||||
v-if="state.descriptionHtml"
|
||||
:can-update="canUpdate"
|
||||
:description-html="state.descriptionHtml"
|
||||
:description-text="state.descriptionText"
|
||||
:updated-at="state.updatedAt"
|
||||
:task-status="state.taskStatus" />
|
||||
:task-status="state.taskStatus"
|
||||
/>
|
||||
<edited-component
|
||||
v-if="hasUpdated"
|
||||
:updated-at="state.updatedAt"
|
||||
:updated-by-name="state.updatedByName"
|
||||
:updated-by-path="state.updatedByPath" />
|
||||
:updated-by-path="state.updatedByPath"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
<script>
|
||||
import animateMixin from '../mixins/animate';
|
||||
import eventHub from '../event_hub';
|
||||
import tooltip from '../../vue_shared/directives/tooltip';
|
||||
import { spriteIcon } from '../../lib/utils/common_utils';
|
||||
|
||||
export default {
|
||||
mixins: [animateMixin],
|
||||
|
@ -15,6 +18,11 @@
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
canUpdate: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
titleHtml: {
|
||||
type: String,
|
||||
required: true,
|
||||
|
@ -23,6 +31,14 @@
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
showInlineEditButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
directives: {
|
||||
tooltip,
|
||||
},
|
||||
watch: {
|
||||
titleHtml() {
|
||||
|
@ -30,24 +46,46 @@
|
|||
this.animateChange();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
pencilIcon() {
|
||||
return spriteIcon('pencil', 'link-highlight');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setPageTitle() {
|
||||
const currentPageTitleScope = this.titleEl.innerText.split('·');
|
||||
currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `;
|
||||
this.titleEl.textContent = currentPageTitleScope.join('·');
|
||||
},
|
||||
edit() {
|
||||
eventHub.$emit('open.form');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2
|
||||
class="title"
|
||||
:class="{
|
||||
'issue-realtime-pre-pulse': preAnimation,
|
||||
'issue-realtime-trigger-pulse': pulseAnimation
|
||||
}"
|
||||
v-html="titleHtml"
|
||||
>
|
||||
</h2>
|
||||
<div class="title-container">
|
||||
<h2
|
||||
class="title"
|
||||
:class="{
|
||||
'issue-realtime-pre-pulse': preAnimation,
|
||||
'issue-realtime-trigger-pulse': pulseAnimation
|
||||
}"
|
||||
v-html="titleHtml"
|
||||
>
|
||||
</h2>
|
||||
<button
|
||||
v-tooltip
|
||||
v-if="showInlineEditButton && canUpdate"
|
||||
type="button"
|
||||
class="btn-blank btn-edit note-action-button"
|
||||
v-html="pencilIcon"
|
||||
title="Edit title and description"
|
||||
data-placement="bottom"
|
||||
data-container="body"
|
||||
@click="edit"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -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}`;
|
||||
|
||||
|
|
|
@ -11,7 +11,9 @@ import Helper from '../helpers/repo_helper';
|
|||
import MonacoLoaderHelper from '../helpers/monaco_loader_helper';
|
||||
|
||||
export default {
|
||||
data: () => Store,
|
||||
data() {
|
||||
return Store;
|
||||
},
|
||||
mixins: [RepoMixin],
|
||||
components: {
|
||||
RepoSidebar,
|
||||
|
|
|
@ -9,7 +9,9 @@ import { visitUrl } from '../../lib/utils/url_utility';
|
|||
export default {
|
||||
mixins: [RepoMixin],
|
||||
|
||||
data: () => Store,
|
||||
data() {
|
||||
return Store;
|
||||
},
|
||||
|
||||
components: {
|
||||
PopupDialog,
|
||||
|
|
|
@ -3,7 +3,9 @@ import Store from '../stores/repo_store';
|
|||
import RepoMixin from '../mixins/repo_mixin';
|
||||
|
||||
export default {
|
||||
data: () => Store,
|
||||
data() {
|
||||
return Store;
|
||||
},
|
||||
mixins: [RepoMixin],
|
||||
computed: {
|
||||
buttonLabel() {
|
||||
|
|
|
@ -5,7 +5,9 @@ import Service from '../services/repo_service';
|
|||
import Helper from '../helpers/repo_helper';
|
||||
|
||||
const RepoEditor = {
|
||||
data: () => Store,
|
||||
data() {
|
||||
return Store;
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (Helper.monacoInstance) {
|
||||
|
@ -22,7 +24,8 @@ const RepoEditor = {
|
|||
const monacoInstance = Helper.monaco.editor.create(this.$el, {
|
||||
model: null,
|
||||
readOnly: false,
|
||||
contextmenu: false,
|
||||
contextmenu: true,
|
||||
scrollBeyondLastLine: false,
|
||||
});
|
||||
|
||||
Helper.monacoInstance = monacoInstance;
|
||||
|
@ -92,7 +95,7 @@ const RepoEditor = {
|
|||
},
|
||||
|
||||
blobRaw() {
|
||||
if (Helper.monacoInstance && !this.isTree) {
|
||||
if (Helper.monacoInstance) {
|
||||
this.setupEditor();
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,107 +1,78 @@
|
|||
<script>
|
||||
import TimeAgoMixin from '../../vue_shared/mixins/timeago';
|
||||
import timeAgoMixin from '../../vue_shared/mixins/timeago';
|
||||
import eventHub from '../event_hub';
|
||||
import repoMixin from '../mixins/repo_mixin';
|
||||
|
||||
const RepoFile = {
|
||||
mixins: [TimeAgoMixin],
|
||||
props: {
|
||||
file: {
|
||||
type: Object,
|
||||
required: true,
|
||||
export default {
|
||||
mixins: [
|
||||
repoMixin,
|
||||
timeAgoMixin,
|
||||
],
|
||||
props: {
|
||||
file: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
isMini: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
computed: {
|
||||
fileIcon() {
|
||||
const classObj = {
|
||||
'fa-spinner fa-spin': this.file.loading,
|
||||
[this.file.icon]: !this.file.loading,
|
||||
'fa-folder-open': !this.file.loading && this.file.opened,
|
||||
};
|
||||
return classObj;
|
||||
},
|
||||
levelIndentation() {
|
||||
return {
|
||||
marginLeft: `${this.file.level * 16}px`,
|
||||
};
|
||||
},
|
||||
},
|
||||
loading: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default() { return { tree: false }; },
|
||||
methods: {
|
||||
linkClicked(file) {
|
||||
eventHub.$emit('fileNameClicked', file);
|
||||
},
|
||||
},
|
||||
hasFiles: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
activeFile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
canShowFile() {
|
||||
return !this.loading.tree || this.hasFiles;
|
||||
},
|
||||
|
||||
fileIcon() {
|
||||
const classObj = {
|
||||
'fa-spinner fa-spin': this.file.loading,
|
||||
[this.file.icon]: !this.file.loading,
|
||||
};
|
||||
return classObj;
|
||||
},
|
||||
|
||||
fileIndentation() {
|
||||
return {
|
||||
'margin-left': `${this.file.level * 10}px`,
|
||||
};
|
||||
},
|
||||
|
||||
activeFileClass() {
|
||||
return {
|
||||
active: this.activeFile.url === this.file.url,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
linkClicked(file) {
|
||||
this.$emit('linkclicked', file);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default RepoFile;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
v-if="canShowFile"
|
||||
class="file"
|
||||
:class="activeFileClass"
|
||||
@click.prevent="linkClicked(file)">
|
||||
<td>
|
||||
<i
|
||||
class="fa fa-fw file-icon"
|
||||
:class="fileIcon"
|
||||
:style="fileIndentation"
|
||||
aria-label="file icon">
|
||||
</i>
|
||||
<a
|
||||
:href="file.url"
|
||||
class="repo-file-name"
|
||||
:title="file.url">
|
||||
{{file.name}}
|
||||
</a>
|
||||
</td>
|
||||
<tr
|
||||
class="file"
|
||||
@click.prevent="linkClicked(file)">
|
||||
<td>
|
||||
<i
|
||||
class="fa fa-fw file-icon"
|
||||
:class="fileIcon"
|
||||
:style="levelIndentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</i>
|
||||
<a
|
||||
:href="file.url"
|
||||
class="repo-file-name"
|
||||
>
|
||||
{{ file.name }}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<template v-if="!isMini">
|
||||
<td class="hidden-sm hidden-xs">
|
||||
<div class="commit-message">
|
||||
<a @click.stop :href="file.lastCommitUrl">
|
||||
{{file.lastCommitMessage}}
|
||||
<template v-if="!isMini">
|
||||
<td class="hidden-sm hidden-xs">
|
||||
<a
|
||||
@click.stop
|
||||
:href="file.lastCommit.url"
|
||||
class="commit-message"
|
||||
>
|
||||
{{ file.lastCommit.message }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</td>
|
||||
|
||||
<td class="hidden-xs text-right">
|
||||
<span
|
||||
class="commit-update"
|
||||
:title="tooltipTitle(file.lastCommitUpdate)">
|
||||
{{timeFormated(file.lastCommitUpdate)}}
|
||||
</span>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<td class="commit-update hidden-xs text-right">
|
||||
<span :title="tooltipTitle(file.lastCommit.updatedAt)">
|
||||
{{ timeFormated(file.lastCommit.updatedAt) }}
|
||||
</span>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
|
|
|
@ -4,7 +4,9 @@ import Helper from '../helpers/repo_helper';
|
|||
import RepoMixin from '../mixins/repo_mixin';
|
||||
|
||||
const RepoFileButtons = {
|
||||
data: () => Store,
|
||||
data() {
|
||||
return Store;
|
||||
},
|
||||
|
||||
mixins: [RepoMixin],
|
||||
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
<script>
|
||||
const RepoFileOptions = {
|
||||
props: {
|
||||
isMini: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
projectName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default RepoFileOptions;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr v-if="isMini" class="repo-file-options">
|
||||
<td>
|
||||
<span class="title">{{projectName}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
|
@ -1,43 +1,23 @@
|
|||
<script>
|
||||
const RepoLoadingFile = {
|
||||
props: {
|
||||
loading: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: {},
|
||||
},
|
||||
hasFiles: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
isMini: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
import repoMixin from '../mixins/repo_mixin';
|
||||
|
||||
computed: {
|
||||
showGhostLines() {
|
||||
return this.loading.tree && !this.hasFiles;
|
||||
export default {
|
||||
mixins: [
|
||||
repoMixin,
|
||||
],
|
||||
methods: {
|
||||
lineOfCode(n) {
|
||||
return `skeleton-line-${n}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
lineOfCode(n) {
|
||||
return `skeleton-line-${n}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default RepoLoadingFile;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
v-if="showGhostLines"
|
||||
class="loading-file">
|
||||
class="loading-file"
|
||||
aria-label="Loading files"
|
||||
>
|
||||
<td>
|
||||
<div
|
||||
class="animation-container animation-container-small">
|
||||
|
@ -48,29 +28,28 @@ export default RepoLoadingFile;
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td
|
||||
v-if="!isMini"
|
||||
class="hidden-sm hidden-xs">
|
||||
<div class="animation-container">
|
||||
<div
|
||||
v-for="n in 6"
|
||||
:key="n"
|
||||
:class="lineOfCode(n)">
|
||||
<template v-if="!isMini">
|
||||
<td
|
||||
class="hidden-sm hidden-xs">
|
||||
<div class="animation-container">
|
||||
<div
|
||||
v-for="n in 6"
|
||||
:key="n"
|
||||
:class="lineOfCode(n)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</td>
|
||||
|
||||
<td
|
||||
v-if="!isMini"
|
||||
class="hidden-xs">
|
||||
<div class="animation-container animation-container-small">
|
||||
<div
|
||||
v-for="n in 6"
|
||||
:key="n"
|
||||
:class="lineOfCode(n)">
|
||||
<td
|
||||
class="hidden-xs">
|
||||
<div class="animation-container animation-container-small animation-container-right">
|
||||
<div
|
||||
v-for="n in 6"
|
||||
:key="n"
|
||||
:class="lineOfCode(n)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
|
|
|
@ -1,38 +1,38 @@
|
|||
<script>
|
||||
import RepoMixin from '../mixins/repo_mixin';
|
||||
import eventHub from '../event_hub';
|
||||
import repoMixin from '../mixins/repo_mixin';
|
||||
|
||||
const RepoPreviousDirectory = {
|
||||
props: {
|
||||
prevUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
export default {
|
||||
mixins: [
|
||||
repoMixin,
|
||||
],
|
||||
props: {
|
||||
prevUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [RepoMixin],
|
||||
|
||||
computed: {
|
||||
colSpanCondition() {
|
||||
return this.isMini ? undefined : 3;
|
||||
computed: {
|
||||
colSpanCondition() {
|
||||
return this.isMini ? undefined : 3;
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
linkClicked(file) {
|
||||
this.$emit('linkclicked', file);
|
||||
methods: {
|
||||
linkClicked(file) {
|
||||
eventHub.$emit('goToPreviousDirectoryClicked', file);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default RepoPreviousDirectory;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr class="prev-directory">
|
||||
<td
|
||||
:colspan="colSpanCondition"
|
||||
@click.prevent="linkClicked(prevUrl)">
|
||||
<a :href="prevUrl">..</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="file prev-directory">
|
||||
<td
|
||||
:colspan="colSpanCondition"
|
||||
class="table-cell"
|
||||
@click.prevent="linkClicked(prevUrl)"
|
||||
>
|
||||
<a :href="prevUrl">...</a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
import Store from '../stores/repo_store';
|
||||
|
||||
export default {
|
||||
data: () => Store,
|
||||
data() {
|
||||
return Store;
|
||||
},
|
||||
computed: {
|
||||
html() {
|
||||
return this.activeFile.html;
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
<script>
|
||||
import _ from 'underscore';
|
||||
import Service from '../services/repo_service';
|
||||
import Helper from '../helpers/repo_helper';
|
||||
import Store from '../stores/repo_store';
|
||||
import eventHub from '../event_hub';
|
||||
import RepoPreviousDirectory from './repo_prev_directory.vue';
|
||||
import RepoFileOptions from './repo_file_options.vue';
|
||||
import RepoFile from './repo_file.vue';
|
||||
import RepoLoadingFile from './repo_loading_file.vue';
|
||||
import RepoMixin from '../mixins/repo_mixin';
|
||||
|
@ -11,21 +12,35 @@ import RepoMixin from '../mixins/repo_mixin';
|
|||
export default {
|
||||
mixins: [RepoMixin],
|
||||
components: {
|
||||
'repo-file-options': RepoFileOptions,
|
||||
'repo-previous-directory': RepoPreviousDirectory,
|
||||
'repo-file': RepoFile,
|
||||
'repo-loading-file': RepoLoadingFile,
|
||||
},
|
||||
|
||||
created() {
|
||||
window.addEventListener('popstate', this.checkHistory);
|
||||
},
|
||||
destroyed() {
|
||||
eventHub.$off('fileNameClicked', this.fileClicked);
|
||||
eventHub.$off('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
|
||||
window.removeEventListener('popstate', this.checkHistory);
|
||||
},
|
||||
mounted() {
|
||||
eventHub.$on('fileNameClicked', this.fileClicked);
|
||||
eventHub.$on('goToPreviousDirectoryClicked', this.goToPreviousDirectoryClicked);
|
||||
},
|
||||
data() {
|
||||
return Store;
|
||||
},
|
||||
computed: {
|
||||
flattendFiles() {
|
||||
const mapFiles = arr => (!arr.files.length ? [] : _.map(arr.files, a => [a, mapFiles(a)]));
|
||||
|
||||
data: () => Store,
|
||||
|
||||
return _.chain(this.files)
|
||||
.map(arr => [arr, mapFiles(arr)])
|
||||
.flatten()
|
||||
.value();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
checkHistory() {
|
||||
let selectedFile = this.files.find(file => location.pathname.indexOf(file.url) > -1);
|
||||
|
@ -52,21 +67,21 @@ export default {
|
|||
},
|
||||
|
||||
fileClicked(clickedFile, lineNumber) {
|
||||
let file = clickedFile;
|
||||
const file = clickedFile;
|
||||
|
||||
if (file.loading) return;
|
||||
file.loading = true;
|
||||
|
||||
if (file.type === 'tree' && file.opened) {
|
||||
file = Store.removeChildFilesOfTree(file);
|
||||
file.loading = false;
|
||||
Helper.setDirectoryToClosed(file);
|
||||
Store.setActiveLine(lineNumber);
|
||||
} else {
|
||||
const openFile = Helper.getFileFromPath(file.url);
|
||||
|
||||
if (openFile) {
|
||||
file.loading = false;
|
||||
Store.setActiveFiles(openFile);
|
||||
Store.setActiveLine(lineNumber);
|
||||
} else {
|
||||
file.loading = true;
|
||||
Service.url = file.url;
|
||||
Helper.getContent(file)
|
||||
.then(() => {
|
||||
|
@ -81,7 +96,7 @@ export default {
|
|||
|
||||
goToPreviousDirectoryClicked(prevURL) {
|
||||
Service.url = prevURL;
|
||||
Helper.getContent(null)
|
||||
Helper.getContent(null, true)
|
||||
.then(() => Helper.scrollTabsRight())
|
||||
.catch(Helper.loadingError);
|
||||
},
|
||||
|
@ -92,38 +107,43 @@ export default {
|
|||
<template>
|
||||
<div id="sidebar" :class="{'sidebar-mini' : isMini}">
|
||||
<table class="table">
|
||||
<thead v-if="!isMini">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="name">Name</th>
|
||||
<th class="hidden-sm hidden-xs last-commit">Last commit</th>
|
||||
<th class="hidden-xs last-update text-right">Last update</th>
|
||||
<th
|
||||
v-if="isMini"
|
||||
class="repo-file-options title"
|
||||
>
|
||||
<strong class="clgray">
|
||||
{{ projectName }}
|
||||
</strong>
|
||||
</th>
|
||||
<template v-else>
|
||||
<th class="name">
|
||||
Name
|
||||
</th>
|
||||
<th class="hidden-sm hidden-xs last-commit">
|
||||
Last commit
|
||||
</th>
|
||||
<th class="hidden-xs last-update text-right">
|
||||
Last update
|
||||
</th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<repo-file-options
|
||||
:is-mini="isMini"
|
||||
:project-name="projectName"
|
||||
/>
|
||||
<repo-previous-directory
|
||||
v-if="isRoot"
|
||||
v-if="!isRoot && !loading.tree"
|
||||
:prev-url="prevURL"
|
||||
@linkclicked="goToPreviousDirectoryClicked(prevURL)"/>
|
||||
/>
|
||||
<repo-loading-file
|
||||
v-if="!flattendFiles.length && loading.tree"
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
:loading="loading"
|
||||
:has-files="!!files.length"
|
||||
:is-mini="isMini"
|
||||
/>
|
||||
<repo-file
|
||||
v-for="file in files"
|
||||
v-for="file in flattendFiles"
|
||||
:key="file.id"
|
||||
:file="file"
|
||||
:is-mini="isMini"
|
||||
@linkclicked="fileClicked(file)"
|
||||
:is-tree="isTree"
|
||||
:has-files="!!files.length"
|
||||
:active-file="activeFile"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
@ -26,11 +26,13 @@ const RepoTab = {
|
|||
},
|
||||
|
||||
methods: {
|
||||
tabClicked: Store.setActiveFiles,
|
||||
|
||||
tabClicked(file) {
|
||||
Store.setActiveFiles(file);
|
||||
},
|
||||
closeTab(file) {
|
||||
if (file.changed) return;
|
||||
this.$emit('tabclosed', file);
|
||||
|
||||
Store.removeFromOpenedFiles(file);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -39,25 +41,28 @@ export default RepoTab;
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<li @click="tabClicked(tab)">
|
||||
<a
|
||||
href="#0"
|
||||
class="close"
|
||||
@click.stop.prevent="closeTab(tab)"
|
||||
:aria-label="closeLabel">
|
||||
<i
|
||||
class="fa"
|
||||
:class="changedClass"
|
||||
aria-hidden="true">
|
||||
</i>
|
||||
</a>
|
||||
<li
|
||||
:class="{ active : tab.active }"
|
||||
@click="tabClicked(tab)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close-btn"
|
||||
@click.stop.prevent="closeTab(tab)"
|
||||
:aria-label="closeLabel">
|
||||
<i
|
||||
class="fa"
|
||||
:class="changedClass"
|
||||
aria-hidden="true">
|
||||
</i>
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="repo-tab"
|
||||
:title="tab.url"
|
||||
@click.prevent="tabClicked(tab)">
|
||||
{{tab.name}}
|
||||
</a>
|
||||
</li>
|
||||
<a
|
||||
href="#"
|
||||
class="repo-tab"
|
||||
:title="tab.url"
|
||||
@click.prevent="tabClicked(tab)">
|
||||
{{tab.name}}
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
|
|
|
@ -1,36 +1,29 @@
|
|||
<script>
|
||||
import Store from '../stores/repo_store';
|
||||
import RepoTab from './repo_tab.vue';
|
||||
import RepoMixin from '../mixins/repo_mixin';
|
||||
import Store from '../stores/repo_store';
|
||||
import RepoTab from './repo_tab.vue';
|
||||
import RepoMixin from '../mixins/repo_mixin';
|
||||
|
||||
const RepoTabs = {
|
||||
mixins: [RepoMixin],
|
||||
|
||||
components: {
|
||||
'repo-tab': RepoTab,
|
||||
},
|
||||
|
||||
data: () => Store,
|
||||
|
||||
methods: {
|
||||
tabClosed(file) {
|
||||
Store.removeFromOpenedFiles(file);
|
||||
export default {
|
||||
mixins: [RepoMixin],
|
||||
components: {
|
||||
'repo-tab': RepoTab,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default RepoTabs;
|
||||
data() {
|
||||
return Store;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul id="tabs">
|
||||
<repo-tab
|
||||
v-for="tab in openedFiles"
|
||||
:key="tab.id"
|
||||
:tab="tab"
|
||||
:class="{'active' : tab.active}"
|
||||
@tabclosed="tabClosed"
|
||||
/>
|
||||
<li class="tabs-divider" />
|
||||
</ul>
|
||||
<ul
|
||||
id="tabs"
|
||||
class="list-unstyled"
|
||||
>
|
||||
<repo-tab
|
||||
v-for="tab in openedFiles"
|
||||
:key="tab.id"
|
||||
:tab="tab"
|
||||
/>
|
||||
<li class="tabs-divider" />
|
||||
</ul>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export default new Vue();
|
|
@ -1,3 +1,4 @@
|
|||
import { convertPermissionToBoolean } from '../../lib/utils/common_utils';
|
||||
import Service from '../services/repo_service';
|
||||
import Store from '../stores/repo_store';
|
||||
import Flash from '../../flash';
|
||||
|
@ -25,10 +26,6 @@ const RepoHelper = {
|
|||
|
||||
key: '',
|
||||
|
||||
isTree(data) {
|
||||
return Object.hasOwnProperty.call(data, 'blobs');
|
||||
},
|
||||
|
||||
Time: window.performance
|
||||
&& window.performance.now
|
||||
? window.performance
|
||||
|
@ -58,13 +55,20 @@ const RepoHelper = {
|
|||
},
|
||||
|
||||
setDirectoryOpen(tree, title) {
|
||||
const file = tree;
|
||||
if (!file) return undefined;
|
||||
if (!tree) return;
|
||||
|
||||
file.opened = true;
|
||||
file.icon = 'fa-folder-open';
|
||||
RepoHelper.updateHistoryEntry(file.url, title);
|
||||
return file;
|
||||
Object.assign(tree, {
|
||||
opened: true,
|
||||
});
|
||||
|
||||
RepoHelper.updateHistoryEntry(tree.url, title);
|
||||
},
|
||||
|
||||
setDirectoryToClosed(entry) {
|
||||
Object.assign(entry, {
|
||||
opened: false,
|
||||
files: [],
|
||||
});
|
||||
},
|
||||
|
||||
isRenderable() {
|
||||
|
@ -81,63 +85,23 @@ const RepoHelper = {
|
|||
.catch(RepoHelper.loadingError);
|
||||
},
|
||||
|
||||
// when you open a directory you need to put the directory files under
|
||||
// the directory... This will merge the list of the current directory and the new list.
|
||||
getNewMergedList(inDirectory, currentList, newList) {
|
||||
const newListSorted = newList.sort(this.compareFilesCaseInsensitive);
|
||||
if (!inDirectory) return newListSorted;
|
||||
const indexOfFile = currentList.findIndex(file => file.url === inDirectory.url);
|
||||
if (!indexOfFile) return newListSorted;
|
||||
return RepoHelper.mergeNewListToOldList(newListSorted, currentList, inDirectory, indexOfFile);
|
||||
},
|
||||
|
||||
// within the get new merged list this does the merging of the current list of files
|
||||
// and the new list of files. The files are never "in" another directory they just
|
||||
// appear like they are because of the margin.
|
||||
mergeNewListToOldList(newList, oldList, inDirectory, indexOfFile) {
|
||||
newList.reverse().forEach((newFile) => {
|
||||
const fileIndex = indexOfFile + 1;
|
||||
const file = newFile;
|
||||
file.level = inDirectory.level + 1;
|
||||
oldList.splice(fileIndex, 0, file);
|
||||
});
|
||||
|
||||
return oldList;
|
||||
},
|
||||
|
||||
compareFilesCaseInsensitive(a, b) {
|
||||
const aName = a.name.toLowerCase();
|
||||
const bName = b.name.toLowerCase();
|
||||
if (a.level > 0) return 0;
|
||||
if (aName < bName) { return -1; }
|
||||
if (aName > bName) { return 1; }
|
||||
return 0;
|
||||
},
|
||||
|
||||
isRoot(url) {
|
||||
// the url we are requesting -> split by the project URL. Grab the right side.
|
||||
const isRoot = !!url.split(Store.projectUrl)[1]
|
||||
// remove the first "/"
|
||||
.slice(1)
|
||||
// split this by "/"
|
||||
.split('/')
|
||||
// remove the first two items of the array... usually /tree/master.
|
||||
.slice(2)
|
||||
// we want to know the length of the array.
|
||||
// If greater than 0 not root.
|
||||
.length;
|
||||
return isRoot;
|
||||
},
|
||||
|
||||
getContent(treeOrFile) {
|
||||
getContent(treeOrFile, emptyFiles = false) {
|
||||
let file = treeOrFile;
|
||||
|
||||
if (!Store.files.length) {
|
||||
Store.loading.tree = true;
|
||||
}
|
||||
|
||||
return Service.getContent()
|
||||
.then((response) => {
|
||||
const data = response.data;
|
||||
if (response.headers && response.headers['page-title']) data.pageTitle = response.headers['page-title'];
|
||||
if (response.headers && response.headers['is-root'] && !Store.isInitialRoot) {
|
||||
Store.isRoot = convertPermissionToBoolean(response.headers['is-root']);
|
||||
Store.isInitialRoot = Store.isRoot;
|
||||
}
|
||||
|
||||
Store.isTree = RepoHelper.isTree(data);
|
||||
if (!Store.isTree) {
|
||||
if (file && file.type === 'blob') {
|
||||
if (!file) file = data;
|
||||
Store.binary = data.binary;
|
||||
|
||||
|
@ -145,38 +109,40 @@ const RepoHelper = {
|
|||
// file might be undefined
|
||||
RepoHelper.setBinaryDataAsBase64(data);
|
||||
Store.setViewToPreview();
|
||||
} else if (!Store.isPreviewView()) {
|
||||
if (!data.render_error) {
|
||||
Service.getRaw(data.raw_path)
|
||||
.then((rawResponse) => {
|
||||
Store.blobRaw = rawResponse.data;
|
||||
data.plain = rawResponse.data;
|
||||
RepoHelper.setFile(data, file);
|
||||
}).catch(RepoHelper.loadingError);
|
||||
}
|
||||
} else if (!Store.isPreviewView() && !data.render_error) {
|
||||
Service.getRaw(data.raw_path)
|
||||
.then((rawResponse) => {
|
||||
Store.blobRaw = rawResponse.data;
|
||||
data.plain = rawResponse.data;
|
||||
RepoHelper.setFile(data, file);
|
||||
}).catch(RepoHelper.loadingError);
|
||||
}
|
||||
|
||||
if (Store.isPreviewView()) {
|
||||
RepoHelper.setFile(data, file);
|
||||
}
|
||||
|
||||
// if the file tree is empty
|
||||
if (Store.files.length === 0) {
|
||||
const parentURL = Service.blobURLtoParentTree(Service.url);
|
||||
Service.url = parentURL;
|
||||
RepoHelper.getContent();
|
||||
}
|
||||
} else {
|
||||
// it's a tree
|
||||
if (!file) Store.isRoot = RepoHelper.isRoot(Service.url);
|
||||
file = RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
|
||||
const newDirectory = RepoHelper.dataToListOfFiles(data);
|
||||
Store.addFilesToDirectory(file, Store.files, newDirectory);
|
||||
Store.loading.tree = false;
|
||||
RepoHelper.setDirectoryOpen(file, data.pageTitle || data.name);
|
||||
|
||||
if (emptyFiles) {
|
||||
Store.files = [];
|
||||
}
|
||||
|
||||
this.addToDirectory(file, data);
|
||||
|
||||
Store.prevURL = Service.blobURLtoParentTree(Service.url);
|
||||
}
|
||||
}).catch(RepoHelper.loadingError);
|
||||
},
|
||||
|
||||
addToDirectory(file, data) {
|
||||
const tree = file || Store;
|
||||
const files = tree.files.concat(this.dataToListOfFiles(data, file ? file.level + 1 : 0));
|
||||
|
||||
tree.files = files;
|
||||
},
|
||||
|
||||
setFile(data, file) {
|
||||
const newFile = data;
|
||||
newFile.url = file.url || Service.url; // Grab the URL from service, happens on page refresh.
|
||||
|
@ -190,57 +156,39 @@ const RepoHelper = {
|
|||
Store.setActiveFiles(newFile);
|
||||
},
|
||||
|
||||
serializeBlob(blob) {
|
||||
const simpleBlob = RepoHelper.serializeRepoEntity('blob', blob);
|
||||
simpleBlob.lastCommitMessage = blob.last_commit.message;
|
||||
simpleBlob.lastCommitUpdate = blob.last_commit.committed_date;
|
||||
simpleBlob.loading = false;
|
||||
|
||||
return simpleBlob;
|
||||
},
|
||||
|
||||
serializeTree(tree) {
|
||||
return RepoHelper.serializeRepoEntity('tree', tree);
|
||||
},
|
||||
|
||||
serializeSubmodule(submodule) {
|
||||
return RepoHelper.serializeRepoEntity('submodule', submodule);
|
||||
},
|
||||
|
||||
serializeRepoEntity(type, entity) {
|
||||
serializeRepoEntity(type, entity, level = 0) {
|
||||
const { url, name, icon, last_commit } = entity;
|
||||
const returnObj = {
|
||||
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
url,
|
||||
level,
|
||||
icon: `fa-${icon}`,
|
||||
level: 0,
|
||||
files: [],
|
||||
loading: false,
|
||||
opened: false,
|
||||
// eslint-disable-next-line camelcase
|
||||
lastCommit: last_commit ? {
|
||||
url: `${Store.projectUrl}/commit/${last_commit.id}`,
|
||||
message: last_commit.message,
|
||||
updatedAt: last_commit.committed_date,
|
||||
} : {},
|
||||
};
|
||||
|
||||
if (entity.last_commit) {
|
||||
returnObj.lastCommitUrl = `${Store.projectUrl}/commit/${last_commit.id}`;
|
||||
} else {
|
||||
returnObj.lastCommitUrl = '';
|
||||
}
|
||||
return returnObj;
|
||||
},
|
||||
|
||||
scrollTabsRight() {
|
||||
// wait for the transition. 0.1 seconds.
|
||||
setTimeout(() => {
|
||||
const tabs = document.getElementById('tabs');
|
||||
if (!tabs) return;
|
||||
tabs.scrollLeft = tabs.scrollWidth;
|
||||
}, 200);
|
||||
const tabs = document.getElementById('tabs');
|
||||
if (!tabs) return;
|
||||
tabs.scrollLeft = tabs.scrollWidth;
|
||||
},
|
||||
|
||||
dataToListOfFiles(data) {
|
||||
dataToListOfFiles(data, level) {
|
||||
const { blobs, trees, submodules } = data;
|
||||
return [
|
||||
...blobs.map(blob => RepoHelper.serializeBlob(blob)),
|
||||
...trees.map(tree => RepoHelper.serializeTree(tree)),
|
||||
...submodules.map(submodule => RepoHelper.serializeSubmodule(submodule)),
|
||||
...trees.map(tree => RepoHelper.serializeRepoEntity('tree', tree, level)),
|
||||
...submodules.map(submodule => RepoHelper.serializeRepoEntity('submodule', submodule, level)),
|
||||
...blobs.map(blob => RepoHelper.serializeRepoEntity('blob', blob, level)),
|
||||
];
|
||||
},
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import $ from 'jquery';
|
||||
import Vue from 'vue';
|
||||
import { convertPermissionToBoolean } from '../lib/utils/common_utils';
|
||||
import Service from './services/repo_service';
|
||||
import Store from './stores/repo_store';
|
||||
import Repo from './components/repo.vue';
|
||||
|
@ -33,6 +34,8 @@ function setInitialStore(data) {
|
|||
Store.onTopOfBranch = data.onTopOfBranch;
|
||||
Store.newMrTemplateUrl = decodeURIComponent(data.newMrTemplateUrl);
|
||||
Store.customBranchURL = decodeURIComponent(data.blobUrl);
|
||||
Store.isRoot = convertPermissionToBoolean(data.root);
|
||||
Store.isInitialRoot = convertPermissionToBoolean(data.root);
|
||||
Store.currentBranch = $('button.dropdown-menu-toggle').attr('data-ref');
|
||||
Store.checkIsCommitable();
|
||||
Store.setBranchHash();
|
||||
|
|
|
@ -2,14 +2,13 @@ import Helper from '../helpers/repo_helper';
|
|||
import Service from '../services/repo_service';
|
||||
|
||||
const RepoStore = {
|
||||
monaco: {},
|
||||
monacoLoading: false,
|
||||
service: '',
|
||||
canCommit: false,
|
||||
onTopOfBranch: false,
|
||||
editMode: false,
|
||||
isTree: false,
|
||||
isRoot: false,
|
||||
isRoot: null,
|
||||
isInitialRoot: null,
|
||||
prevURL: '',
|
||||
projectId: '',
|
||||
projectName: '',
|
||||
|
@ -39,23 +38,11 @@ const RepoStore = {
|
|||
newMrTemplateUrl: '',
|
||||
branchChanged: false,
|
||||
commitMessage: '',
|
||||
binaryTypes: {
|
||||
png: false,
|
||||
md: false,
|
||||
svg: false,
|
||||
unknown: false,
|
||||
},
|
||||
loading: {
|
||||
tree: false,
|
||||
blob: false,
|
||||
},
|
||||
|
||||
resetBinaryTypes() {
|
||||
Object.keys(RepoStore.binaryTypes).forEach((key) => {
|
||||
RepoStore.binaryTypes[key] = false;
|
||||
});
|
||||
},
|
||||
|
||||
setBranchHash() {
|
||||
return Service.getBranch()
|
||||
.then((data) => {
|
||||
|
@ -72,10 +59,6 @@ const RepoStore = {
|
|||
RepoStore.isCommitable = RepoStore.onTopOfBranch && RepoStore.canCommit;
|
||||
},
|
||||
|
||||
addFilesToDirectory(inDirectory, currentList, newList) {
|
||||
RepoStore.files = Helper.getNewMergedList(inDirectory, currentList, newList);
|
||||
},
|
||||
|
||||
toggleRawPreview() {
|
||||
RepoStore.activeFile.raw = !RepoStore.activeFile.raw;
|
||||
RepoStore.activeFileLabel = RepoStore.activeFile.raw ? 'Display rendered file' : 'Display source';
|
||||
|
@ -129,30 +112,6 @@ const RepoStore = {
|
|||
RepoStore.activeFileLabel = 'Display source';
|
||||
},
|
||||
|
||||
removeChildFilesOfTree(tree) {
|
||||
let foundTree = false;
|
||||
const treeToClose = tree;
|
||||
let canStopSearching = false;
|
||||
RepoStore.files = RepoStore.files.filter((file) => {
|
||||
const isItTheTreeWeWant = file.url === treeToClose.url;
|
||||
// if it's the next tree
|
||||
if (foundTree && file.type === 'tree' && !isItTheTreeWeWant && file.level === treeToClose.level) {
|
||||
canStopSearching = true;
|
||||
return true;
|
||||
}
|
||||
if (canStopSearching) return true;
|
||||
|
||||
if (isItTheTreeWeWant) foundTree = true;
|
||||
|
||||
if (foundTree) return file.level <= treeToClose.level;
|
||||
return true;
|
||||
});
|
||||
|
||||
treeToClose.opened = false;
|
||||
treeToClose.icon = 'fa-folder';
|
||||
return treeToClose;
|
||||
},
|
||||
|
||||
removeFromOpenedFiles(file) {
|
||||
if (file.type === 'tree') return;
|
||||
let foundIndex;
|
||||
|
@ -186,6 +145,7 @@ const RepoStore = {
|
|||
if (openedFilesAlreadyExists) return;
|
||||
|
||||
openFile.changed = false;
|
||||
openFile.active = true;
|
||||
RepoStore.openedFiles.push(openFile);
|
||||
},
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
@import "framework/animations";
|
||||
@import "framework/avatar";
|
||||
@import "framework/asciidoctor";
|
||||
@import "framework/banner";
|
||||
@import "framework/blocks";
|
||||
@import "framework/buttons";
|
||||
@import "framework/badges";
|
||||
|
|
|
@ -198,6 +198,13 @@ a {
|
|||
height: 12px;
|
||||
}
|
||||
|
||||
&.animation-container-right {
|
||||
.skeleton-line-2 {
|
||||
left: 0;
|
||||
right: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
animation-duration: 1s;
|
||||
animation-fill-mode: forwards;
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
.banner-callout {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.banner-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
opacity: 1;
|
||||
|
||||
.dismiss-icon {
|
||||
color: $gl-text-color;
|
||||
font-size: $gl-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-graphic {
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
&.banner-non-empty-state {
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
}
|
|
@ -10,6 +10,10 @@
|
|||
border: 0;
|
||||
}
|
||||
|
||||
&.file-holder-bottom-radius {
|
||||
border-radius: 0 0 $border-radius-small $border-radius-small;
|
||||
}
|
||||
|
||||
&.readme-holder {
|
||||
margin: $gl-padding 0;
|
||||
|
||||
|
|
|
@ -281,6 +281,57 @@ ul.indent-list {
|
|||
|
||||
|
||||
// Specific styles for tree list
|
||||
@keyframes spin-avatar {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.groups-list-tree-container {
|
||||
.has-no-search-results {
|
||||
text-align: center;
|
||||
padding: $gl-padding;
|
||||
font-style: italic;
|
||||
color: $well-light-text-color;
|
||||
}
|
||||
|
||||
> .group-list-tree > .group-row.has-children:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.group-list-tree .avatar-container.content-loading {
|
||||
position: relative;
|
||||
|
||||
> a,
|
||||
> a .avatar {
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
> a {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
> a .avatar {
|
||||
border: 2px solid $white-normal;
|
||||
|
||||
&.identicon {
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
border: 2px outset $kdb-border;
|
||||
border-radius: 50%;
|
||||
animation: spin-avatar 3s infinite linear;
|
||||
}
|
||||
}
|
||||
|
||||
.group-list-tree {
|
||||
.folder-toggle-wrap {
|
||||
float: left;
|
||||
|
@ -293,7 +344,7 @@ ul.indent-list {
|
|||
}
|
||||
|
||||
.folder-caret,
|
||||
.folder-icon {
|
||||
.item-type-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
@ -301,11 +352,11 @@ ul.indent-list {
|
|||
width: 15px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
.item-type-icon {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
> .group-row:not(.has-subgroups) {
|
||||
> .group-row:not(.has-children) {
|
||||
.folder-caret .fa {
|
||||
opacity: 0;
|
||||
}
|
||||
|
@ -351,12 +402,23 @@ ul.indent-list {
|
|||
top: 30px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.being-removed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-row {
|
||||
padding: 0;
|
||||
border: none;
|
||||
|
||||
&.has-children {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top: 1px solid $white-normal;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
.group-row-contents:not(:hover) {
|
||||
|
@ -379,6 +441,25 @@ ul.indent-list {
|
|||
.avatar-container > a {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.has-more-items {
|
||||
display: block;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul.group-list-tree {
|
||||
li.group-row {
|
||||
&.has-description {
|
||||
.title {
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
line-height: $list-text-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -466,7 +466,7 @@ $new-sidebar-collapsed-width: 50px;
|
|||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
+ .breadcrumbs-links {
|
||||
padding-left: 17px;
|
||||
padding-left: $gl-padding;
|
||||
border-left: 1px solid $gl-text-color-quaternary;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -233,6 +233,7 @@ $container-text-max-width: 540px;
|
|||
$gl-avatar-size: 40px;
|
||||
$error-exclamation-point: $red-500;
|
||||
$border-radius-default: 4px;
|
||||
$border-radius-small: 2px;
|
||||
$settings-icon-size: 18px;
|
||||
$provider-btn-not-active-color: $blue-500;
|
||||
$link-underline-blue: $blue-500;
|
||||
|
|
|
@ -4,6 +4,6 @@
|
|||
}
|
||||
|
||||
.alert-block {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
border-right: 1px solid $border-color;
|
||||
border-left: 1px solid $border-color;
|
||||
border-bottom: none;
|
||||
border-radius: 2px;
|
||||
border-radius: $border-radius-small $border-radius-small 0 0;
|
||||
background: $gray-normal;
|
||||
}
|
||||
|
||||
|
|
|
@ -26,14 +26,117 @@
|
|||
}
|
||||
}
|
||||
|
||||
.groups-header {
|
||||
@media (min-width: $screen-sm-min) {
|
||||
.nav-links {
|
||||
width: 35%;
|
||||
.group-nav-container .nav-controls {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: $gl-padding-top 0;
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
.group-filter-form {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown-menu-align-right {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.new-project-subgroup {
|
||||
.dropdown-primary {
|
||||
min-width: 115px;
|
||||
}
|
||||
|
||||
.nav-controls {
|
||||
width: 65%;
|
||||
.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 @@
|
|||
}
|
||||
}
|
||||
|
||||
.title-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
margin-left: auto;
|
||||
// Set height to match title height
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
// Border around images in issue and MR descriptions.
|
||||
.description img:not(.emoji) {
|
||||
border: 1px solid $white-normal;
|
||||
|
|
|
@ -531,14 +531,13 @@ ul.notes {
|
|||
padding: 0;
|
||||
min-width: 16px;
|
||||
color: $gray-darkest;
|
||||
fill: $gray-darkest;
|
||||
|
||||
.fa {
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
|
@ -566,6 +565,7 @@ ul.notes {
|
|||
|
||||
.link-highlight {
|
||||
color: $gl-link-color;
|
||||
fill: $gl-link-color;
|
||||
|
||||
svg {
|
||||
fill: $gl-link-color;
|
||||
|
|
|
@ -153,28 +153,13 @@
|
|||
overflow-x: auto;
|
||||
|
||||
li {
|
||||
animation: swipeRightAppear ease-in 0.1s;
|
||||
animation-iteration-count: 1;
|
||||
transform-origin: 0% 50%;
|
||||
list-style-type: none;
|
||||
position: relative;
|
||||
background: $gray-normal;
|
||||
display: inline-block;
|
||||
padding: #{$gl-padding / 2} $gl-padding;
|
||||
border-right: 1px solid $white-dark;
|
||||
border-bottom: 1px solid $white-dark;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&.remove {
|
||||
animation: swipeRightDissapear ease-in 0.1s;
|
||||
animation-iteration-count: 1;
|
||||
transform-origin: 0% 50%;
|
||||
|
||||
a {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $white-light;
|
||||
border-bottom: none;
|
||||
|
@ -182,17 +167,21 @@
|
|||
|
||||
a {
|
||||
@include str-truncated(100px);
|
||||
color: $black;
|
||||
color: $gl-text-color;
|
||||
vertical-align: middle;
|
||||
text-decoration: none;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
&.close {
|
||||
width: auto;
|
||||
font-size: 15px;
|
||||
opacity: 1;
|
||||
margin-right: -6px;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
font-size: $gl-font-size;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.close-icon:hover {
|
||||
|
@ -201,9 +190,6 @@
|
|||
|
||||
.close-icon,
|
||||
.unsaved-icon {
|
||||
float: right;
|
||||
margin-top: 3px;
|
||||
margin-left: 15px;
|
||||
color: $gray-darkest;
|
||||
}
|
||||
|
||||
|
@ -222,9 +208,7 @@
|
|||
|
||||
#repo-file-buttons {
|
||||
background-color: $white-light;
|
||||
border-bottom: 1px solid $white-normal;
|
||||
padding: 5px 10px;
|
||||
position: relative;
|
||||
border-top: 1px solid $white-normal;
|
||||
}
|
||||
|
||||
|
@ -287,37 +271,23 @@
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
tr {
|
||||
animation: fadein 0.5s;
|
||||
cursor: pointer;
|
||||
|
||||
&.repo-file-options td {
|
||||
padding: 0;
|
||||
border-top: none;
|
||||
background: $gray-light;
|
||||
.repo-file-options {
|
||||
padding: 2px 16px;
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: $gl-font-weight-bold;
|
||||
color: $gray-darkest;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
padding: 2px 16px;
|
||||
}
|
||||
.title {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
|
@ -329,11 +299,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
.file {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a {
|
||||
@include str-truncated(250px);
|
||||
color: $almost-black;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
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
|
|
@ -1,33 +1,8 @@
|
|||
class Dashboard::GroupsController < Dashboard::ApplicationController
|
||||
include GroupTree
|
||||
|
||||
def index
|
||||
@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?
|
||||
@groups = @groups.includes(:route)
|
||||
@groups = @groups.sort(@sort)
|
||||
@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
|
||||
groups = GroupsFinder.new(current_user, all_available: false).execute
|
||||
render_group_tree(groups)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,17 +1,7 @@
|
|||
class Explore::GroupsController < Explore::ApplicationController
|
||||
def index
|
||||
@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])
|
||||
include GroupTree
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json do
|
||||
render json: {
|
||||
html: view_to_html_string("explore/groups/_groups", locals: { groups: @groups })
|
||||
}
|
||||
end
|
||||
end
|
||||
def index
|
||||
render_group_tree GroupsFinder.new(current_user).execute
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
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
|
|||
end
|
||||
|
||||
def show
|
||||
setup_projects
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
|
||||
format.json do
|
||||
render json: {
|
||||
html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
|
||||
}
|
||||
format.html do
|
||||
@has_children = GroupDescendantsFinder.new(current_user: current_user,
|
||||
parent_group: @group,
|
||||
params: params).has_children?
|
||||
end
|
||||
|
||||
format.atom do
|
||||
|
@ -64,13 +60,6 @@ class GroupsController < Groups::ApplicationController
|
|||
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
|
||||
respond_to do |format|
|
||||
format.html
|
||||
|
@ -107,20 +96,6 @@ class GroupsController < Groups::ApplicationController
|
|||
|
||||
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!
|
||||
allowed = if params[:parent_id].present?
|
||||
parent = Group.find_by(id: params[:parent_id])
|
||||
|
|
|
@ -36,6 +36,7 @@ class Projects::TreeController < Projects::ApplicationController
|
|||
|
||||
format.json do
|
||||
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
|
||||
Gitlab::GitalyClient.allow_n_plus_1_calls do
|
||||
|
|
|
@ -126,7 +126,7 @@ class ProjectsController < Projects::ApplicationController
|
|||
return access_denied! unless can?(current_user, :remove_project, @project)
|
||||
|
||||
::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
|
||||
rescue Projects::DestroyService::DestroyError => ex
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
# 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
|
|||
else
|
||||
collection_without_user
|
||||
end
|
||||
|
||||
union(projects)
|
||||
end
|
||||
|
||||
|
|
|
@ -108,6 +108,34 @@ module ApplicationSettingsHelper
|
|||
options_for_select(Sidekiq::Queue.all.map(&:name), @application_setting.sidekiq_throttling_queues)
|
||||
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
|
||||
[
|
||||
:admin_notification_email,
|
||||
|
@ -116,6 +144,10 @@ module ApplicationSettingsHelper
|
|||
:akismet_api_key,
|
||||
:akismet_enabled,
|
||||
:auto_devops_enabled,
|
||||
:circuitbreaker_failure_count_threshold,
|
||||
:circuitbreaker_failure_reset_time,
|
||||
:circuitbreaker_failure_wait_time,
|
||||
:circuitbreaker_storage_timeout,
|
||||
:clientside_sentry_dsn,
|
||||
:clientside_sentry_enabled,
|
||||
:container_registry_token_expire_delay,
|
||||
|
|
|
@ -42,6 +42,17 @@ module SortingHelper
|
|||
options
|
||||
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
|
||||
{
|
||||
sort_value_access_level_asc => sort_title_access_level_asc,
|
||||
|
|
|
@ -33,6 +33,8 @@ class ApplicationSetting < ActiveRecord::Base
|
|||
|
||||
attr_accessor :domain_whitelist_raw, :domain_blacklist_raw
|
||||
|
||||
default_value_for :id, 1
|
||||
|
||||
validates :uuid, presence: true
|
||||
|
||||
validates :session_expire_delay,
|
||||
|
@ -151,6 +153,13 @@ class ApplicationSetting < ActiveRecord::Base
|
|||
presence: true,
|
||||
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|
|
||||
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
|
||||
end
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
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
|
|
@ -0,0 +1,72 @@
|
|||
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
|
|||
include Avatarable
|
||||
include Referable
|
||||
include SelectForProjectAuthorization
|
||||
include LoadedInGroupList
|
||||
include GroupDescendant
|
||||
|
||||
has_many :group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
|
||||
alias_method :members, :group_members
|
||||
|
|
|
@ -162,6 +162,13 @@ class Namespace < ActiveRecord::Base
|
|||
.base_and_ancestors
|
||||
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
|
||||
return self.class.where(id: id) unless parent_id
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ class Project < ActiveRecord::Base
|
|||
include ProjectFeaturesCompatibility
|
||||
include SelectForProjectAuthorization
|
||||
include Routable
|
||||
include GroupDescendant
|
||||
|
||||
extend Gitlab::ConfigHelper
|
||||
extend Gitlab::CurrentSettings
|
||||
|
@ -81,6 +82,8 @@ class Project < ActiveRecord::Base
|
|||
belongs_to :creator, class_name: 'User'
|
||||
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
|
||||
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_many :boards, before_add: :validate_board_limit
|
||||
|
@ -479,6 +482,13 @@ class Project < ActiveRecord::Base
|
|||
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?
|
||||
return namespace.lfs_enabled? if self[:lfs_enabled].nil?
|
||||
|
||||
|
@ -1262,7 +1272,7 @@ class Project < ActiveRecord::Base
|
|||
|
||||
# self.forked_from_project will be nil before the project is saved, so
|
||||
# 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
|
||||
|
||||
level <= original_project.visibility_level
|
||||
|
@ -1549,10 +1559,6 @@ class Project < ActiveRecord::Base
|
|||
map.public_path_for_source_path(path)
|
||||
end
|
||||
|
||||
def parent
|
||||
namespace
|
||||
end
|
||||
|
||||
def parent_changed?
|
||||
namespace_id_changed?
|
||||
end
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
class BaseSerializer
|
||||
def initialize(parameters = {})
|
||||
@request = EntityRequest.new(parameters)
|
||||
attr_reader :params
|
||||
|
||||
def initialize(params = {})
|
||||
@params = params
|
||||
@request = EntityRequest.new(params)
|
||||
end
|
||||
|
||||
def represent(resource, opts = {}, entity_class = nil)
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
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
|
|
@ -1,4 +1,6 @@
|
|||
class EnvironmentSerializer < BaseSerializer
|
||||
include WithPagination
|
||||
|
||||
Item = Struct.new(:name, :size, :latest)
|
||||
|
||||
entity EnvironmentEntity
|
||||
|
@ -7,18 +9,10 @@ class EnvironmentSerializer < BaseSerializer
|
|||
tap { @itemize = true }
|
||||
end
|
||||
|
||||
def with_pagination(request, response)
|
||||
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
|
||||
end
|
||||
|
||||
def itemized?
|
||||
@itemize
|
||||
end
|
||||
|
||||
def paginated?
|
||||
@paginator.present?
|
||||
end
|
||||
|
||||
def represent(resource, opts = {})
|
||||
if itemized?
|
||||
itemize(resource).map do |item|
|
||||
|
@ -27,8 +21,6 @@ class EnvironmentSerializer < BaseSerializer
|
|||
latest: super(item.latest, opts) }
|
||||
end
|
||||
else
|
||||
resource = @paginator.paginate(resource) if paginated?
|
||||
|
||||
super(resource, opts)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
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
|
|
@ -0,0 +1,51 @@
|
|||
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
|
|
@ -1,19 +1,5 @@
|
|||
class GroupSerializer < BaseSerializer
|
||||
include WithPagination
|
||||
|
||||
entity GroupEntity
|
||||
|
||||
def with_pagination(request, response)
|
||||
tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) }
|
||||
end
|
||||
|
||||
def paginated?
|
||||
@paginator.present?
|
||||
end
|
||||
|
||||
def represent(resource, opts = {})
|
||||
if paginated?
|
||||
super(@paginator.paginate(resource), opts)
|
||||
else
|
||||
super(resource, opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,16 +1,10 @@
|
|||
class PipelineSerializer < BaseSerializer
|
||||
include WithPagination
|
||||
|
||||
InvalidResourceError = Class.new(StandardError)
|
||||
|
||||
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 = {})
|
||||
if resource.is_a?(ActiveRecord::Relation)
|
||||
|
||||
|
|
|
@ -15,8 +15,8 @@ module Projects
|
|||
|
||||
refresh_forks_count(@project.forked_from_project)
|
||||
|
||||
@project.forked_project_link.destroy
|
||||
@project.fork_network_member.destroy
|
||||
@project.forked_project_link.destroy
|
||||
end
|
||||
|
||||
def refresh_forks_count(project)
|
||||
|
|
|
@ -530,6 +530,32 @@
|
|||
= succeed "." do
|
||||
= 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
|
||||
%legend Repository Checks
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
.top-area
|
||||
%ul.nav-links
|
||||
= 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
|
||||
= 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
|
||||
.nav-controls
|
||||
= render 'shared/groups/search_form'
|
||||
= render 'shared/groups/dropdown'
|
||||
- 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"
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
.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.
|
|
@ -1,9 +1,2 @@
|
|||
.js-groups-list-holder
|
||||
#dashboard-group-app{ data: { endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path } }
|
||||
.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' }
|
||||
#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' } }
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
= webpack_bundle_tag 'common_vue'
|
||||
= webpack_bundle_tag 'groups'
|
||||
|
||||
- if @groups.empty?
|
||||
= render 'empty_state'
|
||||
- if params[:filter].blank? && @groups.empty?
|
||||
= render 'shared/groups/empty_state'
|
||||
- else
|
||||
= render 'groups'
|
||||
|
|
|
@ -1,6 +1,2 @@
|
|||
.js-groups-list-holder
|
||||
%ul.content-list
|
||||
- @groups.each do |group|
|
||||
= render 'shared/groups/group', group: group
|
||||
|
||||
= paginate @groups, theme: 'gitlab'
|
||||
#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' } }
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
- page_title "Groups"
|
||||
- header_title "Groups", dashboard_groups_path
|
||||
|
||||
= webpack_bundle_tag 'common_vue'
|
||||
= webpack_bundle_tag 'groups'
|
||||
|
||||
- if current_user
|
||||
= render 'dashboard/groups_head'
|
||||
- else
|
||||
|
@ -17,7 +20,7 @@
|
|||
%p Below you will find all the groups that are public.
|
||||
%p You can easily contribute to them by requesting to join these groups.
|
||||
|
||||
- if @groups.present?
|
||||
= render 'groups'
|
||||
- else
|
||||
- if params[:filter].blank? && @groups.empty?
|
||||
.nothing-here-block No public groups
|
||||
- else
|
||||
= render 'groups'
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
= 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' } }
|
|
@ -1,8 +0,0 @@
|
|||
%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
|
|
@ -1,5 +1,6 @@
|
|||
- @no_container = true
|
||||
- breadcrumb_title "Details"
|
||||
- can_create_subgroups = can?(current_user, :create_subgroup, @group)
|
||||
|
||||
= content_for :meta_tags do
|
||||
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
|
||||
|
@ -7,13 +8,38 @@
|
|||
= render 'groups/home_panel'
|
||||
|
||||
.groups-header{ class: container_class }
|
||||
.top-area
|
||||
= render 'groups/show_nav'
|
||||
.nav-controls
|
||||
= render 'shared/projects/search_form'
|
||||
= render 'shared/projects/dropdown'
|
||||
.group-nav-container
|
||||
.nav-controls.clearfix
|
||||
= render "shared/groups/search_form"
|
||||
= render "shared/groups/dropdown", show_archive_options: true
|
||||
- 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
|
||||
- new_project_label = _("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
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
- 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.
|
|
@ -1,6 +1,6 @@
|
|||
- 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 } }
|
||||
.editor-ref
|
||||
= icon('code-fork')
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
- 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"})
|
|
@ -1,24 +1,37 @@
|
|||
- @content_class = "limit-container-width" unless fluid_layout
|
||||
- breadcrumb_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?
|
||||
.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',
|
||||
cluster_status: @cluster.status_name,
|
||||
cluster_status_reason: @cluster.status_reason } }
|
||||
.col-sm-4
|
||||
= render 'sidebar'
|
||||
.col-sm-8
|
||||
%label.append-bottom-10{ for: 'enable-cluster-integration' }
|
||||
= s_('ClusterIntegration|Enable cluster integration')
|
||||
%p
|
||||
- if @cluster.enabled?
|
||||
- if can?(current_user, :update_cluster, @cluster)
|
||||
= s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
|
||||
|
||||
%section.settings
|
||||
%h4= s_('ClusterIntegration|Enable cluster integration')
|
||||
.settings-content.expanded
|
||||
|
||||
.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
|
||||
- if @cluster.enabled?
|
||||
- if can?(current_user, :update_cluster, @cluster)
|
||||
= s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
|
||||
- else
|
||||
= s_('ClusterIntegration|Cluster integration is enabled for this project.')
|
||||
- else
|
||||
= s_('ClusterIntegration|Cluster integration is enabled for this project.')
|
||||
- else
|
||||
= s_('ClusterIntegration|Cluster integration is disabled for this project.')
|
||||
= s_('ClusterIntegration|Cluster integration is disabled for this project.')
|
||||
|
||||
= form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field|
|
||||
= form_errors(@cluster)
|
||||
|
@ -36,35 +49,28 @@
|
|||
.form-group
|
||||
= field.submit s_('ClusterIntegration|Save'), class: 'btn btn-success'
|
||||
|
||||
- if can?(current_user, :admin_cluster, @cluster)
|
||||
%label.append-bottom-10{ for: 'google-container-engine' }
|
||||
= 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 }
|
||||
%section.settings#js-cluster-details
|
||||
.settings-header
|
||||
%h4= s_('ClusterIntegration|Cluster details')
|
||||
%button.btn.js-settings-toggle
|
||||
= expanded ? 'Collapse' : 'Expand'
|
||||
%p= s_('ClusterIntegration|See and edit the details for your cluster')
|
||||
|
||||
.hidden.js-cluster-error.alert.alert-danger.alert-block{ role: 'alert' }
|
||||
= s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
|
||||
%p.js-error-reason
|
||||
.settings-content.no-animate{ class: ('expanded' if expanded) }
|
||||
|
||||
.hidden.js-cluster-creating.alert.alert-info.alert-block{ role: 'alert' }
|
||||
= s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
|
||||
.form_group.append-bottom-20
|
||||
%label.append-bottom-10{ for: 'cluter-name' }
|
||||
= s_('ClusterIntegration|Cluster name')
|
||||
.input-group
|
||||
%input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true }
|
||||
%span.input-group-addon.clipboard-addon
|
||||
= clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
|
||||
|
||||
.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
|
||||
%label.append-bottom-10{ for: 'cluter-name' }
|
||||
= s_('ClusterIntegration|Cluster name')
|
||||
.input-group
|
||||
%input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true }
|
||||
%span.input-group-addon.clipboard-addon
|
||||
= clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
|
||||
|
||||
- if can?(current_user, :admin_cluster, @cluster)
|
||||
.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"})
|
||||
%section.settings#js-cluster-advanced-settings
|
||||
.settings-header
|
||||
%h4= s_('ClusterIntegration|Advanced settings')
|
||||
%button.btn.js-settings-toggle
|
||||
= expanded ? 'Collapse' : 'Expand'
|
||||
%p= s_('ClusterIntegration|Manage Cluster integration on your GitLab project')
|
||||
.settings-content.no-animate{ class: ('expanded' if expanded) }
|
||||
= render 'advanced_settings'
|
||||
|
|
|
@ -24,10 +24,15 @@
|
|||
%p
|
||||
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)
|
||||
%div{ class: container_class }
|
||||
- if show_auto_devops_callout?(@project)
|
||||
= render 'shared/auto_devops_callout'
|
||||
.prepend-top-20
|
||||
.empty_wrapper
|
||||
%h3.page-title-empty
|
||||
|
|
|
@ -13,8 +13,6 @@
|
|||
|
||||
- if @project.merge_requests.exists?
|
||||
%div{ class: container_class }
|
||||
- if show_auto_devops_callout?(@project)
|
||||
= render 'shared/auto_devops_callout'
|
||||
.top-area
|
||||
= render 'shared/issuable/nav', type: :merge_requests
|
||||
.nav-controls
|
||||
|
|
|
@ -54,6 +54,10 @@
|
|||
= f.label :visibility_level, class: 'label-light' do #the label here seems wrong
|
||||
Import project from
|
||||
.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
|
||||
- if github_import_enabled?
|
||||
= link_to new_import_github_path, class: 'btn import_github' do
|
||||
|
@ -87,10 +91,6 @@
|
|||
- if git_import_enabled?
|
||||
%button.btn.js-toggle-button.import_git{ type: "button" }
|
||||
= 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
|
||||
.js-toggle-content.hide.toggle-import-form
|
||||
%hr
|
||||
|
|
|
@ -2,8 +2,6 @@
|
|||
- page_title "Pipelines"
|
||||
|
||||
%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),
|
||||
"help-page-path" => help_page_path('ci/quick_start/README'),
|
||||
"help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
|
||||
|
|
|
@ -12,7 +12,5 @@
|
|||
= webpack_bundle_tag 'repo'
|
||||
|
||||
%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/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
.user-callout{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
|
||||
.bordered-box.landing.content-block
|
||||
%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')
|
||||
.user-callout-copy
|
||||
%h4= s_('AutoDevOps|Auto DevOps (Beta)')
|
||||
%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.')
|
||||
%p
|
||||
- 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 }
|
||||
.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
|
||||
.banner-graphic
|
||||
= custom_icon('icon_autodevops')
|
||||
|
||||
= 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'
|
||||
.prepend-top-10.prepend-left-10.append-bottom-10
|
||||
%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
|
||||
- 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 }
|
||||
.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'
|
||||
|
||||
%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')
|
||||
|
|
|
@ -1,18 +1,32 @@
|
|||
.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' }
|
||||
%span.dropdown-label
|
||||
- if @sort.present?
|
||||
= sort_options_hash[@sort]
|
||||
- else
|
||||
= sort_title_recently_created
|
||||
= sort_options_hash[default_sort_by]
|
||||
= icon('chevron-down')
|
||||
%ul.dropdown-menu.dropdown-menu-align-right
|
||||
%li
|
||||
= link_to filter_groups_path(sort: sort_value_recently_created) do
|
||||
= sort_title_recently_created
|
||||
= link_to filter_groups_path(sort: sort_value_oldest_created) do
|
||||
= sort_title_oldest_created
|
||||
= link_to filter_groups_path(sort: sort_value_recently_updated) do
|
||||
= sort_title_recently_updated
|
||||
= link_to filter_groups_path(sort: sort_value_oldest_updated) do
|
||||
= sort_title_oldest_updated
|
||||
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable
|
||||
%li.dropdown-header
|
||||
= _("Sort by")
|
||||
- groups_sort_options_hash.each do |value, title|
|
||||
%li.js-filter-sort-order
|
||||
= link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do
|
||||
= title
|
||||
- if show_archive_options
|
||||
%li.divider
|
||||
%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
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
.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 @@
|
|||
= link_to edit_group_path(group), class: "btn" do
|
||||
= 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')
|
||||
|
||||
.stats
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue