Commit b11b5142 authored by Peter Hegman's avatar Peter Hegman

Merge branch '335016-filter-audit-events-by-username-frontend' into 'master'

[Frontend] Update audit events filter bar to search by username

See merge request gitlab-org/gitlab!73742
parents 10b55756 406fef79
......@@ -24,3 +24,5 @@ export const DEFAULT_TH_CLASSES =
// We set the drawer's z-index to 252 to clear flash messages that might
// be displayed in the page and that have a z-index of 251.
export const DRAWER_Z_INDEX = 252;
export const MIN_USERNAME_LENGTH = 2;
<script>
import Api from '~/api';
import { isValidEntityId } from '../../token_utils';
import AuditFilterToken from './shared/audit_filter_token.vue';
export default {
......@@ -17,6 +18,16 @@ export default {
getItemName(item) {
return item.full_name;
},
getSuggestionValue({ id }) {
return id.toString();
},
isValidIdentifier(id) {
return isValidEntityId(id);
},
findActiveItem(suggestions, id) {
const parsedId = parseInt(id, 10);
return suggestions.find((g) => g.id === parsedId);
},
},
};
</script>
......
<script>
import Api from '~/api';
import { getUser } from '~/rest_api';
import { getUsers } from '~/rest_api';
import { parseUsername, displayUsername, isValidUsername } from '../../token_utils';
import AuditFilterToken from './shared/audit_filter_token.vue';
export default {
......@@ -10,18 +11,19 @@ export default {
},
inheritAttrs: false,
tokenMethods: {
fetchItem(id) {
return getUser(id).then((res) => res.data);
fetchItem(term) {
const username = parseUsername(term);
return getUsers('', { username, per_page: 1 }).then((res) => res.data[0]);
},
fetchSuggestions(term) {
const { groupId, projectPath } = this.config;
if (groupId) {
return Api.groupMembers(groupId, { search: term }).then((res) => res.data);
return Api.groupMembers(groupId, { query: parseUsername(term) }).then((res) => res.data);
}
if (projectPath) {
return Api.projectUsers(projectPath, term);
return Api.projectUsers(projectPath, parseUsername(term));
}
return {};
......@@ -29,6 +31,15 @@ export default {
getItemName({ name }) {
return name;
},
getSuggestionValue({ username }) {
return displayUsername(username);
},
isValidIdentifier(username) {
return isValidUsername(username);
},
findActiveItem(suggestions, username) {
return suggestions.find((u) => u.username === parseUsername(username));
},
},
};
</script>
......
<script>
import Api from '~/api';
import { isValidEntityId } from '../../token_utils';
import AuditFilterToken from './shared/audit_filter_token.vue';
export default {
......@@ -17,6 +18,16 @@ export default {
getItemName({ name }) {
return name;
},
getSuggestionValue({ id }) {
return id.toString();
},
isValidIdentifier(id) {
return isValidEntityId(id);
},
findActiveItem(suggestions, id) {
const parsedId = parseInt(id, 10);
return suggestions.find((p) => p.id === parsedId);
},
},
};
</script>
......
......@@ -8,7 +8,6 @@ import {
import { debounce } from 'lodash';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
import { isNumeric } from '~/lib/utils/number_utils';
import { sprintf, s__, __ } from '~/locale';
export default {
......@@ -44,6 +43,18 @@ export default {
type: Function,
required: true,
},
getSuggestionValue: {
type: Function,
required: true,
},
findActiveItem: {
type: Function,
required: true,
},
isValidIdentifier: {
type: Function,
required: true,
},
},
data() {
return {
......@@ -77,14 +88,14 @@ export default {
},
active() {
const { data: input } = this.value;
if (isNumeric(input)) {
this.selectActiveItem(parseInt(input, 10));
if (this.isValidIdentifier(input)) {
this.activeItem = this.findActiveItem(this.suggestions, input);
}
},
},
mounted() {
const { data: id } = this.value;
if (id && isNumeric(id)) {
if (this.isValidIdentifier(id)) {
this.loadView(id);
} else {
this.loadSuggestions();
......@@ -106,14 +117,14 @@ export default {
message: 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;
if (data) {
this.activeItem = data;
this.suggestions.push(data);
}
})
.catch(this.onApiError)
.finally(() => {
......@@ -152,6 +163,7 @@ export default {
:alt="getAvatarString(activeItem.name)"
shape="circle"
class="gl-mr-2"
data-testid="audit-filter-item-avatar"
/>
{{ activeItemName }}
</template>
......@@ -164,7 +176,8 @@ export default {
<gl-filtered-search-suggestion
v-for="item in suggestions"
:key="item.id"
:value="item.id.toString()"
:value="getSuggestionValue(item)"
data-testid="audit-filter-suggestion"
>
<div class="d-flex">
<gl-avatar
......
<script>
import { getUsers, getUser } from '~/rest_api';
import { getUsers } from '~/rest_api';
import { parseUsername, displayUsername, isValidUsername } from '../../token_utils';
import AuditFilterToken from './shared/audit_filter_token.vue';
export default {
......@@ -8,15 +9,25 @@ export default {
},
inheritAttrs: false,
tokenMethods: {
fetchItem(id) {
return getUser(id).then((res) => res.data);
fetchItem(term) {
const username = parseUsername(term);
return getUsers('', { username, per_page: 1 }).then((res) => res.data[0]);
},
fetchSuggestions(term) {
return getUsers(term).then((res) => res.data);
return getUsers(parseUsername(term)).then((res) => res.data);
},
getItemName({ name }) {
return name;
},
getSuggestionValue({ username }) {
return displayUsername(username);
},
isValidIdentifier(username) {
return isValidUsername(username);
},
findActiveItem(suggestions, username) {
return suggestions.find((u) => u.username === parseUsername(username));
},
},
};
</script>
......
......@@ -12,7 +12,7 @@ const DEFAULT_TOKEN_OPTIONS = {
// Due to the i18n eslint rule we can't have a capitalized string even if it is a case-aware URL param
/* eslint-disable @gitlab/require-i18n-strings */
const ENTITY_TYPES = {
export const ENTITY_TYPES = {
USER: 'User',
AUTHOR: 'Author',
GROUP: 'Group',
......
......@@ -4,14 +4,17 @@ export default {
[types.INITIALIZE_AUDIT_EVENTS](
state,
{
entity_id: id = null,
entity_id: entityId = null,
entity_username: entityUsername = null,
author_username: authorUsername = null,
entity_type: type = null,
created_after: startDate = null,
created_before: endDate = null,
sort: sortBy = null,
} = {},
) {
state.filterValue = type && id ? [{ type, value: { data: id, operator: '=' } }] : [];
const data = entityId ?? entityUsername ?? authorUsername;
state.filterValue = type && data ? [{ type, value: { data, operator: '=' } }] : [];
state.startDate = startDate;
state.endDate = endDate;
state.sortBy = sortBy;
......
// These methods need to be separate from `./utils.js` to avoid a circular dependency.
import { MIN_USERNAME_LENGTH } from '~/lib/utils/constants';
import { isNumeric } from '~/lib/utils/number_utils';
export const parseUsername = (username) =>
username && String(username).startsWith('@') ? username.slice(1) : username;
export const displayUsername = (username) => (username ? `@${username}` : null);
export const isValidUsername = (username) =>
Boolean(username) && username.length >= MIN_USERNAME_LENGTH;
export const isValidEntityId = (id) => Boolean(id) && isNumeric(id) && parseInt(id, 10) > 0;
import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility';
import { AVAILABLE_TOKEN_TYPES, AUDIT_FILTER_CONFIGS } from './constants';
import { AVAILABLE_TOKEN_TYPES, AUDIT_FILTER_CONFIGS, ENTITY_TYPES } from './constants';
import { parseUsername, displayUsername } from './token_utils';
export const getTypeFromEntityType = (entityType) => {
return AUDIT_FILTER_CONFIGS.find(
......@@ -15,24 +16,45 @@ export const parseAuditEventSearchQuery = ({
created_after: createdAfter,
created_before: createdBefore,
entity_type: entityType,
entity_username: entityUsername,
author_username: authorUsername,
...restOfParams
}) => ({
...restOfParams,
created_after: createdAfter ? parsePikadayDate(createdAfter) : null,
created_before: createdBefore ? parsePikadayDate(createdBefore) : null,
entity_type: getTypeFromEntityType(entityType),
entity_username: displayUsername(entityUsername),
author_username: displayUsername(authorUsername),
});
export const createAuditEventSearchQuery = ({ filterValue, startDate, endDate, sortBy }) => {
const entityValue = filterValue.find((value) => AVAILABLE_TOKEN_TYPES.includes(value.type));
const entityType = getEntityTypeFromType(entityValue?.type);
const filterData = entityValue?.value.data;
return {
const params = {
created_after: startDate ? pikadayToString(startDate) : null,
created_before: endDate ? pikadayToString(endDate) : null,
sort: sortBy,
entity_id: entityValue?.value.data,
entity_type: getEntityTypeFromType(entityValue?.type),
entity_type: entityType,
entity_id: null,
entity_username: null,
author_username: null,
// When changing the search parameters, we should be resetting to the first page
page: null,
};
switch (entityType) {
case ENTITY_TYPES.USER:
params.entity_username = parseUsername(filterData);
break;
case ENTITY_TYPES.AUTHOR:
params.author_username = parseUsername(filterData);
break;
default:
params.entity_id = filterData;
}
return params;
};
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AuditFilterToken when initialized with a value matches the snapshot 1`] = `
<div
config="[object Object]"
fetchitem="function () {
return fn.apply(this, arguments);
}"
fetchsuggestions="function () {
return fn.apply(this, arguments);
}"
getitemname="function () {
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 () {
return fn.apply(this, arguments);
}"
fetchsuggestions="function () {
return fn.apply(this, arguments);
}"
getitemname="function () {
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 { shallowMount } from '@vue/test-utils';
import GroupToken from 'ee/audit_events/components/tokens/group_token.vue';
import AuditFilterToken from 'ee/audit_events/components/tokens/shared/audit_filter_token.vue';
import Api from '~/api';
import { isValidEntityId } from 'ee/audit_events/token_utils';
jest.mock('~/api.js', () => ({
group: jest.fn().mockResolvedValue({ id: 1 }),
groups: jest.fn().mockResolvedValue([{ id: 1 }, { id: 2 }]),
}));
jest.mock('ee/audit_events/token_utils', () => ({
isValidEntityId: jest.fn().mockReturnValue(true),
}));
describe('GroupToken', () => {
let wrapper;
const value = { data: 123 };
const config = { type: 'foo' };
const findAuditFilterToken = () => wrapper.find(AuditFilterToken);
const initComponent = () => {
wrapper = shallowMount(GroupToken, {
propsData: {
value,
config,
active: false,
},
stubs: { AuditFilterToken },
});
};
beforeEach(() => {
initComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('binds to value, config and token methods to the filter token', () => {
expect(findAuditFilterToken().props()).toMatchObject({
value,
config,
...wrapper.vm.$options.tokenMethods,
});
});
describe('tokenMethods', () => {
it('fetchItem', async () => {
const subject = wrapper.vm.$options.tokenMethods.fetchItem;
const term = 'term';
const result = await subject(term);
expect(result).toEqual({ id: 1 });
expect(Api.group).toHaveBeenCalledWith(term);
});
it('fetchSuggestions', async () => {
const subject = wrapper.vm.$options.tokenMethods.fetchSuggestions;
const term = 'term';
const result = await subject(term);
expect(result).toEqual([{ id: 1 }, { id: 2 }]);
expect(Api.groups).toHaveBeenCalledWith(term);
});
it('getItemName', () => {
const subject = wrapper.vm.$options.tokenMethods.getItemName;
expect(subject({ full_name: 'foo' })).toBe('foo');
});
it('getSuggestionValue', () => {
const subject = wrapper.vm.$options.tokenMethods.getSuggestionValue;
const id = 123;
expect(subject({ id })).toBe('123');
});
it('isValidIdentifier', () => {
const subject = wrapper.vm.$options.tokenMethods.isValidIdentifier;
expect(subject('foo')).toBe(true);
expect(isValidEntityId).toHaveBeenCalledWith('foo');
});
it('findActiveItem', () => {
const subject = wrapper.vm.$options.tokenMethods.findActiveItem;
const suggestions = [
{ id: 1, username: 'foo' },
{ id: 2, username: 'bar' },
];
expect(subject(suggestions, 1)).toBe(suggestions[0]);
});
});
});
import { shallowMount } from '@vue/test-utils';
import MemberToken from 'ee/audit_events/components/tokens/member_token.vue';
import AuditFilterToken from 'ee/audit_events/components/tokens/shared/audit_filter_token.vue';
import Api from '~/api';
import { getUsers } from '~/rest_api';
import { displayUsername, isValidUsername } from 'ee/audit_events/token_utils';
jest.mock('~/api.js', () => ({
groupMembers: jest.fn().mockResolvedValue({ data: ['foo'] }),
projectUsers: jest.fn().mockResolvedValue(['bar']),
}));
jest.mock('~/rest_api', () => ({
getUsers: jest.fn().mockResolvedValue({
data: [{ id: 1, name: 'user' }],
}),
}));
jest.mock('ee/audit_events/token_utils', () => ({
parseUsername: jest.requireActual('ee/audit_events/token_utils').parseUsername,
displayUsername: jest.fn().mockImplementation((val) => val),
isValidUsername: jest.fn().mockReturnValue(true),
}));
describe('MemberToken', () => {
let wrapper;
const value = { data: 123 };
const config = { type: 'foo', groupId: 123 };
const findAuditFilterToken = () => wrapper.find(AuditFilterToken);
const initComponent = () => {
wrapper = shallowMount(MemberToken, {
propsData: {
value,
config,
active: false,
},
stubs: { AuditFilterToken },
});
};
beforeEach(() => {
initComponent();
});
afterEach(() => {
wrapper.destroy();
Api.groupMembers.mockClear();
Api.projectUsers.mockClear();
});
it('binds to value, config and token methods to the filter token', () => {
expect(findAuditFilterToken().props()).toMatchObject({
value,
config,
...wrapper.vm.$options.tokenMethods,
});
});
describe('tokenMethods', () => {
it('fetchItem', async () => {
const subject = wrapper.vm.$options.tokenMethods.fetchItem;
const username = 'term';
const result = await subject(username);
expect(result).toEqual({ id: 1, name: 'user' });
expect(getUsers).toHaveBeenCalledWith('', { username, per_page: 1 });
});
it('fetchSuggestions - on group level', async () => {
const context = { config: { groupId: 999 } };
const subject = wrapper.vm.$options.tokenMethods.fetchSuggestions;
const username = 'term';
const result = await subject.call(context, username);
expect(result).toEqual(['foo']);
expect(Api.groupMembers).toHaveBeenCalledWith(999, { query: username });
});
it('fetchSuggestions - on project level', async () => {
const context = { config: { projectPath: 'path' } };
const subject = wrapper.vm.$options.tokenMethods.fetchSuggestions;
const username = 'term';
const result = await subject.call(context, username);
expect(result).toEqual(['bar']);
expect(Api.projectUsers).toHaveBeenCalledWith('path', username);
});
it('getItemName', () => {
const subject = wrapper.vm.$options.tokenMethods.getItemName;
const name = 'foo';
expect(subject({ name })).toBe(name);
});
it('getSuggestionValue', () => {
const subject = wrapper.vm.$options.tokenMethods.getSuggestionValue;
const username = 'foo';
expect(subject({ username })).toBe(username);
expect(displayUsername).toHaveBeenCalledWith(username);
});
it('isValidIdentifier', () => {
const subject = wrapper.vm.$options.tokenMethods.isValidIdentifier;
expect(subject('foo')).toBe(true);
expect(isValidUsername).toHaveBeenCalledWith('foo');
});
it('findActiveItem', () => {
const subject = wrapper.vm.$options.tokenMethods.findActiveItem;
const suggestions = [
{ id: 1, username: 'foo' },
{ id: 2, username: 'bar' },
];
expect(subject(suggestions, 'foo')).toBe(suggestions[0]);
});
});
});
import { shallowMount } from '@vue/test-utils';
import ProjectToken from 'ee/audit_events/components/tokens/project_token.vue';
import AuditFilterToken from 'ee/audit_events/components/tokens/shared/audit_filter_token.vue';
import Api from '~/api';
import { isValidEntityId } from 'ee/audit_events/token_utils';
jest.mock('~/api.js', () => ({
project: jest.fn().mockResolvedValue({ data: { id: 1 } }),
projects: jest.fn().mockResolvedValue({ data: [{ id: 1 }, { id: 2 }] }),
}));
jest.mock('ee/audit_events/token_utils', () => ({
isValidEntityId: jest.fn().mockReturnValue(true),
}));
describe('ProjectToken', () => {
let wrapper;
const value = { data: 123 };
const config = { type: 'foo' };
const findAuditFilterToken = () => wrapper.find(AuditFilterToken);
const initComponent = () => {
wrapper = shallowMount(ProjectToken, {
propsData: {
value,
config,
active: false,
},
stubs: { AuditFilterToken },
});
};
beforeEach(() => {
initComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('binds to value, config and token methods to the filter token', () => {
expect(findAuditFilterToken().props()).toMatchObject({
value,
config,
...wrapper.vm.$options.tokenMethods,
});
});
describe('tokenMethods', () => {
it('fetchItem', async () => {
const subject = wrapper.vm.$options.tokenMethods.fetchItem;
const id = 123;
const result = await subject(id);
expect(result).toEqual({ id: 1 });
expect(Api.project).toHaveBeenCalledWith(id);
});
it('fetchSuggestions', async () => {
const subject = wrapper.vm.$options.tokenMethods.fetchSuggestions;
const term = 'term';
const result = await subject(term);
expect(result).toEqual([{ id: 1 }, { id: 2 }]);
expect(Api.projects).toHaveBeenCalledWith(term, { membership: false });
});
it('getItemName', () => {
const subject = wrapper.vm.$options.tokenMethods.getItemName;
expect(subject({ name: 'foo' })).toBe('foo');
});
it('getSuggestionValue', () => {
const subject = wrapper.vm.$options.tokenMethods.getSuggestionValue;
const id = 123;
expect(subject({ id })).toBe('123');
});
it('isValidIdentifier', () => {
const subject = wrapper.vm.$options.tokenMethods.isValidIdentifier;
expect(subject('foo')).toBe(true);
expect(isValidEntityId).toHaveBeenCalledWith('foo');
});
it('findActiveItem', () => {
const subject = wrapper.vm.$options.tokenMethods.findActiveItem;
const suggestions = [
{ id: 1, username: 'foo' },
{ id: 2, username: 'bar' },
];
expect(subject(suggestions, 1)).toBe(suggestions[0]);
});
});
});
import { GlLoadingIcon } from '@gitlab/ui';
import { GlLoadingIcon, GlAvatar } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import AuditFilterToken from 'ee/audit_events/components/tokens/shared/audit_filter_token.vue';
import createFlash from '~/flash';
import httpStatusCodes from '~/lib/utils/http_status';
......@@ -8,45 +9,56 @@ jest.mock('~/flash');
describe('AuditFilterToken', () => {
let wrapper;
const item = { name: 'An item name' };
const suggestions = [
const mockItem = { id: 777, name: 'An item name', avatar_url: 'http://item' };
const mockSuggestions = [
{
id: 1,
id: 888,
name: 'A suggestion name',
avatar_url: 'www',
avatar_url: 'http://suggestion',
full_name: 'Full name',
},
];
const mockResponseFailed = { response: { status: httpStatusCodes.NOT_FOUND } };
const mockFetchLoading = () => new Promise((resolve) => resolve);
const findFilteredSearchSuggestions = () => wrapper.findAllByTestId('audit-filter-suggestion');
const findFilteredSearchToken = () => wrapper.find('#filtered-search-token');
const findItemAvatar = () => wrapper.findByTestId('audit-filter-item-avatar');
const findLoadingIcon = (type) => wrapper.find(type).find(GlLoadingIcon);
const findViewLoadingIcon = () => findLoadingIcon('.view');
const findSuggestionsLoadingIcon = () => findLoadingIcon('.suggestions');
const tokenMethods = {
fetchItem: jest.fn().mockResolvedValue(item),
fetchSuggestions: jest.fn().mockResolvedValue(suggestions),
getItemName: jest.fn(),
fetchItem: jest.fn().mockResolvedValue(mockItem),
fetchSuggestions: jest.fn().mockResolvedValue(mockSuggestions),
getItemName: jest.fn().mockImplementation((item) => item.name),
findActiveItem: jest.fn(),
getSuggestionValue: jest.fn().mockImplementation((item) => item.id),
isValidIdentifier: jest.fn().mockImplementation((id) => Boolean(id)),
};
const initComponent = (props = {}) => {
wrapper = shallowMount(AuditFilterToken, {
propsData: {
value: {},
config: {
type: 'foo',
wrapper = extendedWrapper(
shallowMount(AuditFilterToken, {
propsData: {
value: {},
config: {
type: 'foo_bar',
},
active: false,
...tokenMethods,
...props,
},
active: false,
...tokenMethods,
...props,
},
stubs: {
GlFilteredSearchToken: {
template: `<div id="filtered-search-token">
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(() => {
......@@ -54,101 +66,77 @@ describe('AuditFilterToken', () => {
wrapper = null;
});
describe('when initialized', () => {
it('passes the config correctly', () => {
const config = {
icon: 'user',
type: 'user',
title: 'User',
unique: true,
};
initComponent({ config });
it('passes the config correctly', () => {
const config = {
icon: 'user',
type: 'user',
title: 'User',
unique: true,
};
initComponent({ config });
expect(findFilteredSearchToken().props('config')).toEqual(config);
});
expect(findFilteredSearchToken().props('config')).toEqual(config);
});
it('passes the value correctly', () => {
const value = { data: 1 };
describe('when initialized with a value', () => {
const value = { data: 999 };
beforeEach(() => {
initComponent({ value });
});
it('passes the value correctly', () => {
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('checks if the token has a valid identifier', () => {
expect(tokenMethods.isValidIdentifier).toHaveBeenCalledWith(value.data);
});
it('fetches an item to display', () => {
expect(tokenMethods.fetchItem).toHaveBeenCalledWith(value.data);
});
it('fetches an item to display', () => {
expect(tokenMethods.fetchItem).toHaveBeenCalledWith(value.data);
});
describe('without a value', () => {
describe('when fetching an item', () => {
beforeEach(() => {
initComponent();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
initComponent({ value, fetchItem: mockFetchLoading });
});
it('fetches suggestions to display', () => {
expect(tokenMethods.fetchSuggestions).toHaveBeenCalled();
});
});
});
describe('when fetching suggestions', () => {
let resolveSuggestions;
let rejectSuggestions;
const fetchSuggestions = () =>
new Promise((resolve, reject) => {
resolveSuggestions = resolve;
rejectSuggestions = reject;
it('shows only the view loading icon', () => {
expect(findViewLoadingIcon().exists()).toBe(true);
expect(findSuggestionsLoadingIcon().exists()).toBe(false);
});
beforeEach(() => {
const value = { data: '' };
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', () => {
describe('when fetching the item succeeded', () => {
beforeEach(() => {
resolveSuggestions(suggestions);
const fetchItem = jest.fn().mockResolvedValue(mockItem);
initComponent({ value, fetchItem });
});
it('does not show the suggestions loading icon', () => {
expect(findLoadingIcon('.suggestions').exists()).toBe(false);
it('does not show the view loading icon', () => {
expect(findViewLoadingIcon().exists()).toBe(false);
});
});
describe('and the fetch fails', () => {
beforeEach(() => {
rejectSuggestions({ response: { status: httpStatusCodes.NOT_FOUND } });
it('renders the active item avatar', () => {
expect(findItemAvatar().props()).toMatchObject({
alt: `${mockItem.name}'s avatar`,
entityId: mockItem.id,
src: mockItem.avatar_url,
size: 16,
});
});
it('shows a flash error message', () => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Failed to find foo. Please search for another foo.',
});
it('sets the suggestions to the fetched item', () => {
expect(findFilteredSearchSuggestions()).toHaveLength(1);
expect(findFilteredSearchSuggestions().at(0).props('value')).toBe(mockItem.id);
});
});
describe('and the fetch fails with a multi-word type', () => {
describe('when fetching the item failed', () => {
beforeEach(() => {
initComponent({ config: { type: 'foo_bar' }, fetchSuggestions });
rejectSuggestions({ response: { status: httpStatusCodes.NOT_FOUND } });
const fetchItem = jest.fn().mockRejectedValue(mockResponseFailed);
initComponent({ value, fetchItem });
});
it('shows a flash error message', () => {
......@@ -159,42 +147,67 @@ describe('AuditFilterToken', () => {
});
});
describe('when fetching the view item', () => {
let resolveItem;
let rejectItem;
describe('when initialized without a value', () => {
beforeEach(() => {
const value = { data: 1 };
const fetchItem = () =>
new Promise((resolve, reject) => {
resolveItem = resolve;
rejectItem = reject;
});
initComponent({ value, fetchItem });
initComponent();
});
it('shows the view loading icon', () => {
expect(findLoadingIcon('.view').exists()).toBe(true);
expect(findLoadingIcon('.suggestions').exists()).toBe(false);
it('fetches suggestions to display', () => {
expect(tokenMethods.fetchSuggestions).toHaveBeenCalled();
});
describe('and the fetch succeeds', () => {
describe('when fetching suggestions', () => {
beforeEach(() => {
resolveItem(item);
initComponent({ fetchSuggestions: mockFetchLoading });
});
it('does not show the view loading icon', () => {
expect(findLoadingIcon('.view').exists()).toBe(false);
it('shows only the suggestions loading icon', () => {
expect(findSuggestionsLoadingIcon().exists()).toBe(true);
expect(findViewLoadingIcon().exists()).toBe(false);
});
});
describe('and the fetch fails', () => {
describe('when fetching the suggestions succeeded', () => {
beforeEach(() => {
rejectItem({ response: { status: httpStatusCodes.NOT_FOUND } });
const fetchSuggestions = jest.fn().mockResolvedValue(mockSuggestions);
initComponent({ fetchSuggestions });
});
it('does not show the suggestions loading icon', () => {
expect(findSuggestionsLoadingIcon().exists()).toBe(false);
});
it('gets the suggestion value', () => {
expect(tokenMethods.getSuggestionValue).toHaveBeenCalled();
});
it('renders the suggestions', () => {
expect(findFilteredSearchSuggestions()).toHaveLength(mockSuggestions.length);
});
it('renders an avatar for each suggestion', () => {
mockSuggestions.forEach((suggestion, index) => {
const avatar = findFilteredSearchSuggestions().at(index).find(GlAvatar);
expect(avatar.props()).toMatchObject({
alt: `${suggestion.name}'s avatar`,
entityId: suggestion.id,
src: suggestion.avatar_url,
size: 32,
});
});
});
});
describe('when fetching the suggestions failed', () => {
beforeEach(() => {
const fetchSuggestions = jest.fn().mockRejectedValue(mockResponseFailed);
initComponent({ fetchSuggestions });
});
it('shows a flash error message', () => {
expect(createFlash).toHaveBeenCalledWith({
message: 'Failed to find foo. Please search for another foo.',
message: 'Failed to find foo bar. Please search for another foo bar.',
});
});
});
......@@ -202,25 +215,24 @@ describe('AuditFilterToken', () => {
describe('when no suggestion could be found', () => {
beforeEach(() => {
const value = { data: '' };
const fetchSuggestions = jest.fn().mockResolvedValue([]);
initComponent({ value, fetchSuggestions });
initComponent({ fetchSuggestions });
});
it('renders an empty message', () => {
expect(wrapper.text()).toBe('No matching foo found.');
expect(wrapper.text()).toBe('No matching foo bar found.');
});
});
describe('when a view item could not be found', () => {
beforeEach(() => {
const value = { data: 1 };
const fetchItem = jest.fn().mockResolvedValue({});
const fetchItem = jest.fn().mockResolvedValue(undefined);
initComponent({ value, fetchItem });
});
it('renders an empty message', () => {
expect(wrapper.text()).toBe('No matching foo found.');
it('does not render an item avatar', () => {
expect(findItemAvatar().exists()).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import UserToken from 'ee/audit_events/components/tokens/user_token.vue';
import AuditFilterToken from 'ee/audit_events/components/tokens/shared/audit_filter_token.vue';
import { getUsers } from '~/rest_api';
import { displayUsername, isValidUsername } from 'ee/audit_events/token_utils';
jest.mock('~/rest_api', () => ({
getUsers: jest.fn().mockResolvedValue({
data: [{ id: 1, name: 'user' }],
}),
}));
jest.mock('ee/audit_events/token_utils', () => ({
parseUsername: jest.requireActual('ee/audit_events/token_utils').parseUsername,
displayUsername: jest.fn().mockImplementation((val) => val),
isValidUsername: jest.fn().mockReturnValue(true),
}));
describe('UserToken', () => {
let wrapper;
const value = { data: 123 };
const config = { type: 'foo' };
const findAuditFilterToken = () => wrapper.find(AuditFilterToken);
const initComponent = () => {
wrapper = shallowMount(UserToken, {
propsData: {
value,
config,
active: false,
},
stubs: { AuditFilterToken },
});
};
beforeEach(() => {
initComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('binds to value, config and token methods to the filter token', () => {
expect(findAuditFilterToken().props()).toMatchObject({
value,
config,
...wrapper.vm.$options.tokenMethods,
});
});
describe('tokenMethods', () => {
it('fetchItem', async () => {
const subject = wrapper.vm.$options.tokenMethods.fetchItem;
const username = 'term';
const result = await subject(username);
expect(result).toEqual({ id: 1, name: 'user' });
expect(getUsers).toHaveBeenCalledWith('', { username, per_page: 1 });
});
it('fetchSuggestions', async () => {
const subject = wrapper.vm.$options.tokenMethods.fetchSuggestions;
const username = 'term';
const result = await subject(username);
expect(result).toEqual([{ id: 1, name: 'user' }]);
expect(getUsers).toHaveBeenCalledWith(username);
});
it('getItemName', () => {
const subject = wrapper.vm.$options.tokenMethods.getItemName;
const name = 'foo';
expect(subject({ name })).toBe(name);
});
it('getSuggestionValue', () => {
const subject = wrapper.vm.$options.tokenMethods.getSuggestionValue;
const username = 'foo';
expect(subject({ username })).toBe(username);
expect(displayUsername).toHaveBeenCalledWith(username);
});
it('isValidIdentifier', () => {
const subject = wrapper.vm.$options.tokenMethods.isValidIdentifier;
expect(subject('foo')).toBe(true);
expect(isValidUsername).toHaveBeenCalledWith('foo');
});
it('findActiveItem', () => {
const subject = wrapper.vm.$options.tokenMethods.findActiveItem;
const suggestions = [
{ id: 1, username: 'foo' },
{ id: 2, username: 'bar' },
];
expect(subject(suggestions, 'foo')).toBe(suggestions[0]);
});
});
});
......@@ -41,7 +41,7 @@ describe('Audit Event actions', () => {
);
it('setFilterValue action should commit to the store', () => {
const payload = [{ type: 'User', value: { data: 1, operator: '=' } }];
const payload = [{ type: 'User', value: { data: '@root', operator: '=' } }];
testAction(actions.setFilterValue, payload, state, [{ type: types.SET_FILTER_VALUE, payload }]);
});
......@@ -91,6 +91,9 @@ describe('Audit Event actions', () => {
payload: {
created_after: null,
created_before: null,
author_username: null,
entity_username: null,
entity_type: undefined,
},
},
]);
......@@ -100,7 +103,7 @@ describe('Audit Event actions', () => {
describe('with a full search query', () => {
beforeEach(() => {
setWindowLocation(
'?sort=created_desc&entity_type=User&entity_id=44&created_after=2020-06-05&created_before=2020-06-25',
'?sort=created_desc&entity_type=Project&entity_id=44&created_after=2020-06-05&created_before=2020-06-25',
);
});
......@@ -112,8 +115,10 @@ describe('Audit Event actions', () => {
created_after: new Date('2020-06-05T00:00:00.000Z'),
created_before: new Date('2020-06-25T00:00:00.000Z'),
entity_id: '44',
entity_type: 'user',
entity_type: 'project',
sort: 'created_desc',
author_username: null,
entity_username: null,
},
},
]);
......
......@@ -17,7 +17,7 @@ describe('Audit Events getters', () => {
describe('with filters and dates', () => {
it('returns the export url', () => {
const filterValue = [{ type: 'user', value: { data: 1, operator: '=' } }];
const filterValue = [{ type: 'user', value: { data: '@root', operator: '=' } }];
const startDate = new Date(2020, 1, 2);
const endDate = new Date(2020, 1, 30);
const state = { ...createState, ...{ filterValue, startDate, endDate } };
......@@ -25,7 +25,7 @@ describe('Audit Events getters', () => {
expect(getters.buildExportHref(state)(exportUrl)).toEqual(
'https://example.com/audit_reports.csv?' +
'created_after=2020-02-02&created_before=2020-03-01' +
'&entity_id=1&entity_type=User',
'&entity_type=User&entity_username=root',
);
});
});
......
......@@ -38,9 +38,13 @@ describe('Audit Event mutations', () => {
sort: 'created_asc',
};
const createFilterValue = (data) => {
return [{ type: payload.entity_type, value: { data, operator: '=' } }];
};
it.each`
stateKey | expectedState
${'filterValue'} | ${[{ type: payload.entity_type, value: { data: payload.entity_id, operator: '=' } }]}
${'filterValue'} | ${createFilterValue(payload.entity_id)}
${'startDate'} | ${payload.created_after}
${'endDate'} | ${payload.created_before}
${'sortBy'} | ${payload.sort}
......@@ -50,5 +54,21 @@ describe('Audit Event mutations', () => {
expect(state[stateKey]).toEqual(expectedState);
});
it.each`
payloadKey | payloadValue
${'entity_id'} | ${'1'}
${'entity_username'} | ${'abc'}
${'author_username'} | ${'abc'}
`('sets the filter value when provided with a $payloadKey', ({ payloadKey, payloadValue }) => {
const payloadWithValue = {
...payload,
entity_id: undefined,
[payloadKey]: payloadValue,
};
mutations[types.INITIALIZE_AUDIT_EVENTS](state, payloadWithValue);
expect(state.filterValue).toEqual(createFilterValue(payloadValue));
});
});
});
import { MIN_USERNAME_LENGTH } from '~/lib/utils/constants';
import {
parseUsername,
displayUsername,
isValidUsername,
isValidEntityId,
} from 'ee/audit_events/token_utils';
describe('Audit Event Text Utils', () => {
describe('parseUsername', () => {
it('returns the username without the @ character', () => {
expect(parseUsername('@username')).toBe('username');
});
it('returns the username unchanged when it does not include a @ character', () => {
expect(parseUsername('username')).toBe('username');
});
});
describe('displayUsername', () => {
it('returns the username with the @ character', () => {
expect(displayUsername('username')).toBe('@username');
});
});
describe('isValidUsername', () => {
it('returns true if the username is valid', () => {
const username = 'a'.repeat(MIN_USERNAME_LENGTH);
expect(isValidUsername(username)).toBe(true);
});
it('returns false if the username is too short', () => {
const username = 'a'.repeat(MIN_USERNAME_LENGTH - 1);
expect(isValidUsername(username)).toBe(false);
});
it('returns false if the username is empty', () => {
const username = '';
expect(isValidUsername(username)).toBe(false);
});
});
describe('isValidEntityId', () => {
it('returns true if the entity id is a positive number', () => {
const id = 1;
expect(isValidEntityId(id)).toBe(true);
});
it('returns true if the entity id is a numeric string', () => {
const id = '123';
expect(isValidEntityId(id)).toBe(true);
});
it('returns false if the entity id is zero', () => {
const id = 0;
expect(isValidEntityId(id)).toBe(false);
});
it('returns false if the entity id is not numeric', () => {
const id = 'abc';
expect(isValidEntityId(id)).toBe(false);
});
});
});
......@@ -34,7 +34,7 @@ describe('Audit Event Utils', () => {
sortBy: 'created_asc',
};
expect(parseAuditEventSearchQuery(input)).toEqual({
expect(parseAuditEventSearchQuery(input)).toMatchObject({
created_after: new Date('2020-03-13'),
created_before: new Date('2020-04-13'),
sortBy: 'created_asc',
......@@ -43,22 +43,35 @@ describe('Audit Event Utils', () => {
});
describe('createAuditEventSearchQuery', () => {
it('returns a query object with remapped keys and stringified dates', () => {
const input = {
filterValue: [{ type: 'user', value: { data: '1', operator: '=' } }],
startDate: new Date('2020-03-13'),
endDate: new Date('2020-04-13'),
sortBy: 'bar',
};
expect(createAuditEventSearchQuery(input)).toEqual({
entity_id: '1',
entity_type: 'User',
created_after: '2020-03-13',
created_before: '2020-04-13',
sort: 'bar',
page: null,
});
const createFilterParams = (type, data) => ({
filterValue: [{ type, value: { data, operator: '=' } }],
startDate: new Date('2020-03-13'),
endDate: new Date('2020-04-13'),
sortBy: 'bar',
});
it.each`
type | entity_type | data | entity_id | entity_username | author_username
${'user'} | ${'User'} | ${'@root'} | ${null} | ${'root'} | ${null}
${'member'} | ${'Author'} | ${'@root'} | ${null} | ${null} | ${'root'}
${'project'} | ${'Project'} | ${'1'} | ${'1'} | ${null} | ${null}
${'group'} | ${'Group'} | ${'1'} | ${'1'} | ${null} | ${null}
`(
'returns a query object with remapped keys and stringified dates for type $type',
({ type, entity_type, data, entity_id, entity_username, author_username }) => {
const input = createFilterParams(type, data);
expect(createAuditEventSearchQuery(input)).toEqual({
entity_id,
entity_username,
author_username,
entity_type,
created_after: '2020-03-13',
created_before: '2020-04-13',
sort: 'bar',
page: null,
});
},
);
});
});
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