Commit 6c732761 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'kp-make-refactored-epics-app-default' into 'master'

Make refactored Epic app as default

Closes #7753 and #7751

See merge request gitlab-org/gitlab-ee!9361
parents 3fd19685 86a7f16f
......@@ -24,6 +24,7 @@ export default {
},
computed: {
...mapState([
'sidebarCollapsed',
'epicDeleteInProgress',
'epicStatusChangeInProgress',
'author',
......@@ -64,7 +65,7 @@ export default {
});
},
methods: {
...mapActions(['requestEpicStatusChangeSuccess', 'toggleEpicStatus']),
...mapActions(['toggleSidebar', 'requestEpicStatusChangeSuccess', 'toggleEpicStatus']),
},
};
</script>
......@@ -103,5 +104,14 @@ export default {
@click="toggleEpicStatus(isEpicOpen)"
/>
</div>
<button
:aria-label="__('Toggle sidebar')"
class="btn btn-default float-right d-block d-sm-none
gutter-toggle issuable-gutter-toggle js-sidebar-toggle"
type="button"
@click="toggleSidebar({ sidebarCollapsed })"
>
<i class="fa fa-angle-double-left"></i>
</button>
</div>
</template>
......@@ -40,6 +40,18 @@ export default {
};
},
},
mounted() {
document.addEventListener(
'toggleSidebarRevealLabelsDropdown',
this.toggleSidebarRevealLabelsDropdown,
);
},
beforeDestroy() {
document.removeEventListener(
'toggleSidebarRevealLabelsDropdown',
this.toggleSidebarRevealLabelsDropdown,
);
},
methods: {
...mapActions(['toggleSidebar']),
toggleSidebarRevealLabelsDropdown() {
......
......@@ -11,6 +11,11 @@ import EpicCreateApp from './components/epic_create.vue';
export default (epicCreate = false) => {
const el = document.getElementById(epicCreate ? 'epic-create-root' : 'epic-app-root');
if (!el) {
return false;
}
const store = createStore();
if (epicCreate) {
......
export const status = {
open: 'opened',
close: 'closed',
};
export const stateEvent = {
close: 'close',
reopen: 'reopen',
};
<script>
import $ from 'jquery';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import Icon from '~/vue_shared/components/icon.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import { __, s__ } from '~/locale';
import eventHub from '../../event_hub';
import { stateEvent } from '../../constants';
export default {
name: 'EpicHeader',
directives: {
tooltip,
},
components: {
Icon,
LoadingButton,
userAvatarLink,
timeagoTooltip,
},
props: {
author: {
type: Object,
required: true,
validator: value => value.url && value.username && value.name,
},
created: {
type: String,
required: true,
},
open: {
type: Boolean,
required: true,
},
canUpdate: {
required: true,
type: Boolean,
},
},
data() {
return {
deleteLoading: false,
statusUpdating: false,
isEpicOpen: this.open,
};
},
computed: {
statusIcon() {
return this.isEpicOpen ? 'issue-open-m' : 'mobile-issue-close';
},
statusText() {
return this.isEpicOpen ? __('Open') : __('Closed');
},
actionButtonClass() {
return `btn btn-grouped js-btn-epic-action qa-close-reopen-epic-button ${
this.isEpicOpen ? 'btn-close' : 'btn-open'
}`;
},
actionButtonText() {
return this.isEpicOpen ? __('Close epic') : __('Reopen epic');
},
},
mounted() {
$(document).on('issuable_vue_app:change', (e, isClosed) => {
this.isEpicOpen = e.detail ? !e.detail.isClosed : !isClosed;
this.statusUpdating = false;
});
},
methods: {
deleteEpic() {
// eslint-disable-next-line no-alert
if (window.confirm(s__('Epic will be removed! Are you sure?'))) {
this.deleteLoading = true;
this.$emit('deleteEpic');
}
},
toggleSidebar() {
eventHub.$emit('toggleSidebar');
},
toggleStatus() {
this.statusUpdating = true;
this.$emit('toggleEpicStatus', this.isEpicOpen ? stateEvent.close : stateEvent.reopen);
},
},
};
</script>
<template>
<div class="detail-page-header">
<div class="detail-page-header-body">
<div
:class="{ 'status-box-open': isEpicOpen, 'status-box-issue-closed': !isEpicOpen }"
class="issuable-status-box status-box"
>
<icon :name="statusIcon" css-classes="d-block d-sm-none" />
<span class="d-none d-sm-block">{{ statusText }}</span>
</div>
<div class="issuable-meta">
{{ s__('Opened') }}
<timeago-tooltip :time="created" />
{{ s__('by') }}
<strong>
<user-avatar-link
:link-href="author.url"
:img-src="author.src"
:img-size="24"
:tooltip-text="author.username"
:username="author.name"
img-css-classes="avatar-inline"
/>
</strong>
</div>
</div>
<div v-if="canUpdate" class="detail-page-header-actions js-issuable-actions">
<loading-button
:label="actionButtonText"
:loading="statusUpdating"
:container-class="actionButtonClass"
@click="toggleStatus"
/>
</div>
<button
:aria-label="__('toggle collapse')"
class="btn btn-default float-right d-block d-sm-none
gutter-toggle issuable-gutter-toggle js-sidebar-toggle"
type="button"
@click="toggleSidebar"
>
<i class="fa fa-angle-double-left"></i>
</button>
</div>
</template>
<script>
/* eslint-disable vue/require-default-prop */
import $ from 'jquery';
import issuableApp from '~/issue_show/components/app.vue';
import flash from '~/flash';
import { __ } from '~/locale';
import relatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue';
import epicSidebar from '../../sidebar/components/sidebar_app.vue';
import SidebarContext from '../sidebar_context';
import epicHeader from './epic_header.vue';
import EpicsService from '../../service/epics_service';
import { status, stateEvent } from '../../constants';
export default {
name: 'EpicShowApp',
epicsPathIdSeparator: '&',
components: {
epicHeader,
epicSidebar,
issuableApp,
relatedIssuesRoot,
},
props: {
epicId: {
type: Number,
required: true,
},
endpoint: {
type: String,
required: true,
},
updateEndpoint: {
type: String,
required: true,
},
canUpdate: {
required: true,
type: Boolean,
},
canDestroy: {
required: true,
type: Boolean,
},
canAdmin: {
required: true,
type: Boolean,
},
subepicsSupported: {
type: Boolean,
required: false,
default: true,
},
markdownPreviewPath: {
type: String,
required: true,
},
markdownDocsPath: {
type: String,
required: true,
},
groupPath: {
type: String,
required: true,
},
initialTitleHtml: {
type: String,
required: true,
},
initialTitleText: {
type: String,
required: true,
},
initialDescriptionHtml: {
type: String,
required: false,
default: '',
},
initialDescriptionText: {
type: String,
required: false,
default: '',
},
created: {
type: String,
required: true,
},
author: {
type: Object,
required: true,
},
epicLinksEndpoint: {
type: String,
required: true,
},
issueLinksEndpoint: {
type: String,
required: true,
},
startDateIsFixed: {
type: Boolean,
required: true,
},
startDateFixed: {
type: String,
required: false,
default: '',
},
startDateFromMilestones: {
type: String,
required: false,
default: '',
},
startDate: {
type: String,
required: false,
},
dueDateIsFixed: {
type: Boolean,
required: true,
},
dueDateFixed: {
type: String,
required: false,
default: '',
},
dueDateFromMilestones: {
type: String,
required: false,
default: '',
},
endDate: {
type: String,
required: false,
},
startDateSourcingMilestoneTitle: {
type: String,
required: false,
default: '',
},
startDateSourcingMilestoneDates: {
type: Object,
required: true,
default: () => ({ startDate: '', dueDate: '' }),
},
dueDateSourcingMilestoneTitle: {
type: String,
required: false,
default: '',
},
dueDateSourcingMilestoneDates: {
type: Object,
required: true,
default: () => ({ startDate: '', dueDate: '' }),
},
labels: {
type: Array,
required: true,
},
parent: {
type: Object,
required: false,
default: () => ({}),
},
participants: {
type: Array,
required: true,
},
subscribed: {
type: Boolean,
required: true,
},
todoExists: {
type: Boolean,
required: true,
},
namespace: {
type: String,
required: false,
default: '#',
},
labelsPath: {
type: String,
required: true,
},
toggleSubscriptionPath: {
type: String,
required: true,
},
todoPath: {
type: String,
required: true,
},
todoDeletePath: {
type: String,
required: false,
default: '',
},
labelsWebUrl: {
type: String,
required: true,
},
epicsWebUrl: {
type: String,
required: true,
},
state: {
type: String,
required: true,
default: status.open,
},
},
data() {
return {
// Epics specific configuration
issuableRef: '',
hasRelatedEpicsFeature: this.subepicsSupported,
projectPath: this.groupPath,
parentEpic: this.parent ? this.parent : {},
projectNamespace: '',
service: new EpicsService({
endpoint: this.endpoint,
}),
};
},
computed: {
open() {
return this.state === status.open;
},
},
mounted() {
this.sidebarContext = new SidebarContext();
},
methods: {
triggerDocumentEvent(eventName, isClosed) {
$(document).trigger(eventName, isClosed);
},
toggleEpicStatus(stateEventType) {
return this.service
.updateStatus(stateEventType)
.then(() => {
const isClosed = stateEventType === stateEvent.close;
// Ensure that status change is reflected across the page.
// As `Close`/`Reopen` button is also present under
// comment form (part of Notes app)
// We've wrapped call to `$(document).trigger` for ease of testing
this.triggerDocumentEvent('issuable_vue_app:change', isClosed);
this.triggerDocumentEvent('issuable:change', isClosed);
})
.catch(() => {
flash(__('Unable to update this epic at this time.'));
const isClosed = stateEventType !== stateEvent.close;
this.triggerDocumentEvent('issuable_vue_app:change', isClosed);
this.triggerDocumentEvent('issuable:change', isClosed);
});
},
},
};
</script>
<template>
<div class="epic-page-container">
<epic-header
:author="author"
:created="created"
:open="open"
:can-delete="canDestroy"
:can-update="canUpdate"
@toggleEpicStatus="toggleEpicStatus"
/>
<div class="issuable-details content-block">
<div class="detail-page-description">
<issuable-app
:can-update="canUpdate"
:can-destroy="canDestroy"
:endpoint="endpoint"
:update-endpoint="updateEndpoint"
:issuable-ref="issuableRef"
:initial-title-html="initialTitleHtml"
:initial-title-text="initialTitleText"
:initial-description-html="initialDescriptionHtml"
:initial-description-text="initialDescriptionText"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-inline-edit-button="true"
:show-delete-button="canDestroy"
:enable-autocomplete="true"
issuable-type="epic"
/>
</div>
<epic-sidebar
:epic-id="epicId"
:endpoint="endpoint"
:editable="canUpdate"
:initial-start-date-is-fixed="startDateIsFixed"
:initial-start-date-fixed="startDateFixed"
:start-date-from-milestones="startDateFromMilestones"
:initial-start-date="startDate"
:initial-due-date-is-fixed="dueDateIsFixed"
:initial-due-date-fixed="dueDateFixed"
:due-date-from-milestones="dueDateFromMilestones"
:initial-end-date="endDate"
:start-date-sourcing-milestone-title="startDateSourcingMilestoneTitle"
:start-date-sourcing-milestone-dates="startDateSourcingMilestoneDates"
:due-date-sourcing-milestone-title="dueDateSourcingMilestoneTitle"
:due-date-sourcing-milestone-dates="dueDateSourcingMilestoneDates"
:initial-labels="labels"
:initial-participants="participants"
:initial-subscribed="subscribed"
:initial-todo-exists="todoExists"
:parent="parentEpic"
:namespace="namespace"
:update-path="updateEndpoint"
:labels-path="labelsPath"
:toggle-subscription-path="toggleSubscriptionPath"
:todo-path="todoPath"
:todo-delete-path="todoDeletePath"
:labels-web-url="labelsWebUrl"
:epics-web-url="epicsWebUrl"
/>
<related-issues-root
v-if="hasRelatedEpicsFeature"
:endpoint="epicLinksEndpoint"
:can-admin="canAdmin"
:can-reorder="canAdmin"
:allow-auto-complete="false"
:path-id-separator="$options.epicsPathIdSeparator"
:title="__('Epics')"
:issuable-type="__('epic')"
css-class="js-related-epics-block"
/>
<related-issues-root
:endpoint="issueLinksEndpoint"
:can-admin="canAdmin"
:can-reorder="canAdmin"
:allow-auto-complete="false"
:title="__('Issues')"
:issuable-type="__('issue')"
css-class="js-related-issues-block"
path-id-separator="#"
/>
</div>
</div>
</template>
import Vue from 'vue';
import Cookies from 'js-cookie';
import bp from '~/breakpoints';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import EpicShowApp from './components/epic_show_app.vue';
export default () => {
const el = document.querySelector('#epic-show-app');
const metaData = convertObjectPropsToCamelCase(JSON.parse(el.dataset.meta), { deep: true });
const initialData = JSON.parse(el.dataset.initial);
// Collapse the sidebar on mobile screens by default
const bpBreakpoint = bp.getBreakpointSize();
if (bpBreakpoint === 'xs' || bpBreakpoint === 'sm') {
Cookies.set('collapsed_gutter', true);
}
const props = Object.assign({}, initialData, metaData, el.dataset);
return new Vue({
el,
components: {
'epic-show-app': EpicShowApp,
},
render: createElement =>
createElement('epic-show-app', {
props,
}),
});
};
import $ from 'jquery';
import Cookies from 'js-cookie';
import bp from '~/breakpoints';
export default class SidebarContext {
constructor() {
const $issuableSidebar = $('.js-issuable-update');
$issuableSidebar
.off('click', '.js-sidebar-dropdown-toggle')
.on('click', '.js-sidebar-dropdown-toggle', function onClickEdit(e) {
e.preventDefault();
const $block = $(this).parents('.js-labels-block');
const $selectbox = $block.find('.js-selectbox');
// We use `:visible` to detect element visibility
// since labels dropdown itself is handled by
// labels_select.js which internally uses
// $.hide() & $.show() to toggle elements
// which requires us to use `display: none;`
// in `labels_select/base.vue` as well.
// see: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/4773#note_61844731
if ($selectbox.is(':visible')) {
$selectbox.hide();
$block.find('.js-value').show();
} else {
$selectbox.show();
$block.find('.js-value').hide();
}
if ($selectbox.is(':visible')) {
setTimeout(() => $block.find('.js-label-select').trigger('click'), 0);
}
});
window.addEventListener('beforeunload', () => {
// collapsed_gutter cookie hides the sidebar
const bpBreakpoint = bp.getBreakpointSize();
if (bpBreakpoint === 'xs' || bpBreakpoint === 'sm') {
Cookies.set('collapsed_gutter', true);
}
});
}
}
import Vue from 'vue';
export default new Vue();
<script>
import Flash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import loadingButton from '~/vue_shared/components/loading_button.vue';
import NewEpicService from '../services/new_epic_service';
export default {
name: 'NewEpic',
components: {
loadingButton,
},
props: {
endpoint: {
type: String,
required: true,
},
alignRight: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
service: new NewEpicService(this.endpoint),
creating: false,
title: '',
};
},
computed: {
buttonLabel() {
return this.creating ? s__('Creating epic') : s__('Create epic');
},
isCreatingDisabled() {
return this.title.length === 0;
},
},
methods: {
createEpic() {
this.creating = true;
this.service
.createEpic(this.title)
.then(({ data }) => {
visitUrl(data.web_url);
})
.catch(() => {
this.creating = false;
Flash(s__('Error creating epic'));
});
},
focusInput() {
// Wait for dropdown to appear because of transition CSS
setTimeout(() => {
this.$refs.title.focus();
}, 25);
},
},
};
</script>
<template>
<div class="dropdown new-epic-dropdown">
<button
class="btn btn-success qa-new-epic-button"
type="button"
data-toggle="dropdown"
@click="focusInput"
>
{{ s__('New epic') }}
</button>
<div :class="{ 'dropdown-menu-right': alignRight }" class="dropdown-menu">
<input
ref="title"
v-model="title"
:placeholder="s__('Title')"
type="text"
class="form-control qa-epic-title"
/>
<loading-button
:disabled="isCreatingDisabled"
:loading="creating"
:label="buttonLabel"
container-class="btn btn-success btn-inverted prepend-top-10 qa-create-epic-button"
@click.stop="createEpic"
/>
</div>
</div>
</template>
import Vue from 'vue';
import NewEpicApp from './components/new_epic.vue';
export default () => {
const el = document.querySelector('#new-epic-app');
if (el) {
const props = el.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
'new-epic-app': NewEpicApp,
},
render: createElement =>
createElement('new-epic-app', {
props,
}),
});
}
};
import axios from '~/lib/utils/axios_utils';
export default class NewEpicService {
constructor(endpoint) {
this.endpoint = endpoint;
}
createEpic(title) {
return axios.post(this.endpoint, {
title,
});
}
}
import axios from '~/lib/utils/axios_utils';
export default class EpicsService {
constructor({ endpoint }) {
this.endpoint = endpoint;
}
updateStatus(stateEventType) {
const queryParam = `epic[state_event]=${stateEventType}`;
return axios.put(`${this.endpoint}.json?${encodeURI(queryParam)}`);
}
}
<script>
import _ from 'underscore';
import { __, s__ } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
import popover from '~/vue_shared/directives/popover';
import Icon from '~/vue_shared/components/icon.vue';
import DatePicker from '~/vue_shared/components/pikaday.vue';
import CollapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue';
import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue';
import { GlLoadingIcon } from '@gitlab/ui';
const label = __('Date picker');
const pickerLabel = __('Fixed date');
export default {
directives: {
tooltip,
popover,
},
components: {
Icon,
DatePicker,
CollapsedCalendarIcon,
ToggleSidebar,
GlLoadingIcon,
},
props: {
blockClass: {
type: String,
required: false,
default: '',
},
collapsed: {
type: Boolean,
required: false,
default: true,
},
showToggleSidebar: {
type: Boolean,
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
editable: {
type: Boolean,
required: false,
default: false,
},
label: {
type: String,
required: false,
default: label,
},
datePickerLabel: {
type: String,
required: false,
default: pickerLabel,
},
selectedDate: {
type: Date,
required: false,
default: null,
},
selectedDateIsFixed: {
type: Boolean,
required: false,
default: true,
},
dateFromMilestones: {
type: Date,
required: false,
default: null,
},
dateFixed: {
type: Date,
required: false,
default: null,
},
dateFromMilestonesTooltip: {
type: String,
required: false,
default: '',
},
isDateInvalid: {
type: Boolean,
required: false,
default: true,
},
dateInvalidTooltip: {
type: String,
required: false,
default: '',
},
},
data() {
return {
fieldName: _.uniqueId('dateType_'),
editing: false,
};
},
computed: {
selectedAndEditable() {
return this.selectedDate && this.editable;
},
selectedDateWords() {
return dateInWords(this.selectedDate, true);
},
dateFixedWords() {
return dateInWords(this.dateFixed, true);
},
dateFromMilestonesWords() {
return this.dateFromMilestones ? dateInWords(this.dateFromMilestones, true) : __('None');
},
collapsedText() {
return this.selectedDateWords ? this.selectedDateWords : __('None');
},
popoverOptions() {
return this.getPopoverConfig({
title: s__(
'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.',
),
content: `
<a
href="${gon.gitlab_url}/help/user/group/epics/index.md#start-date-and-due-date"
target="_blank"
rel="noopener noreferrer"
>${s__('Epics|More information')}</a>
`,
});
},
dateInvalidPopoverOptions() {
return this.getPopoverConfig({
title: this.dateInvalidTooltip,
content: `
<a
href="${gon.gitlab_url}/help/user/group/epics/index.md#start-date-and-due-date"
target="_blank"
rel="noopener noreferrer"
>${s__('Epics|How can I solve this?')}</a>
`,
});
},
},
methods: {
getPopoverConfig({ title, content }) {
return {
html: true,
trigger: 'focus',
container: 'body',
boundary: 'viewport',
template: `
<div class="popover" role="tooltip">
<div class="arrow"></div>
<div class="popover-header"></div>
<div class="popover-body"></div>
</div>
`,
title,
content,
};
},
stopEditing() {
this.editing = false;
this.$emit('toggleDateType', true, true);
},
toggleDatePicker() {
this.editing = !this.editing;
},
newDateSelected(date = null) {
this.editing = false;
this.$emit('saveDate', date);
},
toggleDateType(dateTypeFixed) {
this.$emit('toggleDateType', dateTypeFixed);
},
toggleSidebar() {
this.$emit('toggleCollapse');
},
},
};
</script>
<template>
<div :class="blockClass" class="block date">
<collapsed-calendar-icon :text="collapsedText" class="sidebar-collapsed-icon" />
<div class="title">
{{ label }}
<gl-loading-icon v-if="isLoading" :inline="true" />
<div class="float-right d-flex">
<icon
v-popover="popoverOptions"
name="question-o"
css-classes="help-icon append-right-5"
tab-index="0"
/>
<button
v-show="editable && !editing"
type="button"
class="btn-blank btn-link btn-primary-hover-link btn-sidebar-action"
@click="toggleDatePicker"
>
{{ __('Edit') }}
</button>
<toggle-sidebar v-if="showToggleSidebar" :collapsed="collapsed" @toggle="toggleSidebar" />
</div>
</div>
<div class="value">
<div
:class="{ 'is-option-selected': selectedDateIsFixed, 'd-flex': !editing }"
class="value-type-fixed"
>
<input
v-if="!editing && editable"
:name="fieldName"
:checked="selectedDateIsFixed"
type="radio"
@click="toggleDateType(true)"
/>
<span v-show="!editing" class="prepend-left-5">{{ __('Fixed:') }}</span>
<date-picker
v-if="editing"
:selected-date="dateFixed"
:label="datePickerLabel"
@newDateSelected="newDateSelected"
@hidePicker="stopEditing"
/>
<span v-else class="d-flex value-content">
<template v-if="dateFixed">
<span>{{ dateFixedWords }}</span>
<icon
v-if="isDateInvalid && selectedDateIsFixed"
v-popover="dateInvalidPopoverOptions"
name="warning"
css-classes="date-warning-icon append-right-5 prepend-left-5"
tab-index="0"
/>
<span v-if="selectedAndEditable" class="no-value">
-
<button
type="button"
class="btn-blank btn-link btn-default-hover-link"
@click="newDateSelected(null)"
>
{{ __('remove') }}
</button>
</span>
</template>
<span v-else class="no-value"> {{ __('None') }} </span>
</span>
</div>
<abbr
v-tooltip
:title="dateFromMilestonesTooltip"
:class="{ 'is-option-selected': !selectedDateIsFixed }"
class="value-type-dynamic d-flex prepend-top-10"
data-placement="bottom"
data-html="true"
>
<input
v-if="editable"
:name="fieldName"
:checked="!selectedDateIsFixed"
type="radio"
@click="toggleDateType(false)"
/>
<span class="prepend-left-5">{{ __('From milestones:') }}</span>
<span class="value-content">{{ dateFromMilestonesWords }}</span>
<icon
v-if="isDateInvalid && !selectedDateIsFixed"
v-popover="dateInvalidPopoverOptions"
name="warning"
css-classes="date-warning-icon prepend-left-5"
tab-index="0"
/>
</abbr>
</div>
</div>
</template>
<script>
import Participants from '~/sidebar/components/participants/participants.vue';
export default {
components: {
Participants,
},
props: {
participants: {
type: Array,
required: true,
},
},
methods: {
onToggleSidebar() {
this.$emit('toggleCollapse');
},
},
};
</script>
<template>
<div class="block participants">
<participants :participants="participants" @toggleSidebar="onToggleSidebar" />
</div>
</template>
<script>
import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
export default {
components: {
Subscriptions,
},
props: {
loading: {
type: Boolean,
required: true,
},
subscribed: {
type: Boolean,
required: true,
},
},
methods: {
onToggleSubscription() {
this.$emit('toggleSubscription');
},
onToggleSidebar() {
this.$emit('toggleCollapse');
},
},
};
</script>
<template>
<div class="block subscriptions">
<subscriptions
:loading="loading"
:subscribed="subscribed"
@toggleSubscription="onToggleSubscription"
@toggleSidebar="onToggleSidebar"
/>
</div>
</template>
import axios from '~/lib/utils/axios_utils';
export default class SidebarService {
constructor({ endpoint, subscriptionEndpoint, todoPath }) {
this.endpoint = endpoint;
this.subscriptionEndpoint = subscriptionEndpoint;
this.todoPath = todoPath;
}
updateStartDate({ dateValue, isFixed }) {
const requestBody = {
start_date_is_fixed: isFixed,
};
if (isFixed) {
requestBody.start_date_fixed = dateValue;
}
return axios.put(this.endpoint, requestBody);
}
updateEndDate({ dateValue, isFixed }) {
const requestBody = {
due_date_is_fixed: isFixed,
};
if (isFixed) {
requestBody.due_date_fixed = dateValue;
}
return axios.put(this.endpoint, requestBody);
}
toggleSubscribed() {
return axios.post(this.subscriptionEndpoint);
}
addTodo(epicId) {
return axios.post(this.todoPath, {
issuable_id: epicId,
issuable_type: 'epic',
});
}
// eslint-disable-next-line class-methods-use-this
deleteTodo(todoDeletePath) {
return axios.delete(todoDeletePath);
}
}
import { parsePikadayDate } from '~/lib/utils/datetime_utility';
export default class SidebarStore {
constructor({
startDateIsFixed,
startDateFixed,
startDateFromMilestones,
startDate,
dueDateIsFixed,
dueDateFixed,
dueDateFromMilestones,
endDate,
subscribed,
todoExists,
todoDeletePath,
}) {
this.startDateIsFixed = startDateIsFixed;
this.startDateFixed = startDateFixed;
this.startDateFromMilestones = startDateFromMilestones;
this.startDate = startDate;
this.dueDateFixed = dueDateFixed;
this.dueDateIsFixed = dueDateIsFixed;
this.dueDateFromMilestones = dueDateFromMilestones;
this.endDate = endDate;
this.subscribed = subscribed;
this.todoExists = todoExists;
this.todoDeletePath = todoDeletePath;
}
get startDateTime() {
return this.startDate ? parsePikadayDate(this.startDate) : null;
}
get startDateTimeFixed() {
return this.startDateFixed ? parsePikadayDate(this.startDateFixed) : null;
}
get startDateTimeFromMilestones() {
return this.startDateFromMilestones ? parsePikadayDate(this.startDateFromMilestones) : null;
}
get endDateTime() {
return this.endDate ? parsePikadayDate(this.endDate) : null;
}
get dueDateTimeFixed() {
return this.dueDateFixed ? parsePikadayDate(this.dueDateFixed) : null;
}
get dueDateTimeFromMilestones() {
return this.dueDateFromMilestones ? parsePikadayDate(this.dueDateFromMilestones) : null;
}
setSubscribed(subscribed) {
this.subscribed = subscribed;
}
setTodoExists(todoExists) {
this.todoExists = todoExists;
}
setTodoDeletePath(deletePath) {
this.todoDeletePath = deletePath;
}
}
import Cookies from 'js-cookie';
import { parseBoolean } from '~/lib/utils/common_utils';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import FilteredSearchTokenKeysEpics from 'ee/filtered_search/filtered_search_token_keys_epics';
import initNewEpic from 'ee/epics/new_epic/new_epic_bundle';
import initEpicCreateApp from 'ee/epic/epic_bundle';
document.addEventListener('DOMContentLoaded', () => {
......@@ -14,9 +11,5 @@ document.addEventListener('DOMContentLoaded', () => {
stateFiltersSelector: '.epics-state-filters',
});
if (parseBoolean(Cookies.get('load_new_epic_app'))) {
initEpicCreateApp(true);
} else {
initNewEpic();
}
initEpicCreateApp(true);
});
import ZenMode from '~/zen_mode';
import Cookies from 'js-cookie';
import initEpicShow from 'ee/epics/epic_show/epic_show_bundle';
import ShortcutsEpic from 'ee/behaviors/shortcuts/shortcuts_epic';
import initEpicApp from 'ee/epic/epic_bundle';
import '~/notes/index';
import { parseBoolean } from '~/lib/utils/common_utils';
document.addEventListener('DOMContentLoaded', () => {
new ZenMode(); // eslint-disable-line no-new
if (parseBoolean(Cookies.get('load_new_epic_app'))) {
initEpicApp();
} else {
initEpicShow();
new ShortcutsEpic(); // eslint-disable-line no-new
}
initEpicApp();
new ShortcutsEpic(); // eslint-disable-line no-new
});
import initFilteredSearch from '~/pages/search/init_filtered_search';
import FilteredSearchTokenKeysEpics from 'ee/filtered_search/filtered_search_token_keys_epics';
import initNewEpic from 'ee/epics/new_epic/new_epic_bundle';
import initEpicCreateApp from 'ee/epic/epic_bundle';
import initRoadmap from 'ee/roadmap/index';
document.addEventListener('DOMContentLoaded', () => {
......@@ -11,6 +11,6 @@ document.addEventListener('DOMContentLoaded', () => {
filteredSearchTokenKeys: FilteredSearchTokenKeysEpics,
stateFiltersSelector: '.epics-state-filters',
});
initNewEpic();
initEpicCreateApp(true);
initRoadmap();
});
......@@ -4,12 +4,9 @@ import { dateInWords } from '~/lib/utils/datetime_utility';
import { PRESET_TYPES, emptyStateDefault, emptyStateWithFilters } from '../constants';
import NewEpic from '../../epics/new_epic/components/new_epic.vue';
import initEpicCreate from '../../epic/epic_bundle';
export default {
components: {
NewEpic,
},
props: {
presetType: {
type: String,
......@@ -93,6 +90,15 @@ export default {
});
},
},
mounted() {
// If filters are not applied and yet user
// is seeing empty state, we need to show
// `New epic` button, so boot-up Epic app
// in create mode.
if (!this.hasFiltersApplied) {
initEpicCreate(true);
}
},
};
</script>
......@@ -106,7 +112,11 @@ export default {
<h4>{{ message }}</h4>
<p v-html="subMessage"></p>
<div class="text-center">
<new-epic v-if="!hasFiltersApplied" :endpoint="newEpicEndpoint" />
<div
v-if="!hasFiltersApplied"
id="epic-create-root"
:data-endpoint="newEpicEndpoint"
></div>
<a :title="__('List')" :href="newEpicEndpoint" class="btn btn-default">
<span>{{ s__('View epics list') }}</span>
</a>
......
.new-epic-dropdown,
.epic-create-dropdown {
.dropdown-menu {
padding-left: $gl-padding-top;
......@@ -15,12 +14,12 @@
}
.empty-state {
.new-epic-dropdown,
.epic-create-dropdown {
display: inline-flex;
.btn-success {
margin: 0;
margin-top: $gl-padding-top;
}
}
}
......@@ -4,10 +4,7 @@
= render 'shared/issuable/epic_nav', type: :epics
.nav-controls
- if can?(current_user, :create_epic, @group)
- if cookies[:load_new_epic_app] == 'true'
#epic-create-root{ data: { endpoint: request.url, 'align-right' => true } }
- else
#new-epic-app{ data: { endpoint: request.url, 'align-right' => true } }
#epic-create-root{ data: { endpoint: request.url, 'align-right' => true } }
= render 'shared/epic/search_bar', type: :epics
......
......@@ -11,10 +11,7 @@
- page_card_attributes @epic.card_attributes
- if cookies[:load_new_epic_app] == 'true'
#epic-app-root{ data: epic_show_app_data(@epic, author_icon: avatar_icon_for_user(@epic.author), initial: issuable_initial_data(@epic)) }
- else
#epic-show-app{ data: epic_show_app_data(@epic, author_icon: avatar_icon_for_user(@epic.author), initial: issuable_initial_data(@epic)) }
#epic-app-root{ data: epic_show_app_data(@epic, author_icon: avatar_icon_for_user(@epic.author), initial: issuable_initial_data(@epic)) }
.content-block.emoji-block
.row
......
......@@ -10,6 +10,6 @@
= _('To view the roadmap, add a start or due date to one of your epics in this group or its subgroups. In the months view, only epics in the past month, current month, and next 5 months are shown.')
.text-center
- if can?(current_user, :create_epic, @group)
#new-epic-app{ data: { endpoint: request.url } }
#epic-create-root{ data: { endpoint: request.url } }
= link_to group_epics_path(@group), title: 'List', class: 'btn' do
%span= _('View epics list')
---
title: Refactored Epic app in Vuex for better performance and maintenance
merge_request: 9361
author:
type: performance
......@@ -15,7 +15,7 @@ describe 'New Epic', :js do
it 'does not show the create button' do
visit group_epics_path(group)
expect(page).not_to have_selector('.new-epic-dropdown .btn-success')
expect(page).not_to have_selector('.epic-create-dropdown .btn-success')
end
end
......@@ -26,7 +26,7 @@ describe 'New Epic', :js do
end
it 'does show the create button' do
expect(page).to have_selector('.new-epic-dropdown .btn-success')
expect(page).to have_selector('.epic-create-dropdown .btn-success')
end
end
end
......@@ -40,7 +40,7 @@ describe 'New Epic', :js do
end
it 'does not show the create button' do
expect(page).not_to have_selector('.new-epic-dropdown .btn-success')
expect(page).not_to have_selector('.epic-create-dropdown .btn-success')
end
end
......@@ -51,13 +51,13 @@ describe 'New Epic', :js do
end
it 'does show the create button' do
expect(page).to have_selector('.new-epic-dropdown .btn-success')
expect(page).to have_selector('.epic-create-dropdown .btn-success')
end
it 'can create epic' do
find('.new-epic-dropdown .btn-success').click
find('.new-epic-dropdown .dropdown-menu input').set('test epic title')
find('.new-epic-dropdown .dropdown-menu .btn-success').click
find('.epic-create-dropdown .btn-success').click
find('.epic-create-dropdown .dropdown-menu input').set('test epic title')
find('.epic-create-dropdown .dropdown-menu .btn-success').click
wait_for_requests
......
......@@ -124,5 +124,15 @@ describe('EpicHeaderComponent', () => {
'Close epic',
);
});
it('renders toggle sidebar button element', () => {
const toggleButtonEl = vm.$el.querySelector('button.js-sidebar-toggle');
expect(toggleButtonEl).not.toBeNull();
expect(toggleButtonEl.getAttribute('aria-label')).toBe('Toggle sidebar');
expect(toggleButtonEl.classList.contains('d-block')).toBe(true);
expect(toggleButtonEl.classList.contains('d-sm-none')).toBe(true);
expect(toggleButtonEl.classList.contains('gutter-toggle')).toBe(true);
});
});
});
import Vue from 'vue';
import epicHeader from 'ee/epics/epic_show/components/epic_header.vue';
import { stateEvent } from 'ee/epics/constants';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { headerProps } from '../mock_data';
describe('epicHeader', () => {
let vm;
const { author } = headerProps;
beforeEach(() => {
const EpicHeader = Vue.extend(epicHeader);
vm = mountComponent(EpicHeader, headerProps);
});
it('should render timeago tooltip', () => {
expect(vm.$el.querySelector('time')).toBeDefined();
});
it('should link to author url', () => {
expect(vm.$el.querySelector('a').href).toEqual(author.url);
});
it('should render author avatar', () => {
expect(vm.$el.querySelector('img').src).toEqual(`${author.src}?width=24`);
});
it('should render author name', () => {
expect(vm.$el.querySelector('.user-avatar-link').innerText.trim()).toEqual(author.name);
});
it('should render username tooltip', () => {
expect(vm.$el.querySelector('.js-user-avatar-link-username').dataset.originalTitle).toEqual(
author.username,
);
});
it('should render sidebar toggle button', () => {
expect(vm.$el.querySelector('button.js-sidebar-toggle')).not.toBe(null);
});
it('should render status badge', () => {
const badgeEl = vm.$el.querySelector('.issuable-status-box');
const badgeIconEl = badgeEl.querySelector('svg use');
expect(badgeEl).not.toBe(null);
expect(badgeEl.innerText.trim()).toBe('Open');
expect(badgeIconEl.getAttribute('xlink:href')).toContain('issue-open-m');
});
it('should render `Close epic` button when `isEpicOpen` & `canUpdate` props are true', () => {
vm.isEpicOpen = true;
const closeButtonEl = vm.$el.querySelector('.js-issuable-actions .js-btn-epic-action');
expect(closeButtonEl).not.toBe(null);
expect(closeButtonEl.innerText.trim()).toBe('Close epic');
});
describe('computed', () => {
describe('statusIcon', () => {
it('returns `issue-open-m` when `isEpicOpen` prop is true', () => {
vm.isEpicOpen = true;
expect(vm.statusIcon).toBe('issue-open-m');
});
it('returns `mobile-issue-close` when `isEpicOpen` prop is false', () => {
vm.isEpicOpen = false;
expect(vm.statusIcon).toBe('mobile-issue-close');
});
});
describe('statusText', () => {
it('returns `Open` when `isEpicOpen` prop is true', () => {
vm.isEpicOpen = true;
expect(vm.statusText).toBe('Open');
});
it('returns `Closed` when `isEpicOpen` prop is false', () => {
vm.isEpicOpen = false;
expect(vm.statusText).toBe('Closed');
});
});
describe('actionButtonClass', () => {
it('returns classes `btn btn-grouped js-btn-epic-action qa-close-reopen-epic-button` & `btn-close` when `isEpicOpen` prop is true', () => {
vm.isEpicOpen = true;
expect(vm.actionButtonClass).toContain(
'btn btn-grouped js-btn-epic-action qa-close-reopen-epic-button btn-close',
);
});
it('returns classes `btn btn-grouped js-btn-epic-action qa-close-reopen-epic-button` & `btn-open` when `isEpicOpen` prop is false', () => {
vm.isEpicOpen = false;
expect(vm.actionButtonClass).toContain(
'btn btn-grouped js-btn-epic-action qa-close-reopen-epic-button btn-open',
);
});
});
describe('actionButtonText', () => {
it('returns `Close epic` when `isEpicOpen` prop is true', () => {
vm.isEpicOpen = true;
expect(vm.actionButtonText).toBe('Close epic');
});
it('returns `Reopen epic` when `isEpicOpen` prop is false', () => {
vm.isEpicOpen = false;
expect(vm.actionButtonText).toBe('Reopen epic');
});
});
});
describe('methods', () => {
describe('toggleStatus', () => {
it('emits `toggleEpicStatus` on component with stateEventType param as `close` when `isEpicOpen` prop is true', () => {
spyOn(vm, '$emit');
vm.isEpicOpen = true;
vm.toggleStatus();
expect(vm.statusUpdating).toBe(true);
expect(vm.$emit).toHaveBeenCalledWith('toggleEpicStatus', stateEvent.close);
});
it('emits `toggleEpicStatus` on component with stateEventType param as `reopen` when `isEpicOpen` prop is false', () => {
spyOn(vm, '$emit');
vm.isEpicOpen = false;
vm.toggleStatus();
expect(vm.statusUpdating).toBe(true);
expect(vm.$emit).toHaveBeenCalledWith('toggleEpicStatus', stateEvent.reopen);
});
});
});
});
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import epicShowApp from 'ee/epics/epic_show/components/epic_show_app.vue';
import epicHeader from 'ee/epics/epic_show/components/epic_header.vue';
import { stateEvent } from 'ee/epics/constants';
import issuableApp from '~/issue_show/components/app.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import issueShowData from 'spec/issue_show/mock_data';
import { props } from '../mock_data';
describe('EpicShowApp', () => {
let mock;
let vm;
let headerVm;
let issuableAppVm;
beforeEach(done => {
mock = new MockAdapter(axios);
mock.onGet(`${gl.TEST_HOST}/realtime_changes`).reply(200, issueShowData.initialRequest);
const {
canUpdate,
canDestroy,
endpoint,
updateEndpoint,
initialTitleHtml,
initialTitleText,
markdownPreviewPath,
markdownDocsPath,
author,
created,
toggleSubscriptionPath,
state,
open,
} = props;
const EpicShowApp = Vue.extend(epicShowApp);
vm = mountComponent(EpicShowApp, props);
const EpicHeader = Vue.extend(epicHeader);
headerVm = mountComponent(EpicHeader, {
author,
created,
open,
canUpdate,
});
const IssuableApp = Vue.extend(issuableApp);
issuableAppVm = mountComponent(IssuableApp, {
canUpdate,
canDestroy,
endpoint,
updateEndpoint,
issuableRef: '',
initialTitleHtml,
initialTitleText,
initialDescriptionHtml: '',
initialDescriptionText: '',
markdownPreviewPath,
markdownDocsPath,
projectPath: props.groupPath,
projectNamespace: '',
showInlineEditButton: true,
toggleSubscriptionPath,
state,
});
setTimeout(done);
});
afterEach(() => {
mock.restore();
});
it('should render epic-header', () => {
expect(vm.$el.innerHTML.indexOf(headerVm.$el.innerHTML)).not.toBe(-1);
});
it('should render issuable-app', () => {
expect(vm.$el.innerHTML.indexOf(issuableAppVm.$el.innerHTML)).not.toBe(-1);
});
it('should render epic-sidebar', () => {
expect(vm.$el.querySelector('aside.right-sidebar.epic-sidebar')).not.toBe(null);
});
it('calls `updateStatus` with stateEventType param on service and triggers document events when request is successful', done => {
const queryParam = `epic[state_event]=${stateEvent.close}`;
mock.onPut(`${vm.endpoint}.json?${encodeURI(queryParam)}`).reply(200, {});
spyOn(vm.service, 'updateStatus').and.callThrough();
spyOn(vm, 'triggerDocumentEvent');
vm.toggleEpicStatus(stateEvent.close);
setTimeout(() => {
expect(vm.service.updateStatus).toHaveBeenCalledWith(stateEvent.close);
expect(vm.triggerDocumentEvent).toHaveBeenCalledWith('issuable_vue_app:change', true);
expect(vm.triggerDocumentEvent).toHaveBeenCalledWith('issuable:change', true);
done();
}, 0);
});
it('calls `updateStatus` with stateEventType param on service and shows flash error and triggers document events when request is failed', done => {
const queryParam = `epic[state_event]=${stateEvent.close}`;
mock.onPut(`${vm.endpoint}.json?${encodeURI(queryParam)}`).reply(500, {});
spyOn(vm.service, 'updateStatus').and.callThrough();
spyOn(vm, 'triggerDocumentEvent');
vm.toggleEpicStatus(stateEvent.close);
setTimeout(() => {
expect(vm.service.updateStatus).toHaveBeenCalledWith(stateEvent.close);
expect(vm.triggerDocumentEvent).toHaveBeenCalledWith('issuable_vue_app:change', false);
expect(vm.triggerDocumentEvent).toHaveBeenCalledWith('issuable:change', false);
done();
}, 0);
});
});
export const mockLabels = [
{
id: 26,
title: 'Foo Label',
description: 'Foobar',
color: '#BADA55',
text_color: '#FFFFFF',
},
];
export const mockParticipants = [
{
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url: '',
web_url: 'http://127.0.0.1:3001/root',
},
{
id: 12,
name: 'Susy Johnson',
username: 'tana_harvey',
state: 'active',
avatar_url: '',
web_url: 'http://127.0.0.1:3001/tana_harvey',
},
];
export const contentProps = {
epicId: 1,
endpoint: gl.TEST_HOST,
toggleSubscriptionPath: gl.TEST_HOST,
updateEndpoint: gl.TEST_HOST,
todoPath: gl.TEST_HOST,
todoDeletePath: gl.TEST_HOST,
canAdmin: true,
canUpdate: true,
canDestroy: true,
markdownPreviewPath: '',
markdownDocsPath: '',
issueLinksEndpoint: '/',
epicLinksEndpoint: '/',
groupPath: '',
namespace: 'gitlab-org',
labelsPath: '',
labelsWebUrl: '',
epicsWebUrl: '',
initialTitleHtml: '',
initialTitleText: '',
startDate: '2017-01-01',
endDate: '2017-10-10',
dueDate: '2017-10-10',
startDateFixed: '2017-01-01',
startDateIsFixed: true,
startDateFromMilestones: '',
dueDateFixed: '2017-10-10',
dueDateIsFixed: true,
dueDateFromMilestones: '',
startDateSourcingMilestoneTitle: 'Milestone for Start Date',
startDateSourcingMilestoneDates: {
startDate: '2010-01-01',
dueDate: '2019-12-31',
},
dueDateSourcingMilestoneTitle: 'Milestone for End Date',
dueDateSourcingMilestoneDates: {
startDate: '2020-01-01',
dueDate: '2029-12-31',
},
labels: mockLabels,
participants: mockParticipants,
subscribed: true,
todoExists: false,
state: 'opened',
parent: {
id: 12,
startDateIsFixed: true,
startDate: '2018-12-01',
dueDateIsFixed: true,
dueDateFixed: '2019-12-31',
title: 'Sample Parent Epic',
url: `${gl.TEST_HOST}/groups/gitlab-org/-/epics/12`,
},
};
export const headerProps = {
author: {
url: `${gl.TEST_HOST}/url`,
src: `${gl.TEST_HOST}/image`,
username: '@root',
name: 'Administrator',
},
created: new Date().toISOString(),
open: true,
canUpdate: true,
canDelete: true,
};
export const mockDatePickerProps = {
blockClass: 'epic-date',
collapsed: false,
showToggleSidebar: false,
isLoading: false,
editable: true,
label: 'Date',
datePickerLabel: 'Fixed date',
selectedDate: null,
selectedDateIsFixed: true,
dateFromMilestones: null,
dateFixed: null,
dateFromMilestonesTooltip: 'Select an issue with milestone to set date',
isDateInvalid: false,
dateInvalidTooltip: 'Selected date is invalid',
};
export const props = Object.assign({}, contentProps, headerProps);
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import newEpic from 'ee/epics/new_epic/components/new_epic.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('newEpic', () => {
let vm;
let mock;
beforeEach(() => {
const NewEpic = Vue.extend(newEpic);
mock = new MockAdapter(axios);
mock.onPost(gl.TEST_HOST).reply(200, { web_url: gl.TEST_HOST });
vm = mountComponent(NewEpic, {
endpoint: gl.TEST_HOST,
});
});
afterEach(() => {
mock.restore();
vm.$destroy();
});
describe('alignRight', () => {
it('should not add dropdown-menu-right by default', () => {
expect(
vm.$el.querySelector('.dropdown-menu').classList.contains('dropdown-menu-right'),
).toEqual(false);
});
it('should add dropdown-menu-right when alignRight', done => {
vm.alignRight = true;
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.dropdown-menu').classList.contains('dropdown-menu-right'),
).toEqual(true);
done();
});
});
});
describe('creating epic', () => {
it('should call createEpic service', done => {
spyOnDependency(newEpic, 'visitUrl').and.callFake(done);
spyOn(vm.service, 'createEpic').and.callThrough();
vm.title = 'test';
Vue.nextTick(() => {
vm.$el.querySelector('.dropdown-menu .btn-success').click();
expect(vm.service.createEpic).toHaveBeenCalled();
});
});
it('should redirect to epic url after epic creation', done => {
spyOnDependency(newEpic, 'visitUrl').and.callFake(url => {
expect(url).toEqual(gl.TEST_HOST);
done();
});
vm.title = 'test';
Vue.nextTick(() => {
vm.$el.querySelector('.dropdown-menu .btn-success').click();
});
});
it('should toggle loading button while creating', done => {
spyOnDependency(newEpic, 'visitUrl').and.callFake(done);
vm.title = 'test';
Vue.nextTick(() => {
const btnSave = vm.$el.querySelector('.dropdown-menu .btn-success');
const loadingIcon = btnSave.querySelector('.js-loading-button-icon');
expect(loadingIcon).toBeNull();
btnSave.click();
expect(loadingIcon).toBeDefined();
});
});
});
});
import Vue from 'vue';
import SidebarDatepicker from 'ee/epics/sidebar/components/sidebar_date_picker.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockDatePickerProps } from 'ee_spec/epics/epic_show/mock_data';
const createComponent = (datePickerProps = mockDatePickerProps) => {
const Component = Vue.extend(SidebarDatepicker);
return mountComponent(Component, datePickerProps);
};
describe('SidebarParticipants', () => {
window.gon = { gitlab_url: gl.TEST_HOST };
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('return data props with uniqueId for `fieldName`', () => {
expect(vm.fieldName).toContain('dateType_');
});
});
describe('computed', () => {
describe('selectedAndEditable', () => {
it('returns `true` when both `selectedDate` is defined and `editable` is true', done => {
vm.selectedDate = new Date();
Vue.nextTick()
.then(() => {
expect(vm.selectedAndEditable).toBe(true);
})
.then(done)
.catch(done.fail);
});
});
describe('selectedDateWords', () => {
it('returns full date string in words based on `selectedDate` prop value', done => {
vm.selectedDate = new Date(2018, 0, 1);
Vue.nextTick()
.then(() => {
expect(vm.selectedDateWords).toBe('Jan 1, 2018');
})
.then(done)
.catch(done.fail);
});
});
describe('dateFixedWords', () => {
it('returns full date string in words based on `dateFixed` prop value', done => {
vm.dateFixed = new Date(2018, 0, 1);
Vue.nextTick()
.then(() => {
expect(vm.dateFixedWords).toBe('Jan 1, 2018');
})
.then(done)
.catch(done.fail);
});
});
describe('dateFromMilestonesWords', () => {
it('returns full date string in words when `dateFromMilestones` is defined', done => {
vm.dateFromMilestones = new Date(2018, 0, 1);
Vue.nextTick()
.then(() => {
expect(vm.dateFromMilestonesWords).toBe('Jan 1, 2018');
})
.then(done)
.catch(done.fail);
});
it('returns `None` when `dateFromMilestones` is not defined', () => {
expect(vm.dateFromMilestonesWords).toBe('None');
});
});
describe('collapsedText', () => {
it('returns value of `selectedDateWords` when it is defined', done => {
vm.selectedDate = new Date(2018, 0, 1);
Vue.nextTick()
.then(() => {
expect(vm.collapsedText).toBe('Jan 1, 2018');
})
.then(done)
.catch(done.fail);
});
it('returns `None` when `selectedDateWords` is not defined', () => {
expect(vm.collapsedText).toBe('None');
});
});
describe('popoverOptions', () => {
it('returns popover config object containing title with appropriate string', () => {
expect(vm.popoverOptions.title).toBe(
'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.',
);
});
it('returns popover config object containing `content` with href pointing to correct documentation', () => {
const hrefContent = vm.popoverOptions.content.trim();
expect(hrefContent).toContain(
`${gon.gitlab_url}/help/user/group/epics/index.md#start-date-and-due-date`,
);
expect(hrefContent).toContain('More information');
});
});
describe('dateInvalidPopoverOptions', () => {
it('returns popover config object containing title with appropriate string', () => {
expect(vm.dateInvalidPopoverOptions.title).toBe('Selected date is invalid');
});
it('returns popover config object containing `content` with href pointing to correct documentation', () => {
const hrefContent = vm.dateInvalidPopoverOptions.content.trim();
expect(hrefContent).toContain(
`${gon.gitlab_url}/help/user/group/epics/index.md#start-date-and-due-date`,
);
expect(hrefContent).toContain('How can I solve this?');
});
});
});
describe('methods', () => {
describe('getPopoverConfig', () => {
it('returns popover config object with provided `title` and `content` values', () => {
const title = 'Popover title';
const content = 'This is a popover content';
const popoverConfig = vm.getPopoverConfig({ title, content });
const expectedPopoverConfig = {
html: true,
trigger: 'focus',
container: 'body',
boundary: 'viewport',
template: '<div class="popover-header"></div>',
title,
content,
};
Object.keys(popoverConfig).forEach(key => {
if (key === 'template') {
expect(popoverConfig[key]).toContain(expectedPopoverConfig[key]);
} else {
expect(popoverConfig[key]).toBe(expectedPopoverConfig[key]);
}
});
});
});
describe('stopEditing', () => {
it('sets `editing` prop to `false` and emits `toggleDateType` event on component', () => {
spyOn(vm, '$emit');
vm.stopEditing();
expect(vm.editing).toBe(false);
expect(vm.$emit).toHaveBeenCalledWith('toggleDateType', true, true);
});
});
describe('toggleDatePicker', () => {
it('flips value of `editing` prop from `true` to `false` and vice-versa', () => {
vm.editing = true;
vm.toggleDatePicker();
expect(vm.editing).toBe(false);
});
});
describe('newDateSelected', () => {
it('sets `editing` prop to `false` and emits `saveDate` event on component', () => {
spyOn(vm, '$emit');
const date = new Date();
vm.newDateSelected(date);
expect(vm.editing).toBe(false);
expect(vm.$emit).toHaveBeenCalledWith('saveDate', date);
});
});
describe('toggleDateType', () => {
it('emits `toggleDateType` event on component', () => {
spyOn(vm, '$emit');
vm.toggleDateType(true);
expect(vm.$emit).toHaveBeenCalledWith('toggleDateType', true);
});
});
describe('toggleSidebar', () => {
it('emits `toggleCollapse` event on component', () => {
spyOn(vm, '$emit');
vm.toggleSidebar();
expect(vm.$emit).toHaveBeenCalledWith('toggleCollapse');
});
});
});
describe('template', () => {
it('renders component container element', () => {
expect(vm.$el.classList.contains('block', 'date', 'epic-date')).toBe(true);
});
it('renders collapsed calendar icon component', () => {
expect(vm.$el.querySelector('.sidebar-collapsed-icon')).not.toBe(null);
});
it('renders collapse button when `showToggleSidebar` prop is `true`', done => {
vm.showToggleSidebar = true;
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('button.btn-sidebar-action')).not.toBe(null);
})
.then(done)
.catch(done.fail);
});
it('renders title element', () => {
expect(vm.$el.querySelector('.title')).not.toBe(null);
});
it('renders loading icon when `isLoading` prop is true', done => {
vm.isLoading = true;
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.loading-container')).not.toBe(null);
})
.then(done)
.catch(done.fail);
});
it('renders help icon', () => {
const helpIconEl = vm.$el.querySelector('.help-icon');
expect(helpIconEl).not.toBe(null);
expect(helpIconEl.getAttribute('tabindex')).toBe('0');
expect(helpIconEl.querySelector('use').getAttribute('xlink:href')).toContain('question-o');
});
it('renderts edit button', () => {
const buttonEl = vm.$el.querySelector('button.btn-sidebar-action');
expect(buttonEl).not.toBe(null);
expect(buttonEl.innerText.trim()).toBe('Edit');
});
it('renders value container element', () => {
expect(vm.$el.querySelector('.value .value-type-fixed')).not.toBe(null);
expect(vm.$el.querySelector('.value .value-type-dynamic')).not.toBe(null);
});
it('renders fixed type date selection element', () => {
const valueFixedEl = vm.$el.querySelector('.value .value-type-fixed');
expect(valueFixedEl.querySelector('input[type="radio"]')).not.toBe(null);
expect(valueFixedEl.innerText.trim()).toContain('Fixed:');
expect(valueFixedEl.querySelector('.value-content').innerText.trim()).toContain('None');
});
it('renders dynamic type date selection element', () => {
const valueDynamicEl = vm.$el.querySelector('.value abbr.value-type-dynamic');
expect(valueDynamicEl.querySelector('input[type="radio"]')).not.toBe(null);
expect(valueDynamicEl.innerText.trim()).toContain('From milestones:');
expect(valueDynamicEl.querySelector('.value-content').innerText.trim()).toContain('None');
});
it('renders date warning icon when `isDateInvalid` prop is `true`', done => {
vm.isDateInvalid = true;
vm.selectedDateIsFixed = false;
Vue.nextTick()
.then(() => {
const warningIconEl = vm.$el.querySelector('.date-warning-icon');
expect(warningIconEl).not.toBe(null);
expect(warningIconEl.getAttribute('tabindex')).toBe('0');
expect(warningIconEl.querySelector('use').getAttribute('xlink:href')).toContain(
'warning',
);
})
.then(done)
.catch(done.fail);
});
});
});
import Vue from 'vue';
import SidebarParticipants from 'ee/epics/sidebar/components/sidebar_participants.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockParticipants } from 'ee_spec/epics/epic_show/mock_data';
const createComponent = () => {
const Component = Vue.extend(SidebarParticipants);
return mountComponent(Component, {
participants: mockParticipants,
});
};
describe('SidebarParticipants', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('onToggleSidebar', () => {
it('emits `toggleCollapse` event on component', () => {
spyOn(vm, '$emit');
vm.onToggleSidebar();
expect(vm.$emit).toHaveBeenCalledWith('toggleCollapse');
});
});
});
describe('template', () => {
it('renders component container element with classes `block participants`', () => {
expect(vm.$el.classList.contains('block', 'participants')).toBe(true);
});
it('renders participants list element', () => {
expect(vm.$el.querySelector('.participants-list')).not.toBeNull();
expect(vm.$el.querySelectorAll('.js-participants-author').length).toBe(
mockParticipants.length,
);
});
});
});
import Vue from 'vue';
import SidebarSubscriptions from 'ee/epics/sidebar/components/sidebar_subscriptions.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(SidebarSubscriptions);
return mountComponent(Component, {
loading: false,
subscribed: true,
});
};
describe('SidebarSubscriptions', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('onToggleSubscription', () => {
it('emits `toggleSubscription` event on component', () => {
spyOn(vm, '$emit');
vm.onToggleSubscription();
expect(vm.$emit).toHaveBeenCalledWith('toggleSubscription');
});
});
describe('onToggleSidebar', () => {
it('emits `toggleCollapse` event on component', () => {
spyOn(vm, '$emit');
vm.onToggleSidebar();
expect(vm.$emit).toHaveBeenCalledWith('toggleCollapse');
});
});
});
describe('template', () => {
it('renders component container element with classes `block subscriptions`', () => {
expect(vm.$el.classList.contains('block', 'subscriptions')).toBe(true);
});
it('renders subscription toggle element', () => {
expect(vm.$el.querySelector('.project-feature-toggle')).not.toBeNull();
});
});
});
import axios from '~/lib/utils/axios_utils';
import SidebarService from 'ee/epics/sidebar/services/sidebar_service';
describe('Sidebar Service', () => {
let service;
beforeEach(() => {
service = new SidebarService({
endpoint: gl.TEST_HOST,
subscriptionEndpoint: gl.TEST_HOST,
todoPath: gl.TEST_HOST,
});
});
describe('updateStartDate', () => {
it('returns axios instance with PUT for `endpoint` with `start_date_is_fixed` and `start_date_fixed` as request body', () => {
spyOn(axios, 'put').and.stub();
const dateValue = '2018-06-21';
service.updateStartDate({ dateValue, isFixed: true });
expect(axios.put).toHaveBeenCalledWith(service.endpoint, {
start_date_is_fixed: true,
start_date_fixed: dateValue,
});
});
});
describe('updateEndDate', () => {
it('returns axios instance with PUT for `endpoint` with `due_date_is_fixed` and `due_date_fixed` as request body', () => {
spyOn(axios, 'put').and.stub();
const dateValue = '2018-06-21';
service.updateEndDate({ dateValue, isFixed: true });
expect(axios.put).toHaveBeenCalledWith(service.endpoint, {
due_date_is_fixed: true,
due_date_fixed: dateValue,
});
});
});
describe('toggleSubscribed', () => {
it('returns axios instance with POST for `subscriptionEndpoint`', () => {
spyOn(axios, 'post').and.stub();
service.toggleSubscribed();
expect(axios.post).toHaveBeenCalled();
});
});
describe('addTodo', () => {
it('returns axios instance with POST for `todoPath` with `issuable_id` and `issuable_type` as request body', () => {
spyOn(axios, 'post').and.stub();
const epicId = 1;
service.addTodo(epicId);
expect(axios.post).toHaveBeenCalledWith(service.todoPath, {
issuable_id: epicId,
issuable_type: 'epic',
});
});
});
describe('deleteTodo', () => {
it('returns axios instance with DELETE for provided `todoDeletePath` param', () => {
spyOn(axios, 'delete').and.stub();
service.deleteTodo('/foo/bar');
expect(axios.delete).toHaveBeenCalledWith('/foo/bar');
});
});
});
import SidebarStore from 'ee/epics/sidebar/stores/sidebar_store';
describe('Sidebar Store', () => {
const dateString = '2017-01-20';
describe('constructor', () => {
it('should set startDate', () => {
const store = new SidebarStore({
startDate: dateString,
});
expect(store.startDate).toEqual(dateString);
});
it('should set endDate', () => {
const store = new SidebarStore({
endDate: dateString,
});
expect(store.endDate).toEqual(dateString);
});
});
describe('startDateTime', () => {
it('should return null when there is no startDate', () => {
const store = new SidebarStore({});
expect(store.startDateTime).toEqual(null);
});
it('should return date', () => {
const store = new SidebarStore({
startDate: dateString,
});
const date = store.startDateTime;
expect(date.getDate()).toEqual(20);
expect(date.getMonth()).toEqual(0);
expect(date.getFullYear()).toEqual(2017);
});
});
describe('startDateTimeFixed', () => {
it('should return null when there is no startDateFixed', () => {
const store = new SidebarStore({});
expect(store.startDateTimeFixed).toEqual(null);
});
it('should return date', () => {
const store = new SidebarStore({
startDateFixed: dateString,
});
const date = store.startDateTimeFixed;
expect(date.getDate()).toEqual(20);
expect(date.getMonth()).toEqual(0);
expect(date.getFullYear()).toEqual(2017);
});
});
describe('endDateTime', () => {
it('should return null when there is no endDate', () => {
const store = new SidebarStore({});
expect(store.endDateTime).toEqual(null);
});
it('should return date', () => {
const store = new SidebarStore({
endDate: dateString,
});
const date = store.endDateTime;
expect(date.getDate()).toEqual(20);
expect(date.getMonth()).toEqual(0);
expect(date.getFullYear()).toEqual(2017);
});
});
describe('dueDateTimeFixed', () => {
it('should return null when there is no dueDateFixed', () => {
const store = new SidebarStore({});
expect(store.dueDateTimeFixed).toEqual(null);
});
it('should return date', () => {
const store = new SidebarStore({
dueDateFixed: dateString,
});
const date = store.dueDateTimeFixed;
expect(date.getDate()).toEqual(20);
expect(date.getMonth()).toEqual(0);
expect(date.getFullYear()).toEqual(2017);
});
});
describe('setSubscribed', () => {
it('should set store.subscribed value', () => {
const store = new SidebarStore({ subscribed: true });
store.setSubscribed(false);
expect(store.subscribed).toEqual(false);
});
});
describe('setTodoExists', () => {
it('should set store.subscribed value', () => {
const store = new SidebarStore({ todoExists: true });
store.setTodoExists(false);
expect(store.todoExists).toEqual(false);
});
});
describe('setTodoDeletePath', () => {
it('should set store.subscribed value', () => {
const store = new SidebarStore({ todoDeletePath: gl.TEST_HOST });
store.setTodoDeletePath('/foo/bar');
expect(store.todoDeletePath).toEqual('/foo/bar');
});
});
});
......@@ -174,18 +174,15 @@ describe('EpicsListEmptyComponent', () => {
);
});
it('renders new epic button element', () => {
const newEpicBtnEl = vm.$el.querySelector('.new-epic-dropdown');
expect(newEpicBtnEl).not.toBeNull();
expect(newEpicBtnEl.querySelector('button.btn-success').innerText.trim()).toBe('New epic');
it('renders mount point for new epic button to boot via Epic app', () => {
expect(vm.$el.querySelector('#epic-create-root')).not.toBeNull();
});
it('does not render new epic button element when `hasFiltersApplied` prop is true', done => {
vm.hasFiltersApplied = true;
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.new-epic-dropdown')).toBeNull();
expect(vm.$el.querySelector('.epic-create-dropdown')).toBeNull();
})
.then(done)
.catch(done.fail);
......
......@@ -3518,9 +3518,6 @@ msgstr ""
msgid "Epic"
msgstr ""
msgid "Epic will be removed! Are you sure?"
msgstr ""
msgid "Epics"
msgstr ""
......@@ -3530,9 +3527,6 @@ msgstr ""
msgid "Epics let you manage your portfolio of projects more efficiently and with less effort"
msgstr ""
msgid "Epics|An error occurred while saving %{epicDateType} date"
msgstr ""
msgid "Epics|An error occurred while saving the %{epicDateType} date"
msgstr ""
......@@ -11433,9 +11427,6 @@ msgstr ""
msgid "to help your contributors communicate effectively!"
msgstr ""
msgid "toggle collapse"
msgstr ""
msgid "triggered"
msgstr ""
......
......@@ -6,7 +6,7 @@ module QA
module Group
module Epic
class Index < QA::Page::Base
view 'ee/app/assets/javascripts/epics/new_epic/components/new_epic.vue' do
view 'ee/app/assets/javascripts/epic/components/epic_create.vue' do
element :new_epic_button
element :epic_title
element :create_epic_button
......
......@@ -21,7 +21,7 @@ module QA
element :remove_issue_button
end
view 'ee/app/assets/javascripts/epics/epic_show/components/epic_header.vue' do
view 'ee/app/assets/javascripts/epic/components/epic_header.vue' do
element :close_reopen_epic_button
end
......
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