Commit bc09e70b authored by Simon Knox's avatar Simon Knox

Merge branch 'psi-project-cadences' into 'master'

Add project iteration cadences

See merge request gitlab-org/gitlab!64051
parents 1effe4bc e94200ed
......@@ -83,7 +83,7 @@ export default {
GlFormSelect,
GlFormTextarea,
},
inject: ['groupPath', 'cadencesListPath'],
inject: ['fullPath', 'cadencesListPath'],
data() {
return {
group: {
......@@ -133,7 +133,7 @@ export default {
const id = this.isEdit
? convertToGraphQLId(TYPE_ITERATIONS_CADENCE, this.cadenceId)
: undefined;
const groupPath = this.isEdit ? undefined : this.groupPath;
const groupPath = this.isEdit ? undefined : this.fullPath;
const vars = {
input: {
......@@ -160,7 +160,7 @@ export default {
query: readCadence,
variables() {
return {
fullPath: this.groupPath,
fullPath: this.fullPath,
id: this.cadenceId,
};
},
......
......@@ -12,7 +12,9 @@ import {
} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import query from '../queries/iterations_in_cadence.query.graphql';
import { Namespace } from '../constants';
import groupQuery from '../queries/group_iterations_in_cadence.query.graphql';
import projectQuery from '../queries/project_iterations_in_cadence.query.graphql';
const pageSize = 20;
......@@ -43,11 +45,13 @@ export default {
GlSkeletonLoader,
},
apollo: {
group: {
workspace: {
skip() {
return !this.expanded;
},
query,
query() {
return this.query;
},
variables() {
return this.queryVariables;
},
......@@ -56,7 +60,7 @@ export default {
},
},
},
inject: ['groupPath', 'canEditCadence'],
inject: ['fullPath', 'canEditCadence', 'namespaceType'],
props: {
title: {
type: String,
......@@ -86,7 +90,7 @@ export default {
i18n,
expanded: false,
// query response
group: {
workspace: {
iterations: {
nodes: [],
pageInfo: {
......@@ -100,25 +104,34 @@ export default {
};
},
computed: {
query() {
if (this.namespaceType === Namespace.Group) {
return groupQuery;
}
if (this.namespaceType === Namespace.Project) {
return projectQuery;
}
throw new Error('Must provide a namespaceType');
},
queryVariables() {
return {
fullPath: this.groupPath,
fullPath: this.fullPath,
iterationCadenceId: this.cadenceId,
firstPageSize: pageSize,
state: this.iterationState,
};
},
pageInfo() {
return this.group.iterations?.pageInfo || {};
return this.workspace.iterations?.pageInfo || {};
},
hasNextPage() {
return this.pageInfo.hasNextPage;
},
iterations() {
return this.group?.iterations?.nodes || [];
return this.workspace?.iterations?.nodes || [];
},
loading() {
return this.$apollo.queries.group.loading;
return this.$apollo.queries.workspace.loading;
},
editCadence() {
return {
......@@ -144,24 +157,24 @@ export default {
}
// Fetch more data and transform the original result
this.$apollo.queries.group.fetchMore({
this.$apollo.queries.workspace.fetchMore({
variables: {
...this.queryVariables,
afterCursor: this.pageInfo.endCursor,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const newIterations = fetchMoreResult.group?.iterations.nodes || [];
const newIterations = fetchMoreResult.workspace?.iterations.nodes || [];
return {
group: {
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Group',
workspace: {
id: fetchMoreResult.workspace.id,
__typename: this.namespaceType,
iterations: {
__typename: 'IterationConnection',
// Merging the list
nodes: [...previousResult.group.iterations.nodes, ...newIterations],
pageInfo: fetchMoreResult.group?.iterations.pageInfo || {},
nodes: [...previousResult.workspace.iterations.nodes, ...newIterations],
pageInfo: fetchMoreResult.workspace?.iterations.pageInfo || {},
},
},
};
......
......@@ -2,8 +2,10 @@
import { GlAlert, GlButton, GlLoadingIcon, GlKeysetPagination, GlTab, GlTabs } from '@gitlab/ui';
import produce from 'immer';
import { __, s__ } from '~/locale';
import { Namespace } from '../constants';
import destroyIterationCadence from '../queries/destroy_cadence.mutation.graphql';
import query from '../queries/iteration_cadences_list.query.graphql';
import groupQuery from '../queries/group_iteration_cadences_list.query.graphql';
import projectQuery from '../queries/project_iteration_cadences_list.query.graphql';
import IterationCadenceListItem from './iteration_cadence_list_item.vue';
const pageSize = 20;
......@@ -20,8 +22,10 @@ export default {
GlTabs,
},
apollo: {
group: {
query,
workspace: {
query() {
return this.query;
},
variables() {
return this.queryVariables;
},
......@@ -30,10 +34,10 @@ export default {
},
},
},
inject: ['groupPath', 'cadencesListPath', 'canCreateCadence'],
inject: ['fullPath', 'cadencesListPath', 'canCreateCadence', 'namespaceType'],
data() {
return {
group: {
workspace: {
iterationCadences: {
nodes: [],
pageInfo: {
......@@ -48,9 +52,18 @@ export default {
};
},
computed: {
query() {
if (this.namespaceType === Namespace.Group) {
return groupQuery;
}
if (this.namespaceType === Namespace.Project) {
return projectQuery;
}
throw new Error('Must provide a namespaceType');
},
queryVariables() {
const vars = {
fullPath: this.groupPath,
fullPath: this.fullPath,
};
if (this.pagination.beforeCursor) {
......@@ -64,13 +77,13 @@ export default {
return vars;
},
cadences() {
return this.group?.iterationCadences?.nodes || [];
return this.workspace?.iterationCadences?.nodes || [];
},
pageInfo() {
return this.group?.iterationCadences?.pageInfo || {};
return this.workspace?.iterationCadences?.pageInfo || {};
},
loading() {
return this.$apollo.queries.group.loading;
return this.$apollo.queries.workspace.loading;
},
state() {
switch (this.tabIndex) {
......@@ -111,18 +124,18 @@ export default {
}
const sourceData = store.readQuery({
query,
query: this.query,
variables: this.queryVariables,
});
const data = produce(sourceData, (draftData) => {
draftData.group.iterationCadences.nodes = draftData.group.iterationCadences.nodes.filter(
draftData.workspace.iterationCadences.nodes = draftData.workspace.iterationCadences.nodes.filter(
({ id }) => id !== cadenceId,
);
});
store.writeQuery({
query,
query: this.query,
variables: this.queryVariables,
data,
});
......
......@@ -130,7 +130,7 @@ export function initCadenceApp({ namespaceType }) {
}
const {
groupFullPath: groupPath,
fullPath,
cadencesListPath,
canCreateCadence,
canEditCadence,
......@@ -158,8 +158,7 @@ export function initCadenceApp({ namespaceType }) {
router,
apolloProvider,
provide: {
fullPath: groupPath,
groupPath,
fullPath,
cadencesListPath,
canCreateCadence: parseBoolean(canCreateCadence),
canEditCadence: parseBoolean(canEditCadence),
......@@ -178,3 +177,4 @@ export function initCadenceApp({ namespaceType }) {
}
export const initGroupCadenceApp = () => initCadenceApp({ namespaceType: Namespace.Group });
export const initProjectCadenceApp = () => initCadenceApp({ namespaceType: Namespace.Project });
......@@ -7,7 +7,8 @@ query IterationCadences(
$firstPageSize: Int
$lastPageSize: Int
) {
group(fullPath: $fullPath) {
workspace: group(fullPath: $fullPath) {
id
iterationCadences(
includeAncestorGroups: true
before: $beforeCursor
......
......@@ -10,7 +10,8 @@ query Iterations(
$firstPageSize: Int
$lastPageSize: Int
) {
group(fullPath: $fullPath) {
workspace: group(fullPath: $fullPath) {
id
iterations(
iterationCadenceIds: [$iterationCadenceId]
state: $state
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query IterationCadences(
$fullPath: ID!
$beforeCursor: String = ""
$afterCursor: String = ""
$firstPageSize: Int
$lastPageSize: Int
) {
workspace: project(fullPath: $fullPath) {
id
iterationCadences(
includeAncestorGroups: true
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
nodes {
id
title
durationInWeeks
}
pageInfo {
...PageInfo
}
}
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./iteration_list_item.fragment.graphql"
query Iterations(
$fullPath: ID!
$iterationCadenceId: ID!
$state: IterationState!
$beforeCursor: String
$afterCursor: String
$firstPageSize: Int
$lastPageSize: Int
) {
workspace: project(fullPath: $fullPath) {
id
iterations(
iterationCadenceIds: [$iterationCadenceId]
state: $state
before: $beforeCursor
after: $afterCursor
first: $firstPageSize
last: $lastPageSize
) {
nodes {
...IterationListItem
}
pageInfo {
...PageInfo
}
}
}
}
import { initProjectCadenceApp } from 'ee/iterations';
initProjectCadenceApp();
# frozen_string_literal: true
module IterationCadencesActions
extend ActiveSupport::Concern
included do
before_action :check_cadences_available!
before_action :authorize_show_cadence!, only: [:index]
feature_category :issue_tracking
end
def index; end
private
def check_cadences_available!
render_404 unless group&.iteration_cadences_feature_flag_enabled?
end
def authorize_show_cadence!
render_404 unless can?(current_user, :read_iteration_cadence, group)
end
end
# frozen_string_literal: true
class Groups::IterationCadencesController < Groups::ApplicationController
before_action :check_cadences_available!
before_action :authorize_show_cadence!, only: [:index]
feature_category :issue_tracking
def index; end
private
def check_cadences_available!
render_404 unless group.iteration_cadences_feature_flag_enabled?
end
def authorize_show_cadence!
render_404 unless can?(current_user, :read_iteration_cadence, group)
end
include IterationCadencesActions
end
# frozen_string_literal: true
class Projects::IterationCadencesController < Projects::ApplicationController
include IterationCadencesActions
private
def group
project.group
end
end
- page_title s_('Iterations|Iteration cadences')
.js-iteration-cadence-app{ data: { group_full_path: @group.full_path,
.js-iteration-cadence-app{ data: { 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,
......
- page_title _('Iterations|Iteration cadences')
.js-iteration-cadence-app{ data: { full_path: @project.full_path,
cadences_list_path: project_iteration_cadences_path(@project),
has_scoped_labels_feature: @project.licensed_feature_available?(:scoped_labels).to_s,
labels_fetch_path: project_labels_path(@project, format: :json, include_ancestor_groups: true),
preview_markdown_path: preview_markdown_path(@project),
no_issues_svg_path: image_path('illustrations/issues.svg') } }
......@@ -131,6 +131,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :iterations, only: [:index, :show], constraints: { id: /\d+/ }
resources :iteration_cadences, path: 'cadences(/*vueroute)', action: :index do
resources :iterations, only: [:index, :show], constraints: { id: /\d+/ }, controller: :iteration_cadences, action: :index
end
namespace :incident_management, path: '' do
resources :oncall_schedules, only: [:index], path: 'oncall_schedules'
resources :escalation_policies, only: [:index], path: 'escalation_policies'
......
......@@ -24,10 +24,13 @@ module EE
return ::Sidebars::NilMenuItem.new(item_id: :iterations)
end
link = context.project.group&.iteration_cadences_feature_flag_enabled? ? project_iteration_cadences_path(context.project) : project_iterations_path(context.project)
controller = context.project.group&.iteration_cadences_feature_flag_enabled? ? :iteration_cadences : :iterations
::Sidebars::MenuItem.new(
title: _('Iterations'),
link: project_iterations_path(context.project),
active_routes: { controller: :iterations },
link: link,
active_routes: { controller: controller },
item_id: :iterations
)
end
......
......@@ -3,30 +3,9 @@
require 'spec_helper'
RSpec.describe Groups::IterationCadencesController do
using RSpec::Parameterized::TableSyntax
let_it_be(:group) { create(:group, :private) }
let_it_be(:cadence) { create(:iterations_cadence, group: group) }
let_it_be(:user) { create(:user) }
before do
stub_feature_flags(iteration_cadences: feature_flag_available)
group.add_user(user, role) unless role == :none
sign_in(user)
end
describe 'index' do
it_behaves_like 'accessing iteration cadences' do
subject { get :index, params: { group_id: group } }
where(:feature_flag_available, :role, :status) do
false | :developer | :not_found
true | :none | :not_found
true | :guest | :success
true | :developer | :success
end
with_them do
it_behaves_like 'returning response status', params[:status]
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::IterationCadencesController do
let_it_be(:group) { create(:group, :private) }
let_it_be(:project) { create(:project, namespace: group) }
it_behaves_like 'accessing iteration cadences' do
subject { get :index, params: { namespace_id: project.namespace, project_id: project } }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'User views project iteration cadences', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:cadence) { create(:iterations_cadence, group: group) }
let_it_be(:other_cadence) { create(:iterations_cadence, group: group) }
let_it_be(:iteration_in_cadence) { create(:iteration, group: group, iterations_cadence: cadence) }
let_it_be(:closed_iteration_in_cadence) { create(:iteration, group: group, iterations_cadence: cadence, start_date: 2.weeks.ago, due_date: 1.week.ago) }
let_it_be(:iteration_in_other_cadence) { create(:iteration, group: group, iterations_cadence: other_cadence) }
before do
stub_licensed_features(iterations: true)
end
context 'as authorized user' do
before do
group.add_developer(user)
sign_in(user)
visit project_iteration_cadences_path(project)
end
it 'shows read-only iteration cadences', :aggregate_failures do
expect(page).to have_title('Iteration cadences')
expect(page).to have_content(cadence.title)
expect(page).to have_content(other_cadence.title)
expect(page).not_to have_content(iteration_in_cadence.title)
expect(page).not_to have_content(iteration_in_other_cadence.title)
click_button cadence.title
expect(page).to have_content(iteration_in_cadence.title)
expect(page).not_to have_content(iteration_in_other_cadence.title)
expect(page).not_to have_content(closed_iteration_in_cadence.title)
expect(page).not_to have_link('New iteration cadence')
end
end
end
......@@ -67,7 +67,7 @@ describe('Iteration cadence form', () => {
$router,
},
provide: {
groupPath,
fullPath: groupPath,
cadencesListPath: TEST_HOST,
},
}),
......
......@@ -3,7 +3,9 @@ import { createLocalVue, RouterLinkStub } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import IterationCadenceListItem from 'ee/iterations/components/iteration_cadence_list_item.vue';
import iterationsInCadenceQuery from 'ee/iterations/queries/iterations_in_cadence.query.graphql';
import { Namespace } from 'ee/iterations/constants';
import groupIterationsInCadenceQuery from 'ee/iterations/queries/group_iterations_in_cadence.query.graphql';
import projectIterationsInCadenceQuery from 'ee/iterations/queries/project_iterations_in_cadence.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended as mount } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -25,7 +27,7 @@ describe('Iteration cadence list item', () => {
let wrapper;
let apolloProvider;
const groupPath = 'gitlab-org';
const fullPath = 'gitlab-org';
const iterations = [
{
dueDate: '2021-08-14',
......@@ -49,7 +51,8 @@ describe('Iteration cadence list item', () => {
const endCursor = 'MjA';
const querySuccessResponse = {
data: {
group: {
workspace: {
id: '1',
iterations: {
nodes: iterations,
pageInfo: {
......@@ -65,7 +68,8 @@ describe('Iteration cadence list item', () => {
const queryEmptyResponse = {
data: {
group: {
workspace: {
id: '1',
iterations: {
nodes: [],
pageInfo: {
......@@ -83,9 +87,11 @@ describe('Iteration cadence list item', () => {
props = {},
canCreateCadence,
canEditCadence,
namespaceType = Namespace.Group,
query = groupIterationsInCadenceQuery,
resolverMock = jest.fn().mockResolvedValue(querySuccessResponse),
} = {}) {
apolloProvider = createMockApolloProvider([[iterationsInCadenceQuery, resolverMock]]);
apolloProvider = createMockApolloProvider([[query, resolverMock]]);
wrapper = mount(IterationCadenceListItem, {
localVue,
......@@ -97,9 +103,10 @@ describe('Iteration cadence list item', () => {
RouterLink: RouterLinkStub,
},
provide: {
groupPath,
fullPath,
canCreateCadence,
canEditCadence,
namespaceType,
},
propsData: {
title: cadence.title,
......@@ -155,6 +162,21 @@ describe('Iteration cadence list item', () => {
});
});
it('loads project iterations for Project namespaceType', async () => {
await createComponent({
namespaceType: Namespace.Project,
query: projectIterationsInCadenceQuery,
});
expand();
await waitForPromises();
iterations.forEach(({ title }) => {
expect(wrapper.text()).toContain(title);
});
});
it('shows alert on query error', async () => {
await createComponent({
resolverMock: jest.fn().mockRejectedValue(queryEmptyResponse),
......@@ -171,7 +193,7 @@ describe('Iteration cadence list item', () => {
it('calls fetchMore after scrolling down', async () => {
await createComponent();
jest.spyOn(wrapper.vm.$apollo.queries.group, 'fetchMore').mockResolvedValue({});
jest.spyOn(wrapper.vm.$apollo.queries.workspace, 'fetchMore').mockResolvedValue({});
expand();
......@@ -179,7 +201,7 @@ describe('Iteration cadence list item', () => {
wrapper.findComponent(GlInfiniteScroll).vm.$emit('bottomReached');
expect(wrapper.vm.$apollo.queries.group.fetchMore).toHaveBeenCalledWith(
expect(wrapper.vm.$apollo.queries.workspace.fetchMore).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.objectContaining({
afterCursor: endCursor,
......
......@@ -4,8 +4,10 @@ import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import IterationCadenceListItem from 'ee/iterations/components/iteration_cadence_list_item.vue';
import IterationCadencesList from 'ee/iterations/components/iteration_cadences_list.vue';
import { Namespace } from 'ee/iterations/constants';
import destroyIterationCadence from 'ee/iterations/queries/destroy_cadence.mutation.graphql';
import cadencesListQuery from 'ee/iterations/queries/iteration_cadences_list.query.graphql';
import cadencesListQuery from 'ee/iterations/queries/group_iteration_cadences_list.query.graphql';
import projectCadencesListQuery from 'ee/iterations/queries/project_iteration_cadences_list.query.graphql';
import createRouter from 'ee/iterations/router';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
......@@ -28,7 +30,7 @@ describe('Iteration cadences list', () => {
let apolloProvider;
const cadencesListPath = TEST_HOST;
const groupPath = 'gitlab-org';
const fullPath = 'gitlab-org';
const cadences = [
{
id: 'gid://gitlab/Iterations::Cadence/561',
......@@ -54,7 +56,8 @@ describe('Iteration cadences list', () => {
const endCursor = 'MjA';
const querySuccessResponse = {
data: {
group: {
workspace: {
id: 'id',
iterationCadences: {
nodes: cadences,
pageInfo: {
......@@ -70,7 +73,8 @@ describe('Iteration cadences list', () => {
const queryEmptyResponse = {
data: {
group: {
workspace: {
id: '234',
iterationCadences: {
nodes: [],
pageInfo: {
......@@ -87,13 +91,15 @@ describe('Iteration cadences list', () => {
function createComponent({
canCreateCadence,
canEditCadence,
namespaceType = Namespace.Group,
query = cadencesListQuery,
resolverMock = jest.fn().mockResolvedValue(querySuccessResponse),
destroyMutationMock = jest
.fn()
.mockResolvedValue({ data: { iterationCadenceDestroy: { errors: [] } } }),
} = {}) {
apolloProvider = createMockApolloProvider([
[cadencesListQuery, resolverMock],
[query, resolverMock],
[destroyIterationCadence, destroyMutationMock],
]);
......@@ -102,7 +108,8 @@ describe('Iteration cadences list', () => {
apolloProvider,
router,
provide: {
groupPath,
fullPath,
namespaceType,
cadencesListPath,
canCreateCadence,
canEditCadence,
......@@ -169,6 +176,19 @@ describe('Iteration cadences list', () => {
});
});
it('loads project iterations for Project namespaceType', async () => {
await createComponent({
namespaceType: Namespace.Project,
query: projectCadencesListQuery,
});
await waitForPromises();
cadences.forEach(({ title }) => {
expect(wrapper.text()).toContain(title);
});
});
it('shows alert on query error', async () => {
await createComponent({
resolverMock: jest.fn().mockRejectedValue(queryEmptyResponse),
......
# frozen_string_literal: true
RSpec.shared_examples 'accessing iteration cadences' do
using RSpec::Parameterized::TableSyntax
let_it_be(:cadence) { create(:iterations_cadence, group: group) }
let_it_be(:user) { create(:user) }
before do
stub_feature_flags(iteration_cadences: feature_flag_available)
group.add_user(user, role) unless role == :none
sign_in(user)
end
describe 'index' do
where(:feature_flag_available, :role, :status) do
false | :developer | :not_found
true | :none | :not_found
true | :guest | :success
true | :developer | :success
end
with_them do
it_behaves_like 'returning response status', params[:status]
end
end
end
......@@ -28,13 +28,36 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
describe 'Issues' do
describe 'Iterations' do
it 'has a link to the issue iterations path' do
allow(view).to receive(:current_user).and_return(user)
stub_licensed_features(iterations: true)
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be_with_refind(:project) { create(:project, group: group) }
render
before do
group.add_reporter(user)
end
describe 'iteration_cadences flag enabled' do
it 'has a link to the iteration cadences path' do
stub_feature_flags(iteration_cadences: true)
allow(view).to receive(:current_user).and_return(user)
stub_licensed_features(iterations: true)
render
expect(rendered).to have_link('Iterations', href: project_iterations_path(project))
expect(rendered).to have_link('Iterations', href: project_iteration_cadences_path(project))
end
end
describe 'iteration_cadences flag enabled' do
it 'has a link to the issue iterations path' do
stub_feature_flags(iteration_cadences: false)
allow(view).to receive(:current_user).and_return(user)
stub_licensed_features(iterations: true)
render
expect(rendered).to have_link('Iterations', href: project_iterations_path(project))
end
end
end
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