Commit 165b96b8 authored by Nick Thomas's avatar Nick Thomas

Merge branch '6878-add-epic-select-dropdown' into 'master'

Add Epics select dropdown to Issue sidebar

See merge request gitlab-org/gitlab-ee!14763
parents 248887b3 8fcaa3e9
...@@ -22,6 +22,8 @@ export default Vue.extend({ ...@@ -22,6 +22,8 @@ export default Vue.extend({
components: { components: {
AssigneeTitle, AssigneeTitle,
Assignees, Assignees,
SidebarEpicsSelect: () =>
import('ee_component/sidebar/components/sidebar_item_epics_select.vue'),
RemoveBtn, RemoveBtn,
Subscriptions, Subscriptions,
TimeTracker, TimeTracker,
......
...@@ -731,6 +731,66 @@ export const NavigationType = { ...@@ -731,6 +731,66 @@ export const NavigationType = {
TYPE_RESERVED: 255, TYPE_RESERVED: 255,
}; };
/**
* Method to perform case-insensitive search for a string
* within multiple properties and return object containing
* properties in case there are multiple matches or `null`
* if there's no match.
*
* Eg; Suppose we want to allow user to search using for a string
* within `iid`, `title`, `url` or `reference` props of a target object;
*
* const objectToSearch = {
* "iid": 1,
* "title": "Error omnis quos consequatur ullam a vitae sed omnis libero cupiditate. &3",
* "url": "/groups/gitlab-org/-/epics/1",
* "reference": "&1",
* };
*
* Following is how we call searchBy and the return values it will yield;
*
* - `searchBy('omnis', objectToSearch);`: This will return `{ title: ... }` as our
* query was found within title prop we only return that.
* - `searchBy('1', objectToSearch);`: This will return `{ "iid": ..., "reference": ..., "url": ... }`.
* - `searchBy('https://gitlab.com/groups/gitlab-org/-/epics/1', objectToSearch);`:
* This will return `{ "url": ... }`.
* - `searchBy('foo', objectToSearch);`: This will return `null` as no property value
* matched with our query.
*
* You can learn more about behaviour of this method by referring to tests
* within `spec/javascripts/lib/utils/common_utils_spec.js`.
*
* @param {string} query String to search for
* @param {object} searchSpace Object containing properties to search in for `query`
*/
export const searchBy = (query = '', searchSpace = {}) => {
const targetKeys = searchSpace !== null ? Object.keys(searchSpace) : [];
if (!query || !targetKeys.length) {
return null;
}
const normalizedQuery = query.toLowerCase();
const matches = targetKeys
.filter(item => {
const searchItem = `${searchSpace[item]}`.toLowerCase();
return (
searchItem.indexOf(normalizedQuery) > -1 ||
normalizedQuery.indexOf(searchItem) > -1 ||
normalizedQuery === searchItem
);
})
.reduce((acc, prop) => {
const match = acc;
match[prop] = searchSpace[prop];
return acc;
}, {});
return Object.keys(matches).length ? matches : null;
};
/** /**
* Checks if the given Label has a special syntax `::` in * Checks if the given Label has a special syntax `::` in
* it's title. * it's title.
......
...@@ -65,6 +65,8 @@ Example response: ...@@ -65,6 +65,8 @@ Example response:
"title": "Accusamus iste et ullam ratione voluptatem omnis debitis dolor est.", "title": "Accusamus iste et ullam ratione voluptatem omnis debitis dolor est.",
"description": "Molestias dolorem eos vitae expedita impedit necessitatibus quo voluptatum.", "description": "Molestias dolorem eos vitae expedita impedit necessitatibus quo voluptatum.",
"state": "opened", "state": "opened",
"web_edit_url": "http://localhost:3001/groups/test/-/epics/4",
"reference": "&4",
"author": { "author": {
"id": 10, "id": 10,
"name": "Lu Mayer", "name": "Lu Mayer",
...@@ -118,6 +120,8 @@ Example response: ...@@ -118,6 +120,8 @@ Example response:
"title": "Ea cupiditate dolores ut vero consequatur quasi veniam voluptatem et non.", "title": "Ea cupiditate dolores ut vero consequatur quasi veniam voluptatem et non.",
"description": "Molestias dolorem eos vitae expedita impedit necessitatibus quo voluptatum.", "description": "Molestias dolorem eos vitae expedita impedit necessitatibus quo voluptatum.",
"state": "opened", "state": "opened",
"web_edit_url": "http://localhost:3001/groups/test/-/epics/5",
"reference": "&5",
"author":{ "author":{
"id": 7, "id": 7,
"name": "Pamella Huel", "name": "Pamella Huel",
...@@ -182,6 +186,8 @@ Example response: ...@@ -182,6 +186,8 @@ Example response:
"title": "Epic", "title": "Epic",
"description": "Epic description", "description": "Epic description",
"state": "opened", "state": "opened",
"web_edit_url": "http://localhost:3001/groups/test/-/epics/6",
"reference": "&6",
"author": { "author": {
"name" : "Alexandra Bashirian", "name" : "Alexandra Bashirian",
"avatar_url" : null, "avatar_url" : null,
...@@ -247,6 +253,8 @@ Example response: ...@@ -247,6 +253,8 @@ Example response:
"title": "New Title", "title": "New Title",
"description": "Epic description", "description": "Epic description",
"state": "opened", "state": "opened",
"web_edit_url": "http://localhost:3001/groups/test/-/epics/6",
"reference": "&6",
"author": { "author": {
"name" : "Alexandra Bashirian", "name" : "Alexandra Bashirian",
"avatar_url" : null, "avatar_url" : null,
......
...@@ -7,6 +7,8 @@ export default { ...@@ -7,6 +7,8 @@ export default {
ldapGroupsPath: '/api/:version/ldap/:provider/groups.json', ldapGroupsPath: '/api/:version/ldap/:provider/groups.json',
subscriptionPath: '/api/:version/namespaces/:id/gitlab_subscription', subscriptionPath: '/api/:version/namespaces/:id/gitlab_subscription',
childEpicPath: '/api/:version/groups/:id/epics/:epic_iid/epics', childEpicPath: '/api/:version/groups/:id/epics/:epic_iid/epics',
groupEpicsPath: '/api/:version/groups/:id/epics',
epicIssuePath: '/api/:version/groups/:id/epics/:epic_iid/issues/:issue_id',
userSubscription(namespaceId) { userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId)); const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
...@@ -40,4 +42,28 @@ export default { ...@@ -40,4 +42,28 @@ export default {
title, title,
}); });
}, },
groupEpics({ groupId }) {
const url = Api.buildUrl(this.groupEpicsPath).replace(':id', groupId);
return axios.get(url);
},
addEpicIssue({ groupId, epicIid, issueId }) {
const url = Api.buildUrl(this.epicIssuePath)
.replace(':id', groupId)
.replace(':epic_iid', epicIid)
.replace(':issue_id', issueId);
return axios.post(url);
},
removeEpicIssue({ groupId, epicIid, epicIssueId }) {
const url = Api.buildUrl(this.epicIssuePath)
.replace(':id', groupId)
.replace(':epic_iid', epicIid)
.replace(':issue_id', epicIssueId);
return axios.delete(url);
},
}; };
<script>
import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import { spriteIcon } from '~/lib/utils/common_utils';
import Store from '../stores/sidebar_store';
import { GlLoadingIcon } from '@gitlab/ui';
export default {
name: 'SidebarItemEpic',
directives: {
tooltip,
},
components: {
GlLoadingIcon,
},
props: {
blockTitle: {
type: String,
required: false,
default: __('Epic'),
},
initialEpic: {
type: Object,
required: false,
default: () => null,
},
},
data() {
return {
store: !this.initialEpic ? new Store() : {},
};
},
computed: {
isLoading() {
return this.initialEpic ? false : this.store.isFetching.epic;
},
epic() {
return this.initialEpic || this.store.epic;
},
epicIcon() {
return spriteIcon('epic');
},
epicUrl() {
return this.epic.url;
},
epicTitle() {
return this.epic.title;
},
hasEpic() {
return this.epicUrl && this.epicTitle;
},
collapsedTitle() {
return this.hasEpic ? this.epicTitle : __('None');
},
tooltipTitle() {
if (!this.hasEpic) {
return __('Epic');
}
let tooltipTitle = this.epicTitle;
if (this.epic.human_readable_end_date || this.epic.human_readable_timestamp) {
tooltipTitle += '<br />';
tooltipTitle += this.epic.human_readable_end_date
? `${this.epic.human_readable_end_date} `
: '';
tooltipTitle += this.epic.human_readable_timestamp
? `(${this.epic.human_readable_timestamp})`
: '';
}
return tooltipTitle;
},
},
};
</script>
<template>
<div>
<div
v-tooltip
:title="tooltipTitle"
class="sidebar-collapsed-icon"
data-container="body"
data-placement="left"
data-html="true"
data-boundary="viewport"
>
<div v-html="epicIcon"></div>
<span v-if="!isLoading" class="collapse-truncated-title">{{ collapsedTitle }}</span>
</div>
<div class="title hide-collapsed">
{{ blockTitle }}
<gl-loading-icon v-if="isLoading" :inline="true" />
</div>
<div v-if="!isLoading" class="value hide-collapsed">
<a v-if="hasEpic" :href="epicUrl" class="bold">{{ epicTitle }}</a>
<span v-else class="no-value">{{ __('None') }}</span>
</div>
</div>
</template>
<script>
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { noneEpic } from 'ee/vue_shared/constants';
import EpicsSelect from 'ee/vue_shared/components/sidebar/epics_select/base.vue';
export default {
components: {
EpicsSelect,
},
props: {
canEdit: {
type: Boolean,
required: true,
},
sidebarStore: {
type: Object,
required: true,
},
groupId: {
type: Number,
required: true,
},
issueId: {
type: Number,
required: false,
default: 0,
},
epicIssueId: {
type: Number,
required: true,
default: 0,
},
initialEpic: {
type: Object,
required: false,
default: () => null,
},
},
data() {
return {
initialEpicLoading: this.getInitialEpicLoading(),
epic: this.getEpic(),
};
},
watch: {
/**
* sidebarStore is updated async while in Issue Boards
* hence we need a _deep watch_ to update `initialEpicLoading`
* and `epic` props.
*/
sidebarStore: {
handler() {
this.initialEpicLoading = this.getInitialEpicLoading();
this.epic = convertObjectPropsToCamelCase(this.getEpic());
},
deep: true,
},
},
methods: {
getInitialEpicLoading() {
if (this.initialEpic) {
return false;
} else if (this.sidebarStore.isFetching) {
return this.sidebarStore.isFetching.epic;
}
return false;
},
getEpic() {
if (this.initialEpic) {
return this.initialEpic;
} else if (this.sidebarStore.epic && this.sidebarStore.epic.id) {
return this.sidebarStore.epic;
}
return noneEpic;
},
},
};
</script>
<template>
<epics-select
:group-id="groupId"
:issue-id="issueId"
:epic-issue-id="epicIssueId"
:can-edit="canEdit"
:initial-epic="epic"
:initial-epic-loading="initialEpicLoading"
:block-title="__('Epic')"
>
{{ __('None') }}
</epics-select>
</template>
import Vue from 'vue'; import Vue from 'vue';
import * as CEMountSidebar from '~/sidebar/mount_sidebar'; import * as CEMountSidebar from '~/sidebar/mount_sidebar';
import { parseBoolean } from '~/lib/utils/common_utils';
import sidebarWeight from './components/weight/sidebar_weight.vue'; import sidebarWeight from './components/weight/sidebar_weight.vue';
import SidebarItemEpic from './components/sidebar_item_epic.vue';
function mountWeightComponent(mediator) { import SidebarItemEpicsSelect from './components/sidebar_item_epics_select.vue';
import SidebarStore from './stores/sidebar_store';
const mountWeightComponent = mediator => {
const el = document.querySelector('.js-sidebar-weight-entry-point'); const el = document.querySelector('.js-sidebar-weight-entry-point');
if (!el) return; if (!el) return false;
// eslint-disable-next-line no-new return new Vue({
new Vue({
el, el,
components: { components: {
sidebarWeight, sidebarWeight,
...@@ -21,25 +24,36 @@ function mountWeightComponent(mediator) { ...@@ -21,25 +24,36 @@ function mountWeightComponent(mediator) {
}, },
}), }),
}); });
} };
function mountEpic() { const mountEpicsSelect = () => {
const el = document.querySelector('#js-vue-sidebar-item-epic'); const el = document.querySelector('#js-vue-sidebar-item-epics-select');
if (!el) return; if (!el) return false;
// eslint-disable-next-line no-new const { groupId, issueId, epicIssueId, canEdit } = el.dataset;
new Vue({ const sidebarStore = new SidebarStore();
return new Vue({
el, el,
components: { components: {
SidebarItemEpic, SidebarItemEpicsSelect,
}, },
render: createElement => createElement('sidebar-item-epic', {}), render: createElement =>
createElement('sidebar-item-epics-select', {
props: {
sidebarStore,
groupId: Number(groupId),
issueId: Number(issueId),
epicIssueId: Number(epicIssueId),
canEdit: parseBoolean(canEdit),
},
}),
}); });
} };
export default function mountSidebar(mediator) { export default function mountSidebar(mediator) {
CEMountSidebar.mountSidebar(mediator); CEMountSidebar.mountSidebar(mediator);
mountWeightComponent(mediator); mountWeightComponent(mediator);
mountEpic(); mountEpicsSelect();
} }
<script>
import $ from 'jquery';
import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { noneEpic } from 'ee/vue_shared/constants';
import EpicsSelectService from './service/epics_select_service';
import EpicsSelectStore from './store/epics_select_store';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import DropdownButton from './dropdown_button.vue';
import DropdownHeader from './dropdown_header.vue';
import DropdownSearchInput from './dropdown_search_input.vue';
import DropdownContents from './dropdown_contents.vue';
export default {
components: {
GlLoadingIcon,
DropdownTitle,
DropdownValue,
DropdownValueCollapsed,
DropdownButton,
DropdownHeader,
DropdownSearchInput,
DropdownContents,
},
props: {
groupId: {
type: Number,
required: true,
},
issueId: {
type: Number,
required: true,
},
epicIssueId: {
type: Number,
required: true,
},
canEdit: {
type: Boolean,
required: true,
},
blockTitle: {
type: String,
required: true,
},
initialEpic: {
type: Object,
required: true,
},
initialEpicLoading: {
type: Boolean,
required: true,
},
},
data() {
return {
service: new EpicsSelectService({
groupId: this.groupId,
}),
store: new EpicsSelectStore({
selectedEpic: this.initialEpic,
groupId: this.groupId,
selectedEpicIssueId: this.epicIssueId,
}),
showDropdown: false,
isEpicSelectLoading: false,
isEpicsLoading: false,
};
},
computed: {
epics() {
return this.store.getEpics();
},
selectedEpic() {
return this.store.getSelectedEpic();
},
},
watch: {
/**
* Initial Epic is loaded via separate Sidebar store
* So we need to watch for updates before updating local store.
*/
initialEpicLoading() {
this.store.setSelectedEpic(this.initialEpic);
},
},
mounted() {
$(this.$refs.dropdown).on('shown.bs.dropdown', this.handleDropdownShown);
$(this.$refs.dropdown).on('hidden.bs.dropdown', this.handleDropdownHidden);
},
methods: {
fetchGroupEpics() {
this.isEpicsLoading = true;
return this.service
.getGroupEpics()
.then(({ data }) => {
this.isEpicsLoading = false;
this.store.setEpics(data);
})
.catch(() => {
this.isEpicsLoading = false;
createFlash(s__('Epics|Something went wrong while fetching group epics.'));
});
},
handleSelectSuccess({ data, epic, originalSelectedEpic }) {
// Verify if attachment was successful
this.isEpicSelectLoading = false;
if (data.epic.id === epic.id && data.issue.id === this.issueId) {
this.store.setSelectedEpicIssueId(data.id);
} else {
// Revert back to originally selected epic.
this.store.setSelectedEpic(originalSelectedEpic);
}
},
handleSelectFailure(errorMessage, originalSelectedEpic) {
this.isEpicSelectLoading = false;
// Revert back to originally selected epic in case of failure.
this.store.setSelectedEpic(originalSelectedEpic);
createFlash(errorMessage);
},
assignIssueToEpic(epic) {
const originalSelectedEpic = this.store.getSelectedEpic();
this.isEpicSelectLoading = true;
this.store.setSelectedEpic(epic);
return this.service
.assignIssueToEpic(this.issueId, epic)
.then(({ data }) => {
this.handleSelectSuccess({ data, epic, originalSelectedEpic });
})
.catch(() => {
this.handleSelectFailure(
s__('Epics|Something went wrong while assigning issue to epic.'),
originalSelectedEpic,
);
});
},
removeIssueFromEpic(epic) {
const originalSelectedEpic = this.store.getSelectedEpic();
this.isEpicSelectLoading = true;
this.store.setSelectedEpic(noneEpic);
return this.service
.removeIssueFromEpic(this.store.getSelectedEpicIssueId(), epic)
.then(({ data }) => {
this.handleSelectSuccess({ data, epic, originalSelectedEpic });
})
.catch(() => {
this.handleSelectFailure(
s__('Epics|Something went wrong while removing issue from epic.'),
originalSelectedEpic,
);
});
},
handleEditClick() {
this.showDropdown = true;
// Wait for component to render dropdown container
this.$nextTick(() => {
// We're not calling $.dropdown('show') to open
// dropdown and instead triggerring click on button
// so that clicking outside can make dropdown close
// additionally, this approach requires event trigger
// to be deferred so that it doesn't close
setTimeout(() => {
$(this.$refs.dropdownButton.$el).trigger('click');
});
});
},
handleDropdownShown() {
if (this.epics.length === 0) this.fetchGroupEpics();
},
handleDropdownHidden() {
this.showDropdown = false;
},
handleItemSelect(epic) {
if (epic.id === noneEpic.id && epic.title === noneEpic.title) {
this.removeIssueFromEpic(this.selectedEpic);
} else {
this.assignIssueToEpic(epic);
}
},
handleSearchInput(query) {
this.store.filterEpics(query);
},
},
};
</script>
<template>
<div class="block epic js-epic-block">
<dropdown-value-collapsed :epic="selectedEpic" />
<dropdown-title
:can-edit="canEdit"
:block-title="blockTitle"
:is-loading="initialEpicLoading || isEpicSelectLoading"
@onClickEdit="handleEditClick"
/>
<dropdown-value v-show="!showDropdown" :epic="selectedEpic">
<slot></slot>
</dropdown-value>
<div v-if="canEdit" v-show="showDropdown" class="epic-dropdown-container">
<div ref="dropdown" class="dropdown">
<dropdown-button ref="dropdownButton" />
<div
class="dropdown-menu dropdown-select
dropdown-menu-epics dropdown-menu-selectable"
>
<dropdown-header />
<dropdown-search-input @onSearchInput="handleSearchInput" />
<dropdown-contents
v-if="!isEpicsLoading"
:epics="epics"
:selected-epic="selectedEpic"
@onItemSelect="handleItemSelect"
/>
<gl-loading-icon v-if="isEpicsLoading" class="dropdown-contents-loading" size="md" />
</div>
</div>
</div>
</div>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
};
</script>
<template>
<button
type="button"
class="dropdown-menu-toggle js-epic-select js-extra-options"
data-display="static"
data-toggle="dropdown"
>
<span class="dropdown-toggle-text">{{ __('Epic') }}</span>
<icon name="chevron-down" />
</button>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { noneEpic } from 'ee/vue_shared/constants';
export default {
noneEpic,
components: {
GlLink,
Icon,
},
props: {
epics: {
type: Array,
required: true,
},
selectedEpic: {
type: Object,
required: false,
default: () => null,
},
},
computed: {
isNoEpic() {
return (
this.selectedEpic.id === this.$options.noneEpic.id &&
this.selectedEpic.title === this.$options.noneEpic.title
);
},
},
methods: {
isSelected(epic) {
return this.selectedEpic.id === epic.id;
},
handleItemClick(epic) {
if (epic.id !== this.selectedEpic.id) {
this.$emit('onItemSelect', epic);
} else if (epic.id !== noneEpic.id) {
this.$emit('onItemSelect', noneEpic);
}
},
},
};
</script>
<template>
<div class="dropdown-content">
<ul>
<li data-epic-id="None">
<gl-link
:class="{ 'is-active': isNoEpic }"
@click.prevent="handleItemClick($options.noneEpic)"
>{{ __('No Epic') }}</gl-link
>
</li>
<li class="divider"></li>
<li v-for="epic in epics" :key="epic.id">
<gl-link
:class="{ 'is-active': isSelected(epic) }"
@click.prevent="handleItemClick(epic)"
>{{ epic.title }}</gl-link
>
</li>
</ul>
</div>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
GlButton,
Icon,
},
};
</script>
<template>
<div class="dropdown-title">
<span>{{ __('Assign epic') }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
class="dropdown-title-button dropdown-menu-close"
>
<icon name="close" />
</gl-button>
</div>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
export default {
components: {
GlButton,
Icon,
},
directives: {
autofocusonshow,
},
data() {
return {
query: '',
};
},
methods: {
handleKeyUp() {
this.$emit('onSearchInput', this.query);
},
handleInputClear() {
this.query = '';
this.handleKeyUp();
},
},
};
</script>
<template>
<div :class="{ 'has-value': query }" class="dropdown-input">
<input
v-model.trim="query"
v-autofocusonshow
:placeholder="__('Search')"
autocomplete="off"
class="dropdown-input-field"
type="search"
@keyup="handleKeyUp"
/>
<icon v-show="!query" name="search" />
<gl-button
variant="link"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
data-hidden="true"
@click.stop="handleInputClear"
/>
</div>
</template>
<script>
import { GlLoadingIcon, GlLink } from '@gitlab/ui';
export default {
components: {
GlLoadingIcon,
GlLink,
},
props: {
canEdit: {
type: Boolean,
required: true,
},
blockTitle: {
type: String,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<div class="title hide-collapsed align-items-center append-bottom-10">
<div class="flex-grow-1">
<span :class="{ 'align-text-top': isLoading }">{{ blockTitle }}</span>
<gl-loading-icon v-show="isLoading" inline />
</div>
<template v-if="canEdit">
<gl-link
class="btn btn-blank float-right sidebar-dropdown-toggle"
@click="$emit('onClickEdit', $event)"
>{{ __('Edit') }}</gl-link
>
</template>
</div>
</template>
<script>
import { GlLink } from '@gitlab/ui';
export default {
components: {
GlLink,
},
props: {
epic: {
type: Object,
required: true,
},
},
computed: {
hasEpic() {
return this.epic ? Boolean(this.epic.title && this.epic.url) : false;
},
},
};
</script>
<template>
<div class="value js-epic-label hide-collapsed">
<gl-link v-if="hasEpic" :href="epic.url" class="bold">{{ epic.title }}</gl-link>
<span v-else class="no-value">
<slot></slot>
</span>
</div>
</template>
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
epic: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div
v-gl-tooltip="{ boundary: 'viewport', placement: 'left' }"
:title="epic.title"
class="sidebar-collapsed-icon"
>
<icon name="epic" />
<span class="collapse-truncated-title mt-1">{{ epic.title }}</span>
</div>
</template>
import Api from 'ee/api';
export default class EpicsSelectService {
constructor({ groupId }) {
this.groupId = groupId;
}
getGroupEpics() {
return Api.groupEpics({
groupId: this.groupId,
});
}
// eslint-disable-next-line class-methods-use-this
assignIssueToEpic(issueId, epic) {
return Api.addEpicIssue({
issueId,
groupId: epic.groupId,
epicIid: epic.iid,
});
}
// eslint-disable-next-line class-methods-use-this
removeIssueFromEpic(epicIssueId, epic) {
return Api.removeEpicIssue({
epicIssueId,
groupId: epic.groupId,
epicIid: epic.iid,
});
}
}
import { convertObjectPropsToCamelCase, searchBy } from '~/lib/utils/common_utils';
export default class EpicsSelectStore {
constructor({ groupId, selectedEpic, selectedEpicIssueId }) {
this.groupId = groupId;
this.state = {};
this.state.epics = [];
this.state.allEpics = [];
this.state.selectedEpic = selectedEpic;
this.state.selectedEpicIssueId = selectedEpicIssueId;
}
setEpics(rawEpics) {
// Cache all Epics so that
// during search, we only work with `state.epics`
this.state.allEpics = rawEpics
.filter(epic => epic.group_id === this.groupId)
.map(epic =>
convertObjectPropsToCamelCase(Object.assign(epic, { url: epic.web_edit_url }), {
dropKeys: ['web_edit_url'],
}),
);
this.state.epics = this.state.allEpics;
}
getEpics() {
return this.state.epics;
}
filterEpics(query) {
if (query) {
this.state.epics = this.state.allEpics.filter(epic => {
const { title, reference, url, iid } = epic;
// In case user has just pasted ID
// We need to be specific with the search
if (Number(query)) {
return query.includes(iid);
}
return searchBy(query, {
title,
reference,
url,
});
});
} else {
this.state.epics = this.state.allEpics;
}
}
setSelectedEpic(selectedEpic) {
this.state.selectedEpic = selectedEpic;
}
setSelectedEpicIssueId(selectedEpicIssueId) {
this.state.selectedEpicIssueId = selectedEpicIssueId;
}
getSelectedEpic() {
return this.state.selectedEpic;
}
getSelectedEpicIssueId() {
return this.state.selectedEpicIssueId;
}
}
// eslint-disable-next-line import/prefer-default-export
export const noneEpic = {
id: 0,
title: 'none',
};
...@@ -7,6 +7,28 @@ ...@@ -7,6 +7,28 @@
align-items: center; align-items: center;
} }
} }
&.epic {
.title {
display: flex;
}
.dropdown-contents-loading {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 9;
padding-top: 45px;
background-color: $dropdown-loading-bg;
}
.sidebar-dropdown-toggle:hover {
text-decoration: underline;
color: $blue-800;
}
}
} }
.add-issuable-form-input-wrapper { .add-issuable-form-input-wrapper {
...@@ -60,3 +82,12 @@ ...@@ -60,3 +82,12 @@
@include fixed-width-container; @include fixed-width-container;
} }
} }
// This override is needed because `display: flex`
// on `.epic` above causes hiding logic in global
// stylesheet is of lower specificity.
.right-sidebar.right-sidebar-collapsed {
.block.epic .hide-collapsed {
display: none;
}
}
...@@ -5,7 +5,12 @@ module EE ...@@ -5,7 +5,12 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepended do prepended do
expose :epic, using: EpicBaseEntity expose :epic do
expose :epic, merge: true, using: EpicBaseEntity
expose :epic_issue_id do |issuable|
issuable.epic_issue&.id
end
end
expose :weight expose :weight
end end
end end
......
...@@ -5,10 +5,12 @@ class EpicBaseEntity < Grape::Entity ...@@ -5,10 +5,12 @@ class EpicBaseEntity < Grape::Entity
include EntityDateHelper include EntityDateHelper
expose :id expose :id
expose :iid
expose :title expose :title
expose :url do |epic| expose :url do |epic|
group_epic_path(epic.group, epic) group_epic_path(epic.group, epic)
end end
expose :group_id
expose :human_readable_end_date, if: -> (epic, _) { epic.end_date.present? } do |epic| expose :human_readable_end_date, if: -> (epic, _) { epic.end_date.present? } do |epic|
epic.end_date&.to_s(:medium) epic.end_date&.to_s(:medium)
end end
......
- return unless @group&.feature_available?(:epics) || @project&.group&.feature_available?(:epics) - return unless @group&.feature_available?(:epics) || @project&.group&.feature_available?(:epics)
.block.epic %sidebar-epics-select{ ":sidebar-store" => "issue",
.title ":group-id": "#{@project&.group&.id}",
Epic ":issue-id": "issue.id",
%span.js-epic-label-loading{ "v-if" => "issue.isFetching && issue.isFetching.epic" } ":epic-issue-id": "(issue.epic && issue.epic.epic_issue_id) || 0",
= icon('spinner spin', class: 'loading-icon') ":can-edit": can_admin_issue? }
.value.js-epic-label{ "v-if" => "issue.isFetching && !issue.isFetching.epic" }
%a.bold{ "v-if" => "issue.epic", ":href" => "issue.epic.url" }
{{ issue.epic.title }}
.no-value{ "v-if" => "!issue.epic" }
None
- if issuable_sidebar[:supports_epic] - if issuable_sidebar[:supports_epic]
- if issuable_sidebar[:features_available][:epics] - if issuable_sidebar[:features_available][:epics]
.block.epic #js-vue-sidebar-item-epics-select{ data: { can_edit: can_admin_issue?.to_s, group_id: @project.group.id, issue_id: @issuable.id, epic_issue_id: @issuable.epic_issue&.id } }
#js-vue-sidebar-item-epic
.title.hide-collapsed
Epic
= icon('spinner spin')
- else - else
= render 'shared/promotions/promote_epics' = render 'shared/promotions/promote_epics'
---
title: Add Epics select dropdown to Issue sidebar
merge_request: 14763
author:
type: added
...@@ -260,6 +260,12 @@ module EE ...@@ -260,6 +260,12 @@ module EE
expose :due_date_is_fixed?, as: :due_date_is_fixed, if: can_admin_epic expose :due_date_is_fixed?, as: :due_date_is_fixed, if: can_admin_epic
expose :due_date_fixed, :due_date_from_milestones, if: can_admin_epic expose :due_date_fixed, :due_date_from_milestones, if: can_admin_epic
expose :state expose :state
expose :web_edit_url, if: can_admin_epic do |epic|
::Gitlab::Routing.url_helpers.group_epic_path(epic.group, epic)
end
expose :reference, if: { with_reference: true } do |epic|
epic.to_reference(full: true)
end
expose :created_at expose :created_at
expose :updated_at expose :updated_at
expose :labels do |epic| expose :labels do |epic|
...@@ -292,7 +298,9 @@ module EE ...@@ -292,7 +298,9 @@ module EE
class EpicIssueLink < Grape::Entity class EpicIssueLink < Grape::Entity
expose :id expose :id
expose :relative_position expose :relative_position
expose :epic, using: EE::API::Entities::Epic expose :epic do |epic_issue_link, _options|
::EE::API::Entities::Epic.represent(epic_issue_link.epic, with_reference: true)
end
expose :issue, using: ::API::Entities::IssueBasic expose :issue, using: ::API::Entities::IssueBasic
end end
......
...@@ -3,19 +3,68 @@ require 'spec_helper' ...@@ -3,19 +3,68 @@ require 'spec_helper'
describe 'Epic in issue sidebar', :js do describe 'Epic in issue sidebar', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:group) { create(:group, :public) } let(:group) { create(:group, :public) }
let(:epic) { create(:epic, group: group) } let(:epic1) { create(:epic, group: group, title: 'Foo') }
let!(:epic2) { create(:epic, group: group, title: 'Bar') }
let!(:epic3) { create(:epic, group: group, title: 'Baz') }
let(:project) { create(:project, :public, group: group) } let(:project) { create(:project, :public, group: group) }
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
let!(:epic_issue) { create(:epic_issue, epic: epic, issue: issue) } let!(:epic_issue) { create(:epic_issue, epic: epic1, issue: issue) }
shared_examples 'epic in issue sidebar' do shared_examples 'epic in issue sidebar' do
it 'shows epic in issue sidebar for projects with group' do context 'projects within a group' do
before do
group.add_owner(user)
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
end
it 'shows epic in issue sidebar' do
expect(page.find('.js-epic-block .value')).to have_content(epic1.title)
end
expect(page.find('.block.epic .value')).to have_content(epic.title) it 'shows edit button in issue sidebar' do
expect(page.find('.js-epic-block .sidebar-dropdown-toggle')).to have_content('Edit')
end end
it 'does not show epic in issue sidebar for personal projects' do it 'shows epics select dropdown' do
page.within(find('.js-epic-block')) do
page.find('.sidebar-dropdown-toggle').click
wait_for_requests
expect(page).to have_selector('.js-epic-select', visible: true)
expect(page.all('.dropdown-content li a').length).to eq(4) # `No Epic` + 3 epics
end
end
it 'supports searching for an epic' do
page.within(find('.js-epic-block')) do
page.find('.sidebar-dropdown-toggle').click
wait_for_requests
page.find('.dropdown-input-field').send_keys('Foo')
expect(page.all('.dropdown-content li a').length).to eq(2) # `No Epic` + 1 matching epic
end
end
it 'select an epic from the dropdown' do
page.within(find('.js-epic-block')) do
page.find('.sidebar-dropdown-toggle').click
wait_for_requests
click_link epic2.title
wait_for_requests
expect(page.find('.value')).to have_content(epic2.title)
end
end
end
context 'personal projects' do
it 'does not show epic in issue sidebar' do
personal_project = create(:project, :public) personal_project = create(:project, :public)
other_issue = create(:issue, project: personal_project) other_issue = create(:issue, project: personal_project)
...@@ -24,6 +73,7 @@ describe 'Epic in issue sidebar', :js do ...@@ -24,6 +73,7 @@ describe 'Epic in issue sidebar', :js do
expect_no_epic expect_no_epic
end end
end end
end
context 'when epics available' do context 'when epics available' do
before do before do
......
...@@ -8,7 +8,8 @@ ...@@ -8,7 +8,8 @@
"properties": { "properties": {
"id": { "type": "integer" }, "id": { "type": "integer" },
"title": { "type": "string" }, "title": { "type": "string" },
"url": { "type": "string" } "url": { "type": "string" },
"epic_issue_id": { "type": ["integer", "null"] }
} }
}, },
"weight": { "type": ["integer", "null"] } "weight": { "type": ["integer", "null"] }
......
...@@ -38,7 +38,9 @@ ...@@ -38,7 +38,9 @@
"due_date_is_fixed": { "type": "boolean" }, "due_date_is_fixed": { "type": "boolean" },
"state": { "type": "string" }, "state": { "type": "string" },
"created_at": { "type": ["string", "null"] }, "created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] } "updated_at": { "type": ["string", "null"] },
"web_edit_url": { "type": "string" },
"reference": { "type": "string" }
}, },
"required": [ "required": [
"id", "iid", "group_id", "title" "id", "iid", "group_id", "title"
......
...@@ -9,6 +9,20 @@ describe('Api', () => { ...@@ -9,6 +9,20 @@ describe('Api', () => {
api_version: dummyApiVersion, api_version: dummyApiVersion,
relative_url_root: dummyUrlRoot, relative_url_root: dummyUrlRoot,
}; };
const mockEpics = [
{
id: 1,
iid: 10,
group_id: 2,
title: 'foo',
},
{
id: 2,
iid: 11,
group_id: 2,
title: 'bar',
},
];
let originalGon; let originalGon;
let mock; let mock;
...@@ -70,4 +84,82 @@ describe('Api', () => { ...@@ -70,4 +84,82 @@ describe('Api', () => {
.catch(done.fail); .catch(done.fail);
}); });
}); });
describe('groupEpics', () => {
it('calls `axios.get` using param `groupId`', done => {
const groupId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics`;
mock.onGet(expectedUrl).reply(200, mockEpics);
Api.groupEpics({ groupId })
.then(({ data }) => {
data.forEach((epic, index) => {
expect(epic.id).toBe(mockEpics[index].id);
expect(epic.iid).toBe(mockEpics[index].iid);
expect(epic.group_id).toBe(mockEpics[index].group_id);
expect(epic.title).toBe(mockEpics[index].title);
});
})
.then(done)
.catch(done.fail);
});
});
describe('addEpicIssue', () => {
it('calls `axios.post` using params `groupId`, `epicIid` and `issueId`', done => {
const groupId = 2;
const mockIssue = {
id: 20,
};
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics/${mockEpics[0].iid}/issues/${mockIssue.id}`;
const expectedRes = {
id: 30,
epic: mockEpics[0],
issue: mockIssue,
};
mock.onPost(expectedUrl).reply(200, expectedRes);
Api.addEpicIssue({ groupId, epicIid: mockEpics[0].iid, issueId: mockIssue.id })
.then(({ data }) => {
expect(data.id).toBe(expectedRes.id);
expect(data.epic).toEqual(expect.objectContaining({ ...expectedRes.epic }));
expect(data.issue).toEqual(expect.objectContaining({ ...expectedRes.issue }));
})
.then(done)
.catch(done.fail);
});
});
describe('removeEpicIssue', () => {
it('calls `axios.delete` using params `groupId`, `epicIid` and `epicIssueId`', done => {
const groupId = 2;
const mockIssue = {
id: 20,
epic_issue_id: 40,
};
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/epics/${mockEpics[0].iid}/issues/${mockIssue.epic_issue_id}`;
const expectedRes = {
id: 30,
epic: mockEpics[0],
issue: mockIssue,
};
mock.onDelete(expectedUrl).reply(200, expectedRes);
Api.removeEpicIssue({
groupId,
epicIid: mockEpics[0].iid,
epicIssueId: mockIssue.epic_issue_id,
})
.then(({ data }) => {
expect(data.id).toBe(expectedRes.id);
expect(data.epic).toEqual(expect.objectContaining({ ...expectedRes.epic }));
expect(data.issue).toEqual(expect.objectContaining({ ...expectedRes.issue }));
})
.then(done)
.catch(done.fail);
});
});
}); });
import { shallowMount } from '@vue/test-utils';
import SidebarItemEpicsSelect from 'ee/sidebar/components/sidebar_item_epics_select.vue';
import { mockSidebarStore, mockEpic1, mockIssue } from '../mock_data';
describe('SidebarItemEpicsSelect', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(SidebarItemEpicsSelect, {
stubs: {
'epics-select': true,
},
propsData: {
canEdit: true,
sidebarStore: mockSidebarStore,
epicIssueId: mockSidebarStore.epic_issue_id,
groupId: mockEpic1.group_id,
issueId: mockIssue.id,
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('getInitialEpicLoading', () => {
it('should return `false` when `initialEpic` prop is provided', done => {
wrapper.setProps({
initialEpic: mockEpic1,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.getInitialEpicLoading()).toBe(false);
done();
});
});
it('should return value of `sidebarStore.isFetching.epic` when `initialEpic` prop is null and `isFetching` is available', done => {
wrapper.setProps({
sidebarStore: { isFetching: { epic: true } },
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.getInitialEpicLoading()).toBe(true);
done();
});
});
it('should return `false` when both `initialEpic` and `sidebarStore.isFetching` are unavailable', done => {
wrapper.setProps({
initialEpic: null,
sidebarStore: { isFetching: null },
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.getInitialEpicLoading()).toBe(false);
done();
});
});
});
describe('getEpic', () => {
it('should return value of `initialEpic` as it is when it is available', done => {
wrapper.setProps({
initialEpic: mockEpic1,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.getEpic()).toBe(mockEpic1);
done();
});
});
it('should return value of `sidebarStore.epic` as it is when it is available', () => {
expect(wrapper.vm.getEpic()).toBe(mockEpic1);
});
it('should return No Epic object as it is when both `initialEpic` & `sidebarStore.epic` are unavailable', done => {
wrapper.setProps({
initialEpic: null,
sidebarStore: { epic: null },
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.getEpic()).toEqual(
expect.objectContaining({
id: 0,
title: 'none',
}),
);
done();
});
});
});
});
describe('template', () => {
it('should render epics-select component', () => {
expect(wrapper.is('epics-select-stub')).toBe(true);
expect(wrapper.attributes('blocktitle')).toBe('Epic');
expect(wrapper.text()).toBe('None');
});
});
});
export const mockEpic1 = {
group_id: 2,
id: 15,
iid: 1,
reference: '&1',
title: 'Error omnis quos consequatur ullam a vitae sed omnis libero cupiditate.',
url: '/groups/gitlab-org/-/epics/1',
web_edit_url: '/groups/gitlab-org/-/epics/1',
};
export const mockEpic2 = {
group_id: 2,
id: 16,
iid: 2,
reference: '&2',
title: 'Enim reiciendis illo modi non voluptas molestiae error est quas.',
url: '/groups/gitlab-org/-/epics/2',
web_edit_url: '/groups/gitlab-org/-/epics/2',
};
export const mockIssue = {
id: 11,
epic_issue_id: 10,
};
export const mockSidebarStore = {
isFetching: {
epic: false,
},
epic_issue_id: 10,
epic: mockEpic1,
};
export const mockAssignRemoveRes = {
id: 22,
epic: mockEpic1,
issue: mockIssue,
};
export const noneEpic = {
id: 0,
title: 'none',
};
export const mockEpics = [mockEpic1, mockEpic2];
import { shallowMount } from '@vue/test-utils';
import Icon from '~/vue_shared/components/icon.vue';
import DropdownButton from 'ee/vue_shared/components/sidebar/epics_select/dropdown_button.vue';
describe('EpicsSelect', () => {
describe('DropdownButton', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(DropdownButton);
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('should render button element', () => {
expect(wrapper.is('button')).toBe(true);
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['dropdown-menu-toggle', 'js-epic-select', 'js-extra-options']),
);
expect(wrapper.attributes('data-display')).toBe('static');
expect(wrapper.attributes('data-toggle')).toBe('dropdown');
});
it('should render button title', () => {
const titleEl = wrapper.find('.dropdown-toggle-text');
expect(titleEl.exists()).toBe(true);
expect(titleEl.text()).toBe('Epic');
});
it('should render Icon component', () => {
const iconEl = wrapper.find(Icon);
expect(iconEl.exists()).toBe(true);
expect(iconEl.attributes('name')).toBe('chevron-down');
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import DropdownContents from 'ee/vue_shared/components/sidebar/epics_select/dropdown_contents.vue';
import { mockEpic1, mockEpic2, mockEpics, noneEpic } from '../../../../sidebar/mock_data';
const epics = mockEpics.map(epic => convertObjectPropsToCamelCase(epic));
describe('EpicsSelect', () => {
describe('DropdownContents', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(DropdownContents, {
propsData: {
epics,
selectedEpic: mockEpic1,
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('isNoEpic', () => {
it('should return true when `selectedEpic` is of type `No Epic`', done => {
wrapper.setProps({
selectedEpic: noneEpic,
});
wrapper.vm.$nextTick(() => {
expect(wrapper.vm.isNoEpic).toBe(true);
done();
});
});
it('should return false when `selectedEpic` is an epic', () => {
expect(wrapper.vm.isNoEpic).toBe(false);
});
});
});
describe('methods', () => {
describe('isSelected', () => {
it('should return true when passed `epic` param ID is same as `selectedEpic` prop', () => {
expect(wrapper.vm.isSelected(mockEpic1)).toBe(true);
});
it('should return false when passed `epic` param ID is different from `selectedEpic` prop', () => {
expect(wrapper.vm.isSelected(mockEpic2)).toBe(false);
});
});
describe('handleItemClick', () => {
it('should emit `onItemSelect` event with `epic` param when passed `epic` param is different from already selected epic', () => {
jest.spyOn(wrapper.vm, '$emit');
wrapper.vm.handleItemClick(mockEpic2);
expect(wrapper.vm.$emit).toHaveBeenCalledWith('onItemSelect', mockEpic2);
});
it('should emit `onItemSelect` event with `No Epic` param when passed `epic` param is same as already selected epic', () => {
jest.spyOn(wrapper.vm, '$emit');
wrapper.vm.handleItemClick(mockEpic1);
expect(wrapper.vm.$emit).toHaveBeenCalledWith('onItemSelect', noneEpic);
});
});
});
describe('template', () => {
it('should render container element', () => {
expect(wrapper.classes()).toContain('dropdown-content');
});
it('should render `No Epic` as first item within list', () => {
const noneEl = wrapper.find('ul > li');
expect(noneEl.attributes('data-epic-id')).toBe('None');
expect(noneEl.find(GlLink).exists()).toBe(true);
expect(noneEl.find(GlLink).text()).toBe('No Epic');
});
it('should render epics list for all provided epics', () => {
const epicsEl = wrapper.findAll('ul > li');
expect(epicsEl.length).toBe(epics.length + 2); // includes divider & No Epic` <li>.
expect(epicsEl.at(1).classes()).toContain('divider');
expect(
epicsEl
.at(2)
.find(GlLink)
.text(),
).toBe(epics[0].title);
expect(
epicsEl
.at(3)
.find(GlLink)
.text(),
).toBe(epics[1].title);
expect(
epicsEl
.at(2)
.find(GlLink)
.classes(),
).toContain('is-active');
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import DropdownHeader from 'ee/vue_shared/components/sidebar/epics_select/dropdown_header.vue';
describe('EpicsSelect', () => {
describe('DropdownHeader', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(DropdownHeader);
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('should render container element', () => {
expect(wrapper.classes()).toContain('dropdown-title');
});
it('should render title', () => {
expect(wrapper.find('span').text()).toBe('Assign epic');
});
it('should render close button', () => {
const buttonEl = wrapper.find(GlButton);
expect(buttonEl.exists()).toBe(true);
expect(buttonEl.attributes('aria-label')).toBe('Close');
expect(buttonEl.classes()).toEqual(
expect.arrayContaining(['dropdown-title-button', 'dropdown-menu-close']),
);
});
it('should render close button icon', () => {
const iconEl = wrapper.find(GlButton).find(Icon);
expect(iconEl.exists()).toBe(true);
expect(iconEl.attributes('name')).toBe('close');
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import DropdownSearchInput from 'ee/vue_shared/components/sidebar/epics_select/dropdown_search_input.vue';
const createComponent = () => {
const localVue = createLocalVue();
return shallowMount(DropdownSearchInput, {
localVue,
directives: {
/**
* We don't want any observers
* initialized during tests that this
* directive does.
*/
autofocusonshow: {},
},
});
};
describe('EpicsSelect', () => {
describe('DropdownSearchInput', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('methods', () => {
describe('handleKeyUp', () => {
it('should emit `onSearchInput` on component with `query` param', () => {
jest.spyOn(wrapper.vm, '$emit');
wrapper.setData({
query: 'foo',
});
wrapper.vm.handleKeyUp();
expect(wrapper.vm.$emit).toHaveBeenCalledWith('onSearchInput', 'foo');
});
});
describe('handleInputClear', () => {
it('should set `query` prop to empty string and calls `handleKeyUp`', () => {
jest.spyOn(wrapper.vm, 'handleKeyUp');
wrapper.setData({
query: 'foo',
});
wrapper.vm.handleInputClear();
expect(wrapper.vm.query).toBe('');
expect(wrapper.vm.handleKeyUp).toHaveBeenCalled();
});
});
});
describe('template', () => {
it('should render component container', () => {
expect(wrapper.classes()).toContain('dropdown-input');
expect(wrapper.classes()).not.toContain('has-value');
});
it('should add `has-value` class to container when `query` prop is not empty', () => {
wrapper.setData({
query: 'foo',
});
expect(wrapper.classes()).toContain('has-value');
});
it('should render input element', () => {
const inputEl = wrapper.find('input');
expect(inputEl.exists()).toBe(true);
expect(inputEl.classes()).toContain('dropdown-input-field');
expect(inputEl.attributes('placeholder')).toBe('Search');
expect(inputEl.attributes('type')).toBe('search');
expect(inputEl.attributes('autocomplete')).toBe('off');
});
it('should render Icon component', () => {
wrapper.setData({
query: 'foo',
});
const iconEl = wrapper.find(Icon);
expect(iconEl.exists()).toBe(true);
expect(iconEl.attributes('name')).toBe('search');
});
it('should render input clear button', () => {
const clearButtonEl = wrapper.find(GlButton);
expect(clearButtonEl.exists()).toBe(true);
expect(clearButtonEl.classes()).toEqual(
expect.arrayContaining([
'fa',
'fa-times',
'dropdown-input-clear',
'js-dropdown-input-clear',
]),
);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon, GlLink } from '@gitlab/ui';
import DropdownTitle from 'ee/vue_shared/components/sidebar/epics_select/dropdown_title.vue';
describe('EpicsSelect', () => {
describe('DropdownTitle', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(DropdownTitle, {
propsData: {
canEdit: false,
blockTitle: 'Epic',
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('should render component container', () => {
expect(wrapper.classes()).toEqual(expect.arrayContaining(['title', 'hide-collapsed']));
});
it('should render title element', () => {
wrapper.setProps({
isLoading: true,
});
const titleEl = wrapper.find('.flex-grow-1');
expect(titleEl.exists()).toBe(true);
expect(titleEl.find('span').classes()).toContain('align-text-top');
expect(titleEl.find('span').text()).toBe('Epic');
});
it('should render loading icon when `isLoading` prop is true', () => {
wrapper.setProps({
isLoading: true,
});
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('should render edit link when `canEdit` prop is true', () => {
wrapper.setProps({
canEdit: true,
});
const editEl = wrapper.find(GlLink);
expect(editEl.exists()).toBe(true);
expect(editEl.classes()).toContain('sidebar-dropdown-toggle');
expect(editEl.text()).toBe('Edit');
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import Icon from '~/vue_shared/components/icon.vue';
import DropdownValueCollapsed from 'ee/vue_shared/components/sidebar/epics_select/dropdown_value_collapsed.vue';
import { mockEpic1 } from '../../../../sidebar/mock_data';
describe('EpicsSelect', () => {
describe('DropdownValueCollapsed', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(DropdownValueCollapsed, {
directives: {
GlTooltip: {},
},
propsData: {
epic: mockEpic1,
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('should render component container', () => {
expect(wrapper.classes()).toContain('sidebar-collapsed-icon');
expect(wrapper.attributes('title')).toBe(mockEpic1.title);
});
it('should render Icon component', () => {
const iconEl = wrapper.find(Icon);
expect(iconEl.exists()).toBe(true);
expect(iconEl.attributes('name')).toBe('epic');
});
it('should render epic title element', () => {
const titleEl = wrapper.find('.collapse-truncated-title');
expect(titleEl.exists()).toBe(true);
expect(titleEl.text()).toBe(mockEpic1.title);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import DropdownValue from 'ee/vue_shared/components/sidebar/epics_select/dropdown_value.vue';
import { mockEpic1 } from '../../../../sidebar/mock_data';
describe('EpicsSelect', () => {
describe('DropdownValue', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(DropdownValue, {
propsData: {
epic: mockEpic1,
},
slots: {
default: 'None',
},
});
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('hasEpic', () => {
it('should return `true` when provided `epic` prop is a valid Epic object with title and url', () => {
expect(wrapper.vm.hasEpic).toBe(true);
});
it('should return `false` when provided `epic` prop is an invalid Epic object', () => {
wrapper.setProps({
epic: {},
});
expect(wrapper.vm.hasEpic).toBe(false);
});
});
});
describe('template', () => {
it('should render component container', () => {
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['value', 'js-epic-label', 'hide-collapsed']),
);
});
it('should render epic title link element when `hasEpic` is true', () => {
const titleEl = wrapper.find(GlLink);
expect(titleEl.exists()).toBe(true);
expect(titleEl.attributes('href')).toBe(mockEpic1.url);
expect(titleEl.text()).toBe(mockEpic1.title);
});
it('should render no value element with slot contents when `hasEpic` is false', () => {
wrapper.setProps({
epic: {},
});
const noValueEl = wrapper.find('span.no-value');
expect(noValueEl.exists()).toBe(true);
expect(noValueEl.text()).toBe('None');
});
});
});
});
import Api from 'ee/api';
import EpicsSelectService from 'ee/vue_shared/components/sidebar/epics_select/service/epics_select_service';
import {
mockEpic1,
mockIssue,
mockEpics,
mockAssignRemoveRes,
} from '../../../../../sidebar/mock_data';
describe('EpicsSelect', () => {
describe('Service', () => {
const service = new EpicsSelectService({ groupId: mockEpic1.group_id });
describe('getGroupEpics', () => {
it('calls `Api.groupEpics` with `groupId`', () => {
jest.spyOn(Api, 'groupEpics').mockResolvedValue({ data: mockEpics });
service.getGroupEpics();
expect(Api.groupEpics).toHaveBeenCalledWith(
expect.objectContaining({
groupId: mockEpic1.group_id,
}),
);
});
});
describe('assignIssueToEpic', () => {
it('calls `Api.addEpicIssue` with `issueId`, `groupId` & `epicIid`', () => {
jest.spyOn(Api, 'addEpicIssue').mockResolvedValue({ data: mockAssignRemoveRes });
service.assignIssueToEpic(mockIssue.id, {
groupId: mockEpic1.group_id,
iid: mockEpic1.iid,
});
expect(Api.addEpicIssue).toHaveBeenCalledWith(
expect.objectContaining({
issueId: mockIssue.id,
groupId: mockEpic1.group_id,
epicIid: mockEpic1.iid,
}),
);
});
});
describe('removeIssueFromEpic', () => {
it('calls `Api.removeEpicIssue` with `epicIssueId`, `groupId` & `epicIid`', () => {
jest.spyOn(Api, 'removeEpicIssue').mockResolvedValue({ data: mockAssignRemoveRes });
service.removeIssueFromEpic(mockIssue.epic_issue_id, {
groupId: mockEpic1.group_id,
iid: mockEpic1.iid,
});
expect(Api.removeEpicIssue).toHaveBeenCalledWith(
expect.objectContaining({
epicIssueId: mockIssue.epic_issue_id,
groupId: mockEpic1.group_id,
epicIid: mockEpic1.iid,
}),
);
});
});
});
});
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import EpicsSelectStore from 'ee/vue_shared/components/sidebar/epics_select/store/epics_select_store';
import { mockIssue, mockEpics } from '../../../../../sidebar/mock_data';
describe('EpicsSelect', () => {
describe('Store', () => {
const normalizedEpics = mockEpics.map(epic =>
convertObjectPropsToCamelCase(Object.assign(epic, { url: epic.web_edit_url }), {
dropKeys: ['web_edit_url'],
}),
);
let store;
beforeEach(() => {
store = new EpicsSelectStore({
groupId: normalizedEpics[0].groupId,
selectedEpic: normalizedEpics[0],
selectedEpicIssueId: mockIssue.epic_issue_id,
});
});
describe('constructor', () => {
it('should initialize `state` with all the required properties', () => {
expect(store.groupId).toBe(normalizedEpics[0].groupId);
expect(store.state).toEqual(
expect.objectContaining({
epics: [],
allEpics: [],
selectedEpic: normalizedEpics[0],
selectedEpicIssueId: mockIssue.epic_issue_id,
}),
);
});
});
describe('setEpics', () => {
it('should set passed `rawEpics` into the store state by normalizing it', () => {
store.setEpics(mockEpics);
expect(store.state.epics.length).toBe(mockEpics.length);
expect(store.state.allEpics.length).toBe(mockEpics.length);
expect(store.state.epics[0]).toEqual(
expect.objectContaining({
...normalizedEpics[0],
}),
);
expect(store.state.allEpics[0]).toEqual(
expect.objectContaining({
...normalizedEpics[0],
}),
);
});
});
describe('getEpics', () => {
it('should return value of `state.epics`', () => {
store.setEpics(mockEpics);
const epics = store.getEpics();
expect(epics.length).toBe(mockEpics.length);
});
});
describe('filterEpics', () => {
beforeEach(() => {
store.setEpics(mockEpics);
});
it('should return `state.epics` filtered Epic Title', () => {
store.filterEpics('consequatur');
const epics = store.getEpics();
expect(epics.length).toBe(1);
expect(epics[0]).toEqual(
expect.objectContaining({
...normalizedEpics[0],
}),
);
});
it('should return `state.epics` filtered Epic Reference', () => {
store.filterEpics('gitlab-org&1');
const epics = store.getEpics();
expect(epics.length).toBe(1);
expect(epics[0]).toEqual(
expect.objectContaining({
...normalizedEpics[0],
}),
);
});
it('should return `state.epics` filtered Epic URL', () => {
store.filterEpics('http://gitlab.example.com/groups/gitlab-org/-/epics/2');
const epics = store.getEpics();
expect(epics.length).toBe(1);
expect(epics[0]).toEqual(
expect.objectContaining({
...normalizedEpics[1],
}),
);
});
it('should return `state.epics` filtered Epic Iid', () => {
store.filterEpics('2');
const epics = store.getEpics();
expect(epics.length).toBe(1);
expect(epics[0]).toEqual(
expect.objectContaining({
...normalizedEpics[1],
}),
);
});
it('should return `state.epics` without any filters when query is empty', () => {
store.filterEpics('');
const epics = store.getEpics();
expect(epics.length).toBe(normalizedEpics.length);
epics.forEach((epic, index) => {
expect.objectContaining({
...normalizedEpics[index],
});
});
});
});
describe('setSelectedEpic', () => {
it('should set provided `selectedEpic` param to store state', () => {
store.setSelectedEpic(normalizedEpics[1]);
expect(store.state.selectedEpic).toBe(normalizedEpics[1]);
});
});
describe('setSelectedEpicIssueId', () => {
it('should set provided `selectedEpicIssueId` param to store state', () => {
store.setSelectedEpicIssueId(7);
expect(store.state.selectedEpicIssueId).toBe(7);
});
});
describe('getSelectedEpic', () => {
it('should return value of `selectedEpic` from store state', () => {
store.setSelectedEpic(normalizedEpics[1]);
expect(store.getSelectedEpic()).toBe(normalizedEpics[1]);
});
});
describe('getSelectedEpicIssueId', () => {
it('should return value of `selectedEpicIssueId` from store state', () => {
store.setSelectedEpicIssueId(7);
expect(store.getSelectedEpicIssueId()).toBe(7);
});
});
});
});
import Vue from 'vue';
import CESidebarStore from '~/sidebar/stores/sidebar_store';
import SidebarStore from 'ee/sidebar/stores/sidebar_store';
import sidebarItemEpic from 'ee/sidebar/components/sidebar_item_epic.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('sidebarItemEpic', () => {
let vm;
let sidebarStore;
beforeEach(() => {
sidebarStore = new SidebarStore({
currentUser: '',
rootPath: '',
editable: false,
});
const SidebarItemEpic = Vue.extend(sidebarItemEpic);
vm = mountComponent(SidebarItemEpic, {
initialEpic: null,
});
});
afterEach(() => {
vm.$destroy();
CESidebarStore.singleton = null;
});
describe('loading', () => {
it('shows loading icon', () => {
expect(vm.$el.querySelector('.fa-spin')).toBeDefined();
});
it('hides collapsed title', () => {
expect(vm.$el.querySelector('.sidebar-collapsed-icon .collapsed-truncated-title')).toBeNull();
});
});
describe('loaded', () => {
const epicTitle = 'epic title';
const url = 'https://gitlab.com/';
beforeEach(done => {
sidebarStore.setEpicData({
epic: {
title: epicTitle,
id: 1,
url,
},
});
Vue.nextTick(done);
});
it('shows epic title', () => {
expect(vm.$el.querySelector('.value').innerText.trim()).toEqual(epicTitle);
});
it('links epic title to epic url', () => {
expect(vm.$el.querySelector('a').href).toEqual(url);
});
it('shows epic title as collapsed title tooltip', () => {
expect(vm.$el.querySelector('.sidebar-collapsed-icon').getAttribute('title')).toBeDefined();
expect(
vm.$el.querySelector('.sidebar-collapsed-icon').getAttribute('data-original-title'),
).toEqual(epicTitle);
});
describe('no epic', () => {
beforeEach(done => {
sidebarStore.epic = {};
Vue.nextTick(done);
});
it('shows none as the epic text', () => {
expect(vm.$el.querySelector('.value').innerText.trim()).toEqual('None');
});
it('shows none as the collapsed title', () => {
expect(vm.$el.querySelector('.collapse-truncated-title').innerText.trim()).toEqual('None');
});
it('hides collapsed title tooltip', () => {
expect(vm.$el.querySelector('.collapse-truncated-title').getAttribute('title')).toBeNull();
});
});
});
});
...@@ -1830,6 +1830,9 @@ msgstr "" ...@@ -1830,6 +1830,9 @@ msgstr ""
msgid "Assign custom color like #FF0000" msgid "Assign custom color like #FF0000"
msgstr "" msgstr ""
msgid "Assign epic"
msgstr ""
msgid "Assign labels" msgid "Assign labels"
msgstr "" msgstr ""
...@@ -5689,12 +5692,21 @@ msgstr "" ...@@ -5689,12 +5692,21 @@ msgstr ""
msgid "Epics|Show more" msgid "Epics|Show more"
msgstr "" msgstr ""
msgid "Epics|Something went wrong while assigning issue to epic."
msgstr ""
msgid "Epics|Something went wrong while creating child epics." msgid "Epics|Something went wrong while creating child epics."
msgstr "" msgstr ""
msgid "Epics|Something went wrong while fetching child epics." msgid "Epics|Something went wrong while fetching child epics."
msgstr "" msgstr ""
msgid "Epics|Something went wrong while fetching group epics."
msgstr ""
msgid "Epics|Something went wrong while removing issue from epic."
msgstr ""
msgid "Epics|These dates affect how your epics appear in the roadmap. Dates from milestones come from the milestones assigned to issues in the epic. You can also set fixed dates or remove them entirely." msgid "Epics|These dates affect how your epics appear in the roadmap. Dates from milestones come from the milestones assigned to issues in the epic. You can also set fixed dates or remove them entirely."
msgstr "" msgstr ""
...@@ -9902,6 +9914,9 @@ msgstr "" ...@@ -9902,6 +9914,9 @@ msgstr ""
msgid "No %{providerTitle} repositories available to import" msgid "No %{providerTitle} repositories available to import"
msgstr "" msgstr ""
msgid "No Epic"
msgstr ""
msgid "No Label" msgid "No Label"
msgstr "" msgstr ""
......
...@@ -895,6 +895,45 @@ describe('common_utils', () => { ...@@ -895,6 +895,45 @@ describe('common_utils', () => {
}); });
}); });
describe('searchBy', () => {
const searchSpace = {
iid: 1,
reference: '&1',
title: 'Error omnis quos consequatur ullam a vitae sed omnis libero cupiditate.',
url: '/groups/gitlab-org/-/epics/1',
};
it('returns null when `query` or `searchSpace` params are empty/undefined', () => {
expect(commonUtils.searchBy('omnis', null)).toBeNull();
expect(commonUtils.searchBy('', searchSpace)).toBeNull();
expect(commonUtils.searchBy()).toBeNull();
});
it('returns object with matching props based on `query` & `searchSpace` params', () => {
// String `omnis` is found only in `title` prop so return just that
expect(commonUtils.searchBy('omnis', searchSpace)).toEqual(
jasmine.objectContaining({
title: searchSpace.title,
}),
);
// String `1` is found in both `iid` and `reference` props so return both
expect(commonUtils.searchBy('1', searchSpace)).toEqual(
jasmine.objectContaining({
iid: searchSpace.iid,
reference: searchSpace.reference,
}),
);
// String `/epics/1` is found in `url` prop so return just that
expect(commonUtils.searchBy('/epics/1', searchSpace)).toEqual(
jasmine.objectContaining({
url: searchSpace.url,
}),
);
});
});
describe('isScopedLabel', () => { describe('isScopedLabel', () => {
it('returns true when `::` is present in title', () => { it('returns true when `::` is present in title', () => {
expect(commonUtils.isScopedLabel({ title: 'foo::bar' })).toBe(true); expect(commonUtils.isScopedLabel({ title: 'foo::bar' })).toBe(true);
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment