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

Merge branch '229706-requirement-create-edit-drawer' into 'master'

Use Drawer for Requirement Create/Edit form UI

Closes #229706

See merge request gitlab-org/gitlab!37943
parents abf47a37 1680a7e5
<script>
import { GlFormGroup, GlFormTextarea, GlDeprecatedButton } from '@gitlab/ui';
import { GlDrawer, GlFormGroup, GlFormTextarea, GlDeprecatedButton } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { __, sprintf } from '~/locale';
......@@ -10,11 +10,16 @@ export default {
limit: MAX_TITLE_LENGTH,
}),
components: {
GlDrawer,
GlFormGroup,
GlFormTextarea,
GlDeprecatedButton,
},
props: {
drawerOpen: {
type: Boolean,
required: true,
},
requirement: {
type: Object,
required: false,
......@@ -27,13 +32,15 @@ export default {
},
data() {
return {
isCreate: isEmpty(this.requirement),
title: this.requirement?.title || '',
};
},
computed: {
isCreate() {
return isEmpty(this.requirement);
},
fieldLabel() {
return this.isCreate ? __('New requirement') : __('Requirement');
return this.isCreate ? __('New Requirement') : __('Edit Requirement');
},
saveButtonLabel() {
return this.isCreate ? __('Create requirement') : __('Save changes');
......@@ -48,7 +55,24 @@ export default {
return `REQ-${this.requirement?.iid}`;
},
},
watch: {
requirement: {
handler(value) {
this.title = value?.title || '';
},
deep: true,
},
},
methods: {
getDrawerHeaderHeight() {
const wrapperEl = document.querySelector('.js-requirements-container-wrapper');
if (wrapperEl) {
return `${wrapperEl.offsetTop}px`;
}
return '';
},
handleSave() {
if (this.isCreate) {
this.$emit('save', this.title);
......@@ -64,46 +88,50 @@ export default {
</script>
<template>
<div
class="requirement-form"
:class="{ 'p-3 border-bottom': isCreate, 'd-block d-sm-flex': !isCreate }"
>
<span v-if="!isCreate" class="text-muted mr-1">{{ reference }}</span>
<div class="requirement-form-container" :class="{ 'flex-grow-1 ml-sm-1 mt-1': !isCreate }">
<gl-form-group
:label="fieldLabel"
:invalid-feedback="$options.titleInvalidMessage"
:state="!titleInvalid"
class="gl-show-field-errors"
label-for="requirementTitle"
>
<gl-form-textarea
id="requirementTitle"
v-model.trim="title"
autofocus
resize
:disabled="requirementRequestActive"
:placeholder="__('Describe the requirement here')"
max-rows="25"
class="requirement-form-textarea"
:class="{ 'gl-field-error-outline': titleInvalid }"
@keyup.escape.exact="$emit('cancel')"
/>
</gl-form-group>
<div class="d-flex requirement-form-actions">
<gl-deprecated-button
:disabled="disableSaveButton"
:loading="requirementRequestActive"
category="primary"
variant="success"
class="mr-auto js-requirement-save"
@click="handleSave"
>{{ saveButtonLabel }}</gl-deprecated-button
>
<gl-deprecated-button class="js-requirement-cancel" @click="$emit('cancel')">{{
__('Cancel')
}}</gl-deprecated-button>
<gl-drawer :open="drawerOpen" :header-height="getDrawerHeaderHeight()" @close="$emit('cancel')">
<template #header>
<h4 class="m-0">{{ fieldLabel }}</h4>
</template>
<template>
<div class="requirement-form">
<span v-if="!isCreate" class="text-muted">{{ reference }}</span>
<div class="requirement-form-container" :class="{ 'flex-grow-1 mt-1': !isCreate }">
<gl-form-group
:label="__('Title')"
:invalid-feedback="$options.titleInvalidMessage"
:state="!titleInvalid"
class="gl-show-field-errors"
label-for="requirementTitle"
>
<gl-form-textarea
id="requirementTitle"
v-model.trim="title"
autofocus
resize
:disabled="requirementRequestActive"
:placeholder="__('Describe the requirement here')"
max-rows="25"
class="requirement-form-textarea"
:class="{ 'gl-field-error-outline': titleInvalid }"
@keyup.escape.exact="$emit('cancel')"
/>
</gl-form-group>
<div class="d-flex requirement-form-actions">
<gl-deprecated-button
:disabled="disableSaveButton"
:loading="requirementRequestActive"
category="primary"
variant="success"
class="mr-auto js-requirement-save"
@click="handleSave"
>{{ saveButtonLabel }}</gl-deprecated-button
>
<gl-deprecated-button class="js-requirement-cancel" @click="$emit('cancel')">
{{ __('Cancel') }}
</gl-deprecated-button>
</div>
</div>
</div>
</div>
</div>
</template>
</gl-drawer>
</template>
......@@ -13,7 +13,6 @@ import { __, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import RequirementForm from './requirement_form.vue';
import RequirementStatusBadge from './requirement_status_badge.vue';
import { FilterState } from '../constants';
......@@ -26,7 +25,6 @@ export default {
GlDeprecatedButton,
GlIcon,
GlLoadingIcon,
RequirementForm,
RequirementStatusBadge,
},
directives: {
......@@ -49,16 +47,6 @@ export default {
'testReports',
].every(prop => value[prop]),
},
showUpdateForm: {
type: Boolean,
required: false,
default: false,
},
updateRequirementRequestActive: {
type: Boolean,
required: false,
default: false,
},
stateChangeRequestActive: {
type: Boolean,
required: false,
......@@ -110,9 +98,6 @@ export default {
}
return '';
},
handleUpdateRequirementSave(params) {
this.$emit('updateSave', params);
},
handleArchiveClick() {
this.$emit('archiveClick', {
iid: this.requirement.iid,
......@@ -131,14 +116,7 @@ export default {
<template>
<li class="issue requirement" :class="{ 'disabled-content': stateChangeRequestActive }">
<requirement-form
v-if="showUpdateForm"
:requirement="requirement"
:requirement-request-active="updateRequirementRequestActive"
@save="handleUpdateRequirementSave"
@cancel="$emit('updateCancel')"
/>
<div v-else class="issue-box">
<div class="issue-box">
<div class="issuable-info-container">
<span class="issuable-reference text-muted d-none d-sm-block mr-2">{{ reference }}</span>
<div class="issuable-main-info">
......@@ -185,7 +163,7 @@ export default {
size="sm"
class="border-0"
:title="__('Edit')"
@click="$emit('editClick', requirement.iid)"
@click="$emit('editClick', requirement)"
>
<gl-icon name="pencil" />
</gl-deprecated-button>
......
......@@ -34,7 +34,8 @@ export default {
RequirementsLoading,
RequirementsEmptyState,
RequirementItem,
RequirementForm,
RequirementCreateForm: RequirementForm,
RequirementEditForm: RequirementForm,
},
props: {
projectPath: {
......@@ -174,7 +175,8 @@ export default {
authorUsernames: this.initialAuthorUsernames,
sortBy: this.initialSortBy,
showCreateForm: false,
showUpdateFormForRequirement: 0,
showEditForm: false,
editedRequirement: null,
createRequirementRequestActive: false,
stateChangeRequestActiveFor: 0,
currentPage: this.page,
......@@ -367,8 +369,9 @@ export default {
handleNewRequirementClick() {
this.showCreateForm = true;
},
handleEditRequirementClick(iid) {
this.showUpdateFormForRequirement = iid;
handleEditRequirementClick(requirement) {
this.showEditForm = true;
this.editedRequirement = requirement;
},
handleNewRequirementSave(title) {
this.createRequirementRequestActive = true;
......@@ -415,7 +418,8 @@ export default {
})
.then(({ data }) => {
if (!data.updateRequirement.errors.length) {
this.showUpdateFormForRequirement = 0;
this.showEditForm = false;
this.editedRequirement = null;
this.$toast.show(
sprintf(__('Requirement %{reference} has been updated'), {
reference: `REQ-${data.updateRequirement.requirement.iid}`,
......@@ -458,7 +462,8 @@ export default {
});
},
handleUpdateRequirementCancel() {
this.showUpdateFormForRequirement = 0;
this.showEditForm = false;
this.editedRequirement = null;
},
handleFilterRequirements(filters = []) {
const authors = [];
......@@ -529,12 +534,19 @@ export default {
@onFilter="handleFilterRequirements"
@onSort="handleSortRequirements"
/>
<requirement-form
v-if="showCreateForm"
<requirement-create-form
:drawer-open="showCreateForm"
:requirement-request-active="createRequirementRequestActive"
@save="handleNewRequirementSave"
@cancel="handleNewRequirementCancel"
/>
<requirement-edit-form
:drawer-open="showEditForm"
:requirement="editedRequirement"
:requirement-request-active="createRequirementRequestActive"
@save="handleUpdateRequirementSave"
@cancel="handleUpdateRequirementCancel"
/>
<requirements-empty-state
v-if="showEmptyState"
:filter-by="filterBy"
......@@ -557,11 +569,7 @@ export default {
v-for="requirement in requirementsList"
:key="requirement.iid"
:requirement="requirement"
:show-update-form="showUpdateFormForRequirement === requirement.iid"
:update-requirement-request-active="createRequirementRequestActive"
:state-change-request-active="stateChangeRequestActiveFor === requirement.iid"
@updateSave="handleUpdateRequirementSave"
@updateCancel="handleUpdateRequirementCancel"
@editClick="handleEditRequirementClick"
@archiveClick="handleRequirementStateChange"
@reopenClick="handleRequirementStateChange"
......
......@@ -23,6 +23,12 @@
}
.requirements-list-container {
.gl-search-box-by-click {
.gl-filtered-search-scrollable {
border-radius: 0;
}
}
.requirements-list {
li .issuable-main-info {
// These rules prevent adjecant REQ ID from wrapping
......@@ -64,6 +70,28 @@
}
}
}
.gl-drawer {
width: 480px;
padding-left: $gl-padding;
padding-right: $gl-padding;
box-shadow: none;
background-color: $gray-10;
border-left: 1px solid $gray-100;
@include media-breakpoint-down(sm) {
width: 100%;
}
// These overrides should not happen here,
// we should ideally have support for custom
// header and body classes in `GlDrawer`.
.gl-drawer-header,
.gl-drawer-body > * {
padding-left: 0;
padding-right: 0;
}
}
}
.requirement-status-tooltip {
......
- page_title _('Requirements')
- @content_wrapper_class = 'js-requirements-container-wrapper'
- @content_class = 'requirements-container'
-# We'd prefer to have following declarations be part of
......
---
title: Use Drawer for Requirement Create/Edit form UI
merge_request: 37943
author:
type: changed
......@@ -133,18 +133,20 @@ RSpec.describe 'Requirements list', :js do
end
it 'shows edit form when edit button is clicked for a requirement' do
page.within('.requirements-list li.requirement', match: :first) do
requirement_title = 'Foobar'
requirement_title = 'Foobar'
page.within('.requirements-list li.requirement', match: :first) do
find('li.requirement-edit button[title="Edit"]').click
end
page.within('.requirement-form') do
find('textarea#requirementTitle').native.send_keys requirement_title
find('button.js-requirement-save').click
page.within('.requirement-form') do
find('textarea#requirementTitle').native.send_keys requirement_title
find('button.js-requirement-save').click
wait_for_all_requests
end
wait_for_all_requests
end
page.within('.requirements-list li.requirement', match: :first) do
expect(page.find('.issue-title-text')).to have_content(requirement_title)
end
end
......@@ -152,12 +154,12 @@ RSpec.describe 'Requirements list', :js do
it 'saves updated title for requirement using edit form' do
page.within('.requirements-list li.requirement', match: :first) do
find('li.requirement-edit button[title="Edit"]').click
end
page.within('.requirement-form') do
expect(page.find('span')).to have_content("REQ-#{requirement1.iid}")
expect(page.find('textarea#requirementTitle')['value']).to have_content("#{requirement1.title}")
expect(page.find('.js-requirement-save')).to have_content('Save changes')
end
page.within('.requirement-form') do
expect(page.find('span')).to have_content("REQ-#{requirement1.iid}")
expect(page.find('textarea#requirementTitle')['value']).to have_content("#{requirement1.title}")
expect(page.find('.js-requirement-save')).to have_content('Save changes')
end
end
......
import { shallowMount } from '@vue/test-utils';
import { GlFormGroup, GlFormTextarea } from '@gitlab/ui';
import { GlDrawer, GlFormGroup, GlFormTextarea } from '@gitlab/ui';
import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import { MAX_TITLE_LENGTH } from 'ee/requirements/constants';
import { mockRequirementsOpen } from '../mock_data';
const createComponent = ({ requirement = null, requirementRequestActive = false } = {}) =>
const createComponent = ({
drawerOpen = true,
requirement = null,
requirementRequestActive = false,
} = {}) =>
shallowMount(RequirementForm, {
propsData: {
drawerOpen,
requirement,
requirementRequestActive,
},
......@@ -31,13 +36,23 @@ describe('RequirementForm', () => {
});
describe('computed', () => {
describe('isCreate', () => {
it('returns true when `requirement` prop is null', () => {
expect(wrapper.vm.isCreate).toBe(true);
});
it('returns false when `requirement` prop is not null', () => {
expect(wrapperWithRequirement.vm.isCreate).toBe(false);
});
});
describe('fieldLabel', () => {
it('returns string "New requirement" when `requirement` prop is null', () => {
expect(wrapper.vm.fieldLabel).toBe('New requirement');
it('returns string "New Requirement" when `requirement` prop is null', () => {
expect(wrapper.vm.fieldLabel).toBe('New Requirement');
});
it('returns string "Requirement" when `requirement` prop is defined', () => {
expect(wrapperWithRequirement.vm.fieldLabel).toBe('Requirement');
it('returns string "Edit Requirement" when `requirement` prop is defined', () => {
expect(wrapperWithRequirement.vm.fieldLabel).toBe('Edit Requirement');
});
});
......@@ -77,6 +92,30 @@ describe('RequirementForm', () => {
});
});
describe('watchers', () => {
describe('requirement', () => {
it('sets `title` to the value of `requirement.title` when requirement is not null', async () => {
wrapper.setProps({
requirement: mockRequirementsOpen[0],
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.title).toBe(mockRequirementsOpen[0].title);
});
it('sets `title` to empty string when requirement is null', async () => {
wrapperWithRequirement.setProps({
requirement: null,
});
await wrapperWithRequirement.vm.$nextTick();
expect(wrapperWithRequirement.vm.title).toBe('');
});
});
});
describe('methods', () => {
describe('handleSave', () => {
it('emits `save` event on component with `title` as param when form is in create mode', () => {
......@@ -109,18 +148,8 @@ describe('RequirementForm', () => {
});
describe('template', () => {
it('renders component container element with classes `p-3 border-bottom` when form is in create mode', () => {
const wrapperClasses = wrapper.classes();
expect(wrapperClasses).toContain('p-3');
expect(wrapperClasses).toContain('border-bottom');
});
it('renders component container element with classes `d-block d-sm-flex` when form is in edit mode', () => {
const wrapperClasses = wrapperWithRequirement.classes();
expect(wrapperClasses).toContain('d-block');
expect(wrapperClasses).toContain('d-sm-flex');
it('renders gl-drawer as component container element', () => {
expect(wrapper.contains(GlDrawer)).toBe(true);
});
it('renders element containing requirement reference when form is in edit mode', () => {
......@@ -131,7 +160,7 @@ describe('RequirementForm', () => {
const glFormGroup = wrapper.find(GlFormGroup);
expect(glFormGroup.exists()).toBe(true);
expect(glFormGroup.attributes('label')).toBe('New requirement');
expect(glFormGroup.attributes('label')).toBe('Title');
expect(glFormGroup.attributes('label-for')).toBe('requirementTitle');
expect(glFormGroup.attributes('invalid-feedback')).toBe(
`Requirement title cannot have more than ${MAX_TITLE_LENGTH} characters.`,
......
......@@ -2,7 +2,6 @@ import { shallowMount } from '@vue/test-utils';
import { GlLink, GlDeprecatedButton, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import RequirementItem from 'ee/requirements/components/requirement_item.vue';
import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import RequirementStatusBadge from 'ee/requirements/components/requirement_status_badge.vue';
import {
......@@ -92,17 +91,6 @@ describe('RequirementItem', () => {
});
describe('methods', () => {
describe('handleUpdateRequirementSave', () => {
it('emits `updateSave` event on component with params passed as it is', () => {
wrapper.vm.handleUpdateRequirementSave('foo');
return wrapper.vm.$nextTick(() => {
expect(wrapper.emitted('updateSave')).toBeTruthy();
expect(wrapper.emitted('updateSave')[0]).toEqual(['foo']);
});
});
});
describe('handleArchiveClick', () => {
it('emits `archiveClick` event on component with object containing `requirement.iid` & `state` as "ARCHIVED" as param', () => {
wrapper.vm.handleArchiveClick();
......@@ -151,16 +139,6 @@ describe('RequirementItem', () => {
});
});
it('renders requirement-form component', () => {
wrapper.setProps({
showUpdateForm: true,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(RequirementForm).exists()).toBe(true);
});
});
it('renders element containing requirement reference', () => {
expect(wrapper.find('.issuable-reference').text()).toBe(`REQ-${requirement1.iid}`);
});
......
......@@ -12,7 +12,6 @@ import RequirementsTabs from 'ee/requirements/components/requirements_tabs.vue';
import RequirementsLoading from 'ee/requirements/components/requirements_loading.vue';
import RequirementsEmptyState from 'ee/requirements/components/requirements_empty_state.vue';
import RequirementItem from 'ee/requirements/components/requirement_item.vue';
import RequirementForm from 'ee/requirements/components/requirement_form.vue';
import createRequirement from 'ee/requirements/queries/createRequirement.mutation.graphql';
import updateRequirement from 'ee/requirements/queries/updateRequirement.mutation.graphql';
......@@ -378,10 +377,11 @@ describe('RequirementsRoot', () => {
});
describe('handleEditRequirementClick', () => {
it('sets `showUpdateFormForRequirement` prop to value of passed param', () => {
wrapper.vm.handleEditRequirementClick('10');
it('sets `showEditForm` prop to `true` and `editedRequirement` to value of passed param', () => {
wrapper.vm.handleEditRequirementClick(mockRequirementsOpen[0]);
expect(wrapper.vm.showUpdateFormForRequirement).toBe('10');
expect(wrapper.vm.showEditForm).toBe(true);
expect(wrapper.vm.editedRequirement).toBe(mockRequirementsOpen[0]);
});
});
......@@ -494,7 +494,7 @@ describe('RequirementsRoot', () => {
);
});
it('sets `showUpdateFormForRequirement` to `0` and `createRequirementRequestActive` prop to `false` when request is successful', () => {
it('sets `showEditForm` to `true`, `editedRequirement` to `null` and `createRequirementRequestActive` prop to `false` when request is successful', () => {
jest.spyOn(wrapper.vm, 'updateRequirement').mockResolvedValue(mockUpdateMutationResult);
return wrapper.vm
......@@ -503,7 +503,8 @@ describe('RequirementsRoot', () => {
title: 'foo',
})
.then(() => {
expect(wrapper.vm.showUpdateFormForRequirement).toBe(0);
expect(wrapper.vm.showEditForm).toBe(false);
expect(wrapper.vm.editedRequirement).toBe(null);
expect(wrapper.vm.createRequirementRequestActive).toBe(false);
});
});
......@@ -649,10 +650,11 @@ describe('RequirementsRoot', () => {
});
describe('handleUpdateRequirementCancel', () => {
it('sets `showUpdateFormForRequirement` prop to `0`', () => {
it('sets `showEditForm` prop to `false` and `editedRequirement` to `null`', () => {
wrapper.vm.handleUpdateRequirementCancel();
expect(wrapper.vm.showUpdateFormForRequirement).toBe(0);
expect(wrapper.vm.showEditForm).toBe(false);
expect(wrapper.vm.editedRequirement).toBe(null);
});
});
......@@ -793,14 +795,12 @@ describe('RequirementsRoot', () => {
wrapperLoading.destroy();
});
it('renders requirement-form component when `showCreateForm` prop is `true`', () => {
wrapper.setData({
showCreateForm: true,
});
it('renders requirement-create-form component', () => {
expect(wrapper.contains('requirement-create-form-stub')).toBe(true);
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.contains(RequirementForm)).toBe(true);
});
it('renders requirement-edit-form component', () => {
expect(wrapper.contains('requirement-edit-form-stub')).toBe(true);
});
it('does not render requirement-empty-state component when `showCreateForm` prop is `true`', () => {
......
......@@ -8653,6 +8653,9 @@ msgstr ""
msgid "Edit Release"
msgstr ""
msgid "Edit Requirement"
msgstr ""
msgid "Edit Slack integration"
msgstr ""
......@@ -15812,6 +15815,9 @@ msgstr ""
msgid "New Project"
msgstr ""
msgid "New Requirement"
msgstr ""
msgid "New Snippet"
msgstr ""
......@@ -20344,9 +20350,6 @@ msgstr ""
msgid "Required in this project."
msgstr ""
msgid "Requirement"
msgstr ""
msgid "Requirement %{reference} has been added"
msgstr ""
......
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