[Search] Remove Enterprise Search UI Apps (#205634)

## Summary

Removing app search & workplace search kibana applications from
`enterprise_search` plugin. This will be the first of many PRs to remove
code related to the enterprise search node.

### Checklist

- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Rodney Norris 2025-01-09 08:55:18 -06:00 committed by GitHub
parent 45f3241db0
commit 75fe22b604
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1296 changed files with 3 additions and 127531 deletions

View file

@ -146,8 +146,6 @@ export const applicationUsageSchema = {
enterpriseSearchVectorSearch: commonSchema,
enterpriseSearchElasticsearch: commonSchema,
entity_manager: commonSchema,
appSearch: commonSchema,
workplaceSearch: commonSchema,
searchExperiences: commonSchema,
graph: commonSchema,
logs: commonSchema,

View file

@ -3408,268 +3408,6 @@
}
}
},
"appSearch": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "Always `main`"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 90 days"
}
},
"views": {
"type": "array",
"items": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "The application view being tracked"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application sub view since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application sub view is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 90 days"
}
}
}
}
}
}
},
"workplaceSearch": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "Always `main`"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 90 days"
}
},
"views": {
"type": "array",
"items": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "The application view being tracked"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application sub view since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application sub view is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 90 days"
}
}
}
}
}
}
},
"searchExperiences": {
"properties": {
"appId": {

View file

@ -1,274 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import dedent from 'dedent';
import { ElasticsearchIndexWithPrivileges } from '../../../../common/types/indices';
import { EngineCreationSteps } from '../components/engine_creation/engine_creation_logic';
import { SearchIndexSelectableOption } from '../components/engine_creation/search_index_selectable';
export const DEFAULT_VALUES = {
ingestionMethod: '',
isLoading: false,
name: '',
rawName: '',
language: 'Universal',
isLoadingIndices: false,
indices: [],
indicesFormatted: [],
selectedIndex: '',
engineType: 'appSearch',
isSubmitDisabled: true,
aliasName: '',
aliasRawName: '',
isAliasAllowed: true,
isAliasRequired: false,
currentEngineCreationStep: EngineCreationSteps.SelectStep,
aliasNameErrorMessage: '',
showAliasNameErrorMessages: false,
selectedIndexFormatted: undefined,
};
export const mockElasticsearchIndices: ElasticsearchIndexWithPrivileges[] = [
{
count: 0,
health: 'yellow',
hidden: false,
status: 'open',
name: 'search-my-index-1',
uuid: 'ydlR_QQJTeyZP66tzQSmMQ',
alias: false,
privileges: { read: true, manage: true },
total: {
docs: {
count: 0,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
{
count: 100,
health: 'green',
hidden: false,
status: 'open',
name: 'my-index-2',
uuid: '4dlR_QQJTe2ZP6qtzQSmMQ',
alias: false,
privileges: { read: true, manage: true },
total: {
docs: {
count: 100,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
{
count: 100,
health: 'green',
hidden: false,
status: 'open',
name: 'search-my-index-2',
uuid: '4dlR_QQJTe2ZP6qtzQSmMQ',
alias: true,
privileges: { read: true, manage: true },
total: {
docs: {
count: 100,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
{
count: 100,
health: 'green',
hidden: false,
status: 'open',
name: 'alias-my-index-2',
uuid: '4dlR_QQJTe2ZP6qtzQSmMQ',
alias: true,
privileges: { read: true, manage: true },
total: {
docs: {
count: 100,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
{
count: 100,
health: 'green',
hidden: false,
status: 'open',
name: 'index-without-read-privilege',
uuid: '4dlR_QQJTe2ZP6qtzQSmMQ',
alias: false,
privileges: { read: false, manage: true },
total: {
docs: {
count: 100,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
{
count: 100,
health: 'green',
hidden: false,
status: 'open',
name: 'index-without-manage-privilege',
uuid: '4dlR_QQJTe2ZP6qtzQSmMQ',
alias: false,
privileges: { read: true, manage: false },
total: {
docs: {
count: 100,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
{
count: 100,
health: 'green',
hidden: false,
status: 'open',
name: 'alias-without-manage-privilege',
uuid: '4dlR_QQJTe2ZP6qtzQSmMQ',
alias: true,
privileges: { read: true, manage: false },
total: {
docs: {
count: 100,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
];
export const mockSearchIndexOptions: SearchIndexSelectableOption[] = [
{
count: 0,
label: 'search-my-index-1',
health: 'yellow',
status: 'open',
alias: false,
disabled: false,
badge: {
color: 'success',
label: 'Index',
toolTipTitle: 'Index name is compatible',
toolTipContent: dedent(`
You can directly use this index. You can also optionally create an
alias to use as the source of the engine instead.
`),
},
total: {
docs: {
count: 0,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
{
count: 100,
label: 'my-index-2',
health: 'green',
status: 'open',
alias: false,
disabled: false,
badge: {
color: 'warning',
label: 'Index',
icon: 'iInCircle',
toolTipTitle: 'Index name is incompatible',
toolTipContent: dedent(`
Enterprise Search will automatically create an alias to use as the
source of the search engine rather than use this index directly.
`),
},
total: {
docs: {
count: 100,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
{
count: 100,
label: 'search-my-index-2',
health: 'green',
status: 'open',
alias: true,
disabled: false,
badge: {
color: 'success',
label: 'Alias',
toolTipTitle: 'Alias is compatible',
toolTipContent: 'You can use this alias.',
},
total: {
docs: {
count: 100,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
{
count: 100,
label: 'alias-my-index-2',
health: 'green',
status: 'open',
alias: true,
disabled: true,
badge: {
color: 'danger',
label: 'Alias',
icon: 'warning',
toolTipTitle: 'Alias name is incompatible',
toolTipContent: 'You\'ll have to create a new alias prefixed with "search-".',
},
total: {
docs: {
count: 100,
deleted: 0,
},
store: {
size_in_bytes: '225b',
},
},
},
];

View file

@ -1,38 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EngineDetails } from '../components/engine/types';
import { ENGINES_TITLE } from '../components/engines/constants';
import { generateEncodedPath } from '../utils/encode_path_params';
export const mockEngineValues = {
engineName: 'some-engine',
engine: {} as EngineDetails,
searchKey: 'search-abc123',
};
export const mockEngineActions = {
initializeEngine: jest.fn(),
};
export const mockGenerateEnginePath = jest.fn((path, pathParams = {}) =>
generateEncodedPath(path, { engineName: mockEngineValues.engineName, ...pathParams })
);
export const mockGetEngineBreadcrumbs = jest.fn((breadcrumbs = []) => [
ENGINES_TITLE,
mockEngineValues.engineName,
...breadcrumbs,
]);
jest.mock('../components/engine', () => ({
EngineLogic: {
values: mockEngineValues,
actions: mockEngineActions,
},
generateEnginePath: mockGenerateEnginePath,
getEngineBreadcrumbs: mockGetEngineBreadcrumbs,
}));

View file

@ -1,26 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EngineTypes } from '../components/engine/types';
export const defaultEngine = {
id: 'e1',
name: 'engine1',
type: EngineTypes.default,
language: null,
result_fields: {},
};
export const indexedEngine = {
id: 'e2',
name: 'engine2',
type: EngineTypes.indexed,
language: null,
result_fields: {},
};
export const engines = [defaultEngine, indexedEngine];

View file

@ -1,9 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { mockEngineValues, mockEngineActions } from './engine_logic.mock';
export { mockRecursivelyFetchEngines, mockSourceEngines } from './recursively_fetch_engines.mock';

View file

@ -1,21 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EngineDetails } from '../components/engine/types';
export const mockSourceEngines = [
{ name: 'source-engine-1' },
{ name: 'source-engine-2' },
] as EngineDetails[];
export const mockRecursivelyFetchEngines = jest.fn(({ onComplete }) =>
onComplete(mockSourceEngines)
);
jest.mock('../utils/recursively_fetch_engines', () => ({
recursivelyFetchEngines: mockRecursivelyFetchEngines,
}));

View file

@ -1,99 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__';
import { LogicMounter } from '../__mocks__/kea_logic/logic_mounter.test_helper';
jest.mock('../shared/licensing', () => ({
LicensingLogic: { selectors: { hasPlatinumLicense: () => false } },
}));
import { AppLogic } from './app_logic';
describe('AppLogic', () => {
const { mount } = new LogicMounter(AppLogic);
beforeEach(() => {
jest.clearAllMocks();
});
const DEFAULT_VALUES = {
configuredLimits: {
engine: {
maxDocumentByteSize: 102400,
maxEnginesPerMetaEngine: 15,
},
},
account: {
accountId: 'some-id-string',
kibanaUIsEnabled: true,
onboardingComplete: true,
role: DEFAULT_INITIAL_APP_DATA.appSearch.role,
},
myRole: {},
showGateForm: false,
};
it('sets values from props', () => {
mount({}, DEFAULT_INITIAL_APP_DATA);
expect(AppLogic.values).toEqual({
configuredLimits: {
engine: {
maxDocumentByteSize: 102400,
maxEnginesPerMetaEngine: 15,
},
},
account: {
accountId: 'some-id-string',
kibanaUIsEnabled: true,
onboardingComplete: true,
role: DEFAULT_INITIAL_APP_DATA.appSearch.role,
},
myRole: expect.objectContaining({
id: 'account_id:somestring|user_oid:somestring',
roleType: 'owner',
availableRoleTypes: ['owner', 'admin'],
credentialTypes: ['admin', 'private', 'search'],
canAccessAllEngines: true,
canViewAccountCredentials: true,
// Truncated for brevity - see utils/role/index.test.ts for full output
}),
showGateForm: false,
});
});
describe('actions', () => {
describe('setOnboardingComplete()', () => {
it('sets true', () => {
mount({}, { ...DEFAULT_INITIAL_APP_DATA, appSearch: { onboardingComplete: false } });
AppLogic.actions.setOnboardingComplete();
expect(AppLogic.values).toEqual({
...DEFAULT_VALUES,
account: {
onboardingComplete: true,
},
});
});
});
});
describe('selectors', () => {
describe('myRole', () => {
it('falls back to an empty object if role is missing', () => {
mount({}, { ...DEFAULT_INITIAL_APP_DATA, appSearch: {} });
expect(AppLogic.values).toEqual({
...DEFAULT_VALUES,
account: {},
myRole: {},
});
});
});
});
});

View file

@ -1,57 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { InitialAppData } from '../../../common/types';
import { LicensingLogic } from '../shared/licensing';
import { ConfiguredLimits, Account, Role } from './types';
import { getRoleAbilities } from './utils/role';
interface AppValues {
account: Account;
showGateForm: boolean;
configuredLimits: ConfiguredLimits;
myRole: Role;
}
interface AppActions {
setOnboardingComplete(): boolean;
}
export const AppLogic = kea<MakeLogicType<AppValues, AppActions, Required<InitialAppData>>>({
path: ['enterprise_search', 'app_search', 'app_logic'],
actions: {
setOnboardingComplete: () => true,
},
reducers: ({ props }) => ({
account: [
props.appSearch,
{
// @ts-expect-error upgrade typescript v5.1.6
setOnboardingComplete: (account) => ({
...account,
onboardingComplete: true,
}),
},
],
configuredLimits: [props.configuredLimits.appSearch, {}],
showGateForm: [
props.appSearch.kibanaUIsEnabled === false && props.appSearch.role.roleType === 'owner',
{},
],
}),
selectors: {
myRole: [
(selectors) => [selectors.account, LicensingLogic.selectors.hasPlatinumLicense],
({ role }) => (role ? getRoleAbilities(role) : {}),
],
},
});

View file

@ -1,94 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import '../../../__mocks__/shallow_useeffect.mock';
import { mockKibanaValues, setMockValues, setMockActions } from '../../../__mocks__/kea_logic';
import { mockUseParams } from '../../../__mocks__/react_router';
import '../../__mocks__/engine_logic.mock';
import React from 'react';
import { shallow } from 'enzyme';
import {
rerender,
getPageTitle,
getPageHeaderActions,
getPageHeaderChildren,
} from '../../../test_helpers';
import { LogRetentionTooltip, LogRetentionCallout } from '../log_retention';
import { AnalyticsLayout } from './analytics_layout';
import { AnalyticsFilters } from './components';
describe('AnalyticsLayout', () => {
const { history } = mockKibanaValues;
const values = {
history,
dataLoading: false,
};
const actions = {
loadAnalyticsData: jest.fn(),
loadQueryData: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
history.location.search = '';
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
const wrapper = shallow(
<AnalyticsLayout title="Hello">
<div data-test-subj="world">World!</div>
</AnalyticsLayout>
);
expect(wrapper.find(LogRetentionCallout)).toHaveLength(1);
expect(getPageHeaderActions(wrapper).find(LogRetentionTooltip)).toHaveLength(1);
expect(getPageHeaderChildren(wrapper).find(AnalyticsFilters)).toHaveLength(1);
expect(getPageTitle(wrapper)).toEqual('Hello');
expect(wrapper.find('[data-test-subj="world"]').text()).toEqual('World!');
expect(wrapper.prop('pageChrome')).toEqual(['Engines', 'some-engine', 'Analytics']);
});
it('passes analytics breadcrumbs', () => {
const wrapper = shallow(<AnalyticsLayout title="Some page" breadcrumbs={['Queries']} />);
expect(wrapper.prop('pageChrome')).toEqual(['Engines', 'some-engine', 'Analytics', 'Queries']);
});
describe('data loading', () => {
it('loads query data for query details pages', () => {
mockUseParams.mockReturnValueOnce({ query: 'test' });
shallow(<AnalyticsLayout isQueryView title="" />);
expect(actions.loadQueryData).toHaveBeenCalledWith('test');
});
it('loads analytics data for non query details pages', () => {
shallow(<AnalyticsLayout isAnalyticsView title="" />);
expect(actions.loadAnalyticsData).toHaveBeenCalled();
});
it('reloads data when search params are updated (by our AnalyticsHeader filters)', () => {
const wrapper = shallow(<AnalyticsLayout isAnalyticsView title="" />);
expect(actions.loadAnalyticsData).toHaveBeenCalledTimes(1);
history.location.search = '?tag=some-filter';
rerender(wrapper);
expect(actions.loadAnalyticsData).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -1,65 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, FC, PropsWithChildren } from 'react';
import { useParams } from 'react-router-dom';
import { useValues, useActions } from 'kea';
import { KibanaLogic } from '../../../shared/kibana';
import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs';
import { getEngineBreadcrumbs } from '../engine';
import { AppSearchPageTemplate } from '../layout';
import { LogRetentionTooltip, LogRetentionCallout, LogRetentionOptions } from '../log_retention';
import { AnalyticsFilters } from './components';
import { ANALYTICS_TITLE } from './constants';
import { AnalyticsLogic } from '.';
interface Props {
title: string;
breadcrumbs?: BreadcrumbTrail;
isQueryView?: boolean;
isAnalyticsView?: boolean;
}
export const AnalyticsLayout: FC<PropsWithChildren<Props>> = ({
title,
breadcrumbs = [],
isQueryView,
isAnalyticsView,
children,
}) => {
const { history } = useValues(KibanaLogic);
const { query } = useParams() as { query: string };
const { dataLoading } = useValues(AnalyticsLogic);
const { loadAnalyticsData, loadQueryData } = useActions(AnalyticsLogic);
useEffect(() => {
if (isQueryView) loadQueryData(query);
if (isAnalyticsView) loadAnalyticsData();
}, [history.location.search]);
return (
<AppSearchPageTemplate
pageChrome={getEngineBreadcrumbs([ANALYTICS_TITLE, ...breadcrumbs])}
pageHeader={{
pageTitle: title,
rightSideItems: [
<LogRetentionTooltip type={LogRetentionOptions.Analytics} position="left" />,
],
children: <AnalyticsFilters />,
responsive: false,
}}
isLoading={dataLoading}
>
<LogRetentionCallout type={LogRetentionOptions.Analytics} />
{children}
</AppSearchPageTemplate>
);
};

View file

@ -1,259 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { LogicMounter, mockKibanaValues, mockHttpValues } from '../../../__mocks__/kea_logic';
jest.mock('../engine', () => ({
EngineLogic: { values: { engineName: 'test-engine' } },
}));
import { nextTick } from '@kbn/test-jest-helpers';
import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers';
import { DEFAULT_START_DATE, DEFAULT_END_DATE } from './constants';
import { AnalyticsLogic } from '.';
describe('AnalyticsLogic', () => {
const { mount } = new LogicMounter(AnalyticsLogic);
const { history } = mockKibanaValues;
const { http } = mockHttpValues;
const DEFAULT_VALUES = {
dataLoading: true,
allTags: [],
recentQueries: [],
topQueries: [],
topQueriesNoResults: [],
topQueriesNoClicks: [],
topQueriesWithClicks: [],
totalQueries: 0,
totalQueriesNoResults: 0,
totalClicks: 0,
totalQueriesForQuery: 0,
queriesPerDay: [],
queriesNoResultsPerDay: [],
clicksPerDay: [],
queriesPerDayForQuery: [],
topClicksForQuery: [],
startDate: '',
};
const MOCK_TOP_QUERIES = [
{
doc_count: 5,
key: 'some-key',
},
{
doc_count: 0,
key: 'another-key',
},
];
const MOCK_RECENT_QUERIES = [
{
document_ids: ['1', '2'],
query_string: 'some-query',
tags: ['some-tag'],
timestamp: 'some-timestamp',
},
];
const MOCK_TOP_CLICKS = [
{
key: 'highly-clicked-query',
doc_count: 1,
document: {
id: 'some-id',
engine: 'some-engine',
tags: [],
},
clicks: {
doc_count: 100,
},
},
];
const MOCK_ANALYTICS_RESPONSE = {
allTags: ['some-tag'],
startDate: '1970-01-01',
recentQueries: MOCK_RECENT_QUERIES,
topQueries: MOCK_TOP_QUERIES,
topQueriesNoResults: MOCK_TOP_QUERIES,
topQueriesNoClicks: MOCK_TOP_QUERIES,
topQueriesWithClicks: MOCK_TOP_QUERIES,
totalClicks: 1000,
totalQueries: 5000,
totalQueriesNoResults: 500,
clicksPerDay: [0, 10, 50],
queriesPerDay: [10, 50, 100],
queriesNoResultsPerDay: [1, 2, 3],
};
const MOCK_QUERY_RESPONSE = {
allTags: ['some-tag'],
startDate: '1970-01-01',
totalQueriesForQuery: 50,
queriesPerDayForQuery: [25, 0, 25],
topClicksForQuery: MOCK_TOP_CLICKS,
};
beforeEach(() => {
jest.clearAllMocks();
history.location.search = '';
});
it('has expected default values', () => {
mount();
expect(AnalyticsLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('onAnalyticsDataLoad', () => {
it('should set state', () => {
mount();
AnalyticsLogic.actions.onAnalyticsDataLoad(MOCK_ANALYTICS_RESPONSE);
expect(AnalyticsLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: false,
...MOCK_ANALYTICS_RESPONSE,
});
});
});
describe('onQueryDataLoad', () => {
it('should set state', () => {
mount();
AnalyticsLogic.actions.onQueryDataLoad(MOCK_QUERY_RESPONSE);
expect(AnalyticsLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: false,
...MOCK_QUERY_RESPONSE,
});
});
});
});
describe('listeners', () => {
describe('loadAnalyticsData', () => {
it('should set state', () => {
mount({ dataLoading: false });
AnalyticsLogic.actions.loadAnalyticsData();
expect(AnalyticsLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: true,
});
});
it('should make an API call and set state based on the response', async () => {
http.get.mockReturnValueOnce(Promise.resolve(MOCK_ANALYTICS_RESPONSE));
mount();
jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsDataLoad');
AnalyticsLogic.actions.loadAnalyticsData();
await nextTick();
expect(http.get).toHaveBeenCalledWith(
'/internal/app_search/engines/test-engine/analytics/queries',
{
query: {
start: DEFAULT_START_DATE,
end: DEFAULT_END_DATE,
size: 20,
},
}
);
expect(AnalyticsLogic.actions.onAnalyticsDataLoad).toHaveBeenCalledWith(
MOCK_ANALYTICS_RESPONSE
);
});
it('parses and passes the current search query string', async () => {
(http.get as jest.Mock).mockReturnValueOnce({});
history.location.search = '?start=1970-01-01&end=1970-01-02&&tag=some_tag';
mount();
AnalyticsLogic.actions.loadAnalyticsData();
expect(http.get).toHaveBeenCalledWith(
'/internal/app_search/engines/test-engine/analytics/queries',
{
query: {
start: '1970-01-01',
end: '1970-01-02',
tag: 'some_tag',
size: 20,
},
}
);
});
itShowsServerErrorAsFlashMessage(http.get, () => {
mount();
AnalyticsLogic.actions.loadAnalyticsData();
});
});
describe('loadQueryData', () => {
it('should set state', () => {
mount({ dataLoading: false });
AnalyticsLogic.actions.loadQueryData('some-query');
expect(AnalyticsLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: true,
});
});
it('should make an API call and set state based on the response', async () => {
http.get.mockReturnValueOnce(Promise.resolve(MOCK_QUERY_RESPONSE));
mount();
jest.spyOn(AnalyticsLogic.actions, 'onQueryDataLoad');
AnalyticsLogic.actions.loadQueryData('some-query');
await nextTick();
expect(http.get).toHaveBeenCalledWith(
'/internal/app_search/engines/test-engine/analytics/queries/some-query',
{
query: {
start: DEFAULT_START_DATE,
end: DEFAULT_END_DATE,
},
}
);
expect(AnalyticsLogic.actions.onQueryDataLoad).toHaveBeenCalledWith(MOCK_QUERY_RESPONSE);
});
it('parses and passes the current search query string', async () => {
(http.get as jest.Mock).mockReturnValueOnce({});
history.location.search = '?start=1970-12-30&end=1970-12-31&&tag=another_tag';
mount();
AnalyticsLogic.actions.loadQueryData('some-query');
expect(http.get).toHaveBeenCalledWith(
'/internal/app_search/engines/test-engine/analytics/queries/some-query',
{
query: {
start: '1970-12-30',
end: '1970-12-31',
tag: 'another_tag',
},
}
);
});
itShowsServerErrorAsFlashMessage(http.get, () => {
mount();
AnalyticsLogic.actions.loadQueryData('some-query');
});
});
});
});

View file

@ -1,209 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import queryString from 'query-string';
import { flashAPIErrors } from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { KibanaLogic } from '../../../shared/kibana';
import { EngineLogic } from '../engine';
import { DEFAULT_START_DATE, DEFAULT_END_DATE } from './constants';
import { AnalyticsData, QueryDetails } from './types';
interface AnalyticsValues extends AnalyticsData, QueryDetails {
dataLoading: boolean;
}
interface AnalyticsActions {
onAnalyticsDataLoad(data: AnalyticsData): AnalyticsData;
onQueryDataLoad(data: QueryDetails): QueryDetails;
loadAnalyticsData(): void;
loadQueryData(query: string): string;
}
export const AnalyticsLogic = kea<MakeLogicType<AnalyticsValues, AnalyticsActions>>({
path: ['enterprise_search', 'app_search', 'analytics_logic'],
actions: () => ({
onAnalyticsDataLoad: (data) => data,
onQueryDataLoad: (data) => data,
loadAnalyticsData: true,
loadQueryData: (query) => query,
}),
reducers: () => ({
dataLoading: [
true,
{
loadAnalyticsData: () => true,
loadQueryData: () => true,
onAnalyticsDataLoad: () => false,
onQueryDataLoad: () => false,
},
],
allTags: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { allTags }) => allTags,
// @ts-expect-error upgrade typescript v5.1.6
onQueryDataLoad: (_, { allTags }) => allTags,
},
],
recentQueries: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { recentQueries }) => recentQueries,
},
],
topQueries: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { topQueries }) => topQueries,
},
],
topQueriesNoResults: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { topQueriesNoResults }) => topQueriesNoResults,
},
],
topQueriesNoClicks: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { topQueriesNoClicks }) => topQueriesNoClicks,
},
],
topQueriesWithClicks: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { topQueriesWithClicks }) => topQueriesWithClicks,
},
],
totalQueries: [
0,
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { totalQueries }) => totalQueries,
},
],
totalQueriesNoResults: [
0,
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { totalQueriesNoResults }) => totalQueriesNoResults,
},
],
totalClicks: [
0,
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { totalClicks }) => totalClicks,
},
],
queriesPerDay: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { queriesPerDay }) => queriesPerDay,
},
],
queriesNoResultsPerDay: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { queriesNoResultsPerDay }) => queriesNoResultsPerDay,
},
],
clicksPerDay: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { clicksPerDay }) => clicksPerDay,
},
],
totalQueriesForQuery: [
0,
{
// @ts-expect-error upgrade typescript v5.1.6
onQueryDataLoad: (_, { totalQueriesForQuery }) => totalQueriesForQuery,
},
],
queriesPerDayForQuery: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onQueryDataLoad: (_, { queriesPerDayForQuery }) => queriesPerDayForQuery,
},
],
topClicksForQuery: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
onQueryDataLoad: (_, { topClicksForQuery }) => topClicksForQuery,
},
],
startDate: [
'',
{
// @ts-expect-error upgrade typescript v5.1.6
onAnalyticsDataLoad: (_, { startDate }) => startDate,
// @ts-expect-error upgrade typescript v5.1.6
onQueryDataLoad: (_, { startDate }) => startDate,
},
],
}),
listeners: ({ actions }) => ({
loadAnalyticsData: async () => {
const { history } = KibanaLogic.values;
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
try {
const { start, end, tag } = queryString.parse(history.location.search);
const query = {
start: start || DEFAULT_START_DATE,
end: end || DEFAULT_END_DATE,
tag,
size: 20,
};
const url = `/internal/app_search/engines/${engineName}/analytics/queries`;
const response = await http.get<AnalyticsData>(url, { query });
actions.onAnalyticsDataLoad(response);
} catch (e) {
flashAPIErrors(e);
}
},
loadQueryData: async (query) => {
const { history } = KibanaLogic.values;
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
try {
const { start, end, tag } = queryString.parse(history.location.search);
const queryParams = {
start: start || DEFAULT_START_DATE,
end: end || DEFAULT_END_DATE,
tag,
};
const url = `/internal/app_search/engines/${engineName}/analytics/queries/${query}`;
const response = await http.get<QueryDetails>(url, { query: queryParams });
actions.onQueryDataLoad(response);
} catch (e) {
flashAPIErrors(e);
}
},
}),
});

