Commit b33040ee authored by Payton Burdette's avatar Payton Burdette Committed by Stan Hu

Filter pipelines by status

Create the pipeline status token
for filtering pipelines by status.
Add specs for new token and update
query params.
parent bcd225d7
<script> <script>
import { isEqual, pickBy } from 'lodash'; import { isEqual } from 'lodash';
import { __, sprintf, s__ } from '../../locale'; import { __, sprintf, s__ } from '../../locale';
import createFlash from '../../flash'; import createFlash from '../../flash';
import PipelinesService from '../services/pipelines_service'; import PipelinesService from '../services/pipelines_service';
...@@ -10,7 +10,8 @@ import NavigationControls from './nav_controls.vue'; ...@@ -10,7 +10,8 @@ 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 PipelinesFilteredSearch from './pipelines_filtered_search.vue';
import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, SUPPORTED_FILTER_PARAMETERS } from '../constants'; import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING } from '../constants';
import { validateParams } from '../utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default { export default {
...@@ -225,14 +226,12 @@ export default { ...@@ -225,14 +226,12 @@ export default {
return this.glFeatures.filterPipelinesSearch; return this.glFeatures.filterPipelinesSearch;
}, },
validatedParams() { validatedParams() {
return pickBy(this.params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); return validateParams(this.params);
}, },
}, },
created() { created() {
this.service = new PipelinesService(this.endpoint); this.service = new PipelinesService(this.endpoint);
this.requestData = { page: this.page, scope: this.scope }; this.requestData = { page: this.page, scope: this.scope, ...this.validatedParams };
Object.assign(this.requestData, this.validatedParams);
}, },
methods: { methods: {
successCallback(resp) { successCallback(resp) {
...@@ -313,7 +312,6 @@ export default { ...@@ -313,7 +312,6 @@ export default {
<pipelines-filtered-search <pipelines-filtered-search
v-if="canFilterPipelines" v-if="canFilterPipelines"
:pipelines="state.pipelines"
:project-id="projectId" :project-id="projectId"
:params="validatedParams" :params="validatedParams"
@filterPipelines="filterPipelines" @filterPipelines="filterPipelines"
......
...@@ -3,6 +3,7 @@ import { GlFilteredSearch } from '@gitlab/ui'; ...@@ -3,6 +3,7 @@ import { GlFilteredSearch } from '@gitlab/ui';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue'; import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue';
import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue';
import PipelineStatusToken from './tokens/pipeline_status_token.vue';
import { map } from 'lodash'; import { map } from 'lodash';
export default { export default {
...@@ -10,10 +11,6 @@ export default { ...@@ -10,10 +11,6 @@ export default {
GlFilteredSearch, GlFilteredSearch,
}, },
props: { props: {
pipelines: {
type: Array,
required: true,
},
projectId: { projectId: {
type: String, type: String,
required: true, required: true,
...@@ -44,6 +41,14 @@ export default { ...@@ -44,6 +41,14 @@ export default {
operators: [{ value: '=', description: __('is'), default: 'true' }], operators: [{ value: '=', description: __('is'), default: 'true' }],
projectId: this.projectId, projectId: this.projectId,
}, },
{
type: 'status',
icon: 'status',
title: s__('Pipeline|Status'),
unique: true,
token: PipelineStatusToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
},
]; ];
}, },
paramsValue() { paramsValue() {
......
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
computed: {
statuses() {
return [
{
class: 'ci-status-icon-canceled',
icon: 'status_canceled',
text: s__('Pipeline|Canceled'),
value: 'canceled',
},
{
class: 'ci-status-icon-created',
icon: 'status_created',
text: s__('Pipeline|Created'),
value: 'created',
},
{
class: 'ci-status-icon-failed',
icon: 'status_failed',
text: s__('Pipeline|Failed'),
value: 'failed',
},
{
class: 'ci-status-icon-manual',
icon: 'status_manual',
text: s__('Pipeline|Manual'),
value: 'manual',
},
{
class: 'ci-status-icon-success',
icon: 'status_success',
text: s__('Pipeline|Passed'),
value: 'success',
},
{
class: 'ci-status-icon-pending',
icon: 'status_pending',
text: s__('Pipeline|Pending'),
value: 'pending',
},
{
class: 'ci-status-icon-running',
icon: 'status_running',
text: s__('Pipeline|Running'),
value: 'running',
},
{
class: 'ci-status-icon-skipped',
icon: 'status_skipped',
text: s__('Pipeline|Skipped'),
value: 'skipped',
},
];
},
findActiveStatus() {
return this.statuses.find(status => status.value === this.value.data);
},
},
};
</script>
<template>
<gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<template #view>
<div class="gl-display-flex gl-align-items-center">
<div :class="findActiveStatus.class">
<gl-icon :name="findActiveStatus.icon" class="gl-mr-2 gl-display-block" />
</div>
<span>{{ findActiveStatus.text }}</span>
</div>
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="(status, index) in statuses"
:key="index"
:value="status.value"
>
<div class="gl-display-flex" :class="status.class">
<gl-icon :name="status.icon" class="gl-mr-3" />
<span>{{ status.text }}</span>
</div>
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
</template>
...@@ -5,7 +5,7 @@ export const PIPELINES_TABLE = 'PIPELINES_TABLE'; ...@@ -5,7 +5,7 @@ export const PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300; export const LAYOUT_CHANGE_DELAY = 300;
export const FILTER_PIPELINES_SEARCH_DELAY = 200; export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any'; export const ANY_TRIGGER_AUTHOR = 'Any';
export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref']; export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status'];
export const TestStatus = { export const TestStatus = {
FAILED: 'failed', FAILED: 'failed',
......
import axios from '../../lib/utils/axios_utils'; import axios from '../../lib/utils/axios_utils';
import Api from '~/api'; import Api from '~/api';
import { validateParams } from '../utils';
export default class PipelinesService { export default class PipelinesService {
/** /**
...@@ -19,18 +20,10 @@ export default class PipelinesService { ...@@ -19,18 +20,10 @@ export default class PipelinesService {
} }
getPipelines(data = {}) { getPipelines(data = {}) {
const { scope, page, username, ref } = data; const { scope, page } = data;
const { CancelToken } = axios; const { CancelToken } = axios;
const queryParams = { scope, page }; const queryParams = { scope, page, ...validateParams(data) };
if (username) {
queryParams.username = username;
}
if (ref) {
queryParams.ref = ref;
}
this.cancelationSource = CancelToken.source(); this.cancelationSource = CancelToken.source();
......
import { pickBy } from 'lodash';
import { SUPPORTED_FILTER_PARAMETERS } from './constants';
export const validateParams = params => {
return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
};
export default () => {};
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
* Components need to have `scope`, `page` and `requestData` * Components need to have `scope`, `page` and `requestData`
*/ */
import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/common_utils'; import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/common_utils';
import { validateParams } from '~/pipelines/utils';
export default { export default {
methods: { methods: {
...@@ -35,18 +36,7 @@ export default { ...@@ -35,18 +36,7 @@ export default {
}, },
onChangeWithFilter(params) { onChangeWithFilter(params) {
const { username, ref } = this.requestData; return { ...params, ...validateParams(this.requestData) };
const paramsData = params;
if (username) {
paramsData.username = username;
}
if (ref) {
paramsData.ref = ref;
}
return paramsData;
}, },
updateInternalState(parameters) { updateInternalState(parameters) {
......
...@@ -277,7 +277,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -277,7 +277,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end end
def index_params def index_params
params.permit(:scope, :username, :ref) params.permit(:scope, :username, :ref, :status)
end end
end end
......
---
title: Filter pipelines by status
merge_request: 32151
author:
type: added
...@@ -101,6 +101,7 @@ you can filter the pipeline list by: ...@@ -101,6 +101,7 @@ you can filter the pipeline list by:
- Trigger author - Trigger author
- Branch name - Branch name
- Status ([since GitLab 13.1](https://gitlab.com/gitlab-org/gitlab/-/issues/217617))
### Run a pipeline manually ### Run a pipeline manually
......
...@@ -15966,6 +15966,9 @@ msgstr "" ...@@ -15966,6 +15966,9 @@ msgstr ""
msgid "Pipeline|Branch name" msgid "Pipeline|Branch name"
msgstr "" msgstr ""
msgid "Pipeline|Canceled"
msgstr ""
msgid "Pipeline|Commit" msgid "Pipeline|Commit"
msgstr "" msgstr ""
...@@ -15975,6 +15978,9 @@ msgstr "" ...@@ -15975,6 +15978,9 @@ msgstr ""
msgid "Pipeline|Coverage" msgid "Pipeline|Coverage"
msgstr "" msgstr ""
msgid "Pipeline|Created"
msgstr ""
msgid "Pipeline|Date" msgid "Pipeline|Date"
msgstr "" msgstr ""
...@@ -15987,9 +15993,15 @@ msgstr "" ...@@ -15987,9 +15993,15 @@ msgstr ""
msgid "Pipeline|Existing branch name or tag" msgid "Pipeline|Existing branch name or tag"
msgstr "" msgstr ""
msgid "Pipeline|Failed"
msgstr ""
msgid "Pipeline|Key" msgid "Pipeline|Key"
msgstr "" msgstr ""
msgid "Pipeline|Manual"
msgstr ""
msgid "Pipeline|Merge train pipeline" msgid "Pipeline|Merge train pipeline"
msgstr "" msgstr ""
...@@ -15999,6 +16011,12 @@ msgstr "" ...@@ -15999,6 +16011,12 @@ msgstr ""
msgid "Pipeline|No pipeline has been run for this commit." msgid "Pipeline|No pipeline has been run for this commit."
msgstr "" msgstr ""
msgid "Pipeline|Passed"
msgstr ""
msgid "Pipeline|Pending"
msgstr ""
msgid "Pipeline|Pipeline" msgid "Pipeline|Pipeline"
msgstr "" msgstr ""
...@@ -16014,9 +16032,15 @@ msgstr "" ...@@ -16014,9 +16032,15 @@ msgstr ""
msgid "Pipeline|Run for" msgid "Pipeline|Run for"
msgstr "" msgstr ""
msgid "Pipeline|Running"
msgstr ""
msgid "Pipeline|Search branches" msgid "Pipeline|Search branches"
msgstr "" msgstr ""
msgid "Pipeline|Skipped"
msgstr ""
msgid "Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default." msgid "Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default."
msgstr "" msgstr ""
......
...@@ -171,6 +171,40 @@ RSpec.describe Projects::PipelinesController do ...@@ -171,6 +171,40 @@ RSpec.describe Projects::PipelinesController do
end end
end end
context 'filter by status' do
context 'when pipelines with the status exists' do
it 'returns matched pipelines' do
get_pipelines_index_json(status: 'success')
check_pipeline_response(returned: 1, all: 1, running: 0, pending: 0, finished: 1)
end
context 'when filter by unrelated scope' do
it 'returns empty list' do
get_pipelines_index_json(status: 'success', scope: 'running')
check_pipeline_response(returned: 0, all: 1, running: 0, pending: 0, finished: 1)
end
end
end
context 'when no pipeline with the status exists' do
it 'returns empty list' do
get_pipelines_index_json(status: 'manual')
check_pipeline_response(returned: 0, all: 0, running: 0, pending: 0, finished: 0)
end
end
context 'when invalid status' do
it 'returns all list' do
get_pipelines_index_json(status: 'invalid-status')
check_pipeline_response(returned: 6, all: 6, running: 2, pending: 1, finished: 3)
end
end
end
def get_pipelines_index_json(params = {}) def get_pipelines_index_json(params = {})
get :index, params: { get :index, params: {
namespace_id: project.namespace, namespace_id: project.namespace,
......
...@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; ...@@ -3,7 +3,7 @@ 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 PipelinesFilteredSearch from '~/pipelines/components/pipelines_filtered_search.vue'; import PipelinesFilteredSearch from '~/pipelines/components/pipelines_filtered_search.vue';
import { users, mockSearch, pipelineWithStages, branches } from '../mock_data'; import { users, mockSearch, branches } from '../mock_data';
import { GlFilteredSearch } from '@gitlab/ui'; import { GlFilteredSearch } from '@gitlab/ui';
describe('Pipelines filtered search', () => { describe('Pipelines filtered search', () => {
...@@ -19,7 +19,6 @@ describe('Pipelines filtered search', () => { ...@@ -19,7 +19,6 @@ describe('Pipelines filtered search', () => {
const createComponent = (params = {}) => { const createComponent = (params = {}) => {
wrapper = mount(PipelinesFilteredSearch, { wrapper = mount(PipelinesFilteredSearch, {
propsData: { propsData: {
pipelines: [pipelineWithStages],
projectId: '21', projectId: '21',
params, params,
}, },
...@@ -67,6 +66,14 @@ describe('Pipelines filtered search', () => { ...@@ -67,6 +66,14 @@ describe('Pipelines filtered search', () => {
projectId: '21', projectId: '21',
operators: [expect.objectContaining({ value: '=' })], operators: [expect.objectContaining({ value: '=' })],
}); });
expect(getSearchToken('status')).toMatchObject({
type: 'status',
icon: 'status',
title: 'Status',
unique: true,
operators: [expect.objectContaining({ value: '=' })],
});
}); });
it('emits filterPipelines on submit with correct filter', () => { it('emits filterPipelines on submit with correct filter', () => {
......
...@@ -563,6 +563,7 @@ export const branches = [ ...@@ -563,6 +563,7 @@ export const branches = [
export const mockSearch = [ export const mockSearch = [
{ type: 'username', value: { data: 'root', operator: '=' } }, { type: 'username', value: { data: 'root', operator: '=' } },
{ type: 'ref', value: { data: 'master', operator: '=' } }, { type: 'ref', value: { data: 'master', operator: '=' } },
{ type: 'status', value: { data: 'pending', operator: '=' } },
]; ];
export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11']; export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11'];
...@@ -684,7 +684,13 @@ describe('Pipelines', () => { ...@@ -684,7 +684,13 @@ describe('Pipelines', () => {
}); });
it('updates request data and query params on filter submit', () => { it('updates request data and query params on filter submit', () => {
const expectedQueryParams = { page: '1', scope: 'all', username: 'root', ref: 'master' }; const expectedQueryParams = {
page: '1',
scope: 'all',
username: 'root',
ref: 'master',
status: 'pending',
};
findFilteredSearch().vm.$emit('submit', mockSearch); findFilteredSearch().vm.$emit('submit', mockSearch);
......
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PipelineStatusToken from '~/pipelines/components/tokens/pipeline_status_token.vue';
describe('Pipeline Status Token', () => {
let wrapper;
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion);
const findAllGlIcons = () => wrapper.findAll(GlIcon);
const stubs = {
GlFilteredSearchToken: {
template: `<div><slot name="suggestions"></slot></div>`,
},
};
const defaultProps = {
config: {
type: 'status',
icon: 'status',
title: 'Status',
unique: true,
},
value: {
data: '',
},
};
const createComponent = options => {
wrapper = shallowMount(PipelineStatusToken, {
propsData: {
...defaultProps,
},
...options,
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
describe('shows statuses correctly', () => {
beforeEach(() => {
createComponent({ stubs });
});
it('renders all pipeline statuses available', () => {
expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.statuses.length);
expect(findAllGlIcons()).toHaveLength(wrapper.vm.statuses.length);
});
});
});
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