Commit fd6ae5c7 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '207981-show-requirements-list' into 'master'

[Part-2] Show requirements list for the project

Closes #207981

See merge request gitlab-org/gitlab!27596
parents 6f405064 465b3903
<script>
import { escape as esc } from 'lodash';
import { GlPopover, GlLink, GlAvatar, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
components: {
GlPopover,
GlLink,
GlAvatar,
GlButton,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
requirement: {
type: Object,
required: true,
validator: value =>
['iid', 'state', 'userPermissions', 'title', 'createdAt', 'updatedAt', 'author'].every(
prop => value[prop],
),
},
},
computed: {
reference() {
return `REQ-${this.requirement.iid}`;
},
canUpdate() {
return this.requirement.userPermissions.updateRequirement;
},
canArchive() {
return this.requirement.userPermissions.adminRequirement;
},
createdAt() {
return sprintf(__('created %{timeAgo}'), {
timeAgo: esc(getTimeago().format(this.requirement.createdAt)),
});
},
updatedAt() {
return sprintf(__('updated %{timeAgo}'), {
timeAgo: esc(getTimeago().format(this.requirement.updatedAt)),
});
},
author() {
return this.requirement.author;
},
},
};
</script>
<template>
<li class="issue requirement">
<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">
<span class="issuable-reference text-muted d-block d-sm-none">{{ reference }}</span>
<div class="issue-title title">
<span class="issue-title-text">{{ requirement.title }}</span>
</div>
<div class="issuable-info">
<span class="issuable-authored d-none d-sm-inline-block">
<span
v-gl-tooltip:tooltipcontainer.bottom
:title="tooltipTitle(requirement.createdAt)"
>{{ createdAt }}</span
>
{{ __('by') }}
<gl-link ref="authorLink" class="author-link js-user-link" :href="author.webUrl">
<span class="author">{{ author.name }}</span>
</gl-link>
</span>
</div>
</div>
<div class="issuable-meta">
<ul v-if="canUpdate || canArchive" class="controls flex-column flex-sm-row">
<li v-if="canUpdate" class="requirement-edit d-sm-block">
<gl-button v-gl-tooltip size="sm" class="border-0" :title="__('Edit')">
<gl-icon name="pencil" />
</gl-button>
</li>
<li v-if="canArchive" class="requirement-archive d-sm-block">
<gl-button v-gl-tooltip size="sm" class="border-0" :title="__('Archive')">
<gl-icon name="archive" />
</gl-button>
</li>
</ul>
<div class="float-right issuable-updated-at d-none d-sm-inline-block">
<span
v-gl-tooltip:tooltipcontainer.bottom
:title="tooltipTitle(requirement.updatedAt)"
>{{ updatedAt }}</span
>
</div>
</div>
</div>
</div>
<gl-popover :target="() => $refs.authorLink.$el" triggers="hover focus" placement="top">
<div class="user-popover p-0 d-flex">
<div class="p-1 flex-shrink-1">
<gl-avatar :entity-name="author.name" :alt="author.name" :src="author.avatarUrl" />
</div>
<div class="p-1 w-100">
<h5 class="m-0">{{ author.name }}</h5>
<div class="text-secondary mb-2">@{{ author.username }}</div>
</div>
</div>
</gl-popover>
</li>
</template>
<script>
import { GlEmptyState } from '@gitlab/ui';
import { FilterStateEmptyMessage } from '../constants';
export default {
components: {
GlEmptyState,
},
props: {
filterBy: {
type: String,
required: true,
},
emptyStatePath: {
type: String,
required: true,
},
},
computed: {
emptyStateTitle() {
return FilterStateEmptyMessage[this.filterBy];
},
},
};
</script>
<template>
<div class="requirements-empty-state-container">
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyStatePath" />
</div>
</template>
<script>
import * as Sentry from '@sentry/browser';
import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
import RequirementsEmptyState from './requirements_empty_state.vue';
import RequirementItem from './requirement_item.vue';
import projectRequirements from '../queries/projectRequirements.query.graphql';
import { FilterState } from '../constants';
export default {
components: {
GlLoadingIcon,
RequirementsEmptyState,
RequirementItem,
},
props: {
projectPath: {
type: String,
required: true,
},
filterBy: {
type: String,
required: true,
......@@ -9,15 +29,62 @@ export default {
type: Boolean,
required: true,
},
emptyStatePath: {
type: String,
required: true,
},
},
apollo: {
requirements: {
query: projectRequirements,
variables() {
const queryVariables = {
projectPath: this.projectPath,
};
if (this.filterBy !== FilterState.all) {
queryVariables.state = this.filterBy;
}
return queryVariables;
},
update: data => data.project?.requirements?.nodes || [],
error: e => {
createFlash(__('Something went wrong while fetching requirements list.'));
Sentry.captureException(e);
},
},
},
data() {
return {
requirements: [],
};
},
computed: {
requirementsListLoading() {
return this.$apollo.queries.requirements.loading;
},
requirementsListEmpty() {
return !this.$apollo.queries.requirements.loading && !this.requirements.length;
},
},
};
</script>
<template>
<div class="card card-small card-without-border">
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<h3 v-if="showCreateRequirement">Create Requirement Form visible!</h3>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<h2>Hello World from Requirements app!</h2>
<div class="requirements-list-container">
<requirements-empty-state
v-if="requirementsListEmpty"
:filter-by="filterBy"
:empty-state-path="emptyStatePath"
/>
<gl-loading-icon v-if="requirementsListLoading" class="mt-3" size="md" />
<ul v-else class="content-list issuable-list issues-list requirements-list">
<requirement-item
v-for="requirement in requirements"
:key="requirement.iid"
:requirement="requirement"
/>
</ul>
</div>
</template>
// eslint-disable-next-line import/prefer-default-export
import { __ } from '~/locale';
export const FilterState = {
Open: 'opened',
Closed: 'closed',
All: 'all',
opened: 'OPENED',
archived: 'ARCHIVED',
all: 'ALL',
};
export const FilterStateEmptyMessage = {
OPENED: __('There are no open requirements'),
ARCHIVED: __('There are no archived requirements'),
};
query projectRequirements($projectPath: ID!, $state: RequirementState) {
project(fullPath: $projectPath) {
requirements(sort: created_desc, state: $state) {
nodes {
iid
title
createdAt
updatedAt
state
userPermissions {
updateRequirement
adminRequirement
}
author {
name
username
avatarUrl
webUrl
}
}
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import RequirementsRoot from './components/requirements_root.vue';
import { FilterState } from './constants';
Vue.use(VueApollo);
export default () => {
const btnNewRequirement = document.querySelector('.js-new-requirement');
const el = document.getElementById('js-requirements-app');
......@@ -12,15 +16,25 @@ export default () => {
return false;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
components: {
RequirementsRoot,
},
data() {
const { filterBy, projectPath, emptyStatePath } = el.dataset;
const stateFilterBy = filterBy ? FilterState[filterBy] : FilterState.opened;
return {
showCreateRequirement: false,
filterBy: el?.dataset?.filterBy || FilterState.Open,
filterBy: stateFilterBy,
emptyStatePath,
projectPath,
};
},
mounted() {
......@@ -37,8 +51,10 @@ export default () => {
render(createElement) {
return createElement('requirements-root', {
props: {
projectPath: this.projectPath,
filterBy: this.filterBy,
showCreateRequirement: this.showCreateRequirement,
emptyStatePath: this.emptyStatePath,
},
});
},
......
.requirements-container {
// Following overrides will be removed once
// we add filtered search bar on the page
// see https://gitlab.com/gitlab-org/gitlab/-/issues/212543
@include media-breakpoint-down(xs) {
.top-area {
border-bottom: 0;
.nav-links.mobile-separator {
margin-bottom: 0;
}
}
}
}
.requirements-list-container {
.issuable-info {
// The size here is specific to correctly
// align info row perfectly with action buttons & updated date.
margin-top: 9px;
}
.controls {
.requirement-edit .btn,
.requirement-archive .btn {
padding: $gl-padding-4 $gl-vert-padding;
}
}
}
......@@ -32,7 +32,6 @@ module EE
field :requirements, ::Types::RequirementType.connection_type, null: true,
description: 'Find requirements. Available only when feature flag `requirements_management` is enabled.',
max_page_size: 2000,
resolver: ::Resolvers::RequirementsResolver
field :requirement_states_count, ::Types::RequirementStatesCountType, null: true,
......
- page_title _('Requirements')
- type = :requirements
- page_context_word = type.to_s.humanize(capitalize: false)
- @content_class = 'requirements-container'
.top-area
%ul.nav-links.mobile-separator.requirements-state-filters
......@@ -18,4 +19,8 @@
%button.btn.btn-success.js-new-requirement.qa-new-requirement-button{ type: 'button' }
= _('New requirement')
#js-requirements-app{ data: { filter_by: params[:state] } }
#js-requirements-app{ data: { filter_by: params[:state],
project_path: @project.full_path,
empty_state_path: image_path('illustrations/empty-state/empty-requirements-lg.svg') } }
.gl-spinner-container.mt-3
%span.align-text-bottom.gl-spinner.gl-spinner-orange.gl-spinner-md{ aria: { label: _('Loading'), hidden: 'true' } }
......@@ -3,8 +3,11 @@
require 'spec_helper'
describe 'Requirements list', :js do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:requirement1) { create(:requirement, project: project, title: 'Some requirement-1', author: user, created_at: 5.days.ago, updated_at: 2.days.ago) }
let_it_be(:requirement2) { create(:requirement, project: project, title: 'Some requirement-2', author: user, created_at: 6.days.ago, updated_at: 2.days.ago) }
let_it_be(:requirement3) { create(:requirement, project: project, title: 'Some requirement-3', author: user, created_at: 7.days.ago, updated_at: 2.days.ago) }
before do
stub_licensed_features(requirements: true)
......@@ -16,6 +19,8 @@ describe 'Requirements list', :js do
context 'when requirements exist for the project' do
before do
visit project_requirements_path(project)
wait_for_requests
end
it 'shows the requirements in the navigation sidebar' do
......@@ -41,5 +46,23 @@ describe 'Requirements list', :js do
expect(find('button.js-new-requirement')).to have_content('New requirement')
end
end
it 'shows list of all available requirements' do
page.within('.requirements-list-container .requirements-list') do
expect(page).to have_selector('li.requirement', count: 3)
end
end
it 'shows title, metadata and actions within each requirement item' do
page.within('.requirements-list li.requirement', match: :first) do
expect(page.find('.issuable-reference')).to have_content("REQ-#{requirement1.iid}")
expect(page.find('.issue-title-text')).to have_content(requirement1.title)
expect(page.find('.issuable-authored')).to have_content('created 5 days ago by')
expect(page.find('.author')).to have_content(user.name)
expect(page.find('.controls')).to have_selector('li.requirement-edit button[title="Edit"]')
expect(page.find('.controls')).to have_selector('li.requirement-archive button[title="Archive"]')
expect(page.find('.issuable-updated-at')).to have_content('updated 2 days ago')
end
end
end
end
import { shallowMount } from '@vue/test-utils';
import { GlLink, GlButton, GlIcon } from '@gitlab/ui';
import RequirementItem from 'ee/requirements/components/requirement_item.vue';
import { requirement1, mockUserPermissions } from '../mock_data';
const createComponent = (requirement = requirement1) =>
shallowMount(RequirementItem, {
propsData: {
requirement,
},
});
describe('RequirementItem', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('reference', () => {
it('returns string containing `requirement.iid` prefixed with "REQ-"', () => {
expect(wrapper.vm.reference).toBe(`REQ-${requirement1.iid}`);
});
});
describe('canUpdate', () => {
it('returns value of `requirement.userPermissions.updateRequirement`', () => {
expect(wrapper.vm.canUpdate).toBe(requirement1.userPermissions.updateRequirement);
});
});
describe('canArchive', () => {
it('returns value of `requirement.userPermissions.updateRequirement`', () => {
expect(wrapper.vm.canArchive).toBe(requirement1.userPermissions.adminRequirement);
});
});
describe('createdAt', () => {
it('returns timeago-style string representing `requirement.createdAt`', () => {
// We don't have to be accurate here as it is already covered in rspecs
expect(wrapper.vm.createdAt).toContain('created');
expect(wrapper.vm.createdAt).toContain('ago');
});
});
describe('updatedAt', () => {
it('returns timeago-style string representing `requirement.updatedAt`', () => {
// We don't have to be accurate here as it is already covered in rspecs
expect(wrapper.vm.updatedAt).toContain('updated');
expect(wrapper.vm.updatedAt).toContain('ago');
});
});
describe('author', () => {
it('returns value of `requirement.author`', () => {
expect(wrapper.vm.author).toBe(requirement1.author);
});
});
});
describe('template', () => {
it('renders component container element containing class `requirement`', () => {
expect(wrapper.classes()).toContain('requirement');
});
it('renders element containing requirement reference', () => {
expect(wrapper.find('.issuable-reference').text()).toBe(`REQ-${requirement1.iid}`);
});
it('renders element containing requirement title', () => {
expect(wrapper.find('.issue-title-text').text()).toBe(requirement1.title);
});
it('renders element containing requirement created at', () => {
const createdAtEl = wrapper.find('.issuable-info .issuable-authored > span');
expect(createdAtEl.text()).toContain('created');
expect(createdAtEl.text()).toContain('ago');
expect(createdAtEl.attributes('title')).toBe('Mar 19, 2020 8:09am GMT+0000');
});
it('renders element containing requirement author information', () => {
const authorEl = wrapper.find(GlLink);
expect(authorEl.attributes('href')).toBe(requirement1.author.webUrl);
expect(authorEl.find('.author').text()).toBe(requirement1.author.name);
});
it('renders element containing requirement `Edit` button when `requirement.userPermissions.updateRequirement` is true', () => {
const editButtonEl = wrapper.find('.controls .requirement-edit').find(GlButton);
expect(editButtonEl.exists()).toBe(true);
expect(editButtonEl.attributes('title')).toBe('Edit');
expect(editButtonEl.find(GlIcon).exists()).toBe(true);
expect(editButtonEl.find(GlIcon).props('name')).toBe('pencil');
});
it('does not render element containing requirement `Edit` button when `requirement.userPermissions.updateRequirement` is false', () => {
const wrapperNoEdit = createComponent({
...requirement1,
userPermissions: {
...mockUserPermissions,
updateRequirement: false,
},
});
expect(wrapperNoEdit.find('.controls .requirement-edit').exists()).toBe(false);
wrapperNoEdit.destroy();
});
it('renders element containing requirement `Archive` button when `requirement.userPermissions.adminRequirement` is true', () => {
const archiveButtonEl = wrapper.find('.controls .requirement-archive').find(GlButton);
expect(archiveButtonEl.exists()).toBe(true);
expect(archiveButtonEl.attributes('title')).toBe('Archive');
expect(archiveButtonEl.find(GlIcon).exists()).toBe(true);
expect(archiveButtonEl.find(GlIcon).props('name')).toBe('archive');
});
it('does not render element containing requirement `Archive` button when `requirement.userPermissions.adminRequirement` is false', () => {
const wrapperNoArchive = createComponent({
...requirement1,
userPermissions: {
...mockUserPermissions,
adminRequirement: false,
},
});
expect(wrapperNoArchive.find('.controls .requirement-archive').exists()).toBe(false);
wrapperNoArchive.destroy();
});
it('renders element containing requirement updated at', () => {
const updatedAtEl = wrapper.find('.issuable-meta .issuable-updated-at > span');
expect(updatedAtEl.text()).toContain('updated');
expect(updatedAtEl.text()).toContain('ago');
expect(updatedAtEl.attributes('title')).toBe('Mar 20, 2020 8:09am GMT+0000');
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import RequirementsEmptyState from 'ee/requirements/components/requirements_empty_state.vue';
import { FilterState } from 'ee/requirements/constants';
const createComponent = (
filterBy = FilterState.opened,
emptyStatePath = '/assets/illustrations/empty-state/requirements.svg',
) =>
shallowMount(RequirementsEmptyState, {
propsData: {
filterBy,
emptyStatePath,
},
});
describe('RequirementsEmptyState', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('computed', () => {
describe('emptyStateTitle', () => {
it('returns string "There are no open requirements" when value of `filterBy` prop is "OPENED"', () => {
expect(wrapper.vm.emptyStateTitle).toBe('There are no open requirements');
});
it('returns string "There are no archived requirements" when value of `filterBy` prop is "ARCHIVED"', () => {
wrapper.setProps({
filterBy: FilterState.archived,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.emptyStateTitle).toBe('There are no archived requirements');
});
});
});
});
describe('template', () => {
it('renders empty state element', () => {
const emptyStateEl = wrapper.find(GlEmptyState);
expect(emptyStateEl.exists()).toBe(true);
expect(emptyStateEl.props('title')).toBe('There are no open requirements');
expect(emptyStateEl.attributes('svgpath')).toBe(
'/assets/illustrations/empty-state/requirements.svg',
);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import RequirementsRoot from 'ee/requirements/components/requirements_root.vue';
import RequirementsEmptyState from 'ee/requirements/components/requirements_empty_state.vue';
import RequirementItem from 'ee/requirements/components/requirement_item.vue';
import { FilterState } from 'ee/requirements/constants';
import { mockRequirements } from '../mock_data';
const createComponent = ({
projectPath = 'gitlab-org/gitlab-shell',
filterBy = FilterState.opened,
showCreateRequirement = false,
emptyStatePath = '/assets/illustrations/empty-state/requirements.svg',
loading = false,
} = {}) =>
shallowMount(RequirementsRoot, {
propsData: {
projectPath,
filterBy,
showCreateRequirement,
emptyStatePath,
},
mocks: {
$apollo: {
queries: {
requirements: {
loading,
},
},
},
},
});
describe('RequirementsRoot', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('template', () => {
it('renders component container element with class `requirements-list-container`', () => {
expect(wrapper.classes()).toContain('requirements-list-container');
});
it('renders empty state query results are empty', () => {
expect(wrapper.find(RequirementsEmptyState).exists()).toBe(true);
});
it('renders loading icon when query results are still being loaded', () => {
const wrapperLoading = createComponent({ loading: true });
expect(wrapperLoading.find(GlLoadingIcon).exists()).toBe(true);
wrapperLoading.destroy();
});
it('renders requirement items for all the requirements', () => {
wrapper.setData({
requirements: mockRequirements,
});
return wrapper.vm.$nextTick(() => {
const itemsContainer = wrapper.find('ul.requirements-list');
expect(itemsContainer.exists()).toBe(true);
expect(itemsContainer.findAll(RequirementItem).length).toBe(mockRequirements.length);
});
});
});
});
export const mockUserPermissions = {
updateRequirement: true,
adminRequirement: true,
};
export const mockAuthor = {
name: 'Administrator',
username: 'root',
avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
webUrl: 'http://0.0.0.0:3000/root',
};
export const requirement1 = {
iid: '1',
title: 'Virtutis, magnitudinis animi, patientiae, fortitudinis fomentis dolor mitigari solet.',
createdAt: '2020-03-19T08:09:09Z',
updatedAt: '2020-03-20T08:09:09Z',
state: 'OPENED',
userPermissions: mockUserPermissions,
author: mockAuthor,
};
export const requirement2 = {
iid: '2',
title: 'Est autem officium, quod ita factum est, ut eius facti probabilis ratio reddi possit.',
createdAt: '2020-03-19T08:08:14Z',
updatedAt: '2020-03-20T08:08:14Z',
state: 'OPENED',
userPermissions: mockUserPermissions,
author: mockAuthor,
};
export const requirement3 = {
iid: '3',
title: 'Non modo carum sibi quemque, verum etiam vehementer carum esse',
createdAt: '2020-03-19T08:08:25Z',
updatedAt: '2020-03-20T08:08:25Z',
state: 'OPENED',
userPermissions: mockUserPermissions,
author: mockAuthor,
};
export const mockRequirements = [requirement1, requirement2, requirement3];
......@@ -2270,6 +2270,9 @@ msgstr ""
msgid "April"
msgstr ""
msgid "Archive"
msgstr ""
msgid "Archive jobs"
msgstr ""
......@@ -12045,6 +12048,9 @@ msgstr ""
msgid "Live preview"
msgstr ""
msgid "Loading"
msgstr ""
msgid "Loading blob"
msgstr ""
......@@ -18645,6 +18651,9 @@ msgstr ""
msgid "Something went wrong while fetching related merge requests."
msgstr ""
msgid "Something went wrong while fetching requirements list."
msgstr ""
msgid "Something went wrong while fetching the environments for this merge request. Please try again."
msgstr ""
......@@ -20136,6 +20145,9 @@ msgstr ""
msgid "There are no archived projects yet"
msgstr ""
msgid "There are no archived requirements"
msgstr ""
msgid "There are no changes"
msgstr ""
......@@ -20169,6 +20181,9 @@ msgstr ""
msgid "There are no open merge requests"
msgstr ""
msgid "There are no open requirements"
msgstr ""
msgid "There are no packages yet"
msgstr ""
......@@ -23916,6 +23931,9 @@ msgstr ""
msgid "created"
msgstr ""
msgid "created %{timeAgo}"
msgstr ""
msgid "customize"
msgstr ""
......@@ -24784,6 +24802,9 @@ msgstr ""
msgid "updated"
msgstr ""
msgid "updated %{timeAgo}"
msgstr ""
msgid "updated %{time_ago}"
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