Commit a7055bef authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '34067-add-recent-searches-to-sentry-error-list-in-gitlab' into 'master'

Resolve "Add recent searches to Sentry error list in GitLab"

See merge request gitlab-org/gitlab!19301
parents 7156c762 d9a9dced
......@@ -3,11 +3,17 @@ import { mapActions, mapState } from 'vuex';
import {
GlEmptyState,
GlButton,
GlIcon,
GlLink,
GlLoadingIcon,
GlTable,
GlSearchBoxByClick,
GlFormInput,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlTooltipDirective,
} from '@gitlab/ui';
import AccessorUtils from '~/lib/utils/accessor';
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale';
......@@ -24,14 +30,19 @@ export default {
components: {
GlEmptyState,
GlButton,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlIcon,
GlLink,
GlLoadingIcon,
GlTable,
GlSearchBoxByClick,
GlFormInput,
Icon,
TimeAgo,
},
directives: {
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
},
props: {
......@@ -56,13 +67,14 @@ export default {
required: true,
},
},
hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(),
data() {
return {
errorSearchQuery: '',
};
},
computed: {
...mapState('list', ['errors', 'externalUrl', 'loading']),
...mapState('list', ['errors', 'externalUrl', 'loading', 'recentSearches']),
},
created() {
if (this.errorTrackingEnabled) {
......@@ -70,9 +82,23 @@ export default {
}
},
methods: {
...mapActions('list', ['startPolling', 'restartPolling']),
...mapActions('list', [
'startPolling',
'restartPolling',
'addRecentSearch',
'clearRecentSearches',
'loadRecentSearches',
'setIndexPath',
]),
filterErrors() {
this.startPolling(`${this.indexPath}?search_term=${this.errorSearchQuery}`);
const searchTerm = this.errorSearchQuery.trim();
this.addRecentSearch(searchTerm);
this.startPolling(`${this.indexPath}?search_term=${searchTerm}`);
},
setSearchText(text) {
this.errorSearchQuery = text;
this.filterErrors();
},
trackViewInSentryOptions,
getDetailsLink(errorId) {
......@@ -85,81 +111,119 @@ export default {
<template>
<div>
<div v-if="errorTrackingEnabled">
<div>
<div class="d-flex flex-row justify-content-around bg-secondary border">
<gl-search-box-by-click
v-model="errorSearchQuery"
class="col-lg-10 m-3 p-0"
:placeholder="__('Search or filter results...')"
type="search"
autofocus
@submit="filterErrors"
/>
<gl-button
v-track-event="trackViewInSentryOptions(externalUrl)"
class="m-3"
variant="primary"
:href="externalUrl"
target="_blank"
<div class="d-flex flex-row justify-content-around bg-secondary border p-3">
<div class="filtered-search-box">
<gl-dropdown
:text="__('Recent searches')"
class="filtered-search-history-dropdown-wrapper d-none d-md-block"
toggle-class="filtered-search-history-dropdown-toggle-button"
:disabled="loading"
>
{{ __('View in Sentry') }}
<icon name="external-link" class="flex-shrink-0" />
</gl-button>
</div>
<div v-if="loading" class="py-3">
<gl-loading-icon size="md" />
<div v-if="!$options.hasLocalStorage" class="px-3">
{{ __('This feature requires local storage to be enabled') }}
</div>
<template v-else-if="recentSearches.length > 0">
<gl-dropdown-item
v-for="searchQuery in recentSearches"
:key="searchQuery"
@click="setSearchText(searchQuery)"
>{{ searchQuery }}</gl-dropdown-item
>
<gl-dropdown-divider />
<gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches">{{
__('Clear recent searches')
}}</gl-dropdown-item>
</template>
<div v-else class="px-3">{{ __("You don't have any recent searches") }}</div>
</gl-dropdown>
<div class="filtered-search-input-container flex-fill">
<gl-form-input
v-model="errorSearchQuery"
class="pl-2 filtered-search"
:disabled="loading"
:placeholder="__('Search or filter results…')"
autofocus
@keyup.enter.native="filterErrors"
/>
</div>
<div class="gl-search-box-by-type-right-icons">
<gl-button
v-if="errorSearchQuery.length > 0"
v-gl-tooltip.hover
:title="__('Clear')"
class="clear-search text-secondary"
name="clear"
@click="errorSearchQuery = ''"
>
<gl-icon name="close" :size="12" />
</gl-button>
</div>
</div>
<gl-table
v-else
class="mt-3"
:items="errors"
:fields="$options.fields"
:show-empty="true"
fixed
stacked="sm"
<gl-button
v-track-event="trackViewInSentryOptions(externalUrl)"
class="ml-3"
variant="primary"
:href="externalUrl"
target="_blank"
>
<template slot="HEAD_events" slot-scope="data">
<div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="HEAD_users" slot-scope="data">
<div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="error" slot-scope="errors">
<div class="d-flex flex-column">
<gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)">
<strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
</gl-link>
<span class="text-secondary text-truncate">
{{ errors.item.culprit }}
</span>
</div>
</template>
{{ __('View in Sentry') }}
<icon name="external-link" class="flex-shrink-0" />
</gl-button>
</div>
<template slot="events" slot-scope="errors">
<div class="text-md-right">{{ errors.item.count }}</div>
</template>
<div v-if="loading" class="py-3">
<gl-loading-icon size="md" />
</div>
<template slot="users" slot-scope="errors">
<div class="text-md-right">{{ errors.item.userCount }}</div>
</template>
<gl-table
v-else
class="mt-3"
:items="errors"
:fields="$options.fields"
:show-empty="true"
fixed
stacked="sm"
>
<template slot="HEAD_events" slot-scope="data">
<div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="HEAD_users" slot-scope="data">
<div class="text-md-right">{{ data.label }}</div>
</template>
<template slot="error" slot-scope="errors">
<div class="d-flex flex-column">
<gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)">
<strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
</gl-link>
<span class="text-secondary text-truncate">
{{ errors.item.culprit }}
</span>
</div>
</template>
<template slot="lastSeen" slot-scope="errors">
<div class="d-flex align-items-center">
<time-ago :time="errors.item.lastSeen" class="text-secondary" />
</div>
</template>
<template slot="empty">
<div ref="empty">
{{ __('No errors to display.') }}
<gl-link class="js-try-again" @click="restartPolling">
{{ __('Check again') }}
</gl-link>
</div>
</template>
</gl-table>
</div>
<template slot="events" slot-scope="errors">
<div class="text-md-right">{{ errors.item.count }}</div>
</template>
<template slot="users" slot-scope="errors">
<div class="text-md-right">{{ errors.item.userCount }}</div>
</template>
<template slot="lastSeen" slot-scope="errors">
<div class="d-flex align-items-center">
<time-ago :time="errors.item.lastSeen" class="text-secondary" />
</div>
</template>
<template slot="empty">
<div ref="empty">
{{ __('No errors to display.') }}
<gl-link class="js-try-again" @click="restartPolling">
{{ __('Check again') }}
</gl-link>
</div>
</template>
</gl-table>
</div>
<div v-else-if="userCanEnableErrorTracking">
<gl-empty-state
......
......@@ -51,4 +51,20 @@ export function restartPolling({ commit }) {
if (eTagPoll) eTagPoll.restart();
}
export function setIndexPath({ commit }, path) {
commit(types.SET_INDEX_PATH, path);
}
export function loadRecentSearches({ commit }) {
commit(types.LOAD_RECENT_SEARCHES);
}
export function addRecentSearch({ commit }, searchQuery) {
commit(types.ADD_RECENT_SEARCH, searchQuery);
}
export function clearRecentSearches({ commit }) {
commit(types.CLEAR_RECENT_SEARCHES);
}
export default () => {};
export const SET_ERRORS = 'SET_ERRORS';
export const SET_EXTERNAL_URL = 'SET_EXTERNAL_URL';
export const SET_INDEX_PATH = 'SET_INDEX_PATH';
export const SET_LOADING = 'SET_LOADING';
export const ADD_RECENT_SEARCH = 'ADD_RECENT_SEARCH';
export const CLEAR_RECENT_SEARCHES = 'CLEAR_RECENT_SEARCHES';
export const LOAD_RECENT_SEARCHES = 'LOAD_RECENT_SEARCHES';
import * as types from './mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import AccessorUtils from '~/lib/utils/accessor';
export default {
[types.SET_ERRORS](state, data) {
......@@ -11,4 +12,39 @@ export default {
[types.SET_LOADING](state, loading) {
state.loading = loading;
},
[types.SET_INDEX_PATH](state, path) {
state.indexPath = path;
},
[types.ADD_RECENT_SEARCH](state, searchTerm) {
if (searchTerm.length === 0) {
return;
}
// remove any existing item, then add it to the start of the list
const recentSearches = state.recentSearches.filter(s => s !== searchTerm);
recentSearches.unshift(searchTerm);
// only keep the last 5
state.recentSearches = recentSearches.slice(0, 5);
if (AccessorUtils.isLocalStorageAccessSafe()) {
localStorage.setItem(
`recent-searches${state.indexPath}`,
JSON.stringify(state.recentSearches),
);
}
},
[types.CLEAR_RECENT_SEARCHES](state) {
state.recentSearches = [];
if (AccessorUtils.isLocalStorageAccessSafe()) {
localStorage.removeItem(`recent-searches${state.indexPath}`);
}
},
[types.LOAD_RECENT_SEARCHES](state) {
const recentSearches = localStorage.getItem(`recent-searches${state.indexPath}`) || [];
try {
state.recentSearches = JSON.parse(recentSearches);
} catch (e) {
state.recentSearches = [];
throw e;
}
},
};
......@@ -2,4 +2,6 @@ export default () => ({
errors: [],
externalUrl: '',
loading: true,
indexPath: '',
recentSearches: [],
});
......@@ -214,8 +214,8 @@
padding-left: 0;
height: $input-height - 2;
line-height: inherit;
border-color: transparent;
&,
&:focus,
&:hover {
outline: none;
......
---
title: Add recent search to error tracking
merge_request: 19301
author:
type: added
......@@ -15349,6 +15349,9 @@ msgstr ""
msgid "Search or filter results..."
msgstr ""
msgid "Search or filter results…"
msgstr ""
msgid "Search or jump to…"
msgstr ""
......
......@@ -6,8 +6,11 @@ import {
GlLoadingIcon,
GlTable,
GlLink,
GlSearchBoxByClick,
GlFormInput,
GlDropdown,
GlDropdownItem,
} from '@gitlab/ui';
import createListState from '~/error_tracking/store/list/state';
import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
import errorsList from './list_mock.json';
......@@ -51,12 +54,13 @@ describe('ErrorTrackingList', () => {
getErrorList: () => {},
startPolling: jest.fn(),
restartPolling: jest.fn().mockName('restartPolling'),
addRecentSearch: jest.fn(),
loadRecentSearches: jest.fn(),
setIndexPath: jest.fn(),
clearRecentSearches: jest.fn(),
};
const state = {
errors: errorsList,
loading: true,
};
const state = createListState();
store = new Vuex.Store({
modules: {
......@@ -90,6 +94,7 @@ describe('ErrorTrackingList', () => {
describe('results', () => {
beforeEach(() => {
store.state.list.loading = false;
store.state.list.errors = errorsList;
mountComponent();
});
......@@ -114,7 +119,7 @@ describe('ErrorTrackingList', () => {
});
describe('filtering', () => {
const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
const findSearchBox = () => wrapper.find(GlFormInput);
it('shows search box', () => {
expect(findSearchBox().exists()).toBe(true);
......@@ -122,7 +127,9 @@ describe('ErrorTrackingList', () => {
it('makes network request on submit', () => {
expect(actions.startPolling).toHaveBeenCalledTimes(1);
findSearchBox().vm.$emit('submit');
findSearchBox().trigger('keyup.enter');
expect(actions.startPolling).toHaveBeenCalledTimes(2);
});
});
......@@ -185,4 +192,51 @@ describe('ErrorTrackingList', () => {
);
});
});
describe('recent searches', () => {
beforeEach(() => {
mountComponent();
});
it('shows empty message', () => {
store.state.list.recentSearches = [];
expect(wrapper.find(GlDropdown).text()).toBe("You don't have any recent searches");
});
it('shows items', () => {
store.state.list.recentSearches = ['great', 'search'];
const dropdownItems = wrapper.findAll(GlDropdownItem);
expect(dropdownItems.length).toBe(3);
expect(dropdownItems.at(0).text()).toBe('great');
expect(dropdownItems.at(1).text()).toBe('search');
});
describe('clear', () => {
const clearRecentButton = () => wrapper.find({ ref: 'clearRecentSearches' });
it('is hidden when list empty', () => {
store.state.list.recentSearches = [];
expect(clearRecentButton().exists()).toBe(false);
});
it('is visible when list has items', () => {
store.state.list.recentSearches = ['some', 'searches'];
expect(clearRecentButton().exists()).toBe(true);
expect(clearRecentButton().text()).toBe('Clear recent searches');
});
it('clears items on click', () => {
store.state.list.recentSearches = ['some', 'searches'];
clearRecentButton().vm.$emit('click');
expect(actions.clearRecentSearches).toHaveBeenCalledTimes(1);
});
});
});
});
import mutations from '~/error_tracking/store/list/mutations';
import * as types from '~/error_tracking/store/list/mutation_types';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
const ADD_RECENT_SEARCH = mutations[types.ADD_RECENT_SEARCH];
const CLEAR_RECENT_SEARCHES = mutations[types.CLEAR_RECENT_SEARCHES];
const LOAD_RECENT_SEARCHES = mutations[types.LOAD_RECENT_SEARCHES];
describe('Error tracking mutations', () => {
describe('SET_ERRORS', () => {
......@@ -33,4 +38,81 @@ describe('Error tracking mutations', () => {
});
});
});
describe('recent searches', () => {
useLocalStorageSpy();
let state;
beforeEach(() => {
state = {
indexPath: '/project/errors.json',
recentSearches: [],
};
});
describe('ADD_RECENT_SEARCH', () => {
it('adds search queries to recentSearches and localStorage', () => {
ADD_RECENT_SEARCH(state, 'my issue');
expect(state.recentSearches).toEqual(['my issue']);
expect(localStorage.setItem).toHaveBeenCalledWith(
'recent-searches/project/errors.json',
'["my issue"]',
);
});
it('does not add empty searches', () => {
ADD_RECENT_SEARCH(state, '');
expect(state.recentSearches).toEqual([]);
expect(localStorage.setItem).not.toHaveBeenCalled();
});
it('adds new queries to start of the list', () => {
state.recentSearches = ['previous', 'searches'];
ADD_RECENT_SEARCH(state, 'new search');
expect(state.recentSearches).toEqual(['new search', 'previous', 'searches']);
});
it('limits recentSearches to 5 items', () => {
state.recentSearches = [1, 2, 3, 4, 5];
ADD_RECENT_SEARCH(state, 'new search');
expect(state.recentSearches).toEqual(['new search', 1, 2, 3, 4]);
});
it('does not add same search query twice', () => {
state.recentSearches = ['already', 'searched'];
ADD_RECENT_SEARCH(state, 'searched');
expect(state.recentSearches).toEqual(['searched', 'already']);
});
});
describe('CLEAR_RECENT_SEARCHES', () => {
it('clears recentSearches and localStorage', () => {
state.recentSearches = ['first', 'second'];
CLEAR_RECENT_SEARCHES(state);
expect(state.recentSearches).toEqual([]);
expect(localStorage.removeItem).toHaveBeenCalledWith('recent-searches/project/errors.json');
});
});
describe('LOAD_RECENT_SEARCHES', () => {
it('loads recent searches from localStorage', () => {
jest.spyOn(window.localStorage, 'getItem').mockReturnValue('["first", "second"]');
LOAD_RECENT_SEARCHES(state);
expect(state.recentSearches).toEqual(['first', 'second']);
expect(localStorage.getItem).toHaveBeenCalledWith('recent-searches/project/errors.json');
});
});
});
});
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