View file

@ -1,26 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import '../../__mocks__/engine_logic.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { Routes, Route } from '@kbn/shared-ux-router';
import { AnalyticsRouter } from '.';
describe('AnalyticsRouter', () => {
// Detailed route testing is better done via E2E tests
it('renders', () => {
const wrapper = shallow(<AnalyticsRouter />);
expect(wrapper.find(Routes)).toHaveLength(1);
expect(wrapper.find(Route)).toHaveLength(9);
});
});

View file

@ -1,69 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { Redirect } from 'react-router-dom';
import { Routes, Route } from '@kbn/shared-ux-router';
import {
ENGINE_ANALYTICS_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH,
ENGINE_ANALYTICS_RECENT_QUERIES_PATH,
ENGINE_ANALYTICS_QUERY_DETAILS_PATH,
ENGINE_ANALYTICS_QUERY_DETAIL_PATH,
} from '../../routes';
import { generateEnginePath, getEngineBreadcrumbs } from '../engine';
import { NotFound } from '../not_found';
import { ANALYTICS_TITLE } from './constants';
import {
Analytics,
TopQueries,
TopQueriesNoResults,
TopQueriesNoClicks,
TopQueriesWithClicks,
RecentQueries,
QueryDetail,
} from './views';
export const AnalyticsRouter: React.FC = () => {
return (
<Routes>
<Route exact path={ENGINE_ANALYTICS_PATH}>
<Analytics />
</Route>
<Route exact path={ENGINE_ANALYTICS_TOP_QUERIES_PATH}>
<TopQueries />
</Route>
<Route exact path={ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH}>
<TopQueriesNoResults />
</Route>
<Route exact path={ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH}>
<TopQueriesNoClicks />
</Route>
<Route exact path={ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH}>
<TopQueriesWithClicks />
</Route>
<Route exact path={ENGINE_ANALYTICS_RECENT_QUERIES_PATH}>
<RecentQueries />
</Route>
<Route exact path={ENGINE_ANALYTICS_QUERY_DETAIL_PATH}>
<QueryDetail />
</Route>
<Route exact path={ENGINE_ANALYTICS_QUERY_DETAILS_PATH}>
<Redirect to={generateEnginePath(ENGINE_ANALYTICS_PATH)} />
</Route>
<Route>
<NotFound pageChrome={getEngineBreadcrumbs([ANALYTICS_TITLE])} />
</Route>
</Routes>
);
};

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { EuiStat } from '@elastic/eui';
import { AnalyticsCards } from '.';
describe('AnalyticsCards', () => {
it('renders', () => {
const wrapper = shallow(
<AnalyticsCards
stats={[
{
stat: 100,
text: 'Red fish',
dataTestSubj: 'RedFish',
},
{
stat: 2000,
text: 'Blue fish',
dataTestSubj: 'BlueFish',
},
]}
/>
);
expect(wrapper.find(EuiStat)).toHaveLength(2);
expect(wrapper.find('[data-test-subj="RedFish"]').prop('title')).toEqual(100);
expect(wrapper.find('[data-test-subj="RedFish"]').prop('description')).toEqual('Red fish');
expect(wrapper.find('[data-test-subj="BlueFish"]').prop('title')).toEqual(2000);
expect(wrapper.find('[data-test-subj="BlueFish"]').prop('description')).toEqual('Blue fish');
});
});

View file

@ -1,34 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiStat } from '@elastic/eui';
interface Props {
stats: Array<{
text: string;
stat: number;
dataTestSubj?: string;
}>;
}
export const AnalyticsCards: React.FC<Props> = ({ stats }) => (
<EuiFlexGroup direction="column">
{stats.map(({ text, stat, dataTestSubj }) => (
<EuiFlexItem key={text}>
<EuiPanel color="subdued" hasShadow={false}>
<EuiStat
title={stat}
description={text}
titleColor="primary"
data-test-subj={dataTestSubj}
/>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGroup>
);

View file

@ -1,82 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { mockKibanaValues } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { Chart, LineSeries, Axis, Tooltip } from '@elastic/charts';
import { AnalyticsChart } from '.';
describe('AnalyticsChart', () => {
const MOCK_DATA = [
{ x: '1970-01-01', y: 0 },
{ x: '1970-01-02', y: 1 },
{ x: '1970-01-03', y: 5 },
{ x: '1970-01-04', y: 50 },
{ x: '1970-01-05', y: 25 },
];
beforeAll(() => {
jest.clearAllMocks();
});
it('renders an Elastic line chart', () => {
const wrapper = shallow(
<AnalyticsChart height={300} lines={[{ id: 'test', data: MOCK_DATA }]} />
);
expect(wrapper.find(Chart).prop('size')).toEqual({ height: 300 });
expect(wrapper.find(Axis)).toHaveLength(2);
expect(mockKibanaValues.charts.theme.useChartsBaseTheme).toHaveBeenCalled();
expect(wrapper.find(LineSeries)).toHaveLength(1);
expect(wrapper.find(LineSeries).prop('id')).toEqual('test');
expect(wrapper.find(LineSeries).prop('data')).toEqual(MOCK_DATA);
});
it('renders multiple lines', () => {
const wrapper = shallow(
<AnalyticsChart
lines={[
{ id: 'line 1', data: MOCK_DATA },
{ id: 'line 2', data: MOCK_DATA },
{ id: 'line 3', data: MOCK_DATA },
]}
/>
);
expect(wrapper.find(LineSeries)).toHaveLength(3);
});
it('renders dashed lines', () => {
const wrapper = shallow(
<AnalyticsChart lines={[{ id: 'dashed 1', data: MOCK_DATA, isDashed: true }]} />
);
expect(wrapper.find(LineSeries).prop('lineSeriesStyle')?.line?.dash).toBeTruthy();
});
it('formats x-axis dates correctly', () => {
const wrapper = shallow(<AnalyticsChart lines={[{ id: 'test', data: MOCK_DATA }]} />);
const dateFormatter: Function = wrapper.find('#bottom-axis').prop('tickFormat');
expect(dateFormatter('1970-02-28')).toEqual('2/28');
});
it('formats tooltip dates correctly', () => {
const wrapper = shallow(<AnalyticsChart lines={[{ id: 'test', data: MOCK_DATA }]} />);
const dateFormatter = wrapper.find(Tooltip).prop('headerFormatter')!;
expect(dateFormatter({ value: '1970-12-03', formattedValue: '1970-12-03' })).toEqual(
'December 3, 1970'
);
});
});

View file

@ -1,63 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import moment from 'moment';
import { Chart, Settings, LineSeries, CurveType, Axis, Tooltip } from '@elastic/charts';
import { i18n } from '@kbn/i18n';
import { KibanaLogic } from '../../../../shared/kibana';
import { X_AXIS_DATE_FORMAT, TOOLTIP_DATE_FORMAT } from '../constants';
interface ChartPoint {
x: string; // Date string
y: number; // # of clicks, queries, etc.
}
export type ChartData = ChartPoint[];
interface Props {
height?: number;
lines: Array<{
id: string;
data: ChartData;
isDashed?: boolean;
}>;
}
export const AnalyticsChart: React.FC<Props> = ({ height = 300, lines }) => {
const { charts } = useValues(KibanaLogic);
return (
<Chart size={{ height }}>
<Tooltip headerFormatter={(tooltip) => moment(tooltip.value).format(TOOLTIP_DATE_FORMAT)} />
<Settings baseTheme={charts?.theme.useChartsBaseTheme()} locale={i18n.getLocale()} />
{lines.map(({ id, data, isDashed }) => (
<LineSeries
key={id}
id={id}
data={data}
xAccessor={'x'}
yAccessors={['y']}
curve={CurveType.CURVE_MONOTONE_X}
lineSeriesStyle={isDashed ? { line: { dash: [5, 5] } } : undefined}
/>
))}
<Axis
id="bottom-axis"
position="bottom"
tickFormat={(d) => moment(d).format(X_AXIS_DATE_FORMAT)}
gridLine={{ visible: true }}
/>
<Axis id="left-axis" position="left" ticks={4} gridLine={{ visible: true }} />
</Chart>
);
};

View file

@ -1,166 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues, mockKibanaValues } from '../../../../__mocks__/kea_logic';
import React, { ReactElement } from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import moment, { Moment } from 'moment';
import { EuiSelect, EuiDatePickerRange, EuiButton } from '@elastic/eui';
import { DEFAULT_START_DATE, DEFAULT_END_DATE } from '../constants';
import { AnalyticsFilters } from '.';
describe('AnalyticsFilters', () => {
const { history } = mockKibanaValues;
const values = {
allTags: ['All Analytics Tags'], // Comes from the server API
history,
};
const newStartDateMoment = moment('1970-01-30');
const newEndDateMoment = moment('1970-01-31');
let wrapper: ShallowWrapper;
const getTagsSelect = () => wrapper.find(EuiSelect);
const getDateRangePicker = () => wrapper.find(EuiDatePickerRange);
const getStartDatePicker = () => getDateRangePicker().prop('startDateControl') as ReactElement;
const getEndDatePicker = () => getDateRangePicker().prop('endDateControl') as ReactElement;
const getApplyButton = () => wrapper.find(EuiButton);
beforeEach(() => {
jest.clearAllMocks();
history.location.search = '';
setMockValues(values);
});
it('renders', () => {
wrapper = shallow(<AnalyticsFilters />);
expect(wrapper.find(EuiSelect)).toHaveLength(1);
expect(wrapper.find(EuiDatePickerRange)).toHaveLength(1);
});
it('renders tags & dates with default values when no search query params are present', () => {
wrapper = shallow(<AnalyticsFilters />);
expect(getTagsSelect().prop('value')).toEqual('');
expect(getStartDatePicker().props.startDate._i).toEqual(DEFAULT_START_DATE);
expect(getEndDatePicker().props.endDate._i).toEqual(DEFAULT_END_DATE);
});
describe('tags select', () => {
beforeEach(() => {
history.location.search = '?tag=tag1';
const allTags = [...values.allTags, 'tag1', 'tag2', 'tag3'];
setMockValues({ ...values, allTags });
wrapper = shallow(<AnalyticsFilters />);
});
it('renders the tags select with currentTag value and allTags options', () => {
const tagsSelect = getTagsSelect();
expect(tagsSelect.prop('value')).toEqual('tag1');
expect(tagsSelect.prop('options')).toEqual([
{ value: '', text: 'All analytics tags' },
{ value: 'tag1', text: 'tag1' },
{ value: 'tag2', text: 'tag2' },
{ value: 'tag3', text: 'tag3' },
]);
});
it('updates currentTag on new tag select', () => {
getTagsSelect().simulate('change', { target: { value: 'tag3' } });
expect(getTagsSelect().prop('value')).toEqual('tag3');
});
});
describe('date pickers', () => {
beforeEach(() => {
history.location.search = '?start=1970-01-01&end=1970-01-02';
wrapper = shallow(<AnalyticsFilters />);
});
it('renders the start date picker', () => {
const startDatePicker = getStartDatePicker();
expect(startDatePicker.props.selected._i).toEqual('1970-01-01');
expect(startDatePicker.props.startDate._i).toEqual('1970-01-01');
});
it('renders the end date picker', () => {
const endDatePicker = getEndDatePicker();
expect(endDatePicker.props.selected._i).toEqual('1970-01-02');
expect(endDatePicker.props.endDate._i).toEqual('1970-01-02');
});
it('updates startDate on start date pick', () => {
getStartDatePicker().props.onChange(newStartDateMoment);
expect(getStartDatePicker().props.startDate._i).toEqual('1970-01-30');
});
it('updates endDate on start date pick', () => {
getEndDatePicker().props.onChange(newEndDateMoment);
expect(getEndDatePicker().props.endDate._i).toEqual('1970-01-31');
});
});
describe('invalid date ranges', () => {
beforeEach(() => {
history.location.search = '?start=1970-01-02&end=1970-01-01';
wrapper = shallow(<AnalyticsFilters />);
});
it('renders the date pickers as invalid', () => {
expect(getStartDatePicker().props.isInvalid).toEqual(true);
expect(getEndDatePicker().props.isInvalid).toEqual(true);
});
it('disables the apply button', () => {
expect(getApplyButton().prop('isDisabled')).toEqual(true);
});
});
describe('applying filters', () => {
const updateState = ({ start, end, tag }: { start: Moment; end: Moment; tag: string }) => {
getTagsSelect().simulate('change', { target: { value: tag } });
getStartDatePicker().props.onChange(start);
getEndDatePicker().props.onChange(end);
};
beforeEach(() => {
wrapper = shallow(<AnalyticsFilters />);
});
it('pushes up new tag & date state to the search query', () => {
updateState({ start: newStartDateMoment, end: newEndDateMoment, tag: 'tag2' });
getApplyButton().simulate('click');
expect(history.push).toHaveBeenCalledWith({
search: 'end=1970-01-31&start=1970-01-30&tag=tag2',
});
});
it('does not push up the tag param if empty (set to all tags)', () => {
updateState({ start: newStartDateMoment, end: newEndDateMoment, tag: '' });
getApplyButton().simulate('click');
expect(history.push).toHaveBeenCalledWith({
search: 'end=1970-01-31&start=1970-01-30',
});
});
});
});

View file

@ -1,113 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { useValues } from 'kea';
import moment from 'moment';
import queryString from 'query-string';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSelect,
EuiDatePickerRange,
EuiDatePicker,
EuiButton,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AnalyticsLogic } from '..';
import { KibanaLogic } from '../../../../shared/kibana';
import { DEFAULT_START_DATE, DEFAULT_END_DATE, SERVER_DATE_FORMAT } from '../constants';
import { convertTagsToSelectOptions } from '../utils';
export const AnalyticsFilters: React.FC = () => {
const { allTags } = useValues(AnalyticsLogic);
const { history } = useValues(KibanaLogic);
// Parse out existing filters from URL query string
const { start, end, tag } = queryString.parse(history.location.search);
const [startDate, setStartDate] = useState(
start ? moment(start, SERVER_DATE_FORMAT) : moment(DEFAULT_START_DATE)
);
const [endDate, setEndDate] = useState(
end ? moment(end, SERVER_DATE_FORMAT) : moment(DEFAULT_END_DATE)
);
const [currentTag, setCurrentTag] = useState((tag as string) || '');
// Set the current URL query string on filter
const onApplyFilters = () => {
const search = queryString.stringify({
start: moment(startDate).format(SERVER_DATE_FORMAT),
end: moment(endDate).format(SERVER_DATE_FORMAT),
tag: currentTag || undefined,
});
history.push({ search });
};
const hasInvalidDateRange = startDate > endDate;
return (
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" gutterSize="m">
<EuiFlexItem>
<EuiSelect
options={convertTagsToSelectOptions(allTags)}
value={currentTag}
onChange={(e) => setCurrentTag(e.target.value)}
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.filters.tagAriaLabel',
{ defaultMessage: 'Filter by analytics tag"' }
)}
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiDatePickerRange
startDateControl={
<EuiDatePicker
selected={startDate}
onChange={(date) => date && setStartDate(date)}
startDate={startDate}
endDate={endDate}
isInvalid={hasInvalidDateRange}
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.filters.startDateAriaLabel',
{ defaultMessage: 'Filter by start date' }
)}
locale={i18n.getLocale()}
/>
}
endDateControl={
<EuiDatePicker
selected={endDate}
onChange={(date) => date && setEndDate(date)}
startDate={startDate}
endDate={endDate}
isInvalid={hasInvalidDateRange}
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.filters.endDateAriaLabel',
{ defaultMessage: 'Filter by end date' }
)}
locale={i18n.getLocale()}
/>
}
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill isDisabled={hasInvalidDateRange} onClick={onApplyFilters}>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.filters.applyButtonLabel',
{ defaultMessage: 'Apply filters' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -1,60 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { mockKibanaValues } from '../../../../__mocks__/kea_logic';
import '../../../../__mocks__/react_router';
import '../../../__mocks__/engine_logic.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiFieldSearch } from '@elastic/eui';
import { AnalyticsSearch } from '.';
describe('AnalyticsSearch', () => {
const { navigateToUrl } = mockKibanaValues;
const preventDefault = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
const wrapper = shallow(<AnalyticsSearch />);
const setSearchValue = (value: string) =>
wrapper.find(EuiFieldSearch).simulate('change', { target: { value } });
it('renders', () => {
expect(wrapper.find(EuiFieldSearch)).toHaveLength(1);
});
it('updates searchValue state on input change', () => {
expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('');
setSearchValue('some-query');
expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('some-query');
});
it('sends the user to the query detail page on search', () => {
wrapper.find('form').simulate('submit', { preventDefault });
expect(preventDefault).toHaveBeenCalled();
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/some-query'
);
});
it('falls back to showing the "" query if searchValue is empty', () => {
setSearchValue('');
wrapper.find('form').simulate('submit', { preventDefault });
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/%22%22' // "" gets encoded
);
});
});

