Commit 3155685a authored by Simon Knox's avatar Simon Knox

Merge branch '337685-refactor-epic-token-to-use-base-token' into 'master'

Refactor EpicToken to use BaseToken

See merge request gitlab-org/gitlab!70567
parents 11dcec1f fb245442
......@@ -122,7 +122,7 @@ export default {
fullPath: {
default: '',
},
groupEpicsPath: {
groupPath: {
default: '',
},
hasAnyIssues: {
......@@ -371,16 +371,18 @@ export default {
});
}
if (this.groupEpicsPath) {
if (this.groupPath) {
tokens.push({
type: TOKEN_TYPE_EPIC,
title: TOKEN_TITLE_EPIC,
icon: 'epic',
token: EpicToken,
unique: true,
symbol: '&',
idProperty: 'id',
useIdValue: true,
fetchEpics: this.fetchEpics,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-epic_id`,
fullPath: this.groupPath,
});
}
......@@ -450,16 +452,6 @@ export default {
fetchEmojis(search) {
return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search);
},
async fetchEpics({ search }) {
const epics = await this.fetchWithCache(this.groupEpicsPath, 'epics');
if (!search) {
return epics.slice(0, MAX_LIST_SIZE);
}
const number = Number(search);
return Number.isNaN(number)
? fuzzaldrinPlus.filter(epics, search, { key: 'title' })
: epics.filter((epic) => epic.id === number);
},
fetchLabels(search) {
return this.$apollo
.query({
......
......@@ -119,7 +119,7 @@ export function mountIssuesListApp() {
emptyStateSvgPath,
exportCsvPath,
fullPath,
groupEpicsPath,
groupPath,
hasAnyIssues,
hasAnyProjects,
hasBlockedIssuesFeature,
......@@ -152,7 +152,7 @@ export function mountIssuesListApp() {
canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
fullPath,
groupEpicsPath,
groupPath,
hasAnyIssues: parseBoolean(hasAnyIssues),
hasAnyProjects: parseBoolean(hasAnyProjects),
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
......
fragment EpicNode on Epic {
id
iid
group {
fullPath
}
title
state
reference
referencePath: reference(full: true)
webPath
webUrl
createdAt
closedAt
}
#import "./epic.fragment.graphql"
query searchEpics($fullPath: ID!, $search: String, $state: EpicState) {
group(fullPath: $fullPath) {
epics(
search: $search
state: $state
includeAncestorGroups: true
includeDescendantGroups: false
) {
nodes {
...EpicNode
}
}
}
}
......@@ -67,6 +67,11 @@ export default {
required: false,
default: 'id',
},
searchBy: {
type: String,
required: false,
default: undefined,
},
},
data() {
return {
......@@ -112,16 +117,18 @@ export default {
);
},
showDefaultSuggestions() {
return this.availableDefaultSuggestions.length;
return this.availableDefaultSuggestions.length > 0;
},
showRecentSuggestions() {
return this.isRecentSuggestionsEnabled && this.recentSuggestions.length && !this.searchKey;
return (
this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey
);
},
showPreloadedSuggestions() {
return this.preloadedSuggestions.length && !this.searchKey;
return this.preloadedSuggestions.length > 0 && !this.searchKey;
},
showAvailableSuggestions() {
return this.availableSuggestions.length;
return this.availableSuggestions.length > 0;
},
showSuggestions() {
// These conditions must match the template under `#suggestions` slot
......@@ -134,13 +141,19 @@ export default {
this.showAvailableSuggestions
);
},
searchTerm() {
return this.searchBy && this.activeTokenValue
? this.activeTokenValue[this.searchBy]
: undefined;
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.suggestions.length) {
this.$emit('fetch-suggestions', this.value.data);
const search = this.searchTerm ? this.searchTerm : this.value.data;
this.$emit('fetch-suggestions', search);
}
},
},
......@@ -150,7 +163,8 @@ export default {
this.searchKey = data;
if (!this.suggestionsLoading && !this.activeTokenValue) {
this.$emit('fetch-suggestions', data);
const search = this.searchTerm ? this.searchTerm : data;
this.$emit('fetch-suggestions', search);
}
}, DEBOUNCE_DELAY),
handleTokenValueSelected(activeTokenValue) {
......
<script>
import {
GlDropdownDivider,
GlFilteredSearchSuggestion,
GlFilteredSearchToken,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
import searchEpicsQuery from '../queries/search_epics.query.graphql';
import BaseToken from './base_token.vue';
export default {
separator: '::&',
prefix: '&',
separator: '::',
components: {
GlDropdownDivider,
GlFilteredSearchToken,
BaseToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
},
props: {
config: {
......@@ -27,11 +24,15 @@ export default {
type: Object,
required: true,
},
active: {
type: Boolean,
required: true,
},
},
data() {
return {
epics: this.config.initialEpics || [],
loading: true,
loading: false,
};
},
computed: {
......@@ -56,98 +57,73 @@ export default {
}
return this.defaultEpics;
},
activeEpic() {
if (this.currentValue && this.epics.length) {
// Check if current value is an epic ID.
if (typeof this.currentValue === 'number') {
return this.epics.find((epic) => epic[this.idProperty] === this.currentValue);
}
// Current value is a string.
const [groupPath, idProperty] = this.currentValue?.split(this.$options.separator);
return this.epics.find(
(epic) =>
epic.group_full_path === groupPath &&
epic[this.idProperty] === parseInt(idProperty, 10),
);
}
return null;
},
displayText() {
return `${this.activeEpic?.title}${this.$options.separator}${this.activeEpic?.iid}`;
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.epics.length) {
this.searchEpics({ data: this.currentValue });
}
},
},
},
methods: {
fetchEpicsBySearchTerm({ epicPath = '', search = '' }) {
fetchEpics(search = '') {
return this.$apollo
.query({
query: searchEpicsQuery,
variables: { fullPath: this.config.fullPath, search },
})
.then(({ data }) => data.group?.epics.nodes);
},
fetchEpicsBySearchTerm(search) {
this.loading = true;
this.config
.fetchEpics({ epicPath, search })
this.fetchEpics(search)
.then((response) => {
this.epics = Array.isArray(response) ? response : response.data;
this.epics = Array.isArray(response) ? response : response?.data;
})
.catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
.finally(() => {
this.loading = false;
});
},
searchEpics: debounce(function debouncedSearch({ data }) {
let epicPath = this.activeEpic?.web_url;
// When user visits the page with token value already included in filters
// We don't have any information about selected token except for its
// group path and iid joined by separator, so we need to manually
// compose epic path from it.
if (data.includes?.(this.$options.separator)) {
const [groupPath, epicIid] = data.split(this.$options.separator);
epicPath = `/groups/${groupPath}/-/epics/${epicIid}`;
getActiveEpic(epics, data) {
if (data && epics.length) {
return epics.find((epic) => this.getValue(epic) === data);
}
this.fetchEpicsBySearchTerm({ epicPath, search: data });
}, DEBOUNCE_DELAY),
return undefined;
},
getValue(epic) {
return this.config.useIdValue
? String(epic[this.idProperty])
: `${epic.group_full_path}${this.$options.separator}${epic[this.idProperty]}`;
return this.getEpicIdProperty(epic).toString();
},
displayValue(epic) {
return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${
epic?.title
}`;
},
getEpicIdProperty(epic) {
return getIdFromGraphQLId(epic[this.idProperty]);
},
},
};
</script>
<template>
<gl-filtered-search-token
<base-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
:value="value"
:active="active"
:suggestions-loading="loading"
:suggestions="epics"
:get-active-token-value="getActiveEpic"
:default-suggestions="availableDefaultEpics"
:recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
search-by="title"
@fetch-suggestions="fetchEpicsBySearchTerm"
v-on="$listeners"
@input="searchEpics"
>
<template #view="{ inputValue }">
{{ activeEpic ? displayText : inputValue }}
<template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
{{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }}
</template>
<template #suggestions>
<template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
v-for="epic in availableDefaultEpics"
:key="epic.value"
:value="epic.value"
v-for="epic in suggestions"
:key="epic.id"
:value="getValue(epic)"
>
{{ epic.text }}
{{ epic.title }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="availableDefaultEpics.length" />
<gl-loading-icon v-if="loading" size="sm" />
<template v-else>
<gl-filtered-search-suggestion v-for="epic in epics" :key="epic.id" :value="getValue(epic)">
{{ epic.title }}
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</base-token>
</template>
......@@ -2,7 +2,6 @@ import { GlFilteredSearchToken } from '@gitlab/ui';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import {
......@@ -150,29 +149,13 @@ export default {
icon: 'epic',
title: __('Epic'),
unique: true,
idProperty: 'iid',
useIdValue: true,
symbol: '&',
token: EpicToken,
operators: OPERATOR_IS_ONLY,
defaultEpics: [],
fetchEpics: ({ epicPath = '', search = '' }) => {
const epicId = Number(search) || null;
// No search criteria or path has been provided, fetch all epics.
if (!epicPath && !search) {
return axios.get(this.listEpicsPath);
} else if (epicPath) {
// Just epicPath has been provided, fetch a specific epic.
return axios.get(epicPath).then(({ data }) => [data]);
} else if (!epicPath && epicId) {
// Exact epic ID provided, fetch the epic.
return axios
.get(joinPaths(this.listEpicsPath, String(epicId)))
.then(({ data }) => [data]);
}
// Search for an epic.
return axios.get(this.listEpicsPath, { params: { search } });
},
recentSuggestionsStorageKey: `${this.groupFullPath}-epics-recent-tokens-epic_iid`,
fullPath: this.groupFullPath,
});
}
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapActions } from 'vuex';
import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import createDefaultClient from '~/lib/graphql';
import { visitUrl, mergeUrlParams, queryToObject } from '~/lib/utils/url_utility';
import Translate from '~/vue_shared/translate';
......@@ -28,6 +30,12 @@ export default () => {
return false;
}
Vue.use(VueApollo);
const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
const apolloProvider = new VueApollo({
defaultClient,
});
// This event handler is to be removed in 11.1 once
// we allow user to save selected preset in db
if (presetButtonsContainer) {
......@@ -43,7 +51,7 @@ export default () => {
return new Vue({
el,
apolloProvider: {},
apolloProvider,
store: createStore(),
components: {
roadmapApp,
......
......@@ -57,7 +57,7 @@ module EE
def project_issues_list_data(project, current_user, finder)
super.tap do |data|
if project.feature_available?(:epics) && project.group
data[:group_epics_path] = group_epics_path(project.group, format: :json)
data[:group_path] = project.group.full_path
end
end
end
......@@ -68,7 +68,7 @@ module EE
data[:can_bulk_update] = (can?(current_user, :admin_issue, group) && group.feature_available?(:group_bulk_edit)).to_s
if group.feature_available?(:epics)
data[:group_epics_path] = group_epics_path(group, format: :json)
data[:group_path] = group.full_path
end
end
end
......
......@@ -838,8 +838,10 @@ export const mockEpicTokenConfig = {
symbol: '&',
token: EpicToken,
operators: OPERATOR_IS_ONLY,
defaultEpics: [],
fetchEpics: expect.any(Function),
idProperty: 'iid',
useIdValue: true,
recentSuggestionsStorageKey: 'gitlab-org-epics-recent-tokens-epic_iid',
fullPath: 'gitlab-org',
};
export const mockReactionEmojiTokenConfig = {
......
......@@ -147,7 +147,7 @@ RSpec.describe EE::IssuesHelper do
has_issue_weights_feature: 'true',
has_iterations_feature: 'true',
has_multiple_issue_assignees_feature: 'true',
group_epics_path: group_epics_path(project.group, format: :json)
group_path: project.group.full_path
}
expect(helper.project_issues_list_data(project, current_user, finder)).to include(expected)
......@@ -156,8 +156,8 @@ RSpec.describe EE::IssuesHelper do
context 'when project does not have group' do
let(:project_with_no_group) { create :project }
it 'does not return group_epics_path' do
expect(helper.project_issues_list_data(project_with_no_group, current_user, finder)).not_to include(:group_epics_path)
it 'does not return group_path' do
expect(helper.project_issues_list_data(project_with_no_group, current_user, finder)).not_to include(:group_path)
end
end
end
......@@ -179,7 +179,7 @@ RSpec.describe EE::IssuesHelper do
result = helper.project_issues_list_data(project, current_user, finder)
expect(result).to include(expected)
expect(result).not_to include(:group_epics_path)
expect(result).not_to include(:group_path)
end
end
end
......@@ -208,7 +208,7 @@ RSpec.describe EE::IssuesHelper do
has_issue_weights_feature: 'true',
has_iterations_feature: 'true',
has_multiple_issue_assignees_feature: 'true',
group_epics_path: group_epics_path(project.group, format: :json)
group_path: project.group.full_path
}
expect(helper.group_issues_list_data(group, current_user, issues, projects)).to include(expected)
......@@ -233,7 +233,7 @@ RSpec.describe EE::IssuesHelper do
result = helper.group_issues_list_data(group, current_user, issues, projects)
expect(result).to include(expected)
expect(result).not_to include(:group_epics_path)
expect(result).not_to include(:group_path)
end
end
end
......
......@@ -520,7 +520,7 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
groupEpicsPath: '',
groupPath: '',
},
});
});
......@@ -536,7 +536,7 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
groupEpicsPath: '',
groupPath: '',
},
});
});
......@@ -564,7 +564,7 @@ describe('IssuesListApp component', () => {
provide: {
isSignedIn: true,
projectIterationsPath: 'project/iterations/path',
groupEpicsPath: 'group/epics/path',
groupPath: 'group/path',
hasIssueWeightsFeature: true,
},
});
......
......@@ -141,7 +141,62 @@ export const mockEpicToken = {
token: EpicToken,
operators: OPERATOR_IS_ONLY,
idProperty: 'iid',
fetchEpics: () => Promise.resolve({ data: mockEpics }),
fullPath: 'gitlab-org',
};
export const mockEpicNode1 = {
__typename: 'Epic',
parent: null,
id: 'gid://gitlab/Epic/40',
iid: '2',
title: 'Marketing epic',
description: 'Mock epic description',
state: 'opened',
startDate: '2017-12-25',
dueDate: '2018-02-15',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/1',
hasChildren: false,
hasParent: false,
confidential: false,
};
export const mockEpicNode2 = {
__typename: 'Epic',
parent: null,
id: 'gid://gitlab/Epic/41',
iid: '3',
title: 'Another marketing',
startDate: '2017-12-26',
dueDate: '2018-03-10',
state: 'opened',
webUrl: 'http://gdk.test:3000/groups/gitlab-org/marketing/-/epics/2',
};
export const mockGroupEpicsQueryResponse = {
data: {
group: {
id: 'gid://gitlab/Group/1',
name: 'Gitlab Org',
epics: {
edges: [
{
node: {
...mockEpicNode1,
},
__typename: 'EpicEdge',
},
{
node: {
...mockEpicNode2,
},
__typename: 'EpicEdge',
},
],
__typename: 'EpicConnection',
},
__typename: 'Group',
},
},
};
export const mockReactionEmojiToken = {
......
import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
import { GlFilteredSearchTokenSegment } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import searchEpicsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql';
import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { mockEpicToken, mockEpics } from '../mock_data';
import { mockEpicToken, mockEpics, mockGroupEpicsQueryResponse } from '../mock_data';
jest.mock('~/flash');
Vue.use(VueApollo);
const defaultStubs = {
Portal: true,
......@@ -21,31 +27,39 @@ const defaultStubs = {
},
};
function createComponent(options = {}) {
const {
config = mockEpicToken,
value = { data: '' },
active = false,
stubs = defaultStubs,
} = options;
return mount(EpicToken, {
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
},
stubs,
});
}
describe('EpicToken', () => {
let mock;
let wrapper;
let fakeApollo;
const findBaseToken = () => wrapper.findComponent(BaseToken);
function createComponent(
options = {},
epicsQueryHandler = jest.fn().mockResolvedValue(mockGroupEpicsQueryResponse),
) {
fakeApollo = createMockApollo([[searchEpicsQuery, epicsQueryHandler]]);
const {
config = mockEpicToken,
value = { data: '' },
active = false,
stubs = defaultStubs,
} = options;
return mount(EpicToken, {
apolloProvider: fakeApollo,
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: 'custom-class',
},
stubs,
});
}
beforeEach(() => {
mock = new MockAdapter(axios);
......@@ -71,23 +85,20 @@ describe('EpicToken', () => {
describe('methods', () => {
describe('fetchEpicsBySearchTerm', () => {
it('calls `config.fetchEpics` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics');
it('calls fetchEpics with provided searchTerm param', () => {
jest.spyOn(wrapper.vm, 'fetchEpics');
wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' });
findBaseToken().vm.$emit('fetch-suggestions', 'foo');
expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith({
epicPath: '',
search: 'foo',
});
expect(wrapper.vm.fetchEpics).toHaveBeenCalledWith('foo');
});
it('sets response to `epics` when request is successful', async () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics').mockResolvedValue({
jest.spyOn(wrapper.vm, 'fetchEpics').mockResolvedValue({
data: mockEpics,
});
wrapper.vm.fetchEpicsBySearchTerm({});
findBaseToken().vm.$emit('fetch-suggestions');
await waitForPromises();
......@@ -95,9 +106,9 @@ describe('EpicToken', () => {
});
it('calls `createFlash` with flash error message when request fails', async () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({});
jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({});
wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' });
findBaseToken().vm.$emit('fetch-suggestions', 'foo');
await waitForPromises();
......@@ -107,9 +118,9 @@ describe('EpicToken', () => {
});
it('sets `loading` to false when request completes', async () => {
jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({});
jest.spyOn(wrapper.vm, 'fetchEpics').mockRejectedValue({});
wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' });
findBaseToken().vm.$emit('fetch-suggestions', 'foo');
await waitForPromises();
......@@ -123,15 +134,15 @@ describe('EpicToken', () => {
beforeEach(async () => {
wrapper = createComponent({
value: { data: `${mockEpics[0].group_full_path}::&${mockEpics[0].iid}` },
value: { data: `${mockEpics[0].title}::&${mockEpics[0].iid}` },
data: { epics: mockEpics },
});
await wrapper.vm.$nextTick();
});
it('renders gl-filtered-search-token component', () => {
expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
it('renders BaseToken component', () => {
expect(findBaseToken().exists()).toBe(true);
});
it('renders token item when value is selected', () => {
......@@ -142,9 +153,9 @@ describe('EpicToken', () => {
});
it.each`
value | valueType | tokenValueString
${`${mockEpics[0].group_full_path}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`}
${`${mockEpics[1].group_full_path}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`}
value | valueType | tokenValueString
${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`}
${`${mockEpics[1].title}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`}
`('renders token item when selection is a $valueType', async ({ value, tokenValueString }) => {
wrapper.setProps({
value: { data: value },
......
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