Commit 69707e45 authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch 'psi-iteration-iteration' into 'master'

Rename vue-router-incompatible files

See merge request gitlab-org/gitlab!63515
parents d459f0ce d136ebfe
<script>
import { GlButton, GlForm, GlFormInput } from '@gitlab/ui';
import initDatePicker from '~/behaviors/date_picker';
import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import createIteration from '../queries/create_iteration.mutation.graphql';
import updateIteration from '../queries/update_iteration.mutation.graphql';
export default {
components: {
GlButton,
GlForm,
GlFormInput,
MarkdownField,
},
props: {
groupPath: {
type: String,
required: true,
},
previewMarkdownPath: {
type: String,
required: false,
default: '',
},
iterationsListPath: {
type: String,
required: false,
default: '',
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
iteration: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
iterations: [],
loading: false,
title: this.iteration.title,
description: this.iteration.description,
startDate: this.iteration.startDate,
dueDate: this.iteration.dueDate,
};
},
computed: {
variables() {
return {
input: {
groupPath: this.groupPath,
title: this.title,
description: this.description,
startDate: this.startDate,
dueDate: this.dueDate,
},
};
},
},
mounted() {
// TODO: utilize GlDatepicker instead of relying on this jQuery behavior
initDatePicker();
},
methods: {
save() {
this.loading = true;
return this.isEditing ? this.updateIteration() : this.createIteration();
},
cancel() {
if (this.iterationsListPath) {
visitUrl(this.iterationsListPath);
} else {
this.$emit('cancel');
}
},
createIteration() {
return this.$apollo
.mutate({
mutation: createIteration,
variables: this.variables,
})
.then(({ data }) => {
const { errors, iteration } = data.createIteration;
if (errors.length > 0) {
this.loading = false;
createFlash({
message: errors[0],
});
return;
}
visitUrl(iteration.webUrl);
})
.catch(() => {
this.loading = false;
createFlash({
message: __('Unable to save iteration. Please try again'),
});
});
},
updateIteration() {
return this.$apollo
.mutate({
mutation: updateIteration,
variables: {
input: {
...this.variables.input,
id: this.iteration.id,
},
},
})
.then(({ data }) => {
const { errors } = data.updateIteration;
if (errors.length > 0) {
createFlash({
message: errors[0],
});
return;
}
this.$emit('updated');
})
.catch(() => {
createFlash({
message: __('Unable to save iteration. Please try again'),
});
})
.finally(() => {
this.loading = false;
});
},
updateDueDate(val) {
this.dueDate = val;
},
updateStartDate(val) {
this.startDate = val;
},
},
};
</script>
<template>
<div>
<div class="gl-display-flex">
<h3 ref="pageTitle" class="page-title">
{{ isEditing ? __('Edit iteration') : __('New iteration') }}
</h3>
</div>
<hr class="gl-mt-0" />
<gl-form class="row common-note-form">
<div class="col-md-6">
<div class="form-group row">
<div class="col-form-label col-sm-2">
<label for="iteration-title">{{ __('Title') }}</label>
</div>
<div class="col-sm-10">
<gl-form-input
id="iteration-title"
v-model="title"
autocomplete="off"
data-qa-selector="iteration_title_field"
/>
</div>
</div>
<div class="form-group row">
<div class="col-form-label col-sm-2">
<label for="iteration-description">{{ __('Description') }}</label>
</div>
<div class="col-sm-10">
<markdown-field
:markdown-preview-path="previewMarkdownPath"
:can-attach-file="false"
:enable-autocomplete="true"
label="Description"
:textarea-value="description"
markdown-docs-path="/help/user/markdown"
:add-spacing-classes="false"
class="md-area"
>
<template #textarea>
<textarea
id="iteration-description"
v-model="description"
class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
data-supports-quick-actions="false"
:aria-label="__('Description')"
data-qa-selector="iteration_description_field"
>
</textarea>
</template>
</markdown-field>
</div>
</div>
</div>
<div class="col-md-6">
<div class="form-group row">
<div class="col-form-label col-sm-2">
<label for="iteration-start-date">{{ __('Start date') }}</label>
</div>
<div class="col-sm-10">
<gl-form-input
id="iteration-start-date"
v-model="startDate"
class="datepicker form-control"
:placeholder="__('Select start date')"
autocomplete="off"
data-qa-selector="iteration_start_date_field"
@change="updateStartDate"
/>
<a class="inline float-right gl-mt-2 js-clear-start-date" href="#">{{
__('Clear start date')
}}</a>
</div>
</div>
<div class="form-group row">
<div class="col-form-label col-sm-2">
<label for="iteration-due-date">{{ __('Due date') }}</label>
</div>
<div class="col-sm-10">
<gl-form-input
id="iteration-due-date"
v-model="dueDate"
class="datepicker form-control"
:placeholder="__('Select due date')"
autocomplete="off"
data-qa-selector="iteration_due_date_field"
@change="updateDueDate"
/>
<a class="inline float-right gl-mt-2 js-clear-due-date" href="#">{{
__('Clear due date')
}}</a>
</div>
</div>
</div>
</gl-form>
<div class="form-actions d-flex">
<gl-button
:loading="loading"
data-testid="save-iteration"
variant="success"
data-qa-selector="save_iteration_button"
@click="save"
>
{{ isEditing ? __('Update iteration') : __('Create iteration') }}
</gl-button>
<gl-button class="ml-auto" data-testid="cancel-iteration" @click="cancel">
{{ __('Cancel') }}
</gl-button>
</div>
</div>
</template>
......@@ -16,7 +16,6 @@ import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { Namespace } from '../constants';
import query from '../queries/iteration.query.graphql';
import IterationForm from './iteration_form.vue';
import IterationReportTabs from './iteration_report_tabs.vue';
const iterationStates = {
......@@ -25,11 +24,6 @@ const iterationStates = {
expired: 'expired',
};
const page = {
view: 'viewIteration',
edit: 'editIteration',
};
export default {
components: {
BurnCharts,
......@@ -40,7 +34,6 @@ export default {
GlDropdownItem,
GlEmptyState,
GlLoadingIcon,
IterationForm,
IterationReportTabs,
},
apollo: {
......@@ -64,64 +57,30 @@ export default {
},
},
mixins: [glFeatureFlagsMixin()],
inject: ['fullPath'],
props: {
hasScopedLabelsFeature: {
type: Boolean,
required: false,
default: false,
},
iterationId: {
type: String,
required: false,
default: undefined,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
initiallyEditing: {
type: Boolean,
required: false,
default: false,
},
labelsFetchPath: {
type: String,
required: false,
default: '',
},
namespaceType: {
type: String,
required: false,
default: Namespace.Group,
validator: (value) => Object.values(Namespace).includes(value),
},
previewMarkdownPath: {
type: String,
required: false,
default: '',
},
svgPath: {
type: String,
required: false,
default: '',
},
},
inject: [
'fullPath',
'hasScopedLabelsFeature',
'canEditIteration',
'namespaceType',
'noIssuesSvgPath',
'labelsFetchPath',
],
data() {
return {
isEditing: this.initiallyEditing,
error: '',
iteration: {},
};
},
computed: {
canEditIteration() {
return this.canEdit && this.namespaceType === Namespace.Group;
canEdit() {
return this.canEditIteration && this.namespaceType === Namespace.Group;
},
loading() {
return this.$apollo.queries.iteration.loading;
},
iterationId() {
return this.$router.currentRoute.params.iterationId;
},
showEmptyState() {
return !this.loading && this.iteration && !this.iteration.title;
},
......@@ -140,37 +99,16 @@ export default {
return { text: __('Open'), variant: 'success' };
}
},
editPage() {
return {
name: 'editIteration',
};
},
mounted() {
this.boundOnPopState = this.onPopState.bind(this);
window.addEventListener('popstate', this.boundOnPopState);
},
beforeDestroy() {
window.removeEventListener('popstate', this.boundOnPopState);
},
methods: {
onPopState(e) {
if (e.state?.prev === page.view) {
this.isEditing = true;
} else if (e.state?.prev === page.edit) {
this.isEditing = false;
} else {
this.isEditing = this.initiallyEditing;
}
},
formatDate(date) {
return formatDate(date, 'mmm d, yyyy', true);
},
loadEditPage() {
this.isEditing = true;
const newUrl = window.location.pathname.replace(/(\/edit)?\/?$/, '/edit');
window.history.pushState({ prev: page.view }, null, newUrl);
},
loadReportPage() {
this.isEditing = false;
const newUrl = window.location.pathname.replace(/\/edit$/, '');
window.history.pushState({ prev: page.edit }, null, newUrl);
},
},
};
</script>
......@@ -186,15 +124,6 @@ export default {
:title="__('Could not find iteration')"
:compact="false"
/>
<iteration-form
v-else-if="isEditing"
:group-path="fullPath"
:preview-markdown-path="previewMarkdownPath"
:is-editing="true"
:iteration="iteration"
@updated="loadReportPage"
@cancel="loadReportPage"
/>
<template v-else>
<div
ref="topbar"
......@@ -207,7 +136,7 @@ export default {
>{{ formatDate(iteration.startDate) }}{{ formatDate(iteration.dueDate) }}</span
>
<gl-dropdown
v-if="canEditIteration"
v-if="canEdit"
data-testid="actions-dropdown"
variant="default"
toggle-class="gl-text-decoration-none gl-border-0! gl-shadow-none!"
......@@ -218,7 +147,7 @@ export default {
<template #button-content>
<gl-icon name="ellipsis_v" /><span class="gl-sr-only">{{ __('Actions') }}</span>
</template>
<gl-dropdown-item @click="loadEditPage">{{ __('Edit iteration') }}</gl-dropdown-item>
<gl-dropdown-item :to="editPage">{{ __('Edit iteration') }}</gl-dropdown-item>
</gl-dropdown>
</div>
<h3 ref="title" class="page-title">{{ iteration.title }}</h3>
......@@ -237,7 +166,7 @@ export default {
:iteration-id="iteration.id"
:labels-fetch-path="labelsFetchPath"
:namespace-type="namespaceType"
:svg-path="svgPath"
:svg-path="noIssuesSvgPath"
/>
</template>
</div>
......
<script>
/* eslint-disable vue/no-v-html */
import {
GlAlert,
GlBadge,
GlDropdown,
GlDropdownItem,
GlEmptyState,
GlIcon,
GlLoadingIcon,
} from '@gitlab/ui';
import BurnCharts from 'ee/burndown_chart/components/burn_charts.vue';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { Namespace } from '../constants';
import query from '../queries/iteration.query.graphql';
import IterationForm from './iteration_form_without_vue_router.vue';
import IterationReportTabs from './iteration_report_tabs.vue';
const iterationStates = {
closed: 'closed',
upcoming: 'upcoming',
expired: 'expired',
};
const page = {
view: 'viewIteration',
edit: 'editIteration',
};
export default {
components: {
BurnCharts,
GlAlert,
GlBadge,
GlIcon,
GlDropdown,
GlDropdownItem,
GlEmptyState,
GlLoadingIcon,
IterationForm,
IterationReportTabs,
},
apollo: {
iteration: {
query,
/* eslint-disable @gitlab/require-i18n-strings */
variables() {
return {
fullPath: this.fullPath,
id: convertToGraphQLId('Iteration', this.iterationId),
isGroup: this.namespaceType === Namespace.Group,
};
},
/* eslint-enable @gitlab/require-i18n-strings */
update(data) {
return data[this.namespaceType]?.iterations?.nodes[0] || {};
},
error(err) {
this.error = err.message;
},
},
},
mixins: [glFeatureFlagsMixin()],
inject: ['fullPath'],
props: {
hasScopedLabelsFeature: {
type: Boolean,
required: false,
default: false,
},
iterationId: {
type: String,
required: false,
default: undefined,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
initiallyEditing: {
type: Boolean,
required: false,
default: false,
},
labelsFetchPath: {
type: String,
required: false,
default: '',
},
namespaceType: {
type: String,
required: false,
default: Namespace.Group,
validator: (value) => Object.values(Namespace).includes(value),
},
previewMarkdownPath: {
type: String,
required: false,
default: '',
},
svgPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
isEditing: this.initiallyEditing,
error: '',
iteration: {},
};
},
computed: {
canEditIteration() {
return this.canEdit && this.namespaceType === Namespace.Group;
},
loading() {
return this.$apollo.queries.iteration.loading;
},
showEmptyState() {
return !this.loading && this.iteration && !this.iteration.title;
},
status() {
switch (this.iteration.state) {
case iterationStates.closed:
return {
text: __('Closed'),
variant: 'danger',
};
case iterationStates.expired:
return { text: __('Past due'), variant: 'warning' };
case iterationStates.upcoming:
return { text: __('Upcoming'), variant: 'neutral' };
default:
return { text: __('Open'), variant: 'success' };
}
},
},
mounted() {
this.boundOnPopState = this.onPopState.bind(this);
window.addEventListener('popstate', this.boundOnPopState);
},
beforeDestroy() {
window.removeEventListener('popstate', this.boundOnPopState);
},
methods: {
onPopState(e) {
if (e.state?.prev === page.view) {
this.isEditing = true;
} else if (e.state?.prev === page.edit) {
this.isEditing = false;
} else {
this.isEditing = this.initiallyEditing;
}
},
formatDate(date) {
return formatDate(date, 'mmm d, yyyy', true);
},
loadEditPage() {
this.isEditing = true;
const newUrl = window.location.pathname.replace(/(\/edit)?\/?$/, '/edit');
window.history.pushState({ prev: page.view }, null, newUrl);
},
loadReportPage() {
this.isEditing = false;
const newUrl = window.location.pathname.replace(/\/edit$/, '');
window.history.pushState({ prev: page.edit }, null, newUrl);
},
},
};
</script>
<template>
<div>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">
{{ error }}
</gl-alert>
<gl-loading-icon v-else-if="loading" class="gl-py-5" size="lg" />
<gl-empty-state
v-else-if="showEmptyState"
:title="__('Could not find iteration')"
:compact="false"
/>
<iteration-form
v-else-if="isEditing"
:group-path="fullPath"
:preview-markdown-path="previewMarkdownPath"
:is-editing="true"
:iteration="iteration"
@updated="loadReportPage"
@cancel="loadReportPage"
/>
<template v-else>
<div
ref="topbar"
class="gl-display-flex gl-justify-items-center gl-align-items-center gl-py-3 gl-border-1 gl-border-b-solid gl-border-gray-100"
>
<gl-badge :variant="status.variant">
{{ status.text }}
</gl-badge>
<span class="gl-ml-4"
>{{ formatDate(iteration.startDate) }}{{ formatDate(iteration.dueDate) }}</span
>
<gl-dropdown
v-if="canEditIteration"
data-testid="actions-dropdown"
variant="default"
toggle-class="gl-text-decoration-none gl-border-0! gl-shadow-none!"
class="gl-ml-auto gl-text-secondary"
right
no-caret
>
<template #button-content>
<gl-icon name="ellipsis_v" /><span class="gl-sr-only">{{ __('Actions') }}</span>
</template>
<gl-dropdown-item @click="loadEditPage">{{ __('Edit iteration') }}</gl-dropdown-item>
</gl-dropdown>
</div>
<h3 ref="title" class="page-title">{{ iteration.title }}</h3>
<div ref="description" v-html="iteration.descriptionHtml"></div>
<burn-charts
:start-date="iteration.startDate"
:due-date="iteration.dueDate"
:iteration-id="iteration.id"
:iteration-state="iteration.state"
:full-path="fullPath"
:namespace-type="namespaceType"
/>
<iteration-report-tabs
:full-path="fullPath"
:has-scoped-labels-feature="hasScopedLabelsFeature"
:iteration-id="iteration.id"
:labels-fetch-path="labelsFetchPath"
:namespace-type="namespaceType"
:svg-path="svgPath"
/>
</template>
</div>
</template>
......@@ -3,9 +3,10 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import App from './components/app.vue';
import IterationForm from './components/iteration_form.vue';
import IterationReport from './components/iteration_report.vue';
import IterationForm from './components/iteration_form_without_vue_router.vue';
import IterationReport from './components/iteration_report_without_vue_router.vue';
import Iterations from './components/iterations.vue';
import { Namespace } from './constants';
import createRouter from './router';
Vue.use(VueApollo);
......@@ -94,7 +95,7 @@ export function initIterationReport({ namespaceType, initiallyEditing } = {}) {
});
}
export function initCadenceApp() {
export function initCadenceApp({ namespaceType }) {
const el = document.querySelector('.js-iteration-cadence-app');
if (!el) {
......@@ -106,6 +107,11 @@ export function initCadenceApp() {
cadencesListPath,
canCreateCadence,
canEditCadence,
canEditIteration,
hasScopedLabelsFeature,
labelsFetchPath,
previewMarkdownPath,
noIssuesSvgPath,
} = el.dataset;
const router = createRouter(cadencesListPath);
......@@ -119,9 +125,17 @@ export function initCadenceApp() {
cadencesListPath,
canCreateCadence: parseBoolean(canCreateCadence),
canEditCadence: parseBoolean(canEditCadence),
namespaceType,
canEditIteration: parseBoolean(canEditIteration),
hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature),
labelsFetchPath,
previewMarkdownPath,
noIssuesSvgPath,
},
render(createElement) {
return createElement(App);
},
});
}
export const initGroupCadenceApp = () => initCadenceApp({ namespaceType: Namespace.Group });
import { initCadenceApp } from 'ee/iterations';
import { initGroupCadenceApp } from 'ee/iterations';
initCadenceApp();
initGroupCadenceApp();
.js-iteration-cadence-app{ data: { group_full_path: @group.full_path,
cadences_list_path: group_iteration_cadences_path(@group),
can_create_cadence: can?(current_user, :create_iteration_cadence, @group).to_s,
can_edit_cadence: can?(current_user, :admin_iteration_cadence, @group).to_s } }
can_edit_cadence: can?(current_user, :admin_iteration_cadence, @group).to_s,
can_edit_iteration: can?(current_user, :admin_iteration, @group).to_s,
has_scoped_labels_feature: @group.licensed_feature_available?(:scoped_labels).to_s,
labels_fetch_path: group_labels_path(@group, format: :json, include_ancestor_groups: true),
preview_markdown_path: preview_markdown_path(@group),
no_issues_svg_path: image_path('illustrations/issues.svg') } }
import { GlForm } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo';
import IterationForm from 'ee/iterations/components/iteration_form.vue';
import IterationForm from 'ee/iterations/components/iteration_form_without_vue_router.vue';
import createIteration from 'ee/iterations/queries/create_iteration.mutation.graphql';
import updateIteration from 'ee/iterations/queries/update_iteration.mutation.graphql';
import { TEST_HOST } from 'helpers/test_constants';
......
import { GlDropdown, GlDropdownItem, GlEmptyState, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { GlDropdown, GlEmptyState, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import IterationForm from 'ee/iterations/components/iteration_form.vue';
import IterationReport from 'ee/iterations/components/iteration_report.vue';
import IterationReportTabs from 'ee/iterations/components/iteration_report_tabs.vue';
import { Namespace } from 'ee/iterations/constants';
......@@ -12,6 +11,13 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { mockIterationNode, mockGroupIterations, mockProjectIterations } from '../mock_data';
const localVue = createLocalVue();
const $router = {
currentRoute: {
params: {
iterationId: String(getIdFromGraphQLId(mockIterationNode.id)),
},
},
};
describe('Iterations report', () => {
let wrapper;
......@@ -19,24 +25,21 @@ describe('Iterations report', () => {
const defaultProps = {
fullPath: 'gitlab-org',
labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true',
namespaceType: Namespace.Group,
};
const labelsFetchPath = '/labels.json';
const findTopbar = () => wrapper.find({ ref: 'topbar' });
const findTitle = () => wrapper.find({ ref: 'title' });
const findDescription = () => wrapper.find({ ref: 'description' });
const findActionsDropdown = () => wrapper.find('[data-testid="actions-dropdown"]');
const clickEditButton = () => {
findActionsDropdown().vm.$emit('click');
wrapper.findComponent(GlDropdownItem).vm.$emit('click');
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findIterationForm = () => wrapper.findComponent(IterationForm);
const mountComponentWithApollo = ({
const mountComponent = ({
props = defaultProps,
iterationQueryHandler = jest.fn(),
iterationQueryHandler = jest.fn().mockResolvedValue(mockGroupIterations),
} = {}) => {
localVue.use(VueApollo);
mockApollo = createMockApollo([[query, iterationQueryHandler]]);
......@@ -47,6 +50,19 @@ describe('Iterations report', () => {
propsData: props,
provide: {
fullPath: props.fullPath,
groupPath: props.fullPath,
cadencesListPath: '/groups/some-group/-/cadences',
canCreateCadence: true,
canEditCadence: true,
namespaceType: props.namespaceType,
canEditIteration: props.canEditIteration,
hasScopedLabelsFeature: true,
labelsFetchPath,
previewMarkdownPath: '/markdown',
noIssuesSvgPath: '/some.svg',
},
mocks: {
$router,
},
stubs: {
GlLoadingIcon,
......@@ -63,6 +79,7 @@ describe('Iterations report', () => {
{
fullPath: 'group-name',
iterationId: String(getIdFromGraphQLId(mockIterationNode.id)),
namespaceType: Namespace.Group,
},
mockGroupIterations,
{
......@@ -88,7 +105,7 @@ describe('Iterations report', () => {
])('when viewing an iteration in a %s', (_, props, mockIteration, expectedParams) => {
it('calls a query with correct parameters', () => {
const iterationQueryHandler = jest.fn();
mountComponentWithApollo({
mountComponent({
props,
iterationQueryHandler,
});
......@@ -97,7 +114,7 @@ describe('Iterations report', () => {
});
it('renders an iteration title', async () => {
mountComponentWithApollo({
mountComponent({
props,
iterationQueryHandler: jest.fn().mockResolvedValue(mockIteration),
});
......@@ -109,44 +126,27 @@ describe('Iterations report', () => {
});
});
const mountComponent = ({ props = defaultProps, loading = false } = {}) => {
wrapper = shallowMount(IterationReport, {
propsData: props,
mocks: {
$apollo: {
queries: { iteration: { loading } },
},
},
provide: {
fullPath: props.fullPath,
},
stubs: {
GlLoadingIcon,
GlTab,
GlTabs,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('shows spinner while loading', () => {
mountComponent({
loading: true,
});
expect(findLoadingIcon().exists()).toBe(true);
});
describe('empty state', () => {
it('shows empty state if no item loaded', () => {
it('shows empty state if no item loaded', async () => {
mountComponent({
loading: false,
iterationQueryHandler: jest.fn().mockResolvedValue({
data: {
group: {
iterations: {
nodes: [],
},
},
},
}),
});
await waitForPromises();
expect(findEmptyState().props('title')).toBe('Could not find iteration');
expect(findTitle().exists()).toBe(false);
expect(findDescription().exists()).toBe(false);
......@@ -155,30 +155,21 @@ describe('Iterations report', () => {
});
describe('item loaded', () => {
const iteration = {
title: 'June week 1',
id: 'gid://gitlab/Iteration/2',
descriptionHtml: 'The first week of June',
startDate: '2020-06-02',
dueDate: '2020-06-08',
state: 'opened',
};
describe('user without edit permission', () => {
beforeEach(() => {
beforeEach(async () => {
mountComponent({
loading: false,
iterationQueryHandler: jest.fn().mockResolvedValue(mockGroupIterations),
});
wrapper.setData({
iteration,
});
await waitForPromises();
});
it('shows status and date in header', () => {
expect(findTopbar().text()).toContain('Open');
expect(findTopbar().text()).toContain('Jun 2, 2020');
expect(findTopbar().text()).toContain('Jun 8, 2020');
const startDate = IterationReport.methods.formatDate(mockIterationNode.startDate);
const dueDate = IterationReport.methods.formatDate(mockIterationNode.startDate);
expect(findTopbar().text().toLowerCase()).toContain(mockIterationNode.state);
expect(findTopbar().text()).toContain(startDate);
expect(findTopbar().text()).toContain(dueDate);
});
it('hides empty region and loading spinner', () => {
......@@ -186,9 +177,12 @@ describe('Iterations report', () => {
expect(findEmptyState().exists()).toBe(false);
});
it('shows title and description', () => {
expect(findTitle().text()).toContain(iteration.title);
expect(findDescription().text()).toContain(iteration.descriptionHtml);
it('shows title', () => {
expect(findTitle().text()).toContain(mockIterationNode.title);
});
it('shows description', () => {
expect(findDescription().text()).toContain(mockIterationNode.description);
});
it('hides actions dropdown', () => {
......@@ -200,91 +194,16 @@ describe('Iterations report', () => {
expect(iterationReportTabs.props()).toMatchObject({
fullPath: defaultProps.fullPath,
iterationId: iteration.id,
labelsFetchPath: defaultProps.labelsFetchPath,
iterationId: mockIterationNode.id,
labelsFetchPath,
namespaceType: Namespace.Group,
});
});
});
describe('user with edit permission', () => {
describe('loading report view', () => {
beforeEach(() => {
mountComponent({
props: {
...defaultProps,
canEdit: true,
},
loading: false,
});
wrapper.setData({
iteration,
});
});
it('updates URL when loading form', async () => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
clickEditButton();
await wrapper.vm.$nextTick();
expect(window.history.pushState).toHaveBeenCalledWith(
{ prev: 'viewIteration' },
null,
'/edit',
);
});
});
describe('loading edit form directly', () => {
beforeEach(() => {
mountComponent({
props: {
...defaultProps,
canEdit: true,
initiallyEditing: true,
},
loading: false,
});
wrapper.setData({
iteration,
});
});
it('updates URL when cancelling form submit', async () => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
findIterationForm().vm.$emit('cancel');
await wrapper.vm.$nextTick();
expect(window.history.pushState).toHaveBeenCalledWith(
{ prev: 'editIteration' },
null,
'/',
);
});
it('updates URL after form submitted', async () => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
findIterationForm().vm.$emit('updated');
await wrapper.vm.$nextTick();
expect(window.history.pushState).toHaveBeenCalledWith(
{ prev: 'editIteration' },
null,
'/',
);
});
});
});
describe('actions dropdown to edit iteration', () => {
describe.each`
description | canEdit | namespaceType | canEditIteration
description | canEditIteration | namespaceType | canEdit
${'has permissions'} | ${true} | ${Namespace.Group} | ${true}
${'has permissions'} | ${true} | ${Namespace.Project} | ${false}
${'does not have permissions'} | ${false} | ${Namespace.Group} | ${false}
......@@ -296,18 +215,14 @@ describe('Iterations report', () => {
mountComponent({
props: {
...defaultProps,
canEdit,
canEditIteration,
namespaceType,
},
});
wrapper.setData({
iteration,
});
});
it(`${canEditIteration ? 'is shown' : 'is hidden'}`, () => {
expect(wrapper.findComponent(GlDropdown).exists()).toBe(canEditIteration);
expect(wrapper.findComponent(GlDropdown).exists()).toBe(canEdit);
});
},
);
......
import { GlDropdown, GlDropdownItem, GlEmptyState, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import IterationForm from 'ee/iterations/components/iteration_form_without_vue_router.vue';
import IterationReportTabs from 'ee/iterations/components/iteration_report_tabs.vue';
import IterationReport from 'ee/iterations/components/iteration_report_without_vue_router.vue';
import { Namespace } from 'ee/iterations/constants';
import query from 'ee/iterations/queries/iteration.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { mockIterationNode, mockGroupIterations, mockProjectIterations } from '../mock_data';
const localVue = createLocalVue();
describe('Iterations report', () => {
let wrapper;
let mockApollo;
const defaultProps = {
fullPath: 'gitlab-org',
labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true',
};
const findTopbar = () => wrapper.find({ ref: 'topbar' });
const findTitle = () => wrapper.find({ ref: 'title' });
const findDescription = () => wrapper.find({ ref: 'description' });
const findActionsDropdown = () => wrapper.find('[data-testid="actions-dropdown"]');
const clickEditButton = () => {
findActionsDropdown().vm.$emit('click');
wrapper.findComponent(GlDropdownItem).vm.$emit('click');
};
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findIterationForm = () => wrapper.findComponent(IterationForm);
const mountComponentWithApollo = ({
props = defaultProps,
iterationQueryHandler = jest.fn(),
} = {}) => {
localVue.use(VueApollo);
mockApollo = createMockApollo([[query, iterationQueryHandler]]);
wrapper = shallowMount(IterationReport, {
localVue,
apolloProvider: mockApollo,
propsData: props,
provide: {
fullPath: props.fullPath,
},
stubs: {
GlLoadingIcon,
GlTab,
GlTabs,
},
});
};
describe('with mock apollo', () => {
describe.each([
[
'group',
{
fullPath: 'group-name',
iterationId: String(getIdFromGraphQLId(mockIterationNode.id)),
},
mockGroupIterations,
{
fullPath: 'group-name',
id: mockIterationNode.id,
isGroup: true,
},
],
[
'project',
{
fullPath: 'group-name/project-name',
iterationId: String(getIdFromGraphQLId(mockIterationNode.id)),
namespaceType: Namespace.Project,
},
mockProjectIterations,
{
fullPath: 'group-name/project-name',
id: mockIterationNode.id,
isGroup: false,
},
],
])('when viewing an iteration in a %s', (_, props, mockIteration, expectedParams) => {
it('calls a query with correct parameters', () => {
const iterationQueryHandler = jest.fn();
mountComponentWithApollo({
props,
iterationQueryHandler,
});
expect(iterationQueryHandler).toHaveBeenNthCalledWith(1, expectedParams);
});
it('renders an iteration title', async () => {
mountComponentWithApollo({
props,
iterationQueryHandler: jest.fn().mockResolvedValue(mockIteration),
});
await waitForPromises();
expect(findTitle().text()).toContain(mockIterationNode.title);
});
});
});
const mountComponent = ({ props = defaultProps, loading = false } = {}) => {
wrapper = shallowMount(IterationReport, {
propsData: props,
mocks: {
$apollo: {
queries: { iteration: { loading } },
},
},
provide: {
fullPath: props.fullPath,
},
stubs: {
GlLoadingIcon,
GlTab,
GlTabs,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('shows spinner while loading', () => {
mountComponent({
loading: true,
});
expect(findLoadingIcon().exists()).toBe(true);
});
describe('empty state', () => {
it('shows empty state if no item loaded', () => {
mountComponent({
loading: false,
});
expect(findEmptyState().props('title')).toBe('Could not find iteration');
expect(findTitle().exists()).toBe(false);
expect(findDescription().exists()).toBe(false);
expect(findActionsDropdown().exists()).toBe(false);
});
});
describe('item loaded', () => {
const iteration = {
title: 'June week 1',
id: 'gid://gitlab/Iteration/2',
descriptionHtml: 'The first week of June',
startDate: '2020-06-02',
dueDate: '2020-06-08',
state: 'opened',
};
describe('user without edit permission', () => {
beforeEach(() => {
mountComponent({
loading: false,
});
wrapper.setData({
iteration,
});
});
it('shows status and date in header', () => {
expect(findTopbar().text()).toContain('Open');
expect(findTopbar().text()).toContain('Jun 2, 2020');
expect(findTopbar().text()).toContain('Jun 8, 2020');
});
it('hides empty region and loading spinner', () => {
expect(findLoadingIcon().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(false);
});
it('shows title and description', () => {
expect(findTitle().text()).toContain(iteration.title);
expect(findDescription().text()).toContain(iteration.descriptionHtml);
});
it('hides actions dropdown', () => {
expect(findActionsDropdown().exists()).toBe(false);
});
it('shows IterationReportTabs component', () => {
const iterationReportTabs = wrapper.findComponent(IterationReportTabs);
expect(iterationReportTabs.props()).toMatchObject({
fullPath: defaultProps.fullPath,
iterationId: iteration.id,
labelsFetchPath: defaultProps.labelsFetchPath,
namespaceType: Namespace.Group,
});
});
});
describe('user with edit permission', () => {
describe('loading report view', () => {
beforeEach(() => {
mountComponent({
props: {
...defaultProps,
canEdit: true,
},
loading: false,
});
wrapper.setData({
iteration,
});
});
it('updates URL when loading form', async () => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
clickEditButton();
await wrapper.vm.$nextTick();
expect(window.history.pushState).toHaveBeenCalledWith(
{ prev: 'viewIteration' },
null,
'/edit',
);
});
});
describe('loading edit form directly', () => {
beforeEach(() => {
mountComponent({
props: {
...defaultProps,
canEdit: true,
initiallyEditing: true,
},
loading: false,
});
wrapper.setData({
iteration,
});
});
it('updates URL when cancelling form submit', async () => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
findIterationForm().vm.$emit('cancel');
await wrapper.vm.$nextTick();
expect(window.history.pushState).toHaveBeenCalledWith(
{ prev: 'editIteration' },
null,
'/',
);
});
it('updates URL after form submitted', async () => {
jest.spyOn(window.history, 'pushState').mockImplementation(() => {});
findIterationForm().vm.$emit('updated');
await wrapper.vm.$nextTick();
expect(window.history.pushState).toHaveBeenCalledWith(
{ prev: 'editIteration' },
null,
'/',
);
});
});
});
describe('actions dropdown to edit iteration', () => {
describe.each`
description | canEdit | namespaceType | canEditIteration
${'has permissions'} | ${true} | ${Namespace.Group} | ${true}
${'has permissions'} | ${true} | ${Namespace.Project} | ${false}
${'does not have permissions'} | ${false} | ${Namespace.Group} | ${false}
${'does not have permissions'} | ${false} | ${Namespace.Project} | ${false}
`(
'when user $description and they are viewing an iteration within a $namespaceType',
({ canEdit, namespaceType, canEditIteration }) => {
beforeEach(() => {
mountComponent({
props: {
...defaultProps,
canEdit,
namespaceType,
},
});
wrapper.setData({
iteration,
});
});
it(`${canEditIteration ? 'is shown' : 'is hidden'}`, () => {
expect(wrapper.findComponent(GlDropdown).exists()).toBe(canEditIteration);
});
},
);
});
});
});
export const mockIterationNode = {
description: 'some description',
descriptionHtml: '<p></p>',
descriptionHtml: '<p>some description</p>',
dueDate: '2021-02-17',
id: 'gid://gitlab/Iteration/4',
iid: '1',
......
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