Commit a65be6a1 authored by Martin Wortschack's avatar Martin Wortschack

Switch VSA to be using GraphQL for projects query

- This MR switches group-level VSA
to be using the GraphQL API
for querying projects
parent 1e5ef1a2
......@@ -3,7 +3,7 @@ import { GlEmptyState } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { PROJECTS_PER_PAGE } from '../constants';
import ProjectsDropdownFilter from '../../shared/components/projects_dropdown_filter.vue';
import { SIMILARITY_ORDER, LAST_ACTIVITY_AT, DATE_RANGE_LIMIT } from '../../shared/constants';
import { DATE_RANGE_LIMIT } from '../../shared/constants';
import DateRange from '../../shared/components/daterange.vue';
import StageTable from './stage_table.vue';
import DurationChart from './duration_chart.vue';
......@@ -116,12 +116,8 @@ export default {
},
projectsQueryParams() {
return {
per_page: PROJECTS_PER_PAGE,
with_shared: false,
order_by: this.featureFlags.hasAnalyticsSimilaritySearch
? SIMILARITY_ORDER
: LAST_ACTIVITY_AT,
include_subgroups: true,
first: PROJECTS_PER_PAGE,
includeSubgroups: true,
};
},
},
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { GlToast } from '@gitlab/ui';
import CycleAnalytics from './components/base.vue';
import createStore from './store';
import { buildCycleAnalyticsInitialData } from '../shared/utils';
import createDefaultClient from '~/lib/graphql';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
Vue.use(GlToast);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => {
const el = document.querySelector('#js-cycle-analytics-app');
......@@ -43,6 +50,7 @@ export default () => {
return new Vue({
el,
name: 'CycleAnalyticsApp',
apolloProvider,
store,
render: createElement =>
createElement(CycleAnalytics, {
......
import dateFormat from 'dateformat';
import { isNumber } from 'lodash';
import httpStatus from '~/lib/utils/http_status';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from '../../shared/constants';
import { transformStagesForPathNavigation } from '../utils';
......@@ -14,7 +15,7 @@ export const currentValueStreamId = ({ selectedValueStream }) =>
export const currentGroupPath = ({ currentGroup }) => currentGroup?.fullPath || null;
export const selectedProjectIds = ({ selectedProjects }) =>
selectedProjects?.map(({ id }) => id) || [];
selectedProjects?.map(({ id }) => getIdFromGraphQLId(id)) || [];
export const cycleAnalyticsRequestParams = (state, getters) => {
const {
......
......@@ -10,7 +10,6 @@ import {
GlSearchBoxByType,
} from '@gitlab/ui';
import { n__, s__, __ } from '~/locale';
import Api from '~/api';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DATA_REFETCH_DELAY } from '../constants';
import { filterBySearchTerm } from '../utils';
......@@ -56,11 +55,6 @@ export default {
required: false,
default: () => [],
},
useGraphql: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -133,42 +127,33 @@ export default {
fetchData() {
this.loading = true;
if (this.useGraphql) {
return this.$apollo
.query({
query: getProjects,
variables: {
groupFullPath: this.groupNamespace,
search: this.searchTerm,
...this.queryParams,
},
})
.then(response => {
const {
data: {
group: {
projects: { nodes },
},
return this.$apollo
.query({
query: getProjects,
variables: {
groupFullPath: this.groupNamespace,
search: this.searchTerm,
...this.queryParams,
},
})
.then(response => {
const {
data: {
group: {
projects: { nodes },
},
} = response;
this.loading = false;
this.projects = nodes;
});
}
},
} = response;
return Api.groupProjects(this.groupId, this.searchTerm, this.queryParams, projects => {
this.projects = projects;
this.loading = false;
});
this.loading = false;
this.projects = nodes;
});
},
isProjectSelected(id) {
return this.selectedProjects ? this.selectedProjectIds.includes(id) : false;
},
getEntityId(project) {
if (this.useGraphql) return getIdFromGraphQLId(project.id);
return project?.id || null;
return getIdFromGraphQLId(project.id);
},
},
};
......@@ -184,7 +169,7 @@ export default {
<div class="gl-display-flex gl-flex-fill-1">
<gl-avatar
v-if="isOnlyOneProjectSelected"
:src="useGraphql ? selectedProjects[0].avatarUrl : selectedProjects[0].avatar_url"
:src="selectedProjects[0].avatarUrl"
:entity-id="getEntityId(selectedProjects[0])"
:entity-name="selectedProjects[0].name"
:size="16"
......@@ -213,7 +198,7 @@ export default {
:size="16"
:entity-id="getEntityId(project)"
:entity-name="project.name"
:src="useGraphql ? project.avatarUrl : project.avatar_url"
:src="project.avatarUrl"
shape="rect"
/>
{{ project.name }}
......
......@@ -14,10 +14,6 @@ export const scatterChartLineProps = {
},
};
export const LAST_ACTIVITY_AT = 'last_activity_at';
export const SIMILARITY_ORDER = 'similarity';
export const DATE_RANGE_LIMIT = 180;
export const OFFSET_DATE_BY_ONE = 1;
......
......@@ -113,7 +113,7 @@ module Gitlab
def project_data_attributes(project)
{
id: project.id,
id: project.to_gid.to_s,
name: project.name,
path_with_namespace: project.path_with_namespace,
avatar_url: project.avatar_url
......
......@@ -24,6 +24,7 @@ import httpStatusCodes from '~/lib/utils/http_status';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import * as commonUtils from '~/lib/utils/common_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import * as mockData from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
......@@ -62,6 +63,11 @@ const mocks = {
$toast: {
show: jest.fn(),
},
$apollo: {
query: jest.fn().mockResolvedValue({
data: { group: { projects: { nodes: [] } } },
}),
},
};
function mockRequiredRoutes(mockAdapter) {
......@@ -387,25 +393,6 @@ describe('Cycle Analytics component', () => {
});
});
describe('when analyticsSimilaritySearch feature flag is on', () => {
beforeEach(async () => {
wrapper = await createComponent({
withStageSelected: true,
featureFlags: {
hasAnalyticsSimilaritySearch: true,
},
});
});
it('uses similarity as the order param', () => {
displaysProjectsDropdownFilter(true);
expect(wrapper.find(ProjectsDropdownFilter).props().queryParams.order_by).toEqual(
'similarity',
);
});
});
it('displays the date range picker', () => {
displaysDateRangePicker(true);
});
......@@ -632,7 +619,7 @@ describe('Cycle Analytics component', () => {
project_ids: null,
};
const selectedProjectIds = mockData.selectedProjects.map(({ id }) => id);
const selectedProjectIds = mockData.selectedProjects.map(({ id }) => getIdFromGraphQLId(id));
beforeEach(async () => {
commonUtils.historyPushState = jest.fn();
......
......@@ -251,13 +251,13 @@ export const rawDurationMedianData = [
export const selectedProjects = [
{
id: 1,
id: 'gid://gitlab/Project/1',
name: 'cool project',
pathWithNamespace: 'group/cool-project',
avatarUrl: null,
},
{
id: 2,
id: 'gid://gitlab/Project/2',
name: 'another cool project',
pathWithNamespace: 'group/another-cool-project',
avatarUrl: null,
......
......@@ -2,15 +2,9 @@ import { mount } from '@vue/test-utils';
import ProjectsDropdownFilter from 'ee/analytics/shared/components/projects_dropdown_filter.vue';
import getProjects from 'ee/analytics/shared/graphql/projects.query.graphql';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { LAST_ACTIVITY_AT } from 'ee/analytics/shared/constants';
import { TEST_HOST } from 'helpers/test_constants';
import Api from '~/api';
jest.mock('~/api', () => ({
groupProjects: jest.fn(),
}));
const mockGraphqlProjects = [
const projects = [
{
id: 'gid://gitlab/Project/1',
name: 'Gitlab Test',
......@@ -31,28 +25,10 @@ const mockGraphqlProjects = [
},
];
const projects = [
{
id: 1,
name: 'foo',
avatar_url: `${TEST_HOST}/images/home/nasa.svg`,
},
{
id: 2,
name: 'foobar',
avatar_url: null,
},
{
id: 3,
name: 'foooooooo',
avatar_url: null,
},
];
const defaultMocks = {
$apollo: {
query: jest.fn().mockResolvedValue({
data: { group: { projects: { nodes: mockGraphqlProjects } } },
data: { group: { projects: { nodes: projects } } },
}),
},
};
......@@ -98,37 +74,9 @@ describe('ProjectsDropdownFilter component', () => {
.find('button')
.trigger('click');
describe('when using the REST API', () => {
describe('queryParams are applied when fetching data', () => {
beforeEach(() => {
Api.groupProjects.mockImplementation((groupId, term, options, callback) => {
callback(projects);
});
createComponent({
queryParams: {
per_page: 50,
with_shared: false,
order_by: LAST_ACTIVITY_AT,
},
});
});
it('applies the correct queryParams when making an api call', () => {
expect(Api.groupProjects).toHaveBeenCalledWith(
expect.any(Number),
expect.any(String),
expect.objectContaining({ per_page: 50, with_shared: false, order_by: LAST_ACTIVITY_AT }),
expect.any(Function),
);
});
});
});
describe('when using the GraphQL API', () => {
describe('queryParams are applied when fetching data', () => {
beforeEach(() => {
createComponent({
useGraphql: true,
queryParams: {
first: 50,
includeSubgroups: true,
......@@ -156,295 +104,127 @@ describe('ProjectsDropdownFilter component', () => {
});
describe('when passed a an array of defaultProject as prop', () => {
describe('when using the RESTP API', () => {
beforeEach(() => {
Api.groupProjects.mockImplementation((groupId, term, options, callback) => {
callback(projects);
});
createComponent({
defaultProjects: [projects[0]],
});
beforeEach(() => {
createComponent({
defaultProjects: [projects[0]],
});
});
it("displays the defaultProject's name", () => {
expect(findDropdownButton().text()).toContain(projects[0].name);
});
it("displays the defaultProject's name", () => {
expect(findDropdownButton().text()).toContain(projects[0].name);
});
it("renders the defaultProject's avatar", () => {
expect(findDropdownButtonAvatar().exists()).toBe(true);
});
it("renders the defaultProject's avatar", () => {
expect(findDropdownButtonAvatar().exists()).toBe(true);
});
it('marks the defaultProject as selected', () => {
expect(findDropdownAtIndex(0).props('isChecked')).toBe(true);
});
it('marks the defaultProject as selected', () => {
expect(findDropdownAtIndex(0).props('isChecked')).toBe(true);
});
});
describe('when using the GraphQL API', () => {
beforeEach(() => {
createComponent({
useGraphql: true,
defaultProjects: [mockGraphqlProjects[0]],
});
});
describe('when multiSelect is false', () => {
beforeEach(() => {
createComponent({ multiSelect: false });
});
it("displays the defaultProject's name", () => {
expect(findDropdownButton().text()).toContain(mockGraphqlProjects[0].name);
describe('displays the correct information', () => {
it('contains 3 items', () => {
expect(findDropdownItems()).toHaveLength(3);
});
it("renders the defaultProject's avatar", () => {
expect(findDropdownButtonAvatar().exists()).toBe(true);
it('renders an avatar when the project has an avatarUrl', () => {
expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it('marks the defaultProject as selected', () => {
expect(findDropdownAtIndex(0).props('isChecked')).toBe(true);
it("renders an identicon when the project doesn't have an avatarUrl", () => {
expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
});
});
describe('when multiSelect is false', () => {
describe('when using the RESTP API', () => {
beforeEach(() => {
Api.groupProjects.mockImplementation((groupId, term, options, callback) => {
callback(projects);
});
describe('on project click', () => {
it('should emit the "selected" event with the selected project', () => {
selectDropdownItemAtIndex(0);
createComponent({ multiSelect: false });
expect(wrapper.emitted().selected).toEqual([[[projects[0]]]]);
});
describe('displays the correct information', () => {
it('contains 3 items', () => {
expect(findDropdownItems()).toHaveLength(3);
});
it('should change selection when new project is clicked', () => {
selectDropdownItemAtIndex(1);
it('renders an avatar when the project has an avatar_url', () => {
expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it("renders an identicon when the project doesn't have an avatar_url", () => {
expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
expect(wrapper.emitted().selected).toEqual([[[projects[1]]]]);
});
describe('on project click', () => {
it('should emit the "selected" event with the selected project', () => {
selectDropdownItemAtIndex(0);
expect(wrapper.emitted().selected).toEqual([[[projects[0]]]]);
});
it('should change selection when new project is clicked', () => {
selectDropdownItemAtIndex(1);
expect(wrapper.emitted().selected).toEqual([[[projects[1]]]]);
});
it('selection should be emptied when a project is deselected', () => {
selectDropdownItemAtIndex(0); // Select the item
selectDropdownItemAtIndex(0); // deselect it
expect(wrapper.emitted().selected).toEqual([[[projects[0]]], [[]]]);
});
it('renders an avatar in the dropdown button when the project has an avatar_url', async () => {
selectDropdownItemAtIndex(0);
await wrapper.vm.$nextTick().then(() => {
expect(
findDropdownButton()
.find('img.gl-avatar')
.exists(),
).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
});
it("renders an identicon in the dropdown button when the project doesn't have an avatar_url", async () => {
selectDropdownItemAtIndex(1);
await wrapper.vm.$nextTick().then(() => {
expect(
findDropdownButton()
.find('img.gl-avatar')
.exists(),
).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
});
});
});
it('selection should be emptied when a project is deselected', () => {
selectDropdownItemAtIndex(0); // Select the item
selectDropdownItemAtIndex(0); // deselect it
describe('when using the GraphQl API', () => {
beforeEach(() => {
createComponent({ multiSelect: false, useGraphql: true });
expect(wrapper.emitted().selected).toEqual([[[projects[0]]], [[]]]);
});
describe('displays the correct information', () => {
it('contains 3 items', () => {
expect(findDropdownItems()).toHaveLength(3);
});
it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => {
selectDropdownItemAtIndex(0);
it('renders an avatar when the project has an avatarUrl', () => {
await wrapper.vm.$nextTick().then(() => {
expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it("renders an identicon when the project doesn't have an avatarUrl", () => {
expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
});
describe('on project click', () => {
it('should emit the "selected" event with the selected project', () => {
selectDropdownItemAtIndex(0);
expect(wrapper.emitted().selected).toEqual([[[mockGraphqlProjects[0]]]]);
});
it('should change selection when new project is clicked', () => {
selectDropdownItemAtIndex(1);
expect(wrapper.emitted().selected).toEqual([[[mockGraphqlProjects[1]]]]);
});
it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => {
selectDropdownItemAtIndex(1);
it('selection should be emptied when a project is deselected', () => {
selectDropdownItemAtIndex(0); // Select the item
selectDropdownItemAtIndex(0); // deselect it
expect(wrapper.emitted().selected).toEqual([[[mockGraphqlProjects[0]]], [[]]]);
});
it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => {
selectDropdownItemAtIndex(0);
await wrapper.vm.$nextTick().then(() => {
expect(
findDropdownButton()
.find('img.gl-avatar')
.exists(),
).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
});
it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => {
selectDropdownItemAtIndex(1);
await wrapper.vm.$nextTick().then(() => {
expect(
findDropdownButton()
.find('img.gl-avatar')
.exists(),
).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
await wrapper.vm.$nextTick().then(() => {
expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
});
});
});
describe('when multiSelect is true', () => {
describe('when using the RESTP API', () => {
beforeEach(() => {
Api.groupProjects.mockImplementation((groupId, term, options, callback) => {
callback(projects);
});
beforeEach(() => {
createComponent({ multiSelect: true });
});
createComponent({ multiSelect: true });
describe('displays the correct information', () => {
it('contains 3 items', () => {
expect(findDropdownItems()).toHaveLength(3);
});
describe('displays the correct information', () => {
it('contains 3 items', () => {
expect(findDropdownItems()).toHaveLength(3);
});
it('renders an avatar when the project has an avatar_url', () => {
expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it("renders an identicon when the project doesn't have an avatar_url", () => {
expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
it('renders an avatar when the project has an avatarUrl', () => {
expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
describe('on project click', () => {
it('should add to selection when new project is clicked', () => {
selectDropdownItemAtIndex(0);
selectDropdownItemAtIndex(1);
expect(wrapper.emitted().selected).toEqual([
[[projects[0]]],
[[projects[0], projects[1]]],
]);
});
it('should remove from selection when clicked again', () => {
selectDropdownItemAtIndex(0);
selectDropdownItemAtIndex(0);
expect(wrapper.emitted().selected).toEqual([[[projects[0]]], [[]]]);
});
it('renders the correct placeholder text when multiple projects are selected', async () => {
selectDropdownItemAtIndex(0);
selectDropdownItemAtIndex(1);
await wrapper.vm.$nextTick().then(() => {
expect(findDropdownButton().text()).toBe('2 projects selected');
});
});
it("renders an identicon when the project doesn't have an avatarUrl", () => {
expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
});
describe('when using the GraphQl API', () => {
beforeEach(() => {
createComponent({ multiSelect: true, useGraphql: true });
});
describe('displays the correct information', () => {
it('contains 3 items', () => {
expect(findDropdownItems()).toHaveLength(3);
});
describe('on project click', () => {
it('should add to selection when new project is clicked', () => {
selectDropdownItemAtIndex(0);
selectDropdownItemAtIndex(1);
it('renders an avatar when the project has an avatarUrl', () => {
expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it("renders an identicon when the project doesn't have an avatarUrl", () => {
expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
expect(wrapper.emitted().selected).toEqual([[[projects[0]]], [[projects[0], projects[1]]]]);
});
describe('on project click', () => {
it('should add to selection when new project is clicked', () => {
selectDropdownItemAtIndex(0);
selectDropdownItemAtIndex(1);
expect(wrapper.emitted().selected).toEqual([
[[mockGraphqlProjects[0]]],
[[mockGraphqlProjects[0], mockGraphqlProjects[1]]],
]);
});
it('should remove from selection when clicked again', () => {
selectDropdownItemAtIndex(0);
selectDropdownItemAtIndex(0);
it('should remove from selection when clicked again', () => {
selectDropdownItemAtIndex(0);
selectDropdownItemAtIndex(0);
expect(wrapper.emitted().selected).toEqual([[[mockGraphqlProjects[0]]], [[]]]);
});
expect(wrapper.emitted().selected).toEqual([[[projects[0]]], [[]]]);
});
it('renders the correct placeholder text when multiple projects are selected', async () => {
selectDropdownItemAtIndex(0);
selectDropdownItemAtIndex(1);
it('renders the correct placeholder text when multiple projects are selected', async () => {
selectDropdownItemAtIndex(0);
selectDropdownItemAtIndex(1);
await wrapper.vm.$nextTick().then(() => {
expect(findDropdownButton().text()).toBe('2 projects selected');
});
await wrapper.vm.$nextTick().then(() => {
expect(findDropdownButton().text()).toBe('2 projects selected');
});
});
});
......
......@@ -82,7 +82,7 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::RequestParams do
describe 'optional `project_ids`' do
context 'when `project_ids` is not empty' do
def json_project(project)
{ id: project.id,
{ id: project.to_gid.to_s,
name: project.name,
path_with_namespace: project.path_with_namespace,
avatar_url: project.avatar_url }.to_json
......
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