Commit eb0bb14b authored by Illya Klymov's avatar Illya Klymov

Merge branch 'jlouw-audit-filter-mvc-fe' into 'master'

Replace audit log filter with filtered search component

See merge request gitlab-org/gitlab!25809
parents a278742b 7a1e7506
<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.');
});
});
});
......@@ -2837,15 +2837,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