Commit d9a9dced authored by Simon Knox's avatar Simon Knox Committed by Kushal Pandya

Add recent searches to Sentry error list

Add dropdown and recent text values
save values to localStorage
parent 7156c762
......@@ -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