View file

@ -1,55 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { useValues } from 'kea';
import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiButton, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { KibanaLogic } from '../../../../shared/kibana';
import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../routes';
import { generateEnginePath } from '../../engine';
export const AnalyticsSearch: React.FC = () => {
const [searchValue, setSearchValue] = useState('');
const { navigateToUrl } = useValues(KibanaLogic);
const viewQueryDetails = (e: React.SyntheticEvent) => {
e.preventDefault();
const query = searchValue || '""';
navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query }));
};
return (
<form onSubmit={viewQueryDetails}>
<EuiFlexGroup alignItems="center" gutterSize="m" responsive={false}>
<EuiFlexItem>
<EuiFieldSearch
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetailSearchPlaceholder',
{ defaultMessage: 'Go to search term' }
)}
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton type="submit">
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetailSearchButtonLabel',
{ defaultMessage: 'View details' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
</form>
);
};

View file

@ -1,36 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { EuiIcon } from '@elastic/eui';
import { AnalyticsSection } from '.';
describe('AnalyticsSection', () => {
it('renders', () => {
const wrapper = shallow(
<AnalyticsSection title="Lorem ipsum" subtitle="Dolor sit amet.">
<div data-test-subj="HelloWorld">Test</div>
</AnalyticsSection>
);
expect(wrapper.find('h2').text()).toEqual('Lorem ipsum');
expect(wrapper.find('p').text()).toEqual('Dolor sit amet.');
expect(wrapper.find('[data-test-subj="HelloWorld"]')).toHaveLength(1);
});
it('renders an optional icon', () => {
const wrapper = shallow(
<AnalyticsSection title="Lorem ipsum" subtitle="Dolor sit amet." iconType="eye" />
);
expect(wrapper.find(EuiIcon).prop('type')).toEqual('eye');
});
});

View file

@ -1,59 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, PropsWithChildren } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiPageSection,
EuiSpacer,
EuiText,
EuiTitle,
IconType,
} from '@elastic/eui';
interface Props {
iconType?: IconType;
subtitle: string;
title: string;
}
export const AnalyticsSection: FC<PropsWithChildren<Props>> = ({
title,
subtitle,
iconType,
children,
}) => (
<section>
<header>
<EuiFlexGroup
gutterSize="xs"
alignItems="center"
justifyContent="flexStart"
responsive={false}
>
{iconType && (
<EuiFlexItem grow={false}>
<EuiIcon type={iconType} size="l" />
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiTitle size="s">
<h2>{title}</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
<EuiText size="s" color="subdued">
<p>{subtitle}</p>
</EuiText>
</header>
<EuiSpacer size="m" />
<EuiPageSection paddingSize="none">{children}</EuiPageSection>
</section>
);

View file

@ -1,96 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import '../../../../../__mocks__/kea_logic';
import '../../../../../__mocks__/react_router';
import '../../../../__mocks__/engine_logic.mock';
import React from 'react';
import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui';
import { mountWithIntl } from '../../../../../test_helpers';
import { runActionColumnTests } from './test_helpers/shared_columns_tests';
import { AnalyticsTable } from '.';
describe('AnalyticsTable', () => {
const items = [
{
key: 'some search',
tags: ['tagA'],
searches: { doc_count: 100 },
clicks: { doc_count: 10 },
},
{
key: 'another search',
tags: ['tagB'],
searches: { doc_count: 99 },
clicks: { doc_count: 9 },
},
{
key: '',
tags: ['tagA', 'tagB'],
searches: { doc_count: 1 },
clicks: { doc_count: 0 },
},
];
it('renders', () => {
const wrapper = mountWithIntl(<AnalyticsTable items={items} />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Search term');
expect(tableContent).toContain('some search');
expect(tableContent).toContain('another search');
expect(tableContent).toContain('""');
expect(tableContent).toContain('Analytics tags');
expect(tableContent).toContain('tagA');
expect(tableContent).toContain('tagB');
expect(wrapper.find(EuiBadge)).toHaveLength(4);
expect(tableContent).toContain('Queries');
expect(tableContent).toContain('100');
expect(tableContent).toContain('99');
expect(tableContent).toContain('1');
expect(tableContent).not.toContain('Clicks');
});
it('renders a clicks column if hasClicks is passed', () => {
const wrapper = mountWithIntl(<AnalyticsTable items={items} hasClicks />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Clicks');
expect(tableContent).toContain('10');
expect(tableContent).toContain('9');
expect(tableContent).toContain('0');
});
it('renders tag counts instead of tag names if isSmall is passed', () => {
const wrapper = mountWithIntl(<AnalyticsTable items={items} isSmall />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Analytics tags');
expect(tableContent).toContain('1 tag');
expect(tableContent).toContain('2 tags');
expect(wrapper.find(EuiBadge)).toHaveLength(3);
});
describe('renders an action column', () => {
const wrapper = mountWithIntl(<AnalyticsTable items={items} />);
runActionColumnTests(wrapper);
});
it('renders an empty prompt if no items are passed', () => {
const wrapper = mountWithIntl(<AnalyticsTable items={[]} />);
const promptContent = wrapper.find(EuiEmptyPrompt).text();
expect(promptContent).toContain('No queries were performed during this time period.');
});
});

View file

@ -1,81 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Query } from '../../types';
import {
TERM_COLUMN_PROPS,
TAGS_LIST_COLUMN,
TAGS_COUNT_COLUMN,
COUNT_COLUMN_PROPS,
ACTIONS_COLUMN,
} from './shared_columns';
interface Props {
items: Query[];
hasClicks?: boolean;
isSmall?: boolean;
}
type Columns = Array<EuiBasicTableColumn<Query>>;
export const AnalyticsTable: React.FC<Props> = ({ items, hasClicks, isSmall }) => {
const TERM_COLUMN = {
field: 'key',
...TERM_COLUMN_PROPS,
};
const TAGS_COLUMN = isSmall ? TAGS_COUNT_COLUMN : TAGS_LIST_COLUMN;
const COUNT_COLUMNS = [
{
field: 'searches.doc_count',
name: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.queriesColumn',
{ defaultMessage: 'Queries' }
),
...COUNT_COLUMN_PROPS,
},
];
if (hasClicks) {
COUNT_COLUMNS.push({
field: 'clicks.doc_count',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.clicksColumn', {
defaultMessage: 'Clicks',
}),
...COUNT_COLUMN_PROPS,
});
}
return (
<EuiBasicTable
columns={[TERM_COLUMN, TAGS_COLUMN, ...COUNT_COLUMNS, ACTIONS_COLUMN] as Columns}
items={items}
noItemsMessage={
<EuiEmptyPrompt
iconType="visLine"
title={
<h4>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noQueriesTitle',
{ defaultMessage: 'No queries to display' }
)}
</h4>
}
body={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noQueriesDescription',
{ defaultMessage: 'No queries were performed during this time period.' }
)}
/>
}
/>
);
};

View file

@ -1,10 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { AnalyticsTable } from './analytics_table';
export { RecentQueriesTable } from './recent_queries_table';
export { QueryClicksTable } from './query_clicks_table';

View file

@ -1,82 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import '../../../../../__mocks__/kea_logic';
import '../../../../../__mocks__/react_router';
import '../../../../__mocks__/engine_logic.mock';
import React from 'react';
import { EuiBasicTable, EuiLink, EuiBadge, EuiEmptyPrompt } from '@elastic/eui';
import { mountWithIntl } from '../../../../../test_helpers';
import { QueryClicksTable } from '.';
describe('QueryClicksTable', () => {
const items = [
{
key: 'some-document',
document: {
id: 'some-document',
engine: 'some-engine',
},
tags: ['tagA'],
doc_count: 10,
},
{
key: 'another-document',
document: {
id: 'another-document',
engine: 'another-engine',
},
tags: ['tagB'],
doc_count: 5,
},
{
key: 'deleted-document',
tags: [],
doc_count: 1,
},
];
it('renders', () => {
const wrapper = mountWithIntl(<QueryClicksTable items={items} />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Documents');
expect(tableContent).toContain('some-document');
expect(tableContent).toContain('another-document');
expect(tableContent).toContain('deleted-document');
expect(wrapper.find(EuiLink).first().prop('href')).toEqual(
'/app/enterprise_search/engines/some-engine/documents/some-document'
);
expect(wrapper.find(EuiLink).last().prop('href')).toEqual(
'/app/enterprise_search/engines/another-engine/documents/another-document'
);
// deleted-document should not have a link
expect(tableContent).toContain('Analytics tags');
expect(tableContent).toContain('tagA');
expect(tableContent).toContain('tagB');
expect(wrapper.find(EuiBadge)).toHaveLength(2);
expect(tableContent).toContain('Clicks');
expect(tableContent).toContain('10');
expect(tableContent).toContain('5');
expect(tableContent).toContain('1');
});
it('renders an empty prompt if no items are passed', () => {
const wrapper = mountWithIntl(<QueryClicksTable items={[]} />);
const promptContent = wrapper.find(EuiEmptyPrompt).text();
expect(promptContent).toContain('No clicks');
expect(promptContent).toContain('No documents have been clicked from this query.');
});
});

View file

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiLinkTo } from '../../../../../shared/react_router_helpers';
import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../../../routes';
import { DOCUMENTS_TITLE } from '../../../documents';
import { generateEnginePath } from '../../../engine';
import { QueryClick } from '../../types';
import { FIRST_COLUMN_PROPS, TAGS_LIST_COLUMN, COUNT_COLUMN_PROPS } from './shared_columns';
interface Props {
items: QueryClick[];
}
type Columns = Array<EuiBasicTableColumn<QueryClick>>;
export const QueryClicksTable: React.FC<Props> = ({ items }) => {
const DOCUMENT_COLUMN = {
...FIRST_COLUMN_PROPS,
field: 'document',
name: DOCUMENTS_TITLE,
render: (document: QueryClick['document'], query: QueryClick) => {
return document ? (
<EuiLinkTo
to={generateEnginePath(ENGINE_DOCUMENT_DETAIL_PATH, {
engineName: document.engine,
documentId: document.id,
})}
>
{document.id}
</EuiLinkTo>
) : (
query.key
);
},
};
const CLICKS_COLUMN = {
...COUNT_COLUMN_PROPS,
field: 'doc_count',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.clicksColumn', {
defaultMessage: 'Clicks',
}),
};
return (
<EuiBasicTable
columns={[DOCUMENT_COLUMN, TAGS_LIST_COLUMN, CLICKS_COLUMN] as Columns}
items={items}
noItemsMessage={
<EuiEmptyPrompt
iconType="visLine"
title={
<h4>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noClicksTitle',
{ defaultMessage: 'No clicks' }
)}
</h4>
}
body={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noClicksDescription',
{ defaultMessage: 'No documents have been clicked from this query.' }
)}
/>
}
/>
);
};

View file

@ -1,81 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import '../../../../../__mocks__/kea_logic';
import '../../../../../__mocks__/react_router';
import '../../../../__mocks__/engine_logic.mock';
import React from 'react';
import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui';
import { mountWithIntl } from '../../../../../test_helpers';
import { runActionColumnTests } from './test_helpers/shared_columns_tests';
import { RecentQueriesTable } from '.';
describe('RecentQueriesTable', () => {
const items = [
{
query_string: 'some search',
timestamp: '1970-01-03T12:00:00Z',
tags: ['tagA'],
document_ids: ['documentA', 'documentB'],
},
{
query_string: 'another search',
timestamp: '1970-01-02T12:00:00Z',
tags: ['tagB'],
document_ids: ['documentC'],
},
{
query_string: '',
timestamp: '1970-01-01T12:00:00Z',
tags: ['tagA', 'tagB'],
document_ids: ['documentA', 'documentB', 'documentC'],
},
];
it('renders', () => {
const wrapper = mountWithIntl(<RecentQueriesTable items={items} />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Search term');
expect(tableContent).toContain('some search');
expect(tableContent).toContain('another search');
expect(tableContent).toContain('""');
expect(tableContent).toContain('Time');
expect(tableContent).toContain('Jan 3, 1970');
expect(tableContent).toContain('Jan 2, 1970');
expect(tableContent).toContain('Jan 1, 1970');
expect(tableContent).toContain('Analytics tags');
expect(tableContent).toContain('tagA');
expect(tableContent).toContain('tagB');
expect(wrapper.find(EuiBadge)).toHaveLength(4);
expect(tableContent).toContain('Results');
expect(tableContent).toContain('2');
expect(tableContent).toContain('1');
expect(tableContent).toContain('3');
});
describe('renders an action column', () => {
const wrapper = mountWithIntl(<RecentQueriesTable items={items} />);
runActionColumnTests(wrapper);
});
it('renders an empty prompt if no items are passed', () => {
const wrapper = mountWithIntl(<RecentQueriesTable items={[]} />);
const promptContent = wrapper.find(EuiEmptyPrompt).text();
expect(promptContent).toContain('No recent queries');
expect(promptContent).toContain('Queries will appear here as they are received.');
});
});

View file

@ -1,79 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedDateTime } from '../../../../utils/formatted_date_time';
import { RecentQuery } from '../../types';
import {
TERM_COLUMN_PROPS,
TAGS_LIST_COLUMN,
COUNT_COLUMN_PROPS,
ACTIONS_COLUMN,
} from './shared_columns';
interface Props {
items: RecentQuery[];
}
type Columns = Array<EuiBasicTableColumn<RecentQuery>>;
export const RecentQueriesTable: React.FC<Props> = ({ items }) => {
const TERM_COLUMN = {
...TERM_COLUMN_PROPS,
field: 'query_string',
};
const TIME_COLUMN = {
field: 'timestamp',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.timeColumn', {
defaultMessage: 'Time',
}),
render: (timestamp: RecentQuery['timestamp']) => (
<FormattedDateTime date={new Date(timestamp)} />
),
width: '200px',
};
const RESULTS_COLUMN = {
...COUNT_COLUMN_PROPS,
field: 'document_ids',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.resultsColumn', {
defaultMessage: 'Results',
}),
render: (documents: RecentQuery['document_ids']) => documents.length,
};
return (
<EuiBasicTable
columns={
[TERM_COLUMN, TIME_COLUMN, TAGS_LIST_COLUMN, RESULTS_COLUMN, ACTIONS_COLUMN] as Columns
}
items={items}
noItemsMessage={
<EuiEmptyPrompt
iconType="visLine"
title={
<h4>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noRecentQueriesTitle',
{ defaultMessage: 'No recent queries' }
)}
</h4>
}
body={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noRecentQueriesDescription',
{ defaultMessage: 'Queries will appear here as they are received.' }
)}
/>
}
/>
);
};

View file

