Commit d9145389 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'filter-pipeline-by-branch' into 'master'

Filter pipelines by branch name

See merge request gitlab-org/gitlab!31386
parents ec533848 e7be625f
......@@ -249,15 +249,22 @@ export default {
createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.'));
});
},
resetRequestData() {
this.requestData = { page: this.page, scope: this.scope };
},
filterPipelines(filters) {
this.resetRequestData();
filters.forEach(filter => {
this.requestData[filter.type] = filter.value.data;
// do not add Any for username query param, so we
// can fetch all trigger authors
if (filter.value.data !== ANY_TRIGGER_AUTHOR) {
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 };
if (filters.length === 0) {
this.resetRequestData();
}
this.updateContent(this.requestData);
......
......@@ -2,8 +2,10 @@
import { GlFilteredSearch } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue';
import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue';
import Api from '~/api';
import createFlash from '~/flash';
import { FETCH_AUTHOR_ERROR_MESSAGE, FETCH_BRANCH_ERROR_MESSAGE } from '../constants';
export default {
components: {
......@@ -22,6 +24,7 @@ export default {
data() {
return {
projectUsers: null,
projectBranches: null,
};
},
computed: {
......@@ -31,11 +34,21 @@ export default {
type: 'username',
icon: 'user',
title: s__('Pipeline|Trigger author'),
dataType: 'username',
unique: true,
token: PipelineTriggerAuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
triggerAuthors: this.projectUsers,
projectId: this.projectId,
},
{
type: 'ref',
icon: 'branch',
title: s__('Pipeline|Branch name'),
unique: true,
token: PipelineBranchNameToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
branches: this.projectBranches,
projectId: this.projectId,
},
];
},
......@@ -46,7 +59,16 @@ export default {
this.projectUsers = users;
})
.catch(err => {
createFlash(__('There was a problem fetching project users.'));
createFlash(FETCH_AUTHOR_ERROR_MESSAGE);
throw err;
});
Api.branches(this.projectId)
.then(({ data }) => {
this.projectBranches = data.map(branch => branch.name);
})
.catch(err => {
createFlash(FETCH_BRANCH_ERROR_MESSAGE);
throw err;
});
},
......
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import Api from '~/api';
import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants';
import createFlash from '~/flash';
import { debounce } from 'lodash';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
branches: this.config.branches,
loading: true,
};
},
methods: {
fetchBranchBySearchTerm(searchTerm) {
Api.branches(this.config.projectId, searchTerm)
.then(res => {
this.branches = res.data.map(branch => branch.name);
this.loading = false;
})
.catch(err => {
createFlash(FETCH_BRANCH_ERROR_MESSAGE);
this.loading = false;
throw err;
});
},
searchBranches: debounce(function debounceSearch({ data }) {
this.fetchBranchBySearchTerm(data);
}, FILTER_PIPELINES_SEARCH_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchBranches"
>
<template #suggestions>
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="(branch, index) in branches"
:key="index"
:value="branch"
>
{{ branch }}
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -4,8 +4,16 @@ import {
GlAvatar,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { ANY_TRIGGER_AUTHOR } from '../../constants';
import Api from '~/api';
import createFlash from '~/flash';
import { debounce } from 'lodash';
import {
ANY_TRIGGER_AUTHOR,
FETCH_AUTHOR_ERROR_MESSAGE,
FILTER_PIPELINES_SEARCH_DELAY,
} from '../../constants';
export default {
anyTriggerAuthor: ANY_TRIGGER_AUTHOR,
......@@ -14,6 +22,7 @@ export default {
GlAvatar,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
config: {
......@@ -25,26 +34,49 @@ export default {
required: true,
},
},
data() {
return {
users: this.config.triggerAuthors,
loading: 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 this.users.find(user => {
return user.username.toLowerCase() === this.currentValue;
});
},
},
methods: {
fetchAuthorBySearchTerm(searchTerm) {
Api.projectUsers(this.config.projectId, searchTerm)
.then(res => {
this.users = res;
this.loading = false;
})
.catch(err => {
createFlash(FETCH_AUTHOR_ERROR_MESSAGE);
this.loading = false;
throw err;
});
},
searchAuthors: debounce(function debounceSearch({ data }) {
this.fetchAuthorBySearchTerm(data);
}, FILTER_PIPELINES_SEARCH_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchAuthors"
>
<template #view="{inputValue}">
<gl-avatar
v-if="activeUser"
......@@ -60,19 +92,23 @@ export default {
$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>
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="user in users"
: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>
</div>
</gl-filtered-search-suggestion>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
import { __ } from '~/locale';
export const CANCEL_REQUEST = 'CANCEL_REQUEST';
export const PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300;
export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any';
export const TestStatus = {
......@@ -8,3 +11,6 @@ export const TestStatus = {
SKIPPED: 'skipped',
SUCCESS: 'success',
};
export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.');
export const FETCH_BRANCH_ERROR_MESSAGE = __('There was a problem fetching project branches.');
......@@ -19,7 +19,7 @@ export default class PipelinesService {
}
getPipelines(data = {}) {
const { scope, page, username } = data;
const { scope, page, username, ref } = data;
const { CancelToken } = axios;
const queryParams = { scope, page };
......@@ -28,6 +28,10 @@ export default class PipelinesService {
queryParams.username = username;
}
if (ref) {
queryParams.ref = ref;
}
this.cancelationSource = CancelToken.source();
return axios.get(this.endpoint, {
......
......@@ -35,13 +35,18 @@ export default {
},
onChangeWithFilter(params) {
const { username } = this.requestData;
const { username, ref } = this.requestData;
const paramsData = params;
if (username) {
return { ...params, username };
paramsData.username = username;
}
return params;
if (ref) {
paramsData.ref = ref;
}
return paramsData;
},
updateInternalState(parameters) {
......
......@@ -12,7 +12,7 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
push_frontend_feature_flag(:junit_pipeline_view)
push_frontend_feature_flag(:filter_pipelines_search)
push_frontend_feature_flag(:filter_pipelines_search, default_enabled: true)
push_frontend_feature_flag(:dag_pipeline_tab)
end
before_action :ensure_pipeline, only: [:show]
......@@ -269,7 +269,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def index_params
params.permit(:scope, :username)
params.permit(:scope, :username, :ref)
end
end
......
---
title: Filter pipelines by trigger author and branch name
merge_request: 31386
author:
type: added
......@@ -15237,6 +15237,9 @@ msgstr ""
msgid "Pipelines|parent"
msgstr ""
msgid "Pipeline|Branch name"
msgstr ""
msgid "Pipeline|Commit"
msgstr ""
......@@ -21292,6 +21295,9 @@ msgstr ""
msgid "There was a problem communicating with your device."
msgstr ""
msgid "There was a problem fetching project branches."
msgstr ""
msgid "There was a problem fetching project users."
msgstr ""
......
......@@ -195,6 +195,26 @@ describe Projects::PipelinesController do
end
end
context 'filter by ref' do
let!(:pipeline) { create(:ci_pipeline, :running, project: project, ref: 'branch-1') }
context 'when pipelines with the ref exists' do
it 'returns matched pipelines' do
get_pipelines_index_json(ref: 'branch-1')
check_pipeline_response(returned: 1, all: 1, running: 1, pending: 0, finished: 0)
end
end
context 'when no pipeline with the ref exists' do
it 'returns empty list' do
get_pipelines_index_json(ref: 'invalid-ref')
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,
......
......@@ -3,7 +3,13 @@ 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 {
users,
mockSearch,
pipelineWithStages,
branches,
mockBranchesAfterMap,
} from '../mock_data';
import { GlFilteredSearch } from '@gitlab/ui';
describe('Pipelines filtered search', () => {
......@@ -30,6 +36,7 @@ describe('Pipelines filtered search', () => {
mock = new MockAdapter(axios);
jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
createComponent();
});
......@@ -52,9 +59,19 @@ describe('Pipelines filtered search', () => {
type: 'username',
icon: 'user',
title: 'Trigger author',
dataType: 'username',
unique: true,
triggerAuthors: users,
projectId: '21',
operators: [expect.objectContaining({ value: '=' })],
});
expect(getSearchToken('ref')).toMatchObject({
type: 'ref',
icon: 'branch',
title: 'Branch name',
unique: true,
branches: mockBranchesAfterMap,
projectId: '21',
operators: [expect.objectContaining({ value: '=' })],
});
});
......@@ -65,6 +82,12 @@ describe('Pipelines filtered search', () => {
expect(wrapper.vm.projectUsers).toEqual(users);
});
it('fetches and sets branches', () => {
expect(Api.branches).toHaveBeenCalled();
expect(wrapper.vm.projectBranches).toEqual(mockBranchesAfterMap);
});
it('emits filterPipelines on submit with correct filter', () => {
findFilteredSearch().vm.$emit('submit', mockSearch);
......
......@@ -479,4 +479,90 @@ export const users = [
},
];
export const mockSearch = { type: 'username', value: { data: 'root', operator: '=' } };
export const branches = [
{
name: 'branch-1',
commit: {
id: '21fb056cc47dcf706670e6de635b1b326490ebdc',
short_id: '21fb056c',
created_at: '2020-05-07T10:58:28.000-04:00',
parent_ids: null,
title: 'Add new file',
message: 'Add new file',
author_name: 'Administrator',
author_email: 'admin@example.com',
authored_date: '2020-05-07T10:58:28.000-04:00',
committer_name: 'Administrator',
committer_email: 'admin@example.com',
committed_date: '2020-05-07T10:58:28.000-04:00',
web_url:
'http://192.168.1.22:3000/root/dag-pipeline/-/commit/21fb056cc47dcf706670e6de635b1b326490ebdc',
},
merged: false,
protected: false,
developers_can_push: false,
developers_can_merge: false,
can_push: true,
default: false,
web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-1',
},
{
name: 'branch-10',
commit: {
id: '66673b07efef254dab7d537f0433a40e61cf84fe',
short_id: '66673b07',
created_at: '2020-03-16T11:04:46.000-04:00',
parent_ids: null,
title: 'Update .gitlab-ci.yml',
message: 'Update .gitlab-ci.yml',
author_name: 'Administrator',
author_email: 'admin@example.com',
authored_date: '2020-03-16T11:04:46.000-04:00',
committer_name: 'Administrator',
committer_email: 'admin@example.com',
committed_date: '2020-03-16T11:04:46.000-04:00',
web_url:
'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe',
},
merged: false,
protected: false,
developers_can_push: false,
developers_can_merge: false,
can_push: true,
default: false,
web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-10',
},
{
name: 'branch-11',
commit: {
id: '66673b07efef254dab7d537f0433a40e61cf84fe',
short_id: '66673b07',
created_at: '2020-03-16T11:04:46.000-04:00',
parent_ids: null,
title: 'Update .gitlab-ci.yml',
message: 'Update .gitlab-ci.yml',
author_name: 'Administrator',
author_email: 'admin@example.com',
authored_date: '2020-03-16T11:04:46.000-04:00',
committer_name: 'Administrator',
committer_email: 'admin@example.com',
committed_date: '2020-03-16T11:04:46.000-04:00',
web_url:
'http://192.168.1.22:3000/root/dag-pipeline/-/commit/66673b07efef254dab7d537f0433a40e61cf84fe',
},
merged: false,
protected: false,
developers_can_push: false,
developers_can_merge: false,
can_push: true,
default: false,
web_url: 'http://192.168.1.22:3000/root/dag-pipeline/-/tree/branch-11',
},
];
export const mockSearch = [
{ type: 'username', value: { data: 'root', operator: '=' } },
{ type: 'ref', value: { data: 'master', operator: '=' } },
];
export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11'];
......@@ -5,7 +5,7 @@ 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, users, mockSearch } from './mock_data';
import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data';
import { GlFilteredSearch } from '@gitlab/ui';
describe('Pipelines', () => {
......@@ -63,7 +63,9 @@ describe('Pipelines', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
pipelines = getJSONFixture(jsonFixtureName);
jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
});
afterEach(() => {
......@@ -674,9 +676,9 @@ describe('Pipelines', () => {
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' };
const expectedQueryParams = { page: '1', scope: 'all', username: 'root', ref: 'master' };
findFilteredSearch().vm.$emit('submit', [mockSearch]);
findFilteredSearch().vm.$emit('submit', mockSearch);
expect(wrapper.vm.requestData).toEqual(expectedQueryParams);
expect(updateContentMock).toHaveBeenCalledWith(expectedQueryParams);
......
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PipelineBranchNameToken from '~/pipelines/components/tokens/pipeline_branch_name_token.vue';
import { branches } from '../mock_data';
describe('Pipeline Branch Name Token', () => {
let wrapper;
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const stubs = {
GlFilteredSearchToken: {
template: `<div><slot name="suggestions"></slot></div>`,
},
};
const defaultProps = {
config: {
type: 'ref',
icon: 'branch',
title: 'Branch name',
dataType: 'ref',
unique: true,
branches,
projectId: '21',
},
value: {
data: '',
},
};
const createComponent = (options, data) => {
wrapper = shallowMount(PipelineBranchNameToken, {
propsData: {
...defaultProps,
},
data() {
return {
...data,
};
},
...options,
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
describe('displays loading icon correctly', () => {
it('shows loading icon', () => {
createComponent({ stubs }, { loading: true });
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not show loading icon', () => {
createComponent({ stubs }, { loading: false });
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('shows branches correctly', () => {
it('renders all trigger authors', () => {
createComponent({ stubs }, { branches, loading: false });
expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length);
});
it('renders only the branch searched for', () => {
const mockBranches = ['master'];
createComponent({ stubs }, { branches: mockBranches, loading: false });
expect(findAllFilteredSearchSuggestions()).toHaveLength(mockBranches.length);
});
});
});
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } 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';
......@@ -8,6 +8,7 @@ describe('Pipeline Trigger Author Token', () => {
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const stubs = {
GlFilteredSearchToken: {
......@@ -24,20 +25,27 @@ describe('Pipeline Trigger Author Token', () => {
unique: true,
triggerAuthors: users,
},
value: {
data: '',
},
};
const createComponent = (props = {}, options) => {
const createComponent = (options, data) => {
wrapper = shallowMount(PipelineTriggerAuthorToken, {
propsData: {
...props,
...defaultProps,
},
data() {
return {
...data,
};
},
...options,
});
};
beforeEach(() => {
createComponent({ value: { data: '' } });
createComponent();
});
afterEach(() => {
......@@ -49,14 +57,41 @@ describe('Pipeline Trigger Author Token', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
describe('displays loading icon correctly', () => {
it('shows loading icon', () => {
createComponent({ stubs }, { loading: true });
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not show loading icon', () => {
createComponent({ stubs }, { loading: false });
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('shows trigger authors correctly', () => {
beforeEach(() => {});
it('renders all trigger authors', () => {
createComponent({ value: { data: '' } }, { stubs });
expect(findAllFilteredSearchSuggestions()).toHaveLength(7);
createComponent({ stubs }, { users, loading: false });
// should have length of all users plus the static 'Any' option
expect(findAllFilteredSearchSuggestions()).toHaveLength(users.length + 1);
});
it('renders only the trigger author searched for', () => {
createComponent({ value: { data: 'root' } }, { stubs });
createComponent(
{ stubs },
{
users: [
{ name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' },
],
loading: false,
},
);
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