Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-12-09 09:11:03 +00:00
parent 130e0444c6
commit 3d233a67cf
49 changed files with 385 additions and 906 deletions

View file

@ -8,6 +8,7 @@ import defaultSortableConfig from '~/sortable/sortable_config';
import Tracking from '~/tracking';
import { toggleFormEventPrefix, DraggableItemTypes } from '../constants';
import eventHub from '../eventhub';
import listQuery from '../graphql/board_lists_deferred.query.graphql';
import BoardCard from './board_card.vue';
import BoardNewIssue from './board_new_issue.vue';
@ -50,11 +51,22 @@ export default {
showEpicForm: false,
};
},
apollo: {
boardList: {
query: listQuery,
variables() {
return {
id: this.list.id,
filters: this.filterParams,
};
},
},
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags']),
...mapState(['pageInfoByListId', 'listsFlags', 'filterParams']),
...mapGetters(['isEpicBoard']),
listItemsCount() {
return this.isEpicBoard ? this.list.epicsCount : this.list.issuesCount;
return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount;
},
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), {

View file

@ -20,6 +20,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AccessorUtilities from '../../lib/utils/accessor';
import { inactiveId, LIST, ListType, toggleFormEventPrefix } from '../constants';
import eventHub from '../eventhub';
import listQuery from '../graphql/board_lists_deferred.query.graphql';
import ItemCount from './item_count.vue';
export default {
@ -74,7 +75,7 @@ export default {
},
},
computed: {
...mapState(['activeId']),
...mapState(['activeId', 'filterParams']),
...mapGetters(['isEpicBoard', 'isSwimlanesOn']),
isLoggedIn() {
return Boolean(this.currentUserId);
@ -119,14 +120,11 @@ export default {
}
return false;
},
itemsCount() {
return this.list.issuesCount;
},
countIcon() {
return 'issues';
},
itemsTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.itemsCount);
return n__(`%d issue`, `%d issues`, this.boardLists?.issuesCount);
},
chevronTooltip() {
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
@ -158,6 +156,23 @@ export default {
userCanDrag() {
return !this.disabled && isListDraggable(this.list);
},
isLoading() {
return this.$apollo.queries.boardList.loading;
},
},
apollo: {
boardList: {
query: listQuery,
variables() {
return {
id: this.list.id,
filters: this.filterParams,
};
},
skip() {
return this.isEpicBoard;
},
},
},
created() {
const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`));
@ -375,10 +390,10 @@ export default {
</gl-sprintf>
</div>
<div v-else> {{ itemsTooltipLabel }}</div>
<div v-if="weightFeatureAvailable">
<div v-if="weightFeatureAvailable && !isLoading">
<gl-sprintf :message="__('%{totalWeight} total weight')">
<template #totalWeight>{{ list.totalWeight }}</template>
<template #totalWeight>{{ boardList.totalWeight }}</template>
</gl-sprintf>
</div>
</gl-tooltip>
@ -396,14 +411,18 @@ export default {
<gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
<span ref="itemCount" class="gl-display-inline-flex gl-align-items-center">
<gl-icon class="gl-mr-2" :name="countIcon" />
<item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" />
<item-count
v-if="!isLoading"
:items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount"
:max-issue-count="list.maxIssueCount"
/>
</span>
<!-- EE start -->
<template v-if="weightFeatureAvailable && !isEpicBoard">
<template v-if="weightFeatureAvailable && !isEpicBoard && !isLoading">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
<gl-icon class="gl-mr-2" name="weight" />
{{ list.totalWeight }}
{{ boardList.totalWeight }}
</span>
</template>
<!-- EE end -->

View file

@ -1,173 +0,0 @@
<script>
import { GlLabel } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import Api from '~/api';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
export default {
components: {
BoardEditableItem,
LabelsSelect,
GlLabel,
},
inject: {
labelsFetchPath: {
default: null,
},
labelsManagePath: {},
labelsFilterBasePath: {},
},
data() {
return {
loading: false,
oldIid: null,
isEditing: false,
};
},
computed: {
...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']),
selectedLabels() {
const { labels = [] } = this.activeBoardItem;
return labels.map((label) => ({
...label,
id: getIdFromGraphQLId(label.id),
}));
},
issueLabels() {
const { labels = [] } = this.activeBoardItem;
return labels.map((label) => ({
...label,
scoped: isScopedLabel(label),
}));
},
fetchPath() {
/*
Labels fetched in epic boards are always group-level labels
and the correct path are passed from the backend (injected through labelsFetchPath)
For issue boards, we should always include project-level labels and use a different endpoint.
(it requires knowing the project path of a selected issue.)
Note 1. that we will be using GraphQL to fetch labels when we create a labels select widget.
And this component will be removed _wholesale_ https://gitlab.com/gitlab-org/gitlab/-/issues/300653.
Note 2. Moreover, 'fetchPath' needs to be used as a key for 'labels-select' component to force updates.
'labels-select' has its own vuex store and initializes the passed props as states
and these states aren't reactively bound to the passed props.
*/
const projectLabelsFetchPath = mergeUrlParams(
{ include_ancestor_groups: true },
Api.buildUrl(Api.projectLabelsPath).replace(
':namespace_path/:project_path',
this.projectPathForActiveIssue,
),
);
return this.labelsFetchPath || projectLabelsFetchPath;
},
},
watch: {
activeBoardItem(_, oldVal) {
if (this.isEditing) {
this.oldIid = oldVal.iid;
} else {
this.oldIid = null;
}
},
},
methods: {
...mapActions(['setActiveBoardItemLabels', 'setError']),
async setLabels(payload) {
this.loading = true;
this.$refs.sidebarItem.collapse();
try {
const addLabelIds = payload.filter((label) => label.set).map((label) => label.id);
const removeLabelIds = payload.filter((label) => !label.set).map((label) => label.id);
const input = {
addLabelIds,
removeLabelIds,
projectPath: this.projectPathForActiveIssue,
iid: this.oldIid,
};
await this.setActiveBoardItemLabels(input);
this.oldIid = null;
} catch (e) {
this.setError({ error: e, message: __('An error occurred while updating labels.') });
} finally {
this.loading = false;
}
},
async removeLabel(id) {
this.loading = true;
try {
const removeLabelIds = [getIdFromGraphQLId(id)];
const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue };
await this.setActiveBoardItemLabels(input);
} catch (e) {
this.setError({ error: e, message: __('An error occurred when removing the label.') });
} finally {
this.loading = false;
}
},
},
};
</script>
<template>
<board-editable-item
ref="sidebarItem"
:title="__('Labels')"
:loading="loading"
data-testid="sidebar-labels"
@open="isEditing = true"
@close="isEditing = false"
>
<template #collapsed>
<gl-label
v-for="label in issueLabels"
:key="label.id"
:background-color="label.color"
:title="label.title"
:description="label.description"
:scoped="label.scoped"
:show-close-button="true"
:disabled="loading"
class="gl-mr-2 gl-mb-2"
@close="removeLabel(label.id)"
/>
</template>
<template #default="{ edit }">
<labels-select
ref="labelsSelect"
:key="fetchPath"
:allow-label-edit="false"
:allow-label-create="false"
:allow-multiselect="true"
:allow-scoped-labels="true"
:selected-labels="selectedLabels"
:labels-fetch-path="fetchPath"
:labels-manage-path="labelsManagePath"
:labels-filter-base-path="labelsFilterBasePath"
:labels-list-title="__('Select label')"
:dropdown-button-text="__('Choose labels')"
:is-editing="edit"
variant="sidebar"
class="gl-display-block labels gl-w-full"
@updateSelectedLabels="setLabels"
>
{{ __('None') }}
</labels-select>
</template>
</board-editable-item>
</template>

View file

@ -1,75 +0,0 @@
<script>
import { GlToggle } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { __, s__ } from '~/locale';
export default {
i18n: {
header: {
title: __('Notifications'),
/* Any change to subscribeDisabledDescription
must be reflected in app/helpers/notifications_helper.rb */
subscribeDisabledDescription: __(
'Notifications have been disabled by the project or group owner',
),
},
updateSubscribedErrorMessage: s__(
'IssueBoards|An error occurred while setting notifications status. Please try again.',
),
},
components: {
GlToggle,
},
inject: ['emailsDisabled'],
data() {
return {
loading: false,
};
},
computed: {
...mapGetters(['activeBoardItem', 'projectPathForActiveIssue', 'isEpicBoard']),
isEmailsDisabled() {
return this.isEpicBoard ? this.emailsDisabled : this.activeBoardItem.emailsDisabled;
},
notificationText() {
return this.isEmailsDisabled
? this.$options.i18n.header.subscribeDisabledDescription
: this.$options.i18n.header.title;
},
},
methods: {
...mapActions(['setActiveItemSubscribed', 'setError']),
async handleToggleSubscription() {
this.loading = true;
try {
await this.setActiveItemSubscribed({
subscribed: !this.activeBoardItem.subscribed,
projectPath: this.projectPathForActiveIssue,
});
} catch (error) {
this.setError({ error, message: this.$options.i18n.updateSubscribedErrorMessage });
} finally {
this.loading = false;
}
},
},
};
</script>
<template>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-space-between"
data-testid="sidebar-notifications"
>
<span data-testid="notification-header-text"> {{ notificationText }} </span>
<gl-toggle
v-if="!isEmailsDisabled"
:value="activeBoardItem.subscribed"
:is-loading="loading"
:label="$options.i18n.header.title"
label-position="hidden"
data-testid="notification-subscribe-toggle"
@change="handleToggleSubscription"
/>
</div>
</template>

View file

@ -4,7 +4,6 @@ fragment BoardListShared on BoardList {
position
listType
collapsed
issuesCount
label {
id
title

View file

@ -0,0 +1,6 @@
query BoardList($id: ID!, $filters: BoardIssueInput) {
boardList(id: $id, issueFilters: $filters) {
id
issuesCount
}
}

View file

@ -1,6 +1,6 @@
#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
query BoardListEE(
query BoardListsEE(
$fullPath: ID!
$boardId: ID!
$id: ID

View file

@ -30,6 +30,7 @@ import {
} from 'ee_else_ce/boards/boards_util';
import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
@ -501,9 +502,10 @@ export default {
updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => {
try {
const { itemId, fromListId, toListId, moveBeforeId, moveAfterId } = moveData;
const { itemId, fromListId, toListId, moveBeforeId, moveAfterId, itemNotInToList } = moveData;
const {
fullBoardId,
filterParams,
boardItems: {
[itemId]: { iid, referencePath },
},
@ -522,6 +524,67 @@ export default {
// 'mutationVariables' allows EE code to pass in extra parameters.
...mutationVariables,
},
update(
cache,
{
data: {
issueMoveList: {
issue: { weight },
},
},
},
) {
if (fromListId === toListId) return;
const updateFromList = () => {
const fromList = cache.readQuery({
query: totalCountAndWeightQuery,
variables: { id: fromListId, filters: filterParams },
});
const updatedFromList = {
boardList: {
__typename: 'BoardList',
id: fromList.boardList.id,
issuesCount: fromList.boardList.issuesCount - 1,
totalWeight: fromList.boardList.totalWeight - Number(weight),
},
};
cache.writeQuery({
query: totalCountAndWeightQuery,
variables: { id: fromListId, filters: filterParams },
data: updatedFromList,
});
};
const updateToList = () => {
if (!itemNotInToList) return;
const toList = cache.readQuery({
query: totalCountAndWeightQuery,
variables: { id: toListId, filters: filterParams },
});
const updatedToList = {
boardList: {
__typename: 'BoardList',
id: toList.boardList.id,
issuesCount: toList.boardList.issuesCount + 1,
totalWeight: toList.boardList.totalWeight + Number(weight),
},
};
cache.writeQuery({
query: totalCountAndWeightQuery,
variables: { id: toListId, filters: filterParams },
data: updatedToList,
});
};
updateFromList();
updateToList();
},
});
if (data?.issueMoveList?.errors.length || !data.issueMoveList) {
@ -565,7 +628,7 @@ export default {
},
addListNewIssue: (
{ state: { boardConfig, boardType, fullPath }, dispatch, commit },
{ state: { boardConfig, boardType, fullPath, filterParams }, dispatch, commit },
{ issueInput, list, placeholderId = `tmp-${new Date().getTime()}` },
) => {
const input = formatIssueInput(issueInput, boardConfig);
@ -581,6 +644,27 @@ export default {
.mutate({
mutation: issueCreateMutation,
variables: { input },
update(cache) {
const fromList = cache.readQuery({
query: totalCountAndWeightQuery,
variables: { id: list.id, filters: filterParams },
});
const updatedList = {
boardList: {
__typename: 'BoardList',
id: fromList.boardList.id,
issuesCount: fromList.boardList.issuesCount + 1,
totalWeight: fromList.boardList.totalWeight,
},
};
cache.writeQuery({
query: totalCountAndWeightQuery,
variables: { id: list.id, filters: filterParams },
data: updatedList,
});
},
})
.then(({ data }) => {
if (data.createIssue.errors.length) {

View file

@ -1,4 +1,4 @@
import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import { sortMilestonesByDueDate } from '~/milestones/utils';
import { mergeUrlParams } from '../lib/utils/url_utility';
import DropdownAjaxFilter from './dropdown_ajax_filter';
import DropdownEmoji from './dropdown_emoji';

View file

@ -1,22 +0,0 @@
import $ from 'jquery';
import initDatePicker from '~/behaviors/date_picker';
import GLForm from '~/gl_form';
import ZenMode from '~/zen_mode';
export default (initGFM = true) => {
new ZenMode(); // eslint-disable-line no-new
initDatePicker();
// eslint-disable-next-line no-new
new GLForm($('.milestone-form'), {
emojis: true,
members: initGFM,
issues: initGFM,
mergeRequests: initGFM,
epics: initGFM,
milestones: initGFM,
labels: initGFM,
snippets: initGFM,
vulnerabilities: initGFM,
});
};

View file

@ -1,10 +1,58 @@
import $ from 'jquery';
import Vue from 'vue';
import initDatePicker from '~/behaviors/date_picker';
import GLForm from '~/gl_form';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Milestone from '~/milestones/milestone';
import Sidebar from '~/right_sidebar';
import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
import Translate from '~/vue_shared/translate';
import ZenMode from '~/zen_mode';
import DeleteMilestoneModal from './components/delete_milestone_modal.vue';
import PromoteMilestoneModal from './components/promote_milestone_modal.vue';
import eventHub from './event_hub';
export default () => {
export function initForm(initGFM = true) {
new ZenMode(); // eslint-disable-line no-new
initDatePicker();
// eslint-disable-next-line no-new
new GLForm($('.milestone-form'), {
emojis: true,
members: initGFM,
issues: initGFM,
mergeRequests: initGFM,
epics: initGFM,
milestones: initGFM,
labels: initGFM,
snippets: initGFM,
vulnerabilities: initGFM,
});
}
export function initShow() {
new Milestone(); // eslint-disable-line no-new
new Sidebar(); // eslint-disable-line no-new
new MountMilestoneSidebar(); // eslint-disable-line no-new
}
export function initPromoteMilestoneModal() {
Vue.use(Translate);
const promoteMilestoneModal = document.getElementById('promote-milestone-modal');
if (!promoteMilestoneModal) {
return null;
}
return new Vue({
el: promoteMilestoneModal,
render(createElement) {
return createElement(PromoteMilestoneModal);
},
});
}
export function initDeleteMilestoneModal() {
Vue.use(Translate);
const onRequestFinished = ({ milestoneUrl, successful }) => {
@ -72,4 +120,4 @@ export default () => {
});
},
});
};
}

View file

@ -1,11 +0,0 @@
/* eslint-disable no-new */
import Milestone from '~/milestones/milestone';
import Sidebar from '~/right_sidebar';
import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar';
export default () => {
new Milestone();
new Sidebar();
new MountMilestoneSidebar();
};

View file

@ -6,7 +6,7 @@ import { template, escape } from 'lodash';
import Api from '~/api';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { __, sprintf } from '~/locale';
import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import { sortMilestonesByDueDate } from '~/milestones/utils';
import axios from '~/lib/utils/axios_utils';
import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility';

View file

@ -1,19 +0,0 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import PromoteMilestoneModal from './components/promote_milestone_modal.vue';
Vue.use(Translate);
export default () => {
const promoteMilestoneModal = document.getElementById('promote-milestone-modal');
if (!promoteMilestoneModal) {
return null;
}
return new Vue({
el: promoteMilestoneModal,
render(createElement) {
return createElement(PromoteMilestoneModal);
},
});
};

View file

@ -1,3 +1,3 @@
import initForm from '~/milestones/form';
import { initForm } from '~/milestones';
initForm();

View file

@ -1,3 +1,3 @@
import initForm from '~/milestones/form';
import { initForm } from '~/milestones';
initForm();

View file

@ -1,5 +1,4 @@
import initDeleteMilestoneModal from '~/milestones/delete_milestone_modal_init';
import initMilestonesShow from '~/milestones/init_milestones_show';
import { initDeleteMilestoneModal, initShow } from '~/milestones';
initMilestonesShow();
initShow();
initDeleteMilestoneModal();

View file

@ -1,3 +1,3 @@
import initForm from '~/milestones/form';
import { initForm } from '~/milestones';
initForm();

View file

@ -1,5 +1,4 @@
import initDeleteMilestoneModal from '~/milestones/delete_milestone_modal_init';
import initPromoteMilestoneModal from '~/milestones/promote_milestone_modal_init';
import { initDeleteMilestoneModal, initPromoteMilestoneModal } from '~/milestones';
initDeleteMilestoneModal();
initPromoteMilestoneModal();

View file

@ -1,3 +1,3 @@
import initForm from '~/milestones/form';
import { initForm } from '~/milestones';
initForm();

View file

@ -1,7 +1,5 @@
import initMilestonesShow from '~/milestones/init_milestones_show';
import initDeleteMilestoneModal from '~/milestones/delete_milestone_modal_init';
import initPromoteMilestoneModal from '~/milestones/promote_milestone_modal_init';
import { initDeleteMilestoneModal, initPromoteMilestoneModal, initShow } from '~/milestones';
initMilestonesShow();
initShow();
initDeleteMilestoneModal();
initPromoteMilestoneModal();

View file

@ -1,40 +0,0 @@
<script>
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
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';
export default {
components: {
LabelsSelectWidget,
},
variant: DropdownVariant.Sidebar,
inject: ['allowLabelEdit', 'iid', 'fullPath', 'issuableType', 'projectIssuesPath'],
data() {
return {
LabelType,
};
},
};
</script>
<template>
<labels-select-widget
class="block labels js-labels-block"
:iid="iid"
:full-path="fullPath"
:allow-label-remove="allowLabelEdit"
:allow-multiselect="true"
:footer-create-label-title="__('Create project label')"
:footer-manage-label-title="__('Manage project labels')"
:labels-create-title="__('Create project label')"
:labels-filter-base-path="projectIssuesPath"
:variant="$options.variant"
:issuable-type="issuableType"
workspace-type="project"
:attr-workspace-path="fullPath"
:label-create-type="LabelType.project"
data-qa-selector="labels_block"
>
{{ __('None') }}
</labels-select-widget>
</template>

View file

@ -12,6 +12,7 @@ import {
isInIncidentPage,
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
@ -23,10 +24,11 @@ import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_wid
import { apolloProvider } from '~/sidebar/graphql';
import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
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 Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
import SidebarLabels from './components/labels/sidebar_labels.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
@ -264,7 +266,6 @@ function mountMilestoneSelect() {
export function mountSidebarLabels() {
const el = document.querySelector('.js-sidebar-labels');
const { fullPath } = getSidebarOptions();
if (!el) {
return false;
@ -273,22 +274,43 @@ export function mountSidebarLabels() {
return new Vue({
el,
apolloProvider,
components: {
LabelsSelectWidget,
},
provide: {
...el.dataset,
fullPath,
canUpdate: parseBoolean(el.dataset.canEdit),
allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate),
allowLabelEdit: parseBoolean(el.dataset.canEdit),
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels),
variant: DropdownVariant.Sidebar,
canUpdate: parseBoolean(el.dataset.canEdit),
isClassicSidebar: true,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
},
render: (createElement) => createElement(SidebarLabels),
render: (createElement) =>
createElement('labels-select-widget', {
props: {
iid: String(el.dataset.iid),
fullPath: el.dataset.projectPath,
allowLabelRemove: parseBoolean(el.dataset.canEdit),
allowMultiselect: true,
footerCreateLabelTitle: __('Create project label'),
footerManageLabelTitle: __('Manage project labels'),
labelsCreateTitle: __('Create project label'),
labelsFilterBasePath: el.dataset.projectIssuesPath,
variant: DropdownVariant.Sidebar,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
workspaceType: 'project',
attrWorkspacePath: el.dataset.projectPath,
labelCreateType: LabelType.project,
},
class: ['block labels js-labels-block'],
scopedSlots: {
default: () => __('None'),
},
}),
});
}

View file

@ -2,7 +2,7 @@
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import { sortMilestonesByDueDate } from '~/milestones/utils';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { DEFAULT_MILESTONES } from '../constants';
import { stripQuotes } from '../filtered_search_utils';

View file

@ -291,6 +291,7 @@ export default {
'is-standalone': isDropdownVariantStandalone(variant),
'is-embedded': isDropdownVariantEmbedded(variant),
}"
data-qa-selector="labels_block"
>
<template v-if="isDropdownVariantSidebar(variant)">
<dropdown-value-collapsed

View file

@ -67,7 +67,6 @@ module NotificationsHelper
when :custom
_('You will only receive notifications for the events you choose')
when :owner_disabled
# Any change must be reflected in board_sidebar_subscription.vue
_('Notifications have been disabled by the project or group owner')
end
end

View file

@ -958,7 +958,7 @@ module Ci
.limit(100)
.pluck(:expanded_environment_name)
Environment.where(project: project, name: expanded_environment_names).with_deployments
Environment.where(project: project, name: expanded_environment_names).with_deployment(sha)
else
environment_ids = self_and_descendants.joins(:deployments).select(:'deployments.environment_id')

View file

@ -89,7 +89,6 @@ class Environment < ApplicationRecord
scope :for_project, -> (project) { where(project_id: project) }
scope :for_tier, -> (tier) { where(tier: tier).where.not(tier: nil) }
scope :with_deployments, -> { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id')) }
scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) }
scope :unfoldered, -> { where(environment_type: nil) }
scope :with_rank, -> do

View file

@ -7,5 +7,9 @@ module Namespaces
def self.sti_name
'Project'
end
def self.polymorphic_name
'Namespaces::ProjectNamespace'
end
end
end

View file

@ -1,8 +0,0 @@
---
name: load_balancing_for_update_all_mirrors_worker
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64526
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334162
milestone: '14.1'
type: development
group: group::source code
default_enabled: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -36,6 +36,10 @@ graph TD
With the addition of [multi-level epics](../epics/manage_epics.md#multi-level-child-epics) and up to
seven levels of nested epics, you can achieve the following hierarchy:
<!--
Image below was generated with the following Mermaid code.
Attached as an image because a rendered diagram doesn't look clear on the docs page.
```mermaid
classDiagram
direction TD
@ -46,6 +50,10 @@ classDiagram
Epic "1" *-- "0..*" Issue
```
-->
![Diagram showing possible relationships of multi-level epics](img/hierarchy_with_multi_level_epics.png)
## View ancestry of an epic
In an epic, you can view the ancestors as parents in the right sidebar under **Ancestors**.

View file

@ -3650,9 +3650,6 @@ msgstr ""
msgid "An error occurred previewing the blob"
msgstr ""
msgid "An error occurred when removing the label."
msgstr ""
msgid "An error occurred when updating the title"
msgstr ""
@ -7050,9 +7047,6 @@ msgstr ""
msgid "Choose file…"
msgstr ""
msgid "Choose labels"
msgstr ""
msgid "Choose specific groups or storage shards"
msgstr ""
@ -19539,9 +19533,6 @@ msgstr ""
msgid "IssueAnalytics|Weight"
msgstr ""
msgid "IssueBoards|An error occurred while setting notifications status. Please try again."
msgstr ""
msgid "IssueBoards|Board"
msgstr ""

View file

@ -9,21 +9,19 @@ module Gitlab
link :storage_tab, id: 'storage-quota'
link :buy_ci_minutes, text: 'Buy additional minutes'
link :buy_storage, text: /Purchase more storage/
strong :additional_minutes, text: 'Additional minutes'
strong :plan_minutes, text: 'Current period usage'
div :purchased_usage, 'data-testid': 'purchased-usage'
div(:plan_minutes_usage) { plan_minutes_element.following_sibling.span }
div(:additional_minutes_usage) { additional_minutes_element.following_sibling.span }
div :plan_ci_minutes
div :additional_ci_minutes
span :purchased_usage_total
div :ci_purchase_successful_alert, text: /You have successfully purchased CI minutes/
div :storage_purchase_successful_alert, text: /You have successfully purchased a storage/
h4 :storage_available_alert, text: /purchased storage is available/
def plan_minutes_limits
plan_minutes_usage[%r{([^/ ]+)$}]
def plan_ci_limits
plan_ci_minutes_element.span.text[%r{([^/ ]+)$}]
end
def additional_limits
additional_minutes_usage[%r{([^/ ]+)$}]
def additional_ci_limits
additional_ci_minutes_element.span.text[%r{([^/ ]+)$}]
end
# Waits and Checks if storage available alert presents on the page
@ -40,7 +38,7 @@ module Gitlab
# @return [Float] Total purchased storage value in GiB
def total_purchased_storage
storage_available_alert_element.wait_until(&:present?)
purchased_usage_element.p.spans[3].text.to_f
purchased_usage_total.to_f
end
end
end

View file

@ -18,7 +18,7 @@ module QA
element :more_assignees_link
end
base.view 'app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue' do
base.view 'app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue' do
element :labels_block
end

View file

@ -1,4 +1,5 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import BoardCard from '~/boards/components/board_card.vue';
@ -6,7 +7,15 @@ import BoardList from '~/boards/components/board_list.vue';
import BoardNewIssue from '~/boards/components/board_new_issue.vue';
import BoardNewItem from '~/boards/components/board_new_item.vue';
import defaultState from '~/boards/stores/state';
import { mockList, mockIssuesByListId, issues, mockGroupProjects } from './mock_data';
import createMockApollo from 'helpers/mock_apollo_helper';
import listQuery from '~/boards/graphql/board_lists_deferred.query.graphql';
import {
mockList,
mockIssuesByListId,
issues,
mockGroupProjects,
boardListQueryResponse,
} from './mock_data';
export default function createComponent({
listIssueProps = {},
@ -15,16 +24,23 @@ export default function createComponent({
actions = {},
getters = {},
provide = {},
data = {},
state = defaultState,
stubs = {
BoardNewIssue,
BoardNewItem,
BoardCard,
},
issuesCount,
} = {}) {
const localVue = createLocalVue();
localVue.use(VueApollo);
localVue.use(Vuex);
const fakeApollo = createMockApollo([
[listQuery, jest.fn().mockResolvedValue(boardListQueryResponse(issuesCount))],
]);
const store = new Vuex.Store({
state: {
selectedProject: mockGroupProjects[0],
@ -68,6 +84,7 @@ export default function createComponent({
}
const component = shallowMount(BoardList, {
apolloProvider: fakeApollo,
localVue,
store,
propsData: {
@ -87,6 +104,11 @@ export default function createComponent({
...provide,
},
stubs,
data() {
return {
...data,
};
},
});
return component;

View file

@ -38,7 +38,7 @@ describe('Board list component', () => {
describe('When Expanded', () => {
beforeEach(() => {
wrapper = createComponent();
wrapper = createComponent({ issuesCount: 1 });
});
it('renders component', () => {
@ -97,14 +97,6 @@ describe('Board list component', () => {
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count').attributes('data-issue-id')).toBe('-1');
});
it('shows how many more issues to load', async () => {
wrapper.vm.showCount = true;
wrapper.setProps({ list: { issuesCount: 20 } });
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues');
});
});
describe('load more issues', () => {
@ -113,9 +105,7 @@ describe('Board list component', () => {
};
beforeEach(() => {
wrapper = createComponent({
listProps: { issuesCount: 25 },
});
wrapper = createComponent();
});
it('does not load issues if already loading', () => {
@ -131,13 +121,27 @@ describe('Board list component', () => {
it('shows loading more spinner', async () => {
wrapper = createComponent({
state: { listsFlags: { 'gid://gitlab/List/1': { isLoadingMore: true } } },
data: {
showCount: true,
},
});
wrapper.vm.showCount = true;
await wrapper.vm.$nextTick();
expect(findIssueCountLoadingIcon().exists()).toBe(true);
});
it('shows how many more issues to load', async () => {
// wrapper.vm.showCount = true;
wrapper = createComponent({
data: {
showCount: true,
},
});
await wrapper.vm.$nextTick();
expect(wrapper.find('.board-list-count').text()).toBe('Showing 1 of 20 issues');
});
});
describe('max issue count warning', () => {

View file

@ -1,18 +1,22 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { mockLabelList } from 'jest/boards/mock_data';
import { boardListQueryResponse, mockLabelList } from 'jest/boards/mock_data';
import BoardListHeader from '~/boards/components/board_list_header.vue';
import { ListType } from '~/boards/constants';
import listQuery from '~/boards/graphql/board_lists_deferred.query.graphql';
const localVue = createLocalVue();
localVue.use(Vuex);
Vue.use(VueApollo);
Vue.use(Vuex);
describe('Board List Header Component', () => {
let wrapper;
let store;
let fakeApollo;
const updateListSpy = jest.fn();
const toggleListCollapsedSpy = jest.fn();
@ -20,6 +24,7 @@ describe('Board List Header Component', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
fakeApollo = null;
localStorage.clear();
});
@ -29,6 +34,7 @@ describe('Board List Header Component', () => {
collapsed = false,
withLocalStorage = true,
currentUserId = 1,
listQueryHandler = jest.fn().mockResolvedValue(boardListQueryResponse()),
} = {}) => {
const boardId = '1';
@ -56,10 +62,12 @@ describe('Board List Header Component', () => {
getters: { isEpicBoard: () => false },
});
fakeApollo = createMockApollo([[listQuery, listQueryHandler]]);
wrapper = extendedWrapper(
shallowMount(BoardListHeader, {
apolloProvider: fakeApollo,
store,
localVue,
propsData: {
disabled: false,
list: listMock,

View file

@ -1,168 +0,0 @@
import { GlLabel } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import {
labels as TEST_LABELS,
mockIssue as TEST_ISSUE,
mockIssueFullPath as TEST_ISSUE_FULLPATH,
} from 'jest/boards/mock_data';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import { createStore } from '~/boards/stores';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
const TEST_LABELS_PAYLOAD = TEST_LABELS.map((label) => ({ ...label, set: true }));
const TEST_LABELS_TITLES = TEST_LABELS.map((label) => label.title);
describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => {
let wrapper;
let store;
afterEach(() => {
wrapper.destroy();
store = null;
wrapper = null;
});
const createWrapper = ({ labels = [], providedValues = {} } = {}) => {
store = createStore();
store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } };
store.state.activeId = TEST_ISSUE.id;
wrapper = shallowMount(BoardSidebarLabelsSelect, {
store,
provide: {
canUpdate: true,
labelsManagePath: TEST_HOST,
labelsFilterBasePath: TEST_HOST,
...providedValues,
},
stubs: {
BoardEditableItem,
LabelsSelect: true,
},
});
};
const findLabelsSelect = () => wrapper.find({ ref: 'labelsSelect' });
const findLabelsTitles = () =>
wrapper.findAll(GlLabel).wrappers.map((item) => item.props('title'));
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
describe('when labelsFetchPath is provided', () => {
it('uses injected labels fetch path', () => {
createWrapper({ providedValues: { labelsFetchPath: 'foobar' } });
expect(findLabelsSelect().props('labelsFetchPath')).toEqual('foobar');
});
});
it('uses the default project label endpoint', () => {
createWrapper();
expect(findLabelsSelect().props('labelsFetchPath')).toEqual(
`/${TEST_ISSUE_FULLPATH}/-/labels?include_ancestor_groups=true`,
);
});
it('renders "None" when no labels are selected', () => {
createWrapper();
expect(findCollapsed().text()).toBe('None');
});
it('renders labels when set', () => {
createWrapper({ labels: TEST_LABELS });
expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
});
describe('when labels are submitted', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => TEST_LABELS);
findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD);
store.state.boardItems[TEST_ISSUE.id].labels = TEST_LABELS;
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders labels', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({
addLabelIds: TEST_LABELS.map((label) => label.id),
projectPath: TEST_ISSUE_FULLPATH,
removeLabelIds: [],
iid: null,
});
});
});
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 }];
beforeEach(async () => {
createWrapper({ labels: TEST_LABELS });
jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => expectedLabels);
findLabelsSelect().vm.$emit('updateSelectedLabels', testLabelsPayload);
await wrapper.vm.$nextTick();
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({
addLabelIds: [5, 7],
removeLabelIds: [6],
projectPath: TEST_ISSUE_FULLPATH,
iid: null,
});
});
});
describe('when removing individual labels', () => {
const testLabel = TEST_LABELS[0];
beforeEach(async () => {
createWrapper({ labels: [testLabel] });
jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => {});
});
it('commits change to the server', () => {
wrapper.find(GlLabel).vm.$emit('close', testLabel);
expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({
removeLabelIds: [getIdFromGraphQLId(testLabel.id)],
projectPath: TEST_ISSUE_FULLPATH,
});
});
});
describe('when the mutation fails', () => {
beforeEach(async () => {
createWrapper({ labels: TEST_LABELS });
jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => {
throw new Error(['failed mutation']);
});
jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
findLabelsSelect().vm.$emit('updateSelectedLabels', [{ id: '?' }]);
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders former issue weight', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
expect(wrapper.vm.setError).toHaveBeenCalled();
});
});
});

View file

@ -1,163 +0,0 @@
import { GlToggle, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import { createStore } from '~/boards/stores';
import * as types from '~/boards/stores/mutation_types';
import { mockActiveIssue } from '../../mock_data';
Vue.use(Vuex);
describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () => {
let wrapper;
let store;
const findNotificationHeader = () => wrapper.find("[data-testid='notification-header-text']");
const findToggle = () => wrapper.findComponent(GlToggle);
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const createComponent = (activeBoardItem = { ...mockActiveIssue }) => {
store = createStore();
store.state.boardItems = { [activeBoardItem.id]: activeBoardItem };
store.state.activeId = activeBoardItem.id;
wrapper = mount(BoardSidebarSubscription, {
store,
provide: {
emailsDisabled: false,
},
});
};
afterEach(() => {
wrapper.destroy();
store = null;
jest.clearAllMocks();
});
describe('Board sidebar subscription component template', () => {
it('displays "notifications" heading', () => {
createComponent();
expect(findNotificationHeader().text()).toBe('Notifications');
});
it('renders toggle with label', () => {
createComponent();
expect(findToggle().props('label')).toBe(BoardSidebarSubscription.i18n.header.title);
});
it('renders toggle as "off" when currently not subscribed', () => {
createComponent();
expect(findToggle().exists()).toBe(true);
expect(findToggle().props('value')).toBe(false);
});
it('renders toggle as "on" when currently subscribed', () => {
createComponent({
...mockActiveIssue,
subscribed: true,
});
expect(findToggle().exists()).toBe(true);
expect(findToggle().props('value')).toBe(true);
});
describe('when notification emails have been disabled', () => {
beforeEach(() => {
createComponent({
...mockActiveIssue,
emailsDisabled: true,
});
});
it('displays a message that notification have been disabled', () => {
expect(findNotificationHeader().text()).toBe(
'Notifications have been disabled by the project or group owner',
);
});
it('does not render the toggle button', () => {
expect(findToggle().exists()).toBe(false);
});
});
});
describe('Board sidebar subscription component `behavior`', () => {
const mockSetActiveIssueSubscribed = (subscribedState) => {
jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => {
store.commit(types.UPDATE_BOARD_ITEM_BY_ID, {
itemId: mockActiveIssue.id,
prop: 'subscribed',
value: subscribedState,
});
});
};
it('subscribing to notification', async () => {
createComponent();
mockSetActiveIssueSubscribed(true);
expect(findGlLoadingIcon().exists()).toBe(false);
findToggle().vm.$emit('change');
await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(true);
expect(wrapper.vm.setActiveItemSubscribed).toHaveBeenCalledWith({
subscribed: true,
projectPath: 'gitlab-org/test-subgroup/gitlab-test',
});
await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(findToggle().props('value')).toBe(true);
});
it('unsubscribing from notification', async () => {
createComponent({
...mockActiveIssue,
subscribed: true,
});
mockSetActiveIssueSubscribed(false);
expect(findGlLoadingIcon().exists()).toBe(false);
findToggle().vm.$emit('change');
await wrapper.vm.$nextTick();
expect(wrapper.vm.setActiveItemSubscribed).toHaveBeenCalledWith({
subscribed: false,
projectPath: 'gitlab-org/test-subgroup/gitlab-test',
});
expect(findGlLoadingIcon().exists()).toBe(true);
await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(findToggle().props('value')).toBe(false);
});
it('flashes an error message when setting the subscribed state fails', async () => {
createComponent();
jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => {
throw new Error();
});
jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
findToggle().vm.$emit('change');
await wrapper.vm.$nextTick();
expect(wrapper.vm.setError).toHaveBeenCalled();
expect(wrapper.vm.setError.mock.calls[0][0].message).toBe(
wrapper.vm.$options.i18n.updateSubscribedErrorMessage,
);
});
});
});

View file

@ -662,3 +662,14 @@ export const mockGroupLabelsResponse = {
},
},
};
export const boardListQueryResponse = (issuesCount = 20) => ({
data: {
boardList: {
__typename: 'BoardList',
id: 'gid://gitlab/BoardList/5',
totalWeight: 5,
issuesCount,
},
},
});

View file

@ -1241,6 +1241,7 @@ describe('updateIssueOrder', () => {
moveBeforeId: undefined,
moveAfterId: undefined,
},
update: expect.anything(),
};
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
@ -1447,6 +1448,7 @@ describe('addListNewIssue', () => {
variables: {
input: formatIssueInput(mockIssue, stateWithBoardConfig.boardConfig),
},
update: expect.anything(),
});
});
@ -1478,6 +1480,7 @@ describe('addListNewIssue', () => {
variables: {
input: formatIssueInput(issue, stateWithBoardConfig.boardConfig),
},
update: expect.anything(),
});
expect(payload.labelIds).toEqual(['gid://gitlab/GroupLabel/4', 'gid://gitlab/GroupLabel/5']);
expect(payload.assigneeIds).toEqual(['gid://gitlab/User/1', 'gid://gitlab/User/2']);

View file

@ -7,7 +7,7 @@ import Vuex from 'vuex';
import { ENTER_KEY } from '~/lib/utils/keys';
import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import createStore from '~/milestones/stores/';
import { projectMilestones, groupMilestones } from './mock_data';
import { projectMilestones, groupMilestones } from '../mock_data';
const extraLinks = [
{ text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' },

View file

@ -1,5 +1,5 @@
import { useFakeDate } from 'helpers/fake_date';
import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import { sortMilestonesByDueDate } from '~/milestones/utils';
describe('sortMilestonesByDueDate', () => {
useFakeDate(2021, 6, 22);

View file

@ -1,65 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue';
import {
DropdownVariant,
LabelType,
} from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
describe('sidebar labels', () => {
let wrapper;
const defaultProps = {
allowLabelEdit: true,
iid: '1',
issuableType: 'issue',
projectIssuesPath: '/gitlab-org/gitlab-test/-/issues',
fullPath: 'gitlab-org/gitlab-test',
};
const findLabelsSelect = () => wrapper.find(LabelsSelect);
const mountComponent = (props = {}) => {
wrapper = shallowMount(SidebarLabels, {
provide: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('LabelsSelect props', () => {
describe.each`
issuableType
${'issue'}
${'merge_request'}
`('issuableType $issuableType', ({ issuableType }) => {
beforeEach(() => {
mountComponent({ issuableType });
});
it('has expected props', () => {
expect(findLabelsSelect().props()).toMatchObject({
iid: defaultProps.iid,
fullPath: defaultProps.fullPath,
allowLabelRemove: defaultProps.allowLabelEdit,
allowMultiselect: true,
footerCreateLabelTitle: 'Create project label',
footerManageLabelTitle: 'Manage project labels',
labelsCreateTitle: 'Create project label',
labelsFilterBasePath: defaultProps.projectIssuesPath,
variant: DropdownVariant.Sidebar,
issuableType,
workspaceType: 'project',
attrWorkspacePath: defaultProps.fullPath,
labelCreateType: LabelType.project,
});
});
});
});
});

View file

@ -9,7 +9,7 @@ import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
import { sortMilestonesByDueDate } from '~/milestones/utils';
import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
@ -17,7 +17,7 @@ import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/m
import { mockMilestoneToken, mockMilestones, mockRegularMilestone } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/milestones/milestone_utils');
jest.mock('~/milestones/utils');
const defaultStubs = {
Portal: true,

View file

@ -3205,11 +3205,21 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
context 'when pipeline is not child nor parent' do
let_it_be(:pipeline) { create(:ci_pipeline, :created) }
let_it_be(:build) { create(:ci_build, :with_deployment, :deploy_to_production, pipeline: pipeline) }
let_it_be(:build, refind: true) { create(:ci_build, :with_deployment, :deploy_to_production, pipeline: pipeline) }
it 'returns just the pipeline environment' do
expect(subject).to contain_exactly(build.deployment.environment)
end
context 'when deployment SHA is not matched' do
before do
build.deployment.update!(sha: 'old-sha')
end
it 'does not return environments' do
expect(subject).to be_empty
end
end
end
context 'when an associated environment does not have deployments' do

View file

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.shared_examples '.find_by_full_path' do
RSpec.shared_examples 'routable resource' do
describe '.find_by_full_path', :aggregate_failures do
it 'finds records by their full path' do
expect(described_class.find_by_full_path(record.full_path)).to eq(record)
@ -52,13 +52,27 @@ RSpec.shared_examples '.find_by_full_path' do
end
end
RSpec.describe Routable do
it_behaves_like '.find_by_full_path' do
let_it_be(:record) { create(:group) }
RSpec.shared_examples 'routable resource with parent' do
it_behaves_like 'routable resource'
describe '#full_path' do
it { expect(record.full_path).to eq "#{record.parent.full_path}/#{record.path}" }
it 'hits the cache when not preloaded' do
forcibly_hit_cached_lookup(record, :full_path)
expect(record.full_path).to eq("#{record.parent.full_path}/#{record.path}")
end
end
it_behaves_like '.find_by_full_path' do
let_it_be(:record) { create(:project) }
describe '#full_name' do
it { expect(record.full_name).to eq "#{record.parent.human_name} / #{record.name}" }
it 'hits the cache when not preloaded' do
forcibly_hit_cached_lookup(record, :full_name)
expect(record.full_name).to eq("#{record.parent.human_name} / #{record.name}")
end
end
end
@ -66,6 +80,14 @@ RSpec.describe Group, 'Routable', :with_clean_rails_cache do
let_it_be_with_reload(:group) { create(:group, name: 'foo') }
let_it_be(:nested_group) { create(:group, parent: group) }
it_behaves_like 'routable resource' do
let_it_be(:record) { group }
end
it_behaves_like 'routable resource with parent' do
let_it_be(:record) { nested_group }
end
describe 'Validations' do
it { is_expected.to validate_presence_of(:route) }
end
@ -119,24 +141,6 @@ RSpec.describe Group, 'Routable', :with_clean_rails_cache do
end
end
describe '.find_by_full_path' do
it_behaves_like '.find_by_full_path' do
let_it_be(:record) { group }
end
it_behaves_like '.find_by_full_path' do
let_it_be(:record) { nested_group }
end
it 'does not find projects with a matching path' do
project = create(:project)
redirect_route = create(:redirect_route, source: project)
expect(described_class.find_by_full_path(project.full_path)).to be_nil
expect(described_class.find_by_full_path(redirect_route.path, follow_redirects: true)).to be_nil
end
end
describe '.where_full_path_in' do
context 'without any paths' do
it 'returns an empty relation' do
@ -195,64 +199,39 @@ RSpec.describe Group, 'Routable', :with_clean_rails_cache do
expect(group.route_loaded?).to be_truthy
end
end
describe '#full_path' do
it { expect(group.full_path).to eq(group.path) }
it { expect(nested_group.full_path).to eq("#{group.full_path}/#{nested_group.path}") }
it 'hits the cache when not preloaded' do
forcibly_hit_cached_lookup(nested_group, :full_path)
expect(nested_group.full_path).to eq("#{group.full_path}/#{nested_group.path}")
end
end
describe '#full_name' do
it { expect(group.full_name).to eq(group.name) }
it { expect(nested_group.full_name).to eq("#{group.name} / #{nested_group.name}") }
it 'hits the cache when not preloaded' do
forcibly_hit_cached_lookup(nested_group, :full_name)
expect(nested_group.full_name).to eq("#{group.name} / #{nested_group.name}")
end
end
end
RSpec.describe Project, 'Routable', :with_clean_rails_cache do
let_it_be(:namespace) { create(:namespace) }
let_it_be(:project) { create(:project, namespace: namespace) }
it_behaves_like '.find_by_full_path' do
it_behaves_like 'routable resource with parent' do
let_it_be(:record) { project }
end
end
it 'does not find groups with a matching path' do
group = create(:group)
redirect_route = create(:redirect_route, source: group)
expect(described_class.find_by_full_path(group.full_path)).to be_nil
expect(described_class.find_by_full_path(redirect_route.path, follow_redirects: true)).to be_nil
end
describe '#full_path' do
it { expect(project.full_path).to eq "#{namespace.full_path}/#{project.path}" }
it 'hits the cache when not preloaded' do
forcibly_hit_cached_lookup(project, :full_path)
expect(project.full_path).to eq("#{namespace.full_path}/#{project.path}")
RSpec.describe Namespaces::ProjectNamespace, 'Routable', :with_clean_rails_cache do
let_it_be(:group) { create(:group) }
let_it_be(:project_namespace) do
# For now we create only project namespace w/o project, otherwise same path
# would be used for project and project namespace.
# This can be removed when route is created automatically for project namespaces.
# https://gitlab.com/gitlab-org/gitlab/-/issues/346448
create(:project_namespace, project: nil, parent: group,
visibility_level: Gitlab::VisibilityLevel::PUBLIC,
path: 'foo', name: 'foo').tap do |project_namespace|
Route.create!(source: project_namespace, path: project_namespace.full_path,
name: project_namespace.full_name)
end
end
describe '#full_name' do
it { expect(project.full_name).to eq "#{namespace.human_name} / #{project.name}" }
# we have couple of places where we use generic Namespace, in that case
# we don't want to include ProjectNamespace routes yet
it 'ignores project namespace when searching for generic namespace' do
redirect_route = create(:redirect_route, source: project_namespace)
it 'hits the cache when not preloaded' do
forcibly_hit_cached_lookup(project, :full_name)
expect(project.full_name).to eq("#{namespace.human_name} / #{project.name}")
end
expect(Namespace.find_by_full_path(project_namespace.full_path)).to be_nil
expect(Namespace.find_by_full_path(redirect_route.path, follow_redirects: true)).to be_nil
end
end