Commit c104c906 authored by Payton Burdette's avatar Payton Burdette Committed by Mayra Cabrera

Add pipeline filter feature

Filter pipelines by trigger author,
Add unit tests, Add needed backend
changes to pipelines controller. Ensure
polling is working correctly with new
filter.
parent 574191a4
...@@ -51,6 +51,7 @@ document.addEventListener( ...@@ -51,6 +51,7 @@ document.addEventListener(
hasGitlabCi: parseBoolean(this.dataset.hasGitlabCi), hasGitlabCi: parseBoolean(this.dataset.hasGitlabCi),
ciLintPath: this.dataset.ciLintPath, ciLintPath: this.dataset.ciLintPath,
resetCachePath: this.dataset.resetCachePath, resetCachePath: this.dataset.resetCachePath,
projectId: this.dataset.projectId,
}, },
}); });
}, },
......
...@@ -9,14 +9,18 @@ import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; ...@@ -9,14 +9,18 @@ import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue';
import NavigationControls from './nav_controls.vue'; import NavigationControls from './nav_controls.vue';
import { getParameterByName } from '../../lib/utils/common_utils'; import { getParameterByName } from '../../lib/utils/common_utils';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin'; import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
import { ANY_TRIGGER_AUTHOR } from '../constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default { export default {
components: { components: {
TablePagination, TablePagination,
NavigationTabs, NavigationTabs,
NavigationControls, NavigationControls,
PipelinesFilteredSearch,
}, },
mixins: [pipelinesMixin, CIPaginationMixin], mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()],
props: { props: {
store: { store: {
type: Object, type: Object,
...@@ -78,6 +82,10 @@ export default { ...@@ -78,6 +82,10 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
projectId: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -209,6 +217,9 @@ export default { ...@@ -209,6 +217,9 @@ export default {
}, },
]; ];
}, },
canFilterPipelines() {
return this.glFeatures.filterPipelinesSearch;
},
}, },
created() { created() {
this.service = new PipelinesService(this.endpoint); this.service = new PipelinesService(this.endpoint);
...@@ -238,6 +249,19 @@ export default { ...@@ -238,6 +249,19 @@ export default {
createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.')); createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.'));
}); });
}, },
filterPipelines(filters) {
filters.forEach(filter => {
this.requestData[filter.type] = filter.value.data;
});
// set query params back to default if filtering by Any author
// or input is cleared on submit
if (this.requestData.username === ANY_TRIGGER_AUTHOR || filters.length === 0) {
this.requestData = { page: this.page, scope: this.scope };
}
this.updateContent(this.requestData);
},
}, },
}; };
</script> </script>
...@@ -267,6 +291,13 @@ export default { ...@@ -267,6 +291,13 @@ export default {
/> />
</div> </div>
<pipelines-filtered-search
v-if="canFilterPipelines"
:pipelines="state.pipelines"
:project-id="projectId"
@filterPipelines="filterPipelines"
/>
<div class="content-list pipelines"> <div class="content-list pipelines">
<gl-loading-icon <gl-loading-icon
v-if="stateToRender === $options.stateMap.loading" v-if="stateToRender === $options.stateMap.loading"
......
<script>
import { GlFilteredSearch } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue';
import Api from '~/api';
import createFlash from '~/flash';
export default {
components: {
GlFilteredSearch,
},
props: {
pipelines: {
type: Array,
required: true,
},
projectId: {
type: String,
required: true,
},
},
data() {
return {
projectUsers: null,
};
},
computed: {
tokens() {
return [
{
type: 'username',
icon: 'user',
title: s__('Pipeline|Trigger author'),
dataType: 'username',
unique: true,
token: PipelineTriggerAuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
triggerAuthors: this.projectUsers,
},
];
},
},
created() {
Api.projectUsers(this.projectId)
.then(users => {
this.projectUsers = users;
})
.catch(err => {
createFlash(__('There was a problem fetching project users.'));
throw err;
});
},
methods: {
onSubmit(filters) {
this.$emit('filterPipelines', filters);
},
},
};
</script>
<template>
<div class="row-content-block">
<gl-filtered-search
:placeholder="__('Filter pipelines')"
:available-tokens="tokens"
@submit="onSubmit"
/>
</div>
</template>
<script>
import {
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
GlDropdownDivider,
} from '@gitlab/ui';
import { ANY_TRIGGER_AUTHOR } from '../../constants';
export default {
anyTriggerAuthor: ANY_TRIGGER_AUTHOR,
components: {
GlFilteredSearchToken,
GlAvatar,
GlFilteredSearchSuggestion,
GlDropdownDivider,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
computed: {
currentValue() {
return this.value.data.toLowerCase();
},
filteredTriggerAuthors() {
return this.config.triggerAuthors.filter(user => {
return user.username.toLowerCase().includes(this.currentValue);
});
},
activeUser() {
return this.config.triggerAuthors.find(user => {
return user.username.toLowerCase() === this.currentValue;
});
},
},
};
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<template #view="{inputValue}">
<gl-avatar
v-if="activeUser"
:size="16"
:src="activeUser.avatar_url"
shape="circle"
class="gl-mr-2"
/>
<span>{{ activeUser ? activeUser.name : inputValue }}</span>
</template>
<template #suggestions>
<gl-filtered-search-suggestion :value="$options.anyTriggerAuthor">{{
$options.anyTriggerAuthor
}}</gl-filtered-search-suggestion>
<gl-dropdown-divider />
<gl-filtered-search-suggestion
v-for="user in filteredTriggerAuthors"
:key="user.username"
:value="user.username"
>
<div class="d-flex">
<gl-avatar :size="32" :src="user.avatar_url" />
<div>
<div>{{ user.name }}</div>
<div>@{{ user.username }}</div>
</div>
</div>
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
</template>
export const CANCEL_REQUEST = 'CANCEL_REQUEST'; export const CANCEL_REQUEST = 'CANCEL_REQUEST';
export const PIPELINES_TABLE = 'PIPELINES_TABLE'; export const PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300; export const LAYOUT_CHANGE_DELAY = 300;
export const ANY_TRIGGER_AUTHOR = 'Any';
export const TestStatus = { export const TestStatus = {
FAILED: 'failed', FAILED: 'failed',
......
...@@ -19,13 +19,19 @@ export default class PipelinesService { ...@@ -19,13 +19,19 @@ export default class PipelinesService {
} }
getPipelines(data = {}) { getPipelines(data = {}) {
const { scope, page } = data; const { scope, page, username } = data;
const { CancelToken } = axios; const { CancelToken } = axios;
const queryParams = { scope, page };
if (username) {
queryParams.username = username;
}
this.cancelationSource = CancelToken.source(); this.cancelationSource = CancelToken.source();
return axios.get(this.endpoint, { return axios.get(this.endpoint, {
params: { scope, page }, params: queryParams,
cancelToken: this.cancelationSource.token, cancelToken: this.cancelationSource.token,
}); });
} }
......
...@@ -9,21 +9,41 @@ import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/c ...@@ -9,21 +9,41 @@ import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/c
export default { export default {
methods: { methods: {
onChangeTab(scope) { onChangeTab(scope) {
this.updateContent({ scope, page: '1' }); let params = {
scope,
page: '1',
};
params = this.onChangeWithFilter(params);
this.updateContent(params);
}, },
onChangePage(page) { onChangePage(page) {
/* URLS parameters are strings, we need to parse to match types */ /* URLS parameters are strings, we need to parse to match types */
const params = { let params = {
page: Number(page).toString(), page: Number(page).toString(),
}; };
if (this.scope) { if (this.scope) {
params.scope = this.scope; params.scope = this.scope;
} }
params = this.onChangeWithFilter(params);
this.updateContent(params); this.updateContent(params);
}, },
onChangeWithFilter(params) {
const { username } = this.requestData;
if (username) {
return { ...params, username };
}
return params;
},
updateInternalState(parameters) { updateInternalState(parameters) {
// stop polling // stop polling
this.poll.stop(); this.poll.stop();
......
...@@ -24,9 +24,8 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -24,9 +24,8 @@ class Projects::PipelinesController < Projects::ApplicationController
POLLING_INTERVAL = 10_000 POLLING_INTERVAL = 10_000
def index def index
@scope = params[:scope]
@pipelines = Ci::PipelinesFinder @pipelines = Ci::PipelinesFinder
.new(project, current_user, scope: @scope) .new(project, current_user, index_params)
.execute .execute
.page(params[:page]) .page(params[:page])
.per(30) .per(30)
...@@ -256,7 +255,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -256,7 +255,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end end
def limited_pipelines_count(project, scope = nil) def limited_pipelines_count(project, scope = nil)
finder = Ci::PipelinesFinder.new(project, current_user, scope: scope) finder = Ci::PipelinesFinder.new(project, current_user, index_params.merge(scope: scope))
view_context.limited_counter_with_delimiter(finder.execute) view_context.limited_counter_with_delimiter(finder.execute)
end end
...@@ -268,6 +267,10 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -268,6 +267,10 @@ class Projects::PipelinesController < Projects::ApplicationController
end end
end end
end end
def index_params
params.permit(:scope, :username)
end
end end
Projects::PipelinesController.prepend_if_ee('EE::Projects::PipelinesController') Projects::PipelinesController.prepend_if_ee('EE::Projects::PipelinesController')
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
= render_if_exists "shared/shared_runners_minutes_limit_flash_message" = render_if_exists "shared/shared_runners_minutes_limit_flash_message"
#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json), #pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
project_id: @project.id,
"help-page-path" => help_page_path('ci/quick_start/README'), "help-page-path" => help_page_path('ci/quick_start/README'),
"help-auto-devops-path" => help_page_path('topics/autodevops/index.md'), "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
"empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'), "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
......
...@@ -9319,6 +9319,9 @@ msgstr "" ...@@ -9319,6 +9319,9 @@ msgstr ""
msgid "Filter by user" msgid "Filter by user"
msgstr "" msgstr ""
msgid "Filter pipelines"
msgstr ""
msgid "Filter projects" msgid "Filter projects"
msgstr "" msgstr ""
...@@ -15132,6 +15135,9 @@ msgstr "" ...@@ -15132,6 +15135,9 @@ msgstr ""
msgid "Pipeline|Stop pipeline #%{pipelineId}?" msgid "Pipeline|Stop pipeline #%{pipelineId}?"
msgstr "" msgstr ""
msgid "Pipeline|Trigger author"
msgstr ""
msgid "Pipeline|Triggerer" msgid "Pipeline|Triggerer"
msgstr "" msgstr ""
...@@ -21031,6 +21037,9 @@ msgstr "" ...@@ -21031,6 +21037,9 @@ msgstr ""
msgid "There was a problem communicating with your device." msgid "There was a problem communicating with your device."
msgstr "" msgstr ""
msgid "There was a problem fetching project users."
msgstr ""
msgid "There was a problem refreshing the data, please try again" msgid "There was a problem refreshing the data, please try again"
msgstr "" msgstr ""
......
...@@ -145,11 +145,61 @@ describe Projects::PipelinesController do ...@@ -145,11 +145,61 @@ describe Projects::PipelinesController do
end end
end end
def get_pipelines_index_json context 'filter by scope' do
it 'returns matched pipelines' do
get_pipelines_index_json(scope: 'running')
check_pipeline_response(returned: 2, all: 6, running: 2, pending: 1, finished: 3)
end
context 'scope is branches or tags' do
before do
create(:ci_pipeline, :failed, project: project, ref: 'v1.0.0', tag: true)
end
context 'when scope is branches' do
it 'returns matched pipelines' do
get_pipelines_index_json(scope: 'branches')
check_pipeline_response(returned: 1, all: 7, running: 2, pending: 1, finished: 4)
end
end
context 'when scope is tags' do
it 'returns matched pipelines' do
get_pipelines_index_json(scope: 'tags')
check_pipeline_response(returned: 1, all: 7, running: 2, pending: 1, finished: 4)
end
end
end
end
context 'filter by username' do
let!(:pipeline) { create(:ci_pipeline, :running, project: project, user: user) }
context 'when username exists' do
it 'returns matched pipelines' do
get_pipelines_index_json(username: user.username)
check_pipeline_response(returned: 1, all: 1, running: 1, pending: 0, finished: 0)
end
end
context 'when username does not exist' do
it 'returns empty' do
get_pipelines_index_json(username: 'invalid-username')
check_pipeline_response(returned: 0, all: 0, running: 0, pending: 0, finished: 0)
end
end
end
def get_pipelines_index_json(params = {})
get :index, params: { get :index, params: {
namespace_id: project.namespace, namespace_id: project.namespace,
project_id: project project_id: project
}, }.merge(params),
format: :json format: :json
end end
...@@ -199,6 +249,18 @@ describe Projects::PipelinesController do ...@@ -199,6 +249,18 @@ describe Projects::PipelinesController do
user: user user: user
) )
end end
def check_pipeline_response(returned:, all:, running:, pending:, finished:)
aggregate_failures do
expect(response).to match_response_schema('pipeline')
expect(json_response['pipelines'].count).to eq returned
expect(json_response['count']['all'].to_i).to eq all
expect(json_response['count']['running'].to_i).to eq running
expect(json_response['count']['pending'].to_i).to eq pending
expect(json_response['count']['finished'].to_i).to eq finished
end
end
end end
describe 'GET show.json' do describe 'GET show.json' do
......
import Api from '~/api';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import PipelinesFilteredSearch from '~/pipelines/components/pipelines_filtered_search.vue';
import { users, mockSearch, pipelineWithStages } from '../mock_data';
import { GlFilteredSearch } from '@gitlab/ui';
describe('Pipelines filtered search', () => {
let wrapper;
let mock;
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
const getSearchToken = type =>
findFilteredSearch()
.props('availableTokens')
.find(token => token.type === type);
const createComponent = () => {
wrapper = mount(PipelinesFilteredSearch, {
propsData: {
pipelines: [pipelineWithStages],
projectId: '21',
},
attachToDocument: true,
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
createComponent();
});
afterEach(() => {
mock.restore();
wrapper.destroy();
wrapper = null;
});
it('displays UI elements', () => {
expect(wrapper.isVueInstance()).toBe(true);
expect(wrapper.isEmpty()).toBe(false);
expect(findFilteredSearch().exists()).toBe(true);
});
it('displays search tokens', () => {
expect(getSearchToken('username')).toMatchObject({
type: 'username',
icon: 'user',
title: 'Trigger author',
dataType: 'username',
unique: true,
triggerAuthors: users,
operators: [expect.objectContaining({ value: '=' })],
});
});
it('fetches and sets project users', () => {
expect(Api.projectUsers).toHaveBeenCalled();
expect(wrapper.vm.projectUsers).toEqual(users);
});
it('emits filterPipelines on submit with correct filter', () => {
findFilteredSearch().vm.$emit('submit', mockSearch);
expect(wrapper.emitted('filterPipelines')).toBeTruthy();
expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]);
});
});
...@@ -421,3 +421,62 @@ export const stageReply = { ...@@ -421,3 +421,62 @@ export const stageReply = {
path: '/twitter/flight/pipelines/13#deploy', path: '/twitter/flight/pipelines/13#deploy',
dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy', dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy',
}; };
export const users = [
{
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://192.168.1.22:3000/root',
},
{
id: 10,
name: 'Angel Spinka',
username: 'shalonda',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/709df1b65ad06764ee2b0edf1b49fc27?s=80\u0026d=identicon',
web_url: 'http://192.168.1.22:3000/shalonda',
},
{
id: 11,
name: 'Art Davis',
username: 'deja.green',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/bb56834c061522760e7a6dd7d431a306?s=80\u0026d=identicon',
web_url: 'http://192.168.1.22:3000/deja.green',
},
{
id: 32,
name: 'Arnold Mante',
username: 'reported_user_10',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/ab558033a82466d7905179e837d7723a?s=80\u0026d=identicon',
web_url: 'http://192.168.1.22:3000/reported_user_10',
},
{
id: 38,
name: 'Cher Wintheiser',
username: 'reported_user_16',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/2640356e8b5bc4314133090994ed162b?s=80\u0026d=identicon',
web_url: 'http://192.168.1.22:3000/reported_user_16',
},
{
id: 39,
name: 'Bethel Wolf',
username: 'reported_user_17',
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/4b948694fadba4b01e4acfc06b065e8e?s=80\u0026d=identicon',
web_url: 'http://192.168.1.22:3000/reported_user_17',
},
];
export const mockSearch = { type: 'username', value: { data: 'root', operator: '=' } };
import Api from '~/api';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import PipelinesComponent from '~/pipelines/components/pipelines.vue'; import PipelinesComponent from '~/pipelines/components/pipelines.vue';
import Store from '~/pipelines/stores/pipelines_store'; import Store from '~/pipelines/stores/pipelines_store';
import { pipelineWithStages, stageReply } from './mock_data'; import { pipelineWithStages, stageReply, users, mockSearch } from './mock_data';
import { GlFilteredSearch } from '@gitlab/ui';
describe('Pipelines', () => { describe('Pipelines', () => {
const jsonFixtureName = 'pipelines/pipelines.json'; const jsonFixtureName = 'pipelines/pipelines.json';
...@@ -42,10 +44,14 @@ describe('Pipelines', () => { ...@@ -42,10 +44,14 @@ describe('Pipelines', () => {
...paths, ...paths,
}; };
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
const createComponent = (props = defaultProps, methods) => { const createComponent = (props = defaultProps, methods) => {
wrapper = mount(PipelinesComponent, { wrapper = mount(PipelinesComponent, {
provide: { glFeatures: { filterPipelinesSearch: true } },
propsData: { propsData: {
store: new Store(), store: new Store(),
projectId: '21',
...props, ...props,
}, },
methods: { methods: {
...@@ -57,6 +63,7 @@ describe('Pipelines', () => { ...@@ -57,6 +63,7 @@ describe('Pipelines', () => {
beforeEach(() => { beforeEach(() => {
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
pipelines = getJSONFixture(jsonFixtureName); pipelines = getJSONFixture(jsonFixtureName);
jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
}); });
afterEach(() => { afterEach(() => {
...@@ -656,4 +663,23 @@ describe('Pipelines', () => { ...@@ -656,4 +663,23 @@ describe('Pipelines', () => {
}); });
}); });
}); });
describe('Pipeline filters', () => {
beforeEach(() => {
mock.onGet(paths.endpoint).reply(200, pipelines);
createComponent();
return waitForPromises();
});
it('updates request data and query params on filter submit', () => {
const updateContentMock = jest.spyOn(wrapper.vm, 'updateContent');
const expectedQueryParams = { page: '1', scope: 'all', username: 'root' };
findFilteredSearch().vm.$emit('submit', [mockSearch]);
expect(wrapper.vm.requestData).toEqual(expectedQueryParams);
expect(updateContentMock).toHaveBeenCalledWith(expectedQueryParams);
});
});
}); });
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PipelineTriggerAuthorToken from '~/pipelines/components/tokens/pipeline_trigger_author_token.vue';
import { users } from '../mock_data';
describe('Pipeline Trigger Author Token', () => {
let wrapper;
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
const stubs = {
GlFilteredSearchToken: {
template: `<div><slot name="suggestions"></slot></div>`,
},
};
const defaultProps = {
config: {
type: 'username',
icon: 'user',
title: 'Trigger author',
dataType: 'username',
unique: true,
triggerAuthors: users,
},
};
const createComponent = (props = {}, options) => {
wrapper = shallowMount(PipelineTriggerAuthorToken, {
propsData: {
...props,
...defaultProps,
},
...options,
});
};
beforeEach(() => {
createComponent({ value: { data: '' } });
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
describe('shows trigger authors correctly', () => {
it('renders all trigger authors', () => {
createComponent({ value: { data: '' } }, { stubs });
expect(findAllFilteredSearchSuggestions()).toHaveLength(7);
});
it('renders only the trigger author searched for', () => {
createComponent({ value: { data: 'root' } }, { stubs });
expect(findAllFilteredSearchSuggestions()).toHaveLength(2);
});
});
});
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