Commit 5b139b6f authored by Mayra Cabrera's avatar Mayra Cabrera

Merge branch 'filter-pipelines-feature' into 'master'

Pipeline filter by trigger author

See merge request gitlab-org/gitlab!29945
parents a780bfe2 c104c906
......@@ -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'),
......
......@@ -9316,6 +9316,9 @@ msgstr ""
msgid "Filter by user"
msgstr ""
msgid "Filter pipelines"
msgstr ""
msgid "Filter projects"
msgstr ""
......@@ -15165,6 +15168,9 @@ msgstr ""
msgid "Pipeline|Stop pipeline #%{pipelineId}?"
msgstr ""
msgid "Pipeline|Trigger author"
msgstr ""
msgid "Pipeline|Triggerer"
msgstr ""
......@@ -21100,6 +21106,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