@ -1,122 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EDIT_BUTTON_LABEL } from '../../../../../shared/constants';
import { flashAPIErrors } from '../../../../../shared/flash_messages';
import { HttpLogic } from '../../../../../shared/http';
import { KibanaLogic } from '../../../../../shared/kibana';
import { EuiLinkTo } from '../../../../../shared/react_router_helpers';
import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH, ENGINE_CURATION_PATH } from '../../../../routes';
import { generateEnginePath, EngineLogic } from '../../../engine';
import { Query, RecentQuery } from '../../types';
import { TagsList, TagsCount } from './tags';
/**
* Shared columns / column properties between separate analytics tables
*/
export const FIRST_COLUMN_PROPS = {
truncateText: true,
width: '25%',
mobileOptions: {
enlarge: true,
width: '100%',
},
};
export const TERM_COLUMN_PROPS = {
// Field key changes per-table
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.termColumn', {
defaultMessage: 'Search term',
}),
render: (query: Query['key']) => {
if (!query) query = '""';
return (
<EuiLinkTo to={generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })}>
{query}
</EuiLinkTo>
);
},
...FIRST_COLUMN_PROPS,
};
export const ACTIONS_COLUMN = {
width: '90px',
actions: [
{
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.viewAction', {
defaultMessage: 'View',
}),
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.viewTooltip',
{ defaultMessage: 'View query analytics' }
),
type: 'icon',
icon: 'eye',
color: 'primary',
onClick: (item: Query | RecentQuery) => {
const { navigateToUrl } = KibanaLogic.values;
const query = (item as Query).key || (item as RecentQuery).query_string || '""';
navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query }));
},
'data-test-subj': 'AnalyticsTableViewQueryButton',
},
{
name: EDIT_BUTTON_LABEL,
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.editTooltip',
{ defaultMessage: 'Manage curation' }
),
type: 'icon',
icon: 'package',
onClick: async (item: Query | RecentQuery) => {
const { http } = HttpLogic.values;
const { navigateToUrl } = KibanaLogic.values;
const { engineName } = EngineLogic.values;
try {
const query = (item as Query).key || (item as RecentQuery).query_string || '""';
const response = await http.post<{ id: string }>(
`/internal/app_search/engines/${engineName}/curations/find_or_create`,
{ body: JSON.stringify({ query }) }
);
navigateToUrl(generateEnginePath(ENGINE_CURATION_PATH, { curationId: response.id }));
} catch (e) {
flashAPIErrors(e);
}
},
'data-test-subj': 'AnalyticsTableEditQueryButton',
},
],
};
export const TAGS_COLUMN_PROPS = {
field: 'tags',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.tagsColumn', {
defaultMessage: 'Analytics tags',
}),
truncateText: true,
};
export const TAGS_LIST_COLUMN = {
...TAGS_COLUMN_PROPS,
render: (tags: Query['tags']) => <TagsList tags={tags} />,
};
export const TAGS_COUNT_COLUMN = {
...TAGS_COLUMN_PROPS,
render: (tags: Query['tags']) => <TagsCount tags={tags} />,
};
export const COUNT_COLUMN_PROPS = {
dataType: 'number',
width: '100px',
};

View file

@ -1,67 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { EuiBadge, EuiToolTip } from '@elastic/eui';
import { TagsList, TagsCount } from './tags';
describe('TagsList', () => {
it('renders', () => {
const wrapper = shallow(<TagsList tags={['test']} />);
expect(wrapper.find(EuiBadge)).toHaveLength(1);
expect(wrapper.find(EuiBadge).prop('children')).toEqual('test');
});
it('renders >2 badges in a tooltip list', () => {
const wrapper = shallow(<TagsList tags={['1', '2', '3', '4', '5']} />);
expect(wrapper.find(EuiBadge)).toHaveLength(3);
expect(wrapper.find(EuiToolTip)).toHaveLength(1);
expect(wrapper.find(EuiBadge).at(0).prop('children')).toEqual('1');
expect(wrapper.find(EuiBadge).at(1).prop('children')).toEqual('2');
expect(wrapper.find(EuiBadge).at(2).prop('children')).toEqual('and 3 more');
expect(wrapper.find(EuiToolTip).prop('content')).toEqual('3, 4, 5');
});
it('does not render if missing tags', () => {
const wrapper = shallow(<TagsList tags={[]} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
});
describe('TagsCount', () => {
it('renders a count and all tags in a tooltip', () => {
const wrapper = shallow(<TagsCount tags={['1', '2', '3']} />);
expect(wrapper.find(EuiToolTip)).toHaveLength(1);
expect(wrapper.find(EuiBadge)).toHaveLength(1);
expect(wrapper.find(EuiBadge).prop('children')).toEqual('3 tags');
expect(wrapper.find(EuiToolTip).prop('content')).toEqual('1, 2, 3');
});
it('handles pluralization correctly', () => {
const wrapper = shallow(<TagsCount tags={['1']} />);
expect(wrapper.find(EuiToolTip)).toHaveLength(1);
expect(wrapper.find(EuiBadge)).toHaveLength(1);
expect(wrapper.find(EuiBadge).prop('children')).toEqual('1 tag');
expect(wrapper.find(EuiToolTip).prop('content')).toEqual('1');
});
it('does not render if missing tags', () => {
const wrapper = shallow(<TagsCount tags={[]} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
});

View file

@ -1,64 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiBadgeGroup, EuiBadge, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Query } from '../../types';
import './tags.scss';
interface Props {
tags?: Query['tags'];
}
export const TagsCount: React.FC<Props> = ({ tags }) => {
if (!tags?.length) return null;
return (
<EuiToolTip position="bottom" content={tags.join(', ')}>
<EuiBadge color={'hollow'}>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.tagsCountBadge', {
defaultMessage: '{tagsCount, plural, one {# tag} other {# tags}}',
values: { tagsCount: tags.length },
})}
</EuiBadge>
</EuiToolTip>
);
};
export const TagsList: React.FC<Props> = ({ tags }) => {
if (!tags?.length) return null;
const displayedTags = tags.slice(0, 2);
const tooltipTags = tags.slice(2);
return (
<EuiBadgeGroup className="tagsList">
{displayedTags.map((tag: string) => (
<EuiBadge color="hollow" key={tag}>
{tag}
</EuiBadge>
))}
{tooltipTags.length > 0 && (
<EuiToolTip position="bottom" content={tooltipTags.join(', ')}>
<EuiBadge>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.table.moreTagsBadge',
{
defaultMessage: 'and {moreTagsCount} more',
values: { moreTagsCount: tooltipTags.length },
}
)}
</EuiBadge>
</EuiToolTip>
)}
</EuiBadgeGroup>
);
};

View file

@ -1,82 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
mockHttpValues,
mockKibanaValues,
mockFlashMessageHelpers,
} from '../../../../../../__mocks__/kea_logic';
import '../../../../../__mocks__/engine_logic.mock';
import { ReactWrapper } from 'enzyme';
import { nextTick } from '@kbn/test-jest-helpers';
export const runActionColumnTests = (wrapper: ReactWrapper) => {
const { http } = mockHttpValues;
const { navigateToUrl } = mockKibanaValues;
const { flashAPIErrors } = mockFlashMessageHelpers;
beforeEach(() => {
jest.clearAllMocks();
});
describe('view action', () => {
it('navigates to the query detail view', () => {
wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first().simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/some%20search'
);
});
it('falls back to "" for the empty query', () => {
wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').last().simulate('click');
expect(navigateToUrl).toHaveBeenCalledWith(
'/engines/some-engine/analytics/query_detail/%22%22'
);
});
});
describe('edit action', () => {
it('calls the find_or_create curation API, then navigates the user to the curation', async () => {
http.post.mockReturnValue(Promise.resolve({ id: 'cur-123456789' }));
wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first().simulate('click');
await nextTick();
expect(http.post).toHaveBeenCalledWith(
'/internal/app_search/engines/some-engine/curations/find_or_create',
{
body: JSON.stringify({ query: 'some search' }),
}
);
expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-123456789');
});
it('falls back to "" for the empty query', async () => {
http.post.mockReturnValue(Promise.resolve({ id: 'cur-987654321' }));
wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').last().simulate('click');
await nextTick();
expect(http.post).toHaveBeenCalledWith(
'/internal/app_search/engines/some-engine/curations/find_or_create',
{
body: JSON.stringify({ query: '""' }),
}
);
expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-987654321');
});
it('handles API errors', async () => {
http.post.mockReturnValue(Promise.reject());
wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first().simulate('click');
await nextTick();
expect(flashAPIErrors).toHaveBeenCalled();
});
});
};

View file

@ -1,13 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { AnalyticsCards } from './analytics_cards';
export { AnalyticsChart } from './analytics_chart';
export { AnalyticsFilters } from './analytics_filters';
export { AnalyticsSection } from './analytics_section';
export { AnalyticsSearch } from './analytics_search';
export { AnalyticsTable, RecentQueriesTable, QueryClicksTable } from './analytics_tables';

View file

@ -1,67 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import { i18n } from '@kbn/i18n';
export const ANALYTICS_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.title',
{ defaultMessage: 'Analytics' }
);
// Total card titles
export const TOTAL_DOCUMENTS = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.totalDocuments',
{ defaultMessage: 'Total documents' }
);
export const TOTAL_API_OPERATIONS = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.totalApiOperations',
{ defaultMessage: 'Total API operations' }
);
export const TOTAL_QUERIES = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.totalQueries',
{ defaultMessage: 'Total queries' }
);
export const TOTAL_QUERIES_NO_RESULTS = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.totalQueriesNoResults',
{ defaultMessage: 'Total queries with no results' }
);
export const TOTAL_CLICKS = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.totalClicks',
{ defaultMessage: 'Total clicks' }
);
// Queries sub-pages
export const TOP_QUERIES = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.topQueriesTitle',
{ defaultMessage: 'Top queries' }
);
export const TOP_QUERIES_NO_RESULTS = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.topQueriesNoResultsTitle',
{ defaultMessage: 'Top queries with no results' }
);
export const TOP_QUERIES_NO_CLICKS = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.topQueriesNoClicksTitle',
{ defaultMessage: 'Top queries with no clicks' }
);
export const TOP_QUERIES_WITH_CLICKS = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.topQueriesWithClicksTitle',
{ defaultMessage: 'Top queries with clicks' }
);
export const RECENT_QUERIES = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.recentQueriesTitle',
{ defaultMessage: 'Recent queries' }
);
// Date formats & dates
export const SERVER_DATE_FORMAT = 'YYYY-MM-DD';
export const TOOLTIP_DATE_FORMAT = 'MMMM D, YYYY';
export const X_AXIS_DATE_FORMAT = 'M/D';
export const DEFAULT_START_DATE = moment().subtract(6, 'days').format(SERVER_DATE_FORMAT);
export const DEFAULT_END_DATE = moment().format(SERVER_DATE_FORMAT);

View file

@ -1,12 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { ANALYTICS_TITLE } from './constants';
export { AnalyticsLogic } from './analytics_logic';
export { AnalyticsRouter } from './analytics_router';
export { AnalyticsCards, AnalyticsChart } from './components';
export { convertToChartData } from './utils';

View file

@ -1,60 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export interface Query {
key: string;
tags?: string[];
searches?: { doc_count: number };
clicks?: { doc_count: number };
}
export interface QueryClick extends Query {
document?: {
id: string;
engine: string;
};
}
export interface RecentQuery {
query_string: string;
timestamp: string;
tags: string[];
document_ids: string[];
}
/**
* API response data
*/
interface BaseData {
allTags: string[];
startDate: string;
// NOTE: The API sends us back even more data than this (e.g.,
// analyticsUnavailable, endDate, currentTag, logRetentionSettings, query),
// but we currently don't need that data in our front-end code,
// so I'm opting not to list them in our types
}
export interface AnalyticsData extends BaseData {
recentQueries: RecentQuery[];
topQueries: Query[];
topQueriesWithClicks: Query[];
topQueriesNoClicks: Query[];
topQueriesNoResults: Query[];
totalClicks: number;
totalQueries: number;
totalQueriesNoResults: number;
clicksPerDay: number[];
queriesPerDay: number[];
queriesNoResultsPerDay: number[];
}
export interface QueryDetails extends BaseData {
totalQueriesForQuery: number;
queriesPerDayForQuery: number[];
topClicksForQuery: QueryClick[];
}

View file

@ -1,45 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { convertToChartData, convertTagsToSelectOptions } from './utils';
describe('convertToChartData', () => {
it('converts server-side analytics data into an array of objects that Elastic Charts can consume', () => {
expect(
convertToChartData({
startDate: '1970-01-01',
data: [0, 1, 5, 50, 25],
})
).toEqual([
{ x: '1970-01-01', y: 0 },
{ x: '1970-01-02', y: 1 },
{ x: '1970-01-03', y: 5 },
{ x: '1970-01-04', y: 50 },
{ x: '1970-01-05', y: 25 },
]);
});
});
describe('convertTagsToSelectOptions', () => {
it('converts server-side tag data into an array of objects that EuiSelect can consume', () => {
expect(
convertTagsToSelectOptions([
'All Analytics Tags',
'lorem_ipsum',
'dolor_sit',
'amet',
'consectetur_adipiscing_elit',
])
).toEqual([
{ value: '', text: 'All analytics tags' },
{ value: 'lorem_ipsum', text: 'lorem_ipsum' },
{ value: 'dolor_sit', text: 'dolor_sit' },
{ value: 'amet', text: 'amet' },
{ value: 'consectetur_adipiscing_elit', text: 'consectetur_adipiscing_elit' },
]);
});
});

View file

@ -1,48 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import { EuiSelectProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ChartData } from './components/analytics_chart';
import { SERVER_DATE_FORMAT } from './constants';
interface ConvertToChartData {
data: number[];
startDate: string;
}
export const convertToChartData = ({ data, startDate }: ConvertToChartData): ChartData => {
const date = moment(startDate, SERVER_DATE_FORMAT);
return data.map((y, index) => ({
x: moment(date).add(index, 'days').format(SERVER_DATE_FORMAT),
y,
}));
};
export const convertTagsToSelectOptions = (tags: string[]): EuiSelectProps['options'] => {
// Our server API returns an initial default tag for us, but we don't want to use it because
// it's not i18n'ed, and also setting the value to '' is nicer for select/param UX
tags = tags.slice(1);
const DEFAULT_OPTION = {
value: '',
text: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.allTagsDropDownOptionLabel',
{ defaultMessage: 'All analytics tags' }
),
};
return [
DEFAULT_OPTION,
...tags.map((tag: string) => ({
value: tag,
text: tag,
})),
];
};

View file

@ -1,5 +0,0 @@
.analyticsOverviewTables {
@include euiBreakpoint('xs', 's', 'm', 'l') {
flex-direction: column; // Force full width on table panels earlier to ensure content stays legible
}
}

View file

@ -1,60 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../__mocks__/kea_logic';
import '../../../__mocks__/engine_logic.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { SuggestedCurationsCallout } from '../../engine_overview/components/suggested_curations_callout';
import {
AnalyticsCards,
AnalyticsChart,
AnalyticsSection,
AnalyticsTable,
RecentQueriesTable,
} from '../components';
import { Analytics, ViewAllButton } from './analytics';
describe('Analytics overview', () => {
it('renders', () => {
setMockValues({
totalQueries: 3,
totalQueriesNoResults: 2,
totalClicks: 1,
queriesPerDay: [10, 20, 30],
queriesNoResultsPerDay: [1, 2, 3],
clicksPerDay: [0, 1, 5],
startDate: '1970-01-01',
topQueries: [],
topQueriesNoResults: [],
topQueriesNoClicks: [],
topQueriesWithClicks: [],
recentQueries: [],
});
const wrapper = shallow(<Analytics />);
expect(wrapper.find(SuggestedCurationsCallout)).toHaveLength(1);
expect(wrapper.find(AnalyticsCards)).toHaveLength(1);
expect(wrapper.find(AnalyticsChart)).toHaveLength(1);
expect(wrapper.find(AnalyticsSection)).toHaveLength(2);
expect(wrapper.find(AnalyticsTable)).toHaveLength(4);
expect(wrapper.find(RecentQueriesTable)).toHaveLength(1);
});
describe('ViewAllButton', () => {
it('renders', () => {
const to = '/analytics/top_queries';
const wrapper = shallow(<ViewAllButton to={to} />);
expect(wrapper.prop('to')).toEqual(to);
});
});
});

View file

@ -1,220 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AnalyticsLogic, AnalyticsCards, AnalyticsChart, convertToChartData } from '..';
import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers';
import { CursorIcon } from '../../../icons';
import {
ENGINE_ANALYTICS_TOP_QUERIES_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH,
ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH,
ENGINE_ANALYTICS_RECENT_QUERIES_PATH,
} from '../../../routes';
import { DataPanel } from '../../data_panel';
import { generateEnginePath } from '../../engine';
import { SuggestedCurationsCallout } from '../../engine_overview/components/suggested_curations_callout';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSection, AnalyticsTable, RecentQueriesTable } from '../components';
import {
ANALYTICS_TITLE,
TOTAL_QUERIES,
TOTAL_QUERIES_NO_RESULTS,
TOTAL_CLICKS,
TOP_QUERIES,
TOP_QUERIES_NO_RESULTS,
TOP_QUERIES_WITH_CLICKS,
TOP_QUERIES_NO_CLICKS,
RECENT_QUERIES,
} from '../constants';
import './analytics.scss';
export const Analytics: React.FC = () => {
const {
totalQueries,
totalQueriesNoResults,
totalClicks,
queriesPerDay,
queriesNoResultsPerDay,
clicksPerDay,
startDate,
topQueries,
topQueriesNoResults,
topQueriesWithClicks,
topQueriesNoClicks,
recentQueries,
} = useValues(AnalyticsLogic);
return (
<AnalyticsLayout isAnalyticsView title={ANALYTICS_TITLE}>
<SuggestedCurationsCallout />
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={1}>
<AnalyticsCards
stats={[
{
stat: totalQueries,
text: TOTAL_QUERIES,
dataTestSubj: 'TotalQueriesCard',
},
{
stat: totalQueriesNoResults,
text: TOTAL_QUERIES_NO_RESULTS,
dataTestSubj: 'TotalQueriesNoResultsCard',
},
{
stat: totalClicks,
text: TOTAL_CLICKS,
dataTestSubj: 'TotalClicksCard',
},
]}
/>
</EuiFlexItem>
<EuiFlexItem grow={3}>
<EuiPanel hasBorder>
<AnalyticsChart
lines={[
{
id: TOTAL_QUERIES,
data: convertToChartData({ startDate, data: queriesPerDay }),
},
{
id: TOTAL_QUERIES_NO_RESULTS,
data: convertToChartData({ startDate, data: queriesNoResultsPerDay }),
isDashed: true,
},
{
id: TOTAL_CLICKS,
data: convertToChartData({ startDate, data: clicksPerDay }),
isDashed: true,
},
]}
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiSpacer />
<AnalyticsSection
title={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryTablesTitle',
{ defaultMessage: 'Query analytics' }
)}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryTablesDescription',
{
defaultMessage:
'Gain insight into the most frequent queries, and which queries returned no results.',
}
)}
iconType="search"
>
<EuiFlexGroup className="analyticsOverviewTables">
<EuiFlexItem>
<DataPanel
title={<h3>{TOP_QUERIES}</h3>}
filled
action={<ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_PATH)} />}
>
<AnalyticsTable items={topQueries.slice(0, 10)} hasClicks isSmall />
</DataPanel>
</EuiFlexItem>
<EuiFlexItem>
<DataPanel
title={<h3>{TOP_QUERIES_NO_RESULTS}</h3>}
filled
action={
<ViewAllButton
to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH)}
/>
}
>
<AnalyticsTable items={topQueriesNoResults.slice(0, 10)} isSmall />
</DataPanel>
</EuiFlexItem>
</EuiFlexGroup>
</AnalyticsSection>
<EuiSpacer size="xl" />
<AnalyticsSection
title={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.clickTablesTitle',
{ defaultMessage: 'Click analytics' }
)}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.clickTablesDescription',
{
defaultMessage: 'Discover which queries generated the most and least amount of clicks.',
}
)}
iconType={CursorIcon}
>
<EuiFlexGroup className="analyticsOverviewTables">
<EuiFlexItem>
<DataPanel
title={<h3>{TOP_QUERIES_WITH_CLICKS}</h3>}
filled
action={
<ViewAllButton
to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH)}
/>
}
>
<AnalyticsTable items={topQueriesWithClicks.slice(0, 10)} hasClicks isSmall />
</DataPanel>
</EuiFlexItem>
<EuiFlexItem>
<DataPanel
title={<h3>{TOP_QUERIES_NO_CLICKS}</h3>}
filled
action={
<ViewAllButton
to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH)}
/>
}
>
<AnalyticsTable items={topQueriesNoClicks.slice(0, 10)} isSmall />
</DataPanel>
</EuiFlexItem>
</EuiFlexGroup>
</AnalyticsSection>
<EuiSpacer size="xl" />
<DataPanel
hasBorder
title={<h2>{RECENT_QUERIES}</h2>}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.recentQueriesDescription',
{ defaultMessage: 'A view into queries happening right now.' }
)}
action={<ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_RECENT_QUERIES_PATH)} />}
>
<RecentQueriesTable items={recentQueries.slice(0, 10)} />
</DataPanel>
</AnalyticsLayout>
);
};
export const ViewAllButton: React.FC<{ to: string }> = ({ to }) => (
<EuiButtonEmptyTo to={to} size="s" iconType="eye">
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.viewAllButtonLabel', {
defaultMessage: 'View all',
})}
</EuiButtonEmptyTo>
);

