Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
61a1ecc5e9
commit
ceb0c326ae
|
@ -1,61 +0,0 @@
|
|||
<script>
|
||||
// This component is being replaced in favor of './board_card.vue' for GraphQL boards
|
||||
import sidebarEventHub from '~/sidebar/event_hub';
|
||||
import eventHub from '../eventhub';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BoardCardLayout: BoardCardLayoutDeprecated,
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
required: false,
|
||||
},
|
||||
issue: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// These are methods instead of computed's, because boardsStore is not reactive.
|
||||
isActive() {
|
||||
return this.getActiveId() === this.issue.id;
|
||||
},
|
||||
getActiveId() {
|
||||
return boardsStore.detail?.issue?.id;
|
||||
},
|
||||
showIssue({ isMultiSelect }) {
|
||||
// If no issues are opened, close all sidebars first
|
||||
if (!this.getActiveId()) {
|
||||
sidebarEventHub.$emit('sidebar.closeAll');
|
||||
}
|
||||
if (this.isActive()) {
|
||||
eventHub.$emit('clearDetailIssue', isMultiSelect);
|
||||
|
||||
if (isMultiSelect) {
|
||||
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
|
||||
}
|
||||
} else {
|
||||
eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
|
||||
boardsStore.setListDetail(this.list);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<board-card-layout
|
||||
data-qa-selector="board_card"
|
||||
:issue="issue"
|
||||
:list="list"
|
||||
:is-active="isActive()"
|
||||
v-bind="$attrs"
|
||||
@show="showIssue"
|
||||
/>
|
||||
</template>
|
|
@ -1,91 +0,0 @@
|
|||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import { ISSUABLE } from '~/boards/constants';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue';
|
||||
|
||||
export default {
|
||||
name: 'BoardCardLayout',
|
||||
components: {
|
||||
IssueCardInner: IssueCardInnerDeprecated,
|
||||
},
|
||||
mixins: [glFeatureFlagMixin()],
|
||||
props: {
|
||||
list: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
required: false,
|
||||
},
|
||||
issue: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
required: false,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showDetail: false,
|
||||
multiSelect: boardsStore.multiSelect,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isSwimlanesOn']),
|
||||
multiSelectVisible() {
|
||||
return this.multiSelect.list.findIndex((issue) => issue.id === this.issue.id) > -1;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['setActiveId']),
|
||||
mouseDown() {
|
||||
this.showDetail = true;
|
||||
},
|
||||
mouseMove() {
|
||||
this.showDetail = false;
|
||||
},
|
||||
showIssue(e) {
|
||||
// Don't do anything if this happened on a no trigger element
|
||||
if (e.target.classList.contains('js-no-trigger')) return;
|
||||
|
||||
this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
:class="{
|
||||
'multi-select': multiSelectVisible,
|
||||
'user-can-drag': !disabled && issue.id,
|
||||
'is-disabled': disabled || !issue.id,
|
||||
'is-active': isActive,
|
||||
}"
|
||||
:index="index"
|
||||
:data-issue-id="issue.id"
|
||||
:data-issue-iid="issue.iid"
|
||||
:data-issue-path="issue.referencePath"
|
||||
data-testid="board_card"
|
||||
class="board-card gl-p-5 gl-rounded-base"
|
||||
@mousedown="mouseDown"
|
||||
@mousemove="mouseMove"
|
||||
@mouseup="showIssue($event)"
|
||||
>
|
||||
<issue-card-inner :list="list" :issue="issue" :update-filters="true" />
|
||||
</li>
|
||||
</template>
|
|
@ -1,112 +0,0 @@
|
|||
<script>
|
||||
// This component is being replaced in favor of './board_column.vue' for GraphQL boards
|
||||
import Sortable from 'sortablejs';
|
||||
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_deprecated.vue';
|
||||
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import BoardList from './board_list_deprecated.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BoardListHeader,
|
||||
BoardList,
|
||||
},
|
||||
inject: {
|
||||
boardId: {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
detailIssue: boardsStore.detail,
|
||||
filter: boardsStore.filter,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
listIssues() {
|
||||
return this.list.issues;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filter: {
|
||||
handler() {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.list.page = 1;
|
||||
this.list.getIssues(true).catch(() => {
|
||||
// TODO: handle request error
|
||||
});
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
'list.highlighted': {
|
||||
handler(highlighted) {
|
||||
if (highlighted) {
|
||||
this.$nextTick(() => {
|
||||
this.$el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const instance = this;
|
||||
|
||||
const sortableOptions = getBoardSortableDefaultOptions({
|
||||
disabled: this.disabled,
|
||||
group: 'boards',
|
||||
draggable: '.is-draggable',
|
||||
handle: '.js-board-handle',
|
||||
onEnd(e) {
|
||||
sortableEnd();
|
||||
|
||||
const sortable = this;
|
||||
|
||||
if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
|
||||
const order = sortable.toArray();
|
||||
const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
|
||||
|
||||
instance.$nextTick(() => {
|
||||
boardsStore.moveList(list, order);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Sortable.create(this.$el.parentNode, sortableOptions);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'is-draggable': !list.preset,
|
||||
'is-expandable': list.isExpandable,
|
||||
'is-collapsed': !list.isExpanded,
|
||||
'board-type-assignee': list.type === 'assignee',
|
||||
}"
|
||||
:data-id="list.id"
|
||||
class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
|
||||
data-qa-selector="board_list"
|
||||
>
|
||||
<div
|
||||
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
|
||||
:class="{ 'board-column-highlighted': list.highlighted }"
|
||||
>
|
||||
<board-list-header :list="list" :disabled="disabled" />
|
||||
<board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,459 +0,0 @@
|
|||
<script>
|
||||
import { GlLoadingIcon } from '@gitlab/ui';
|
||||
import { Sortable, MultiDrag } from 'sortablejs';
|
||||
import createFlash from '~/flash';
|
||||
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
|
||||
import { sprintf, __ } from '~/locale';
|
||||
import eventHub from '../eventhub';
|
||||
import {
|
||||
getBoardSortableDefaultOptions,
|
||||
sortableStart,
|
||||
sortableEnd,
|
||||
} from '../mixins/sortable_default_options';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import boardCard from './board_card_deprecated.vue';
|
||||
import boardNewIssue from './board_new_issue_deprecated.vue';
|
||||
|
||||
// This component is being replaced in favor of './board_list.vue' for GraphQL boards
|
||||
|
||||
Sortable.mount(new MultiDrag());
|
||||
|
||||
export default {
|
||||
name: 'BoardList',
|
||||
components: {
|
||||
boardCard,
|
||||
boardNewIssue,
|
||||
GlLoadingIcon,
|
||||
},
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
list: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
issues: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scrollOffset: 250,
|
||||
filters: boardsStore.state.filters,
|
||||
showCount: false,
|
||||
showIssueForm: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
paginatedIssueText() {
|
||||
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
|
||||
pageSize: this.list.issues.length,
|
||||
total: this.list.issuesSize,
|
||||
});
|
||||
},
|
||||
issuesSizeExceedsMax() {
|
||||
return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
|
||||
},
|
||||
loading() {
|
||||
return this.list.loading;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filters: {
|
||||
handler() {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.list.loadingMore = false;
|
||||
this.$refs.list.scrollTop = 0;
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
issues() {
|
||||
this.$nextTick(() => {
|
||||
if (
|
||||
this.scrollHeight() <= this.listHeight() &&
|
||||
this.list.issuesSize > this.list.issues.length &&
|
||||
this.list.isExpanded
|
||||
) {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.list.page += 1;
|
||||
this.list.getIssues(false).catch(() => {
|
||||
// TODO: handle request error
|
||||
});
|
||||
}
|
||||
|
||||
if (this.scrollHeight() > Math.ceil(this.listHeight())) {
|
||||
this.showCount = true;
|
||||
} else {
|
||||
this.showCount = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
'list.id': {
|
||||
handler(id) {
|
||||
if (id) {
|
||||
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
|
||||
eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
|
||||
},
|
||||
mounted() {
|
||||
const multiSelectOpts = {
|
||||
multiDrag: true,
|
||||
selectedClass: 'js-multi-select',
|
||||
animation: 500,
|
||||
};
|
||||
|
||||
const options = getBoardSortableDefaultOptions({
|
||||
scroll: true,
|
||||
disabled: this.disabled,
|
||||
filter: '.board-list-count, .is-disabled',
|
||||
dataIdAttr: 'data-issue-id',
|
||||
removeCloneOnHide: false,
|
||||
...multiSelectOpts,
|
||||
group: {
|
||||
name: 'issues',
|
||||
/**
|
||||
* Dynamically determine between which containers
|
||||
* items can be moved or copied as
|
||||
* Assignee lists (EE feature) require this behavior
|
||||
*/
|
||||
pull: (to, from, dragEl, e) => {
|
||||
// As per Sortable's docs, `to` should provide
|
||||
// reference to exact sortable container on which
|
||||
// we're trying to drag element, but either it is
|
||||
// a library's bug or our markup structure is too complex
|
||||
// that `to` never points to correct container
|
||||
// See https://github.com/RubaXa/Sortable/issues/1037
|
||||
//
|
||||
// So we use `e.target` which is always accurate about
|
||||
// which element we're currently dragging our card upon
|
||||
// So from there, we can get reference to actual container
|
||||
// and thus the container type to enable Copy or Move
|
||||
if (e.target) {
|
||||
const containerEl =
|
||||
e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
|
||||
const toBoardType = containerEl.dataset.boardType;
|
||||
const cloneActions = {
|
||||
label: ['milestone', 'assignee', 'iteration'],
|
||||
assignee: ['milestone', 'label', 'iteration'],
|
||||
milestone: ['label', 'assignee', 'iteration'],
|
||||
iteration: ['label', 'assignee', 'milestone'],
|
||||
};
|
||||
|
||||
if (toBoardType) {
|
||||
const fromBoardType = this.list.type;
|
||||
// For each list we check if the destination list is
|
||||
// a the list were we should clone the issue
|
||||
const shouldClone = Object.entries(cloneActions).some(
|
||||
(entry) => fromBoardType === entry[0] && entry[1].includes(toBoardType),
|
||||
);
|
||||
|
||||
if (shouldClone) {
|
||||
return 'clone';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
revertClone: true,
|
||||
},
|
||||
onStart: (e) => {
|
||||
const card = this.$refs.issue[e.oldIndex];
|
||||
|
||||
card.showDetail = false;
|
||||
|
||||
const { list } = card;
|
||||
|
||||
const issue = list.findIssue(Number(e.item.dataset.issueId));
|
||||
|
||||
boardsStore.startMoving(list, issue);
|
||||
|
||||
this.$root.$emit(BV_HIDE_TOOLTIP);
|
||||
|
||||
sortableStart();
|
||||
},
|
||||
onAdd: (e) => {
|
||||
const { items = [], newIndicies = [] } = e;
|
||||
if (items.length) {
|
||||
// Not using e.newIndex here instead taking a min of all
|
||||
// the newIndicies. Basically we have to find that during
|
||||
// a drop what is the index we're going to start putting
|
||||
// all the dropped elements from.
|
||||
const newIndex = Math.min(...newIndicies.map((obj) => obj.index).filter((i) => i !== -1));
|
||||
const issues = items.map((item) =>
|
||||
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
|
||||
);
|
||||
|
||||
boardsStore.moveMultipleIssuesToList({
|
||||
listFrom: boardsStore.moving.list,
|
||||
listTo: this.list,
|
||||
issues,
|
||||
newIndex,
|
||||
});
|
||||
} else {
|
||||
boardsStore.moveIssueToList(
|
||||
boardsStore.moving.list,
|
||||
this.list,
|
||||
boardsStore.moving.issue,
|
||||
e.newIndex,
|
||||
);
|
||||
this.$nextTick(() => {
|
||||
e.item.remove();
|
||||
});
|
||||
}
|
||||
},
|
||||
onUpdate: (e) => {
|
||||
const sortedArray = this.sortable.toArray().filter((id) => id !== '-1');
|
||||
|
||||
const { items = [], newIndicies = [], oldIndicies = [] } = e;
|
||||
if (items.length) {
|
||||
const newIndex = Math.min(...newIndicies.map((obj) => obj.index));
|
||||
const issues = items.map((item) =>
|
||||
boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
|
||||
);
|
||||
boardsStore.moveMultipleIssuesInList({
|
||||
list: this.list,
|
||||
issues,
|
||||
oldIndicies: oldIndicies.map((obj) => obj.index),
|
||||
newIndex,
|
||||
idArray: sortedArray,
|
||||
});
|
||||
e.items.forEach((el) => {
|
||||
Sortable.utils.deselect(el);
|
||||
});
|
||||
boardsStore.clearMultiSelect();
|
||||
return;
|
||||
}
|
||||
|
||||
boardsStore.moveIssueInList(
|
||||
this.list,
|
||||
boardsStore.moving.issue,
|
||||
e.oldIndex,
|
||||
e.newIndex,
|
||||
sortedArray,
|
||||
);
|
||||
},
|
||||
onEnd: (e) => {
|
||||
const { items = [], clones = [], to } = e;
|
||||
|
||||
// This is not a multi select operation
|
||||
if (!items.length && !clones.length) {
|
||||
sortableEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
let toList;
|
||||
if (to) {
|
||||
const containerEl = to.closest('.js-board-list');
|
||||
toList = boardsStore.findList('id', Number(containerEl.dataset.board));
|
||||
}
|
||||
|
||||
/**
|
||||
* onEnd is called irrespective if the cards were moved in the
|
||||
* same list or the other list. Don't remove items if it's same list.
|
||||
*/
|
||||
const isSameList = toList && toList.id === this.list.id;
|
||||
if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
|
||||
const issues = items.map((item) => this.list.findIssue(Number(item.dataset.issueId)));
|
||||
if (
|
||||
issues.filter(Boolean).length &&
|
||||
!boardsStore.issuesAreContiguous(this.list, issues)
|
||||
) {
|
||||
const indexes = [];
|
||||
const ids = this.list.issues.map((i) => i.id);
|
||||
issues.forEach((issue) => {
|
||||
const index = ids.indexOf(issue.id);
|
||||
if (index > -1) {
|
||||
indexes.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Descending sort because splice would cause index discrepancy otherwise
|
||||
const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
|
||||
|
||||
sortedIndexes.forEach((i) => {
|
||||
/**
|
||||
* **setTimeout and splice each element one-by-one in a loop
|
||||
* is intended.**
|
||||
*
|
||||
* The problem here is all the indexes are in the list but are
|
||||
* non-contiguous. Due to that, when we splice all the indexes,
|
||||
* at once, Vue -- during a re-render -- is unable to find reference
|
||||
* nodes and the entire app crashes.
|
||||
*
|
||||
* If the indexes are contiguous, this piece of code is not
|
||||
* executed. If it is, this is a possible regression. Only when
|
||||
* issue indexes are far apart, this logic should ever kick in.
|
||||
*/
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.list.issues.splice(i, 1);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!toList) {
|
||||
createFlash({
|
||||
message: __('Something went wrong while performing the action.'),
|
||||
});
|
||||
}
|
||||
|
||||
if (!isSameList) {
|
||||
boardsStore.clearMultiSelect();
|
||||
|
||||
// Since Vue's list does not re-render the same keyed item, we'll
|
||||
// remove `multi-select` class to express it's unselected
|
||||
if (clones && clones.length) {
|
||||
clones.forEach((el) => el.classList.remove('multi-select'));
|
||||
}
|
||||
|
||||
// Due to some bug which I am unable to figure out
|
||||
// Sortable does not deselect some pending items from the
|
||||
// source list.
|
||||
// We'll just do it forcefully here.
|
||||
Array.from(document.querySelectorAll('.js-multi-select') || []).forEach((item) => {
|
||||
Sortable.utils.deselect(item);
|
||||
});
|
||||
|
||||
/**
|
||||
* SortableJS leaves all the moving items "as is" on the DOM.
|
||||
* Vue picks up and rehydrates the DOM, but we need to explicity
|
||||
* remove the "trash" items from the DOM.
|
||||
*
|
||||
* This is in parity to the logic on single item move from a list/in
|
||||
* a list. For reference, look at the implementation of onAdd method.
|
||||
*/
|
||||
this.$nextTick(() => {
|
||||
if (items && items.length) {
|
||||
items.forEach((item) => {
|
||||
item.remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
sortableEnd();
|
||||
},
|
||||
onMove(e) {
|
||||
return !e.related.classList.contains('board-list-count');
|
||||
},
|
||||
onSelect(e) {
|
||||
const {
|
||||
item: { classList },
|
||||
} = e;
|
||||
|
||||
if (
|
||||
classList &&
|
||||
classList.contains('js-multi-select') &&
|
||||
!classList.contains('multi-select')
|
||||
) {
|
||||
Sortable.utils.deselect(e.item);
|
||||
}
|
||||
},
|
||||
onDeselect: (e) => {
|
||||
const {
|
||||
item: { dataset, classList },
|
||||
} = e;
|
||||
|
||||
if (
|
||||
classList &&
|
||||
classList.contains('multi-select') &&
|
||||
!classList.contains('js-multi-select')
|
||||
) {
|
||||
const issue = this.list.findIssue(Number(dataset.issueId));
|
||||
boardsStore.toggleMultiSelect(issue);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.sortable = Sortable.create(this.$refs.list, options);
|
||||
|
||||
// Scroll event on list to load more
|
||||
this.$refs.list.addEventListener('scroll', this.onScroll);
|
||||
},
|
||||
beforeDestroy() {
|
||||
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
|
||||
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
|
||||
this.$refs.list.removeEventListener('scroll', this.onScroll);
|
||||
},
|
||||
methods: {
|
||||
listHeight() {
|
||||
return this.$refs.list.getBoundingClientRect().height;
|
||||
},
|
||||
scrollHeight() {
|
||||
return this.$refs.list.scrollHeight;
|
||||
},
|
||||
scrollTop() {
|
||||
return this.$refs.list.scrollTop + this.listHeight();
|
||||
},
|
||||
scrollToTop() {
|
||||
this.$refs.list.scrollTop = 0;
|
||||
},
|
||||
loadNextPage() {
|
||||
const getIssues = this.list.nextPage();
|
||||
const loadingDone = () => {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.list.loadingMore = false;
|
||||
};
|
||||
|
||||
if (getIssues) {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.list.loadingMore = true;
|
||||
getIssues.then(loadingDone).catch(loadingDone);
|
||||
}
|
||||
},
|
||||
toggleForm() {
|
||||
this.showIssueForm = !this.showIssueForm;
|
||||
},
|
||||
onScroll() {
|
||||
if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) {
|
||||
this.loadNextPage();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
|
||||
class="board-list-component position-relative h-100"
|
||||
data-qa-selector="board_list_cards_area"
|
||||
>
|
||||
<div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')">
|
||||
<gl-loading-icon size="sm" />
|
||||
</div>
|
||||
<board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
|
||||
<ul
|
||||
v-show="!loading"
|
||||
ref="list"
|
||||
:data-board="list.id"
|
||||
:data-board-type="list.type"
|
||||
:class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }"
|
||||
class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list"
|
||||
>
|
||||
<board-card
|
||||
v-for="(issue, index) in issues"
|
||||
ref="issue"
|
||||
:key="issue.id"
|
||||
:index="index"
|
||||
:list="list"
|
||||
:issue="issue"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
|
||||
<gl-loading-icon v-show="list.loadingMore" size="sm" label="Loading more issues" />
|
||||
<span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
|
||||
<span v-else>{{ paginatedIssueText }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
|
@ -1,361 +0,0 @@
|
|||
<script>
|
||||
import {
|
||||
GlButton,
|
||||
GlButtonGroup,
|
||||
GlLabel,
|
||||
GlTooltip,
|
||||
GlIcon,
|
||||
GlSprintf,
|
||||
GlTooltipDirective,
|
||||
} from '@gitlab/ui';
|
||||
import { mapActions, mapState } from 'vuex';
|
||||
import { isScopedLabel } from '~/lib/utils/common_utils';
|
||||
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
|
||||
import { n__, s__ } from '~/locale';
|
||||
import sidebarEventHub from '~/sidebar/event_hub';
|
||||
import AccessorUtilities from '../../lib/utils/accessor';
|
||||
import { inactiveId, LIST, ListType } from '../constants';
|
||||
import eventHub from '../eventhub';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import IssueCount from './item_count.vue';
|
||||
|
||||
// This component is being replaced in favor of './board_list_header.vue' for GraphQL boards
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlButtonGroup,
|
||||
GlButton,
|
||||
GlLabel,
|
||||
GlTooltip,
|
||||
GlIcon,
|
||||
GlSprintf,
|
||||
IssueCount,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
inject: {
|
||||
currentUserId: {
|
||||
default: null,
|
||||
},
|
||||
boardId: {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isSwimlanesHeader: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
weightFeatureAvailable: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['activeId']),
|
||||
isLoggedIn() {
|
||||
return Boolean(this.currentUserId);
|
||||
},
|
||||
listType() {
|
||||
return this.list.type;
|
||||
},
|
||||
listAssignee() {
|
||||
return this.list?.assignee?.username || '';
|
||||
},
|
||||
listTitle() {
|
||||
return this.list?.label?.description || this.list.title || '';
|
||||
},
|
||||
showListHeaderButton() {
|
||||
return !this.disabled && this.listType !== ListType.closed;
|
||||
},
|
||||
showMilestoneListDetails() {
|
||||
return this.list.type === 'milestone' && this.list.milestone && this.showListDetails;
|
||||
},
|
||||
showAssigneeListDetails() {
|
||||
return this.list.type === 'assignee' && this.showListDetails;
|
||||
},
|
||||
showIterationListDetails() {
|
||||
return this.listType === ListType.iteration && this.showListDetails;
|
||||
},
|
||||
showListDetails() {
|
||||
return this.list.isExpanded || !this.isSwimlanesHeader;
|
||||
},
|
||||
showListHeaderActions() {
|
||||
if (this.isLoggedIn) {
|
||||
return this.isNewIssueShown || this.isSettingsShown;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
issuesCount() {
|
||||
return this.list.issuesSize;
|
||||
},
|
||||
issuesTooltipLabel() {
|
||||
return n__(`%d issue`, `%d issues`, this.issuesCount);
|
||||
},
|
||||
chevronTooltip() {
|
||||
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
|
||||
},
|
||||
chevronIcon() {
|
||||
return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
|
||||
},
|
||||
isNewIssueShown() {
|
||||
return this.listType === ListType.backlog || this.showListHeaderButton;
|
||||
},
|
||||
isSettingsShown() {
|
||||
return (
|
||||
this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
|
||||
);
|
||||
},
|
||||
uniqueKey() {
|
||||
// eslint-disable-next-line @gitlab/require-i18n-strings
|
||||
return `boards.${this.boardId}.${this.listType}.${this.list.id}`;
|
||||
},
|
||||
collapsedTooltipTitle() {
|
||||
return this.listTitle || this.listAssignee;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['setActiveId']),
|
||||
openSidebarSettings() {
|
||||
if (this.activeId === inactiveId) {
|
||||
sidebarEventHub.$emit('sidebar.closeAll');
|
||||
}
|
||||
|
||||
this.setActiveId({ id: this.list.id, sidebarType: LIST });
|
||||
},
|
||||
showScopedLabels(label) {
|
||||
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
|
||||
},
|
||||
|
||||
showNewIssueForm() {
|
||||
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
|
||||
},
|
||||
toggleExpanded() {
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
this.list.isExpanded = !this.list.isExpanded;
|
||||
|
||||
if (!this.isLoggedIn) {
|
||||
this.addToLocalStorage();
|
||||
} else {
|
||||
this.updateListFunction();
|
||||
}
|
||||
|
||||
// When expanding/collapsing, the tooltip on the caret button sometimes stays open.
|
||||
// Close all tooltips manually to prevent dangling tooltips.
|
||||
this.$root.$emit(BV_HIDE_TOOLTIP);
|
||||
},
|
||||
addToLocalStorage() {
|
||||
if (AccessorUtilities.isLocalStorageAccessSafe()) {
|
||||
localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
|
||||
}
|
||||
},
|
||||
updateListFunction() {
|
||||
this.list.update();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
:class="{
|
||||
'has-border': list.label && list.label.color,
|
||||
'gl-h-full': !list.isExpanded,
|
||||
'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
|
||||
}"
|
||||
:style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }"
|
||||
class="board-header gl-relative"
|
||||
data-qa-selector="board_list_header"
|
||||
data-testid="board-list-header"
|
||||
>
|
||||
<h3
|
||||
:class="{
|
||||
'user-can-drag': !disabled && !list.preset,
|
||||
'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
|
||||
'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
|
||||
'gl-py-2': !list.isExpanded && isSwimlanesHeader,
|
||||
'gl-flex-direction-column': !list.isExpanded,
|
||||
}"
|
||||
class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
|
||||
>
|
||||
<gl-button
|
||||
v-if="list.isExpandable"
|
||||
v-gl-tooltip.hover
|
||||
:aria-label="chevronTooltip"
|
||||
:title="chevronTooltip"
|
||||
:icon="chevronIcon"
|
||||
class="board-title-caret no-drag gl-cursor-pointer"
|
||||
category="tertiary"
|
||||
size="small"
|
||||
@click="toggleExpanded"
|
||||
/>
|
||||
<!-- The following is only true in EE and if it is a milestone -->
|
||||
<span
|
||||
v-if="showMilestoneListDetails"
|
||||
aria-hidden="true"
|
||||
class="milestone-icon"
|
||||
:class="{
|
||||
'gl-mt-3 gl-rotate-90': !list.isExpanded,
|
||||
'gl-mr-2': list.isExpanded,
|
||||
}"
|
||||
>
|
||||
<gl-icon name="timer" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="showIterationListDetails"
|
||||
aria-hidden="true"
|
||||
:class="{
|
||||
'gl-mt-3 gl-rotate-90': !list.isExpanded,
|
||||
'gl-mr-2': list.isExpanded,
|
||||
}"
|
||||
>
|
||||
<gl-icon name="iteration" />
|
||||
</span>
|
||||
|
||||
<a
|
||||
v-if="showAssigneeListDetails"
|
||||
:href="list.assignee.path"
|
||||
class="user-avatar-link js-no-trigger"
|
||||
:class="{
|
||||
'gl-mt-3 gl-rotate-90': !list.isExpanded,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-gl-tooltip.hover.bottom
|
||||
:title="listAssignee"
|
||||
:alt="list.assignee.name"
|
||||
:src="list.assignee.avatar"
|
||||
class="avatar s20"
|
||||
height="20"
|
||||
width="20"
|
||||
/>
|
||||
</a>
|
||||
<div
|
||||
class="board-title-text"
|
||||
:class="{
|
||||
'gl-display-none': !list.isExpanded && isSwimlanesHeader,
|
||||
'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded,
|
||||
'gl-flex-grow-1': list.isExpanded,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="list.type !== 'label'"
|
||||
v-gl-tooltip.hover
|
||||
:class="{
|
||||
'gl-display-block': !list.isExpanded || list.type === 'milestone',
|
||||
}"
|
||||
:title="listTitle"
|
||||
class="board-title-main-text gl-text-truncate"
|
||||
>
|
||||
{{ list.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="list.type === 'assignee'"
|
||||
class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
|
||||
:class="{ 'gl-display-none': !list.isExpanded }"
|
||||
>
|
||||
@{{ listAssignee }}
|
||||
</span>
|
||||
<gl-label
|
||||
v-if="list.type === 'label'"
|
||||
v-gl-tooltip.hover.bottom
|
||||
:background-color="list.label.color"
|
||||
:description="list.label.description"
|
||||
:scoped="showScopedLabels(list.label)"
|
||||
:size="!list.isExpanded ? 'sm' : ''"
|
||||
:title="list.label.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="isSwimlanesHeader && !list.isExpanded"
|
||||
ref="collapsedInfo"
|
||||
aria-hidden="true"
|
||||
class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500"
|
||||
>
|
||||
<gl-icon name="information" />
|
||||
</span>
|
||||
<gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo">
|
||||
<div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div>
|
||||
<div v-if="list.maxIssueCount !== 0">
|
||||
•
|
||||
<gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
|
||||
<template #issuesSize>{{ issuesTooltipLabel }}</template>
|
||||
<template #maxIssueCount>{{ list.maxIssueCount }}</template>
|
||||
</gl-sprintf>
|
||||
</div>
|
||||
<div v-else>• {{ issuesTooltipLabel }}</div>
|
||||
<div v-if="weightFeatureAvailable">
|
||||
•
|
||||
<gl-sprintf :message="__('%{totalWeight} total weight')">
|
||||
<template #totalWeight>{{ list.totalWeight }}</template>
|
||||
</gl-sprintf>
|
||||
</div>
|
||||
</gl-tooltip>
|
||||
|
||||
<div
|
||||
class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary"
|
||||
:class="{
|
||||
'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
|
||||
'gl-p-0': !list.isExpanded,
|
||||
}"
|
||||
>
|
||||
<span class="gl-display-inline-flex">
|
||||
<gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
|
||||
<span ref="issueCount" class="issue-count-badge-count">
|
||||
<gl-icon class="gl-mr-2" name="issues" />
|
||||
<issue-count :items-size="issuesCount" :max-issue-count="list.maxIssueCount" />
|
||||
</span>
|
||||
<!-- The following is only true in EE. -->
|
||||
<template v-if="weightFeatureAvailable">
|
||||
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
|
||||
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
|
||||
<gl-icon class="gl-mr-2" name="weight" />
|
||||
{{ list.totalWeight }}
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<gl-button-group v-if="showListHeaderActions" class="board-list-button-group pl-2">
|
||||
<gl-button
|
||||
v-if="isNewIssueShown"
|
||||
ref="newIssueBtn"
|
||||
v-gl-tooltip.hover
|
||||
:class="{
|
||||
'gl-display-none': !list.isExpanded,
|
||||
}"
|
||||
:aria-label="__('New issue')"
|
||||
:title="__('New issue')"
|
||||
class="issue-count-badge-add-button no-drag"
|
||||
icon="plus"
|
||||
@click="showNewIssueForm"
|
||||
/>
|
||||
|
||||
<gl-button
|
||||
v-if="isSettingsShown"
|
||||
ref="settingsBtn"
|
||||
v-gl-tooltip.hover
|
||||
:aria-label="__('List settings')"
|
||||
class="no-drag js-board-settings-button"
|
||||
:title="__('List settings')"
|
||||
icon="settings"
|
||||
@click="openSidebarSettings"
|
||||
/>
|
||||
<gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
|
||||
</gl-button-group>
|
||||
</h3>
|
||||
</header>
|
||||
</template>
|
|
@ -1,138 +0,0 @@
|
|||
<script>
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getMilestone } from 'ee_else_ce/boards/boards_util';
|
||||
import ListIssue from 'ee_else_ce/boards/models/issue';
|
||||
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import eventHub from '../eventhub';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import ProjectSelect from './project_select_deprecated.vue';
|
||||
|
||||
// This component is being replaced in favor of './board_new_issue.vue' for GraphQL boards
|
||||
|
||||
export default {
|
||||
name: 'BoardNewIssueDeprecated',
|
||||
components: {
|
||||
ProjectSelect,
|
||||
GlButton,
|
||||
},
|
||||
mixins: [glFeatureFlagMixin()],
|
||||
inject: ['groupId'],
|
||||
props: {
|
||||
list: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
title: '',
|
||||
error: false,
|
||||
selectedProject: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isGroupBoard']),
|
||||
disabled() {
|
||||
if (this.isGroupBoard) {
|
||||
return this.title === '' || !this.selectedProject.name;
|
||||
}
|
||||
return this.title === '';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.input.focus();
|
||||
eventHub.$on('setSelectedProject', this.setSelectedProject);
|
||||
},
|
||||
methods: {
|
||||
submit(e) {
|
||||
e.preventDefault();
|
||||
if (this.title.trim() === '') return Promise.resolve();
|
||||
|
||||
this.error = false;
|
||||
|
||||
const labels = this.list.label ? [this.list.label] : [];
|
||||
const assignees = this.list.assignee ? [this.list.assignee] : [];
|
||||
const milestone = getMilestone(this.list);
|
||||
|
||||
const { weightFeatureAvailable } = boardsStore;
|
||||
const { weight } = weightFeatureAvailable ? boardsStore.state.currentBoard : {};
|
||||
|
||||
const issue = new ListIssue({
|
||||
title: this.title,
|
||||
labels,
|
||||
subscribed: true,
|
||||
assignees,
|
||||
milestone,
|
||||
project_id: this.selectedProject.id,
|
||||
weight,
|
||||
});
|
||||
|
||||
eventHub.$emit(`scroll-board-list-${this.list.id}`);
|
||||
this.cancel();
|
||||
|
||||
return this.list
|
||||
.newIssue(issue)
|
||||
.then(() => {
|
||||
boardsStore.setIssueDetail(issue);
|
||||
boardsStore.setListDetail(this.list);
|
||||
})
|
||||
.catch(() => {
|
||||
this.list.removeIssue(issue);
|
||||
|
||||
// Show error message
|
||||
this.error = true;
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
this.title = '';
|
||||
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
|
||||
},
|
||||
setSelectedProject(selectedProject) {
|
||||
this.selectedProject = selectedProject;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="board-new-issue-form">
|
||||
<div class="board-card position-relative p-3 rounded">
|
||||
<form @submit="submit($event)">
|
||||
<div v-if="error" class="flash-container">
|
||||
<div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div>
|
||||
</div>
|
||||
<label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label>
|
||||
<input
|
||||
:id="list.id + '-title'"
|
||||
ref="input"
|
||||
v-model="title"
|
||||
class="form-control"
|
||||
type="text"
|
||||
name="issue_title"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<project-select v-if="isGroupBoard" :group-id="groupId" :list="list" />
|
||||
<div class="clearfix gl-mt-3">
|
||||
<gl-button
|
||||
ref="submitButton"
|
||||
:disabled="disabled"
|
||||
class="float-left js-no-auto-disable"
|
||||
variant="success"
|
||||
category="primary"
|
||||
type="submit"
|
||||
>{{ __('Create issue') }}</gl-button
|
||||
>
|
||||
<gl-button
|
||||
ref="cancelButton"
|
||||
class="float-right"
|
||||
type="button"
|
||||
variant="default"
|
||||
@click="cancel"
|
||||
>{{ __('Cancel') }}</gl-button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,247 +0,0 @@
|
|||
<script>
|
||||
import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui';
|
||||
import { sortBy } from 'lodash';
|
||||
import { mapState } from 'vuex';
|
||||
import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
|
||||
import { isScopedLabel } from '~/lib/utils/common_utils';
|
||||
import { sprintf, __, n__ } from '~/locale';
|
||||
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
|
||||
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import IssueDueDate from './issue_due_date.vue';
|
||||
import IssueTimeEstimate from './issue_time_estimate_deprecated.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlLabel,
|
||||
GlIcon,
|
||||
UserAvatarLink,
|
||||
TooltipOnTruncate,
|
||||
IssueDueDate,
|
||||
IssueTimeEstimate,
|
||||
IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
mixins: [boardCardInner],
|
||||
inject: ['groupId', 'rootPath'],
|
||||
props: {
|
||||
issue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
list: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
updateFilters: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
limitBeforeCounter: 2,
|
||||
maxRender: 3,
|
||||
maxCounter: 99,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(['isShowingLabels']),
|
||||
numberOverLimit() {
|
||||
return this.issue.assignees.length - this.limitBeforeCounter;
|
||||
},
|
||||
assigneeCounterTooltip() {
|
||||
const { numberOverLimit, maxCounter } = this;
|
||||
const count = numberOverLimit > maxCounter ? maxCounter : numberOverLimit;
|
||||
return sprintf(__('%{count} more assignees'), { count });
|
||||
},
|
||||
assigneeCounterLabel() {
|
||||
if (this.numberOverLimit > this.maxCounter) {
|
||||
return `${this.maxCounter}+`;
|
||||
}
|
||||
|
||||
return `+${this.numberOverLimit}`;
|
||||
},
|
||||
shouldRenderCounter() {
|
||||
if (this.issue.assignees.length <= this.maxRender) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.issue.assignees.length > this.numberOverLimit;
|
||||
},
|
||||
issueId() {
|
||||
if (this.issue.iid) {
|
||||
return `#${this.issue.iid}`;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
showLabelFooter() {
|
||||
return this.isShowingLabels && this.issue.labels.find(this.showLabel);
|
||||
},
|
||||
issueReferencePath() {
|
||||
const { referencePath, groupId } = this.issue;
|
||||
return !groupId ? referencePath.split('#')[0] : null;
|
||||
},
|
||||
orderedLabels() {
|
||||
return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title');
|
||||
},
|
||||
blockedLabel() {
|
||||
if (this.issue.blockedByCount) {
|
||||
return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount);
|
||||
}
|
||||
return __('Blocked issue');
|
||||
},
|
||||
assignees() {
|
||||
return this.issue.assignees.filter((_, index) => this.shouldRenderAssignee(index));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isIndexLessThanlimit(index) {
|
||||
return index < this.limitBeforeCounter;
|
||||
},
|
||||
shouldRenderAssignee(index) {
|
||||
// Eg. maxRender is 4,
|
||||
// Render up to all 4 assignees if there are only 4 assigness
|
||||
// Otherwise render up to the limitBeforeCounter
|
||||
if (this.issue.assignees.length <= this.maxRender) {
|
||||
return index < this.maxRender;
|
||||
}
|
||||
|
||||
return index < this.limitBeforeCounter;
|
||||
},
|
||||
assigneeUrl(assignee) {
|
||||
if (!assignee) return '';
|
||||
return `${this.rootPath}${assignee.username}`;
|
||||
},
|
||||
avatarUrlTitle(assignee) {
|
||||
return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name });
|
||||
},
|
||||
showLabel(label) {
|
||||
if (!label.id) return false;
|
||||
return true;
|
||||
},
|
||||
isNonListLabel(label) {
|
||||
return label.id && !(this.list.type === 'label' && this.list.title === label.title);
|
||||
},
|
||||
filterByLabel(label) {
|
||||
if (!this.updateFilters) return;
|
||||
const labelTitle = encodeURIComponent(label.title);
|
||||
const filter = `label_name[]=${labelTitle}`;
|
||||
|
||||
boardsStore.toggleFilter(filter);
|
||||
},
|
||||
showScopedLabel(label) {
|
||||
return boardsStore.scopedLabels.enabled && isScopedLabel(label);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div class="gl-display-flex" dir="auto">
|
||||
<h4 class="board-card-title gl-mb-0 gl-mt-0">
|
||||
<gl-icon
|
||||
v-if="issue.blocked"
|
||||
v-gl-tooltip
|
||||
name="issue-block"
|
||||
:title="blockedLabel"
|
||||
class="issue-blocked-icon gl-mr-2"
|
||||
:aria-label="blockedLabel"
|
||||
data-testid="issue-blocked-icon"
|
||||
/>
|
||||
<gl-icon
|
||||
v-if="issue.confidential"
|
||||
v-gl-tooltip
|
||||
name="eye-slash"
|
||||
:title="__('Confidential')"
|
||||
class="confidential-icon gl-mr-2"
|
||||
:aria-label="__('Confidential')"
|
||||
/>
|
||||
<a
|
||||
:href="issue.path || issue.webUrl || ''"
|
||||
:title="issue.title"
|
||||
class="js-no-trigger"
|
||||
@mousemove.stop
|
||||
>{{ issue.title }}</a
|
||||
>
|
||||
</h4>
|
||||
</div>
|
||||
<div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
|
||||
<template v-for="label in orderedLabels">
|
||||
<gl-label
|
||||
:key="label.id"
|
||||
:background-color="label.color"
|
||||
:title="label.title"
|
||||
:description="label.description"
|
||||
size="sm"
|
||||
:scoped="showScopedLabel(label)"
|
||||
@click="filterByLabel(label)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end"
|
||||
>
|
||||
<div
|
||||
class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container"
|
||||
>
|
||||
<span
|
||||
v-if="issue.referencePath"
|
||||
class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
|
||||
>
|
||||
<tooltip-on-truncate
|
||||
v-if="issueReferencePath"
|
||||
:title="issueReferencePath"
|
||||
placement="bottom"
|
||||
class="board-issue-path gl-text-truncate gl-font-weight-bold"
|
||||
>{{ issueReferencePath }}</tooltip-on-truncate
|
||||
>
|
||||
#{{ issue.iid }}
|
||||
</span>
|
||||
<span class="board-info-items gl-mt-3 gl-display-inline-block">
|
||||
<issue-due-date
|
||||
v-if="issue.dueDate"
|
||||
:date="issue.dueDate"
|
||||
:closed="issue.closed || Boolean(issue.closedAt)"
|
||||
/>
|
||||
<issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
|
||||
<issue-card-weight
|
||||
v-if="validIssueWeight(issue)"
|
||||
:weight="issue.weight"
|
||||
@click="filterByWeight(issue.weight)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="board-card-assignee gl-display-flex">
|
||||
<user-avatar-link
|
||||
v-for="assignee in assignees"
|
||||
:key="assignee.id"
|
||||
:link-href="assigneeUrl(assignee)"
|
||||
:img-alt="avatarUrlTitle(assignee)"
|
||||
:img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url"
|
||||
:img-size="24"
|
||||
class="js-no-trigger"
|
||||
tooltip-placement="bottom"
|
||||
>
|
||||
<span class="js-assignee-tooltip">
|
||||
<span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span>
|
||||
{{ assignee.name }}
|
||||
<span class="text-white-50">@{{ assignee.username }}</span>
|
||||
</span>
|
||||
</user-avatar-link>
|
||||
<span
|
||||
v-if="shouldRenderCounter"
|
||||
v-gl-tooltip
|
||||
:title="assigneeCounterTooltip"
|
||||
class="avatar-counter"
|
||||
data-placement="bottom"
|
||||
>{{ assigneeCounterLabel }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,48 +0,0 @@
|
|||
<script>
|
||||
import { GlTooltip, GlIcon } from '@gitlab/ui';
|
||||
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
|
||||
import boardsStore from '../stores/boards_store';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlIcon,
|
||||
GlTooltip,
|
||||
},
|
||||
props: {
|
||||
estimate: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
limitToHours: boardsStore.timeTracking.limitToHours,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }), true);
|
||||
},
|
||||
timeEstimate() {
|
||||
return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<span ref="issueTimeEstimate" class="board-card-info card-number">
|
||||
<gl-icon name="hourglass" class="board-card-info-icon" /><time class="board-card-info-text">{{
|
||||
timeEstimate
|
||||
}}</time>
|
||||
</span>
|
||||
<gl-tooltip
|
||||
:target="() => $refs.issueTimeEstimate"
|
||||
placement="bottom"
|
||||
class="js-issue-time-estimate"
|
||||
>
|
||||
<span class="bold d-block">{{ __('Time estimate') }}</span> {{ title }}
|
||||
</gl-tooltip>
|
||||
</span>
|
||||
</template>
|
|
@ -1,146 +0,0 @@
|
|||
<script>
|
||||
import {
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
GlDropdownText,
|
||||
GlSearchBoxByType,
|
||||
GlLoadingIcon,
|
||||
} from '@gitlab/ui';
|
||||
import { s__ } from '~/locale';
|
||||
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
|
||||
import Api from '../../api';
|
||||
import { ListType } from '../constants';
|
||||
import eventHub from '../eventhub';
|
||||
|
||||
export default {
|
||||
name: 'ProjectSelect',
|
||||
i18n: {
|
||||
headerTitle: s__(`BoardNewIssue|Projects`),
|
||||
dropdownText: s__(`BoardNewIssue|Select a project`),
|
||||
searchPlaceholder: s__(`BoardNewIssue|Search projects`),
|
||||
emptySearchResult: s__(`BoardNewIssue|No matching results`),
|
||||
},
|
||||
defaultFetchOptions: {
|
||||
with_issues_enabled: true,
|
||||
with_shared: false,
|
||||
include_subgroups: true,
|
||||
order_by: 'similarity',
|
||||
archived: false,
|
||||
},
|
||||
components: {
|
||||
GlLoadingIcon,
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
GlDropdownText,
|
||||
GlSearchBoxByType,
|
||||
},
|
||||
inject: ['groupId'],
|
||||
props: {
|
||||
list: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
initialLoading: true,
|
||||
isFetching: false,
|
||||
projects: [],
|
||||
selectedProject: {},
|
||||
searchTerm: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectedProjectName() {
|
||||
return this.selectedProject.name || this.$options.i18n.dropdownText;
|
||||
},
|
||||
fetchOptions() {
|
||||
const additionalAttrs = {};
|
||||
if (this.list.type && this.list.type !== ListType.backlog) {
|
||||
additionalAttrs.min_access_level = featureAccessLevel.EVERYONE;
|
||||
}
|
||||
|
||||
return {
|
||||
...this.$options.defaultFetchOptions,
|
||||
...additionalAttrs,
|
||||
};
|
||||
},
|
||||
isFetchResultEmpty() {
|
||||
return this.projects.length === 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
searchTerm() {
|
||||
this.fetchProjects();
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchProjects();
|
||||
|
||||
this.initialLoading = false;
|
||||
},
|
||||
methods: {
|
||||
async fetchProjects() {
|
||||
this.isFetching = true;
|
||||
try {
|
||||
const projects = await Api.groupProjects(this.groupId, this.searchTerm, this.fetchOptions);
|
||||
|
||||
this.projects = projects.map((project) => {
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
namespacedName: project.name_with_namespace,
|
||||
path: project.path_with_namespace,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
/* Handled in Api.groupProjects */
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
},
|
||||
selectProject(projectId) {
|
||||
this.selectedProject = this.projects.find((project) => project.id === projectId);
|
||||
|
||||
eventHub.$emit('setSelectedProject', this.selectedProject);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{
|
||||
$options.i18n.headerTitle
|
||||
}}</label>
|
||||
<gl-dropdown
|
||||
data-testid="project-select-dropdown"
|
||||
:text="selectedProjectName"
|
||||
:header-text="$options.i18n.headerTitle"
|
||||
block
|
||||
menu-class="gl-w-full!"
|
||||
:loading="initialLoading"
|
||||
>
|
||||
<gl-search-box-by-type
|
||||
v-model.trim="searchTerm"
|
||||
debounce="250"
|
||||
:placeholder="$options.i18n.searchPlaceholder"
|
||||
/>
|
||||
<gl-dropdown-item
|
||||
v-for="project in projects"
|
||||
v-show="!isFetching"
|
||||
:key="project.id"
|
||||
:name="project.name"
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
{{ project.namespacedName }}
|
||||
</gl-dropdown-item>
|
||||
<gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon">
|
||||
<gl-loading-icon class="gl-mx-auto" size="sm" />
|
||||
</gl-dropdown-text>
|
||||
<gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message">
|
||||
<span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
|
||||
</gl-dropdown-text>
|
||||
</gl-dropdown>
|
||||
</div>
|
||||
</template>
|
|
@ -31,9 +31,18 @@ module Gitlab
|
|||
require_dependency Rails.root.join('lib/gitlab/middleware/handle_malformed_strings')
|
||||
require_dependency Rails.root.join('lib/gitlab/middleware/rack_multipart_tempfile_factory')
|
||||
require_dependency Rails.root.join('lib/gitlab/runtime')
|
||||
require_dependency Rails.root.join('lib/gitlab/patch/legacy_database_config')
|
||||
|
||||
config.autoloader = :zeitwerk
|
||||
|
||||
# To be removed in 15.0
|
||||
# This preload is needed to convert legacy `database.yml`
|
||||
# from `production: adapter: postgresql`
|
||||
# into a `production: main: adapter: postgresql`
|
||||
unless Gitlab::Utils.to_boolean(ENV['SKIP_DATABASE_CONFIG_VALIDATION'], default: false)
|
||||
config.class.prepend(::Gitlab::Patch::LegacyDatabaseConfig)
|
||||
end
|
||||
|
||||
# Settings in config/environments/* take precedence over those specified here.
|
||||
# Application configuration should go into files in config/initializers
|
||||
# -- all .rb files in that directory are automatically loaded.
|
||||
|
|
|
@ -2,56 +2,60 @@
|
|||
# PRODUCTION
|
||||
#
|
||||
production:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
# load_balancing:
|
||||
# hosts:
|
||||
# - host1.example.com
|
||||
# - host2.example.com
|
||||
# discover:
|
||||
# nameserver: 1.2.3.4
|
||||
# port: 8600
|
||||
# record: secondary.postgresql.service.consul
|
||||
# interval: 300
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
# load_balancing:
|
||||
# hosts:
|
||||
# - host1.example.com
|
||||
# - host2.example.com
|
||||
# discover:
|
||||
# nameserver: 1.2.3.4
|
||||
# port: 8600
|
||||
# record: secondary.postgresql.service.consul
|
||||
# interval: 300
|
||||
|
||||
#
|
||||
# Development specific
|
||||
#
|
||||
development:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_development
|
||||
username: postgres
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
variables:
|
||||
statement_timeout: 15s
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_development
|
||||
username: postgres
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
variables:
|
||||
statement_timeout: 15s
|
||||
|
||||
#
|
||||
# Staging specific
|
||||
#
|
||||
staging:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_staging
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_staging
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
|
||||
# Warning: The database defined as "test" will be erased and
|
||||
# re-generated from your development database when you run "rake".
|
||||
# Do not set this db to the same as development or production.
|
||||
test: &test
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_test
|
||||
username: postgres
|
||||
password:
|
||||
host: localhost
|
||||
prepared_statements: false
|
||||
variables:
|
||||
statement_timeout: 15s
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_test
|
||||
username: postgres
|
||||
password:
|
||||
host: localhost
|
||||
prepared_statements: false
|
||||
variables:
|
||||
statement_timeout: 15s
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
---
|
||||
name: gitaly_backup
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitaly/-/merge_requests/3554
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/333034
|
||||
milestone: '14.0'
|
||||
type: development
|
||||
group: group::gitaly
|
||||
default_enabled: true
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
if Gitlab::Utils.to_boolean(ENV['SKIP_DATABASE_CONFIG_VALIDATION'], default: false)
|
||||
return
|
||||
end
|
||||
|
||||
if Rails.application.config.uses_legacy_database_config
|
||||
warn "WARNING: This installation of GitLab uses a deprecated syntax for 'config/database.yml'. " \
|
||||
"The support for this syntax will be removed in 15.0. " \
|
||||
"More information can be found here: https://gitlab.com/gitlab-org/gitlab/-/issues/338182"
|
||||
end
|
||||
|
||||
if configurations = ActiveRecord::Base.configurations.configurations
|
||||
if configurations.first.name != Gitlab::Database::MAIN_DATABASE_NAME
|
||||
raise "ERROR: This installation of GitLab uses unsupported 'config/database.yml'. " \
|
||||
"The `main:` database needs to be defined as a first configuration item instead of `#{configurations.first.name}`."
|
||||
end
|
||||
|
||||
rejected_config_names = configurations.map(&:name).to_set - Gitlab::Database::DATABASE_NAMES
|
||||
if rejected_config_names.any?
|
||||
raise "ERROR: This installation of GitLab uses unsupported database names " \
|
||||
"in 'config/database.yml': #{rejected_config_names.to_a.join(", ")}. The only supported ones are " \
|
||||
"#{Gitlab::Database::DATABASE_NAMES.join(", ")}."
|
||||
end
|
||||
|
||||
replicas_config_names = configurations.select(&:replica?).map(&:name)
|
||||
if replicas_config_names.any?
|
||||
raise "ERROR: This installation of GitLab uses unsupported database configuration " \
|
||||
"with 'replica: true' parameter in 'config/database.yml' for: #{replicas_config_names.join(", ")}"
|
||||
end
|
||||
end
|
|
@ -0,0 +1,33 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class RemoveDuplicateDastSiteTokens < ActiveRecord::Migration[6.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
class DastSiteToken < ApplicationRecord
|
||||
self.table_name = 'dast_site_tokens'
|
||||
self.inheritance_column = :_type_disabled
|
||||
|
||||
scope :duplicates, -> do
|
||||
all_duplicates = select(:project_id, :url)
|
||||
.distinct
|
||||
.group(:project_id, :url)
|
||||
.having('count(*) > 1')
|
||||
.pluck('array_agg(id) as ids')
|
||||
|
||||
duplicate_ids = extract_duplicate_ids(all_duplicates)
|
||||
|
||||
where(id: duplicate_ids)
|
||||
end
|
||||
|
||||
def self.extract_duplicate_ids(duplicates)
|
||||
duplicates.flat_map { |ids| ids.first(ids.size - 1) }
|
||||
end
|
||||
end
|
||||
|
||||
def up
|
||||
DastSiteToken.duplicates.delete_all
|
||||
end
|
||||
|
||||
def down
|
||||
end
|
||||
end
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddUniqueIndexDastSiteTokenProjectIdAndUrl < ActiveRecord::Migration[6.1]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
INDEX_NAME = 'index_dast_site_token_on_project_id_and_url'
|
||||
|
||||
def up
|
||||
add_concurrent_index :dast_site_tokens, [:project_id, :url], name: INDEX_NAME, unique: true
|
||||
end
|
||||
|
||||
def down
|
||||
remove_concurrent_index_by_name :dast_site_tokens, name: INDEX_NAME
|
||||
end
|
||||
end
|
|
@ -0,0 +1,80 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PrepareAsyncIndexesForCiBuilds < Gitlab::Database::Migration[1.0]
|
||||
def up
|
||||
prepare_async_index :ci_builds, :stage_id_convert_to_bigint, name: :index_ci_builds_on_converted_stage_id
|
||||
|
||||
prepare_async_index :ci_builds, [:commit_id, :artifacts_expire_at, :id_convert_to_bigint],
|
||||
where: "type::text = 'Ci::Build'::text
|
||||
AND (retried = false OR retried IS NULL)
|
||||
AND (name::text = ANY (ARRAY['sast'::character varying::text,
|
||||
'secret_detection'::character varying::text,
|
||||
'dependency_scanning'::character varying::text,
|
||||
'container_scanning'::character varying::text,
|
||||
'dast'::character varying::text]))",
|
||||
name: :index_ci_builds_on_commit_id_expire_at_and_converted_id
|
||||
|
||||
prepare_async_index :ci_builds, [:project_id, :id_convert_to_bigint],
|
||||
name: :index_ci_builds_on_project_and_converted_id
|
||||
|
||||
prepare_async_index :ci_builds, [:runner_id, :id_convert_to_bigint],
|
||||
order: { id_convert_to_bigint: :desc },
|
||||
name: :index_ci_builds_on_runner_id_and_converted_id_desc
|
||||
|
||||
prepare_async_index :ci_builds, [:resource_group_id, :id_convert_to_bigint],
|
||||
where: 'resource_group_id IS NOT NULL',
|
||||
name: :index_ci_builds_on_resource_group_and_converted_id
|
||||
|
||||
prepare_async_index :ci_builds, [:name, :id_convert_to_bigint],
|
||||
where: "(name::text = ANY (ARRAY['container_scanning'::character varying::text,
|
||||
'dast'::character varying::text,
|
||||
'dependency_scanning'::character varying::text,
|
||||
'license_management'::character varying::text,
|
||||
'sast'::character varying::text,
|
||||
'secret_detection'::character varying::text,
|
||||
'coverage_fuzzing'::character varying::text,
|
||||
'license_scanning'::character varying::text])
|
||||
) AND type::text = 'Ci::Build'::text",
|
||||
name: :index_security_ci_builds_on_name_and_converted_id_parser
|
||||
|
||||
prepare_async_index_from_sql(:ci_builds, :index_ci_builds_runner_id_and_converted_id_pending_covering, <<~SQL.squish)
|
||||
CREATE INDEX CONCURRENTLY index_ci_builds_runner_id_and_converted_id_pending_covering
|
||||
ON ci_builds (runner_id, id_convert_to_bigint) INCLUDE (project_id)
|
||||
WHERE status::text = 'pending'::text AND type::text = 'Ci::Build'::text
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
unprepare_async_index_by_name :ci_builds, :index_ci_builds_runner_id_and_converted_id_pending_covering
|
||||
|
||||
unprepare_async_index_by_name :ci_builds, :index_security_ci_builds_on_name_and_converted_id_parser
|
||||
|
||||
unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_resource_group_and_converted_id
|
||||
|
||||
unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_runner_id_and_converted_id_desc
|
||||
|
||||
unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_project_and_converted_id
|
||||
|
||||
unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_commit_id_expire_at_and_converted_id
|
||||
|
||||
unprepare_async_index_by_name :ci_builds, :index_ci_builds_on_converted_stage_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prepare_async_index_from_sql(table_name, index_name, definition)
|
||||
return unless async_index_creation_available?
|
||||
|
||||
return if index_name_exists?(table_name, index_name)
|
||||
|
||||
async_index = Gitlab::Database::AsyncIndexes::PostgresAsyncIndex.find_or_create_by!(name: index_name) do |rec|
|
||||
rec.table_name = table_name
|
||||
rec.definition = definition
|
||||
end
|
||||
|
||||
Gitlab::AppLogger.info(
|
||||
message: 'Prepared index for async creation',
|
||||
table_name: async_index.table_name,
|
||||
index_name: async_index.name)
|
||||
end
|
||||
end
|
|
@ -0,0 +1 @@
|
|||
7324c3803c910338261556c65cae5d0827e78b77890386e402e056d480c3486b
|
|
@ -0,0 +1 @@
|
|||
b7916e025131f11da97ab89a01b32d1dbacf94bb96dc84877ba18404c8b0b2ba
|
|
@ -0,0 +1 @@
|
|||
40780a28f881d4e80bdb6b023f22804c4da735223323c8cf03cfcdcaf5337fe6
|
|
@ -9606,14 +9606,14 @@ CREATE TABLE application_settings (
|
|||
encrypted_customers_dot_jwt_signing_key bytea,
|
||||
encrypted_customers_dot_jwt_signing_key_iv bytea,
|
||||
pypi_package_requests_forwarding boolean DEFAULT true NOT NULL,
|
||||
max_yaml_size_bytes bigint DEFAULT 1048576 NOT NULL,
|
||||
max_yaml_depth integer DEFAULT 100 NOT NULL,
|
||||
throttle_unauthenticated_files_api_requests_per_period integer DEFAULT 125 NOT NULL,
|
||||
throttle_unauthenticated_files_api_period_in_seconds integer DEFAULT 15 NOT NULL,
|
||||
throttle_authenticated_files_api_requests_per_period integer DEFAULT 500 NOT NULL,
|
||||
throttle_authenticated_files_api_period_in_seconds integer DEFAULT 15 NOT NULL,
|
||||
throttle_unauthenticated_files_api_enabled boolean DEFAULT false NOT NULL,
|
||||
throttle_authenticated_files_api_enabled boolean DEFAULT false NOT NULL,
|
||||
max_yaml_size_bytes bigint DEFAULT 1048576 NOT NULL,
|
||||
max_yaml_depth integer DEFAULT 100 NOT NULL,
|
||||
throttle_authenticated_git_lfs_requests_per_period integer DEFAULT 1000 NOT NULL,
|
||||
throttle_authenticated_git_lfs_period_in_seconds integer DEFAULT 60 NOT NULL,
|
||||
throttle_authenticated_git_lfs_enabled boolean DEFAULT false NOT NULL,
|
||||
|
@ -23846,6 +23846,8 @@ CREATE UNIQUE INDEX index_dast_site_profiles_on_project_id_and_name ON dast_site
|
|||
|
||||
CREATE UNIQUE INDEX index_dast_site_profiles_pipelines_on_ci_pipeline_id ON dast_site_profiles_pipelines USING btree (ci_pipeline_id);
|
||||
|
||||
CREATE UNIQUE INDEX index_dast_site_token_on_project_id_and_url ON dast_site_tokens USING btree (project_id, url);
|
||||
|
||||
CREATE INDEX index_dast_site_tokens_on_project_id ON dast_site_tokens USING btree (project_id);
|
||||
|
||||
CREATE INDEX index_dast_site_validations_on_dast_site_token_id ON dast_site_validations USING btree (dast_site_token_id);
|
||||
|
|
|
@ -1477,16 +1477,8 @@ If this happens, examine the following:
|
|||
### `gitaly-backup` for repository backup and restore **(FREE SELF)**
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/333034) in GitLab 14.2.
|
||||
> - [Deployed behind a feature flag](../user/feature_flags.md), enabled by default.
|
||||
> - Recommended for production use.
|
||||
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#disable-or-enable-gitaly-backup).
|
||||
|
||||
There can be
|
||||
[risks when disabling released features](../administration/feature_flags.md#risks-when-disabling-released-features).
|
||||
Refer to this feature's version history for more details.
|
||||
|
||||
`gitaly-backup` is used by the backup Rake task to create and restore repository backups from Gitaly.
|
||||
`gitaly-backup` replaces the previous backup method that directly calls RPCs on Gitaly from GitLab.
|
||||
|
||||
The backup Rake task must be able to find this executable. It can be configured in Omnibus GitLab packages:
|
||||
|
||||
|
@ -1498,22 +1490,3 @@ The backup Rake task must be able to find this executable. It can be configured
|
|||
|
||||
1. [Reconfigure GitLab](../administration/restart_gitlab.md#omnibus-gitlab-reconfigure)
|
||||
for the changes to take effect
|
||||
|
||||
#### Disable or enable `gitaly-backup`
|
||||
|
||||
`gitaly-backup` is under development but ready for production use.
|
||||
It is deployed behind a feature flag that is **enabled by default**.
|
||||
[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
|
||||
can opt to disable it.
|
||||
|
||||
To disable it:
|
||||
|
||||
```ruby
|
||||
Feature.disable(:gitaly_backup)
|
||||
```
|
||||
|
||||
To enable it:
|
||||
|
||||
```ruby
|
||||
Feature.enable(:gitaly_backup)
|
||||
```
|
||||
|
|
|
@ -120,6 +120,13 @@ SSO has the following effects when enabled:
|
|||
- Users must be signed-in through SSO before they can pull images using the [Dependency Proxy](../../packages/dependency_proxy/index.md).
|
||||
<!-- Add bullet for API activity when https://gitlab.com/gitlab-org/gitlab/-/issues/9152 is complete -->
|
||||
|
||||
Notes:
|
||||
|
||||
- When SSO is enforced users are not immediately revoked
|
||||
- If they are signed out then they cannot access the group after being removed from the identity provider
|
||||
- However, if the user has an active session they can continue accessing the group for up to 24 hours, until the identity provider session times out
|
||||
- Upon SCIM update, the user's access would be immediately revoked
|
||||
|
||||
## Providers
|
||||
|
||||
The SAML standard means that a wide range of identity providers will work with GitLab. Your identity provider may have relevant documentation. It may be generic SAML documentation, or specifically targeted for GitLab.
|
||||
|
@ -140,13 +147,13 @@ Follow the Azure documentation on [configuring single sign-on to applications](h
|
|||
For a demo of the Azure SAML setup including SCIM, see [SCIM Provisioning on Azure Using SAML SSO for Groups Demo](https://youtu.be/24-ZxmTeEBU). The video is outdated in regard to
|
||||
objectID mapping and the [SCIM documentation should be followed](scim_setup.md#azure-configuration-steps).
|
||||
|
||||
| GitLab Setting | Azure Field |
|
||||
|--------------|----------------|
|
||||
| Identifier | Identifier (Entity ID) |
|
||||
| Assertion consumer service URL | Reply URL (Assertion Consumer Service URL) |
|
||||
| GitLab single sign-on URL | Sign on URL |
|
||||
| Identity provider single sign-on URL | Login URL |
|
||||
| Certificate fingerprint | Thumbprint |
|
||||
| GitLab Setting | Azure Field |
|
||||
| ------------------------------------ | ------------------------------------------ |
|
||||
| Identifier | Identifier (Entity ID) |
|
||||
| Assertion consumer service URL | Reply URL (Assertion Consumer Service URL) |
|
||||
| GitLab single sign-on URL | Sign on URL |
|
||||
| Identity provider single sign-on URL | Login URL |
|
||||
| Certificate fingerprint | Thumbprint |
|
||||
|
||||
We recommend:
|
||||
|
||||
|
@ -164,12 +171,12 @@ Please follow the Okta documentation on [setting up a SAML application in Okta](
|
|||
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
|
||||
For a demo of the Okta SAML setup including SCIM, see [Demo: Okta Group SAML & SCIM setup](https://youtu.be/0ES9HsZq0AQ).
|
||||
|
||||
| GitLab Setting | Okta Field |
|
||||
|--------------|----------------|
|
||||
| Identifier | Audience URI |
|
||||
| Assertion consumer service URL | Single sign-on URL |
|
||||
| GitLab single sign-on URL | Login page URL (under **Application Login Page** settings) |
|
||||
| Identity provider single sign-on URL | Identity Provider Single Sign-On URL |
|
||||
| GitLab Setting | Okta Field |
|
||||
| ------------------------------------ | ---------------------------------------------------------- |
|
||||
| Identifier | Audience URI |
|
||||
| Assertion consumer service URL | Single sign-on URL |
|
||||
| GitLab single sign-on URL | Login page URL (under **Application Login Page** settings) |
|
||||
| Identity provider single sign-on URL | Identity Provider Single Sign-On URL |
|
||||
|
||||
Under Okta's **Single sign-on URL** field, check the option **Use this for Recipient URL and Destination URL**.
|
||||
|
||||
|
@ -186,14 +193,14 @@ application.
|
|||
If you decide to use the OneLogin generic [SAML Test Connector (Advanced)](https://onelogin.service-now.com/support?id=kb_article&sys_id=b2c19353dbde7b8024c780c74b9619fb&kb_category=93e869b0db185340d5505eea4b961934),
|
||||
we recommend the ["Use the OneLogin SAML Test Connector" documentation](https://onelogin.service-now.com/support?id=kb_article&sys_id=93f95543db109700d5505eea4b96198f) with the following settings:
|
||||
|
||||
| GitLab Setting | OneLogin Field |
|
||||
|--------------|----------------|
|
||||
| Identifier | Audience |
|
||||
| Assertion consumer service URL | Recipient |
|
||||
| Assertion consumer service URL | ACS (Consumer) URL |
|
||||
| GitLab Setting | OneLogin Field |
|
||||
| ------------------------------------------------ | ---------------------------- |
|
||||
| Identifier | Audience |
|
||||
| Assertion consumer service URL | Recipient |
|
||||
| Assertion consumer service URL | ACS (Consumer) URL |
|
||||
| Assertion consumer service URL (escaped version) | ACS (Consumer) URL Validator |
|
||||
| GitLab single sign-on URL | Login URL |
|
||||
| Identity provider single sign-on URL | SAML 2.0 Endpoint |
|
||||
| GitLab single sign-on URL | Login URL |
|
||||
| Identity provider single sign-on URL | SAML 2.0 Endpoint |
|
||||
|
||||
Recommended `NameID` value: `OneLogin ID`.
|
||||
|
||||
|
@ -281,10 +288,7 @@ If a user is already a member of the group, linking the SAML identity does not c
|
|||
|
||||
### Blocking access
|
||||
|
||||
To rescind access to the group, perform the following steps, in order:
|
||||
|
||||
1. Remove the user from the user data store on the identity provider or the list of users on the specific app.
|
||||
1. Remove the user from the GitLab.com group.
|
||||
Please refer to [Blocking access via SCiM](scim_setup.md#blocking-access).
|
||||
|
||||
### Unlinking accounts
|
||||
|
||||
|
@ -406,14 +410,14 @@ If you do not wish to use that GitLab user with the SAML login, you can [unlink
|
|||
The user that you're signed in with already has SAML linked to a different identity.
|
||||
Here are possible causes and solutions:
|
||||
|
||||
| Cause | Solution |
|
||||
|------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Cause | Solution |
|
||||
| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| You've tried to link multiple SAML identities to the same user, for a given identity provider. | Change the identity that you sign in with. To do so, [unlink the previous SAML identity](#unlinking-accounts) from this GitLab account before attempting to sign in again. |
|
||||
|
||||
### Message: "SAML authentication failed: Email has already been taken"
|
||||
|
||||
| Cause | Solution |
|
||||
|------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| When a user account with the email address already exists in GitLab, but the user does not have the SAML identity tied to their account. | The user will need to [link their account](#user-access-and-management). |
|
||||
|
||||
### Message: "SAML authentication failed: Extern UID has already been taken, User has already been taken"
|
||||
|
@ -439,8 +443,8 @@ Alternatively, when users need to [link SAML to their existing GitLab.com accoun
|
|||
|
||||
### The NameID has changed
|
||||
|
||||
| Cause | Solution |
|
||||
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Cause | Solution |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| As mentioned in the [NameID](#nameid) section, if the NameID changes for any user, the user can be locked out. This is a common problem when an email address is used as the identifier. | Follow the steps outlined in the ["SAML authentication failed: User has already been taken"](#message-saml-authentication-failed-user-has-already-been-taken) section. |
|
||||
|
||||
### I need to change my SAML app
|
||||
|
|
|
@ -59,6 +59,7 @@ During this configuration, note the following:
|
|||
[previous step](#gitlab-configuration).
|
||||
- It is recommended to set a notification email and check the **Send an email notification when a failure occurs** checkbox.
|
||||
- For mappings, we will only leave `Synchronize Azure Active Directory Users to AppName` enabled.
|
||||
- `Synchronize Azure Active Directory Groups to AppName` should be disabled. However, this does not mean Azure AD users cannot be provisioned in groups. Leaving it enabled does not break the SCIM user provisioning, but causes errors in Azure AD that may be confusing and misleading.
|
||||
|
||||
You can then test the connection by clicking on **Test Connection**. If the connection is successful, be sure to save your configuration before moving on. See below for [troubleshooting](#troubleshooting).
|
||||
|
||||
|
@ -71,11 +72,11 @@ your SAML configuration differs from [the recommended SAML settings](index.md#az
|
|||
modify the corresponding `customappsso` settings accordingly. If a mapping is not listed in the
|
||||
table, use the Azure defaults.
|
||||
|
||||
| Azure Active Directory Attribute | `customappsso` Attribute | Matching precedence |
|
||||
| -------------------------------- | ---------------------- | -------------------- |
|
||||
| `objectId` | `externalId` | 1 |
|
||||
| `userPrincipalName` | `emails[type eq "work"].value` | |
|
||||
| `mailNickname` | `userName` | |
|
||||
| Azure Active Directory Attribute | `customappsso` Attribute | Matching precedence |
|
||||
| -------------------------------- | ------------------------------ | ------------------- |
|
||||
| `objectId` | `externalId` | 1 |
|
||||
| `userPrincipalName` | `emails[type eq "work"].value` | |
|
||||
| `mailNickname` | `userName` | |
|
||||
|
||||
For guidance, you can view [an example configuration in the troubleshooting reference](../../../administration/troubleshooting/group_saml_scim.md#azure-active-directory).
|
||||
|
||||
|
@ -162,6 +163,11 @@ graph TD
|
|||
B -->|Yes| D[GitLab sends message back 'Email exists']
|
||||
```
|
||||
|
||||
During provisioning, note the following:
|
||||
|
||||
- Both primary and secondary emails are considered when checking whether a GitLab user account exists.
|
||||
- Duplicate usernames are also handled, by adding suffix `1` upon user creation. E.g. due to already existing `test_user` username, `test_user1` is used).
|
||||
|
||||
As long as [Group SAML](index.md) has been configured, existing GitLab.com users can link to their accounts in one of the following ways:
|
||||
|
||||
- By updating their *primary* email address in their GitLab.com user account to match their identity provider's user profile email address.
|
||||
|
@ -183,13 +189,12 @@ For role information, please see the [Group SAML page](index.md#user-access-and-
|
|||
|
||||
### Blocking access
|
||||
|
||||
To rescind access to the group, remove the user from the identity
|
||||
provider or users list for the specific app.
|
||||
|
||||
Upon the next sync, the user is deprovisioned, which means that the user is removed from the group.
|
||||
To rescind access to the top-level group and all sub-groups and projects remove or deactivate the user on the identity provider.
|
||||
SCIM providers will generally update GitLab with the changes on-demand, which is minutes at most.
|
||||
The user's membership is revoked and they immediately lose access.
|
||||
|
||||
NOTE:
|
||||
Deprovisioning does not delete the user account.
|
||||
Deprovisioning does not delete the GitLab user account.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
|
@ -260,9 +265,9 @@ Alternatively, users can be removed from the SCIM app which de-links all removed
|
|||
|
||||
Changing the SAML or SCIM configuration or provider can cause the following problems:
|
||||
|
||||
| Problem | Solution |
|
||||
|------------------------------------------------------------------------------|--------------------|
|
||||
| SAML and SCIM identity mismatch. | First [verify that the user's SAML NameId matches the SCIM externalId](#how-do-i-verify-users-saml-nameid-matches-the-scim-externalid) and then [update or fix the mismatched SCIM externalId and SAML NameId](#update-or-fix-mismatched-scim-externalid-and-saml-nameid). |
|
||||
| Problem | Solution |
|
||||
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| SAML and SCIM identity mismatch. | First [verify that the user's SAML NameId matches the SCIM externalId](#how-do-i-verify-users-saml-nameid-matches-the-scim-externalid) and then [update or fix the mismatched SCIM externalId and SAML NameId](#update-or-fix-mismatched-scim-externalid-and-saml-nameid). |
|
||||
| SCIM identity mismatch between GitLab and the Identify Provider SCIM app. | You can confirm whether you're hitting the error because of your SCIM identity mismatch between your SCIM app and GitLab.com by using [SCIM API](../../../api/scim.md#update-a-single-scim-provisioned-user) which shows up in the `id` key and compares it with the user `externalId` in the SCIM app. You can use the same [SCIM API](../../../api/scim.md#update-a-single-scim-provisioned-user) to update the SCIM `id` for the user on GitLab.com. |
|
||||
|
||||
### Azure
|
||||
|
|
|
@ -57,10 +57,6 @@ module Backup
|
|||
}.merge(Gitlab::GitalyClient.connection_data(repository.storage)).to_json)
|
||||
end
|
||||
|
||||
def parallel_enqueue?
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def started?
|
||||
|
|
|
@ -1,132 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Backup
|
||||
# Backup and restores repositories using the gitaly RPC
|
||||
class GitalyRpcBackup
|
||||
def initialize(progress)
|
||||
@progress = progress
|
||||
end
|
||||
|
||||
def start(type)
|
||||
raise Error, 'already started' if @type
|
||||
|
||||
@type = type
|
||||
case type
|
||||
when :create
|
||||
FileUtils.rm_rf(backup_repos_path)
|
||||
FileUtils.mkdir_p(Gitlab.config.backup.path)
|
||||
FileUtils.mkdir(backup_repos_path, mode: 0700)
|
||||
when :restore
|
||||
# no op
|
||||
else
|
||||
raise Error, "unknown backup type: #{type}"
|
||||
end
|
||||
end
|
||||
|
||||
def wait
|
||||
@type = nil
|
||||
end
|
||||
|
||||
def enqueue(container, repository_type)
|
||||
backup_restore = BackupRestore.new(
|
||||
progress,
|
||||
repository_type.repository_for(container),
|
||||
backup_repos_path
|
||||
)
|
||||
|
||||
case @type
|
||||
when :create
|
||||
backup_restore.backup
|
||||
when :restore
|
||||
backup_restore.restore(always_create: repository_type.project?)
|
||||
else
|
||||
raise Error, 'not started'
|
||||
end
|
||||
end
|
||||
|
||||
def parallel_enqueue?
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :progress
|
||||
|
||||
def backup_repos_path
|
||||
@backup_repos_path ||= File.join(Gitlab.config.backup.path, 'repositories')
|
||||
end
|
||||
|
||||
class BackupRestore
|
||||
attr_accessor :progress, :repository, :backup_repos_path
|
||||
|
||||
def initialize(progress, repository, backup_repos_path)
|
||||
@progress = progress
|
||||
@repository = repository
|
||||
@backup_repos_path = backup_repos_path
|
||||
end
|
||||
|
||||
def backup
|
||||
progress.puts " * #{display_repo_path} ... "
|
||||
|
||||
if repository.empty?
|
||||
progress.puts " * #{display_repo_path} ... " + "[EMPTY] [SKIPPED]".color(:cyan)
|
||||
return
|
||||
end
|
||||
|
||||
FileUtils.mkdir_p(repository_backup_path)
|
||||
|
||||
repository.bundle_to_disk(path_to_bundle)
|
||||
repository.gitaly_repository_client.backup_custom_hooks(custom_hooks_tar)
|
||||
|
||||
progress.puts " * #{display_repo_path} ... " + "[DONE]".color(:green)
|
||||
|
||||
rescue StandardError => e
|
||||
progress.puts "[Failed] backing up #{display_repo_path}".color(:red)
|
||||
progress.puts "Error #{e}".color(:red)
|
||||
end
|
||||
|
||||
def restore(always_create: false)
|
||||
progress.puts " * #{display_repo_path} ... "
|
||||
|
||||
repository.remove rescue nil
|
||||
|
||||
if File.exist?(path_to_bundle)
|
||||
repository.create_from_bundle(path_to_bundle)
|
||||
restore_custom_hooks
|
||||
elsif always_create
|
||||
repository.create_repository
|
||||
end
|
||||
|
||||
progress.puts " * #{display_repo_path} ... " + "[DONE]".color(:green)
|
||||
|
||||
rescue StandardError => e
|
||||
progress.puts "[Failed] restoring #{display_repo_path}".color(:red)
|
||||
progress.puts "Error #{e}".color(:red)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def display_repo_path
|
||||
"#{repository.full_path} (#{repository.disk_path})"
|
||||
end
|
||||
|
||||
def repository_backup_path
|
||||
@repository_backup_path ||= File.join(backup_repos_path, repository.disk_path)
|
||||
end
|
||||
|
||||
def path_to_bundle
|
||||
@path_to_bundle ||= File.join(backup_repos_path, repository.disk_path + '.bundle')
|
||||
end
|
||||
|
||||
def restore_custom_hooks
|
||||
return unless File.exist?(custom_hooks_tar)
|
||||
|
||||
repository.gitaly_repository_client.restore_custom_hooks(custom_hooks_tar)
|
||||
end
|
||||
|
||||
def custom_hooks_tar
|
||||
File.join(repository_backup_path, "custom_hooks.tar")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -9,36 +9,10 @@ module Backup
|
|||
@strategy = strategy
|
||||
end
|
||||
|
||||
def dump(max_concurrency:, max_storage_concurrency:)
|
||||
def dump
|
||||
strategy.start(:create)
|
||||
enqueue_consecutive
|
||||
|
||||
# gitaly-backup is designed to handle concurrency on its own. So we want
|
||||
# to avoid entering the buggy concurrency code here when gitaly-backup
|
||||
# is enabled.
|
||||
if (max_concurrency <= 1 && max_storage_concurrency <= 1) || !strategy.parallel_enqueue?
|
||||
return enqueue_consecutive
|
||||
end
|
||||
|
||||
check_valid_storages!
|
||||
|
||||
semaphore = Concurrent::Semaphore.new(max_concurrency)
|
||||
errors = Queue.new
|
||||
|
||||
threads = Gitlab.config.repositories.storages.keys.map do |storage|
|
||||
Thread.new do
|
||||
Rails.application.executor.wrap do
|
||||
enqueue_storage(storage, semaphore, max_storage_concurrency: max_storage_concurrency)
|
||||
rescue StandardError => e
|
||||
errors << e
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
|
||||
threads.each(&:join)
|
||||
end
|
||||
|
||||
raise errors.pop unless errors.empty?
|
||||
ensure
|
||||
strategy.wait
|
||||
end
|
||||
|
@ -58,18 +32,6 @@ module Backup
|
|||
|
||||
attr_reader :progress, :strategy
|
||||
|
||||
def check_valid_storages!
|
||||
repository_storage_klasses.each do |klass|
|
||||
if klass.excluding_repository_storage(Gitlab.config.repositories.storages.keys).exists?
|
||||
raise Error, "repositories.storages in gitlab.yml does not include all storages used by #{klass}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def repository_storage_klasses
|
||||
[ProjectRepository, SnippetRepository]
|
||||
end
|
||||
|
||||
def enqueue_consecutive
|
||||
enqueue_consecutive_projects
|
||||
enqueue_consecutive_snippets
|
||||
|
@ -85,50 +47,6 @@ module Backup
|
|||
Snippet.find_each(batch_size: 1000) { |snippet| enqueue_snippet(snippet) }
|
||||
end
|
||||
|
||||
def enqueue_storage(storage, semaphore, max_storage_concurrency:)
|
||||
errors = Queue.new
|
||||
queue = InterlockSizedQueue.new(1)
|
||||
|
||||
threads = Array.new(max_storage_concurrency) do
|
||||
Thread.new do
|
||||
Rails.application.executor.wrap do
|
||||
while container = queue.pop
|
||||
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
|
||||
semaphore.acquire
|
||||
end
|
||||
|
||||
begin
|
||||
enqueue_container(container)
|
||||
rescue StandardError => e
|
||||
errors << e
|
||||
break
|
||||
ensure
|
||||
semaphore.release
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
enqueue_records_for_storage(storage, queue, errors)
|
||||
|
||||
raise errors.pop unless errors.empty?
|
||||
ensure
|
||||
queue.close
|
||||
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
|
||||
threads.each(&:join)
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue_container(container)
|
||||
case container
|
||||
when Project
|
||||
enqueue_project(container)
|
||||
when Snippet
|
||||
enqueue_snippet(container)
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue_project(project)
|
||||
strategy.enqueue(project, Gitlab::GlRepository::PROJECT)
|
||||
strategy.enqueue(project, Gitlab::GlRepository::WIKI)
|
||||
|
@ -139,32 +57,10 @@ module Backup
|
|||
strategy.enqueue(snippet, Gitlab::GlRepository::SNIPPET)
|
||||
end
|
||||
|
||||
def enqueue_records_for_storage(storage, queue, errors)
|
||||
records_to_enqueue(storage).each do |relation|
|
||||
relation.find_each(batch_size: 100) do |project|
|
||||
break unless errors.empty?
|
||||
|
||||
queue.push(project)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def records_to_enqueue(storage)
|
||||
[projects_in_storage(storage), snippets_in_storage(storage)]
|
||||
end
|
||||
|
||||
def projects_in_storage(storage)
|
||||
project_relation.id_in(ProjectRepository.for_repository_storage(storage).select(:project_id))
|
||||
end
|
||||
|
||||
def project_relation
|
||||
Project.includes(:route, :group, namespace: :owner)
|
||||
end
|
||||
|
||||
def snippets_in_storage(storage)
|
||||
Snippet.id_in(SnippetRepository.for_repository_storage(storage).select(:snippet_id))
|
||||
end
|
||||
|
||||
def restore_object_pools
|
||||
PoolRepository.includes(:source_project).find_each do |pool|
|
||||
progress.puts " - Object pool #{pool.disk_path}..."
|
||||
|
@ -199,24 +95,6 @@ module Backup
|
|||
|
||||
Snippet.id_in(invalid_snippets).delete_all
|
||||
end
|
||||
|
||||
class InterlockSizedQueue < SizedQueue
|
||||
extend ::Gitlab::Utils::Override
|
||||
|
||||
override :pop
|
||||
def pop(*)
|
||||
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
override :push
|
||||
def push(*)
|
||||
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
module Gitlab
|
||||
module Database
|
||||
DATABASE_NAMES = %w[main ci].freeze
|
||||
|
||||
MAIN_DATABASE_NAME = 'main'
|
||||
CI_DATABASE_NAME = 'ci'
|
||||
|
||||
# This constant is used when renaming tables concurrently.
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# The purpose of this code is to transform legacy `database.yml`
|
||||
# into a `database.yml` containing `main:` as a name of a first database
|
||||
#
|
||||
# This should be removed once all places using legacy `database.yml`
|
||||
# are fixed. The likely moment to remove this check is the %14.0.
|
||||
#
|
||||
# This converts the following syntax:
|
||||
#
|
||||
# production:
|
||||
# adapter: postgresql
|
||||
# database: gitlabhq_production
|
||||
# username: git
|
||||
# password: "secure password"
|
||||
# host: localhost
|
||||
#
|
||||
# Into:
|
||||
#
|
||||
# production:
|
||||
# main:
|
||||
# adapter: postgresql
|
||||
# database: gitlabhq_production
|
||||
# username: git
|
||||
# password: "secure password"
|
||||
# host: localhost
|
||||
#
|
||||
|
||||
module Gitlab
|
||||
module Patch
|
||||
module LegacyDatabaseConfig
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
prepended do
|
||||
attr_reader :uses_legacy_database_config
|
||||
end
|
||||
|
||||
def database_configuration
|
||||
@uses_legacy_database_config = false # rubocop:disable Gitlab/ModuleWithInstanceVariables
|
||||
|
||||
super.to_h do |env, configs|
|
||||
# This check is taken from Rails where the transformation
|
||||
# of a flat database.yml is done into `primary:`
|
||||
# https://github.com/rails/rails/blob/v6.1.4/activerecord/lib/active_record/database_configurations.rb#L169
|
||||
if configs.is_a?(Hash) && !configs.all? { |_, v| v.is_a?(Hash) }
|
||||
configs = { "main" => configs }
|
||||
|
||||
@uses_legacy_database_config = true # rubocop:disable Gitlab/ModuleWithInstanceVariables
|
||||
end
|
||||
|
||||
[env, configs]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -102,19 +102,10 @@ namespace :gitlab do
|
|||
task create: :gitlab_environment do
|
||||
puts_time "Dumping repositories ...".color(:blue)
|
||||
|
||||
max_concurrency = ENV.fetch('GITLAB_BACKUP_MAX_CONCURRENCY', 1).to_i
|
||||
max_storage_concurrency = ENV.fetch('GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY', 1).to_i
|
||||
|
||||
if ENV["SKIP"] && ENV["SKIP"].include?("repositories")
|
||||
puts_time "[SKIPPED]".color(:cyan)
|
||||
elsif max_concurrency < 1 || max_storage_concurrency < 1
|
||||
puts "GITLAB_BACKUP_MAX_CONCURRENCY and GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY must have a value of at least 1".color(:red)
|
||||
exit 1
|
||||
else
|
||||
Backup::Repositories.new(progress, strategy: repository_backup_strategy).dump(
|
||||
max_concurrency: max_concurrency,
|
||||
max_storage_concurrency: max_storage_concurrency
|
||||
)
|
||||
Backup::Repositories.new(progress, strategy: repository_backup_strategy).dump
|
||||
puts_time "done".color(:green)
|
||||
end
|
||||
end
|
||||
|
@ -299,13 +290,9 @@ namespace :gitlab do
|
|||
end
|
||||
|
||||
def repository_backup_strategy
|
||||
if Feature.enabled?(:gitaly_backup, default_enabled: :yaml)
|
||||
max_concurrency = ENV['GITLAB_BACKUP_MAX_CONCURRENCY'].presence
|
||||
max_storage_concurrency = ENV['GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY'].presence
|
||||
Backup::GitalyBackup.new(progress, parallel: max_concurrency, parallel_storage: max_storage_concurrency)
|
||||
else
|
||||
Backup::GitalyRpcBackup.new(progress)
|
||||
end
|
||||
max_concurrency = ENV['GITLAB_BACKUP_MAX_CONCURRENCY'].presence
|
||||
max_storage_concurrency = ENV['GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY'].presence
|
||||
Backup::GitalyBackup.new(progress, parallel: max_concurrency, parallel_storage: max_storage_concurrency)
|
||||
end
|
||||
end
|
||||
# namespace end: backup
|
||||
|
|
|
@ -621,9 +621,6 @@ msgstr ""
|
|||
msgid "%{issueType} actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{issuesCount} issues with a limit of %{maxIssueCount}"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{issuesSize} with a limit of %{maxIssueCount}"
|
||||
msgstr ""
|
||||
|
||||
|
@ -20232,9 +20229,6 @@ msgstr ""
|
|||
msgid "Loading functions timed out. Please reload the page to try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading issues"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading more"
|
||||
msgstr ""
|
||||
|
||||
|
@ -30904,9 +30898,6 @@ msgstr ""
|
|||
msgid "Showing %{pageSize} of %{total} %{issuableType}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Showing %{pageSize} of %{total} issues"
|
||||
msgstr ""
|
||||
|
||||
msgid "Showing all epics"
|
||||
msgstr ""
|
||||
|
||||
|
@ -31327,9 +31318,6 @@ msgstr ""
|
|||
msgid "Something went wrong while obtaining the Let's Encrypt certificate."
|
||||
msgstr ""
|
||||
|
||||
msgid "Something went wrong while performing the action."
|
||||
msgstr ""
|
||||
|
||||
msgid "Something went wrong while promoting the issue to an epic. Please try again."
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -115,7 +115,7 @@
|
|||
"codesandbox-api": "0.0.23",
|
||||
"compression-webpack-plugin": "^5.0.2",
|
||||
"copy-webpack-plugin": "^6.4.1",
|
||||
"core-js": "^3.16.4",
|
||||
"core-js": "^3.17.1",
|
||||
"cron-validator": "^1.1.1",
|
||||
"cropper": "^2.3.0",
|
||||
"css-loader": "^2.1.1",
|
||||
|
|
|
@ -1,274 +0,0 @@
|
|||
/* global List */
|
||||
/* global ListIssue */
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Vue from 'vue';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import BoardList from '~/boards/components/board_list_deprecated.vue';
|
||||
import eventHub from '~/boards/eventhub';
|
||||
import store from '~/boards/stores';
|
||||
import boardsStore from '~/boards/stores/boards_store';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import '~/boards/models/issue';
|
||||
import '~/boards/models/list';
|
||||
import { listObj, boardsMockInterceptor } from './mock_data';
|
||||
|
||||
const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listProps = {} }) => {
|
||||
const el = document.createElement('div');
|
||||
|
||||
document.body.appendChild(el);
|
||||
const mock = new MockAdapter(axios);
|
||||
mock.onAny().reply(boardsMockInterceptor);
|
||||
boardsStore.create();
|
||||
|
||||
const BoardListComp = Vue.extend(BoardList);
|
||||
const list = new List({ ...listObj, ...listProps });
|
||||
const issue = new ListIssue({
|
||||
title: 'Testing',
|
||||
id: 1,
|
||||
iid: 1,
|
||||
confidential: false,
|
||||
labels: [],
|
||||
assignees: [],
|
||||
...listIssueProps,
|
||||
});
|
||||
if (!Object.prototype.hasOwnProperty.call(listProps, 'issuesSize')) {
|
||||
list.issuesSize = 1;
|
||||
}
|
||||
list.issues.push(issue);
|
||||
|
||||
const component = new BoardListComp({
|
||||
el,
|
||||
store,
|
||||
propsData: {
|
||||
disabled: false,
|
||||
list,
|
||||
issues: list.issues,
|
||||
...componentProps,
|
||||
},
|
||||
provide: {
|
||||
groupId: null,
|
||||
rootPath: '/',
|
||||
},
|
||||
}).$mount();
|
||||
|
||||
Vue.nextTick(() => {
|
||||
done();
|
||||
});
|
||||
|
||||
return { component, mock };
|
||||
};
|
||||
|
||||
describe('Board list component', () => {
|
||||
let mock;
|
||||
let component;
|
||||
let getIssues;
|
||||
function generateIssues(compWrapper) {
|
||||
for (let i = 1; i < 20; i += 1) {
|
||||
const issue = { ...compWrapper.list.issues[0] };
|
||||
issue.id += i;
|
||||
compWrapper.list.issues.push(issue);
|
||||
}
|
||||
}
|
||||
|
||||
describe('When Expanded', () => {
|
||||
beforeEach((done) => {
|
||||
getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
|
||||
({ mock, component } = createComponent({ done }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
component.$destroy();
|
||||
});
|
||||
|
||||
it('loads first page of issues', () => {
|
||||
return waitForPromises().then(() => {
|
||||
expect(getIssues).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders component', () => {
|
||||
expect(component.$el.classList.contains('board-list-component')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders loading icon', () => {
|
||||
component.list.loading = true;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-list-loading')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders issues', () => {
|
||||
expect(component.$el.querySelectorAll('.board-card').length).toBe(1);
|
||||
});
|
||||
|
||||
it('sets data attribute with issue id', () => {
|
||||
expect(component.$el.querySelector('.board-card').getAttribute('data-issue-id')).toBe('1');
|
||||
});
|
||||
|
||||
it('shows new issue form', () => {
|
||||
component.toggleForm();
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
|
||||
|
||||
expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows new issue form after eventhub event', () => {
|
||||
eventHub.$emit(`toggle-issue-form-${component.list.id}`);
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-new-issue-form')).not.toBeNull();
|
||||
|
||||
expect(component.$el.querySelector('.is-smaller')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show new issue form for closed list', () => {
|
||||
component.list.type = 'closed';
|
||||
component.toggleForm();
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-new-issue-form')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows count list item', () => {
|
||||
component.showCount = true;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-list-count')).not.toBeNull();
|
||||
|
||||
expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
|
||||
'Showing all issues',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets data attribute with invalid id', () => {
|
||||
component.showCount = true;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-list-count').getAttribute('data-issue-id')).toBe(
|
||||
'-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows how many more issues to load', () => {
|
||||
component.showCount = true;
|
||||
component.list.issuesSize = 20;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-list-count').textContent.trim()).toBe(
|
||||
'Showing 1 of 20 issues',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads more issues after scrolling', () => {
|
||||
jest.spyOn(component.list, 'nextPage').mockImplementation(() => {});
|
||||
generateIssues(component);
|
||||
component.$refs.list.dispatchEvent(new Event('scroll'));
|
||||
|
||||
return waitForPromises().then(() => {
|
||||
expect(component.list.nextPage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not load issues if already loading', () => {
|
||||
component.list.nextPage = jest
|
||||
.spyOn(component.list, 'nextPage')
|
||||
.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
component.onScroll();
|
||||
component.onScroll();
|
||||
|
||||
return waitForPromises().then(() => {
|
||||
expect(component.list.nextPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading more spinner', () => {
|
||||
component.showCount = true;
|
||||
component.list.loadingMore = true;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.board-list-count .gl-spinner')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When Collapsed', () => {
|
||||
beforeEach((done) => {
|
||||
getIssues = jest.spyOn(List.prototype, 'getIssues').mockReturnValue(new Promise(() => {}));
|
||||
({ mock, component } = createComponent({
|
||||
done,
|
||||
listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
|
||||
}));
|
||||
generateIssues(component);
|
||||
component.scrollHeight = jest.spyOn(component, 'scrollHeight').mockReturnValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
component.$destroy();
|
||||
});
|
||||
|
||||
it('does not load all issues', () => {
|
||||
return waitForPromises().then(() => {
|
||||
// Initial getIssues from list constructor
|
||||
expect(getIssues).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('max issue count warning', () => {
|
||||
beforeEach((done) => {
|
||||
({ mock, component } = createComponent({
|
||||
done,
|
||||
listProps: { type: 'closed', collapsed: true, issuesSize: 50 },
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
component.$destroy();
|
||||
});
|
||||
|
||||
describe('when issue count exceeds max issue count', () => {
|
||||
it('sets background to bg-danger-100', () => {
|
||||
component.list.issuesSize = 4;
|
||||
component.list.maxIssueCount = 3;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.bg-danger-100')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when list issue count does NOT exceed list max issue count', () => {
|
||||
it('does not sets background to bg-danger-100', () => {
|
||||
component.list.issuesSize = 2;
|
||||
component.list.maxIssueCount = 3;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when list max issue count is 0', () => {
|
||||
it('does not sets background to bg-danger-100', () => {
|
||||
component.list.maxIssueCount = 0;
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(component.$el.querySelector('.bg-danger-100')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,211 +0,0 @@
|
|||
/* global List */
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import boardNewIssue from '~/boards/components/board_new_issue_deprecated.vue';
|
||||
import boardsStore from '~/boards/stores/boards_store';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
import '~/boards/models/list';
|
||||
import { listObj, boardsMockInterceptor } from './mock_data';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
describe('Issue boards new issue form', () => {
|
||||
let wrapper;
|
||||
let vm;
|
||||
let list;
|
||||
let mock;
|
||||
let newIssueMock;
|
||||
const promiseReturn = {
|
||||
data: {
|
||||
iid: 100,
|
||||
},
|
||||
};
|
||||
|
||||
const submitIssue = () => {
|
||||
const dummySubmitEvent = {
|
||||
preventDefault() {},
|
||||
};
|
||||
wrapper.vm.$refs.submitButton = wrapper.find({ ref: 'submitButton' });
|
||||
return wrapper.vm.submit(dummySubmitEvent);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const BoardNewIssueComp = Vue.extend(boardNewIssue);
|
||||
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onAny().reply(boardsMockInterceptor);
|
||||
|
||||
boardsStore.create();
|
||||
|
||||
list = new List(listObj);
|
||||
|
||||
newIssueMock = Promise.resolve(promiseReturn);
|
||||
jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock);
|
||||
|
||||
const store = new Vuex.Store({
|
||||
getters: { isGroupBoard: () => false },
|
||||
});
|
||||
|
||||
wrapper = mount(BoardNewIssueComp, {
|
||||
propsData: {
|
||||
disabled: false,
|
||||
list,
|
||||
},
|
||||
store,
|
||||
provide: {
|
||||
groupId: null,
|
||||
},
|
||||
});
|
||||
|
||||
vm = wrapper.vm;
|
||||
|
||||
return Vue.nextTick();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('calls submit if submit button is clicked', () => {
|
||||
jest.spyOn(wrapper.vm, 'submit').mockImplementation();
|
||||
vm.title = 'Testing Title';
|
||||
|
||||
return Vue.nextTick()
|
||||
.then(submitIssue)
|
||||
.then(() => {
|
||||
expect(wrapper.vm.submit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables submit button if title is empty', () => {
|
||||
expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('enables submit button if title is not empty', () => {
|
||||
wrapper.setData({ title: 'Testing Title' });
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title');
|
||||
expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears title after clicking cancel', () => {
|
||||
wrapper.find({ ref: 'cancelButton' }).trigger('click');
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(vm.title).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not create new issue if title is empty', () => {
|
||||
return submitIssue().then(() => {
|
||||
expect(list.newIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit success', () => {
|
||||
it('creates new issue', () => {
|
||||
wrapper.setData({ title: 'create issue' });
|
||||
|
||||
return Vue.nextTick()
|
||||
.then(submitIssue)
|
||||
.then(() => {
|
||||
expect(list.newIssue).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('enables button after submit', () => {
|
||||
jest.spyOn(wrapper.vm, 'submit').mockImplementation();
|
||||
wrapper.setData({ title: 'create issue' });
|
||||
|
||||
return Vue.nextTick()
|
||||
.then(submitIssue)
|
||||
.then(() => {
|
||||
expect(wrapper.vm.$refs.submitButton.props().disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears title after submit', () => {
|
||||
wrapper.setData({ title: 'create issue' });
|
||||
|
||||
return Vue.nextTick()
|
||||
.then(submitIssue)
|
||||
.then(() => {
|
||||
expect(vm.title).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('sets detail issue after submit', () => {
|
||||
expect(boardsStore.detail.issue.title).toBe(undefined);
|
||||
wrapper.setData({ title: 'create issue' });
|
||||
|
||||
return Vue.nextTick()
|
||||
.then(submitIssue)
|
||||
.then(() => {
|
||||
expect(boardsStore.detail.issue.title).toBe('create issue');
|
||||
});
|
||||
});
|
||||
|
||||
it('sets detail list after submit', () => {
|
||||
wrapper.setData({ title: 'create issue' });
|
||||
|
||||
return Vue.nextTick()
|
||||
.then(submitIssue)
|
||||
.then(() => {
|
||||
expect(boardsStore.detail.list.id).toBe(list.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets detail weight after submit', () => {
|
||||
boardsStore.weightFeatureAvailable = true;
|
||||
wrapper.setData({ title: 'create issue' });
|
||||
|
||||
return Vue.nextTick()
|
||||
.then(submitIssue)
|
||||
.then(() => {
|
||||
expect(boardsStore.detail.list.weight).toBe(list.weight);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not set detail weight after submit', () => {
|
||||
boardsStore.weightFeatureAvailable = false;
|
||||
wrapper.setData({ title: 'create issue' });
|
||||
|
||||
return Vue.nextTick()
|
||||
.then(submitIssue)
|
||||
.then(() => {
|
||||
expect(boardsStore.detail.list.weight).toBe(list.weight);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit error', () => {
|
||||
beforeEach(() => {
|
||||
newIssueMock = Promise.reject(new Error('My hovercraft is full of eels!'));
|
||||
vm.title = 'error';
|
||||
});
|
||||
|
||||
it('removes issue', () => {
|
||||
const lengthBefore = list.issues.length;
|
||||
return Vue.nextTick()
|
||||
.then(submitIssue)
|
||||
.then(() => {
|
||||
expect(list.issues.length).toBe(lengthBefore);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error', () => {
|
||||
return Vue.nextTick()
|
||||
.then(submitIssue)
|
||||
.then(() => {
|
||||
expect(vm.error).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,181 +0,0 @@
|
|||
/* global List */
|
||||
/* global ListAssignee */
|
||||
/* global ListLabel */
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import BoardCardDeprecated from '~/boards/components/board_card_deprecated.vue';
|
||||
import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue';
|
||||
import store from '~/boards/stores';
|
||||
import boardsStore from '~/boards/stores/boards_store';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
import sidebarEventHub from '~/sidebar/event_hub';
|
||||
import '~/boards/models/label';
|
||||
import '~/boards/models/assignee';
|
||||
import '~/boards/models/list';
|
||||
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
|
||||
|
||||
describe('BoardCard', () => {
|
||||
let wrapper;
|
||||
let mock;
|
||||
let list;
|
||||
|
||||
const findIssueCardInner = () => wrapper.find(issueCardInner);
|
||||
const findUserAvatarLink = () => wrapper.find(userAvatarLink);
|
||||
|
||||
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
|
||||
const mountComponent = (propsData) => {
|
||||
wrapper = mount(BoardCardDeprecated, {
|
||||
stubs: {
|
||||
issueCardInner,
|
||||
},
|
||||
store,
|
||||
propsData: {
|
||||
list,
|
||||
issue: list.issues[0],
|
||||
disabled: false,
|
||||
index: 0,
|
||||
...propsData,
|
||||
},
|
||||
provide: {
|
||||
groupId: null,
|
||||
rootPath: '/',
|
||||
scopedLabelsAvailable: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setupData = async () => {
|
||||
list = new List(listObj);
|
||||
boardsStore.create();
|
||||
boardsStore.detail.issue = {};
|
||||
const label1 = new ListLabel({
|
||||
id: 3,
|
||||
title: 'testing 123',
|
||||
color: '#000cff',
|
||||
text_color: 'white',
|
||||
description: 'test',
|
||||
});
|
||||
await waitForPromises();
|
||||
|
||||
list.issues[0].labels.push(label1);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onAny().reply(boardsMockInterceptor);
|
||||
setMockEndpoints();
|
||||
return setupData();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
list = null;
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('when details issue is empty does not show the element', () => {
|
||||
mountComponent();
|
||||
expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active');
|
||||
});
|
||||
|
||||
it('when detailIssue is equal to card issue shows the element', () => {
|
||||
[boardsStore.detail.issue] = list.issues;
|
||||
mountComponent();
|
||||
|
||||
expect(wrapper.classes()).toContain('is-active');
|
||||
});
|
||||
|
||||
it('when multiSelect does not contain issue removes multi select class', () => {
|
||||
mountComponent();
|
||||
expect(wrapper.classes()).not.toContain('multi-select');
|
||||
});
|
||||
|
||||
it('when multiSelect contain issue add multi select class', () => {
|
||||
boardsStore.multiSelect.list = [list.issues[0]];
|
||||
mountComponent();
|
||||
|
||||
expect(wrapper.classes()).toContain('multi-select');
|
||||
});
|
||||
|
||||
it('adds user-can-drag class if not disabled', () => {
|
||||
mountComponent();
|
||||
expect(wrapper.classes()).toContain('user-can-drag');
|
||||
});
|
||||
|
||||
it('does not add user-can-drag class disabled', () => {
|
||||
mountComponent({ disabled: true });
|
||||
|
||||
expect(wrapper.classes()).not.toContain('user-can-drag');
|
||||
});
|
||||
|
||||
it('does not add disabled class', () => {
|
||||
mountComponent();
|
||||
expect(wrapper.classes()).not.toContain('is-disabled');
|
||||
});
|
||||
|
||||
it('adds disabled class is disabled is true', () => {
|
||||
mountComponent({ disabled: true });
|
||||
|
||||
expect(wrapper.classes()).toContain('is-disabled');
|
||||
});
|
||||
|
||||
describe('mouse events', () => {
|
||||
it('does not set detail issue if showDetail is false', () => {
|
||||
mountComponent();
|
||||
expect(boardsStore.detail.issue).toEqual({});
|
||||
});
|
||||
|
||||
it('does not set detail issue if link is clicked', () => {
|
||||
mountComponent();
|
||||
findIssueCardInner().find('a').trigger('mouseup');
|
||||
|
||||
expect(boardsStore.detail.issue).toEqual({});
|
||||
});
|
||||
|
||||
it('does not set detail issue if img is clicked', () => {
|
||||
mountComponent({
|
||||
issue: {
|
||||
...list.issues[0],
|
||||
assignees: [
|
||||
new ListAssignee({
|
||||
id: 1,
|
||||
name: 'testing 123',
|
||||
username: 'test',
|
||||
avatar: 'test_image',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
findUserAvatarLink().trigger('mouseup');
|
||||
|
||||
expect(boardsStore.detail.issue).toEqual({});
|
||||
});
|
||||
|
||||
it('does not set detail issue if showDetail is false after mouseup', () => {
|
||||
mountComponent();
|
||||
wrapper.trigger('mouseup');
|
||||
|
||||
expect(boardsStore.detail.issue).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidebarHub events', () => {
|
||||
it('it does not closes all sidebars before showing an issue if an issue is opened', () => {
|
||||
jest.spyOn(sidebarEventHub, '$emit').mockImplementation(() => {});
|
||||
const [issue] = list.issues;
|
||||
boardsStore.detail.issue = issue;
|
||||
mountComponent();
|
||||
|
||||
wrapper.trigger('mousedown');
|
||||
|
||||
expect(sidebarEventHub.$emit).not.toHaveBeenCalledWith('sidebar.closeAll');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,154 +0,0 @@
|
|||
/* global List */
|
||||
/* global ListLabel */
|
||||
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import Vuex from 'vuex';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
|
||||
import '~/boards/models/label';
|
||||
import '~/boards/models/assignee';
|
||||
import '~/boards/models/list';
|
||||
import BoardCardLayout from '~/boards/components/board_card_layout_deprecated.vue';
|
||||
import issueCardInner from '~/boards/components/issue_card_inner_deprecated.vue';
|
||||
import { ISSUABLE } from '~/boards/constants';
|
||||
import boardsVuexStore from '~/boards/stores';
|
||||
import boardsStore from '~/boards/stores/boards_store';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data';
|
||||
|
||||
describe('Board card layout', () => {
|
||||
let wrapper;
|
||||
let mock;
|
||||
let list;
|
||||
let store;
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
|
||||
const createStore = ({ getters = {}, actions = {} } = {}) => {
|
||||
store = new Vuex.Store({
|
||||
...boardsVuexStore,
|
||||
actions,
|
||||
getters,
|
||||
});
|
||||
};
|
||||
|
||||
// this particular mount component needs to be used after the root beforeEach because it depends on list being initialized
|
||||
const mountComponent = ({ propsData = {}, provide = {} } = {}) => {
|
||||
wrapper = shallowMount(BoardCardLayout, {
|
||||
localVue,
|
||||
stubs: {
|
||||
issueCardInner,
|
||||
},
|
||||
store,
|
||||
propsData: {
|
||||
list,
|
||||
issue: list.issues[0],
|
||||
disabled: false,
|
||||
index: 0,
|
||||
...propsData,
|
||||
},
|
||||
provide: {
|
||||
groupId: null,
|
||||
rootPath: '/',
|
||||
scopedLabelsAvailable: false,
|
||||
...provide,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setupData = () => {
|
||||
list = new List(listObj);
|
||||
boardsStore.create();
|
||||
boardsStore.detail.issue = {};
|
||||
const label1 = new ListLabel({
|
||||
id: 3,
|
||||
title: 'testing 123',
|
||||
color: '#000cff',
|
||||
text_color: 'white',
|
||||
description: 'test',
|
||||
});
|
||||
return waitForPromises().then(() => {
|
||||
list.issues[0].labels.push(label1);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onAny().reply(boardsMockInterceptor);
|
||||
setMockEndpoints();
|
||||
return setupData();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
list = null;
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe('mouse events', () => {
|
||||
it('sets showDetail to true on mousedown', async () => {
|
||||
createStore();
|
||||
mountComponent();
|
||||
|
||||
wrapper.trigger('mousedown');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.showDetail).toBe(true);
|
||||
});
|
||||
|
||||
it('sets showDetail to false on mousemove', async () => {
|
||||
createStore();
|
||||
mountComponent();
|
||||
wrapper.trigger('mousedown');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.vm.showDetail).toBe(true);
|
||||
wrapper.trigger('mousemove');
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.vm.showDetail).toBe(false);
|
||||
});
|
||||
|
||||
it("calls 'setActiveId'", async () => {
|
||||
const setActiveId = jest.fn();
|
||||
createStore({
|
||||
actions: {
|
||||
setActiveId,
|
||||
},
|
||||
});
|
||||
mountComponent();
|
||||
|
||||
wrapper.trigger('mouseup');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(setActiveId).toHaveBeenCalledTimes(1);
|
||||
expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
|
||||
id: list.issues[0].id,
|
||||
sidebarType: ISSUABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls 'setActiveId' when epic swimlanes is active", async () => {
|
||||
const setActiveId = jest.fn();
|
||||
const isSwimlanesOn = () => true;
|
||||
createStore({
|
||||
getters: { isSwimlanesOn },
|
||||
actions: {
|
||||
setActiveId,
|
||||
},
|
||||
});
|
||||
mountComponent();
|
||||
|
||||
wrapper.trigger('mouseup');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(setActiveId).toHaveBeenCalledTimes(1);
|
||||
expect(setActiveId).toHaveBeenCalledWith(expect.any(Object), {
|
||||
id: list.issues[0].id,
|
||||
sidebarType: ISSUABLE,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,106 +0,0 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import AxiosMockAdapter from 'axios-mock-adapter';
|
||||
import Vue, { nextTick } from 'vue';
|
||||
|
||||
import { TEST_HOST } from 'helpers/test_constants';
|
||||
import { listObj } from 'jest/boards/mock_data';
|
||||
import Board from '~/boards/components/board_column_deprecated.vue';
|
||||
import { ListType } from '~/boards/constants';
|
||||
import List from '~/boards/models/list';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
describe('Board Column Component', () => {
|
||||
let wrapper;
|
||||
let axiosMock;
|
||||
|
||||
beforeEach(() => {
|
||||
window.gon = {};
|
||||
axiosMock = new AxiosMockAdapter(axios);
|
||||
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
|
||||
wrapper.destroy();
|
||||
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const createComponent = ({
|
||||
listType = ListType.backlog,
|
||||
collapsed = false,
|
||||
highlighted = false,
|
||||
withLocalStorage = true,
|
||||
} = {}) => {
|
||||
const boardId = '1';
|
||||
|
||||
const listMock = {
|
||||
...listObj,
|
||||
list_type: listType,
|
||||
highlighted,
|
||||
collapsed,
|
||||
};
|
||||
|
||||
if (listType === ListType.assignee) {
|
||||
delete listMock.label;
|
||||
listMock.user = {};
|
||||
}
|
||||
|
||||
// Making List reactive
|
||||
const list = Vue.observable(new List(listMock));
|
||||
|
||||
if (withLocalStorage) {
|
||||
localStorage.setItem(
|
||||
`boards.${boardId}.${list.type}.${list.id}.expanded`,
|
||||
(!collapsed).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
wrapper = shallowMount(Board, {
|
||||
propsData: {
|
||||
boardId,
|
||||
disabled: false,
|
||||
list,
|
||||
},
|
||||
provide: {
|
||||
boardId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isExpandable = () => wrapper.classes('is-expandable');
|
||||
const isCollapsed = () => wrapper.classes('is-collapsed');
|
||||
|
||||
describe('Given different list types', () => {
|
||||
it('is expandable when List Type is `backlog`', () => {
|
||||
createComponent({ listType: ListType.backlog });
|
||||
|
||||
expect(isExpandable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expanded / collapsed column', () => {
|
||||
it('has class is-collapsed when list is collapsed', () => {
|
||||
createComponent({ collapsed: false });
|
||||
|
||||
expect(wrapper.vm.list.isExpanded).toBe(true);
|
||||
});
|
||||
|
||||
it('does not have class is-collapsed when list is expanded', () => {
|
||||
createComponent({ collapsed: true });
|
||||
|
||||
expect(isCollapsed()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlighting', () => {
|
||||
it('scrolls to column when highlighted', async () => {
|
||||
createComponent({ highlighted: true });
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.element.scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,174 +0,0 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import AxiosMockAdapter from 'axios-mock-adapter';
|
||||
import Vue from 'vue';
|
||||
|
||||
import { TEST_HOST } from 'helpers/test_constants';
|
||||
import { listObj } from 'jest/boards/mock_data';
|
||||
import BoardListHeader from '~/boards/components/board_list_header_deprecated.vue';
|
||||
import { ListType } from '~/boards/constants';
|
||||
import List from '~/boards/models/list';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
||||
describe('Board List Header Component', () => {
|
||||
let wrapper;
|
||||
let axiosMock;
|
||||
|
||||
beforeEach(() => {
|
||||
window.gon = {};
|
||||
axiosMock = new AxiosMockAdapter(axios);
|
||||
axiosMock.onGet(`${TEST_HOST}/lists/1/issues`).reply(200, { issues: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
|
||||
wrapper.destroy();
|
||||
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const createComponent = ({
|
||||
listType = ListType.backlog,
|
||||
collapsed = false,
|
||||
withLocalStorage = true,
|
||||
currentUserId = 1,
|
||||
} = {}) => {
|
||||
const boardId = '1';
|
||||
|
||||
const listMock = {
|
||||
...listObj,
|
||||
list_type: listType,
|
||||
collapsed,
|
||||
};
|
||||
|
||||
if (listType === ListType.assignee) {
|
||||
delete listMock.label;
|
||||
listMock.user = {};
|
||||
}
|
||||
|
||||
// Making List reactive
|
||||
const list = Vue.observable(new List(listMock));
|
||||
|
||||
if (withLocalStorage) {
|
||||
localStorage.setItem(
|
||||
`boards.${boardId}.${list.type}.${list.id}.expanded`,
|
||||
(!collapsed).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
wrapper = shallowMount(BoardListHeader, {
|
||||
propsData: {
|
||||
disabled: false,
|
||||
list,
|
||||
},
|
||||
provide: {
|
||||
boardId,
|
||||
currentUserId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isCollapsed = () => !wrapper.props().list.isExpanded;
|
||||
const isExpanded = () => wrapper.vm.list.isExpanded;
|
||||
|
||||
const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
|
||||
const findCaret = () => wrapper.find('.board-title-caret');
|
||||
|
||||
describe('Add issue button', () => {
|
||||
const hasNoAddButton = [ListType.closed];
|
||||
const hasAddButton = [
|
||||
ListType.backlog,
|
||||
ListType.label,
|
||||
ListType.milestone,
|
||||
ListType.iteration,
|
||||
ListType.assignee,
|
||||
];
|
||||
|
||||
it.each(hasNoAddButton)('does not render when List Type is `%s`', (listType) => {
|
||||
createComponent({ listType });
|
||||
|
||||
expect(findAddIssueButton().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it.each(hasAddButton)('does render when List Type is `%s`', (listType) => {
|
||||
createComponent({ listType });
|
||||
|
||||
expect(findAddIssueButton().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('has a test for each list type', () => {
|
||||
Object.values(ListType).forEach((value) => {
|
||||
expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render when logged out', () => {
|
||||
createComponent({
|
||||
currentUserId: null,
|
||||
});
|
||||
|
||||
expect(findAddIssueButton().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expanding / collapsing the column', () => {
|
||||
it('does not collapse when clicking the header', () => {
|
||||
createComponent();
|
||||
|
||||
expect(isCollapsed()).toBe(false);
|
||||
wrapper.find('[data-testid="board-list-header"]').trigger('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(isCollapsed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses expanded Column when clicking the collapse icon', () => {
|
||||
createComponent();
|
||||
|
||||
expect(isExpanded()).toBe(true);
|
||||
findCaret().vm.$emit('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(isCollapsed()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('expands collapsed Column when clicking the expand icon', () => {
|
||||
createComponent({ collapsed: true });
|
||||
|
||||
expect(isCollapsed()).toBe(true);
|
||||
findCaret().vm.$emit('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(isCollapsed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("when logged in it calls list update and doesn't set localStorage", () => {
|
||||
jest.spyOn(List.prototype, 'update');
|
||||
|
||||
createComponent({ withLocalStorage: false });
|
||||
|
||||
findCaret().vm.$emit('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.list.update).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
it("when logged out it doesn't call list update and sets localStorage", () => {
|
||||
jest.spyOn(List.prototype, 'update');
|
||||
|
||||
createComponent({ currentUserId: null });
|
||||
|
||||
findCaret().vm.$emit('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.vm.list.update).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,214 +0,0 @@
|
|||
import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { TEST_HOST } from 'spec/test_constants';
|
||||
import BoardsSelector from '~/boards/components/boards_selector_deprecated.vue';
|
||||
import boardsStore from '~/boards/stores/boards_store';
|
||||
|
||||
const throttleDuration = 1;
|
||||
|
||||
function boardGenerator(n) {
|
||||
return new Array(n).fill().map((board, index) => {
|
||||
const id = `${index}`;
|
||||
const name = `board${id}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe('BoardsSelector', () => {
|
||||
let wrapper;
|
||||
let allBoardsResponse;
|
||||
let recentBoardsResponse;
|
||||
const boards = boardGenerator(20);
|
||||
const recentBoards = boardGenerator(5);
|
||||
|
||||
const fillSearchBox = (filterTerm) => {
|
||||
const searchBox = wrapper.find({ ref: 'searchBox' });
|
||||
const searchBoxInput = searchBox.find('input');
|
||||
searchBoxInput.setValue(filterTerm);
|
||||
searchBoxInput.trigger('input');
|
||||
};
|
||||
|
||||
const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
|
||||
const getDropdownHeaders = () => wrapper.findAll(GlDropdownSectionHeader);
|
||||
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
|
||||
const findDropdown = () => wrapper.find(GlDropdown);
|
||||
|
||||
beforeEach(() => {
|
||||
const $apollo = {
|
||||
queries: {
|
||||
boards: {
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
boardsStore.setEndpoints({
|
||||
boardsEndpoint: '',
|
||||
recentBoardsEndpoint: '',
|
||||
listsEndpoint: '',
|
||||
bulkUpdatePath: '',
|
||||
boardId: '',
|
||||
});
|
||||
|
||||
allBoardsResponse = Promise.resolve({
|
||||
data: {
|
||||
group: {
|
||||
boards: {
|
||||
edges: boards.map((board) => ({ node: board })),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
recentBoardsResponse = Promise.resolve({
|
||||
data: recentBoards,
|
||||
});
|
||||
|
||||
boardsStore.allBoards = jest.fn(() => allBoardsResponse);
|
||||
boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
|
||||
|
||||
wrapper = mount(BoardsSelector, {
|
||||
propsData: {
|
||||
throttleDuration,
|
||||
currentBoard: {
|
||||
id: 1,
|
||||
name: 'Development',
|
||||
milestone_id: null,
|
||||
weight: null,
|
||||
assignee_id: null,
|
||||
labels: [],
|
||||
},
|
||||
boardBaseUrl: `${TEST_HOST}/board/base/url`,
|
||||
hasMissingBoards: false,
|
||||
canAdminBoard: true,
|
||||
multipleIssueBoardsAvailable: true,
|
||||
labelsPath: `${TEST_HOST}/labels/path`,
|
||||
labelsWebUrl: `${TEST_HOST}/labels`,
|
||||
projectId: 42,
|
||||
groupId: 19,
|
||||
scopedIssueBoardFeatureEnabled: true,
|
||||
weights: [],
|
||||
},
|
||||
mocks: { $apollo },
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
|
||||
wrapper.setData({
|
||||
[options.loadingKey]: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
|
||||
findDropdown().vm.$emit('show');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
describe('loading', () => {
|
||||
// we are testing loading state, so don't resolve responses until after the tests
|
||||
afterEach(() => {
|
||||
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
|
||||
});
|
||||
|
||||
it('shows loading spinner', () => {
|
||||
expect(getDropdownHeaders()).toHaveLength(0);
|
||||
expect(getDropdownItems()).toHaveLength(0);
|
||||
expect(getLoadingIcon().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loaded', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.setData({
|
||||
loadingBoards: false,
|
||||
});
|
||||
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
|
||||
});
|
||||
|
||||
it('hides loading spinner', () => {
|
||||
expect(getLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setData({
|
||||
boards,
|
||||
});
|
||||
|
||||
return nextTick();
|
||||
});
|
||||
|
||||
it('shows all boards without filtering', () => {
|
||||
expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
|
||||
});
|
||||
|
||||
it('shows only matching boards when filtering', () => {
|
||||
const filterTerm = 'board1';
|
||||
const expectedCount = boards.filter((board) => board.name.includes(filterTerm)).length;
|
||||
|
||||
fillSearchBox(filterTerm);
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownItems()).toHaveLength(expectedCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows message if there are no matching boards', () => {
|
||||
fillSearchBox('does not exist');
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownItems()).toHaveLength(0);
|
||||
expect(wrapper.text().includes('No matching boards found')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('recent boards section', () => {
|
||||
it('shows only when boards are greater than 10', () => {
|
||||
wrapper.setData({
|
||||
boards,
|
||||
});
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownHeaders()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show when boards are less than 10', () => {
|
||||
wrapper.setData({
|
||||
boards: boards.slice(0, 5),
|
||||
});
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownHeaders()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show when recentBoards api returns empty array', () => {
|
||||
wrapper.setData({
|
||||
recentBoards: [],
|
||||
});
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownHeaders()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show when search is active', () => {
|
||||
fillSearchBox('Random string');
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownHeaders()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,64 +0,0 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import IssueTimeEstimate from '~/boards/components/issue_time_estimate_deprecated.vue';
|
||||
import boardsStore from '~/boards/stores/boards_store';
|
||||
|
||||
describe('Issue Time Estimate component', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
boardsStore.create();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('when limitToHours is false', () => {
|
||||
beforeEach(() => {
|
||||
boardsStore.timeTracking.limitToHours = false;
|
||||
wrapper = shallowMount(IssueTimeEstimate, {
|
||||
propsData: {
|
||||
estimate: 374460,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the correct time estimate', () => {
|
||||
expect(wrapper.find('time').text().trim()).toEqual('2w 3d 1m');
|
||||
});
|
||||
|
||||
it('renders expanded time estimate in tooltip', () => {
|
||||
expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute');
|
||||
});
|
||||
|
||||
it('prevents tooltip xss', (done) => {
|
||||
const alertSpy = jest.spyOn(window, 'alert');
|
||||
wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' });
|
||||
wrapper.vm.$nextTick(() => {
|
||||
expect(alertSpy).not.toHaveBeenCalled();
|
||||
expect(wrapper.find('time').text().trim()).toEqual('0m');
|
||||
expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when limitToHours is true', () => {
|
||||
beforeEach(() => {
|
||||
boardsStore.timeTracking.limitToHours = true;
|
||||
wrapper = shallowMount(IssueTimeEstimate, {
|
||||
propsData: {
|
||||
estimate: 374460,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the correct time estimate', () => {
|
||||
expect(wrapper.find('time').text().trim()).toEqual('104h 1m');
|
||||
});
|
||||
|
||||
it('renders expanded time estimate in tooltip', () => {
|
||||
expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,332 +0,0 @@
|
|||
/* global ListAssignee, ListLabel, ListIssue */
|
||||
import { GlLabel } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { range } from 'lodash';
|
||||
import '~/boards/models/label';
|
||||
import '~/boards/models/assignee';
|
||||
import '~/boards/models/issue';
|
||||
import '~/boards/models/list';
|
||||
import IssueCardInner from '~/boards/components/issue_card_inner_deprecated.vue';
|
||||
import store from '~/boards/stores';
|
||||
import { listObj } from './mock_data';
|
||||
|
||||
describe('Issue card component', () => {
|
||||
const user = new ListAssignee({
|
||||
id: 1,
|
||||
name: 'testing 123',
|
||||
username: 'test',
|
||||
avatar: 'test_image',
|
||||
});
|
||||
|
||||
const label1 = new ListLabel({
|
||||
id: 3,
|
||||
title: 'testing 123',
|
||||
color: '#000CFF',
|
||||
text_color: 'white',
|
||||
description: 'test',
|
||||
});
|
||||
|
||||
let wrapper;
|
||||
let issue;
|
||||
let list;
|
||||
|
||||
beforeEach(() => {
|
||||
list = { ...listObj, type: 'label' };
|
||||
issue = new ListIssue({
|
||||
title: 'Testing',
|
||||
id: 1,
|
||||
iid: 1,
|
||||
confidential: false,
|
||||
labels: [list.label],
|
||||
assignees: [],
|
||||
reference_path: '#1',
|
||||
real_path: '/test/1',
|
||||
weight: 1,
|
||||
});
|
||||
wrapper = mount(IssueCardInner, {
|
||||
propsData: {
|
||||
list,
|
||||
issue,
|
||||
},
|
||||
store,
|
||||
stubs: {
|
||||
GlLabel: true,
|
||||
},
|
||||
provide: {
|
||||
groupId: null,
|
||||
rootPath: '/',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders issue title', () => {
|
||||
expect(wrapper.find('.board-card-title').text()).toContain(issue.title);
|
||||
});
|
||||
|
||||
it('includes issue base in link', () => {
|
||||
expect(wrapper.find('.board-card-title a').attributes('href')).toContain('/test');
|
||||
});
|
||||
|
||||
it('includes issue title on link', () => {
|
||||
expect(wrapper.find('.board-card-title a').attributes('title')).toBe(issue.title);
|
||||
});
|
||||
|
||||
it('does not render confidential icon', () => {
|
||||
expect(wrapper.find('.confidential-icon').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render blocked icon', () => {
|
||||
expect(wrapper.find('.issue-blocked-icon').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders confidential icon', (done) => {
|
||||
wrapper.setProps({
|
||||
issue: {
|
||||
...wrapper.props('issue'),
|
||||
confidential: true,
|
||||
},
|
||||
});
|
||||
wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find('.confidential-icon').exists()).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders issue ID with #', () => {
|
||||
expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`);
|
||||
});
|
||||
|
||||
describe('assignee', () => {
|
||||
it('does not render assignee', () => {
|
||||
expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('exists', () => {
|
||||
beforeEach((done) => {
|
||||
wrapper.setProps({
|
||||
issue: {
|
||||
...wrapper.props('issue'),
|
||||
assignees: [user],
|
||||
updateData(newData) {
|
||||
Object.assign(this, newData);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
wrapper.vm.$nextTick(done);
|
||||
});
|
||||
|
||||
it('renders assignee', () => {
|
||||
expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('sets title', () => {
|
||||
expect(wrapper.find('.js-assignee-tooltip').text()).toContain(`${user.name}`);
|
||||
});
|
||||
|
||||
it('sets users path', () => {
|
||||
expect(wrapper.find('.board-card-assignee a').attributes('href')).toBe('/test');
|
||||
});
|
||||
|
||||
it('renders avatar', () => {
|
||||
expect(wrapper.find('.board-card-assignee img').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('renders the avatar using avatar_url property', (done) => {
|
||||
wrapper.props('issue').updateData({
|
||||
...wrapper.props('issue'),
|
||||
assignees: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'test',
|
||||
state: 'active',
|
||||
username: 'test_name',
|
||||
avatar_url: 'test_image_from_avatar_url',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe(
|
||||
'test_image_from_avatar_url?width=24',
|
||||
);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignee default avatar', () => {
|
||||
beforeEach((done) => {
|
||||
global.gon.default_avatar_url = 'default_avatar';
|
||||
|
||||
wrapper.setProps({
|
||||
issue: {
|
||||
...wrapper.props('issue'),
|
||||
assignees: [
|
||||
new ListAssignee({
|
||||
id: 1,
|
||||
name: 'testing 123',
|
||||
username: 'test',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
wrapper.vm.$nextTick(done);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.gon.default_avatar_url = null;
|
||||
});
|
||||
|
||||
it('displays defaults avatar if users avatar is null', () => {
|
||||
expect(wrapper.find('.board-card-assignee img').exists()).toBe(true);
|
||||
expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe(
|
||||
'default_avatar?width=24',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple assignees', () => {
|
||||
beforeEach((done) => {
|
||||
wrapper.setProps({
|
||||
issue: {
|
||||
...wrapper.props('issue'),
|
||||
assignees: [
|
||||
new ListAssignee({
|
||||
id: 2,
|
||||
name: 'user2',
|
||||
username: 'user2',
|
||||
avatar: 'test_image',
|
||||
}),
|
||||
new ListAssignee({
|
||||
id: 3,
|
||||
name: 'user3',
|
||||
username: 'user3',
|
||||
avatar: 'test_image',
|
||||
}),
|
||||
new ListAssignee({
|
||||
id: 4,
|
||||
name: 'user4',
|
||||
username: 'user4',
|
||||
avatar: 'test_image',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
wrapper.vm.$nextTick(done);
|
||||
});
|
||||
|
||||
it('renders all three assignees', () => {
|
||||
expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(3);
|
||||
});
|
||||
|
||||
describe('more than three assignees', () => {
|
||||
beforeEach((done) => {
|
||||
const { assignees } = wrapper.props('issue');
|
||||
assignees.push(
|
||||
new ListAssignee({
|
||||
id: 5,
|
||||
name: 'user5',
|
||||
username: 'user5',
|
||||
avatar: 'test_image',
|
||||
}),
|
||||
);
|
||||
|
||||
wrapper.setProps({
|
||||
issue: {
|
||||
...wrapper.props('issue'),
|
||||
assignees,
|
||||
},
|
||||
});
|
||||
wrapper.vm.$nextTick(done);
|
||||
});
|
||||
|
||||
it('renders more avatar counter', () => {
|
||||
expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('+2');
|
||||
});
|
||||
|
||||
it('renders two assignees', () => {
|
||||
expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(2);
|
||||
});
|
||||
|
||||
it('renders 99+ avatar counter', (done) => {
|
||||
const assignees = [
|
||||
...wrapper.props('issue').assignees,
|
||||
...range(5, 103).map(
|
||||
(i) =>
|
||||
new ListAssignee({
|
||||
id: i,
|
||||
name: 'name',
|
||||
username: 'username',
|
||||
avatar: 'test_image',
|
||||
}),
|
||||
),
|
||||
];
|
||||
wrapper.setProps({
|
||||
issue: {
|
||||
...wrapper.props('issue'),
|
||||
assignees,
|
||||
},
|
||||
});
|
||||
|
||||
wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find('.board-card-assignee .avatar-counter').text().trim()).toEqual('99+');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('labels', () => {
|
||||
beforeEach((done) => {
|
||||
issue.addLabel(label1);
|
||||
wrapper.setProps({ issue: { ...issue } });
|
||||
|
||||
wrapper.vm.$nextTick(done);
|
||||
});
|
||||
|
||||
it('does not render list label but renders all other labels', () => {
|
||||
expect(wrapper.findAll(GlLabel).length).toBe(1);
|
||||
const label = wrapper.find(GlLabel);
|
||||
expect(label.props('title')).toEqual(label1.title);
|
||||
expect(label.props('description')).toEqual(label1.description);
|
||||
expect(label.props('backgroundColor')).toEqual(label1.color);
|
||||
});
|
||||
|
||||
it('does not render label if label does not have an ID', (done) => {
|
||||
issue.addLabel(
|
||||
new ListLabel({
|
||||
title: 'closed',
|
||||
}),
|
||||
);
|
||||
wrapper.setProps({ issue: { ...issue } });
|
||||
wrapper.vm
|
||||
.$nextTick()
|
||||
.then(() => {
|
||||
expect(wrapper.findAll(GlLabel).length).toBe(1);
|
||||
expect(wrapper.text()).not.toContain('closed');
|
||||
done();
|
||||
})
|
||||
.catch(done.fail);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocked', () => {
|
||||
beforeEach((done) => {
|
||||
wrapper.setProps({
|
||||
issue: {
|
||||
...wrapper.props('issue'),
|
||||
blocked: true,
|
||||
},
|
||||
});
|
||||
wrapper.vm.$nextTick(done);
|
||||
});
|
||||
|
||||
it('renders blocked icon if issue is blocked', () => {
|
||||
expect(wrapper.find('.issue-blocked-icon').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,263 +0,0 @@
|
|||
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import AxiosMockAdapter from 'axios-mock-adapter';
|
||||
import ProjectSelect from '~/boards/components/project_select_deprecated.vue';
|
||||
import { ListType } from '~/boards/constants';
|
||||
import eventHub from '~/boards/eventhub';
|
||||
import createFlash from '~/flash';
|
||||
import httpStatus from '~/lib/utils/http_status';
|
||||
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
|
||||
|
||||
import { listObj, mockRawGroupProjects } from './mock_data';
|
||||
|
||||
jest.mock('~/boards/eventhub');
|
||||
jest.mock('~/flash');
|
||||
|
||||
const dummyGon = {
|
||||
api_version: 'v4',
|
||||
relative_url_root: '/gitlab',
|
||||
};
|
||||
|
||||
const mockGroupId = 1;
|
||||
const mockProjectsList1 = mockRawGroupProjects.slice(0, 1);
|
||||
const mockProjectsList2 = mockRawGroupProjects.slice(1);
|
||||
const mockDefaultFetchOptions = {
|
||||
with_issues_enabled: true,
|
||||
with_shared: false,
|
||||
include_subgroups: true,
|
||||
order_by: 'similarity',
|
||||
archived: false,
|
||||
};
|
||||
|
||||
const itemsPerPage = 20;
|
||||
|
||||
describe('ProjectSelect component', () => {
|
||||
let wrapper;
|
||||
let axiosMock;
|
||||
|
||||
const findLabel = () => wrapper.find("[data-testid='header-label']");
|
||||
const findGlDropdown = () => wrapper.find(GlDropdown);
|
||||
const findGlDropdownLoadingIcon = () =>
|
||||
findGlDropdown().find('button:first-child').find(GlLoadingIcon);
|
||||
const findGlSearchBoxByType = () => wrapper.find(GlSearchBoxByType);
|
||||
const findGlDropdownItems = () => wrapper.findAll(GlDropdownItem);
|
||||
const findFirstGlDropdownItem = () => findGlDropdownItems().at(0);
|
||||
const findInMenuLoadingIcon = () => wrapper.find("[data-testid='dropdown-text-loading-icon']");
|
||||
const findEmptySearchMessage = () => wrapper.find("[data-testid='empty-result-message']");
|
||||
|
||||
const mockGetRequest = (data = [], statusCode = httpStatus.OK) => {
|
||||
axiosMock
|
||||
.onGet(`/gitlab/api/v4/groups/${mockGroupId}/projects.json`)
|
||||
.replyOnce(statusCode, data);
|
||||
};
|
||||
|
||||
const searchForProject = async (keyword, waitForAll = true) => {
|
||||
findGlSearchBoxByType().vm.$emit('input', keyword);
|
||||
|
||||
if (waitForAll) {
|
||||
await axios.waitForAll();
|
||||
}
|
||||
};
|
||||
|
||||
const createWrapper = async ({ list = listObj } = {}, waitForAll = true) => {
|
||||
wrapper = mount(ProjectSelect, {
|
||||
propsData: {
|
||||
list,
|
||||
},
|
||||
provide: {
|
||||
groupId: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (waitForAll) {
|
||||
await axios.waitForAll();
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new AxiosMockAdapter(axios);
|
||||
window.gon = dummyGon;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
axiosMock.restore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('displays a header title', async () => {
|
||||
createWrapper({});
|
||||
|
||||
expect(findLabel().text()).toBe('Projects');
|
||||
});
|
||||
|
||||
it('renders a default dropdown text', async () => {
|
||||
createWrapper({});
|
||||
|
||||
expect(findGlDropdown().exists()).toBe(true);
|
||||
expect(findGlDropdown().text()).toContain('Select a project');
|
||||
});
|
||||
|
||||
describe('when mounted', () => {
|
||||
it('displays a loading icon while projects are being fetched', async () => {
|
||||
mockGetRequest([]);
|
||||
|
||||
createWrapper({}, false);
|
||||
|
||||
expect(findGlDropdownLoadingIcon().exists()).toBe(true);
|
||||
|
||||
await axios.waitForAll();
|
||||
|
||||
expect(axiosMock.history.get[0].params).toMatchObject({ search: '' });
|
||||
expect(axiosMock.history.get[0].url).toBe(
|
||||
`/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
|
||||
);
|
||||
|
||||
expect(findGlDropdownLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when dropdown menu is open', () => {
|
||||
describe('by default', () => {
|
||||
beforeEach(async () => {
|
||||
mockGetRequest(mockProjectsList1);
|
||||
|
||||
await createWrapper();
|
||||
});
|
||||
|
||||
it('shows GlSearchBoxByType with default attributes', () => {
|
||||
expect(findGlSearchBoxByType().exists()).toBe(true);
|
||||
expect(findGlSearchBoxByType().vm.$attrs).toMatchObject({
|
||||
placeholder: 'Search projects',
|
||||
debounce: '250',
|
||||
});
|
||||
});
|
||||
|
||||
it("displays the fetched project's name", () => {
|
||||
expect(findFirstGlDropdownItem().exists()).toBe(true);
|
||||
expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList1[0].name);
|
||||
});
|
||||
|
||||
it("doesn't render loading icon in the menu", () => {
|
||||
expect(findInMenuLoadingIcon().isVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders empty search result message', async () => {
|
||||
await createWrapper();
|
||||
|
||||
expect(findEmptySearchMessage().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a project is selected', () => {
|
||||
beforeEach(async () => {
|
||||
mockGetRequest(mockProjectsList1);
|
||||
|
||||
await createWrapper();
|
||||
|
||||
await findFirstGlDropdownItem().find('button').trigger('click');
|
||||
});
|
||||
|
||||
it('emits setSelectedProject with correct project metadata', () => {
|
||||
expect(eventHub.$emit).toHaveBeenCalledWith('setSelectedProject', {
|
||||
id: mockProjectsList1[0].id,
|
||||
path: mockProjectsList1[0].path_with_namespace,
|
||||
name: mockProjectsList1[0].name,
|
||||
namespacedName: mockProjectsList1[0].name_with_namespace,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the name of the selected project', () => {
|
||||
expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe(
|
||||
mockProjectsList1[0].name,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user searches for a project', () => {
|
||||
beforeEach(async () => {
|
||||
mockGetRequest(mockProjectsList1);
|
||||
|
||||
await createWrapper();
|
||||
});
|
||||
|
||||
it('calls API with correct parameters with default fetch options', async () => {
|
||||
await searchForProject('foobar');
|
||||
|
||||
const expectedApiParams = {
|
||||
search: 'foobar',
|
||||
per_page: itemsPerPage,
|
||||
...mockDefaultFetchOptions,
|
||||
};
|
||||
|
||||
expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams);
|
||||
expect(axiosMock.history.get[1].url).toBe(
|
||||
`/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
|
||||
);
|
||||
});
|
||||
|
||||
describe("when list type is defined and isn't backlog", () => {
|
||||
it('calls API with an additional fetch option (min_access_level)', async () => {
|
||||
axiosMock.reset();
|
||||
|
||||
await createWrapper({ list: { ...listObj, type: ListType.label } });
|
||||
|
||||
await searchForProject('foobar');
|
||||
|
||||
const expectedApiParams = {
|
||||
search: 'foobar',
|
||||
per_page: itemsPerPage,
|
||||
...mockDefaultFetchOptions,
|
||||
min_access_level: featureAccessLevel.EVERYONE,
|
||||
};
|
||||
|
||||
expect(axiosMock.history.get[1].params).toMatchObject(expectedApiParams);
|
||||
expect(axiosMock.history.get[1].url).toBe(
|
||||
`/gitlab/api/v4/groups/${mockGroupId}/projects.json`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays and hides gl-loading-icon while and after fetching data', async () => {
|
||||
await searchForProject('some keyword', false);
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(findInMenuLoadingIcon().isVisible()).toBe(true);
|
||||
|
||||
await axios.waitForAll();
|
||||
|
||||
expect(findInMenuLoadingIcon().isVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('flashes an error message when fetching fails', async () => {
|
||||
mockGetRequest([], httpStatus.INTERNAL_SERVER_ERROR);
|
||||
|
||||
await searchForProject('foobar');
|
||||
|
||||
expect(createFlash).toHaveBeenCalledTimes(1);
|
||||
expect(createFlash).toHaveBeenCalledWith({
|
||||
message: 'Something went wrong while fetching projects',
|
||||
});
|
||||
});
|
||||
|
||||
describe('with non-empty search result', () => {
|
||||
beforeEach(async () => {
|
||||
mockGetRequest(mockProjectsList2);
|
||||
|
||||
await searchForProject('foobar');
|
||||
});
|
||||
|
||||
it('displays the retrieved list of projects', async () => {
|
||||
expect(findFirstGlDropdownItem().text()).toContain(mockProjectsList2[0].name);
|
||||
});
|
||||
|
||||
it('does not render empty search result message', async () => {
|
||||
expect(findEmptySearchMessage().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,166 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'validate database config' do
|
||||
include RakeHelpers
|
||||
include StubENV
|
||||
|
||||
let(:rails_configuration) { Rails::Application::Configuration.new(Rails.root) }
|
||||
let(:ar_configurations) { ActiveRecord::DatabaseConfigurations.new(rails_configuration.database_configuration) }
|
||||
|
||||
subject do
|
||||
load Rails.root.join('config/initializers/validate_database_config.rb')
|
||||
end
|
||||
|
||||
before do
|
||||
# The `AS::ConfigurationFile` calls `read` in `def initialize`
|
||||
# thus we cannot use `expect_next_instance_of`
|
||||
# rubocop:disable RSpec/AnyInstanceOf
|
||||
expect_any_instance_of(ActiveSupport::ConfigurationFile)
|
||||
.to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml)
|
||||
# rubocop:enable RSpec/AnyInstanceOf
|
||||
|
||||
allow(Rails.application).to receive(:config).and_return(rails_configuration)
|
||||
allow(ActiveRecord::Base).to receive(:configurations).and_return(ar_configurations)
|
||||
end
|
||||
|
||||
shared_examples 'with SKIP_DATABASE_CONFIG_VALIDATION=true' do
|
||||
before do
|
||||
stub_env('SKIP_DATABASE_CONFIG_VALIDATION', 'true')
|
||||
end
|
||||
|
||||
it 'does not raise exception' do
|
||||
expect { subject }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when config/database.yml is valid' do
|
||||
context 'uses legacy syntax' do
|
||||
let(:database_yml) do
|
||||
<<-EOS
|
||||
production:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
EOS
|
||||
end
|
||||
|
||||
it 'validates configuration with a warning' do
|
||||
expect(main_object).to receive(:warn).with /uses a deprecated syntax for/
|
||||
|
||||
expect { subject }.not_to raise_error
|
||||
end
|
||||
|
||||
it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
|
||||
end
|
||||
|
||||
context 'uses new syntax' do
|
||||
let(:database_yml) do
|
||||
<<-EOS
|
||||
production:
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
EOS
|
||||
end
|
||||
|
||||
it 'validates configuration without errors and warnings' do
|
||||
expect(main_object).not_to receive(:warn)
|
||||
|
||||
expect { subject }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when config/database.yml is invalid' do
|
||||
context 'uses unknown connection name' do
|
||||
let(:database_yml) do
|
||||
<<-EOS
|
||||
production:
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
|
||||
another:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
EOS
|
||||
end
|
||||
|
||||
it 'raises exception' do
|
||||
expect { subject }.to raise_error /This installation of GitLab uses unsupported database names/
|
||||
end
|
||||
|
||||
it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
|
||||
end
|
||||
|
||||
context 'uses replica configuration' do
|
||||
let(:database_yml) do
|
||||
<<-EOS
|
||||
production:
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
replica: true
|
||||
EOS
|
||||
end
|
||||
|
||||
it 'raises exception' do
|
||||
expect { subject }.to raise_error /with 'replica: true' parameter in/
|
||||
end
|
||||
|
||||
it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
|
||||
end
|
||||
|
||||
context 'main is not a first entry' do
|
||||
let(:database_yml) do
|
||||
<<-EOS
|
||||
production:
|
||||
ci:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production_ci
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
replica: true
|
||||
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
replica: true
|
||||
EOS
|
||||
end
|
||||
|
||||
it 'raises exception' do
|
||||
expect { subject }.to raise_error /The `main:` database needs to be defined as a first configuration item/
|
||||
end
|
||||
|
||||
it_behaves_like 'with SKIP_DATABASE_CONFIG_VALIDATION=true'
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,153 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Backup::GitalyRpcBackup do
|
||||
let(:progress) { spy(:stdout) }
|
||||
|
||||
subject { described_class.new(progress) }
|
||||
|
||||
after do
|
||||
# make sure we do not leave behind any backup files
|
||||
FileUtils.rm_rf(File.join(Gitlab.config.backup.path, 'repositories'))
|
||||
end
|
||||
|
||||
context 'unknown' do
|
||||
it 'fails to start unknown' do
|
||||
expect { subject.start(:unknown) }.to raise_error(::Backup::Error, 'unknown backup type: unknown')
|
||||
end
|
||||
end
|
||||
|
||||
context 'create' do
|
||||
RSpec.shared_examples 'creates a repository backup' do
|
||||
it 'creates repository bundles', :aggregate_failures do
|
||||
# Add data to the wiki, design repositories, and snippets, so they will be included in the dump.
|
||||
create(:wiki_page, container: project)
|
||||
create(:design, :with_file, issue: create(:issue, project: project))
|
||||
project_snippet = create(:project_snippet, :repository, project: project)
|
||||
personal_snippet = create(:personal_snippet, :repository, author: project.owner)
|
||||
|
||||
subject.start(:create)
|
||||
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
|
||||
subject.enqueue(project, Gitlab::GlRepository::WIKI)
|
||||
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
|
||||
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
|
||||
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
|
||||
subject.wait
|
||||
|
||||
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.bundle'))
|
||||
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.wiki.bundle'))
|
||||
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project.disk_path + '.design.bundle'))
|
||||
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', personal_snippet.disk_path + '.bundle'))
|
||||
expect(File).to exist(File.join(Gitlab.config.backup.path, 'repositories', project_snippet.disk_path + '.bundle'))
|
||||
end
|
||||
|
||||
context 'failure' do
|
||||
before do
|
||||
allow_next_instance_of(Repository) do |repository|
|
||||
allow(repository).to receive(:bundle_to_disk) { raise 'Fail in tests' }
|
||||
end
|
||||
end
|
||||
|
||||
it 'logs an appropriate message', :aggregate_failures do
|
||||
subject.start(:create)
|
||||
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
|
||||
subject.wait
|
||||
|
||||
expect(progress).to have_received(:puts).with("[Failed] backing up #{project.full_path} (#{project.disk_path})")
|
||||
expect(progress).to have_received(:puts).with("Error Fail in tests")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'hashed storage' do
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
|
||||
it_behaves_like 'creates a repository backup'
|
||||
end
|
||||
|
||||
context 'legacy storage' do
|
||||
let_it_be(:project) { create(:project, :repository, :legacy_storage) }
|
||||
|
||||
it_behaves_like 'creates a repository backup'
|
||||
end
|
||||
end
|
||||
|
||||
context 'restore' do
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
let_it_be(:personal_snippet) { create(:personal_snippet, author: project.owner) }
|
||||
let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.owner) }
|
||||
|
||||
def copy_bundle_to_backup_path(bundle_name, destination)
|
||||
FileUtils.mkdir_p(File.join(Gitlab.config.backup.path, 'repositories', File.dirname(destination)))
|
||||
FileUtils.cp(Rails.root.join('spec/fixtures/lib/backup', bundle_name), File.join(Gitlab.config.backup.path, 'repositories', destination))
|
||||
end
|
||||
|
||||
it 'restores from repository bundles', :aggregate_failures do
|
||||
copy_bundle_to_backup_path('project_repo.bundle', project.disk_path + '.bundle')
|
||||
copy_bundle_to_backup_path('wiki_repo.bundle', project.disk_path + '.wiki.bundle')
|
||||
copy_bundle_to_backup_path('design_repo.bundle', project.disk_path + '.design.bundle')
|
||||
copy_bundle_to_backup_path('personal_snippet_repo.bundle', personal_snippet.disk_path + '.bundle')
|
||||
copy_bundle_to_backup_path('project_snippet_repo.bundle', project_snippet.disk_path + '.bundle')
|
||||
|
||||
subject.start(:restore)
|
||||
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
|
||||
subject.enqueue(project, Gitlab::GlRepository::WIKI)
|
||||
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
|
||||
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
|
||||
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
|
||||
subject.wait
|
||||
|
||||
collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) }
|
||||
|
||||
expect(collect_commit_shas.call(project.repository)).to eq(['393a7d860a5a4c3cc736d7eb00604e3472bb95ec'])
|
||||
expect(collect_commit_shas.call(project.wiki.repository)).to eq(['c74b9948d0088d703ee1fafeddd9ed9add2901ea'])
|
||||
expect(collect_commit_shas.call(project.design_repository)).to eq(['c3cd4d7bd73a51a0f22045c3a4c871c435dc959d'])
|
||||
expect(collect_commit_shas.call(personal_snippet.repository)).to eq(['3b3c067a3bc1d1b695b51e2be30c0f8cf698a06e'])
|
||||
expect(collect_commit_shas.call(project_snippet.repository)).to eq(['6e44ba56a4748be361a841e759c20e421a1651a1'])
|
||||
end
|
||||
|
||||
it 'cleans existing repositories', :aggregate_failures do
|
||||
expect_next_instance_of(DesignManagement::Repository) do |repository|
|
||||
expect(repository).to receive(:remove)
|
||||
end
|
||||
|
||||
# 4 times = project repo + wiki repo + project_snippet repo + personal_snippet repo
|
||||
expect(Repository).to receive(:new).exactly(4).times.and_wrap_original do |method, *original_args|
|
||||
full_path, container, kwargs = original_args
|
||||
|
||||
repository = method.call(full_path, container, **kwargs)
|
||||
|
||||
expect(repository).to receive(:remove)
|
||||
|
||||
repository
|
||||
end
|
||||
|
||||
subject.start(:restore)
|
||||
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
|
||||
subject.enqueue(project, Gitlab::GlRepository::WIKI)
|
||||
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
|
||||
subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
|
||||
subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
|
||||
subject.wait
|
||||
end
|
||||
|
||||
context 'failure' do
|
||||
before do
|
||||
allow_next_instance_of(Repository) do |repository|
|
||||
allow(repository).to receive(:create_repository) { raise 'Fail in tests' }
|
||||
allow(repository).to receive(:create_from_bundle) { raise 'Fail in tests' }
|
||||
end
|
||||
end
|
||||
|
||||
it 'logs an appropriate message', :aggregate_failures do
|
||||
subject.start(:restore)
|
||||
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
|
||||
subject.wait
|
||||
|
||||
expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} (#{project.disk_path})")
|
||||
expect(progress).to have_received(:puts).with("Error Fail in tests")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -4,8 +4,7 @@ require 'spec_helper'
|
|||
|
||||
RSpec.describe Backup::Repositories do
|
||||
let(:progress) { spy(:stdout) }
|
||||
let(:parallel_enqueue) { true }
|
||||
let(:strategy) { spy(:strategy, parallel_enqueue?: parallel_enqueue) }
|
||||
let(:strategy) { spy(:strategy) }
|
||||
|
||||
subject { described_class.new(progress, strategy: strategy) }
|
||||
|
||||
|
@ -17,7 +16,7 @@ RSpec.describe Backup::Repositories do
|
|||
project_snippet = create(:project_snippet, :repository, project: project)
|
||||
personal_snippet = create(:personal_snippet, :repository, author: project.owner)
|
||||
|
||||
subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
|
||||
subject.dump
|
||||
|
||||
expect(strategy).to have_received(:start).with(:create)
|
||||
expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
|
||||
|
@ -41,132 +40,30 @@ RSpec.describe Backup::Repositories do
|
|||
it_behaves_like 'creates repository bundles'
|
||||
end
|
||||
|
||||
context 'no concurrency' do
|
||||
it 'creates the expected number of threads' do
|
||||
expect(Thread).not_to receive(:new)
|
||||
context 'command failure' do
|
||||
it 'enqueue_project raises an error' do
|
||||
allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError)
|
||||
|
||||
expect(strategy).to receive(:start).with(:create)
|
||||
projects.each do |project|
|
||||
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
|
||||
end
|
||||
expect(strategy).to receive(:wait)
|
||||
|
||||
subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
|
||||
expect { subject.dump }.to raise_error(IOError)
|
||||
end
|
||||
|
||||
describe 'command failure' do
|
||||
it 'enqueue_project raises an error' do
|
||||
allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError)
|
||||
it 'project query raises an error' do
|
||||
allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
|
||||
|
||||
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(IOError)
|
||||
end
|
||||
|
||||
it 'project query raises an error' do
|
||||
allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
|
||||
|
||||
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: 1) }.to raise_error(ActiveRecord::StatementTimeout)
|
||||
end
|
||||
end
|
||||
|
||||
it 'avoids N+1 database queries' do
|
||||
control_count = ActiveRecord::QueryRecorder.new do
|
||||
subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
|
||||
end.count
|
||||
|
||||
create_list(:project, 2, :repository)
|
||||
|
||||
expect do
|
||||
subject.dump(max_concurrency: 1, max_storage_concurrency: 1)
|
||||
end.not_to exceed_query_limit(control_count)
|
||||
expect { subject.dump }.to raise_error(ActiveRecord::StatementTimeout)
|
||||
end
|
||||
end
|
||||
|
||||
context 'concurrency with a strategy without parallel enqueueing support' do
|
||||
let(:parallel_enqueue) { false }
|
||||
it 'avoids N+1 database queries' do
|
||||
control_count = ActiveRecord::QueryRecorder.new do
|
||||
subject.dump
|
||||
end.count
|
||||
|
||||
it 'enqueues all projects sequentially' do
|
||||
expect(Thread).not_to receive(:new)
|
||||
create_list(:project, 2, :repository)
|
||||
|
||||
expect(strategy).to receive(:start).with(:create)
|
||||
projects.each do |project|
|
||||
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
|
||||
end
|
||||
expect(strategy).to receive(:wait)
|
||||
|
||||
subject.dump(max_concurrency: 2, max_storage_concurrency: 2)
|
||||
end
|
||||
end
|
||||
|
||||
[4, 10].each do |max_storage_concurrency|
|
||||
context "max_storage_concurrency #{max_storage_concurrency}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241701' do
|
||||
let(:storage_keys) { %w[default test_second_storage] }
|
||||
|
||||
before do
|
||||
allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(storage_keys)
|
||||
end
|
||||
|
||||
it 'creates the expected number of threads' do
|
||||
expect(Thread).to receive(:new)
|
||||
.exactly(storage_keys.length * (max_storage_concurrency + 1)).times
|
||||
.and_call_original
|
||||
|
||||
expect(strategy).to receive(:start).with(:create)
|
||||
projects.each do |project|
|
||||
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
|
||||
end
|
||||
expect(strategy).to receive(:wait)
|
||||
|
||||
subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
|
||||
end
|
||||
|
||||
it 'creates the expected number of threads with extra max concurrency' do
|
||||
expect(Thread).to receive(:new)
|
||||
.exactly(storage_keys.length * (max_storage_concurrency + 1)).times
|
||||
.and_call_original
|
||||
|
||||
expect(strategy).to receive(:start).with(:create)
|
||||
projects.each do |project|
|
||||
expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
|
||||
end
|
||||
expect(strategy).to receive(:wait)
|
||||
|
||||
subject.dump(max_concurrency: 3, max_storage_concurrency: max_storage_concurrency)
|
||||
end
|
||||
|
||||
describe 'command failure' do
|
||||
it 'enqueue_project raises an error' do
|
||||
allow(strategy).to receive(:enqueue).and_raise(IOError)
|
||||
|
||||
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(IOError)
|
||||
end
|
||||
|
||||
it 'project query raises an error' do
|
||||
allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
|
||||
|
||||
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(ActiveRecord::StatementTimeout)
|
||||
end
|
||||
|
||||
context 'misconfigured storages' do
|
||||
let(:storage_keys) { %w[test_second_storage] }
|
||||
|
||||
it 'raises an error' do
|
||||
expect { subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency) }.to raise_error(Backup::Error, 'repositories.storages in gitlab.yml is misconfigured')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'avoids N+1 database queries' do
|
||||
control_count = ActiveRecord::QueryRecorder.new do
|
||||
subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
|
||||
end.count
|
||||
|
||||
create_list(:project, 2, :repository)
|
||||
|
||||
expect do
|
||||
subject.dump(max_concurrency: 1, max_storage_concurrency: max_storage_concurrency)
|
||||
end.not_to exceed_query_limit(control_count)
|
||||
end
|
||||
end
|
||||
expect do
|
||||
subject.dump
|
||||
end.not_to exceed_query_limit(control_count)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Patch::LegacyDatabaseConfig do
|
||||
it 'module is included' do
|
||||
expect(Rails::Application::Configuration).to include(described_class)
|
||||
end
|
||||
|
||||
describe 'config/database.yml' do
|
||||
let(:configuration) { Rails::Application::Configuration.new(Rails.root) }
|
||||
|
||||
before do
|
||||
# The `AS::ConfigurationFile` calls `read` in `def initialize`
|
||||
# thus we cannot use `expect_next_instance_of`
|
||||
# rubocop:disable RSpec/AnyInstanceOf
|
||||
expect_any_instance_of(ActiveSupport::ConfigurationFile)
|
||||
.to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml)
|
||||
# rubocop:enable RSpec/AnyInstanceOf
|
||||
end
|
||||
|
||||
shared_examples 'hash containing main: connection name' do
|
||||
it 'returns a hash containing only main:' do
|
||||
database_configuration = configuration.database_configuration
|
||||
|
||||
expect(database_configuration).to match(
|
||||
"production" => { "main" => a_hash_including("adapter") },
|
||||
"development" => { "main" => a_hash_including("adapter" => "postgresql") },
|
||||
"test" => { "main" => a_hash_including("adapter" => "postgresql") }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a new syntax is used' do
|
||||
let(:database_yml) do
|
||||
<<-EOS
|
||||
production:
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
|
||||
development:
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_development
|
||||
username: postgres
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
variables:
|
||||
statement_timeout: 15s
|
||||
|
||||
test: &test
|
||||
main:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_test
|
||||
username: postgres
|
||||
password:
|
||||
host: localhost
|
||||
prepared_statements: false
|
||||
variables:
|
||||
statement_timeout: 15s
|
||||
EOS
|
||||
end
|
||||
|
||||
include_examples 'hash containing main: connection name'
|
||||
|
||||
it 'configuration is not legacy one' do
|
||||
configuration.database_configuration
|
||||
|
||||
expect(configuration.uses_legacy_database_config).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a legacy syntax is used' do
|
||||
let(:database_yml) do
|
||||
<<-EOS
|
||||
production:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_production
|
||||
username: git
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
|
||||
development:
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_development
|
||||
username: postgres
|
||||
password: "secure password"
|
||||
host: localhost
|
||||
variables:
|
||||
statement_timeout: 15s
|
||||
|
||||
test: &test
|
||||
adapter: postgresql
|
||||
encoding: unicode
|
||||
database: gitlabhq_test
|
||||
username: postgres
|
||||
password:
|
||||
host: localhost
|
||||
prepared_statements: false
|
||||
variables:
|
||||
statement_timeout: 15s
|
||||
EOS
|
||||
end
|
||||
|
||||
include_examples 'hash containing main: connection name'
|
||||
|
||||
it 'configuration is legacy' do
|
||||
configuration.database_configuration
|
||||
|
||||
expect(configuration.uses_legacy_database_config).to eq(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,53 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
require_migration!
|
||||
|
||||
RSpec.describe RemoveDuplicateDastSiteTokens do
|
||||
let(:namespaces) { table(:namespaces) }
|
||||
let(:projects) { table(:projects) }
|
||||
let(:dast_site_tokens) { table(:dast_site_tokens) }
|
||||
let!(:namespace) { namespaces.create!(id: 1, name: 'group', path: 'group') }
|
||||
let!(:project1) { projects.create!(id: 1, namespace_id: namespace.id, path: 'project1') }
|
||||
# create non duplicate dast site token
|
||||
let!(:dast_site_token1) { dast_site_tokens.create!(project_id: project1.id, url: 'https://gitlab.com', token: SecureRandom.uuid) }
|
||||
|
||||
context 'when duplicate dast site tokens exists' do
|
||||
# create duplicate dast site token
|
||||
let_it_be(:duplicate_url) { 'https://about.gitlab.com' }
|
||||
|
||||
let!(:project2) { projects.create!(id: 2, namespace_id: namespace.id, path: 'project2') }
|
||||
let!(:dast_site_token2) { dast_site_tokens.create!(project_id: project2.id, url: duplicate_url, token: SecureRandom.uuid) }
|
||||
let!(:dast_site_token3) { dast_site_tokens.create!(project_id: project2.id, url: 'https://temp_url.com', token: SecureRandom.uuid) }
|
||||
let!(:dast_site_token4) { dast_site_tokens.create!(project_id: project2.id, url: 'https://other_temp_url.com', token: SecureRandom.uuid) }
|
||||
|
||||
before 'update URL to bypass uniqueness validation' do
|
||||
dast_site_tokens.where(project_id: 2).update_all(url: duplicate_url)
|
||||
end
|
||||
|
||||
describe 'migration up' do
|
||||
it 'does remove duplicated dast site tokens' do
|
||||
expect(dast_site_tokens.count).to eq(4)
|
||||
expect(dast_site_tokens.where(project_id: 2, url: duplicate_url).size).to eq(3)
|
||||
|
||||
migrate!
|
||||
|
||||
expect(dast_site_tokens.count).to eq(2)
|
||||
expect(dast_site_tokens.where(project_id: 2, url: duplicate_url).size).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when duplicate dast site tokens does not exists' do
|
||||
before do
|
||||
dast_site_tokens.create!(project_id: 1, url: 'https://about.gitlab.com/handbook', token: SecureRandom.uuid)
|
||||
end
|
||||
|
||||
describe 'migration up' do
|
||||
it 'does remove duplicated dast site tokens' do
|
||||
expect { migrate! }.not_to change(dast_site_tokens, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -383,30 +383,10 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
|
|||
create(:project, :repository)
|
||||
end
|
||||
|
||||
it 'has defaults' do
|
||||
expect_next_instance_of(::Backup::Repositories) do |instance|
|
||||
expect(instance).to receive(:dump)
|
||||
.with(max_concurrency: 1, max_storage_concurrency: 1)
|
||||
.and_call_original
|
||||
end
|
||||
|
||||
expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process
|
||||
end
|
||||
|
||||
it 'passes through concurrency environment variables' do
|
||||
# The way concurrency is handled will change with the `gitaly_backup`
|
||||
# feature flag. For now we need to check that both ways continue to
|
||||
# work. This will be cleaned up in the rollout issue.
|
||||
# See https://gitlab.com/gitlab-org/gitlab/-/issues/333034
|
||||
|
||||
stub_env('GITLAB_BACKUP_MAX_CONCURRENCY', 5)
|
||||
stub_env('GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY', 2)
|
||||
|
||||
expect_next_instance_of(::Backup::Repositories) do |instance|
|
||||
expect(instance).to receive(:dump)
|
||||
.with(max_concurrency: 5, max_storage_concurrency: 2)
|
||||
.and_call_original
|
||||
end
|
||||
expect(::Backup::GitalyBackup).to receive(:new).with(anything, parallel: 5, parallel_storage: 2).and_call_original
|
||||
|
||||
expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process
|
||||
|
|
|
@ -3898,10 +3898,10 @@ core-js-pure@^3.0.0:
|
|||
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813"
|
||||
integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA==
|
||||
|
||||
core-js@^3.1.3, core-js@^3.16.4:
|
||||
version "3.16.4"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.16.4.tgz#0fb1029a554fc2688c0963d7c900e188188a78e0"
|
||||
integrity sha512-Tq4GVE6XCjE+hcyW6hPy0ofN3hwtLudz5ZRdrlCnsnD/xkm/PWQRudzYHiKgZKUcefV6Q57fhDHjZHJP5dpfSg==
|
||||
core-js@^3.1.3, core-js@^3.17.1:
|
||||
version "3.17.1"
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.17.1.tgz#b39e086f413789cf2ca4680c4ecd1b36a50ba277"
|
||||
integrity sha512-C8i/FNpVN2Ti89QIJcFn9ZQmnM+HaAQr2OpE+ja3TRM9Q34FigsGlAVuwPGkIgydSVClo/1l1D1grP8LVt9IYA==
|
||||
|
||||
core-js@~2.3.0:
|
||||
version "2.3.0"
|
||||
|
|
Loading…
Reference in New Issue