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(
hasGitlabCi: parseBoolean(this.dataset.hasGitlabCi),
ciLintPath: this.dataset.ciLintPath,
resetCachePath: this.dataset.resetCachePath,
projectId: this.dataset.projectId,
},
});
},
......
......@@ -9,14 +9,18 @@ import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue';
import NavigationControls from './nav_controls.vue';
import { getParameterByName } from '../../lib/utils/common_utils';
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 {
components: {
TablePagination,
NavigationTabs,
NavigationControls,
PipelinesFilteredSearch,
},
mixins: [pipelinesMixin, CIPaginationMixin],
mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()],
props: {
store: {
type: Object,
......@@ -78,6 +82,10 @@ export default {
required: false,
default: null,
},
projectId: {
type: String,
required: true,
},
},
data() {
return {
......@@ -209,6 +217,9 @@ export default {
},
];
},
canFilterPipelines() {
return this.glFeatures.filterPipelinesSearch;
},
},
created() {
this.service = new PipelinesService(this.endpoint);
......@@ -238,6 +249,19 @@ export default {
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>
......@@ -267,6 +291,13 @@ export default {
/>
</div>
<pipelines-filtered-search
v-if="canFilterPipelines"
:pipelines="state.pipelines"
:project-id="projectId"
@filterPipelines="filterPipelines"
/>
<div class="content-list pipelines">
<gl-loading-icon
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 PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300;
export const ANY_TRIGGER_AUTHOR = 'Any';
export const TestStatus = {
FAILED: 'failed',
......
......@@ -19,13 +19,19 @@ export default class PipelinesService {
}
getPipelines(data = {}) {
const { scope, page } = data;
const { scope, page, username } = data;
const { CancelToken } = axios;
const queryParams = { scope, page };
if (username) {
queryParams.username = username;
}
this.cancelationSource = CancelToken.source();
return axios.get(this.endpoint, {
params: { scope, page },
params: queryParams,
cancelToken: this.cancelationSource.token,
});
}
......
......@@ -9,21 +9,41 @@ import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/c
export default {
methods: {
onChangeTab(scope) {
this.updateContent({ scope, page: '1' });
let params = {
scope,
page: '1',
};
params = this.onChangeWithFilter(params);
this.updateContent(params);
},
onChangePage(page) {
/* URLS parameters are strings, we need to parse to match types */
const params = {
let params = {
page: Number(page).toString(),
};
if (this.scope) {
params.scope = this.scope;
}
params = this.onChangeWithFilter(params);
this.updateContent(params);
},
onChangeWithFilter(params) {
const { username } = this.requestData;
if (username) {
return { ...params, username };
}
return params;
},
updateInternalState(parameters) {
// stop polling
this.poll.stop();
......
......@@ -24,9 +24,8 @@ class Projects::PipelinesController < Projects::ApplicationController
POLLING_INTERVAL = 10_000
def index
@scope = params[:scope]
@pipelines = Ci::PipelinesFinder
.new(project, current_user, scope: @scope)
.new(project, current_user, index_params)
.execute
.page(params[:page])
.per(30)
......@@ -256,7 +255,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
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)
end
......@@ -268,6 +267,10 @@ class Projects::PipelinesController < Projects::ApplicationController
end
end
end
def index_params
params.permit(:scope, :username)
end
end
Projects::PipelinesController.prepend_if_ee('EE::Projects::PipelinesController')
......@@ -3,6 +3,7 @@
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
#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-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
"empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
......
......@@ -9319,6 +9319,9 @@ msgstr ""
msgid "Filter by user"
msgstr ""
msgid "Filter pipelines"
msgstr ""
msgid "Filter projects"
msgstr ""
......@@ -15132,6 +15135,9 @@ msgstr ""
msgid "Pipeline|Stop pipeline #%{pipelineId}?"
msgstr ""
msgid "Pipeline|Trigger author"
msgstr ""
msgid "Pipeline|Triggerer"
msgstr ""
......@@ -21031,6 +21037,9 @@ msgstr ""
msgid "There was a problem communicating with your device."
msgstr ""
msgid "There was a problem fetching project users."
msgstr ""
msgid "There was a problem refreshing the data, please try again"
msgstr ""
......
......@@ -145,11 +145,61 @@ describe Projects::PipelinesController do
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: {
namespace_id: project.namespace,
project_id: project
},
}.merge(params),
format: :json
end
......@@ -199,6 +249,18 @@ describe Projects::PipelinesController do
user: user
)
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
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 = {
path: '/twitter/flight/pipelines/13#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 MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises';
import PipelinesComponent from '~/pipelines/components/pipelines.vue';
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', () => {
const jsonFixtureName = 'pipelines/pipelines.json';
......@@ -42,10 +44,14 @@ describe('Pipelines', () => {
...paths,
};
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
const createComponent = (props = defaultProps, methods) => {
wrapper = mount(PipelinesComponent, {
provide: { glFeatures: { filterPipelinesSearch: true } },
propsData: {
store: new Store(),
projectId: '21',
...props,
},
methods: {
......@@ -57,6 +63,7 @@ describe('Pipelines', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
pipelines = getJSONFixture(jsonFixtureName);
jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
});
afterEach(() => {
......@@ -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