Commit e7be625f authored by pburdette's avatar pburdette

Filter by branch name

Add pipeline branch name
token. Make necessary code
changes to update query params
with tokens. Search with debounce
after first 20 authors / branches
are rendered.
parent f6c40a9e
......@@ -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
......@@ -15178,6 +15178,9 @@ msgstr ""
msgid "Pipelines|parent"
msgstr ""
msgid "Pipeline|Branch name"
msgstr ""
msgid "Pipeline|Commit"
msgstr ""
......@@ -21191,6 +21194,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