Commit 7a1e7506 authored by Jiaan Louw's avatar Jiaan Louw Committed by Illya Klymov

Update audit log filter with new component

Changes audit log filter to use the
filtered search component from gitlab-ui.
parent 899f88a2
<script>
import { GlFilteredSearch } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { queryToObject } from '~/lib/utils/url_utility';
import UserToken from './tokens/user_token.vue';
import ProjectToken from './tokens/project_token.vue';
import GroupToken from './tokens/group_token.vue';
const DEFAULT_TOKEN_OPTIONS = {
operators: [{ value: '=', description: __('is'), default: 'true' }],
unique: true,
};
const FILTER_TOKENS = [
{
...DEFAULT_TOKEN_OPTIONS,
icon: 'user',
title: s__('AuditLogs|User Events'),
type: 'User',
token: UserToken,
},
{
...DEFAULT_TOKEN_OPTIONS,
icon: 'bookmark',
title: s__('AuditLogs|Project Events'),
type: 'Project',
token: ProjectToken,
},
{
...DEFAULT_TOKEN_OPTIONS,
icon: 'group',
title: s__('AuditLogs|Group Events'),
type: 'Group',
token: GroupToken,
},
];
const ALLOWED_FILTER_TYPES = FILTER_TOKENS.map(token => token.type);
export default {
components: {
GlFilteredSearch,
},
data() {
return {
searchTerms: [],
};
},
computed: {
searchTerm() {
return this.searchTerms.find(term => ALLOWED_FILTER_TYPES.includes(term.type));
},
filterTokens() {
// This limits the user to search by only one of the available tokens
const { searchTerm } = this;
if (searchTerm?.type) {
return FILTER_TOKENS.map(token => ({
...token,
disabled: searchTerm.type !== token.type,
}));
}
return FILTER_TOKENS;
},
id() {
return this.searchTerm?.value?.data;
},
type() {
return this.searchTerm?.type;
},
},
created() {
this.setSearchTermsFromQuery();
},
methods: {
// The form logic here will be removed once all the audit
// components are migrated into a single Vue application.
// https://gitlab.com/gitlab-org/gitlab/-/issues/215363
getFormElement() {
return this.$refs.input.form;
},
setSearchTermsFromQuery() {
const { entity_type: type, entity_id: value } = queryToObject(window.location.search);
if (type && value) {
this.searchTerms = [{ type, value: { data: value, operator: '=' } }];
}
},
filteredSearchSubmit() {
this.getFormElement().submit();
},
},
};
</script>
<template>
<div class="input-group bg-white flex-grow-1" data-qa-selector="admin_audit_log_filter">
<gl-filtered-search
v-model="searchTerms"
:placeholder="__('Search')"
:clear-button-title="__('Clear')"
:close-button-title="__('Close')"
:available-tokens="filterTokens"
class="gl-h-32 w-100"
@submit="filteredSearchSubmit"
/>
<input ref="input" v-model="type" type="hidden" name="entity_type" />
<input v-model="id" type="hidden" name="entity_id" />
</div>
</template>
<script>
import Api from '~/api';
import AuditFilterToken from './shared/audit_filter_token.vue';
export default {
components: {
AuditFilterToken,
},
inheritAttrs: false,
tokenMethods: {
fetchItem(id) {
return Api.group(id, () => {});
},
fetchSuggestions(term) {
return Api.groups(term);
},
getItemName(item) {
return item.full_name;
},
},
};
</script>
<template>
<audit-filter-token v-bind="{ ...this.$attrs, ...this.$options.tokenMethods }" v-on="$listeners">
<template #suggestion="{item: group}">
<p class="m-0">{{ group.full_name }}</p>
<p class="m-0">{{ group.full_path }}</p>
</template>
</audit-filter-token>
</template>
<script>
import Api from '~/api';
import AuditFilterToken from './shared/audit_filter_token.vue';
export default {
components: {
AuditFilterToken,
},
inheritAttrs: false,
tokenMethods: {
fetchItem(id) {
return Api.project(id).then(res => res.data);
},
fetchSuggestions(term) {
return Api.projects(term, { membership: false }).then(res => res.data);
},
getItemName({ name }) {
return name;
},
},
};
</script>
<template>
<audit-filter-token v-bind="{ ...this.$attrs, ...this.$options.tokenMethods }" v-on="$listeners">
<template #suggestion="{item: project}">
<p class="m-0">{{ project.name }}</p>
<p class="m-0">{{ project.name_with_namespace }}</p>
</template>
</audit-filter-token>
</template>
<script>
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
GlAvatar,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { sprintf, s__, __ } from '~/locale';
import httpStatusCodes from '~/lib/utils/http_status';
import createFlash from '~/flash';
import { isNumeric } from '../../../utils';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
GlAvatar,
},
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
config: {
type: Object,
required: true,
},
active: {
type: Boolean,
required: true,
},
fetchItem: {
type: Function,
required: true,
},
fetchSuggestions: {
type: Function,
required: true,
},
getItemName: {
type: Function,
required: true,
},
},
data() {
return {
activeItem: null,
viewLoading: false,
suggestionsLoading: false,
suggestions: [],
};
},
computed: {
activeItemName() {
return this.activeItem ? this.getItemName(this.activeItem) : null;
},
debouncedLoadSuggestions() {
return debounce(this.loadSuggestions, 500);
},
hasSuggestions() {
return this.suggestions.length > 0;
},
lowerCaseType() {
return this.config.type.toLowerCase();
},
noSuggestionsString() {
return sprintf(s__('AuditLogs|No matching %{type} found.'), { type: this.lowerCaseType });
},
},
watch: {
// eslint-disable-next-line func-names
'value.data': function(term) {
this.debouncedLoadSuggestions(term);
},
active() {
const { data: input } = this.value;
if (isNumeric(input)) {
this.selectActiveItem(parseInt(input, 10));
}
},
},
mounted() {
const { data: id } = this.value;
if (id && isNumeric(id)) {
this.loadView(id);
} else {
this.loadSuggestions();
}
},
methods: {
getAvatarString(name) {
return sprintf(__("%{name}'s avatar"), { name });
},
onApiError({ response: { status } }) {
const type = this.lowerCaseType;
let message;
if (status === httpStatusCodes.NOT_FOUND) {
message = s__('AuditLogs|Failed to find %{type}. Please search for another %{type}.');
} else {
message = s__('AuditLogs|Failed to find %{type}. Please try again.');
}
createFlash(sprintf(message, { type }));
},
selectActiveItem(id) {
this.activeItem = this.suggestions.find(u => u.id === id);
},
loadView(id) {
this.viewLoading = true;
return this.fetchItem(id)
.then(data => {
this.activeItem = data;
})
.catch(this.onApiError)
.finally(() => {
this.viewLoading = false;
});
},
loadSuggestions(term) {
this.suggestionsLoading = true;
return this.fetchSuggestions(term)
.then(data => {
this.suggestions = data;
})
.catch(this.onApiError)
.finally(() => {
this.suggestionsLoading = false;
});
},
},
};
</script>
<template>
<gl-filtered-search-token
v-bind="{ ...this.$props, ...this.$attrs }"
:operators="config.operators"
v-on="$listeners"
>
<template #view>
<gl-loading-icon v-if="viewLoading" size="sm" class="gl-mr-2" />
<template v-else-if="activeItem">
<gl-avatar
:size="16"
:src="activeItem.avatar_url"
:entity-name="activeItemName"
:entity-id="activeItem.id"
:alt="getAvatarString(activeItem.name)"
shape="circle"
class="gl-mr-2"
/>
{{ activeItemName }}
</template>
</template>
<template #suggestions>
<template v-if="suggestionsLoading">
<gl-loading-icon />
</template>
<template v-else-if="hasSuggestions">
<gl-filtered-search-suggestion
v-for="item in suggestions"
:key="item.id"
:value="item.id.toString()"
>
<div class="d-flex">
<gl-avatar
:size="32"
:src="item.avatar_url"
:entity-id="item.id"
:entity-name="item.name"
:alt="getAvatarString(item.name)"
shape="circle"
/>
<div>
<slot name="suggestion" :item="item"></slot>
</div>
</div>
</gl-filtered-search-suggestion>
</template>
<span v-else class="dropdown-item">{{ noSuggestionsString }}</span>
</template>
</gl-filtered-search-token>
</template>
<script>
import Api from '~/api';
import AuditFilterToken from './shared/audit_filter_token.vue';
export default {
components: {
AuditFilterToken,
},
inheritAttrs: false,
tokenMethods: {
fetchItem(id) {
return Api.user(id).then(res => res.data);
},
fetchSuggestions(term) {
return Api.users(term).then(res => res.data);
},
getItemName({ name }) {
return name;
},
},
};
</script>
<template>
<audit-filter-token v-bind="{ ...this.$attrs, ...this.$options.tokenMethods }" v-on="$listeners">
<template #suggestion="{item: user}">
<p class="m-0">{{ user.name }}</p>
<p class="m-0">@{{ user.username }}</p>
</template>
</audit-filter-token>
</template>
export const isNumeric = str => {
return !Number.isNaN(parseInt(str, 10), 10);
};
export default {};
/* eslint-disable class-methods-use-this, no-unneeded-ternary, no-new */
import $ from 'jquery';
import UsersSelect from '~/users_select';
import groupsSelect from '~/groups_select';
import projectSelect from '~/project_select';
class AuditLogs {
constructor() {
this.initFilters();
}
initFilters() {
projectSelect();
groupsSelect();
new UsersSelect();
this.initFilterDropdown($('.js-type-filter'), 'entity_type', null, () => {
$('.hidden-filter-value').val('');
$('form.filter-form').submit();
});
$('.project-item-select').on('click', () => {
$('form.filter-form').submit();
});
}
initFilterDropdown($dropdown, fieldName, searchFields, cb) {
const dropdownOptions = {
fieldName,
selectable: true,
filterable: searchFields ? true : false,
search: { fields: searchFields },
data: $dropdown.data('data'),
clicked: () => $dropdown.closest('form.filter-form').submit(),
};
if (cb) {
dropdownOptions.clicked = cb;
}
$dropdown.glDropdown(dropdownOptions);
}
}
export default AuditLogs;
......@@ -2,13 +2,11 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import AuditLogFilter from 'ee/audit_logs/components/audit_log_filter.vue';
import DateRangeField from 'ee/audit_logs/components/date_range_field.vue';
import LogsTable from 'ee/audit_logs/components/logs_table.vue';
import AuditLogs from './audit_logs';
// Merge these when working on https://gitlab.com/gitlab-org/gitlab/-/issues/215363
document.addEventListener('DOMContentLoaded', () => new AuditLogs());
document.addEventListener('DOMContentLoaded', () => {
const el = document.querySelector('#js-audit-logs-date-range-app');
const formElement = el.closest('form');
......@@ -42,3 +40,13 @@ document.addEventListener('DOMContentLoaded', () => {
}),
});
});
document.addEventListener('DOMContentLoaded', () => {
const el = document.querySelector('#js-audit-logs-filter-app');
// eslint-disable-next-line no-new
new Vue({
el,
name: 'AuditLogFilterApp',
render: createElement => createElement(AuditLogFilter),
});
});
......@@ -8,18 +8,6 @@
background-color: $white;
}
.dropdown-menu-toggle {
// New GitLab UI inputs are 32px high, while the older inputs are 34px
// This can be removed once the audit log fields are converted to use GitLab UI's dropdown instead
padding-bottom: 5px;
padding-top: 5px;
.fa-chevron-down,
.fa-spinner {
top: 10px;
}
}
@include media-breakpoint-down(md) {
.dropdown-menu-toggle,
.filter-item {
......
......@@ -3,47 +3,10 @@
.todos-filters
.row-content-block.second-block.pb-0
= form_tag admin_audit_logs_path, method: :get, class: 'filter-form d-flex justify-content-between audit-controls row' do
.flex-lg-row.col-lg-auto
.filter-item.inline.form-group.mr-2.mr-md-0
- if params[:sort]
= hidden_field_tag(:sort, params[:sort])
- if params[:entity_type].present?
= hidden_field_tag(:entity_type, params[:entity_type])
.col-lg-auto.flex-fill.form-group.align-items-lg-center.pr-lg-8
#js-audit-logs-filter-app
= dropdown_tag(audit_entity_type_label(params[:entity_type]),
options: { toggle_class: 'js-type-search js-filter-submit js-type-filter',
dropdown_class: 'dropdown-menu-type dropdown-menu-selectable dropdown-menu-action js-filter-submit',
placeholder: 'Search types',
data: { field_name: 'entity_type', data: audit_entity_type_options, default_label: 'All Events' } })
- if params[:entity_type] == 'User'
.filter-item.inline.form-group
- if params[:entity_id].present?
= hidden_field_tag(:entity_id, params[:entity_id], class:'hidden-filter-value')
= dropdown_tag(@entity&.name || _('Search users'),
options: { toggle_class: 'js-user-search js-filter-submit', filter: true,
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable',
placeholder: _('Search users'),
data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, field_name: 'entity_id' } })
- elsif params[:entity_type] == 'Project'
.filter-item.inline.form-group
= project_select_tag(:entity_id, { class: 'project-item-select hidden-filter-value',
toggle_class: 'js-project-search js-project-filter js-filter-submit',
dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
placeholder: @entity&.full_name || _('Search projects'), idAttribute: 'id',
data: { order_by: 'last_activity_at', idattribute: 'id', all_projects: 'true', simple_filter: true } })
- elsif params[:entity_type] == 'Group'
.filter-item.inline.form-group
= groups_select_tag(:entity_id, { required: true, class: 'group-item-select project-item-select hidden-filter-value',
toggle_class: 'js-group-search js-group-filter js-filter-submit',
dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit',
placeholder: @entity&.full_path || _('Search groups'), idAttribute: 'id',
data: { order_by: 'last_activity_at', idattribute: 'id', all_available: true } })
.d-flex.col-lg-auto.flex-wrap
.d-flex.col-lg-auto.flex-wrap.pl-lg-0
%form.row-content-block.second-block.d-flex.justify-content-lg-end.pb-0
.audit-controls.d-flex.align-items-lg-center.flex-column.flex-lg-row.col-lg-auto.px-0
#js-audit-logs-date-range-app
......
---
title: Audit logs now uses filtered search
merge_request:
author:
type: changed
......@@ -60,16 +60,7 @@ describe 'Admin::AuditLogs', :js do
end
it 'filters by user' do
filter_by_type('User Events')
click_button 'Search users'
wait_for_requests
within '.dropdown-menu-user' do
click_link user.name
end
wait_for_requests
filter_for('User Events', user.name)
expect(page).to have_content('Signed in with LDAP authentication')
end
......@@ -86,13 +77,7 @@ describe 'Admin::AuditLogs', :js do
end
it 'filters by group' do
filter_by_type('Group Events')
find('.group-item-select').click
wait_for_requests
find('.select2-results').click
find('.audit-log-table td', match: :first)
filter_for('Group Events', group_member.group.name)
expect(page).to have_content('Added user access as Owner')
end
......@@ -110,13 +95,7 @@ describe 'Admin::AuditLogs', :js do
end
it 'filters by project' do
filter_by_type('Project Events')
find('.project-item-select').click
wait_for_requests
find('.select2-results').click
find('.audit-log-table td', match: :first)
filter_for('Project Events', project_member.project.name)
expect(page).to have_content('Removed user access')
end
......@@ -155,11 +134,14 @@ describe 'Admin::AuditLogs', :js do
end
end
def filter_by_type(type)
click_button 'All Events'
def filter_for(type, name)
within '[data-qa-selector="admin_audit_log_filter"]' do
find('input').click
within '.dropdown-menu-type' do
click_link type
click_link name
find('button[type="button"]').click
end
wait_for_requests
......
import { GlFilteredSearch } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import AuditLogFilter from 'ee/audit_logs/components/audit_log_filter.vue';
describe('AuditLogFilter', () => {
let wrapper;
const formElement = document.createElement('form');
formElement.submit = jest.fn();
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
const getAvailableTokens = () => findFilteredSearch().props('availableTokens');
const getAvailableTokenProps = type =>
getAvailableTokens().filter(token => token.type === type)[0];
const initComponent = () => {
wrapper = shallowMount(AuditLogFilter, {
methods: {
getFormElement: () => formElement,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe.each`
type | title
${'Project'} | ${'Project Events'}
${'Group'} | ${'Group Events'}
${'User'} | ${'User Events'}
`('for the list of available tokens', ({ type, title }) => {
it(`creates a unique token for ${type}`, () => {
initComponent();
expect(getAvailableTokenProps(type)).toMatchObject({
title,
unique: true,
operators: [expect.objectContaining({ value: '=' })],
});
});
});
describe('when the URL query has a search term', () => {
const type = 'User';
const id = '1';
beforeEach(() => {
delete window.location;
window.location = { search: `entity_type=${type}&entity_id=${id}` };
initComponent();
});
it('sets the filtered searched token', () => {
expect(findFilteredSearch().props('value')).toMatchObject([
{
type,
value: {
data: id,
},
},
]);
});
});
describe('when the URL query is empty', () => {
beforeEach(() => {
delete window.location;
window.location = { search: '' };
initComponent();
});
it('has an empty search value', () => {
expect(findFilteredSearch().vm.value).toEqual([]);
});
});
describe('when submitting the filtered search', () => {
beforeEach(() => {
initComponent();
findFilteredSearch().vm.$emit('submit');
});
it("calls submit on this component's FORM element", () => {
expect(formElement.submit).toHaveBeenCalledWith();
});
});
describe('when a search token has been selected', () => {
const searchTerm = {
value: { data: '1' },
type: 'Project',
};
beforeEach(() => {
initComponent();
wrapper.setData({
searchTerms: [searchTerm],
});
});
it('only one token matching the selected type is available', () => {
expect(getAvailableTokenProps('Project').disabled).toEqual(false);
expect(getAvailableTokenProps('Group').disabled).toEqual(true);
expect(getAvailableTokenProps('User').disabled).toEqual(true);
});
it('sets the input values according to the search term', () => {
expect(wrapper.find('input[name="entity_type"]').attributes().value).toEqual(searchTerm.type);
expect(wrapper.find('input[name="entity_id"]').attributes().value).toEqual(
searchTerm.value.data,
);
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AuditFilterToken when initialized with a value matches the snapshot 1`] = `
<div
config="[object Object]"
fetchitem="function mockConstructor() {
return fn.apply(this, arguments);
}"
fetchsuggestions="function mockConstructor() {
return fn.apply(this, arguments);
}"
getitemname="function mockConstructor() {
return fn.apply(this, arguments);
}"
id="filtered-search-token"
value="[object Object]"
>
<div
class="view"
>
<gl-avatar-stub
alt="An item name's avatar"
class="gl-mr-2"
entityid="0"
entityname=""
shape="circle"
size="16"
src=""
/>
</div>
<div
class="suggestions"
>
<span
class="dropdown-item"
>
No matching foo found.
</span>
</div>
</div>
`;
exports[`AuditFilterToken when initialized without a value matches the snapshot 1`] = `
<div
config="[object Object]"
fetchitem="function mockConstructor() {
return fn.apply(this, arguments);
}"
fetchsuggestions="function mockConstructor() {
return fn.apply(this, arguments);
}"
getitemname="function mockConstructor() {
return fn.apply(this, arguments);
}"
id="filtered-search-token"
value="[object Object]"
>
<div
class="view"
/>
<div
class="suggestions"
>
<gl-filtered-search-suggestion-stub
value="1"
>
<div
class="d-flex"
>
<gl-avatar-stub
alt="A suggestion name's avatar"
entityid="1"
entityname="A suggestion name"
shape="circle"
size="32"
src="www"
/>
<div />
</div>
</gl-filtered-search-suggestion-stub>
</div>
</div>
`;
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import httpStatusCodes from '~/lib/utils/http_status';
import createFlash from '~/flash';
import AuditFilterToken from 'ee/audit_logs/components/tokens/shared/audit_filter_token.vue';
jest.mock('~/flash');
describe('AuditFilterToken', () => {
let wrapper;
const item = { name: 'An item name' };
const suggestions = [
{
id: 1,
name: 'A suggestion name',
avatar_url: 'www',
full_name: 'Full name',
},
];
const findFilteredSearchToken = () => wrapper.find('#filtered-search-token');
const findLoadingIcon = type => wrapper.find(type).find(GlLoadingIcon);
const tokenMethods = {
fetchItem: jest.fn().mockResolvedValue(item),
fetchSuggestions: jest.fn().mockResolvedValue(suggestions),
getItemName: jest.fn(),
};
const initComponent = (props = {}) => {
wrapper = shallowMount(AuditFilterToken, {
propsData: {
value: {},
config: {
type: 'Foo',
},
active: false,
...tokenMethods,
...props,
},
stubs: {
GlFilteredSearchToken: {
template: `<div id="filtered-search-token">
<div class="view"><slot name="view"></slot></div>
<div class="suggestions"><slot name="suggestions"></slot></div>
</div>`,
},
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when initialized', () => {
it('passes the config correctly', () => {
const config = {
icon: 'user',
type: 'user',
title: 'User',
unique: true,
};
initComponent({ config });
expect(findFilteredSearchToken().props('config')).toEqual(config);
});
it('passes the value correctly', () => {
const value = { data: 1 };
initComponent({ value });
expect(findFilteredSearchToken().props('value')).toEqual(value);
});
describe('with a value', () => {
const value = { data: 1 };
beforeEach(() => {
initComponent({ value });
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('fetches an item to display', () => {
expect(tokenMethods.fetchItem).toHaveBeenCalledWith(value.data);
});
});
describe('without a value', () => {
beforeEach(() => {
initComponent();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('fetches suggestions to display', () => {
expect(tokenMethods.fetchSuggestions).toHaveBeenCalled();
});
});
});
describe('when fetching suggestions', () => {
let resolveSuggestions;
let rejectSuggestions;
beforeEach(() => {
const value = { data: '' };
const fetchSuggestions = () =>
new Promise((resolve, reject) => {
resolveSuggestions = resolve;
rejectSuggestions = reject;
});
initComponent({ value, fetchSuggestions });
});
it('shows the suggestions loading icon', () => {
expect(findLoadingIcon('.suggestions').exists()).toBe(true);
expect(findLoadingIcon('.view').exists()).toBe(false);
});
describe('and the fetch succeeds', () => {
beforeEach(() => {
resolveSuggestions(suggestions);
});
it('does not show the suggestions loading icon', () => {
expect(findLoadingIcon('.suggestions').exists()).toBe(false);
});
});
describe('and the fetch fails', () => {
beforeEach(() => {
rejectSuggestions({ response: { status: httpStatusCodes.NOT_FOUND } });
});
it('shows a flash error message', () => {
expect(createFlash).toHaveBeenCalledWith(
'Failed to find foo. Please search for another foo.',
);
});
});
});
describe('when fetching the view item', () => {
let resolveItem;
let rejectItem;
beforeEach(() => {
const value = { data: 1 };
const fetchItem = () =>
new Promise((resolve, reject) => {
resolveItem = resolve;
rejectItem = reject;
});
initComponent({ value, fetchItem });
});
it('shows the view loading icon', () => {
expect(findLoadingIcon('.view').exists()).toBe(true);
expect(findLoadingIcon('.suggestions').exists()).toBe(false);
});
describe('and the fetch succeeds', () => {
beforeEach(() => {
resolveItem(item);
});
it('does not show the view loading icon', () => {
expect(findLoadingIcon('.view').exists()).toBe(false);
});
});
describe('and the fetch fails', () => {
beforeEach(() => {
rejectItem({ response: { status: httpStatusCodes.NOT_FOUND } });
});
it('shows a flash error message', () => {
expect(createFlash).toHaveBeenCalledWith(
'Failed to find foo. Please search for another foo.',
);
});
});
});
describe('when no suggestion could be found', () => {
beforeEach(() => {
const value = { data: '' };
const fetchSuggestions = jest.fn().mockResolvedValue([]);
initComponent({ value, fetchSuggestions });
});
it('renders an empty message', () => {
expect(wrapper.text()).toBe('No matching foo found.');
});
});
describe('when a view item could not be found', () => {
beforeEach(() => {
const value = { data: 1 };
const fetchItem = jest.fn().mockResolvedValue({});
initComponent({ value, fetchItem });
});
it('renders an empty message', () => {
expect(wrapper.text()).toBe('No matching foo found.');
});
});
});
......@@ -2824,15 +2824,33 @@ msgstr ""
msgid "AuditLogs|Date"
msgstr ""
msgid "AuditLogs|Failed to find %{type}. Please search for another %{type}."
msgstr ""
msgid "AuditLogs|Failed to find %{type}. Please try again."
msgstr ""
msgid "AuditLogs|Group Events"
msgstr ""
msgid "AuditLogs|IP Address"
msgstr ""
msgid "AuditLogs|No matching %{type} found."
msgstr ""
msgid "AuditLogs|Object"
msgstr ""
msgid "AuditLogs|Project Events"
msgstr ""
msgid "AuditLogs|Target"
msgstr ""
msgid "AuditLogs|User Events"
msgstr ""
msgid "Aug"
msgstr ""
......
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