mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
45f3241db0
commit
75fe22b604
1296 changed files with 3 additions and 127531 deletions
|
@ -146,8 +146,6 @@ export const applicationUsageSchema = {
|
|||
enterpriseSearchVectorSearch: commonSchema,
|
||||
enterpriseSearchElasticsearch: commonSchema,
|
||||
entity_manager: commonSchema,
|
||||
appSearch: commonSchema,
|
||||
workplaceSearch: commonSchema,
|
||||
searchExperiences: commonSchema,
|
||||
graph: commonSchema,
|
||||
logs: commonSchema,
|
||||
|
|
|
@ -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": {
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
|
@ -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,
|
||||
}));
|
|
@ -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];
|
|
@ -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';
|
|
@ -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,
|
||||
}));
|
|
@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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) : {}),
|
||||
],
|
||||
},
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
|
@ -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.');
|
||||
});
|
||||
});
|
|
@ -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.' }
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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';
|
|
@ -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.');
|
||||
});
|
||||
});
|
|
@ -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.' }
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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.');
|
||||
});
|
||||
});
|
|
@ -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.' }
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
};
|
|
@ -1,3 +0,0 @@
|
|||
.tagsList .euiBadge {
|
||||
max-width: $euiSize * 9;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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';
|
|
@ -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);
|
|
@ -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';
|
|
@ -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[];
|
||||
}
|
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
})),
|
||||
];
|
||||
};
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
|
@ -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';
|
|
@ -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('""');
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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":[]}',
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
}),
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
|
@ -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';
|
|
@ -1,6 +0,0 @@
|
|||
.newApiEventsPrompt {
|
||||
padding: $euiSizeXS;
|
||||
padding-left: $euiSizeS;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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.' }
|
||||
);
|
|
@ -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';
|
|
@ -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
|
||||
}
|
|
@ -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}');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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="Elastic’s 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>
|
||||
);
|
||||
};
|
|
@ -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"}}',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
);
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
|
@ -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, we’ve 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>
|
||||
);
|
||||
};
|
|
@ -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';
|
Binary file not shown.
Before Width: | Height: | Size: 14 KiB |
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue