Commit e1e51f66 authored by Payton Burdette's avatar Payton Burdette Committed by Nicolò Maria Mezzopera

Add filter by tag name

Add filter by tag name,
also bring in public api
for fetching project tags.
parent 0dcc35a9
......@@ -53,6 +53,7 @@ const Api = {
environmentsPath: '/api/:version/projects/:id/environments',
rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw',
issuePath: '/api/:version/projects/:id/issues/:issue_iid',
tagsPath: '/api/:version/projects/:id/repository/tags',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
......@@ -564,6 +565,18 @@ const Api = {
return axios.put(url, data);
},
tags(id, query = '', options = {}) {
const url = Api.buildUrl(this.tagsPath).replace(':id', encodeURIComponent(id));
return axios.get(url, {
params: {
search: query,
per_page: DEFAULT_PER_PAGE,
...options,
},
});
},
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
......
......@@ -10,8 +10,8 @@ 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, RAW_TEXT_WARNING } from '../constants';
import { validateParams } from '../utils';
import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
......@@ -266,10 +266,18 @@ export default {
filters.forEach(filter => {
// do not add Any for username query param, so we
// can fetch all trigger authors
if (filter.type && filter.value.data !== ANY_TRIGGER_AUTHOR) {
if (
filter.type &&
filter.value.data !== ANY_TRIGGER_AUTHOR &&
filter.type !== FILTER_TAG_IDENTIFIER
) {
this.requestData[filter.type] = filter.value.data;
}
if (filter.type === FILTER_TAG_IDENTIFIER) {
this.requestData.ref = filter.value.data;
}
if (!filter.type) {
createFlash(RAW_TEXT_WARNING, 'warning');
}
......
......@@ -4,9 +4,15 @@ import { __, s__ } from '~/locale';
import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue';
import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue';
import PipelineStatusToken from './tokens/pipeline_status_token.vue';
import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue';
import { map } from 'lodash';
export default {
userType: 'username',
branchType: 'ref',
tagType: 'tag',
statusType: 'status',
defaultTokensLength: 1,
components: {
GlFilteredSearch,
},
......@@ -20,11 +26,19 @@ export default {
required: true,
},
},
data() {
return {
internalValue: [],
};
},
computed: {
selectedTypes() {
return this.value.map(i => i.type);
},
tokens() {
return [
{
type: 'username',
type: this.$options.userType,
icon: 'user',
title: s__('Pipeline|Trigger author'),
unique: true,
......@@ -33,16 +47,27 @@ export default {
projectId: this.projectId,
},
{
type: 'ref',
type: this.$options.branchType,
icon: 'branch',
title: s__('Pipeline|Branch name'),
unique: true,
token: PipelineBranchNameToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
projectId: this.projectId,
disabled: this.selectedTypes.includes(this.$options.tagType),
},
{
type: this.$options.tagType,
icon: 'tag',
title: s__('Pipeline|Tag name'),
unique: true,
token: PipelineTagNameToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
projectId: this.projectId,
disabled: this.selectedTypes.includes(this.$options.branchType),
},
{
type: 'status',
type: this.$options.statusType,
icon: 'status',
title: s__('Pipeline|Status'),
unique: true,
......@@ -51,12 +76,20 @@ export default {
},
];
},
paramsValue() {
parsedParams() {
return map(this.params, (val, key) => ({
type: key,
value: { data: val, operator: '=' },
}));
},
value: {
get() {
return this.internalValue.length > 0 ? this.internalValue : this.parsedParams;
},
set(value) {
this.internalValue = value;
},
},
},
methods: {
onSubmit(filters) {
......@@ -69,9 +102,9 @@ export default {
<template>
<div class="row-content-block">
<gl-filtered-search
v-model="value"
:placeholder="__('Filter pipelines')"
:available-tokens="tokens"
:value="paramsValue"
@submit="onSubmit"
/>
</div>
......
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import Api from '~/api';
import { FETCH_TAG_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 {
tags: null,
loading: true,
};
},
created() {
this.fetchTags();
},
methods: {
fetchTags(searchTerm) {
Api.tags(this.config.projectId, searchTerm)
.then(({ data }) => {
this.tags = data.map(tag => tag.name);
this.loading = false;
})
.catch(err => {
createFlash(FETCH_TAG_ERROR_MESSAGE);
this.loading = false;
throw err;
});
},
searchTags: debounce(function debounceSearch({ data }) {
this.fetchTags(data);
}, FILTER_PIPELINES_SEARCH_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" @input="searchTags">
<template #suggestions>
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion v-for="(tag, index) in tags" :key="index" :value="tag">
{{ tag }}
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -6,6 +6,7 @@ export const LAYOUT_CHANGE_DELAY = 300;
export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any';
export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status'];
export const FILTER_TAG_IDENTIFIER = 'tag';
export const TestStatus = {
FAILED: 'failed',
......@@ -15,6 +16,7 @@ export const TestStatus = {
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.');
export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project tags.');
export const RAW_TEXT_WARNING = s__(
'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
);
---
title: Filter Pipelines by Tag Name
merge_request: 32470
author:
type: added
......@@ -102,6 +102,7 @@ you can filter the pipeline list by:
- Trigger author
- Branch name
- Status ([since GitLab 13.1](https://gitlab.com/gitlab-org/gitlab/-/issues/217617))
- Tag ([since GitLab 13.1](https://gitlab.com/gitlab-org/gitlab/-/issues/217617))
### Run a pipeline manually
......
......@@ -16171,6 +16171,9 @@ msgstr ""
msgid "Pipeline|Stop pipeline #%{pipelineId}?"
msgstr ""
msgid "Pipeline|Tag name"
msgstr ""
msgid "Pipeline|Trigger author"
msgstr ""
......@@ -22378,6 +22381,9 @@ msgstr ""
msgid "There was a problem fetching project branches."
msgstr ""
msgid "There was a problem fetching project tags."
msgstr ""
msgid "There was a problem fetching project users."
msgstr ""
......
......@@ -725,4 +725,26 @@ describe('Api', () => {
.catch(done.fail);
});
});
describe('tags', () => {
it('fetches all tags of a particular project', done => {
const query = 'dummy query';
const options = { unused: 'option' };
const projectId = 8;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/repository/tags`;
mock.onGet(expectedUrl).reply(200, [
{
name: 'test',
},
]);
Api.tags(projectId, query, options)
.then(({ data }) => {
expect(data.length).toBe(1);
expect(data[0].name).toBe('test');
})
.then(done)
.catch(done.fail);
});
});
});
......@@ -3,7 +3,7 @@ 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, branches } from '../mock_data';
import { users, mockSearch, branches, tags } from '../mock_data';
import { GlFilteredSearch } from '@gitlab/ui';
describe('Pipelines filtered search', () => {
......@@ -15,6 +15,10 @@ describe('Pipelines filtered search', () => {
findFilteredSearch()
.props('availableTokens')
.find(token => token.type === type);
const findBranchToken = () => getSearchToken('ref');
const findTagToken = () => getSearchToken('tag');
const findUserToken = () => getSearchToken('username');
const findStatusToken = () => getSearchToken('status');
const createComponent = (params = {}) => {
wrapper = mount(PipelinesFilteredSearch, {
......@@ -31,6 +35,7 @@ describe('Pipelines filtered search', () => {
jest.spyOn(Api, 'projectUsers').mockResolvedValue(users);
jest.spyOn(Api, 'branches').mockResolvedValue({ data: branches });
jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags });
createComponent();
});
......@@ -49,7 +54,7 @@ describe('Pipelines filtered search', () => {
});
it('displays search tokens', () => {
expect(getSearchToken('username')).toMatchObject({
expect(findUserToken()).toMatchObject({
type: 'username',
icon: 'user',
title: 'Trigger author',
......@@ -58,7 +63,7 @@ describe('Pipelines filtered search', () => {
operators: [expect.objectContaining({ value: '=' })],
});
expect(getSearchToken('ref')).toMatchObject({
expect(findBranchToken()).toMatchObject({
type: 'ref',
icon: 'branch',
title: 'Branch name',
......@@ -67,13 +72,21 @@ describe('Pipelines filtered search', () => {
operators: [expect.objectContaining({ value: '=' })],
});
expect(getSearchToken('status')).toMatchObject({
expect(findStatusToken()).toMatchObject({
type: 'status',
icon: 'status',
title: 'Status',
unique: true,
operators: [expect.objectContaining({ value: '=' })],
});
expect(findTagToken()).toMatchObject({
type: 'tag',
icon: 'tag',
title: 'Tag name',
unique: true,
operators: [expect.objectContaining({ value: '=' })],
});
});
it('emits filterPipelines on submit with correct filter', () => {
......@@ -83,6 +96,48 @@ describe('Pipelines filtered search', () => {
expect(wrapper.emitted('filterPipelines')[0]).toEqual([mockSearch]);
});
it('disables tag name token when branch name token is active', () => {
findFilteredSearch().vm.$emit('input', [
{ type: 'ref', value: { data: 'branch-1', operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
]);
return wrapper.vm.$nextTick().then(() => {
expect(findBranchToken().disabled).toBe(false);
expect(findTagToken().disabled).toBe(true);
});
});
it('disables branch name token when tag name token is active', () => {
findFilteredSearch().vm.$emit('input', [
{ type: 'tag', value: { data: 'tag-1', operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
]);
return wrapper.vm.$nextTick().then(() => {
expect(findBranchToken().disabled).toBe(true);
expect(findTagToken().disabled).toBe(false);
});
});
it('resets tokens disabled state on clear', () => {
findFilteredSearch().vm.$emit('clearInput');
return wrapper.vm.$nextTick().then(() => {
expect(findBranchToken().disabled).toBe(false);
expect(findTagToken().disabled).toBe(false);
});
});
it('resets tokens disabled state when clearing tokens by backspace', () => {
findFilteredSearch().vm.$emit('input', [{ type: 'filtered-search-term', value: { data: '' } }]);
return wrapper.vm.$nextTick().then(() => {
expect(findBranchToken().disabled).toBe(false);
expect(findTagToken().disabled).toBe(false);
});
});
describe('Url query params', () => {
const params = {
username: 'deja.green',
......
......@@ -560,6 +560,101 @@ export const branches = [
},
];
export const tags = [
{
name: 'tag-3',
message: '',
target: '66673b07efef254dab7d537f0433a40e61cf84fe',
commit: {
id: '66673b07efef254dab7d537f0433a40e61cf84fe',
short_id: '66673b07',
created_at: '2020-03-16T11:04:46.000-04:00',
parent_ids: ['def28bf679235071140180495f25b657e2203587'],
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',
},
release: null,
protected: false,
},
{
name: 'tag-2',
message: '',
target: '66673b07efef254dab7d537f0433a40e61cf84fe',
commit: {
id: '66673b07efef254dab7d537f0433a40e61cf84fe',
short_id: '66673b07',
created_at: '2020-03-16T11:04:46.000-04:00',
parent_ids: ['def28bf679235071140180495f25b657e2203587'],
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',
},
release: null,
protected: false,
},
{
name: 'tag-1',
message: '',
target: '66673b07efef254dab7d537f0433a40e61cf84fe',
commit: {
id: '66673b07efef254dab7d537f0433a40e61cf84fe',
short_id: '66673b07',
created_at: '2020-03-16T11:04:46.000-04:00',
parent_ids: ['def28bf679235071140180495f25b657e2203587'],
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',
},
release: null,
protected: false,
},
{
name: 'master-tag',
message: '',
target: '66673b07efef254dab7d537f0433a40e61cf84fe',
commit: {
id: '66673b07efef254dab7d537f0433a40e61cf84fe',
short_id: '66673b07',
created_at: '2020-03-16T11:04:46.000-04:00',
parent_ids: ['def28bf679235071140180495f25b657e2203587'],
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',
},
release: null,
protected: false,
},
];
export const mockSearch = [
{ type: 'username', value: { data: 'root', operator: '=' } },
{ type: 'ref', value: { data: 'master', operator: '=' } },
......@@ -567,3 +662,5 @@ export const mockSearch = [
];
export const mockBranchesAfterMap = ['branch-1', 'branch-10', 'branch-11'];
export const mockTagsAfterMap = ['tag-3', 'tag-2', 'tag-1', 'master-tag'];
......@@ -22,10 +22,9 @@ describe('Pipeline Branch Name Token', () => {
type: 'ref',
icon: 'branch',
title: 'Branch name',
dataType: 'ref',
unique: true,
branches,
projectId: '21',
disabled: false,
},
value: {
data: '',
......@@ -83,7 +82,7 @@ describe('Pipeline Branch Name Token', () => {
});
describe('shows branches correctly', () => {
it('renders all trigger authors', () => {
it('renders all branches', () => {
createComponent({ stubs }, { branches, loading: false });
expect(findAllFilteredSearchSuggestions()).toHaveLength(branches.length);
......
import Api from '~/api';
import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PipelineTagNameToken from '~/pipelines/components/tokens/pipeline_tag_name_token.vue';
import { tags, mockTagsAfterMap } 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: 'tag',
icon: 'tag',
title: 'Tag name',
unique: true,
projectId: '21',
disabled: false,
},
value: {
data: '',
},
};
const createComponent = (options, data) => {
wrapper = shallowMount(PipelineTagNameToken, {
propsData: {
...defaultProps,
},
data() {
return {
...data,
};
},
...options,
});
};
beforeEach(() => {
jest.spyOn(Api, 'tags').mockResolvedValue({ data: tags });
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('passes config correctly', () => {
expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
});
it('fetches and sets project tags', () => {
expect(Api.tags).toHaveBeenCalled();
expect(wrapper.vm.tags).toEqual(mockTagsAfterMap);
expect(findLoadingIcon().exists()).toBe(false);
});
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 tags correctly', () => {
it('renders all tags', () => {
createComponent({ stubs }, { tags, loading: false });
expect(findAllFilteredSearchSuggestions()).toHaveLength(tags.length);
});
it('renders only the tag searched for', () => {
const mockTags = ['master-tag'];
createComponent({ stubs }, { tags: mockTags, loading: false });
expect(findAllFilteredSearchSuggestions()).toHaveLength(mockTags.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