Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-03-11 09:09:36 +00:00
parent 536045b147
commit 3ba6a5a16d
52 changed files with 1066 additions and 221 deletions

View File

@ -1 +1 @@
3822772ed121b764fdcc5011d199c37ef76e06a9
ac2235fe44c106e9f69b6614ecb72b67421fd402

View File

@ -113,31 +113,31 @@ export function formatIssueInput(issueInput, boardConfig) {
};
}
export function moveIssueListHelper(issue, fromList, toList) {
const updatedIssue = issue;
export function moveItemListHelper(item, fromList, toList) {
const updatedItem = item;
if (
toList.listType === ListType.label &&
!updatedIssue.labels.find((label) => label.id === toList.label.id)
!updatedItem.labels.find((label) => label.id === toList.label.id)
) {
updatedIssue.labels.push(toList.label);
updatedItem.labels.push(toList.label);
}
if (fromList?.label && fromList.listType === ListType.label) {
updatedIssue.labels = updatedIssue.labels.filter((label) => fromList.label.id !== label.id);
updatedItem.labels = updatedItem.labels.filter((label) => fromList.label.id !== label.id);
}
if (
toList.listType === ListType.assignee &&
!updatedIssue.assignees.find((assignee) => assignee.id === toList.assignee.id)
!updatedItem.assignees.find((assignee) => assignee.id === toList.assignee.id)
) {
updatedIssue.assignees.push(toList.assignee);
updatedItem.assignees.push(toList.assignee);
}
if (fromList?.assignee && fromList.listType === ListType.assignee) {
updatedIssue.assignees = updatedIssue.assignees.filter(
updatedItem.assignees = updatedItem.assignees.filter(
(assignee) => assignee.id !== fromList.assignee.id,
);
}
return updatedIssue;
return updatedItem;
}
export function isListDraggable(list) {

View File

@ -13,7 +13,7 @@ export default {
default: () => ({}),
required: false,
},
issue: {
item: {
type: Object,
default: () => ({}),
required: false,
@ -33,12 +33,12 @@ export default {
...mapState(['selectedBoardItems', 'activeId']),
...mapGetters(['isSwimlanesOn']),
isActive() {
return this.issue.id === this.activeId;
return this.item.id === this.activeId;
},
multiSelectVisible() {
return (
!this.activeId &&
this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1
this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1
);
},
},
@ -50,9 +50,9 @@ export default {
const isMultiSelect = e.ctrlKey || e.metaKey;
if (isMultiSelect) {
this.toggleBoardItemMultiSelection(this.issue);
this.toggleBoardItemMultiSelection(this.item);
} else {
this.toggleBoardItem({ boardItem: this.issue });
this.toggleBoardItem({ boardItem: this.item });
}
},
},
@ -64,18 +64,18 @@ export default {
data-qa-selector="board_card"
:class="{
'multi-select': multiSelectVisible,
'user-can-drag': !disabled && issue.id,
'is-disabled': disabled || !issue.id,
'user-can-drag': !disabled && item.id,
'is-disabled': disabled || !item.id,
'is-active': isActive,
}"
:index="index"
:data-issue-id="issue.id"
:data-issue-iid="issue.iid"
:data-issue-path="issue.referencePath"
:data-item-id="item.id"
:data-item-iid="item.iid"
:data-item-path="item.referencePath"
data-testid="board_card"
class="board-card gl-p-5 gl-rounded-base"
@mouseup="toggleIssue($event)"
>
<board-card-inner :list="list" :item="issue" :update-filters="true" />
<board-card-inner :list="list" :item="item" :update-filters="true" />
</li>
</template>

View File

@ -49,7 +49,7 @@ export default {
: this.lists;
},
canDragColumns() {
return this.glFeatures.graphqlBoardLists && this.canAdminList;
return !this.isEpicBoard && this.glFeatures.graphqlBoardLists && this.canAdminList;
},
boardColumnWrapper() {
return this.canDragColumns ? Draggable : 'div';
@ -80,6 +80,7 @@ export default {
handleDragOnEnd(params) {
sortableEnd();
if (this.isEpicBoard) return;
const { item, newIndex, oldIndex, to } = params;

View File

@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import Draggable from 'vuedraggable';
import { mapActions, mapGetters, mapState } from 'vuex';
import { mapActions, mapState } from 'vuex';
import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
import { sprintf, __ } from '~/locale';
import defaultSortableConfig from '~/sortable/sortable_config';
@ -49,7 +49,6 @@ export default {
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags']),
...mapGetters(['isEpicBoard']),
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.boardItems.length,
@ -70,13 +69,13 @@ export default {
},
listRef() {
// When list is draggable, the reference to the list needs to be accessed differently
return this.canAdminList && !this.isEpicBoard ? this.$refs.list.$el : this.$refs.list;
return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
},
showingAllIssues() {
return this.boardItems.length === this.list.issuesCount;
},
treeRootWrapper() {
return this.canAdminList && !this.isEpicBoard ? Draggable : 'ul';
return this.canAdminList ? Draggable : 'ul';
},
treeRootOptions() {
const options = {
@ -113,7 +112,7 @@ export default {
this.listRef.removeEventListener('scroll', this.onScroll);
},
methods: {
...mapActions(['fetchItemsForList', 'moveIssue']),
...mapActions(['fetchItemsForList', 'moveItem']),
listHeight() {
return this.listRef.getBoundingClientRect().height;
},
@ -149,40 +148,40 @@ export default {
handleDragOnEnd(params) {
sortableEnd();
const { newIndex, oldIndex, from, to, item } = params;
const { issueId, issueIid, issuePath } = item.dataset;
const { itemId, itemIid, itemPath } = item.dataset;
const { children } = to;
let moveBeforeId;
let moveAfterId;
const getIssueId = (el) => Number(el.dataset.issueId);
const getItemId = (el) => Number(el.dataset.itemId);
// If issue is being moved within the same list
// If item is being moved within the same list
if (from === to) {
if (newIndex > oldIndex && children.length > 1) {
// If issue is being moved down we look for the issue that ends up before
moveBeforeId = getIssueId(children[newIndex]);
// If item is being moved down we look for the item that ends up before
moveBeforeId = getItemId(children[newIndex]);
} else if (newIndex < oldIndex && children.length > 1) {
// If issue is being moved up we look for the issue that ends up after
moveAfterId = getIssueId(children[newIndex]);
// If item is being moved up we look for the item that ends up after
moveAfterId = getItemId(children[newIndex]);
} else {
// If issue remains in the same list at the same position we do nothing
// If item remains in the same list at the same position we do nothing
return;
}
} else {
// We look for the issue that ends up before the moved issue if it exists
// We look for the item that ends up before the moved item if it exists
if (children[newIndex - 1]) {
moveBeforeId = getIssueId(children[newIndex - 1]);
moveBeforeId = getItemId(children[newIndex - 1]);
}
// We look for the issue that ends up after the moved issue if it exists
// We look for the item that ends up after the moved item if it exists
if (children[newIndex]) {
moveAfterId = getIssueId(children[newIndex]);
moveAfterId = getItemId(children[newIndex]);
}
}
this.moveIssue({
issueId,
issueIid,
issuePath,
this.moveItem({
itemId,
itemIid,
itemPath,
fromListId: from.dataset.listId,
toListId: to.dataset.listId,
moveBeforeId,
@ -227,7 +226,7 @@ export default {
:key="item.id"
:index="index"
:list="list"
:issue="item"
:item="item"
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">

View File

@ -325,17 +325,21 @@ export default {
commit(types.RESET_ISSUES);
},
moveItem: ({ dispatch }) => {
dispatch('moveIssue');
},
moveIssue: (
{ state, commit },
{ issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId },
{ itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId },
) => {
const originalIssue = state.boardItems[issueId];
const originalIssue = state.boardItems[itemId];
const fromList = state.boardItemsByListId[fromListId];
const originalIndex = fromList.indexOf(Number(issueId));
const originalIndex = fromList.indexOf(Number(itemId));
commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId });
const { boardId } = state;
const [fullProjectPath] = issuePath.split(/[#]/);
const [fullProjectPath] = itemPath.split(/[#]/);
gqlClient
.mutate({
@ -343,7 +347,7 @@ export default {
variables: {
projectPath: fullProjectPath,
boardId: fullBoardId(boardId),
iid: issueIid,
iid: itemIid,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
@ -352,7 +356,7 @@ export default {
})
.then(({ data }) => {
if (data?.issueMoveList?.errors.length) {
commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex });
throw new Error();
} else {
const issue = data.issueMoveList?.issue;
commit(types.MOVE_ISSUE_SUCCESS, { issue });

View File

@ -2,7 +2,8 @@ import { pull, union } from 'lodash';
import Vue from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import { formatIssue, moveIssueListHelper } from '../boards_util';
import { formatIssue, moveItemListHelper } from '../boards_util';
import { issuableTypes } from '../constants';
import * as mutationTypes from './mutation_types';
const notImplemented = () => {
@ -10,13 +11,21 @@ const notImplemented = () => {
throw new Error('Not implemented!');
};
export const removeIssueFromList = ({ state, listId, issueId }) => {
Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], issueId));
const updateListItemsCount = ({ state, listId, value }) => {
const list = state.boardLists[listId];
Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount - 1 });
if (state.issuableType === issuableTypes.epic) {
Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value });
} else {
Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + value });
}
};
export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
export const removeItemFromList = ({ state, listId, itemId }) => {
Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId));
updateListItemsCount({ state, listId, value: -1 });
};
export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }) => {
const listIssues = state.boardItemsByListId[listId];
let newIndex = atIndex || 0;
if (moveBeforeId) {
@ -24,10 +33,9 @@ export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfter
} else if (moveAfterId) {
newIndex = listIssues.indexOf(moveAfterId);
}
listIssues.splice(newIndex, 0, issueId);
listIssues.splice(newIndex, 0, itemId);
Vue.set(state.boardItemsByListId, listId, listIssues);
const list = state.boardLists[listId];
Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + 1 });
updateListItemsCount({ state, listId, value: 1 });
};
export default {
@ -182,11 +190,11 @@ export default {
const fromList = state.boardLists[fromListId];
const toList = state.boardLists[toListId];
const issue = moveIssueListHelper(originalIssue, fromList, toList);
const issue = moveItemListHelper(originalIssue, fromList, toList);
Vue.set(state.boardItems, issue.id, issue);
removeIssueFromList({ state, listId: fromListId, issueId: issue.id });
addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId });
removeItemFromList({ state, listId: fromListId, itemId: issue.id });
addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId });
},
[mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => {
@ -200,11 +208,11 @@ export default {
) => {
state.error = s__('Boards|An error occurred while moving the issue. Please try again.');
Vue.set(state.boardItems, originalIssue.id, originalIssue);
removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id });
addIssueToList({
removeItemFromList({ state, listId: toListId, itemId: originalIssue.id });
addItemToList({
state,
listId: fromListId,
issueId: originalIssue.id,
itemId: originalIssue.id,
atIndex: originalIndex,
});
},
@ -226,10 +234,10 @@ export default {
},
[mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => {
addIssueToList({
addItemToList({
state,
listId: list.id,
issueId: issue.id,
itemId: issue.id,
atIndex: position,
});
Vue.set(state.boardItems, issue.id, issue);
@ -237,11 +245,11 @@ export default {
[mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => {
state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
removeIssueFromList({ state, listId: list.id, issueId });
removeItemFromList({ state, listId: list.id, itemId: issueId });
},
[mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => {
removeIssueFromList({ state, listId: list.id, issueId: issue.id });
removeItemFromList({ state, listId: list.id, itemId: issue.id });
Vue.delete(state.boardItems, issue.id);
},

View File

@ -84,7 +84,7 @@ export default {
</script>
<template>
<div class="ci-variable-table">
<div class="ci-variable-table" data-testid="ci-variable-table">
<gl-table
:fields="fields"
:items="variables"

View File

@ -0,0 +1,100 @@
<script>
import { GlButton, GlModal, GlSprintf, GlIcon } from '@gitlab/ui';
import { ISSUABLE_TYPE } from '../constants';
export default {
name: 'CsvExportModal',
components: {
GlButton,
GlModal,
GlSprintf,
GlIcon,
},
inject: {
issuableType: {
default: '',
},
issuableCount: {
default: 0,
},
email: {
default: '',
},
exportCsvPath: {
default: '',
},
},
props: {
modalId: {
type: String,
required: true,
},
},
data() {
return {
// eslint-disable-next-line @gitlab/require-i18n-strings
issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
};
},
issueableType: ISSUABLE_TYPE,
};
</script>
<template>
<gl-modal :modal-id="modalId" body-class="gl-p-0!">
<template #modal-title>
<gl-sprintf :message="__('Export %{name}')">
<template #name>{{ issuableName }}</template>
</gl-sprintf>
</template>
<div
v-if="issuableCount > -1"
data-testid="issuable-count-note"
class="gl-justify-content-start gl-align-items-center gl-p-4 gl-border-b-solid gl-border-1 gl-border-gray-50"
>
<gl-icon name="check" class="gl-color-green-400" />
<strong class="gl-m-3">
<gl-sprintf
v-if="issuableType === $options.issueableType.issues"
:message="n__('1 issue selected', '%d issues selected', issuableCount)"
>
<template #issuableCount>{{ issuableCount }}</template>
</gl-sprintf>
<gl-sprintf
v-else
:message="n__('1 merge request selected', '%d merge request selected', issuableCount)"
>
<template #issuableCount>{{ issuableCount }}</template>
</gl-sprintf>
</strong>
</div>
<div class="modal-text gl-px-4 gl-py-5">
<gl-sprintf
:message="
__(
`The CSV export will be created in the background. Once finished, it will be sent to %{strongStart}${email}%{strongEnd} in an attachment.`,
)
"
>
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</div>
<template #modal-footer>
<gl-button
category="primary"
variant="confirm"
:href="exportCsvPath"
data-method="post"
:data-qa-selector="`export_${issuableType}_button`"
data-track-event="click_button"
:data-track-label="`export_${issuableType}_csv`"
>
<gl-sprintf :message="__('Export %{name}')">
<template #name>{{ issuableName }}</template>
</gl-sprintf>
</gl-button>
</template>
</gl-modal>
</template>

View File

@ -0,0 +1,86 @@
<script>
import {
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownItem,
GlTooltipDirective,
GlModalDirective,
} from '@gitlab/ui';
import CsvExportModal from './csv_export_modal.vue';
import CsvImportModal from './csv_import_modal.vue';
export default {
name: 'CsvImportExportButtons',
components: {
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownItem,
CsvExportModal,
CsvImportModal,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
inject: {
showExportButton: {
default: false,
},
showImportButton: {
default: false,
},
containerClass: {
default: '',
},
canEdit: {
default: false,
},
projectImportJiraPath: {
default: null,
},
},
computed: {
exportModalId() {
return `${this.issuableType}-export-modal`;
},
importModalId() {
return `${this.issuableType}-import-modal`;
},
},
};
</script>
<template>
<div :class="containerClass">
<gl-button-group>
<gl-button
v-if="showExportButton"
v-gl-tooltip.hover="__('Export as CSV')"
v-gl-modal="exportModalId"
icon="export"
data-testid="export-csv-button"
/>
<gl-dropdown
v-if="showImportButton"
v-gl-tooltip.hover="__('Import issues')"
data-testid="import-csv-dropdown"
icon="import"
>
<gl-dropdown-item v-gl-modal="importModalId" data-testid="import-csv-link">{{
__('Import CSV')
}}</gl-dropdown-item>
<gl-dropdown-item
v-if="canEdit"
:href="projectImportJiraPath"
data-qa-selector="import_from_jira_link"
data-testid="import-from-jira-link"
>{{ __('Import from Jira') }}</gl-dropdown-item
>
</gl-dropdown>
</gl-button-group>
<csv-export-modal v-if="showExportButton" :modal-id="exportModalId" />
<csv-import-modal v-if="showImportButton" :modal-id="importModalId" />
</div>
</template>

View File

@ -0,0 +1,86 @@
<script>
import { GlModal, GlSprintf, GlFormGroup, GlButton } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { ISSUABLE_TYPE } from '../constants';
export default {
name: 'CsvImportModal',
components: {
GlModal,
GlSprintf,
GlFormGroup,
GlButton,
},
inject: {
issuableType: {
default: '',
},
exportCsvPath: {
default: '',
},
importCsvIssuesPath: {
default: '',
},
maxAttachmentSize: {
default: 0,
},
},
props: {
modalId: {
type: String,
required: true,
},
},
data() {
return {
// eslint-disable-next-line @gitlab/require-i18n-strings
issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
};
},
methods: {
submitForm() {
this.$refs.form.submit();
},
},
csrf,
};
</script>
<template>
<gl-modal :modal-id="modalId" :title="__('Import issues')">
<form
ref="form"
:action="importCsvIssuesPath"
enctype="multipart/form-data"
method="post"
data-testid="import-csv-form"
>
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
<p>
{{
__(
"Your issues will be imported in the background. Once finished, you'll get a confirmation email.",
)
}}
</p>
<gl-form-group :label="__('Upload CSV file')" label-for="file">
<input id="file" type="file" name="file" accept=".csv,text/csv" />
</gl-form-group>
<p class="text-secondary">
{{
__(
'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.',
)
}}
<gl-sprintf :message="__('The maximum file size allowed is %{size}.')"
><template #size>{{ maxAttachmentSize }}</template></gl-sprintf
>
</p>
</form>
<template #modal-footer>
<gl-button category="primary" variant="confirm" @click="submitForm">{{
__('Import issues')
}}</gl-button>
</template>
</gl-modal>
</template>

View File

@ -1 +1,6 @@
export const EVENT_ISSUABLE_VUE_APP_CHANGE = 'issuable_vue_app:change';
export const ISSUABLE_TYPE = {
issues: 'issues',
mergeRequests: 'merge-requests',
};

View File

@ -0,0 +1,43 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import ImportExportButtons from './components/csv_import_export_buttons.vue';
export default () => {
const el = document.querySelector('.js-csv-import-export-buttons');
if (!el) return null;
const {
showExportButton,
showImportButton,
issuableType,
issuableCount,
email,
exportCsvPath,
importCsvIssuesPath,
containerClass,
canEdit,
projectImportJiraPath,
maxAttachmentSize,
} = el.dataset;
return new Vue({
el,
provide: {
showExportButton: parseBoolean(showExportButton),
showImportButton: parseBoolean(showImportButton),
issuableType,
issuableCount,
email,
exportCsvPath,
importCsvIssuesPath,
containerClass,
canEdit: parseBoolean(canEdit),
projectImportJiraPath,
maxAttachmentSize,
},
render(h) {
return h(ImportExportButtons);
},
});
};

View File

@ -24,12 +24,21 @@ export default {
default: '',
},
},
model: {
prop: 'visible',
event: 'change',
},
props: {
modalId: {
type: String,
required: false,
default: 'custom-notifications-modal',
},
visible: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -95,9 +104,11 @@ export default {
<template>
<gl-modal
ref="modal"
:visible="visible"
:modal-id="modalId"
:title="$options.i18n.customNotificationsModal.title"
@show="onOpen"
v-on="$listeners"
>
<div class="container-fluid">
<div class="row">

View File

@ -1,12 +1,5 @@
<script>
import {
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownDivider,
GlTooltipDirective,
GlModalDirective,
} from '@gitlab/ui';
import { GlDropdown, GlDropdownDivider, GlTooltipDirective } from '@gitlab/ui';
import Api from '~/api';
import { sprintf } from '~/locale';
import { CUSTOM_LEVEL, i18n } from '../constants';
@ -16,8 +9,6 @@ import NotificationsDropdownItem from './notifications_dropdown_item.vue';
export default {
name: 'NotificationsDropdown',
components: {
GlButtonGroup,
GlButton,
GlDropdown,
GlDropdownDivider,
NotificationsDropdownItem,
@ -25,7 +16,6 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
'gl-modal': GlModalDirective,
},
inject: {
containerClass: {
@ -57,6 +47,7 @@ export default {
return {
selectedNotificationLevel: this.initialNotificationLevel,
isLoading: false,
notificationsModalVisible: false,
};
},
computed: {
@ -95,6 +86,11 @@ export default {
},
},
methods: {
openNotificationsModal() {
if (this.isCustomNotification) {
this.notificationsModalVisible = true;
}
},
selectItem(level) {
if (level !== this.selectedNotificationLevel) {
this.updateNotificationLevel(level);
@ -106,10 +102,7 @@ export default {
try {
await Api.updateNotificationSettings(this.projectId, this.groupId, { level });
this.selectedNotificationLevel = level;
if (level === CUSTOM_LEVEL) {
this.$refs.customNotificationsModal.open();
}
this.openNotificationsModal();
} catch (error) {
this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' });
} finally {
@ -125,54 +118,16 @@ export default {
<template>
<div :class="containerClass">
<gl-button-group
v-if="isCustomNotification"
v-gl-tooltip="{ title: buttonTooltip }"
data-testid="notification-button"
:class="{ disabled: disabled }"
:size="buttonSize"
>
<gl-button
v-gl-modal="$options.modalId"
:size="buttonSize"
:icon="buttonIcon"
:loading="isLoading"
:disabled="disabled"
>
<template v-if="buttonText">{{ buttonText }}</template>
</gl-button>
<gl-dropdown :size="buttonSize" :disabled="disabled">
<notifications-dropdown-item
v-for="item in notificationLevels"
:key="item.level"
:level="item.level"
:title="item.title"
:description="item.description"
:notification-level="selectedNotificationLevel"
@item-selected="selectItem"
/>
<gl-dropdown-divider />
<notifications-dropdown-item
:key="$options.customLevel"
:level="$options.customLevel"
:title="$options.i18n.notificationTitles.custom"
:description="$options.i18n.notificationDescriptions.custom"
:notification-level="selectedNotificationLevel"
@item-selected="selectItem"
/>
</gl-dropdown>
</gl-button-group>
<gl-dropdown
v-else
v-gl-tooltip="{ title: buttonTooltip }"
data-testid="notification-button"
:text="buttonText"
data-testid="notification-dropdown"
:size="buttonSize"
:icon="buttonIcon"
:loading="isLoading"
:size="buttonSize"
:disabled="disabled"
:class="{ disabled: disabled }"
:split="isCustomNotification"
:text="buttonText"
@click="openNotificationsModal"
>
<notifications-dropdown-item
v-for="item in notificationLevels"
@ -193,6 +148,6 @@ export default {
@item-selected="selectItem"
/>
</gl-dropdown>
<custom-notifications-modal ref="customNotificationsModal" :modal-id="$options.modalId" />
<custom-notifications-modal v-model="notificationsModalVisible" :modal-id="$options.modalId" />
</div>
</template>

View File

@ -2,6 +2,7 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index';
import initIssuablesList from '~/issues_list';
@ -26,3 +27,4 @@ new UsersSelect();
initManualOrdering();
initIssuablesList();
initIssuableByEmail();
initCsvImportExportButtons();

View File

@ -1,6 +1,7 @@
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index';
import { FILTERED_SEARCH } from '~/pages/constants';
@ -22,3 +23,4 @@ new UsersSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initIssuableByEmail();
initCsvImportExportButtons();

View File

@ -132,6 +132,8 @@ class Packages::Package < ApplicationRecord
scope :order_project_path_desc, -> { joins(:project).reorder('projects.path DESC, id DESC') }
scope :order_by_package_file, -> { joins(:package_files).order('packages_package_files.created_at ASC') }
after_commit :update_composer_cache, on: :destroy, if: -> { composer? }
def self.for_projects(projects)
return none unless projects.any?
@ -232,6 +234,12 @@ class Packages::Package < ApplicationRecord
private
def update_composer_cache
return unless composer?
::Packages::Composer::CacheUpdateWorker.perform_async(project_id, name, composer_metadatum.version_cache_sha) # rubocop:disable CodeReuse/Worker
end
def composer_tag_version?
composer? && !Gitlab::Regex.composer_dev_version_regex.match(version.to_s)
end

View File

@ -17,6 +17,8 @@ module Packages
})
end
::Packages::Composer::CacheUpdateWorker.perform_async(created_package.project_id, created_package.name, nil)
created_package
end

View File

@ -1,17 +1,15 @@
- show_feed_buttons = local_assigns.fetch(:show_feed_buttons, true)
- show_import_button = local_assigns.fetch(:show_import_button, true) && can?(current_user, :import_issues, @project)
- show_export_button = local_assigns.fetch(:show_export_button, true)
- issuable_type = 'issues'
- can_edit = can?(current_user, :admin_project, @project)
- notification_email = @current_user.present? ? @current_user.notification_email : nil
.nav-controls.issues-nav-controls
- if show_feed_buttons
= render 'shared/issuable/feed_buttons'
.btn-group
- if show_export_button
= render 'shared/issuable/csv_export/button', issuable_type: 'issues'
- if show_import_button
= render 'projects/issues/import_csv/button'
.js-csv-import-export-buttons{ data: { show_export_button: show_export_button.to_s, show_import_button: show_import_button.to_s, issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_issues_path(@project, request.query_parameters), import_csv_issues_path: import_csv_namespace_project_issues_path, container_class: 'gl-mr-3', can_edit: can_edit.to_s, project_import_jira_path: project_import_jira_path(@project) } }
- if @can_bulk_update
= button_tag _("Edit issues"), class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
@ -19,11 +17,6 @@
= link_to _("New issue"), new_project_issue_path(@project,
issue: { assignee_id: finder.assignee.try(:id),
milestone_id: finder.milestones.first.try(:id) }),
class: "gl-button btn btn-success",
class: "gl-button btn btn-confirm",
id: "new_issue_link"
- if show_export_button
= render 'shared/issuable/csv_export/modal', issuable_type: 'issues'
- if show_import_button
= render 'projects/issues/import_csv/modal'

View File

@ -1,10 +1,10 @@
.btn-group
= render 'shared/issuable/csv_export/button', issuable_type: 'merge-requests'
- issuable_type = 'merge-requests'
- notification_email = @current_user.present? ? @current_user.notification_email : nil
.js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters), container_class: 'gl-mr-3' } }
- if @can_bulk_update
= button_tag "Edit merge requests", class: "gl-button btn btn-default gl-mr-3 js-bulk-update-toggle"
- if merge_project
= link_to new_merge_request_path, class: "gl-button btn btn-confirm", title: "New merge request" do
New merge request
= render 'shared/issuable/csv_export/modal', issuable_type: 'merge_requests'

View File

@ -1908,6 +1908,14 @@
:weight: 2
:idempotent:
:tags: []
- :name: packages_composer_cache_update
:feature_category: :package_registry
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: pages
:feature_category: :pages
:has_external_dependencies:

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Packages
module Composer
class CacheUpdateWorker
include ApplicationWorker
feature_category :package_registry
idempotent!
def perform(project_id, package_name, last_page_sha)
project = Project.find_by_id(project_id)
return unless project
Gitlab::Composer::Cache.new(project: project, name: package_name, last_page_sha: last_page_sha).execute
rescue => e
Gitlab::ErrorTracking.log_exception(e, project_id: project_id)
end
end
end
end

View File

@ -0,0 +1,5 @@
---
title: Replace import/export CSV modal with Vue component
merge_request: 54214
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Composer cache update worker
merge_request: 54551
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Add Pages cache configuration settings
merge_request: 55812
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add a confirmation prompt to lock and unlock path locks
merge_request: 44849
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Simplify notifications dropdown
merge_request: 55522
author:
type: changed

View File

@ -240,6 +240,8 @@
- 1
- - package_repositories
- 1
- - packages_composer_cache_update
- 1
- - pages
- 1
- - pages_domain_ssl_renewal

View File

@ -6,9 +6,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Fast lookup of authorized SSH keys in the database
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1631) in [GitLab Starter](https://about.gitlab.com/pricing/) 9.3.
> - [Available in](https://gitlab.com/gitlab-org/gitlab/-/issues/3953) GitLab Community Edition 10.4.
NOTE:
This document describes a drop-in replacement for the
`authorized_keys` file. For normal (non-deploy key) users, consider using
@ -219,8 +216,6 @@ the database. The following instructions can be used to build OpenSSH 7.5:
## SELinux support and limitations
> [Introduced](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/2855) in GitLab 10.5.
GitLab supports `authorized_keys` database lookups with [SELinux](https://en.wikipedia.org/wiki/Security-Enhanced_Linux).
Because the SELinux policy is static, GitLab doesn't support the ability to change

View File

@ -234,6 +234,12 @@ control over how the Pages daemon runs and serves content in your environment.
| `external_https` | Configure Pages to bind to one or more secondary IP addresses, serving HTTPS requests. Multiple addresses can be given as an array, along with exact ports, for example `['1.2.3.4', '1.2.3.5:8063']`. Sets value for `listen_https`. |
| `gitlab_client_http_timeout` | GitLab API HTTP client connection timeout in seconds (default: 10s). |
| `gitlab_client_jwt_expiry` | JWT Token expiry time in seconds (default: 30s). |
| `gitlab_cache_expiry` | The maximum time a domain's configuration is stored in the cache (default: 600s). |
| `gitlab_cache_refresh` | The interval at which a domain's configuration is set to be due to refresh (default: 60s). |
| `gitlab_cache_cleanup` | The interval at which expired items are removed from the cache (default: 60s). |
| `gitlab_retrieval_timeout` | The maximum time to wait for a response from the GitLab API per request (default: 30s). |
| `gitlab_retrieval_interval` | The interval to wait before retrying to resolve a domain's configuration via the GitLab API (default: 1s). |
| `gitlab_retrieval_retries` | The maximum number of times to retry to resolve a domain's configuration via the API (default: 3). |
| `domain_config_source` | Domain configuration source (default: `auto`) |
| `gitlab_id` | The OAuth application public ID. Leave blank to automatically fill when Pages authenticates with GitLab. |
| `gitlab_secret` | The OAuth application secret. Leave blank to automatically fill when Pages authenticates with GitLab. |
@ -792,6 +798,44 @@ gitlab_pages['domain_config_source'] = "disk"
For other common issues, see the [troubleshooting section](#failed-to-connect-to-the-internal-gitlab-api)
or report an issue.
### GitLab API cache configuration
> [Introduced](https://gitlab.com/gitlab-org/gitlab-pages/-/issues/520) in GitLab 13.10.
API-based configuration uses a caching mechanism to improve performance and reliability of serving Pages.
The cache behavior can be modified by changing the cache settings, however, the recommended values are set for you and should only be modified if needed.
Incorrect configuration of these values may result in intermittent
or persistent errors, or the Pages Daemon serving old content.
NOTE:
Expiry, interval and timeout flags use [Golang's duration formatting](https://golang.org/pkg/time/#ParseDuration).
A duration string is a possibly signed sequence of decimal numbers,
each with optional fraction and a unit suffix, such as "300ms", "1.5h" or "2h45m".
Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
Examples:
- Increasing `gitlab_cache_expiry` will allow items to exist in the cache longer.
This setting might be useful if the communication between GitLab Pages and GitLab Rails
is not stable.
- Increasing `gitlab_cache_refresh` will reduce the frequency at which GitLab Pages
requests a domain's configuration from GitLab Rails. This setting might be useful
GitLab Pages generates too many requests to GitLab API and content does not change frequently.
- Decreasing `gitlab_cache_cleanup` will remove expired items from the cache more frequently,
reducing the memory usage of your Pages node.
- Decreasing `gitlab_retrieval_timeout` allows you to stop the request to GitLab Rails
more quickly. Increasing it will allow more time to receive a response from the API,
useful in slow networking environments.
- Decreasing `gitlab_retrieval_interval` will make requests to the API more frequently,
only when there is an error response from the API, for example a connection timeout.
- Decreasing `gitlab_retrieval_retries` will reduce the number of times a domain's
configuration is tried to be resolved automatically before reporting an error.
## Using object storage
> [Introduced](https://gitlab.com/gitlab-org/omnibus-gitlab/-/issues/5577) in GitLab 13.6.

View File

@ -1171,6 +1171,16 @@ msgid_plural "%d hours"
msgstr[0] ""
msgstr[1] ""
msgid "1 issue selected"
msgid_plural "%d issues selected"
msgstr[0] ""
msgstr[1] ""
msgid "1 merge request selected"
msgid_plural "%d merge request selected"
msgstr[0] ""
msgstr[1] ""
msgid "1 merged merge request"
msgid_plural "%{merge_requests} merged merge requests"
msgstr[0] ""
@ -3977,6 +3987,12 @@ msgid_plural "Are you sure you want to import %d repositories?"
msgstr[0] ""
msgstr[1] ""
msgid "Are you sure you want to lock %{path}?"
msgstr ""
msgid "Are you sure you want to lock this directory?"
msgstr ""
msgid "Are you sure you want to lose unsaved changes?"
msgstr ""
@ -4037,6 +4053,12 @@ msgstr ""
msgid "Are you sure you want to unlock %{path_lock_path}?"
msgstr ""
msgid "Are you sure you want to unlock %{path}?"
msgstr ""
msgid "Are you sure you want to unlock this directory?"
msgstr ""
msgid "Are you sure you want to unsubscribe from the %{type}: %{link_to_noteable_text}?"
msgstr ""
@ -4850,6 +4872,9 @@ msgstr ""
msgid "Boards|An error occurred while generating lists. Please reload the page."
msgstr ""
msgid "Boards|An error occurred while moving the epic. Please try again."
msgstr ""
msgid "Boards|An error occurred while moving the issue. Please try again."
msgstr ""
@ -12424,6 +12449,9 @@ msgstr ""
msgid "Export"
msgstr ""
msgid "Export %{name}"
msgstr ""
msgid "Export %{requirementsCount} requirements?"
msgstr ""

View File

@ -183,7 +183,11 @@ module QA
end
def fast_forward_possible?
has_no_text?('Fast-forward merge is not possible')
has_text?('Fast-forward merge without a merge commit')
end
def fast_forward_not_possible?
has_text?('Fast-forward merge is not possible')
end
def has_file?(file_name)

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
RSpec.describe 'Create', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/30226', type: :bug } do
RSpec.describe 'Create', quarantine: { only: { subdomain: :staging }, issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/323990', type: :flaky } do
describe 'Merge request rebasing' do
it 'user rebases source branch of merge request', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1274' do
Flow::Login.sign_in
@ -34,7 +34,7 @@ module QA
Page::MergeRequest::Show.perform do |merge_request|
expect(merge_request).to have_content('Needs rebasing')
expect(merge_request).not_to be_fast_forward_possible
expect(merge_request).to be_fast_forward_not_possible
expect(merge_request).not_to have_merge_button
merge_request.rebase!

View File

@ -170,14 +170,14 @@ RSpec.describe 'Group show page' do
it 'is enabled by default' do
visit path
expect(page).to have_selector('[data-testid="notification-button"]:not(.disabled)')
expect(page).to have_selector('[data-testid="notification-dropdown"] button:not(.disabled)')
end
it 'is disabled if emails are disabled' do
group.update_attribute(:emails_disabled, true)
visit path
expect(page).to have_selector('[data-testid="notification-button"].disabled')
expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled')
end
end

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'Issues csv' do
RSpec.describe 'Issues csv', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:milestone) { create(:milestone, title: 'v1.0', project: project) }
@ -17,7 +17,7 @@ RSpec.describe 'Issues csv' do
def request_csv(params = {})
visit project_issues_path(project, params)
page.within('.nav-controls') do
click_on 'Export as CSV'
find('[data-testid="export-csv-button"]').click
end
click_on 'Export issues'
end

View File

@ -14,11 +14,13 @@ RSpec.describe 'Merge Requests > Exports as CSV', :js do
subject { page.find('.nav-controls') }
it { is_expected.to have_button('Export as CSV') }
it { is_expected.to have_selector '[data-testid="export-csv-button"]' }
context 'button is clicked' do
before do
click_button('Export as CSV')
page.within('.nav-controls') do
find('[data-testid="export-csv-button"]').click
end
end
it 'shows a success message' do

View File

@ -15,17 +15,17 @@ RSpec.describe 'User visits the notifications tab', :js do
it 'changes the project notifications setting' do
expect(page).to have_content('Notifications')
first('[data-testid="notification-button"]').click
first('[data-testid="notification-dropdown"]').click
click_button('On mention')
expect(page).to have_selector('[data-testid="notification-button"]', text: 'On mention')
expect(page).to have_selector('[data-testid="notification-dropdown"]', text: 'On mention')
end
context 'when project emails are disabled' do
let(:project) { create(:project, emails_disabled: true) }
it 'notification button is disabled' do
expect(page).to have_selector('[data-testid="notification-button"].disabled')
expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled')
end
end
end

View File

@ -57,7 +57,7 @@ RSpec.describe 'Project group variables', :js do
wait_for_requests
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) [data-label="Key"]').text).to eq(key1)
end
end

View File

@ -32,7 +32,7 @@ RSpec.describe 'Project variables', :js do
wait_for_requests
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*')
end
end

View File

@ -10,7 +10,7 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do
end
def click_notifications_button
first('[data-testid="notification-button"]').click
first('[data-testid="notification-dropdown"]').click
end
it 'changes the notification setting' do
@ -22,7 +22,7 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do
click_notifications_button
page.within first('[data-testid="notification-button"]') do
page.within first('[data-testid="notification-dropdown"]') do
expect(page.find('.gl-new-dropdown-item.is-active')).to have_content('On mention')
expect(page).to have_css('[data-testid="notifications-icon"]')
end
@ -33,7 +33,7 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do
click_notifications_button
click_button 'Disabled'
page.within first('[data-testid="notification-button"]') do
page.within first('[data-testid="notification-dropdown"]') do
expect(page).to have_css('[data-testid="notifications-off-icon"]')
end
end
@ -80,7 +80,7 @@ RSpec.describe 'Projects > Show > User manages notifications', :js do
it 'is disabled' do
visit project_path(project)
expect(page).to have_selector('[data-testid="notification-button"].disabled', visible: true)
expect(page).to have_selector('[data-testid="notification-dropdown"] .disabled', visible: true)
end
end
end

View File

@ -125,7 +125,7 @@ describe('Board list component', () => {
});
it('sets data attribute with issue id', () => {
expect(wrapper.find('.board-card').attributes('data-issue-id')).toBe('1');
expect(wrapper.find('.board-card').attributes('data-item-id')).toBe('1');
});
it('shows new issue form', async () => {
@ -258,7 +258,7 @@ describe('Board list component', () => {
describe('handleDragOnEnd', () => {
it('removes class `is-dragging` from document body', () => {
jest.spyOn(wrapper.vm, 'moveIssue').mockImplementation(() => {});
jest.spyOn(wrapper.vm, 'moveItem').mockImplementation(() => {});
document.body.classList.add('is-dragging');
findByTestId('tree-root-wrapper').vm.$emit('end', {
@ -266,9 +266,9 @@ describe('Board list component', () => {
newIndex: 0,
item: {
dataset: {
issueId: mockIssues[0].id,
issueIid: mockIssues[0].iid,
issuePath: mockIssues[0].referencePath,
itemId: mockIssues[0].id,
itemIid: mockIssues[0].iid,
itemPath: mockIssues[0].referencePath,
},
},
to: { children: [], dataset: { listId: 'gid://gitlab/List/1' } },

View File

@ -6,7 +6,7 @@ import BoardCardInner from '~/boards/components/board_card_inner.vue';
import { inactiveId } from '~/boards/constants';
import { mockLabelList, mockIssue } from '../mock_data';
describe('Board card layout', () => {
describe('Board card', () => {
let wrapper;
let store;
let mockActions;
@ -44,7 +44,7 @@ describe('Board card layout', () => {
store,
propsData: {
list: mockLabelList,
issue: mockIssue,
item: mockIssue,
disabled: false,
index: 0,
...propsData,
@ -113,7 +113,7 @@ describe('Board card layout', () => {
expect(wrapper.classes()).not.toContain('is-active');
});
describe('when mouseup event is called on the issue card', () => {
describe('when mouseup event is called on the card', () => {
beforeEach(() => {
createStore({ isSwimlanesOn });
mountComponent();

View File

@ -637,6 +637,15 @@ describe('resetIssues', () => {
});
});
describe('moveItem', () => {
it('should dispatch moveIssue action', () => {
testAction({
action: actions.moveItem,
expectedActions: [{ type: 'moveIssue' }],
});
});
});
describe('moveIssue', () => {
const listIssues = {
'gid://gitlab/List/1': [436, 437],
@ -671,9 +680,9 @@ describe('moveIssue', () => {
testAction(
actions.moveIssue,
{
issueId: '436',
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
itemId: '436',
itemIid: mockIssue.iid,
itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
@ -722,9 +731,9 @@ describe('moveIssue', () => {
actions.moveIssue(
{ state, commit: () => {} },
{
issueId: mockIssue.id,
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
itemId: mockIssue.id,
itemIid: mockIssue.iid,
itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},
@ -746,9 +755,9 @@ describe('moveIssue', () => {
testAction(
actions.moveIssue,
{
issueId: '436',
issueIid: mockIssue.iid,
issuePath: mockIssue.referencePath,
itemId: '436',
itemIid: mockIssue.iid,
itemPath: mockIssue.referencePath,
fromListId: 'gid://gitlab/List/1',
toListId: 'gid://gitlab/List/2',
},

View File

@ -0,0 +1,91 @@
import { GlModal, GlIcon, GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CsvExportModal from '~/issuable/components/csv_export_modal.vue';
describe('CsvExportModal', () => {
let wrapper;
function createComponent(options = {}) {
const { injectedProperties = {}, props = {} } = options;
return extendedWrapper(
mount(CsvExportModal, {
propsData: {
modalId: 'csv-export-modal',
...props,
},
provide: {
issuableType: 'issues',
...injectedProperties,
},
stubs: {
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
}),
},
}),
);
}
afterEach(() => {
wrapper.destroy();
});
const findModal = () => wrapper.findComponent(GlModal);
const findIcon = () => wrapper.findComponent(GlIcon);
const findButton = () => wrapper.findComponent(GlButton);
describe('template', () => {
describe.each`
issuableType | modalTitle
${'issues'} | ${'Export issues'}
${'merge-requests'} | ${'Export merge requests'}
`('with the issuableType "$issuableType"', ({ issuableType, modalTitle }) => {
beforeEach(() => {
wrapper = createComponent({ injectedProperties: { issuableType } });
});
it('displays the modal title "$modalTitle"', () => {
expect(findModal().text()).toContain(modalTitle);
});
it('displays the button with title "$modalTitle"', () => {
expect(findButton().text()).toBe(modalTitle);
});
});
describe('issuable count info text', () => {
it('displays the info text when issuableCount is > -1', () => {
wrapper = createComponent({ injectedProperties: { issuableCount: 10 } });
expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(true);
expect(wrapper.findByTestId('issuable-count-note').text()).toContain('10 issues selected');
expect(findIcon().exists()).toBe(true);
});
it("doesn't display the info text when issuableCount is -1", () => {
wrapper = createComponent({ injectedProperties: { issuableCount: -1 } });
expect(wrapper.findByTestId('issuable-count-note').exists()).toBe(false);
});
});
describe('email info text', () => {
it('displays the proper email', () => {
const email = 'admin@example.com';
wrapper = createComponent({ injectedProperties: { email } });
expect(findModal().text()).toContain(
`The CSV export will be created in the background. Once finished, it will be sent to ${email} in an attachment.`,
);
});
});
describe('primary button', () => {
it('passes the exportCsvPath to the button', () => {
const exportCsvPath = '/gitlab-org/gitlab-test/-/issues/export_csv';
wrapper = createComponent({ injectedProperties: { exportCsvPath } });
expect(findButton().attributes('href')).toBe(exportCsvPath);
});
});
});
});

View File

@ -0,0 +1,161 @@
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CsvExportModal from '~/issuable/components/csv_export_modal.vue';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import CsvImportModal from '~/issuable/components/csv_import_modal.vue';
describe('CsvImportExportButtons', () => {
let wrapper;
let glModalDirective;
function createComponent(injectedProperties = {}) {
glModalDirective = jest.fn();
return extendedWrapper(
shallowMount(CsvImportExportButtons, {
directives: {
GlTooltip: createMockDirective(),
glModal: {
bind(_, { value }) {
glModalDirective(value);
},
},
},
provide: {
...injectedProperties,
},
}),
);
}
afterEach(() => {
wrapper.destroy();
});
const findExportCsvButton = () => wrapper.findByTestId('export-csv-button');
const findImportDropdown = () => wrapper.findByTestId('import-csv-dropdown');
const findImportCsvButton = () => wrapper.findByTestId('import-csv-dropdown');
const findImportFromJiraLink = () => wrapper.findByTestId('import-from-jira-link');
const findExportCsvModal = () => wrapper.findComponent(CsvExportModal);
const findImportCsvModal = () => wrapper.findComponent(CsvImportModal);
describe('template', () => {
describe('when the showExportButton=true', () => {
beforeEach(() => {
wrapper = createComponent({ showExportButton: true });
});
it('displays the export button', () => {
expect(findExportCsvButton().exists()).toBe(true);
});
it('export button has a tooltip', () => {
const tooltip = getBinding(findExportCsvButton().element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe('Export as CSV');
});
it('renders the export modal', () => {
expect(findExportCsvModal().exists()).toBe(true);
});
it('opens the export modal', () => {
findExportCsvButton().trigger('click');
expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.exportModalId);
});
});
describe('when the showExportButton=false', () => {
beforeEach(() => {
wrapper = createComponent({ showExportButton: false });
});
it('does not display the export button', () => {
expect(findExportCsvButton().exists()).toBe(false);
});
it('does not render the export modal', () => {
expect(findExportCsvModal().exists()).toBe(false);
});
});
describe('when the showImportButton=true', () => {
beforeEach(() => {
wrapper = createComponent({ showImportButton: true });
});
it('displays the import dropdown', () => {
expect(findImportDropdown().exists()).toBe(true);
});
it('renders the import button', () => {
expect(findImportCsvButton().exists()).toBe(true);
});
it('import button has a tooltip', () => {
const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe('Import issues');
});
it('renders the import modal', () => {
expect(findImportCsvModal().exists()).toBe(true);
});
it('opens the import modal', () => {
findImportCsvButton().trigger('click');
expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.importModalId);
});
describe('import from jira link', () => {
const projectImportJiraPath = 'gitlab-org/gitlab-test/-/import/jira';
beforeEach(() => {
wrapper = createComponent({
showImportButton: true,
canEdit: true,
projectImportJiraPath,
});
});
describe('when canEdit=true', () => {
it('renders the import dropdown item', () => {
expect(findImportFromJiraLink().exists()).toBe(true);
});
it('passes the proper path to the link', () => {
expect(findImportFromJiraLink().attributes('href')).toBe(projectImportJiraPath);
});
});
describe('when canEdit=false', () => {
beforeEach(() => {
wrapper = createComponent({ showImportButton: true, canEdit: false });
});
it('does not render the import dropdown item', () => {
expect(findImportFromJiraLink().exists()).toBe(false);
});
});
});
});
describe('when the showImportButton=false', () => {
beforeEach(() => {
wrapper = createComponent({ showImportButton: false });
});
it('does not display the import dropdown', () => {
expect(findImportDropdown().exists()).toBe(false);
});
it('does not render the import modal', () => {
expect(findImportCsvModal().exists()).toBe(false);
});
});
});
});

View File

@ -0,0 +1,86 @@
import { GlModal } from '@gitlab/ui';
import { getByRole, getByLabelText } from '@testing-library/dom';
import { mount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CsvImportModal from '~/issuable/components/csv_import_modal.vue';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
describe('CsvImportModal', () => {
let wrapper;
let formSubmitSpy;
function createComponent(options = {}) {
const { injectedProperties = {}, props = {} } = options;
return extendedWrapper(
mount(CsvImportModal, {
propsData: {
modalId: 'csv-import-modal',
...props,
},
provide: {
issuableType: 'issues',
...injectedProperties,
},
stubs: {
GlModal: stubComponent(GlModal, {
template: '<div><slot></slot><slot name="modal-footer"></slot></div>',
}),
},
}),
);
}
beforeEach(() => {
formSubmitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation();
});
afterEach(() => {
wrapper.destroy();
});
const findModal = () => wrapper.findComponent(GlModal);
const findPrimaryButton = () => getByRole(wrapper.element, 'button', { name: 'Import issues' });
const findForm = () => wrapper.findByTestId('import-csv-form');
const findFileInput = () => getByLabelText(wrapper.element, 'Upload CSV file');
const findAuthenticityToken = () => new FormData(findForm().element).get('authenticity_token');
describe('template', () => {
it('displays modal title', () => {
wrapper = createComponent();
expect(findModal().text()).toContain('Import issues');
});
it('displays a note about the maximum allowed file size', () => {
const maxAttachmentSize = 500;
wrapper = createComponent({ injectedProperties: { maxAttachmentSize } });
expect(findModal().text()).toContain(`The maximum file size allowed is ${maxAttachmentSize}`);
});
describe('form', () => {
const importCsvIssuesPath = 'gitlab-org/gitlab-test/-/issues/import_csv';
beforeEach(() => {
wrapper = createComponent({ injectedProperties: { importCsvIssuesPath } });
});
it('displays the form with the correct action and inputs', () => {
expect(findForm().exists()).toBe(true);
expect(findForm().attributes('action')).toBe(importCsvIssuesPath);
expect(findAuthenticityToken()).toBe('mock-csrf-token');
expect(findFileInput()).toExist();
});
it('displays the correct primary button action text', () => {
expect(findPrimaryButton()).toExist();
});
it('submits the form when the primary action is clicked', async () => {
findPrimaryButton().click();
expect(formSubmitSpy).toHaveBeenCalled();
});
});
});
});

View File

@ -1,4 +1,4 @@
import { GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
@ -15,14 +15,10 @@ const mockToastShow = jest.fn();
describe('NotificationsDropdown', () => {
let wrapper;
let mockAxios;
let glModalDirective;
function createComponent(injectedProperties = {}) {
glModalDirective = jest.fn();
return shallowMount(NotificationsDropdown, {
stubs: {
GlButtonGroup,
GlDropdown,
GlDropdownItem,
NotificationsDropdownItem,
@ -30,11 +26,6 @@ describe('NotificationsDropdown', () => {
},
directives: {
GlTooltip: createMockDirective(),
glModal: {
bind(_, { value }) {
glModalDirective(value);
},
},
},
provide: {
dropdownItems: mockDropdownItems,
@ -49,13 +40,12 @@ describe('NotificationsDropdown', () => {
});
}
const findButtonGroup = () => wrapper.find(GlButtonGroup);
const findButton = () => wrapper.find(GlButton);
const findDropdown = () => wrapper.find(GlDropdown);
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findAllNotificationsDropdownItems = () => wrapper.findAll(NotificationsDropdownItem);
const findDropdownItemAt = (index) =>
findAllNotificationsDropdownItems().at(index).find(GlDropdownItem);
const findNotificationsModal = () => wrapper.find(CustomNotificationsModal);
const clickDropdownItemAt = async (index) => {
const dropdownItem = findDropdownItemAt(index);
@ -83,8 +73,8 @@ describe('NotificationsDropdown', () => {
});
});
it('renders a button group', () => {
expect(findButtonGroup().exists()).toBe(true);
it('renders split dropdown', () => {
expect(findDropdown().props().split).toBe(true);
});
it('shows the button text when showLabel is true', () => {
@ -93,7 +83,7 @@ describe('NotificationsDropdown', () => {
showLabel: true,
});
expect(findButton().text()).toBe('Custom');
expect(findDropdown().props().text).toBe('Custom');
});
it("doesn't show the button text when showLabel is false", () => {
@ -102,7 +92,7 @@ describe('NotificationsDropdown', () => {
showLabel: false,
});
expect(findButton().text()).toBe('');
expect(findDropdown().props().text).toBe(null);
});
it('opens the modal when the user clicks the button', async () => {
@ -113,9 +103,9 @@ describe('NotificationsDropdown', () => {
initialNotificationLevel: 'custom',
});
findButton().vm.$emit('click');
await findDropdown().vm.$emit('click');
expect(glModalDirective).toHaveBeenCalled();
expect(findNotificationsModal().props().visible).toBe(true);
});
});
@ -126,8 +116,8 @@ describe('NotificationsDropdown', () => {
});
});
it('does not render a button group', () => {
expect(findButtonGroup().exists()).toBe(false);
it('renders unified dropdown', () => {
expect(findDropdown().props().split).toBe(false);
});
it('shows the button text when showLabel is true', () => {
@ -162,7 +152,7 @@ describe('NotificationsDropdown', () => {
initialNotificationLevel: level,
});
const tooltipElement = findByTestId('notification-button');
const tooltipElement = findByTestId('notification-dropdown');
const tooltip = getBinding(tooltipElement.element, 'gl-tooltip');
expect(tooltip.value.title).toBe(`${tooltipTitlePrefix} - ${title}`);
@ -264,11 +254,9 @@ describe('NotificationsDropdown', () => {
mockAxios.onPut('/api/v4/notification_settings').reply(httpStatus.OK, {});
wrapper = createComponent();
const mockModalShow = jest.spyOn(wrapper.vm.$refs.customNotificationsModal, 'open');
await clickDropdownItemAt(5);
expect(mockModalShow).toHaveBeenCalled();
expect(findNotificationsModal().props().visible).toBe(true);
});
});
});

View File

@ -854,4 +854,22 @@ RSpec.describe Packages::Package, type: :model do
it_behaves_like 'not enqueuing a sync worker job'
end
end
context 'destroying a composer package' do
let_it_be(:package_name) { 'composer-package-name' }
let_it_be(:json) { { 'name' => package_name } }
let_it_be(:project) { create(:project, :custom_repo, files: { 'composer.json' => json.to_json } ) }
let!(:package) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '1.0.0', json: json) }
before do
Gitlab::Composer::Cache.new(project: project, name: package_name).execute
package.composer_metadatum.reload
end
it 'schedule the update job' do
expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, package.composer_metadatum.version_cache_sha)
package.destroy!
end
end
end

View File

@ -28,6 +28,8 @@ RSpec.describe Packages::Composer::CreatePackageService do
let(:branch) { project.repository.find_branch('master') }
it 'creates the package' do
expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil)
expect { subject }
.to change { Packages::Package.composer.count }.by(1)
.and change { Packages::Composer::Metadatum.count }.by(1)
@ -54,6 +56,8 @@ RSpec.describe Packages::Composer::CreatePackageService do
end
it 'creates the package' do
expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil)
expect { subject }
.to change { Packages::Package.composer.count }.by(1)
.and change { Packages::Composer::Metadatum.count }.by(1)
@ -80,6 +84,8 @@ RSpec.describe Packages::Composer::CreatePackageService do
end
it 'does not create a new package' do
expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil)
expect { subject }
.to change { Packages::Package.composer.count }.by(0)
.and change { Packages::Composer::Metadatum.count }.by(0)
@ -101,6 +107,8 @@ RSpec.describe Packages::Composer::CreatePackageService do
let!(:other_package) { create(:package, name: package_name, version: 'dev-master', project: other_project) }
it 'creates the package' do
expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, nil)
expect { subject }
.to change { Packages::Package.composer.count }.by(1)
.and change { Packages::Composer::Metadatum.count }.by(1)

View File

@ -2,7 +2,7 @@
RSpec.shared_examples 'variable list' do
it 'shows a list of variables' do
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq(variable.key)
end
end
@ -16,7 +16,7 @@ RSpec.shared_examples 'variable list' do
wait_for_requests
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
end
end
@ -30,7 +30,7 @@ RSpec.shared_examples 'variable list' do
wait_for_requests
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Protected"] svg[data-testid="mobile-issue-close-icon"]')).to be_present
end
@ -45,14 +45,14 @@ RSpec.shared_examples 'variable list' do
wait_for_requests
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="close-icon"]')).to be_present
end
end
it 'reveals and hides variables' do
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq(variable.key)
expect(page).to have_content('*' * 17)
@ -72,7 +72,7 @@ RSpec.shared_examples 'variable list' do
it 'deletes a variable' do
expect(page).to have_selector('.js-ci-variable-row', count: 1)
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
end
@ -86,7 +86,7 @@ RSpec.shared_examples 'variable list' do
end
it 'edits a variable' do
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
end
@ -102,7 +102,7 @@ RSpec.shared_examples 'variable list' do
end
it 'edits a variable to be unmasked' do
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
end
@ -115,13 +115,13 @@ RSpec.shared_examples 'variable list' do
wait_for_requests
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="close-icon"]')).to be_present
end
end
it 'edits a variable to be masked' do
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
end
@ -133,7 +133,7 @@ RSpec.shared_examples 'variable list' do
wait_for_requests
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
click_button('Edit')
end
@ -143,7 +143,7 @@ RSpec.shared_examples 'variable list' do
click_button('Update variable')
end
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="mobile-issue-close-icon"]')).to be_present
end
end
@ -211,7 +211,7 @@ RSpec.shared_examples 'variable list' do
expect(page).to have_selector('.js-ci-variable-row', count: 3)
# Remove the `akey` variable
page.within('.ci-variable-table') do
page.within('[data-testid="ci-variable-table"]') do
page.within('.js-ci-variable-row:first-child') do
click_button('Edit')
end

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Packages::Composer::CacheUpdateWorker, type: :worker do
describe '#perform' do
let_it_be(:package_name) { 'sample-project' }
let_it_be(:json) { { 'name' => package_name } }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :custom_repo, files: { 'composer.json' => json.to_json }, group: group) }
let(:last_sha) { nil }
let!(:package) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '1.0.0', json: json) }
let(:job_args) { [project.id, package_name, last_sha] }
subject { described_class.new.perform(*job_args) }
before do
stub_composer_cache_object_storage
end
include_examples 'an idempotent worker' do
context 'creating a package' do
it 'updates the cache' do
expect { subject }.to change { Packages::Composer::CacheFile.count }.by(1)
end
end
context 'deleting a package' do
let!(:last_sha) do
Gitlab::Composer::Cache.new(project: project, name: package_name).execute
package.reload.composer_metadatum.version_cache_sha
end
before do
package.destroy!
end
it 'marks the file for deletion' do
expect { subject }.not_to change { Packages::Composer::CacheFile.count }
cache_file = Packages::Composer::CacheFile.last
expect(cache_file.reload.delete_at).not_to be_nil
end
end
end
end
end