Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-10-21 09:09:48 +00:00
parent b86ad5f488
commit a7d30d92f8
55 changed files with 824 additions and 411 deletions

View File

@ -15,6 +15,7 @@ import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
@ -63,7 +64,7 @@ export default {
'groupPathForActiveIssue',
'projectPathForActiveIssue',
]),
...mapState(['sidebarType', 'issuableType', 'isSettingLabels']),
...mapState(['sidebarType', 'issuableType']),
isIssuableSidebar() {
return this.sidebarType === ISSUABLE;
},
@ -84,7 +85,10 @@ export default {
});
},
attrWorkspacePath() {
return this.isGroupBoard ? this.groupPathForActiveIssue : undefined;
return this.isGroupBoard ? this.groupPathForActiveIssue : this.projectPathForActiveIssue;
},
labelType() {
return this.isGroupBoard ? LabelType.group : LabelType.project;
},
},
methods: {
@ -98,21 +102,19 @@ export default {
handleClose() {
this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
},
handleUpdateSelectedLabels(input) {
handleUpdateSelectedLabels({ labels, id }) {
this.setActiveBoardItemLabels({
iid: this.activeBoardItem.iid,
id,
projectPath: this.projectPathForActiveIssue,
addLabelIds: input.map((label) => getIdFromGraphQLId(label.id)),
removeLabelIds: this.activeBoardItem.labels
.filter((label) => !input.find((selected) => selected.id === label.id))
.map((label) => label.id),
labelIds: labels.map((label) => getIdFromGraphQLId(label.id)),
labels,
});
},
handleLabelRemove(input) {
handleLabelRemove(removeLabelId) {
this.setActiveBoardItemLabels({
iid: this.activeBoardItem.iid,
projectPath: this.projectPathForActiveIssue,
removeLabelIds: [input],
removeLabelIds: [removeLabelId],
});
},
},
@ -207,14 +209,13 @@ export default {
:full-path="projectPathForActiveIssue"
:allow-label-remove="allowLabelEdit"
:allow-multiselect="true"
:selected-labels="activeBoardItem.labels"
:labels-select-in-progress="isSettingLabels"
:footer-create-label-title="createLabelTitle"
:footer-manage-label-title="manageLabelTitle"
:labels-create-title="createLabelTitle"
:labels-filter-base-path="projectPathForActiveIssue"
:attr-workspace-path="attrWorkspacePath"
:issuable-type="issuableType"
:label-type="labelType"
@onLabelRemove="handleLabelRemove"
@updateSelectedLabels="handleUpdateSelectedLabels"
>

View File

@ -91,9 +91,7 @@ export default {
try {
const addLabelIds = payload.filter((label) => label.set).map((label) => label.id);
const removeLabelIds = this.selectedLabels
.filter((label) => !payload.find((selected) => selected.id === label.id))
.map((label) => label.id);
const removeLabelIds = payload.filter((label) => !label.set).map((label) => label.id);
const input = {
addLabelIds,
@ -164,7 +162,7 @@ export default {
:labels-list-title="__('Select label')"
:dropdown-button-text="__('Choose labels')"
:is-editing="edit"
variant="embedded"
variant="sidebar"
class="gl-display-block labels gl-w-full"
@updateSelectedLabels="setLabels"
>

View File

@ -656,30 +656,45 @@ export default {
},
setActiveIssueLabels: async ({ commit, getters }, input) => {
commit(types.SET_LABELS_LOADING, true);
const { activeBoardItem } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetLabelsMutation,
variables: {
input: {
iid: input.iid || String(activeBoardItem.iid),
addLabelIds: input.addLabelIds ?? [],
removeLabelIds: input.removeLabelIds ?? [],
projectPath: input.projectPath,
if (!gon.features?.labelsWidget) {
const { data } = await gqlClient.mutate({
mutation: issueSetLabelsMutation,
variables: {
input: {
iid: input.iid || String(activeBoardItem.iid),
labelIds: input.labelsId ?? undefined,
addLabelIds: input.addLabelIds ?? [],
removeLabelIds: input.removeLabelIds ?? [],
projectPath: input.projectPath,
},
},
},
});
});
commit(types.SET_LABELS_LOADING, false);
if (data.updateIssue?.errors?.length > 0) {
throw new Error(data.updateIssue.errors);
}
if (data.updateIssue?.errors?.length > 0) {
throw new Error(data.updateIssue.errors);
commit(types.UPDATE_BOARD_ITEM_BY_ID, {
itemId: data.updateIssue?.issue?.id || activeBoardItem.id,
prop: 'labels',
value: data.updateIssue?.issue?.labels.nodes,
});
return;
}
let labels = input?.labels || [];
if (input.removeLabelIds) {
labels = activeBoardItem.labels.filter(
(label) => input.removeLabelIds[0] !== getIdFromGraphQLId(label.id),
);
}
commit(types.UPDATE_BOARD_ITEM_BY_ID, {
itemId: data.updateIssue?.issue?.id || activeBoardItem.id,
itemId: input.id || activeBoardItem.id,
prop: 'labels',
value: data.updateIssue.issue.labels.nodes,
value: labels,
});
},

View File

@ -28,7 +28,6 @@ export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST';
export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST';
export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID';
export const SET_LABELS_LOADING = 'SET_LABELS_LOADING';
export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING';
export const RESET_ISSUES = 'RESET_ISSUES';
export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS';

View File

@ -195,10 +195,6 @@ export default {
Vue.set(state.boardItems[itemId], prop, value);
},
[mutationTypes.SET_LABELS_LOADING](state, isLoading) {
state.isSettingLabels = isLoading;
},
[mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) {
state.isSettingAssignees = isLoading;
},

View File

@ -12,7 +12,6 @@ export default () => ({
listsFlags: {},
boardItemsByListId: {},
backupItemsList: [],
isSettingLabels: false,
isSettingAssignees: false,
pageInfoByListId: {},
boardItems: {},

View File

@ -11,6 +11,7 @@ import { toLabelGid } from '~/sidebar/utils';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const mutationMap = {
@ -48,6 +49,7 @@ export default {
return {
isLabelsSelectInProgress: false,
selectedLabels: this.initiallySelectedLabels,
LabelType,
};
},
methods: {
@ -154,13 +156,11 @@ export default {
:footer-manage-label-title="__('Manage project labels')"
:labels-create-title="__('Create project label')"
:labels-filter-base-path="projectIssuesPath"
:labels-select-in-progress="isLabelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.variant"
:issuable-type="issuableType"
:attr-workspace-path="fullPath"
:label-type="LabelType.project"
data-qa-selector="labels_block"
@onLabelRemove="handleLabelRemove"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
{{ __('None') }}
</labels-select-widget>

View File

@ -1,3 +1,4 @@
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
import { IssuableType } from '~/issue_show/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
@ -29,6 +30,7 @@ import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_conf
import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql';
import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql';
import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql';
import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql';
@ -120,6 +122,17 @@ export const labelsQueries = {
},
};
export const labelsMutations = {
[IssuableType.Issue]: {
mutation: updateIssueLabelsMutation,
mutationName: 'updateIssue',
},
[IssuableType.MergeRequest]: {
mutation: updateMergeRequestLabelsMutation,
mutationName: 'mergeRequestSetLabels',
},
};
export const dateTypes = {
start: 'startDate',
due: 'dueDate',

View File

@ -5,3 +5,8 @@ export const DropdownVariant = {
Standalone: 'standalone',
Embedded: 'embedded',
};
export const LabelType = {
group: 'GroupLabel',
project: 'ProjectLabel',
};

View File

@ -1,20 +1,25 @@
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
import { debounce } from 'lodash';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __, s__, sprintf } from '~/locale';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
import DropdownFooter from './dropdown_footer.vue';
import DropdownHeader from './dropdown_header.vue';
import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils';
export default {
components: {
DropdownContentsLabelsView,
DropdownContentsCreateView,
DropdownHeader,
DropdownFooter,
GlButton,
GlDropdown,
GlDropdownItem,
GlLink,
},
inject: ['allowLabelCreate', 'labelsManagePath'],
props: {
labelsCreateTitle: {
type: String,
@ -63,8 +68,11 @@ export default {
},
attrWorkspacePath: {
type: String,
required: false,
default: undefined,
required: true,
},
labelType: {
type: String,
required: true,
},
},
data() {
@ -72,6 +80,7 @@ export default {
showDropdownContentsCreateView: false,
localSelectedLabels: [...this.selectedLabels],
isDirty: false,
searchKey: '',
};
},
computed: {
@ -113,15 +122,24 @@ export default {
if (newVal) {
this.$refs.dropdown.show();
this.isDirty = false;
this.localSelectedLabels = this.selectedLabels;
} else {
this.$refs.dropdown.hide();
this.setLabels();
}
},
selectedLabels(newVal) {
this.localSelectedLabels = newVal;
if (!this.isDirty) {
this.localSelectedLabels = newVal;
}
},
},
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
beforeDestroy() {
this.debouncedSearchKeyUpdate.cancel();
},
methods: {
toggleDropdownContentsCreateView() {
this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView;
@ -144,6 +162,12 @@ export default {
this.setLabels();
}
},
setSearchKey(value) {
this.searchKey = value;
},
setFocus() {
this.$refs.header.focusInput();
},
},
};
</script>
@ -155,60 +179,41 @@ export default {
class="gl-w-full gl-mt-2"
data-qa-selector="labels_dropdown_content"
@hide="handleDropdownHide"
@shown="setFocus"
>
<template #header>
<div
<dropdown-header
v-if="!isStandalone"
class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
data-testid="dropdown-header"
>
<gl-button
v-if="showDropdownContentsCreateView"
:aria-label="__('Go back')"
variant="link"
size="small"
class="js-btn-back dropdown-header-button gl-p-0"
icon="arrow-left"
data-testid="go-back-button"
@click.stop="toggleDropdownContent"
/>
<span class="gl-flex-grow-1">{{ dropdownTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="small"
class="dropdown-header-button gl-p-0!"
icon="close"
data-testid="close-button"
@click="$emit('closeDropdown')"
/>
</div>
ref="header"
v-model="searchKey"
:labels-create-title="labelsCreateTitle"
:labels-list-title="labelsListTitle"
:show-dropdown-contents-create-view="showDropdownContentsCreateView"
@toggleDropdownContentsCreateView="toggleDropdownContent"
@closeDropdown="$emit('closeDropdown')"
@input="debouncedSearchKeyUpdate"
/>
</template>
<template #default>
<component
:is="dropdownContentsView"
v-model="localSelectedLabels"
:selected-labels="selectedLabels"
:search-key="searchKey"
:allow-multiselect="allowMultiselect"
:issuable-type="issuableType"
:full-path="fullPath"
:attr-workspace-path="attrWorkspacePath"
:label-type="labelType"
@hideCreateView="toggleDropdownContentsCreateView"
/>
</template>
<template #footer>
<div v-if="showDropdownFooter" data-testid="dropdown-footer">
<gl-dropdown-item
v-if="allowLabelCreate"
data-testid="create-label-button"
@click.capture.native.stop="toggleDropdownContent"
>
{{ footerCreateLabelTitle }}
</gl-dropdown-item>
<gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop>
{{ footerManageLabelTitle }}
</gl-dropdown-item>
</div>
<dropdown-footer
v-if="showDropdownFooter"
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
@toggleDropdownContentsCreateView="toggleDropdownContent"
/>
</template>
</gl-dropdown>
</template>

View File

@ -2,10 +2,10 @@
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer';
import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale';
import { labelsQueries } from '~/sidebar/constants';
import createLabelMutation from './graphql/create_label.mutation.graphql';
import { LabelType } from './constants';
const errorMessage = __('Error creating label.');
@ -30,8 +30,11 @@ export default {
},
attrWorkspacePath: {
type: String,
required: false,
default: undefined,
required: true,
},
labelType: {
type: String,
required: true,
},
},
data() {
@ -50,25 +53,13 @@ export default {
return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
},
mutationVariables() {
if (this.issuableType === IssuableType.Epic) {
return {
title: this.labelTitle,
color: this.selectedColor,
groupPath: this.fullPath,
};
}
const attributePath = this.labelType === LabelType.group ? 'groupPath' : 'projectPath';
return this.attrWorkspacePath !== undefined
? {
title: this.labelTitle,
color: this.selectedColor,
groupPath: this.attrWorkspacePath,
}
: {
title: this.labelTitle,
color: this.selectedColor,
projectPath: this.fullPath,
};
return {
title: this.labelTitle,
color: this.selectedColor,
[attributePath]: this.attrWorkspacePath,
};
},
},
methods: {

View File

@ -1,16 +1,8 @@
<script>
import {
GlDropdownForm,
GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
GlIntersectionObserver,
} from '@gitlab/ui';
import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale';
import { labelsQueries } from '~/sidebar/constants';
import LabelItem from './label_item.vue';
@ -20,7 +12,6 @@ export default {
GlDropdownForm,
GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
GlIntersectionObserver,
LabelItem,
},
@ -28,10 +19,6 @@ export default {
prop: 'localSelectedLabels',
},
props: {
selectedLabels: {
type: Array,
required: true,
},
allowMultiselect: {
type: Boolean,
required: true,
@ -48,10 +35,13 @@ export default {
type: String,
required: true,
},
searchKey: {
type: String,
required: true,
},
},
data() {
return {
searchKey: '',
labels: [],
isVisible: false,
};
@ -71,12 +61,6 @@ export default {
return this.searchKey.length === 1 || !this.isVisible;
},
update: (data) => data.workspace?.labels?.nodes || [],
async result() {
if (this.$refs.searchInput) {
await this.$nextTick;
this.$refs.searchInput.focusInput();
}
},
error() {
createFlash({ message: __('Error fetching labels.') });
},
@ -101,12 +85,6 @@ export default {
return Boolean(this.searchKey) && this.visibleLabels.length === 0;
},
},
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
beforeDestroy() {
this.debouncedSearchKeyUpdate.cancel();
},
methods: {
isLabelSelected(label) {
return this.localSelectedLabelsIds.includes(getIdFromGraphQLId(label.id));
@ -153,12 +131,8 @@ export default {
this.$emit('closeDropdown', this.localSelectedLabels);
}
},
setSearchKey(value) {
this.searchKey = value;
},
onDropdownAppear() {
this.isVisible = true;
this.$refs.searchInput.focusInput();
},
},
};
@ -167,14 +141,6 @@ export default {
<template>
<gl-intersection-observer @appear="onDropdownAppear">
<gl-dropdown-form class="labels-select-contents-list js-labels-list">
<gl-search-box-by-type
ref="searchInput"
:value="searchKey"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
data-testid="dropdown-input-field"
@input="debouncedSearchKeyUpdate"
/>
<div ref="labelsListContainer" data-testid="dropdown-content">
<gl-loading-icon
v-if="labelsFetchInProgress"

View File

@ -0,0 +1,35 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlDropdownItem,
},
inject: ['allowLabelCreate', 'labelsManagePath'],
props: {
footerCreateLabelTitle: {
type: String,
required: true,
},
footerManageLabelTitle: {
type: String,
required: true,
},
},
};
</script>
<template>
<div data-testid="dropdown-footer">
<gl-dropdown-item
v-if="allowLabelCreate"
data-testid="create-label-button"
@click.capture.native.stop="$emit('toggleDropdownContentsCreateView')"
>
{{ footerCreateLabelTitle }}
</gl-dropdown-item>
<gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop>
{{ footerManageLabelTitle }}
</gl-dropdown-item>
</div>
</template>

View File

@ -0,0 +1,82 @@
<script>
import { GlButton, GlSearchBoxByType } from '@gitlab/ui';
export default {
components: {
GlButton,
GlSearchBoxByType,
},
model: {
prop: 'searchKey',
},
props: {
labelsCreateTitle: {
type: String,
required: true,
},
labelsListTitle: {
type: String,
required: true,
},
showDropdownContentsCreateView: {
type: Boolean,
required: true,
},
labelsFetchInProgress: {
type: Boolean,
required: false,
default: false,
},
searchKey: {
type: String,
required: true,
},
},
computed: {
dropdownTitle() {
return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle;
},
},
methods: {
focusInput() {
this.$refs.searchInput.focusInput();
},
},
};
</script>
<template>
<div data-testid="dropdown-header">
<div class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!">
<gl-button
v-if="showDropdownContentsCreateView"
:aria-label="__('Go back')"
variant="link"
size="small"
class="js-btn-back dropdown-header-button gl-p-0"
icon="arrow-left"
data-testid="go-back-button"
@click.stop="$emit('toggleDropdownContentsCreateView')"
/>
<span class="gl-flex-grow-1">{{ dropdownTitle }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
size="small"
class="dropdown-header-button gl-p-0!"
icon="close"
data-testid="close-button"
@click="$emit('closeDropdown')"
/>
</div>
<gl-search-box-by-type
v-if="!showDropdownContentsCreateView"
ref="searchInput"
:value="searchKey"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
data-testid="dropdown-input-field"
@input="$emit('input', $event)"
/>
</div>
</template>

View File

@ -1,8 +1,10 @@
<script>
import { MutationOperationMode } from '~/graphql_shared/utils';
import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { labelsQueries } from '~/sidebar/constants';
import { labelsQueries, labelsMutations } from '~/sidebar/constants';
import { DropdownVariant } from './constants';
import DropdownContents from './dropdown_contents.vue';
import DropdownValue from './dropdown_value.vue';
@ -50,16 +52,6 @@ export default {
required: false,
default: DropdownVariant.Sidebar,
},
selectedLabels: {
type: Array,
required: false,
default: () => [],
},
labelsSelectInProgress: {
type: Boolean,
required: false,
default: false,
},
labelsFilterBasePath: {
type: String,
required: false,
@ -95,25 +87,25 @@ export default {
required: false,
default: __('Manage group labels'),
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
issuableType: {
type: String,
required: true,
},
attrWorkspacePath: {
type: String,
required: false,
default: undefined,
required: true,
},
labelType: {
type: String,
required: true,
},
},
data() {
return {
contentIsOnViewport: true,
issuableLabels: [],
labelsSelectInProgress: false,
oldIid: null,
};
},
computed: {
@ -143,9 +135,19 @@ export default {
},
},
},
watch: {
iid(_, oldVal) {
this.oldIid = oldVal;
},
},
methods: {
handleDropdownClose(labels) {
this.$emit('updateSelectedLabels', labels);
if (this.iid !== '') {
this.updateSelectedLabels(this.getUpdateVariables(labels));
} else {
this.$emit('updateSelectedLabels', { labels });
}
this.collapseEditableItem();
},
collapseEditableItem() {
@ -154,6 +156,85 @@ export default {
handleCollapsedValueClick() {
this.$emit('toggleCollapse');
},
getUpdateVariables(labels) {
let labelIds = [];
labelIds = labels.map(({ id }) => id);
const currentIid = this.oldIid || this.iid;
const updateVariables = {
iid: currentIid,
projectPath: this.fullPath,
labelIds,
};
switch (this.issuableType) {
case IssuableType.Issue:
return updateVariables;
case IssuableType.MergeRequest:
updateVariables.operationMode = MutationOperationMode.Replace;
return updateVariables;
default:
return {};
}
},
updateSelectedLabels(inputVariables) {
this.labelsSelectInProgress = true;
this.$apollo
.mutate({
mutation: labelsMutations[this.issuableType].mutation,
variables: { input: inputVariables },
})
.then(({ data }) => {
const { mutationName } = labelsMutations[this.issuableType];
if (data[mutationName]?.errors?.length) {
throw new Error();
}
this.$emit('updateSelectedLabels', {
id: data[mutationName]?.[this.issuableType].id,
labels: data[mutationName]?.[this.issuableType].labels?.nodes,
});
})
.catch((error) =>
createFlash({
message: __('An error occurred while updating labels.'),
captureError: true,
error,
}),
)
.finally(() => {
this.labelsSelectInProgress = false;
});
},
getRemoveVariables(labelId) {
const removeVariables = {
iid: this.iid,
projectPath: this.fullPath,
};
switch (this.issuableType) {
case IssuableType.Issue:
return {
...removeVariables,
removeLabelIds: [labelId],
};
case IssuableType.MergeRequest:
return {
...removeVariables,
labelIds: [labelId],
operationMode: MutationOperationMode.Remove,
};
default:
return {};
}
},
handleLabelRemove(labelId) {
this.updateSelectedLabels(this.getRemoveVariables(labelId));
this.$emit('onLabelRemove', labelId);
},
isDropdownVariantSidebar,
isDropdownVariantStandalone,
isDropdownVariantEmbedded,
@ -180,6 +261,7 @@ export default {
:title="__('Labels')"
:loading="isLoading"
:can-edit="allowLabelEdit"
@open="oldIid = null"
>
<template #collapsed>
<dropdown-value
@ -188,7 +270,7 @@ export default {
:allow-label-remove="allowLabelRemove"
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
@onLabelRemove="$emit('onLabelRemove', $event)"
@onLabelRemove="handleLabelRemove"
>
<slot></slot>
</dropdown-value>
@ -201,7 +283,7 @@ export default {
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
class="gl-mb-2"
@onLabelRemove="$emit('onLabelRemove', $event)"
@onLabelRemove="handleLabelRemove"
>
<slot></slot>
</dropdown-value>
@ -212,12 +294,13 @@ export default {
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
:labels-create-title="labelsCreateTitle"
:selected-labels="selectedLabels"
:selected-labels="issuableLabels"
:variant="variant"
:issuable-type="issuableType"
:is-visible="edit"
:full-path="fullPath"
:attr-workspace-path="attrWorkspacePath"
:label-type="labelType"
@setLabels="handleDropdownClose"
@closeDropdown="collapseEditableItem"
/>
@ -233,10 +316,12 @@ export default {
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
:labels-create-title="labelsCreateTitle"
:selected-labels="selectedLabels"
:selected-labels="issuableLabels"
:variant="variant"
:issuable-type="issuableType"
:full-path="fullPath"
:attr-workspace-path="attrWorkspacePath"
:label-type="labelType"
@setLabels="handleDropdownClose"
/>
</div>

View File

@ -6,6 +6,8 @@ class Projects::CommitsController < Projects::ApplicationController
include ExtractsPath
include RendersCommits
COMMITS_DEFAULT_LIMIT = 40
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
around_action :allow_gitaly_ref_name_caching
before_action :require_non_empty_project
@ -63,7 +65,9 @@ class Projects::CommitsController < Projects::ApplicationController
def set_commits
render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present?
@limit = (params[:limit] || 40).to_i
limit = params[:limit].to_i
@limit = limit > 0 ? limit : COMMITS_DEFAULT_LIMIT # limit can only ever be a positive number
@offset = (params[:offset] || 0).to_i
search = params[:search]
author = params[:author]

View File

@ -284,9 +284,7 @@ module IssuablesHelper
end
def issuables_count_for_state(issuable_type, state)
store_in_cache = parent.is_a?(Group) ? parent.cached_issues_state_count_enabled? : false
Gitlab::IssuablesCountForState.new(finder, store_in_redis_cache: store_in_cache)[state]
Gitlab::IssuablesCountForState.new(finder, store_in_redis_cache: true)[state]
end
def close_issuable_path(issuable)
@ -442,7 +440,7 @@ module IssuablesHelper
end
def format_count(issuable_type, count, threshold)
if issuable_type == :issues && parent.is_a?(Group) && parent.cached_issues_state_count_enabled?
if issuable_type == :issues && parent.is_a?(Group)
format_cached_count(threshold, count)
else
number_with_delimiter(count)

View File

@ -61,9 +61,9 @@ class ErrorTracking::ErrorEvent < ApplicationRecord
pre_context = entry['pre_context']
post_context = entry['post_context']
context += lines_with_position(pre_context, error_line_no - pre_context.size)
context += lines_with_position(pre_context, error_line_no - pre_context.size) if pre_context
context += lines_with_position([error_line], error_line_no)
context += lines_with_position(post_context, error_line_no + 1)
context += lines_with_position(post_context, error_line_no + 1) if post_context
context.reject(&:blank?)
end

View File

@ -760,10 +760,6 @@ class Group < Namespace
Timelog.in_group(self)
end
def cached_issues_state_count_enabled?
Feature.enabled?(:cached_issues_state_count, self, default_enabled: :yaml)
end
def organizations
::CustomerRelations::Organization.where(group_id: self.id)
end

View File

@ -2582,18 +2582,21 @@ class Project < ApplicationRecord
config = Gitlab.config.incoming_email
wildcard = Gitlab::IncomingEmail::WILDCARD_PLACEHOLDER
config.address&.gsub(wildcard, "#{full_path_slug}-#{id}-issue-")
config.address&.gsub(wildcard, "#{full_path_slug}-#{default_service_desk_suffix}")
end
def service_desk_custom_address
return unless Gitlab::ServiceDeskEmail.enabled?
key = service_desk_setting&.project_key
return unless key.present?
key = service_desk_setting&.project_key || default_service_desk_suffix
Gitlab::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}")
end
def default_service_desk_suffix
"#{id}-issue-"
end
def root_namespace
if namespace.has_parent?
namespace.root_ancestor

View File

@ -14,7 +14,7 @@
%li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.")
- if group.paid?
.gl-alert.gl-alert-info.gl-mb-5{ data: { testid: 'group-to-transfer-has-linked-subscription-alert' } }
.gl-alert.gl-alert-info.gl-mb-5
= sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body
= html_escape(_("This group can't be transfered because it is linked to a subscription. To transfer this group, %{linkStart}link the subscription%{linkEnd} with a different group.")) % { linkStart: "<a href=\"#{help_page_path('subscriptions/index', anchor: 'change-the-linked-namespace')}\">".html_safe, linkEnd: '</a>'.html_safe }

View File

@ -1,8 +0,0 @@
---
name: cached_issues_state_count
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67418
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/333089
milestone: '14.3'
type: development
group: group::product planning
default_enabled: false

View File

@ -102,14 +102,12 @@ track_action: "click_button" })
### Implement Vue component tracking
For custom event tracking, use a Vue `mixin` in components. Vue `mixin` exposes the `Tracking.event`
static method and the `track` method called from components or templates. You can specify tracking
options in `data` or `computed`. These options override any defaults and allow the values to be dynamic
from props or based on state.
static method and the `track` method. You can specify tracking options in `data` or `computed`.
These options override any defaults and allow the values to be dynamic from props or based on state.
Default options are passed when an event is tracked from the component. If you don't specify an option,
the default `document.body.dataset.page` is used. The default options are:
Several default options are passed when an event is tracked from the component:
- `category`
- `category`: If you don't specify, by default `document.body.dataset.page` is used.
- `label`
- `property`
- `value`

View File

@ -166,13 +166,13 @@ To edit the custom email display name:
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/284656) in GitLab 13.8.
It is possible to customize the email address used by Service Desk. To do this, you must configure
both a [custom mailbox](#configuring-a-custom-mailbox) and a
a [custom mailbox](#configuring-a-custom-mailbox). If you want you can also configure a
[custom suffix](#configuring-a-custom-email-address-suffix).
#### Configuring a custom mailbox
NOTE:
On GitLab.com a custom mailbox is already configured with `contact-project+%{key}@incoming.gitlab.com` as the email address, so you only have to configure the
On GitLab.com a custom mailbox is already configured with `contact-project+%{key}@incoming.gitlab.com` as the email address, you can still configure the
[custom suffix](#configuring-a-custom-email-address-suffix) in project settings.
Using the `service_desk_email` configuration, you can customize the mailbox
@ -271,6 +271,8 @@ For example, suppose the `mygroup/myproject` project Service Desk settings has t
The Service Desk email address for this project is: `contact+mygroup-myproject-support@example.com`.
The [incoming email](../../administration/incoming_email.md) address still works.
If you don't configure the custom suffix, the default project identification will be used for identifying the project. You can see that email address in the project settings.
## Using Service Desk
You can use Service Desk to [create an issue](#as-an-end-user-issue-creator) or [respond to one](#as-a-responder-to-the-issue).

View File

@ -15,16 +15,14 @@ module Gitlab
PROJECT_KEY_PATTERN = /\A(?<slug>.+)-(?<key>[a-z0-9_]+)\z/.freeze
def initialize(mail, mail_key, service_desk_key: nil)
if service_desk_key
mail_key ||= service_desk_key
@service_desk_key = service_desk_key
end
super(mail, mail_key)
if service_desk_key.present?
@service_desk_key = service_desk_key
elsif !mail_key&.include?('/') && (matched = HANDLER_REGEX.match(mail_key.to_s))
@project_slug = matched[:project_slug]
@project_id = matched[:project_id]&.to_i
elsif matched = HANDLER_REGEX_LEGACY.match(mail_key.to_s)
@project_path = matched[:project_path]
end
match_project_slug || match_legacy_project_slug
end
def can_handle?
@ -42,15 +40,29 @@ module Gitlab
end
end
def match_project_slug
return if mail_key&.include?('/')
return unless matched = HANDLER_REGEX.match(mail_key.to_s)
@project_slug = matched[:project_slug]
@project_id = matched[:project_id]&.to_i
end
def match_legacy_project_slug
return unless matched = HANDLER_REGEX_LEGACY.match(mail_key.to_s)
@project_path = matched[:project_path]
end
def metrics_event
:receive_email_service_desk
end
def project
strong_memoize(:project) do
@project = service_desk_key ? project_from_key : super
@project = nil unless @project&.service_desk_enabled?
@project
project_record = super
project_record ||= project_from_key if service_desk_key
project_record&.service_desk_enabled? ? project_record : nil
end
end

View File

@ -59,6 +59,9 @@ module QA
}
end
# Get import status
#
# @return [String]
def import_status
response = get(Runtime::API::Request.new(api_client, "/bulk_imports/#{import_id}").url)
@ -69,6 +72,15 @@ module QA
parse_body(response)[:status]
end
# Get import details
#
# @return [Array]
def import_details
response = get(Runtime::API::Request.new(api_client, "/bulk_imports/#{import_id}/entities").url)
parse_body(response)
end
private
def transform_api_resource(api_resource)

View File

@ -48,6 +48,10 @@ module QA
imported_group.reload!.projects
end
let(:import_details) do
imported_group.import_details.find { |entity| entity[:destination_name] == source_project.name }
end
before do
Runtime::Feature.enable(:bulk_import_projects)
Runtime::Feature.enable(:top_level_group_creation_enabled) if staging?
@ -70,6 +74,7 @@ module QA
testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/quality/test_cases/2297'
) do
expect { imported_group.import_status }.to eventually_eq('finished').within(import_wait_duration)
expect(import_details[:failures]).to be_empty, "Expected to not have import errors, was: #{import_details}"
aggregate_failures do
expect(imported_projects.count).to eq(1)
@ -109,6 +114,7 @@ module QA
testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/quality/test_cases/2325'
) do
expect { imported_group.import_status }.to eventually_eq('finished').within(import_wait_duration)
expect(import_details[:failures]).to be_empty, "Expected to not have import errors, was: #{import_details}"
aggregate_failures do
expect(imported_issues.count).to eq(1)

View File

@ -67,6 +67,29 @@ RSpec.describe Projects::CommitsController do
end
end
context "with an invalid limit" do
let(:id) { "master/README.md" }
it "uses the default limit" do
expect_any_instance_of(Repository).to receive(:commits).with(
"master",
path: "README.md",
limit: described_class::COMMITS_DEFAULT_LIMIT,
offset: 0
).and_call_original
get(:show,
params: {
namespace_id: project.namespace,
project_id: project,
id: id,
limit: "foo"
})
expect(response).to be_successful
end
end
context "when the ref name ends in .atom" do
context "when the ref does not exist with the suffix" do
before do

View File

@ -63,5 +63,9 @@ FactoryBot.define do
level { 'error' }
occurred_at { Time.now.iso8601 }
payload { Gitlab::Json.parse(File.read(Rails.root.join('spec/fixtures/', 'error_tracking/parsed_event.json'))) }
trait :browser do
payload { Gitlab::Json.parse(File.read(Rails.root.join('spec/fixtures/', 'error_tracking/browser_event.json'))) }
end
end
end

View File

@ -94,7 +94,7 @@ RSpec.describe 'Admin Appearance' do
sign_in(admin)
gitlab_enable_admin_mode_sign_in(admin)
visit new_project_path
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
expect_custom_new_project_appearance(appearance)
end

View File

@ -83,6 +83,18 @@ RSpec.describe 'Group issues page' do
end
end
it 'truncates issue counts if over the threshold', :clean_gitlab_redis_cache do
allow(Rails.cache).to receive(:read).and_call_original
allow(Rails.cache).to receive(:read).with(
['group', group.id, 'issues'],
{ expires_in: Gitlab::IssuablesCountForState::CACHE_EXPIRES_IN }
).and_return({ opened: 1050, closed: 500, all: 1550 })
visit issues_group_path(group)
expect(page).to have_text('Open 1.1k Closed 500 All 1.6k')
end
context 'when project is archived' do
before do
::Projects::UpdateService.new(project, user_in_group, archived: true).execute
@ -94,41 +106,6 @@ RSpec.describe 'Group issues page' do
expect(page).not_to have_content issue.title[0..80]
end
end
context 'when cached issues state count is enabled', :clean_gitlab_redis_cache do
before do
stub_feature_flags(cached_issues_state_count: true)
end
it 'truncates issue counts if over the threshold' do
allow(Rails.cache).to receive(:read).and_call_original
allow(Rails.cache).to receive(:read).with(
['group', group.id, 'issues'],
{ expires_in: Gitlab::IssuablesCountForState::CACHE_EXPIRES_IN }
).and_return({ opened: 1050, closed: 500, all: 1550 })
visit issues_group_path(group)
expect(page).to have_text('Open 1.1k Closed 500 All 1.6k')
end
end
context 'when cached issues state count is disabled', :clean_gitlab_redis_cache do
before do
stub_feature_flags(cached_issues_state_count: false)
end
it 'does not truncate counts if they are over the threshold' do
allow_next_instance_of(IssuesFinder) do |finder|
allow(finder).to receive(:count_by_state).and_return(true)
.and_return({ opened: 1050, closed: 500, all: 1550 })
end
visit issues_group_path(group)
expect(page).to have_text('Open 1,050 Closed 500 All 1,550')
end
end
end
context 'projects with issues disabled' do

View File

@ -21,7 +21,7 @@ RSpec.describe 'Project variables', :js do
click_button('Add variable')
page.within('#add-ci-variable') do
find('[data-qa-selector="ci_variable_key_field"] input').set('akey') # rubocop:disable QA/SelectorUsage
fill_in 'Key', with: 'akey'
find('#ci-variable-value').set('akey_value')
find('[data-testid="environment-scope"]').click
find('[data-testid="ci-environment-search"]').set('review/*')

View File

@ -31,7 +31,7 @@ RSpec.describe 'Import/Export - project import integration test', :js do
it 'user imports an exported project successfully', :sidekiq_might_not_need_inline do
visit new_project_path
click_import_project
click_link 'Import project'
click_link 'GitLab export'
fill_in :name, with: 'Test Project Name', visible: true
@ -50,7 +50,7 @@ RSpec.describe 'Import/Export - project import integration test', :js do
visit new_project_path
click_import_project
click_link 'Import project'
click_link 'GitLab export'
fill_in :name, with: project.name, visible: true
attach_file('file', file)
@ -61,8 +61,4 @@ RSpec.describe 'Import/Export - project import integration test', :js do
end
end
end
def click_import_project
find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage
end
end

View File

@ -23,7 +23,7 @@ RSpec.describe 'New project', :js do
)
visit new_project_path
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
expect(page).to have_content 'Other visibility settings have been disabled by the administrator.'
end
@ -34,7 +34,7 @@ RSpec.describe 'New project', :js do
)
visit new_project_path
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
expect(page).to have_content 'Visibility settings have been disabled by the administrator.'
end
@ -49,14 +49,14 @@ RSpec.describe 'New project', :js do
it 'shows "New project" page', :js do
visit new_project_path
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
expect(page).to have_content('Project name')
expect(page).to have_content('Project URL')
expect(page).to have_content('Project slug')
click_link('New project')
find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Import project'
expect(page).to have_link('GitHub')
expect(page).to have_link('Bitbucket')
@ -69,7 +69,7 @@ RSpec.describe 'New project', :js do
before do
visit new_project_path
find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Import project'
end
it 'has Manifest file' do
@ -83,7 +83,7 @@ RSpec.describe 'New project', :js do
stub_application_setting(default_project_visibility: level)
visit new_project_path
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
page.within('#blank-project-pane') do
expect(find_field("project_visibility_level_#{level}")).to be_checked
end
@ -91,7 +91,7 @@ RSpec.describe 'New project', :js do
it "saves visibility level #{level} on validation error" do
visit new_project_path
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
choose(key)
click_button('Create project')
@ -111,7 +111,7 @@ RSpec.describe 'New project', :js do
context 'when admin mode is enabled', :enable_admin_mode do
it 'has private selected' do
visit new_project_path(namespace_id: group.id)
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
page.within('#blank-project-pane') do
expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked
@ -138,7 +138,7 @@ RSpec.describe 'New project', :js do
context 'when admin mode is enabled', :enable_admin_mode do
it 'has private selected' do
visit new_project_path(namespace_id: group.id, project: { visibility_level: Gitlab::VisibilityLevel::PRIVATE })
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
page.within('#blank-project-pane') do
expect(find_field("project_visibility_level_#{Gitlab::VisibilityLevel::PRIVATE}")).to be_checked
@ -159,7 +159,7 @@ RSpec.describe 'New project', :js do
context 'Readme selector' do
it 'shows the initialize with Readme checkbox on "Blank project" tab' do
visit new_project_path
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
expect(page).to have_css('input#project_initialize_with_readme')
expect(page).to have_content('Initialize repository with a README')
@ -167,7 +167,7 @@ RSpec.describe 'New project', :js do
it 'does not show the initialize with Readme checkbox on "Create from template" tab' do
visit new_project_path
find('[data-qa-panel-name="create_from_template"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create from template'
first('.choose-template').click
page.within '.project-fields-form' do
@ -178,7 +178,7 @@ RSpec.describe 'New project', :js do
it 'does not show the initialize with Readme checkbox on "Import project" tab' do
visit new_project_path
find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Import project'
first('.js-import-git-toggle-button').click
page.within '#import-project-pane' do
@ -192,7 +192,7 @@ RSpec.describe 'New project', :js do
context 'with user namespace' do
before do
visit new_project_path
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
end
it 'selects the user namespace' do
@ -208,7 +208,7 @@ RSpec.describe 'New project', :js do
before do
group.add_owner(user)
visit new_project_path(namespace_id: group.id)
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
end
it 'selects the group namespace' do
@ -225,7 +225,7 @@ RSpec.describe 'New project', :js do
before do
group.add_maintainer(user)
visit new_project_path(namespace_id: subgroup.id)
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
end
it 'selects the group namespace' do
@ -245,7 +245,7 @@ RSpec.describe 'New project', :js do
internal_group.add_owner(user)
private_group.add_owner(user)
visit new_project_path(namespace_id: public_group.id)
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
end
it 'enables the correct visibility options' do
@ -275,7 +275,7 @@ RSpec.describe 'New project', :js do
context 'Import project options', :js do
before do
visit new_project_path
find('[data-qa-panel-name="import_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Import project'
end
context 'from git repository url, "Repo by URL"' do
@ -351,7 +351,7 @@ RSpec.describe 'New project', :js do
before do
group.add_developer(user)
visit new_project_path(namespace_id: group.id)
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
end
it 'selects the group namespace' do

View File

@ -54,7 +54,7 @@ RSpec.describe 'Service Desk Setting', :js, :clean_gitlab_redis_cache do
wait_for_requests
project.reload
expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_incoming_address)
expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_custom_address)
page.within '#js-service-desk' do
fill_in('service-desk-project-suffix', with: 'foo')

View File

@ -14,7 +14,7 @@ RSpec.describe 'User creates a project', :js do
it 'creates a new project' do
visit(new_project_path)
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
fill_in(:project_name, with: 'Empty')
expect(page).to have_checked_field 'Initialize repository with a README'
@ -38,7 +38,7 @@ RSpec.describe 'User creates a project', :js do
visit(new_project_path)
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
fill_in(:project_name, with: 'With initial commits')
expect(page).to have_checked_field 'Initialize repository with a README'
@ -67,7 +67,7 @@ RSpec.describe 'User creates a project', :js do
it 'creates a new project' do
visit(new_project_path)
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
fill_in :project_name, with: 'A Subgroup Project'
fill_in :project_path, with: 'a-subgroup-project'
@ -96,7 +96,7 @@ RSpec.describe 'User creates a project', :js do
it 'creates a new project' do
visit(new_project_path)
find('[data-qa-panel-name="blank_project"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create blank project'
fill_in :project_name, with: 'a-new-project'
fill_in :project_path, with: 'a-new-project'

View File

@ -16,7 +16,7 @@ RSpec.describe 'Project' do
shared_examples 'creates from template' do |template, sub_template_tab = nil|
it "is created from template", :js do
find('[data-qa-panel-name="create_from_template"]').click # rubocop:disable QA/SelectorUsage
click_link 'Create from template'
find(".project-template #{sub_template_tab}").click if sub_template_tab
find("label[for=#{template.name}]").click
fill_in("project_name", with: template.name)

View File

@ -0,0 +1,27 @@
Return-Path: <jake@adventuretime.ooo>
Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <support+project_slug-project_key@example.com>; Thu, 13 Jun 2013 14:03:48 -0700
Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
Date: Thu, 13 Jun 2013 17:03:48 -0400
From: Jake the Dog <jake@adventuretime.ooo>
To: support+email-test-project_id-issue-@example.com
Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
Subject: The message subject! @all
Mime-Version: 1.0
Content-Type: text/plain;
charset=ISO-8859-1
Content-Transfer-Encoding: 7bit
X-Sieve: CMU Sieve 2.2
X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu,
13 Jun 2013 14:03:48 -0700 (PDT)
X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1
Service desk stuff!
```
a = b
```
/label ~label1
/assign @user1
/close

View File

@ -0,0 +1 @@
{"sdk":{"name":"sentry.javascript.browser","version":"5.7.1","packages":[{"name":"npm:@sentry/browser","version":"5.7.1"}],"integrations":["InboundFilters","FunctionToString","TryCatch","Breadcrumbs","GlobalHandlers","LinkedErrors","UserAgent","Dedupe","ExtraErrorData","ReportingObserver","RewriteFrames","Vue"]},"level":"error","request":{"url":"http://localhost:5444/","headers":{"User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:91.0) Gecko/20100101 Firefox/91.0"}},"event_id":"6a32dc45cd924196930e06aa21b48c8d","platform":"javascript","exception":{"values":[{"type":"TypeError","value":"Cannot read property 'filter' of undefined","mechanism":{"type":"generic","handled":true},"stacktrace":{"frames":[{"colno":34,"in_app":true,"lineno":6395,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"hydrate"},{"colno":57,"in_app":true,"lineno":6362,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"hydrate"},{"colno":13,"in_app":true,"lineno":3115,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"init"},{"colno":10,"in_app":true,"lineno":8399,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"Vue.prototype.$mount"},{"colno":3,"in_app":true,"lineno":4061,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"mountComponent"},{"colno":12,"in_app":true,"lineno":4456,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"Watcher"},{"colno":25,"in_app":true,"lineno":4467,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"get"},{"colno":10,"in_app":true,"lineno":4048,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"updateComponent"},{"colno":19,"in_app":true,"lineno":3933,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"lifecycleMixin/Vue.prototype._update"},{"colno":24,"in_app":true,"lineno":6477,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"patch"},{"colno":34,"in_app":true,"lineno":6395,"filename":"webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js","function":"hydrate"},{"colno":64,"in_app":true,"lineno":78,"filename":"webpack-internal:///./node_modules/babel-loader/lib/index.js?!./node_modules/vue-loader/lib/index.js?!./pages/index.vue?vue&type=script&lang=js&","function":"data"}]}}]},"environment":"development"}

View File

@ -105,6 +105,7 @@ describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => {
describe('when labels are updated over existing labels', () => {
const testLabelsPayload = [
{ id: 5, set: true },
{ id: 6, set: false },
{ id: 7, set: true },
];
const expectedLabels = [{ id: 5 }, { id: 7 }];

View File

@ -27,6 +27,7 @@ import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'
import actions from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import mutations from '~/boards/stores/mutations';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import {
mockLists,
@ -1572,12 +1573,13 @@ describe('setActiveIssueLabels', () => {
const getters = { activeBoardItem: mockIssue };
const testLabelIds = labels.map((label) => label.id);
const input = {
addLabelIds: testLabelIds,
labelIds: testLabelIds,
removeLabelIds: [],
projectPath: 'h/b',
labels,
};
it('should assign labels on success, and sets loading state for labels', (done) => {
it('should assign labels on success', (done) => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } });
@ -1593,14 +1595,6 @@ describe('setActiveIssueLabels', () => {
input,
{ ...state, ...getters },
[
{
type: types.SET_LABELS_LOADING,
payload: true,
},
{
type: types.SET_LABELS_LOADING,
payload: false,
},
{
type: types.UPDATE_BOARD_ITEM_BY_ID,
payload,
@ -1618,6 +1612,64 @@ describe('setActiveIssueLabels', () => {
await expect(actions.setActiveIssueLabels({ getters }, input)).rejects.toThrow(Error);
});
describe('labels_widget FF on', () => {
beforeEach(() => {
window.gon = {
features: { labelsWidget: true },
};
getters.activeBoardItem = { ...mockIssue, labels };
});
afterEach(() => {
window.gon = {
features: {},
};
});
it('should assign labels', () => {
const payload = {
itemId: getters.activeBoardItem.id,
prop: 'labels',
value: labels,
};
testAction(
actions.setActiveIssueLabels,
input,
{ ...state, ...getters },
[
{
type: types.UPDATE_BOARD_ITEM_BY_ID,
payload,
},
],
[],
);
});
it('should remove label', () => {
const payload = {
itemId: getters.activeBoardItem.id,
prop: 'labels',
value: [labels[1]],
};
testAction(
actions.setActiveIssueLabels,
{ ...input, removeLabelIds: [getIdFromGraphQLId(labels[0].id)] },
{ ...state, ...getters },
[
{
type: types.UPDATE_BOARD_ITEM_BY_ID,
payload,
},
],
[],
);
});
});
});
describe('setActiveItemSubscribed', () => {

View File

@ -51,6 +51,7 @@ describe('DropdownContentsCreateView', () => {
const createComponent = ({
mutationHandler = createLabelSuccessHandler,
issuableType = IssuableType.Issue,
labelType = 'ProjectLabel',
} = {}) => {
const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]);
mockApollo.clients.defaultClient.cache.writeQuery({
@ -68,6 +69,8 @@ describe('DropdownContentsCreateView', () => {
propsData: {
issuableType,
fullPath: '',
attrWorkspacePath: '',
labelType,
},
});
};
@ -174,7 +177,7 @@ describe('DropdownContentsCreateView', () => {
});
it('calls a mutation with `groupPath` variable on the epic', () => {
createComponent({ issuableType: IssuableType.Epic });
createComponent({ issuableType: IssuableType.Epic, labelType: 'GroupLabel' });
fillLabelAttributes();
findCreateButton().vm.$emit('click');

View File

@ -43,6 +43,7 @@ describe('DropdownContentsLabelsView', () => {
initialState = mockConfig,
queryHandler = successfulQueryHandler,
injected = {},
searchKey = '',
} = {}) => {
const mockApollo = createMockApollo([[projectLabelsQuery, queryHandler]]);
@ -57,6 +58,7 @@ describe('DropdownContentsLabelsView', () => {
...initialState,
localSelectedLabels,
issuableType: IssuableType.Issue,
searchKey,
},
stubs: {
GlSearchBoxByType,
@ -68,7 +70,6 @@ describe('DropdownContentsLabelsView', () => {
wrapper.destroy();
});
const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
const findLabels = () => wrapper.findAllComponents(LabelItem);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findObserver = () => wrapper.findComponent(GlIntersectionObserver);
@ -81,12 +82,6 @@ describe('DropdownContentsLabelsView', () => {
}
describe('when loading labels', () => {
it('renders disabled search input field', async () => {
createComponent();
await makeObserverAppear();
expect(findSearchInput().props('disabled')).toBe(true);
});
it('renders loading icon', async () => {
createComponent();
await makeObserverAppear();
@ -107,10 +102,6 @@ describe('DropdownContentsLabelsView', () => {
await waitForPromises();
});
it('renders enabled search input field', async () => {
expect(findSearchInput().props('disabled')).toBe(false);
});
it('does not render loading icon', async () => {
expect(findLoadingIcon().exists()).toBe(false);
});
@ -132,9 +123,9 @@ describe('DropdownContentsLabelsView', () => {
},
},
}),
searchKey: '123',
});
await makeObserverAppear();
findSearchInput().vm.$emit('input', '123');
await waitForPromises();
await nextTick();

View File

@ -4,6 +4,8 @@ import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_w
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
import DropdownHeader from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue';
import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue';
import { mockLabels } from './mock_data';
@ -26,7 +28,7 @@ const GlDropdownStub = {
describe('DropdownContent', () => {
let wrapper;
const createComponent = ({ props = {}, injected = {}, data = {} } = {}) => {
const createComponent = ({ props = {}, data = {} } = {}) => {
wrapper = shallowMount(DropdownContents, {
propsData: {
labelsCreateTitle: 'test',
@ -39,6 +41,8 @@ describe('DropdownContent', () => {
variant: 'sidebar',
issuableType: 'issue',
fullPath: 'test',
labelType: 'ProjectLabel',
attrWorkspacePath: 'path',
...props,
},
data() {
@ -46,11 +50,6 @@ describe('DropdownContent', () => {
...data,
};
},
provide: {
allowLabelCreate: true,
labelsManagePath: 'foo/bar',
...injected,
},
stubs: {
GlDropdown: GlDropdownStub,
},
@ -63,13 +62,10 @@ describe('DropdownContent', () => {
const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView);
const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView);
const findDropdownHeader = () => wrapper.findComponent(DropdownHeader);
const findDropdownFooter = () => wrapper.findComponent(DropdownFooter);
const findDropdown = () => wrapper.findComponent(GlDropdownStub);
const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
const findDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]');
const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]');
const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]');
it('calls dropdown `show` method on `isVisible` prop change', async () => {
createComponent();
await wrapper.setProps({
@ -136,6 +132,16 @@ describe('DropdownContent', () => {
expect(findDropdownHeader().exists()).toBe(true);
});
it('sets searchKey for labels view on input event from header', async () => {
createComponent();
expect(wrapper.vm.searchKey).toEqual('');
findDropdownHeader().vm.$emit('input', '123');
await nextTick();
expect(findLabelsView().props('searchKey')).toEqual('123');
});
describe('Create view', () => {
beforeEach(() => {
createComponent({ data: { showDropdownContentsCreateView: true } });
@ -149,16 +155,8 @@ describe('DropdownContent', () => {
expect(findDropdownFooter().exists()).toBe(false);
});
it('does not render create label button', () => {
expect(findCreateLabelButton().exists()).toBe(false);
});
it('renders go back button', () => {
expect(findGoBackButton().exists()).toBe(true);
});
it('changes the view to Labels view on back button click', async () => {
findGoBackButton().vm.$emit('click', new MouseEvent('click'));
it('changes the view to Labels view on `toggleDropdownContentsCreateView` event', async () => {
findDropdownHeader().vm.$emit('toggleDropdownContentsCreateView');
await nextTick();
expect(findCreateView().exists()).toBe(false);
@ -198,32 +196,5 @@ describe('DropdownContent', () => {
expect(findDropdownFooter().exists()).toBe(true);
});
it('does not render go back button', () => {
expect(findGoBackButton().exists()).toBe(false);
});
it('does not render create label button if `allowLabelCreate` is false', () => {
createComponent({ injected: { allowLabelCreate: false } });
expect(findCreateLabelButton().exists()).toBe(false);
});
describe('when `allowLabelCreate` is true', () => {
beforeEach(() => {
createComponent();
});
it('renders create label button', () => {
expect(findCreateLabelButton().exists()).toBe(true);
});
it('changes the view to Create on create label button click', async () => {
findCreateLabelButton().trigger('click');
await nextTick();
expect(findLabelsView().exists()).toBe(false);
});
});
});
});

View File

@ -0,0 +1,57 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue';
describe('DropdownFooter', () => {
let wrapper;
const createComponent = ({ props = {}, injected = {} } = {}) => {
wrapper = shallowMount(DropdownFooter, {
propsData: {
footerCreateLabelTitle: 'create',
footerManageLabelTitle: 'manage',
...props,
},
provide: {
allowLabelCreate: true,
labelsManagePath: 'foo/bar',
...injected,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]');
describe('Labels view', () => {
beforeEach(() => {
createComponent();
});
it('does not render create label button if `allowLabelCreate` is false', () => {
createComponent({ injected: { allowLabelCreate: false } });
expect(findCreateLabelButton().exists()).toBe(false);
});
describe('when `allowLabelCreate` is true', () => {
beforeEach(() => {
createComponent();
});
it('renders create label button', () => {
expect(findCreateLabelButton().exists()).toBe(true);
});
it('emits `toggleDropdownContentsCreateView` event on create label button click', async () => {
findCreateLabelButton().trigger('click');
await nextTick();
expect(wrapper.emitted('toggleDropdownContentsCreateView')).toEqual([[]]);
});
});
});
});

View File

@ -0,0 +1,75 @@
import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import DropdownHeader from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue';
describe('DropdownHeader', () => {
let wrapper;
const createComponent = ({
showDropdownContentsCreateView = false,
labelsFetchInProgress = false,
} = {}) => {
wrapper = extendedWrapper(
shallowMount(DropdownHeader, {
propsData: {
showDropdownContentsCreateView,
labelsFetchInProgress,
labelsCreateTitle: 'Create label',
labelsListTitle: 'Select label',
searchKey: '',
},
stubs: {
GlSearchBoxByType,
},
}),
);
};
afterEach(() => {
wrapper.destroy();
});
const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
const findGoBackButton = () => wrapper.findByTestId('go-back-button');
beforeEach(() => {
createComponent();
});
describe('Create view', () => {
beforeEach(() => {
createComponent({ showDropdownContentsCreateView: true });
});
it('renders go back button', () => {
expect(findGoBackButton().exists()).toBe(true);
});
it('does not render search input field', async () => {
expect(findSearchInput().exists()).toBe(false);
});
});
describe('Labels view', () => {
beforeEach(() => {
createComponent();
});
it('does not render go back button', () => {
expect(findGoBackButton().exists()).toBe(false);
});
it.each`
labelsFetchInProgress | disabled
${true} | ${true}
${false} | ${false}
`(
'when labelsFetchInProgress is $labelsFetchInProgress, renders search input with disabled prop to $disabled',
({ labelsFetchInProgress, disabled }) => {
createComponent({ labelsFetchInProgress });
expect(findSearchInput().props('disabled')).toBe(disabled);
},
);
});
});

View File

@ -41,6 +41,7 @@ describe('LabelsSelectRoot', () => {
propsData: {
...config,
issuableType: IssuableType.Issue,
labelType: 'ProjectLabel',
},
stubs: {
SidebarEditableItem,
@ -121,11 +122,11 @@ describe('LabelsSelectRoot', () => {
});
});
it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event', async () => {
it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if iid is not set', async () => {
const label = { id: 'gid://gitlab/ProjectLabel/1' };
createComponent();
createComponent({ config: { ...mockConfig, iid: undefined } });
findDropdownContents().vm.$emit('setLabels', [label]);
expect(wrapper.emitted('updateSelectedLabels')).toEqual([[[label]]]);
expect(wrapper.emitted('updateSelectedLabels')).toEqual([[{ labels: [label] }]]);
});
});

View File

@ -40,12 +40,12 @@ export const mockConfig = {
labelsListTitle: 'Assign labels',
labelsCreateTitle: 'Create label',
variant: 'sidebar',
selectedLabels: [mockRegularLabel, mockScopedLabel],
labelsSelectInProgress: false,
labelsFilterBasePath: '/gitlab-org/my-project/issues',
labelsFilterParam: 'label_name',
footerCreateLabelTitle: 'create',
footerManageLabelTitle: 'manage',
attrWorkspacePath: 'test',
};
export const mockSuggestedColors = {

View File

@ -169,26 +169,9 @@ RSpec.describe IssuablesHelper do
stub_const("Gitlab::IssuablesCountForState::THRESHOLD", 1000)
end
context 'when feature flag cached_issues_state_count is disabled' do
before do
stub_feature_flags(cached_issues_state_count: false)
end
it 'returns complete count' do
expect(helper.issuables_state_counter_text(:issues, :opened, true))
.to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">1,100</span>')
end
end
context 'when feature flag cached_issues_state_count is enabled' do
before do
stub_feature_flags(cached_issues_state_count: true)
end
it 'returns truncated count' do
expect(helper.issuables_state_counter_text(:issues, :opened, true))
.to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">1.1k</span>')
end
it 'returns truncated count' do
expect(helper.issuables_state_counter_text(:issues, :opened, true))
.to eq('<span>Open</span> <span class="badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm gl-display-none gl-sm-display-inline-flex">1.1k</span>')
end
end
end

View File

@ -196,51 +196,64 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do
end
end
context 'when using service desk key' do
let_it_be(:service_desk_key) { 'mykey' }
let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml') }
context 'when using custom service desk address' do
let(:receiver) { Gitlab::Email::ServiceDeskReceiver.new(email_raw) }
before do
stub_service_desk_email_setting(enabled: true, address: 'support+%{key}@example.com')
end
before_all do
create(:service_desk_setting, project: project, project_key: service_desk_key)
end
context 'when using project key' do
let_it_be(:service_desk_key) { 'mykey' }
it_behaves_like 'a new issue request'
let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml') }
context 'when there is no project with the key' do
let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', key: 'some_key') }
before_all do
create(:service_desk_setting, project: project, project_key: service_desk_key)
end
it 'bounces the email' do
expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound)
it_behaves_like 'a new issue request'
context 'when there is no project with the key' do
let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', key: 'some_key') }
it 'bounces the email' do
expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound)
end
end
context 'when the project slug does not match' do
let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: 'some-slug') }
it 'bounces the email' do
expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound)
end
end
context 'when there are multiple projects with same key' do
let_it_be(:project_with_same_key) { create(:project, group: group, service_desk_enabled: true) }
let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: project_with_same_key.full_path_slug.to_s) }
before do
create(:service_desk_setting, project: project_with_same_key, project_key: service_desk_key)
end
it 'process email for project with matching slug' do
expect { receiver.execute }.to change { Issue.count }.by(1)
expect(Issue.last.project).to eq(project_with_same_key)
end
end
end
context 'when the project slug does not match' do
let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: 'some-slug') }
it 'bounces the email' do
expect { receiver.execute }.to raise_error(Gitlab::Email::ProjectNotFound)
end
end
context 'when there are multiple projects with same key' do
let_it_be(:project_with_same_key) { create(:project, group: group, service_desk_enabled: true) }
let(:email_raw) { service_desk_fixture('emails/service_desk_custom_address.eml', slug: project_with_same_key.full_path_slug.to_s) }
context 'when project key is not set' do
let(:email_raw) { email_fixture('emails/service_desk_custom_address_no_key.eml') }
before do
create(:service_desk_setting, project: project_with_same_key, project_key: service_desk_key)
stub_service_desk_email_setting(enabled: true, address: 'support+%{key}@example.com')
end
it 'process email for project with matching slug' do
expect { receiver.execute }.to change { Issue.count }.by(1)
expect(Issue.last.project).to eq(project_with_same_key)
end
it_behaves_like 'a new issue request'
end
end

View File

@ -37,6 +37,23 @@ RSpec.describe ErrorTracking::ErrorEvent, type: :model do
expect(event.stacktrace).to be_kind_of(Array)
expect(event.stacktrace.first).to eq(expected_entry)
end
context 'error context is missing' do
let(:event) { create(:error_tracking_error_event, :browser) }
it 'generates a stacktrace without context' do
expected_entry = {
'lineNo' => 6395,
'context' => [],
'filename' => 'webpack-internal:///./node_modules/vue/dist/vue.runtime.esm.js',
'function' => 'hydrate',
'colNo' => 0
}
expect(event.stacktrace).to be_kind_of(Array)
expect(event.stacktrace.first).to eq(expected_entry)
end
end
end
describe '#to_sentry_error_event' do

View File

@ -1715,13 +1715,19 @@ RSpec.describe Project, factory_default: :keep do
allow(::Gitlab::ServiceDeskEmail).to receive(:config).and_return(config)
end
it 'returns custom address when project_key is set' do
create(:service_desk_setting, project: project, project_key: 'key1')
context 'when project_key is set' do
it 'returns custom address including the project_key' do
create(:service_desk_setting, project: project, project_key: 'key1')
expect(subject).to eq("foo+#{project.full_path_slug}-key1@bar.com")
expect(subject).to eq("foo+#{project.full_path_slug}-key1@bar.com")
end
end
it_behaves_like 'with incoming email address'
context 'when project_key is not set' do
it 'returns custom address including the project full path' do
expect(subject).to eq("foo+#{project.full_path_slug}-#{project.project_id}-issue-@bar.com")
end
end
end
end

View File

@ -9,9 +9,9 @@ RSpec.describe 'groups/settings/_transfer.html.haml' do
render 'groups/settings/transfer', group: group
expect(rendered).to have_selector '[data-qa-selector="select_group_dropdown"]' # rubocop:disable QA/SelectorUsage
expect(rendered).not_to have_selector '[data-qa-selector="select_group_dropdown"][disabled]' # rubocop:disable QA/SelectorUsage
expect(rendered).not_to have_selector '[data-testid="group-to-transfer-has-linked-subscription-alert"]'
expect(rendered).to have_button 'Select parent group'
expect(rendered).not_to have_button 'Select parent group', disabled: true
expect(rendered).not_to have_text "This group can't be transfered because it is linked to a subscription."
end
end
end

View File

@ -67,6 +67,8 @@ func (p *Injector) Inject(w http.ResponseWriter, r *http.Request, sendData strin
return
}
w.Header().Set("Content-Length", dependencyResponse.Header.Get("Content-Length"))
teeReader := io.TeeReader(dependencyResponse.Body, w)
saveFileRequest, err := http.NewRequestWithContext(r.Context(), "POST", r.URL.String()+"/upload", teeReader)
if err != nil {
@ -75,8 +77,6 @@ func (p *Injector) Inject(w http.ResponseWriter, r *http.Request, sendData strin
saveFileRequest.Header = helper.HeaderClone(r.Header)
saveFileRequest.ContentLength = dependencyResponse.ContentLength
w.Header().Del("Content-Length")
nrw := &nullResponseWriter{header: make(http.Header)}
p.uploadHandler.ServeHTTP(nrw, saveFileRequest)

View File

@ -33,7 +33,7 @@ func (f *fakeUploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
type errWriter struct{ writes int }
func (w *errWriter) Header() http.Header { return nil }
func (w *errWriter) Header() http.Header { return make(http.Header) }
func (w *errWriter) WriteHeader(h int) {}
// First call of Write function succeeds while all the subsequent ones fail
@ -112,8 +112,9 @@ func TestInject(t *testing.T) {
func TestSuccessfullRequest(t *testing.T) {
content := []byte("result")
contentLength := strconv.Itoa(len(content))
originResourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", strconv.Itoa(len(content)))
w.Header().Set("Content-Length", contentLength)
w.Write(content)
}))
@ -135,6 +136,7 @@ func TestSuccessfullRequest(t *testing.T) {
require.Equal(t, 200, response.Code)
require.Equal(t, string(content), response.Body.String())
require.Equal(t, contentLength, response.Header().Get("Content-Length"))
}
func TestIncorrectSendData(t *testing.T) {