View file

@ -1,14 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { Analytics } from './analytics';
export { TopQueries } from './top_queries';
export { TopQueriesNoResults } from './top_queries_no_results';
export { TopQueriesNoClicks } from './top_queries_no_clicks';
export { TopQueriesWithClicks } from './top_queries_with_clicks';
export { RecentQueries } from './recent_queries';
export { QueryDetail } from './query_detail';

View file

@ -1,47 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../__mocks__/kea_logic';
import { mockUseParams } from '../../../../__mocks__/react_router';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsCards, AnalyticsChart, QueryClicksTable } from '../components';
import { QueryDetail } from '.';
describe('QueryDetail', () => {
beforeEach(() => {
mockUseParams.mockReturnValue({ query: 'some-query' });
setMockValues({
totalQueriesForQuery: 100,
queriesPerDayForQuery: [0, 5, 10],
});
});
it('renders', () => {
const wrapper = shallow(<QueryDetail />);
expect(wrapper.find(AnalyticsLayout).prop('title')).toEqual('"some-query"');
expect(wrapper.find(AnalyticsLayout).prop('breadcrumbs')).toEqual(['Query', 'some-query']);
expect(wrapper.find(AnalyticsCards)).toHaveLength(1);
expect(wrapper.find(AnalyticsChart)).toHaveLength(1);
expect(wrapper.find(QueryClicksTable)).toHaveLength(1);
});
it('renders empty "" search titles correctly', () => {
mockUseParams.mockReturnValue({ query: '""' });
const wrapper = shallow(<QueryDetail />);
expect(wrapper.find(AnalyticsLayout).prop('title')).toEqual('""');
});
});

View file

@ -1,80 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import { EuiPanel, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AnalyticsLogic, AnalyticsCards, AnalyticsChart, convertToChartData } from '..';
import { useDecodedParams } from '../../../utils/encode_path_params';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSection, QueryClicksTable } from '../components';
const QUERY_DETAIL_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetail.title',
{ defaultMessage: 'Query' }
);
export const QueryDetail: React.FC = () => {
const { query } = useDecodedParams();
const queryTitle = query === '""' ? query : `"${query}"`;
const { totalQueriesForQuery, queriesPerDayForQuery, startDate, topClicksForQuery } =
useValues(AnalyticsLogic);
return (
<AnalyticsLayout isQueryView title={queryTitle} breadcrumbs={[QUERY_DETAIL_TITLE, query]}>
<AnalyticsCards
stats={[
{
stat: totalQueriesForQuery,
text: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetail.cardDescription',
{
defaultMessage: 'Queries for {queryTitle}',
values: { queryTitle: query },
}
),
},
]}
/>
<EuiSpacer />
<EuiPanel hasBorder>
<AnalyticsChart
lines={[
{
id: i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetail.chartTooltip',
{ defaultMessage: 'Queries per day' }
),
data: convertToChartData({ startDate, data: queriesPerDayForQuery }),
},
]}
/>
</EuiPanel>
<EuiSpacer />
<AnalyticsSection
title={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetail.tableTitle',
{ defaultMessage: 'Top clicks' }
)}
subtitle={i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetail.tableDescription',
{ defaultMessage: 'The documents with the most clicks resulting from this query.' }
)}
>
<QueryClicksTable items={topClicksForQuery} />
</AnalyticsSection>
</AnalyticsLayout>
);
};

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { RecentQueriesTable } from '../components';
import { RecentQueries } from '.';
describe('RecentQueries', () => {
it('renders', () => {
setMockValues({ recentQueries: [] });
const wrapper = shallow(<RecentQueries />);
expect(wrapper.find(RecentQueriesTable)).toHaveLength(1);
});
});

View file

@ -1,26 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import { AnalyticsLogic } from '..';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSearch, RecentQueriesTable } from '../components';
import { RECENT_QUERIES } from '../constants';
export const RecentQueries: React.FC = () => {
const { recentQueries } = useValues(AnalyticsLogic);
return (
<AnalyticsLayout isAnalyticsView title={RECENT_QUERIES} breadcrumbs={[RECENT_QUERIES]}>
<AnalyticsSearch />
<RecentQueriesTable items={recentQueries} />
</AnalyticsLayout>
);
};

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsTable } from '../components';
import { TopQueries } from '.';
describe('TopQueries', () => {
it('renders', () => {
setMockValues({ topQueries: [] });
const wrapper = shallow(<TopQueries />);
expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});

View file

@ -1,26 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import { AnalyticsLogic } from '..';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSearch, AnalyticsTable } from '../components';
import { TOP_QUERIES } from '../constants';
export const TopQueries: React.FC = () => {
const { topQueries } = useValues(AnalyticsLogic);
return (
<AnalyticsLayout isAnalyticsView title={TOP_QUERIES} breadcrumbs={[TOP_QUERIES]}>
<AnalyticsSearch />
<AnalyticsTable items={topQueries} hasClicks />
</AnalyticsLayout>
);
};

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsTable } from '../components';
import { TopQueriesNoClicks } from '.';
describe('TopQueriesNoClicks', () => {
it('renders', () => {
setMockValues({ topQueriesNoClicks: [] });
const wrapper = shallow(<TopQueriesNoClicks />);
expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import { AnalyticsLogic } from '..';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSearch, AnalyticsTable } from '../components';
import { TOP_QUERIES_NO_CLICKS } from '../constants';
export const TopQueriesNoClicks: React.FC = () => {
const { topQueriesNoClicks } = useValues(AnalyticsLogic);
return (
<AnalyticsLayout
isAnalyticsView
title={TOP_QUERIES_NO_CLICKS}
breadcrumbs={[TOP_QUERIES_NO_CLICKS]}
>
<AnalyticsSearch />
<AnalyticsTable items={topQueriesNoClicks} hasClicks />
</AnalyticsLayout>
);
};

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsTable } from '../components';
import { TopQueriesNoResults } from '.';
describe('TopQueriesNoResults', () => {
it('renders', () => {
setMockValues({ topQueriesNoResults: [] });
const wrapper = shallow(<TopQueriesNoResults />);
expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import { AnalyticsLogic } from '..';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSearch, AnalyticsTable } from '../components';
import { TOP_QUERIES_NO_RESULTS } from '../constants';
export const TopQueriesNoResults: React.FC = () => {
const { topQueriesNoResults } = useValues(AnalyticsLogic);
return (
<AnalyticsLayout
isAnalyticsView
title={TOP_QUERIES_NO_RESULTS}
breadcrumbs={[TOP_QUERIES_NO_RESULTS]}
>
<AnalyticsSearch />
<AnalyticsTable items={topQueriesNoResults} hasClicks />
</AnalyticsLayout>
);
};

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { AnalyticsTable } from '../components';
import { TopQueriesWithClicks } from '.';
describe('TopQueriesWithClicks', () => {
it('renders', () => {
setMockValues({ topQueriesWithClicks: [] });
const wrapper = shallow(<TopQueriesWithClicks />);
expect(wrapper.find(AnalyticsTable)).toHaveLength(1);
});
});

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import { AnalyticsLogic } from '..';
import { AnalyticsLayout } from '../analytics_layout';
import { AnalyticsSearch, AnalyticsTable } from '../components';
import { TOP_QUERIES_WITH_CLICKS } from '../constants';
export const TopQueriesWithClicks: React.FC = () => {
const { topQueriesWithClicks } = useValues(AnalyticsLogic);
return (
<AnalyticsLayout
isAnalyticsView
title={TOP_QUERIES_WITH_CLICKS}
breadcrumbs={[TOP_QUERIES_WITH_CLICKS]}
>
<AnalyticsSearch />
<AnalyticsTable items={topQueriesWithClicks} hasClicks />
</AnalyticsLayout>
);
};

View file

@ -1,17 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const mockApiLog = {
timestamp: '1970-01-01T12:00:00.000Z',
http_method: 'POST',
status: 200,
user_agent: 'Mozilla/5.0',
full_request_path: '/api/as/v1/engines/national-parks-demo/search.json',
request_body: '{"query":"test search"}',
response_body:
'{"meta":{"page":{"current":1,"total_pages":0,"total_results":0,"size":20}},"results":[]}',
};

View file

@ -1,62 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic';
import { mockApiLog } from '../__mocks__/api_log.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiFlyout, EuiBadge } from '@elastic/eui';
import { ApiLogFlyout, ApiLogHeading } from './api_log_flyout';
describe('ApiLogFlyout', () => {
const values = {
isFlyoutOpen: true,
apiLog: mockApiLog,
};
const actions = {
closeFlyout: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
const wrapper = shallow(<ApiLogFlyout />);
expect(wrapper.find('h2').text()).toEqual('Request details');
expect(wrapper.find(ApiLogHeading).last().dive().find('h3').text()).toEqual('Response body');
expect(wrapper.find(EuiBadge).prop('children')).toEqual('POST');
});
it('closes the flyout', () => {
const wrapper = shallow(<ApiLogFlyout />);
wrapper.find(EuiFlyout).simulate('close');
expect(actions.closeFlyout).toHaveBeenCalled();
});
it('does not render if the flyout is not open', () => {
setMockValues({ ...values, isFlyoutOpen: false });
const wrapper = shallow(<ApiLogFlyout />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('does not render if a current apiLog has not been set', () => {
setMockValues({ ...values, apiLog: null });
const wrapper = shallow(<ApiLogFlyout />);
expect(wrapper.isEmptyRender()).toBe(true);
});
});

View file

@ -1,131 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, PropsWithChildren } from 'react';
import { useActions, useValues } from 'kea';
import {
EuiPortal,
EuiFlyout,
EuiFlyoutHeader,
EuiTitle,
EuiFlyoutBody,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiBadge,
EuiHealth,
EuiText,
EuiCode,
EuiCodeBlock,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { getStatusColor, attemptToFormatJson } from '../utils';
import { ApiLogLogic } from '.';
export const ApiLogFlyout: React.FC = () => {
const { isFlyoutOpen, apiLog } = useValues(ApiLogLogic);
const { closeFlyout } = useActions(ApiLogLogic);
if (!isFlyoutOpen) return null;
if (!apiLog) return null;
return (
<EuiPortal>
<EuiFlyout ownFocus onClose={closeFlyout} aria-labelledby="apiLogFlyout">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2 id="apiLogFlyout">
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.flyout.title', {
defaultMessage: 'Request details',
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFlexGroup>
<EuiFlexItem>
<ApiLogHeading>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.methodTitle', {
defaultMessage: 'Method',
})}
</ApiLogHeading>
<div>
<EuiBadge color="primary">{apiLog.http_method}</EuiBadge>
</div>
</EuiFlexItem>
<EuiFlexItem>
<ApiLogHeading>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.statusTitle', {
defaultMessage: 'Status',
})}
</ApiLogHeading>
<EuiHealth color={getStatusColor(apiLog.status)}>{apiLog.status}</EuiHealth>
</EuiFlexItem>
<EuiFlexItem>
<ApiLogHeading>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.timestampTitle', {
defaultMessage: 'Timestamp',
})}
</ApiLogHeading>
{apiLog.timestamp}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<ApiLogHeading>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.userAgentTitle', {
defaultMessage: 'User agent',
})}
</ApiLogHeading>
<EuiText>
<EuiCode>{apiLog.user_agent}</EuiCode>
</EuiText>
<EuiSpacer />
<ApiLogHeading>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.requestPathTitle', {
defaultMessage: 'Request path',
})}
</ApiLogHeading>
<EuiText>
<EuiCode>{apiLog.full_request_path}</EuiCode>
</EuiText>
<EuiSpacer />
<ApiLogHeading>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.requestBodyTitle', {
defaultMessage: 'Request body',
})}
</ApiLogHeading>
<EuiCodeBlock language="json" paddingSize="m">
{attemptToFormatJson(apiLog.request_body)}
</EuiCodeBlock>
<EuiSpacer />
<ApiLogHeading>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.responseBodyTitle', {
defaultMessage: 'Response body',
})}
</ApiLogHeading>
<EuiCodeBlock language="json" paddingSize="m">
{attemptToFormatJson(apiLog.response_body)}
</EuiCodeBlock>
</EuiFlyoutBody>
</EuiFlyout>
</EuiPortal>
);
};
export const ApiLogHeading: FC<PropsWithChildren<unknown>> = ({ children }) => (
<EuiTitle size="xs">
<h3>{children}</h3>
</EuiTitle>
);

View file

@ -1,57 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { LogicMounter } from '../../../../__mocks__/kea_logic';
import { mockApiLog } from '../__mocks__/api_log.mock';
import { ApiLogLogic } from '.';
describe('ApiLogLogic', () => {
const { mount } = new LogicMounter(ApiLogLogic);
const DEFAULT_VALUES = {
isFlyoutOpen: false,
apiLog: null,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('has expected default values', () => {
mount();
expect(ApiLogLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('openFlyout', () => {
it('sets isFlyoutOpen to true & sets the current apiLog', () => {
mount({ isFlyoutOpen: false, apiLog: null });
ApiLogLogic.actions.openFlyout(mockApiLog);
expect(ApiLogLogic.values).toEqual({
...DEFAULT_VALUES,
isFlyoutOpen: true,
apiLog: mockApiLog,
});
});
});
describe('closeFlyout', () => {
it('sets isFlyoutOpen to false & resets the current apiLog', () => {
mount({ isFlyoutOpen: true, apiLog: mockApiLog });
ApiLogLogic.actions.closeFlyout();
expect(ApiLogLogic.values).toEqual({
...DEFAULT_VALUES,
isFlyoutOpen: false,
apiLog: null,
});
});
});
});
});

View file

@ -1,45 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { ApiLog } from '../types';
interface ApiLogValues {
isFlyoutOpen: boolean;
apiLog: ApiLog | null;
}
interface ApiLogActions {
openFlyout(apiLog: ApiLog): { apiLog: ApiLog };
closeFlyout(): void;
}
export const ApiLogLogic = kea<MakeLogicType<ApiLogValues, ApiLogActions>>({
path: ['enterprise_search', 'app_search', 'api_log_logic'],
actions: () => ({
openFlyout: (apiLog) => ({ apiLog }),
closeFlyout: true,
}),
reducers: () => ({
isFlyoutOpen: [
false,
{
openFlyout: () => true,
closeFlyout: () => false,
},
],
apiLog: [
null,
{
// @ts-expect-error upgrade typescript v5.1.6
openFlyout: (_, { apiLog }) => apiLog,
closeFlyout: () => null,
},
],
}),
});

View file

@ -1,9 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { ApiLogFlyout } from './api_log_flyout';
export { ApiLogLogic } from './api_log_logic';

View file

@ -1,82 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic';
import '../../../__mocks__/shallow_useeffect.mock';
import '../../__mocks__/engine_logic.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { rerender, getPageTitle } from '../../../test_helpers';
import { LogRetentionCallout, LogRetentionTooltip } from '../log_retention';
import { ApiLogsTable, NewApiEventsPrompt } from './components';
import { ApiLogs } from '.';
describe('ApiLogs', () => {
const values = {
dataLoading: false,
apiLogs: [],
meta: { page: { current: 1 } },
};
const actions = {
fetchApiLogs: jest.fn(),
pollForApiLogs: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
const wrapper = shallow(<ApiLogs />);
expect(getPageTitle(wrapper)).toEqual('API Logs');
expect(wrapper.find(ApiLogsTable)).toHaveLength(1);
expect(wrapper.find(NewApiEventsPrompt)).toHaveLength(1);
expect(wrapper.find(LogRetentionCallout).prop('type')).toEqual('api');
expect(wrapper.find(LogRetentionTooltip).prop('type')).toEqual('api');
});
describe('loading state', () => {
it('renders a full-page loading state on initial page load (no logs exist yet)', () => {
setMockValues({ ...values, dataLoading: true, apiLogs: [] });
const wrapper = shallow(<ApiLogs />);
expect(wrapper.prop('isLoading')).toEqual(true);
});
it('does not re-render a full-page loading state after initial page load (uses component-level loading state instead)', () => {
setMockValues({ ...values, dataLoading: true, apiLogs: [{}] });
const wrapper = shallow(<ApiLogs />);
expect(wrapper.prop('isLoading')).toEqual(false);
});
});
describe('effects', () => {
it('calls a manual fetchApiLogs on page load and pagination', () => {
const wrapper = shallow(<ApiLogs />);
expect(actions.fetchApiLogs).toHaveBeenCalledTimes(1);
setMockValues({ ...values, meta: { page: { current: 2 } } });
rerender(wrapper);
expect(actions.fetchApiLogs).toHaveBeenCalledTimes(2);
});
it('starts pollForApiLogs on page load', () => {
shallow(<ApiLogs />);
expect(actions.pollForApiLogs).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -1,68 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect } from 'react';
import { useValues, useActions } from 'kea';
import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import { getEngineBreadcrumbs } from '../engine';
import { AppSearchPageTemplate } from '../layout';
import { LogRetentionCallout, LogRetentionTooltip, LogRetentionOptions } from '../log_retention';
import { ApiLogFlyout } from './api_log';
import { ApiLogsTable, NewApiEventsPrompt, EmptyState } from './components';
import { API_LOGS_TITLE, RECENT_API_EVENTS } from './constants';
import { ApiLogsLogic } from '.';
export const ApiLogs: React.FC = () => {
const { dataLoading, apiLogs, meta } = useValues(ApiLogsLogic);
const { fetchApiLogs, pollForApiLogs } = useActions(ApiLogsLogic);
useEffect(() => {
fetchApiLogs();
}, [meta.page.current]);
useEffect(() => {
pollForApiLogs();
}, []);
return (
<AppSearchPageTemplate
pageChrome={getEngineBreadcrumbs([API_LOGS_TITLE])}
pageHeader={{ pageTitle: API_LOGS_TITLE }}
isLoading={dataLoading && !apiLogs.length}
isEmptyState={!apiLogs.length}
emptyState={<EmptyState />}
>
<LogRetentionCallout type={LogRetentionOptions.API} />
<EuiPanel hasBorder>
<EuiFlexGroup gutterSize="m" alignItems="center" responsive={false} wrap>
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h2>{RECENT_API_EVENTS}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LogRetentionTooltip type={LogRetentionOptions.API} />
</EuiFlexItem>
<EuiFlexItem />
<EuiFlexItem grow={false}>
<NewApiEventsPrompt />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<ApiLogsTable hasPagination />
<ApiLogFlyout />
</EuiPanel>
</AppSearchPageTemplate>
);
};

View file

@ -1,301 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { mockApiLog } from './__mocks__/api_log.mock';
import {
LogicMounter,
mockHttpValues,
mockFlashMessageHelpers,
} from '../../../__mocks__/kea_logic';
import '../../__mocks__/engine_logic.mock';
import { nextTick } from '@kbn/test-jest-helpers';
import { DEFAULT_META } from '../../../shared/constants';
import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers';
import { ApiLogsLogic } from '.';
describe('ApiLogsLogic', () => {
const { mount, unmount } = new LogicMounter(ApiLogsLogic);
const { http } = mockHttpValues;
const { flashErrorToast } = mockFlashMessageHelpers;
const DEFAULT_VALUES = {
dataLoading: true,
apiLogs: [],
meta: DEFAULT_META,
hasNewData: false,
polledData: {},
intervalId: null,
};
const MOCK_API_RESPONSE = {
results: [mockApiLog, mockApiLog],
meta: {
page: {
current: 1,
total_pages: 10,
total_results: 100,
size: 10,
},
},
};
beforeEach(() => {
jest.clearAllMocks();
});
it('has expected default values', () => {
mount();
expect(ApiLogsLogic.values).toEqual(DEFAULT_VALUES);
});
describe('actions', () => {
describe('onPollStart', () => {
it('sets intervalId state', () => {
mount();
ApiLogsLogic.actions.onPollStart(123);
expect(ApiLogsLogic.values).toEqual({
...DEFAULT_VALUES,
intervalId: 123,
});
});
});
describe('storePolledData', () => {
it('sets hasNewData to true & polledData state', () => {
mount({ hasNewData: false });
ApiLogsLogic.actions.storePolledData(MOCK_API_RESPONSE);
expect(ApiLogsLogic.values).toEqual({
...DEFAULT_VALUES,
hasNewData: true,
polledData: MOCK_API_RESPONSE,
});
});
});
describe('updateView', () => {
it('sets dataLoading & hasNewData to false, sets apiLogs & meta state', () => {
mount({ dataLoading: true, hasNewData: true });
ApiLogsLogic.actions.updateView(MOCK_API_RESPONSE);
expect(ApiLogsLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: false,
hasNewData: false,
apiLogs: MOCK_API_RESPONSE.results,
meta: MOCK_API_RESPONSE.meta,
});
});
});
describe('onPaginate', () => {
it('sets dataLoading to true & sets meta state', () => {
mount({ dataLoading: false });
ApiLogsLogic.actions.onPaginate(5);
expect(ApiLogsLogic.values).toEqual({
...DEFAULT_VALUES,
dataLoading: true,
meta: {
...DEFAULT_META,
page: {
...DEFAULT_META.page,
current: 5,
},
},
});
});
});
});
describe('listeners', () => {
describe('pollForApiLogs', () => {
jest.useFakeTimers({ legacyFakeTimers: true });
const setIntervalSpy = jest.spyOn(global, 'setInterval');
it('starts a poll that calls fetchApiLogs at set intervals', () => {
mount();
jest.spyOn(ApiLogsLogic.actions, 'onPollStart');
jest.spyOn(ApiLogsLogic.actions, 'fetchApiLogs');
ApiLogsLogic.actions.pollForApiLogs();
expect(setIntervalSpy).toHaveBeenCalled();
expect(ApiLogsLogic.actions.onPollStart).toHaveBeenCalled();
jest.advanceTimersByTime(5000);
expect(ApiLogsLogic.actions.fetchApiLogs).toHaveBeenCalledWith({ isPoll: true });
});
it('does not create new polls if one already exists', () => {
mount({ intervalId: 123 });
ApiLogsLogic.actions.pollForApiLogs();
expect(setIntervalSpy).not.toHaveBeenCalled();
});
afterAll(() => jest.useRealTimers);
});
describe('fetchApiLogs', () => {
const mockDate = jest
.spyOn(global.Date, 'now')
.mockImplementation(() => new Date('1970-01-02').valueOf());
afterAll(() => mockDate.mockRestore());
it('should make an API call', () => {
mount();
ApiLogsLogic.actions.fetchApiLogs();
expect(http.get).toHaveBeenCalledWith('/internal/app_search/engines/some-engine/api_logs', {
query: {
'page[current]': 1,
'filters[date][from]': '1970-01-01T00:00:00.000Z',
'filters[date][to]': '1970-01-02T00:00:00.000Z',
sort_direction: 'desc',
},
});
});
describe('manual fetch (page load & pagination)', () => {
it('updates the view immediately with the returned data', async () => {
http.get.mockReturnValueOnce(Promise.resolve(MOCK_API_RESPONSE));
mount();
jest.spyOn(ApiLogsLogic.actions, 'updateView');
ApiLogsLogic.actions.fetchApiLogs();
await nextTick();
expect(ApiLogsLogic.actions.updateView).toHaveBeenCalledWith(MOCK_API_RESPONSE);
});
itShowsServerErrorAsFlashMessage(http.get, () => {
mount();
ApiLogsLogic.actions.fetchApiLogs();
});
});
describe('poll fetch (interval)', () => {
it('does not automatically update the view', async () => {
http.get.mockReturnValueOnce(Promise.resolve(MOCK_API_RESPONSE));
mount({ dataLoading: false });
jest.spyOn(ApiLogsLogic.actions, 'onPollInterval');
ApiLogsLogic.actions.fetchApiLogs({ isPoll: true });
await nextTick();
expect(ApiLogsLogic.actions.onPollInterval).toHaveBeenCalledWith(MOCK_API_RESPONSE);
});
it('sets a custom error message on poll error', async () => {
http.get.mockReturnValueOnce(Promise.reject('error'));
mount({ dataLoading: false });
ApiLogsLogic.actions.fetchApiLogs({ isPoll: true });
await nextTick();
expect(flashErrorToast).toHaveBeenCalledWith('Could not refresh API log data', {
text: expect.stringContaining('Please check your connection'),
toastLifeTimeMs: 3750,
});
});
});
describe('when a manual fetch and a poll fetch occur at the same time', () => {
it('should short-circuit polls in favor of manual fetches', async () => {
// dataLoading is the signal we're using to check for a manual fetch
mount({ dataLoading: true });
jest.spyOn(ApiLogsLogic.actions, 'onPollInterval');
ApiLogsLogic.actions.fetchApiLogs({ isPoll: true });
await nextTick();
expect(http.get).not.toHaveBeenCalled();
expect(ApiLogsLogic.actions.onPollInterval).not.toHaveBeenCalled();
});
});
});
describe('onPollInterval', () => {
describe('when API logs are empty and new polled data comes in', () => {
it('updates the view immediately with the returned data (no manual action required)', () => {
mount({ meta: { page: { total_results: 0 } } });
jest.spyOn(ApiLogsLogic.actions, 'updateView');
ApiLogsLogic.actions.onPollInterval(MOCK_API_RESPONSE);
expect(ApiLogsLogic.actions.updateView).toHaveBeenCalledWith(MOCK_API_RESPONSE);
});
});
describe('when previous API logs already exist on the page', () => {
describe('when new data is returned', () => {
it('stores the new polled data', () => {
mount({ meta: { page: { total_results: 1 } } });
jest.spyOn(ApiLogsLogic.actions, 'storePolledData');
ApiLogsLogic.actions.onPollInterval(MOCK_API_RESPONSE);
expect(ApiLogsLogic.actions.storePolledData).toHaveBeenCalledWith(MOCK_API_RESPONSE);
});
});
describe('when the same data is returned', () => {
it('does nothing', () => {
mount({ meta: { page: { total_results: 100 } } });
jest.spyOn(ApiLogsLogic.actions, 'updateView');
jest.spyOn(ApiLogsLogic.actions, 'storePolledData');
ApiLogsLogic.actions.onPollInterval(MOCK_API_RESPONSE);
expect(ApiLogsLogic.actions.updateView).not.toHaveBeenCalled();
expect(ApiLogsLogic.actions.storePolledData).not.toHaveBeenCalled();
});
});
});
});
describe('onUserRefresh', () => {
it('updates the apiLogs data with the stored polled data', () => {
mount({ apiLogs: [], polledData: MOCK_API_RESPONSE });
ApiLogsLogic.actions.onUserRefresh();
expect(ApiLogsLogic.values).toEqual({
...DEFAULT_VALUES,
apiLogs: MOCK_API_RESPONSE.results,
meta: MOCK_API_RESPONSE.meta,
polledData: MOCK_API_RESPONSE,
dataLoading: false,
});
});
});
});
describe('events', () => {
describe('unmount', () => {
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
it('clears the poll interval', () => {
mount({ intervalId: 123 });
unmount();
expect(clearIntervalSpy).toHaveBeenCalledWith(123);
});
it('does not clearInterval if a poll has not been started', () => {
mount({ intervalId: null });
unmount();
expect(clearIntervalSpy).not.toHaveBeenCalled();
});
});
});
});

View file

@ -1,167 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import { DEFAULT_META } from '../../../shared/constants';
import { flashAPIErrors, flashErrorToast } from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { updateMetaPageIndex } from '../../../shared/table_pagination';
import { EngineLogic } from '../engine';
import { POLLING_DURATION, POLLING_ERROR_TITLE, POLLING_ERROR_TEXT } from './constants';
import { ApiLogsData, ApiLog } from './types';
import { getDateString } from './utils';
interface ApiLogsValues {
dataLoading: boolean;
apiLogs: ApiLog[];
meta: ApiLogsData['meta'];
hasNewData: boolean;
polledData: ApiLogsData;
intervalId: number | null;
}
interface ApiLogsActions {
fetchApiLogs(options?: { isPoll: boolean }): { isPoll: boolean };
pollForApiLogs(): void;
onPollStart(intervalId: number): { intervalId: number };
onPollInterval(data: ApiLogsData): ApiLogsData;
storePolledData(data: ApiLogsData): ApiLogsData;
updateView(data: ApiLogsData): ApiLogsData;
onUserRefresh(): void;
onPaginate(newPageIndex: number): { newPageIndex: number };
}
export const ApiLogsLogic = kea<MakeLogicType<ApiLogsValues, ApiLogsActions>>({
path: ['enterprise_search', 'app_search', 'api_logs_logic'],
actions: () => ({
fetchApiLogs: ({ isPoll } = { isPoll: false }) => ({ isPoll }),
pollForApiLogs: true,
onPollStart: (intervalId) => ({ intervalId }),
onPollInterval: ({ results, meta }) => ({ results, meta }),
storePolledData: ({ results, meta }) => ({ results, meta }),
updateView: ({ results, meta }) => ({ results, meta }),
onUserRefresh: true,
onPaginate: (newPageIndex) => ({ newPageIndex }),
}),
reducers: () => ({
dataLoading: [
true,
{
updateView: () => false,
onPaginate: () => true,
},
],
apiLogs: [
[],
{
// @ts-expect-error upgrade typescript v5.1.6
updateView: (_, { results }) => results,
},
],
meta: [
DEFAULT_META,
{
// @ts-expect-error upgrade typescript v5.1.6
updateView: (_, { meta }) => meta,
// @ts-expect-error upgrade typescript v5.1.6
onPaginate: (state, { newPageIndex }) => updateMetaPageIndex(state, newPageIndex),
},
],
hasNewData: [
false,
{
storePolledData: () => true,
updateView: () => false,
},
],
polledData: [
{} as ApiLogsData,
{
// @ts-expect-error upgrade typescript v5.1.6
storePolledData: (_, data) => data,
},
],
intervalId: [
null,
{
// @ts-expect-error upgrade typescript v5.1.6
onPollStart: (_, { intervalId }) => intervalId,
},
],
}),
listeners: ({ actions, values }) => ({
pollForApiLogs: () => {
if (values.intervalId) return; // Ensure we only have one poll at a time
const id = window.setInterval(() => actions.fetchApiLogs({ isPoll: true }), POLLING_DURATION);
actions.onPollStart(id);
},
fetchApiLogs: async ({ isPoll }) => {
if (isPoll && values.dataLoading) return; // Manual fetches (i.e. user pagination) should override polling
const { http } = HttpLogic.values;
const { engineName } = EngineLogic.values;
try {
const response = await http.get<ApiLogsData>(
`/internal/app_search/engines/${engineName}/api_logs`,
{
query: {
'page[current]': values.meta.page.current,
'filters[date][from]': getDateString(-1),
'filters[date][to]': getDateString(),
sort_direction: 'desc',
},
}
);
// Manual fetches (e.g. page load, user pagination) should update the view immediately,
// while polls are stored in-state until the user manually triggers the 'Refresh' action
if (isPoll) {
actions.onPollInterval(response);
} else {
actions.updateView(response);
}
} catch (e) {
if (isPoll) {
// If polling fails, it will typically be due to http connection -
// we should send a more human-readable message if so
flashErrorToast(POLLING_ERROR_TITLE, {
text: POLLING_ERROR_TEXT,
toastLifeTimeMs: POLLING_DURATION * 0.75,
});
} else {
flashAPIErrors(e);
}
}
},
onPollInterval: (data, breakpoint) => {
breakpoint(); // Prevents errors if logic unmounts while fetching
const previousResults = values.meta.page.total_results;
const newResults = data.meta.page.total_results;
const isEmpty = previousResults === 0;
const hasNewData = previousResults !== newResults;
if (isEmpty && hasNewData) {
actions.updateView(data); // Empty logs should automatically update with new data without a manual action
} else if (hasNewData) {
actions.storePolledData(data); // Otherwise, store any new data until the user manually refreshes the table
}
},
onUserRefresh: () => {
actions.updateView(values.polledData);
},
}),
events: ({ values }) => ({
beforeUnmount() {
if (values.intervalId !== null) clearInterval(values.intervalId);
},
}),
});

View file

@ -1,5 +0,0 @@
.apiLogDetailButton {
// More closely mimics the regular line height of an EuiLink /
// compresses table rows back to the standard height
height: $euiSizeL !important;
}

View file

@ -1,111 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic';
// NOTE: We're mocking FormattedRelative here because it (currently) has
// console warn issues, and it allows us to skip mocking dates
jest.mock('@kbn/i18n-react', () => {
const { i18n } = jest.requireActual('@kbn/i18n');
i18n.init({ locale: 'en' });
return {
...(jest.requireActual('@kbn/i18n-react') as object),
FormattedRelative: jest.fn(() => '20 hours ago'),
};
});
import React from 'react';
import { EuiBasicTable, EuiBadge, EuiHealth, EuiButtonEmpty } from '@elastic/eui';
import { shallowWithIntl, mountWithIntl } from '@kbn/test-jest-helpers';
import { DEFAULT_META } from '../../../../shared/constants';
import { ApiLogsTable } from '.';
describe('ApiLogsTable', () => {
const apiLogs = [
{
timestamp: '1970-01-01T00:00:00.000Z',
status: 404,
http_method: 'GET',
full_request_path: '/api/as/v1/test',
},
{
timestamp: '1970-01-01T00:00:00.000Z',
status: 500,
http_method: 'DELETE',
full_request_path: '/api/as/v1/test',
},
{
timestamp: '1970-01-01T00:00:00.000Z',
status: 200,
http_method: 'POST',
full_request_path: '/api/as/v1/engines/some-engine/search',
},
];
const values = {
dataLoading: false,
apiLogs,
meta: DEFAULT_META,
};
const actions = {
onPaginate: jest.fn(),
openFlyout: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
const wrapper = mountWithIntl(<ApiLogsTable />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Method');
expect(tableContent).toContain('GET');
expect(tableContent).toContain('DELETE');
expect(tableContent).toContain('POST');
expect(wrapper.find(EuiBadge)).toHaveLength(3);
expect(tableContent).toContain('Time');
expect(tableContent).toContain('20 hours ago');
expect(tableContent).toContain('Endpoint');
expect(tableContent).toContain('/api/as/v1/test');
expect(tableContent).toContain('/api/as/v1/engines/some-engine/search');
expect(tableContent).toContain('Status');
expect(tableContent).toContain('404');
expect(tableContent).toContain('500');
expect(tableContent).toContain('200');
expect(wrapper.find(EuiHealth)).toHaveLength(3);
expect(wrapper.find(EuiButtonEmpty)).toHaveLength(3);
wrapper.find('[data-test-subj="ApiLogsTableDetailsButton"]').first().simulate('click');
expect(actions.openFlyout).toHaveBeenCalled();
});
describe('hasPagination', () => {
it('does not render with pagination by default', () => {
const wrapper = shallowWithIntl(<ApiLogsTable />);
expect(wrapper.find(EuiBasicTable).prop('pagination')).toBeFalsy();
});
it('renders pagination if hasPagination is true', () => {
const wrapper = shallowWithIntl(<ApiLogsTable hasPagination />);
expect(wrapper.find(EuiBasicTable).prop('pagination')).toBeTruthy();
});
});
});

View file

@ -1,116 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues, useActions } from 'kea';
import {
EuiBasicTable,
EuiBasicTableColumn,
EuiBadge,
EuiHealth,
EuiButtonEmpty,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedRelative } from '@kbn/i18n-react';
import { ApiLogsLogic } from '..';
import { convertMetaToPagination, handlePageChange } from '../../../../shared/table_pagination';
import { ApiLogLogic } from '../api_log';
import { ApiLog } from '../types';
import { getStatusColor } from '../utils';
import { EmptyState } from '.';
import './api_logs_table.scss';
interface Props {
hasPagination?: boolean;
}
export const ApiLogsTable: React.FC<Props> = ({ hasPagination }) => {
const { dataLoading, apiLogs, meta } = useValues(ApiLogsLogic);
const { onPaginate } = useActions(ApiLogsLogic);
const { openFlyout } = useActions(ApiLogLogic);
const columns: Array<EuiBasicTableColumn<ApiLog>> = [
{
field: 'http_method',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.methodTableHeading', {
defaultMessage: 'Method',
}),
width: '100px',
render: (method: string) => <EuiBadge color="primary">{method}</EuiBadge>,
},
{
field: 'timestamp',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.timeTableHeading', {
defaultMessage: 'Time',
}),
width: '20%',
render: (dateString: string) => <FormattedRelative value={new Date(dateString)} />,
},
{
field: 'full_request_path',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.endpointTableHeading', {
defaultMessage: 'Endpoint',
}),
width: '50%',
truncateText: true,
mobileOptions: {
// @ts-ignore - EUI's typing is incorrect here
width: '100%',
},
},
{
field: 'status',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.statusTableHeading', {
defaultMessage: 'Status',
}),
dataType: 'number',
width: '100px',
render: (status: number) => <EuiHealth color={getStatusColor(status)}>{status}</EuiHealth>,
},
{
width: '100px',
align: 'right',
render: (apiLog: ApiLog) => (
<EuiButtonEmpty
size="s"
className="apiLogDetailButton"
data-test-subj="ApiLogsTableDetailsButton"
onClick={() => openFlyout(apiLog)}
>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.detailsButtonLabel', {
defaultMessage: 'Details',
})}
</EuiButtonEmpty>
),
},
];
const paginationProps = hasPagination
? {
pagination: {
...convertMetaToPagination(meta),
showPerPageOptions: false,
},
onChange: handlePageChange(onPaginate),
}
: {};
return (
<EuiBasicTable
columns={columns}
items={apiLogs}
loading={dataLoading}
noItemsMessage={<EmptyState />}
{...paginationProps}
/>
);
};

View file

@ -1,29 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { EuiEmptyPrompt, EuiButton } from '@elastic/eui';
import { docLinks } from '../../../../shared/doc_links';
import { EmptyState } from '.';
describe('EmptyState', () => {
it('renders', () => {
const wrapper = shallow(<EmptyState />)
.find(EuiEmptyPrompt)
.dive();
expect(wrapper.find('h2').text()).toEqual('No API events in the last 24 hours');
expect(wrapper.find(EuiButton).prop('href')).toEqual(
expect.stringContaining(docLinks.appSearchApis)
);
});
});

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { docLinks } from '../../../../shared/doc_links';
export const EmptyState: React.FC = () => (
<EuiEmptyPrompt
iconType="clock"
title={
<h2>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyTitle', {
defaultMessage: 'No API events in the last 24 hours',
})}
</h2>
}
body={
<p>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyDescription', {
defaultMessage: 'Logs will update in real-time when an API request occurs.',
})}
</p>
}
actions={
<EuiButton size="s" target="_blank" iconType="popout" href={docLinks.appSearchApis}>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.empty.buttonLabel', {
defaultMessage: 'View the API reference',
})}
</EuiButton>
}
/>
);

View file

@ -1,10 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { ApiLogsTable } from './api_logs_table';
export { NewApiEventsPrompt } from './new_api_events_prompt';
export { EmptyState } from './empty_state';

View file

@ -1,6 +0,0 @@
.newApiEventsPrompt {
padding: $euiSizeXS;
padding-left: $euiSizeS;
display: flex;
align-items: center;
}

View file

@ -1,51 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiButtonEmpty } from '@elastic/eui';
import { NewApiEventsPrompt } from '.';
describe('NewApiEventsPrompt', () => {
const values = {
hasNewData: true,
};
const actions = {
onUserRefresh: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
const wrapper = shallow(<NewApiEventsPrompt />);
expect(wrapper.isEmptyRender()).toBe(false);
});
it('does not render if no new data has been polled', () => {
setMockValues({ ...values, hasNewData: false });
const wrapper = shallow(<NewApiEventsPrompt />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('calls onUserRefresh', () => {
const wrapper = shallow(<NewApiEventsPrompt />);
wrapper.find(EuiButtonEmpty).simulate('click');
expect(actions.onUserRefresh).toHaveBeenCalled();
});
});

View file

@ -1,35 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues, useActions } from 'kea';
import { EuiPanel, EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ApiLogsLogic } from '..';
import './new_api_events_prompt.scss';
export const NewApiEventsPrompt: React.FC = () => {
const { hasNewData } = useValues(ApiLogsLogic);
const { onUserRefresh } = useActions(ApiLogsLogic);
return hasNewData ? (
<EuiPanel color="subdued" hasShadow={false} paddingSize="s" className="newApiEventsPrompt">
{i18n.translate('xpack.enterpriseSearch.appSearch.engines.apiLogs.newEventsMessage', {
defaultMessage: 'New events have been logged.',
})}
<EuiButtonEmpty iconType="refresh" size="xs" onClick={onUserRefresh}>
{i18n.translate('xpack.enterpriseSearch.appSearch.engines.apiLogs.newEventsButtonLabel', {
defaultMessage: 'Refresh',
})}
</EuiButtonEmpty>
</EuiPanel>
) : null;
};

View file

@ -1,29 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const API_LOGS_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.apiLogs.title',
{ defaultMessage: 'API Logs' }
);
export const RECENT_API_EVENTS = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.apiLogs.recent',
{ defaultMessage: 'Recent API events' }
);
export const POLLING_DURATION = 5000;
export const POLLING_ERROR_TITLE = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.apiLogs.pollingErrorMessage',
{ defaultMessage: 'Could not refresh API log data' }
);
export const POLLING_ERROR_TEXT = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.apiLogs.pollingErrorDescription',
{ defaultMessage: 'Please check your connection or manually reload the page.' }
);

View file

@ -1,12 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { API_LOGS_TITLE } from './constants';
export { ApiLogsTable, NewApiEventsPrompt } from './components';
export { ApiLogFlyout } from './api_log';
export { ApiLogs } from './api_logs';
export { ApiLogsLogic } from './api_logs_logic';

View file

@ -1,27 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Meta } from '../../../../../common/types';
export interface ApiLog {
timestamp: string; // Date ISO string
status: number;
http_method: string;
full_request_path: string;
user_agent: string;
request_body: string; // JSON string
response_body: string; // JSON string
// NOTE: The API also sends us back `path: null`, but we don't appear to be
// using it anywhere, so I've opted not to list it in our types
}
export interface ApiLogsData {
results: ApiLog[];
meta: Meta;
// NOTE: The API sends us back even more `meta` data than the normal (sort_direction, filters, query),
// but we currently don't use that data in our front-end code, so I'm opting not to list them in our types
}

View file

@ -1,53 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import dedent from 'dedent';
import { getDateString, getStatusColor, attemptToFormatJson } from './utils';
describe('getDateString', () => {
const mockDate = jest
.spyOn(global.Date, 'now')
.mockImplementation(() => new Date('1970-01-02').valueOf());
it('gets the current date in ISO format', () => {
expect(getDateString()).toEqual('1970-01-02T00:00:00.000Z');
});
it('allows passing a number of days to offset the timestamp by', () => {
expect(getDateString(-1)).toEqual('1970-01-01T00:00:00.000Z');
expect(getDateString(10)).toEqual('1970-01-12T00:00:00.000Z');
});
afterAll(() => mockDate.mockRestore());
});
describe('getStatusColor', () => {
it('returns a valid EUI badge color based on the status code', () => {
expect(getStatusColor(200)).toEqual('success');
expect(getStatusColor(301)).toEqual('primary');
expect(getStatusColor(404)).toEqual('warning');
expect(getStatusColor(503)).toEqual('danger');
});
});
describe('attemptToFormatJson', () => {
it('takes an unformatted JSON string and correctly newlines/indents it', () => {
expect(attemptToFormatJson('{"hello":"world","lorem":{"ipsum":"dolor","sit":"amet"}}'))
.toEqual(dedent`{
"hello": "world",
"lorem": {
"ipsum": "dolor",
"sit": "amet"
}
}`);
});
it('returns the original content if it is not properly formatted JSON', () => {
expect(attemptToFormatJson('{invalid json}')).toEqual('{invalid json}');
});
});

View file

@ -1,31 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const getDateString = (offSetDays?: number) => {
const date = new Date(Date.now());
if (offSetDays) date.setDate(date.getDate() + offSetDays);
return date.toISOString();
};
export const getStatusColor = (status: number) => {
let color = '';
if (status >= 100 && status < 300) color = 'success';
if (status >= 300 && status < 400) color = 'primary';
if (status >= 400 && status < 500) color = 'warning';
if (status >= 500) color = 'danger';
return color;
};
export const attemptToFormatJson = (possibleJson: string) => {
try {
// it is JSON, we can format it with newlines/indentation
return JSON.stringify(JSON.parse(possibleJson), null, 2);
} catch {
// if it's not JSON, we return the original content
return possibleJson;
}
};

View file

@ -1,613 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useActions, useValues } from 'kea';
import {
EuiButton,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormLabel,
EuiFormRow,
EuiIcon,
EuiLink,
EuiPanel,
EuiSelect,
EuiSpacer,
EuiSuperSelect,
EuiText,
EuiTextArea,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { docLinks } from '../../../shared/doc_links';
import { AppSearchGateLogic } from './app_search_gate_logic';
const featuresList = {
webCrawler: {
actionLabel: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.webCrawler.featureButtonLabel',
{
defaultMessage: 'Try Open Crawler',
}
),
actionLink: 'https://github.com/elastic/crawler?tab=readme-ov-file#setup',
addOnLearnMoreLabel: undefined,
addOnLearnMoreUrl: undefined,
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.webCrawler.featureDescription',
{
defaultMessage: 'Ingest web content into Elasticsearch using a web crawler',
}
),
id: 'webCrawler',
learnMore: 'https://github.com/elastic/crawler#readme ',
panelText: i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.webCrawler.panelText', {
defaultMessage:
'Did you know the new self-managed Elastic open crawler is now available? You can keep your web content in sync with your search-optimized indices!',
}),
title: i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.webCrawler.featureName', {
defaultMessage: 'Web crawler',
}),
},
analyticsAndLogs: {
actionLabel: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.analyticsAndLogs.featureButtonLabel',
{
defaultMessage: 'Add search analytics',
}
),
actionLink:
'https://www.elastic.co/guide/en/elasticsearch/reference/current/behavioral-analytics-event.html',
addOnLearnMoreLabel: undefined,
addOnLearnMoreUrl: undefined,
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.analyticsAndLogs.featureDescription',
{
defaultMessage: 'Add and view analytics and logs for your search application',
}
),
id: 'analyticsAndLogs',
learnMore:
'https://www.elastic.co/guide/en/elasticsearch/reference/current/behavioral-analytics-overview.html',
panelText: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.analyticsAndLogs.panelText',
{
defaultMessage:
"You can track and analyze users' searching and clicking behavior with Behavioral Analytics. Instrument your website or application to track relevant user actions.",
}
),
title: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.analyticsAndLogs.featureName',
{
defaultMessage: 'Search analytics and logs',
}
),
},
synonyms: {
actionLabel: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.synonyms.featureButtonLabel',
{
defaultMessage: 'Search with synonyms',
}
),
actionLink:
'https://www.elastic.co/guide/en/elasticsearch/reference/current/synonyms-apis.html',
addOnLearnMoreLabel: undefined,
addOnLearnMoreUrl: undefined,
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.synonyms.featureDescription',
{
defaultMessage: 'Perform search with synonym based query expansion',
}
),
id: 'synonyms',
learnMore:
'https://www.elastic.co/guide/en/elasticsearch/reference/current/search-with-synonyms.html',
panelText: i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.synonyms.panelText', {
defaultMessage:
'Use the Elasticsearch Synonyms APIs to easily create and manage synonym sets.',
}),
title: i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.synonyms.featureName', {
defaultMessage: 'Search with synonyms',
}),
},
relevanceTuning: {
actionLabel: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.relevanceTuning.featureButtonLabel',
{
defaultMessage: 'Tune search relevancy',
}
),
actionLink:
'https://www.elastic.co/guide/en/elasticsearch/reference/current/search-with-elasticsearch.html',
addOnLearnMoreLabel: undefined,
addOnLearnMoreUrl: undefined,
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.relevanceTuning.featureDescription',
{
defaultMessage: 'Tune the relevancy of your results using ranking and boosting methods',
}
),
id: 'relevanceTuning',
learnMore:
'https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-boosting-query.html',
panelText: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.relevanceTuning.panelText',
{
defaultMessage: "Elasticsearch's query DSL provides an in-depth set of relevance tools.",
}
),
title: i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.relevanceTuning.featureName', {
defaultMessage: 'Relevance tuning',
}),
},
curations: {
actionLabel: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.curations.featureButtonLabel',
{
defaultMessage: 'Use query rules',
}
),
actionLink:
'https://www.elastic.co/guide/en/elasticsearch/reference/current/search-using-query-rules.html',
addOnLearnMoreLabel: undefined,
addOnLearnMoreUrl: undefined,
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.curations.featureDescription',
{
defaultMessage: 'Curate and pin results for specific queries',
}
),
id: 'curations',
learnMore: 'https://www.elastic.co/blog/introducing-query-rules-elasticsearch-8-10',
panelText: i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.curations.panelText', {
defaultMessage:
'Query rules provide a more robust set of tools to customize your search results for queries that match specific criteria and metadata.',
}),
title: i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.curations.featureName', {
defaultMessage: 'Curate results',
}),
},
searchManagementUis: {
actionLabel: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.searchManagementUis.featureButtonLabel',
{
defaultMessage: 'Build a search experience with Search UI',
}
),
actionLink: 'https://www.elastic.co/docs/current/search-ui/overview',
addOnLearnMoreLabel: undefined,
addOnLearnMoreUrl: undefined,
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.searchManagementUis.featureDescription',
{
defaultMessage:
'Use graphical user interfaces (GUIs) to manage your search application experience',
}
),
id: 'searchManagementUis',
learnMore: 'https://www.elastic.co/docs/current/search-ui/tutorials/elasticsearch',
panelText: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.searchManagementUis.panelText',
{
defaultMessage:
'Search UI provides the components needed to build a modern search experience.',
}
),
title: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.searchManagementUis.featureName',
{
defaultMessage: 'Search and management UIs',
}
),
},
credentials: {
actionLabel: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.credentials.featureButtonLabel',
{
defaultMessage: 'Secure with Elasticsearch',
}
),
actionLink:
'https://www.elastic.co/guide/en/elasticsearch/reference/current/document-level-security.html',
addOnLearnMoreLabel: undefined,
addOnLearnMoreUrl: undefined,
description: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.credentials.featureDescription',
{
defaultMessage:
'Manage your users and roles, and credentials for accessing your search endpoints',
}
),
id: 'credentials',
learnMore: 'https://www.elastic.co/search-labs/blog/dls-internal-knowledge-search',
panelText: i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.credentials.panelText', {
defaultMessage:
'Elasticsearch provides a comprehensive set of security features, including document-level security and role-based access control.',
}),
title: i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.credentials.featureName', {
defaultMessage: 'Credentials and roles',
}),
},
};
interface FeatureOption {
id: string;
title: string;
description: string;
learnMore: string | undefined;
actionLabel: string;
actionLink: string;
panelText: string;
addOnLearnMoreLabel?: string;
addOnLearnMoreUrl?: string;
}
const getFeature = (id: string): FeatureOption | undefined => {
switch (id) {
case featuresList.webCrawler.id:
return featuresList.webCrawler;
case featuresList.analyticsAndLogs.id:
return featuresList.analyticsAndLogs;
case featuresList.synonyms.id:
return featuresList.synonyms;
case featuresList.relevanceTuning.id:
return featuresList.relevanceTuning;
case featuresList.curations.id:
return featuresList.curations;
case featuresList.searchManagementUis.id:
return featuresList.searchManagementUis;
case featuresList.credentials.id:
return featuresList.credentials;
default:
return undefined;
}
};
interface FeatureOptionsSelection {
dropdownDisplay: React.ReactNode;
inputDisplay: string;
value: string;
}
const getOptionsFeaturesList = (): FeatureOptionsSelection[] => {
const baseTranslatePrefix = 'xpack.enterpriseSearch.appSearch.gateForm.superSelect';
const featureList = Object.keys(featuresList).map((featureKey): FeatureOptionsSelection => {
const feature = getFeature(featureKey);
if (!feature) {
return {
dropdownDisplay: <></>,
inputDisplay: '',
value: '',
};
}
return {
dropdownDisplay: (
<>
<strong>{feature.title}</strong>
<EuiText size="s" color="subdued">
<p>{feature.description}</p>
</EuiText>
</>
),
inputDisplay: feature.title,
value: feature.id,
};
});
featureList.push({
dropdownDisplay: (
<>
<strong>
{i18n.translate(`${baseTranslatePrefix}.other.title`, {
defaultMessage: 'Other',
})}
</strong>
<EuiText size="s" color="subdued">
<p>
{i18n.translate(`${baseTranslatePrefix}.other.description`, {
defaultMessage: 'Another feature not listed here',
})}
</p>
</EuiText>
</>
),
inputDisplay: i18n.translate(`${baseTranslatePrefix}.other.inputDisplay`, {
defaultMessage: 'Other',
}),
value: 'other',
});
return featureList;
};
const participateInUXLabsChoice = {
no: { choice: 'no', value: false },
yes: { choice: 'yes', value: true },
};
const EducationPanel: React.FC<{ featureContent: string }> = ({ featureContent }) => {
const feature = getFeature(featureContent);
const { setFeaturesOther } = useActions(AppSearchGateLogic);
if (feature) {
return (
<EuiPanel hasShadow={false}>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiIcon type="logoElastic" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<EuiText>
<h5>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.educationalPanel.title',
{
defaultMessage: 'Elasticsearch native equivalent',
}
)}
</h5>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
<p>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.educationalPanel.subTitle',
{
defaultMessage: 'Based on your selection we recommend:',
}
)}
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiCallOut title={feature.title} color="success" iconType="checkInCircleFilled">
<p>{feature.panelText}</p>
<EuiFlexGroup gutterSize="m" wrap alignItems="baseline">
{feature.actionLink !== undefined && feature.actionLabel !== undefined && (
<EuiFlexItem grow={false}>
<EuiButton
href={feature.actionLink}
target="_blank"
iconType="sortRight"
iconSide="right"
>
{feature.actionLabel}
</EuiButton>
</EuiFlexItem>
)}
{feature.learnMore !== undefined && (
<EuiFlexItem grow={false}>
<EuiLink href={feature.learnMore} target="_blank">
{i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.educationalPanel.learnMore',
{
defaultMessage: 'Learn more',
}
)}
</EuiLink>
</EuiFlexItem>
)}
{feature.addOnLearnMoreLabel !== undefined &&
feature.addOnLearnMoreUrl !== undefined && (
<EuiFlexItem grow={false}>
<EuiLink type="button" href={feature.addOnLearnMoreUrl} target="_blank" external>
<EuiSpacer />
{feature.addOnLearnMoreLabel}
</EuiLink>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiCallOut>
</EuiPanel>
);
} else {
return (
<>
<EuiSpacer />
<EuiFormRow
label={i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.featureOther.Label', {
defaultMessage: "Can you explain what other feature(s) you're looking for?",
})}
>
<EuiTextArea
onChange={(e) => {
setFeaturesOther(e.target.value);
}}
/>
</EuiFormRow>
</>
);
}
};
export const AppSearchGate: React.FC = () => {
const { feature, participateInUXLabs } = useValues(AppSearchGateLogic);
const { formSubmitRequest, setAdditionalFeedback, setParticipateInUXLabs, setFeature } =
useActions(AppSearchGateLogic);
const options = getOptionsFeaturesList();
return (
<EuiPanel hasShadow={false}>
<EuiForm component="form" fullWidth>
<EuiFormLabel>
{i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.features.Label', {
defaultMessage: 'What App Search feature are you looking to use?',
})}
</EuiFormLabel>
<EuiSpacer size="xs" />
<EuiSuperSelect
options={options}
valueOfSelected={feature}
placeholder={i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.features.selectOption',
{
defaultMessage: 'Select an option',
}
)}
onChange={(value) => setFeature(value)}
itemLayoutAlign="top"
hasDividers
fullWidth
/>
{feature && <EducationPanel featureContent={feature} />}
<EuiSpacer />
<EuiFormRow
label={i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.additionalFeedback.Label',
{
defaultMessage: 'Would you like to share any additional feedback?',
}
)}
labelAppend={i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.additionalFeedback.optionalLabel',
{
defaultMessage: 'Optional',
}
)}
>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<EuiTextArea
onChange={(e) => {
setAdditionalFeedback(e.target.value);
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="xs">
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.gateForm.additionalFeedback.description"
defaultMessage=" By submitting feedback you acknowledge that you've read and agree to our {termsOfService}, and that Elastic may {contact} about our related products and services,
using the details you provide above. See {privacyStatementLink} for more
details or to opt-out at any time."
values={{
contact: (
<EuiLink href={docLinks.workplaceSearchGatedFormDataUse}>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.gateForm.additionalFeedback.contact"
defaultMessage="contact you"
/>
</EuiLink>
),
privacyStatementLink: (
<EuiLink href={docLinks.workplaceSearchGatedFormPrivacyStatement}>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.gateForm.additionalFeedback.readDataPrivacyStatementLink"
defaultMessage="Elastics Privacy Statement"
/>
</EuiLink>
),
termsOfService: (
<EuiLink href={docLinks.workplaceSearchGatedFormTermsOfService}>
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.gateForm.additionalFeedback.readTermsOfService"
defaultMessage="Terms of Service"
/>
</EuiLink>
),
}}
/>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiSpacer />
<EuiFormRow
labelAppend={i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.participateUxLab.optionalLabel',
{
defaultMessage: 'Optional',
}
)}
label={i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.participateUxLab.Label',
{
defaultMessage: 'Join our user research studies to improve Elasticsearch?',
}
)}
>
<EuiSelect
hasNoInitialSelection={participateInUXLabs === null}
options={[
{
text: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.participateUxLab.Label.Yes',
{
defaultMessage: 'Yes',
}
),
value: participateInUXLabsChoice.yes.choice,
},
{
text: i18n.translate(
'xpack.enterpriseSearch.appSearch.gateForm.participateUxLab.Label.No',
{
defaultMessage: 'No',
}
),
value: participateInUXLabsChoice.no.choice,
},
]}
onChange={(e) =>
setParticipateInUXLabs(
e.target.value === participateInUXLabsChoice.yes.choice
? participateInUXLabsChoice.yes.value
: participateInUXLabsChoice.no.value
)
}
value={
participateInUXLabs !== null
? participateInUXLabs
? participateInUXLabsChoice.yes.choice
: participateInUXLabsChoice.no.choice
: undefined
}
/>
</EuiFormRow>
<EuiSpacer />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
isDisabled={!feature ?? false}
type="submit"
fill
onClick={() => formSubmitRequest()}
>
{i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.submit', {
defaultMessage: 'Submit',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
</EuiPanel>
);
};

View file

@ -1,36 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { mockHttpValues } from '../../../__mocks__/kea_logic';
import { nextTick } from '@kbn/test-jest-helpers';
import { sendAppSearchGatedFormData } from './app_search_gate_api_logic';
describe('AppSearchGatedFormApiLogic', () => {
const { http } = mockHttpValues;
beforeEach(() => {
jest.clearAllMocks();
});
describe('sendAppSearchGatedFormData', () => {
it('calls correct api', async () => {
const asFormData = {
additionalFeedback: 'my-test-additional-data',
feature: 'Web Crawler',
featuresOther: null,
participateInUXLabs: null,
};
const promise = Promise.resolve();
http.put.mockReturnValue(promise);
sendAppSearchGatedFormData(asFormData);
await nextTick();
expect(http.post).toHaveBeenCalledWith('/internal/app_search/as_gate', {
body: '{"as_gate_data":{"additional_feedback":"my-test-additional-data","feature":"Web Crawler"}}',
});
});
});
});

View file

@ -1,51 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic';
import { HttpLogic } from '../../../shared/http';
export interface AppSearchGatedFormDataApiLogicArguments {
additionalFeedback: string | null;
feature: string;
featuresOther: string | null;
participateInUXLabs: boolean | null;
}
export interface AppSearchGatedFormDataApiLogicResponse {
created: string;
}
export const sendAppSearchGatedFormData = async ({
feature,
featuresOther,
additionalFeedback,
participateInUXLabs,
}: AppSearchGatedFormDataApiLogicArguments): Promise<AppSearchGatedFormDataApiLogicResponse> => {
return await HttpLogic.values.http.post<AppSearchGatedFormDataApiLogicResponse>(
'/internal/app_search/as_gate',
{
body: JSON.stringify({
as_gate_data: {
additional_feedback: additionalFeedback != null ? additionalFeedback : undefined,
feature,
features_other: featuresOther != null ? featuresOther : undefined,
participate_in_ux_labs: participateInUXLabs != null ? participateInUXLabs : undefined,
},
}),
}
);
};
export type AppSearchGatedFormDataApiLogicActions = Actions<
AppSearchGatedFormDataApiLogicArguments,
AppSearchGatedFormDataApiLogicResponse
>;
export const UpdateAppSearchGatedFormDataApiLogic = createApiLogic(
['app_search', 'send_app_search_gatedForm_data_api_logic'],
sendAppSearchGatedFormData
);

View file

@ -1,57 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { LogicMounter } from '../../../__mocks__/kea_logic';
import { UpdateAppSearchGatedFormDataApiLogic } from './app_search_gate_api_logic';
import { AppSearchGateLogic } from './app_search_gate_logic';
const DEFAULT_VALUES = {
additionalFeedback: null,
feature: '',
featuresOther: null,
participateInUXLabs: null,
};
describe('Gated form data', () => {
const { mount: apiLogicMount } = new LogicMounter(UpdateAppSearchGatedFormDataApiLogic);
const { mount } = new LogicMounter(AppSearchGateLogic);
beforeEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
apiLogicMount();
mount();
});
it('has expected default values', () => {
expect(AppSearchGateLogic.values).toEqual(DEFAULT_VALUES);
});
describe('listeners', () => {
it('make Request with only feature, participateInUXLabs response ', () => {
jest.spyOn(AppSearchGateLogic.actions, 'submitGatedFormDataRequest');
AppSearchGateLogic.actions.setFeature('Web Crawler');
AppSearchGateLogic.actions.setParticipateInUXLabs(false);
AppSearchGateLogic.actions.formSubmitRequest();
expect(AppSearchGateLogic.actions.submitGatedFormDataRequest).toHaveBeenCalledTimes(1);
expect(AppSearchGateLogic.actions.submitGatedFormDataRequest).toHaveBeenCalledWith({
additionalFeedback: null,
feature: 'Web Crawler',
featuresOther: null,
participateInUXLabs: false,
});
});
it('when no feature selected, formSubmitRequest is not called', () => {
jest.spyOn(AppSearchGateLogic.actions, 'submitGatedFormDataRequest');
AppSearchGateLogic.actions.formSubmitRequest();
expect(AppSearchGateLogic.actions.submitGatedFormDataRequest).toHaveBeenCalledTimes(0);
});
});
});

View file

@ -1,98 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { kea, MakeLogicType } from 'kea';
import {
AppSearchGatedFormDataApiLogicActions,
UpdateAppSearchGatedFormDataApiLogic,
} from './app_search_gate_api_logic';
interface AppSearchGateValues {
additionalFeedback: string | null;
feature: string;
featuresOther: string | null;
participateInUXLabs: boolean | null;
}
interface AppSearchGateActions {
formSubmitRequest: () => void;
setAdditionalFeedback(additionalFeedback: string): { additionalFeedback: string };
setFeature(feature: string): { feature: string };
setFeaturesOther(featuresOther: string): { featuresOther: string };
setParticipateInUXLabs(participateInUXLabs: boolean): {
participateInUXLabs: boolean;
};
submitGatedFormDataRequest: AppSearchGatedFormDataApiLogicActions['makeRequest'];
}
export const AppSearchGateLogic = kea<MakeLogicType<AppSearchGateValues, AppSearchGateActions>>({
actions: {
formSubmitRequest: true,
setAdditionalFeedback: (additionalFeedback) => ({ additionalFeedback }),
setFeature: (feature) => ({ feature }),
setFeaturesOther: (featuresOther) => ({ featuresOther }),
setParticipateInUXLabs: (participateInUXLabs) => ({ participateInUXLabs }),
},
connect: {
actions: [UpdateAppSearchGatedFormDataApiLogic, ['makeRequest as submitGatedFormDataRequest']],
},
listeners: ({ actions, values }) => ({
formSubmitRequest: () => {
if (values.feature) {
actions.submitGatedFormDataRequest({
additionalFeedback: values?.additionalFeedback ? values?.additionalFeedback : null,
feature: values.feature,
featuresOther: values?.featuresOther ? values?.featuresOther : null,
participateInUXLabs: values?.participateInUXLabs,
});
}
},
}),
path: ['enterprise_search', 'app_search', 'gate_form'],
reducers: ({}) => ({
additionalFeedback: [
null,
{
setAdditionalFeedback: (
_: AppSearchGateValues['additionalFeedback'],
{ additionalFeedback }: { additionalFeedback: AppSearchGateValues['additionalFeedback'] }
): AppSearchGateValues['additionalFeedback'] => additionalFeedback ?? null,
},
],
feature: [
'',
{
setFeature: (
_: AppSearchGateValues['feature'],
{ feature }: { feature: AppSearchGateValues['feature'] }
): AppSearchGateValues['feature'] => feature ?? '',
},
],
featuresOther: [
null,
{
setFeaturesOther: (
_: AppSearchGateValues['featuresOther'],
{ featuresOther }: { featuresOther: AppSearchGateValues['featuresOther'] }
): AppSearchGateValues['featuresOther'] => featuresOther ?? null,
},
],
participateInUXLabs: [
null,
{
setParticipateInUXLabs: (
_: AppSearchGateValues['participateInUXLabs'],
{
participateInUXLabs,
}: { participateInUXLabs: AppSearchGateValues['participateInUXLabs'] }
): AppSearchGateValues['participateInUXLabs'] => participateInUXLabs ?? null,
},
],
}),
});

View file

@ -1,51 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../../../../../common/constants';
import {
EnterpriseSearchPageTemplateWrapper,
PageTemplateProps,
useEnterpriseSearchNav,
} from '../../../shared/layout';
import { SendAppSearchTelemetry } from '../../../shared/telemetry';
import { AppSearchGate } from './app_search_gate';
export const AppSearchGatePage: React.FC<PageTemplateProps> = ({ isLoading }) => {
return (
<EnterpriseSearchPageTemplateWrapper
restrictWidth
pageHeader={{
description: (
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.gateForm.description"
defaultMessage="The standalone App Search product remains available in maintenance mode, but is not recommended for new search experiences. We recommend using native Elasticsearch tools, which offer flexibility and composability, and include exciting new search features. To help you choose the tools best suited for your use case, weve created this recommendation wizard. Select the features you need, and we'll point you to corresponding Elasticsearch features. If you still want to use the standalone App Search product, you can do so after submitting the form."
/>
),
pageTitle: i18n.translate('xpack.enterpriseSearch.appSearch.gateForm.title', {
defaultMessage: 'Before you begin...',
}),
}}
solutionNav={{
items: useEnterpriseSearchNav(),
name: ENTERPRISE_SEARCH_CONTENT_PLUGIN.NAME,
}}
isLoading={isLoading}
hideEmbeddedConsole
>
<SendAppSearchTelemetry action="viewed" metric="App Search Gate form" />
<AppSearchGate />
</EnterpriseSearchPageTemplateWrapper>
);
};

View file

@ -1,8 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { AppSearchGate } from './app_search_gate';

View file

@ -1,69 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiButton, EuiButtonEmpty, EuiFlyout, EuiFlyoutBody } from '@elastic/eui';
import { AddDomainFlyout } from './add_domain_flyout';
import { AddDomainForm } from './add_domain_form';
import { AddDomainFormErrors } from './add_domain_form_errors';
import { AddDomainFormSubmitButton } from './add_domain_form_submit_button';
describe('AddDomainFlyout', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('is hidden by default', () => {
const wrapper = shallow(<AddDomainFlyout />);
expect(wrapper.find(EuiFlyout)).toHaveLength(0);
});
it('displays the flyout when the button is pressed', () => {
const wrapper = shallow(<AddDomainFlyout />);
wrapper.find(EuiButton).simulate('click');
expect(wrapper.find(EuiFlyout)).toHaveLength(1);
});
describe('flyout', () => {
let wrapper: ShallowWrapper;
beforeEach(() => {
wrapper = shallow(<AddDomainFlyout />);
wrapper.find(EuiButton).simulate('click');
});
it('displays form errors', () => {
expect(wrapper.find(EuiFlyoutBody).dive().find(AddDomainFormErrors)).toHaveLength(1);
});
it('contains a form to add domains', () => {
expect(wrapper.find(AddDomainForm)).toHaveLength(1);
});
it('contains a cancel buttonn', () => {
wrapper.find(EuiButtonEmpty).simulate('click');
expect(wrapper.find(EuiFlyout)).toHaveLength(0);
});
it('contains a submit button', () => {
expect(wrapper.find(AddDomainFormSubmitButton)).toHaveLength(1);
});
it('hides the flyout on close', () => {
wrapper.find(EuiFlyout).simulate('close');
expect(wrapper.find(EuiFlyout)).toHaveLength(0);
});
});
});

View file

@ -1,105 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiPortal,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { CANCEL_BUTTON_LABEL } from '../../../../../shared/constants';
import { AddDomainForm } from './add_domain_form';
import { AddDomainFormErrors } from './add_domain_form_errors';
import { AddDomainFormSubmitButton } from './add_domain_form_submit_button';
export const AddDomainFlyout: React.FC = () => {
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
return (
<>
<EuiButton
size="s"
color="success"
iconType="plusInCircle"
onClick={() => setIsFlyoutVisible(true)}
>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.openButtonLabel',
{
defaultMessage: 'Add domain',
}
)}
</EuiButton>
{isFlyoutVisible && (
<EuiPortal>
<EuiFlyout onClose={() => setIsFlyoutVisible(false)}>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.title',
{
defaultMessage: 'Add a new domain',
}
)}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody
banner={
<>
<EuiSpacer size="l" />
<AddDomainFormErrors />
</>
}
>
<EuiText>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.description',
{
defaultMessage:
'You can add multiple domains to this engine\'s web crawler. Add another domain here and modify the entry points and crawl rules from the "Manage" page.',
}
)}
<p />
</EuiText>
<EuiSpacer size="l" />
<AddDomainForm />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => setIsFlyoutVisible(false)}>
{CANCEL_BUTTON_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddDomainFormSubmitButton />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</EuiPortal>
)}
</>
);
};

View file

@ -1,142 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { EuiButton, EuiFieldText, EuiForm } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { rerender } from '../../../../../test_helpers';
import { AddDomainForm } from './add_domain_form';
import { AddDomainValidation } from './add_domain_validation';
const MOCK_VALUES = {
addDomainFormInputValue: 'https://',
entryPointValue: '/',
isValidationLoading: false,
hasValidationCompleted: false,
};
const MOCK_ACTIONS = {
setAddDomainFormInputValue: jest.fn(),
startDomainValidation: jest.fn(),
};
describe('AddDomainForm', () => {
let wrapper: ShallowWrapper;
beforeEach(() => {
jest.clearAllMocks();
setMockActions(MOCK_ACTIONS);
setMockValues(MOCK_VALUES);
wrapper = shallow(<AddDomainForm />);
});
it('renders', () => {
expect(wrapper.find(EuiForm)).toHaveLength(1);
});
it('contains a submit button', () => {
expect(wrapper.find(EuiButton).prop('type')).toEqual('submit');
});
it('validates domain on submit', () => {
wrapper.find(EuiForm).simulate('submit', { preventDefault: jest.fn() });
expect(MOCK_ACTIONS.startDomainValidation).toHaveBeenCalledTimes(1);
});
describe('url field', () => {
it('uses the value from the logic', () => {
setMockValues({
...MOCK_VALUES,
addDomainFormInputValue: 'test value',
});
rerender(wrapper);
expect(wrapper.find(EuiFieldText).prop('value')).toEqual('test value');
});
it('sets the value in the logic on change', () => {
wrapper.find(EuiFieldText).simulate('change', { target: { value: 'test value' } });
expect(MOCK_ACTIONS.setAddDomainFormInputValue).toHaveBeenCalledWith('test value');
});
});
describe('validate domain button', () => {
it('is enabled when the input has a value', () => {
setMockValues({
...MOCK_VALUES,
addDomainFormInputValue: 'https://elastic.co',
});
rerender(wrapper);
expect(wrapper.find(EuiButton).prop('disabled')).toEqual(false);
});
it('is disabled when the input value is empty', () => {
setMockValues({
...MOCK_VALUES,
addDomainFormInputValue: '',
});
rerender(wrapper);
expect(wrapper.find(EuiButton).prop('disabled')).toEqual(true);
});
});
describe('entry point indicator', () => {
it('is hidden when the entry point is /', () => {
setMockValues({
...MOCK_VALUES,
entryPointValue: '/',
});
rerender(wrapper);
expect(wrapper.find(FormattedMessage)).toHaveLength(0);
});
it('displays the entry point otherwise', () => {
setMockValues({
...MOCK_VALUES,
entryPointValue: '/guide',
});
rerender(wrapper);
expect(wrapper.find(FormattedMessage)).toHaveLength(1);
});
});
describe('validation', () => {
it('is hidden by default', () => {
expect(wrapper.find(AddDomainValidation)).toHaveLength(0);
});
it('can be shown to the user', () => {
setMockValues({
...MOCK_VALUES,
displayValidation: true,
});
rerender(wrapper);
expect(wrapper.find(AddDomainValidation)).toHaveLength(1);
});
});
});

View file

@ -1,106 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useActions, useValues } from 'kea';
import {
EuiButton,
EuiCode,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiFieldText,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { AddDomainLogic } from './add_domain_logic';
import { AddDomainValidation } from './add_domain_validation';
export const AddDomainForm: React.FC = () => {
const { setAddDomainFormInputValue, startDomainValidation } = useActions(AddDomainLogic);
const { addDomainFormInputValue, displayValidation, entryPointValue } = useValues(AddDomainLogic);
return (
<>
<EuiForm
onSubmit={(event) => {
event.preventDefault();
startDomainValidation();
}}
component="form"
>
<EuiFormRow
fullWidth
label={i18n.translate('xpack.enterpriseSearch.appSearch.crawler.addDomainForm.urlLabel', {
defaultMessage: 'Domain URL',
})}
helpText={
<EuiText size="s">
{i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainForm.urlHelpText',
{
defaultMessage: 'Domain URLs require a protocol and cannot contain any paths.',
}
)}
</EuiText>
}
>
<EuiFlexGroup>
<EuiFlexItem grow>
<EuiFieldText
autoFocus
placeholder="https://"
value={addDomainFormInputValue}
onChange={(e) => setAddDomainFormInputValue(e.target.value)}
fullWidth
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton type="submit" fill disabled={addDomainFormInputValue.length === 0}>
{i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainForm.validateButtonLabel',
{
defaultMessage: 'Validate Domain',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
{entryPointValue !== '/' && (
<>
<EuiSpacer />
<EuiText size="s">
<p>
<strong>
<FormattedMessage
id="xpack.enterpriseSearch.appSearch.crawler.addDomainForm.entryPointLabel"
defaultMessage="Web Crawler entry point has been set as {entryPointValue}"
values={{
entryPointValue: <EuiCode>{entryPointValue}</EuiCode>,
}}
/>
</strong>
</p>
</EuiText>
</>
)}
{displayValidation && <AddDomainValidation />}
</EuiForm>
</>
);
};

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setMockValues } from '../../../../../__mocks__/kea_logic';
import React from 'react';
import { shallow } from 'enzyme';
import { AddDomainFormErrors } from './add_domain_form_errors';
describe('AddDomainFormErrors', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('is empty when there are no errors', () => {
setMockValues({
errors: [],
});
const wrapper = shallow(<AddDomainFormErrors />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('displays all the errors from the logic', () => {
setMockValues({
errors: ['first error', 'second error'],
});
const wrapper = shallow(<AddDomainFormErrors />);
expect(wrapper.find('p')).toHaveLength(2);
expect(wrapper.find('p').first().text()).toContain('first error');
expect(wrapper.find('p').last().text()).toContain('second error');
});
});

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { useValues } from 'kea';
import { EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AddDomainLogic } from './add_domain_logic';
export const AddDomainFormErrors: React.FC = () => {
const { errors } = useValues(AddDomainLogic);
if (errors.length > 0) {
return (
<EuiCallOut
color="danger"
iconType="warning"
title={i18n.translate(
'xpack.enterpriseSearch.appSearch.crawler.addDomainForm.errorsTitle',
{
defaultMessage: 'Something went wrong. Please address the errors and try again.',
}
)}
>
{errors.map((message, index) => (
<p key={index}>{message}</p>
))}
</EuiCallOut>
);
}
return null;
};

Some files were not shown because too many files have changed in this diff Show more