mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Synonyms UI] Search synonyms set list (#206931)
## Summary Adds Synonyms set table and endpoint to the synonyms. Actions are just a placeholder and will be working in next PR following this with Delete modal. <img width="1161" alt="Screenshot 2025-01-16 at 13 43 44" src="https://github.com/user-attachments/assets/bc410a58-85e0-4e89-baff-e7a427d82ecd" /> <img width="1163" alt="Screenshot 2025-01-16 at 13 43 55" src="https://github.com/user-attachments/assets/e087bd51-71a9-49a5-936e-00fde2492ddd" /> ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [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: Liam Thompson <32779855+leemthompo@users.noreply.github.com> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
49d1cea3ba
commit
354385213f
13 changed files with 450 additions and 9 deletions
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 enum APIRoutes {
|
||||
SYNONYM_SETS = '/internal/search_synonyms/synonyms',
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 DEFAULT_PAGE_VALUE: Page = {
|
||||
from: 0,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
export interface Pagination {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
totalItemCount: number;
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
from: number; // current page index, 0-based
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface Paginate<T> {
|
||||
_meta: Pagination;
|
||||
data: T[];
|
||||
}
|
||||
|
||||
export function paginationToPage(pagination: Pagination): Page {
|
||||
return {
|
||||
from: pagination.pageIndex * pagination.pageSize,
|
||||
size: pagination.pageSize,
|
||||
};
|
||||
}
|
||||
export function pageToPagination(page: { from: number; size: number; total: number }) {
|
||||
// Prevent divide-by-zero-error
|
||||
const pageIndex = page.size ? Math.trunc(page.from / page.size) : 0;
|
||||
return {
|
||||
pageIndex,
|
||||
pageSize: page.size,
|
||||
totalItemCount: page.total,
|
||||
};
|
||||
}
|
|
@ -12,9 +12,11 @@ import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
|
|||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { Route, Router, Routes } from '@kbn/shared-ux-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AppPluginStartDependencies } from './types';
|
||||
import { SearchSynonymsOverview } from './components/overview/overview';
|
||||
|
||||
const queryClient = new QueryClient({});
|
||||
export const renderApp = async (
|
||||
core: CoreStart,
|
||||
services: AppPluginStartDependencies,
|
||||
|
@ -24,13 +26,15 @@ export const renderApp = async (
|
|||
<KibanaRenderContextProvider {...core}>
|
||||
<KibanaContextProvider services={{ ...core, ...services }}>
|
||||
<I18nProvider>
|
||||
<Router history={services.history}>
|
||||
<Routes>
|
||||
<Route path="/">
|
||||
<SearchSynonymsOverview />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router history={services.history}>
|
||||
<Routes>
|
||||
<Route path="/">
|
||||
<SearchSynonymsOverview />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</KibanaContextProvider>
|
||||
</KibanaRenderContextProvider>,
|
||||
|
|
|
@ -8,13 +8,17 @@
|
|||
import React, { useMemo } from 'react';
|
||||
|
||||
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
|
||||
import { EuiLoadingSpinner } from '@elastic/eui';
|
||||
import { useKibana } from '../../hooks/use_kibana';
|
||||
import { SynonymSets } from '../synonym_sets/synonym_sets';
|
||||
import { useFetchSynonymsSets } from '../../hooks/use_fetch_synonyms_sets';
|
||||
import { EmptyPrompt } from '../empty_prompt/empty_prompt';
|
||||
|
||||
export const SearchSynonymsOverview = () => {
|
||||
const {
|
||||
services: { console: consolePlugin, history, searchNavigation },
|
||||
} = useKibana();
|
||||
const { data: synonymsData, isInitialLoading } = useFetchSynonymsSets();
|
||||
|
||||
const embeddableConsole = useMemo(
|
||||
() => (consolePlugin?.EmbeddableConsole ? <consolePlugin.EmbeddableConsole /> : null),
|
||||
|
@ -28,7 +32,16 @@ export const SearchSynonymsOverview = () => {
|
|||
data-test-subj="searchSynonymsOverviewPage"
|
||||
solutionNav={searchNavigation?.useClassicNavigation(history)}
|
||||
>
|
||||
<EmptyPrompt />
|
||||
<KibanaPageTemplate.Section restrictWidth>
|
||||
{isInitialLoading && <EuiLoadingSpinner />}
|
||||
|
||||
{!isInitialLoading && synonymsData && synonymsData._meta.totalItemCount > 0 && (
|
||||
<SynonymSets />
|
||||
)}
|
||||
{!isInitialLoading && synonymsData && synonymsData._meta.totalItemCount === 0 && (
|
||||
<EmptyPrompt />
|
||||
)}
|
||||
</KibanaPageTemplate.Section>
|
||||
{embeddableConsole}
|
||||
</KibanaPageTemplate>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import { SynonymSets } from './synonym_sets';
|
||||
|
||||
jest.mock('../../hooks/use_fetch_synonyms_sets', () => ({
|
||||
useFetchSynonymsSets: () => ({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
synonyms_set: 'Synonyms Set 1',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
synonyms_set: 'Synonyms Set 2',
|
||||
count: 3,
|
||||
},
|
||||
],
|
||||
_meta: { pageIndex: 0, pageSize: 10, totalItemCount: 2 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Search Synonym Sets list', () => {
|
||||
it('should render the list with synonym sets', () => {
|
||||
render(<SynonymSets />);
|
||||
const synonymSetTable = screen.getByTestId('synonyms-set-table');
|
||||
expect(synonymSetTable).toBeInTheDocument();
|
||||
|
||||
const synonymSetItemNames = screen.getAllByTestId('synonyms-set-item-name');
|
||||
expect(synonymSetItemNames).toHaveLength(2);
|
||||
expect(synonymSetItemNames[0].textContent).toBe('Synonyms Set 1');
|
||||
expect(synonymSetItemNames[1].textContent).toBe('Synonyms Set 2');
|
||||
|
||||
const synonymSetItemRuleCounts = screen.getAllByTestId('synonyms-set-item-rule-count');
|
||||
expect(synonymSetItemRuleCounts).toHaveLength(2);
|
||||
expect(synonymSetItemRuleCounts[0].textContent).toBe('2');
|
||||
expect(synonymSetItemRuleCounts[1].textContent).toBe('3');
|
||||
|
||||
const synonymSetItemPageSize = screen.getByTestId('tablePaginationPopoverButton');
|
||||
const synonymSetPageButton = screen.getByTestId('pagination-button-0');
|
||||
expect(synonymSetItemPageSize).toBeInTheDocument();
|
||||
expect(synonymSetPageButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 { SynonymsGetSynonymsSetsSynonymsSetItem } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React, { useState } from 'react';
|
||||
import { DEFAULT_PAGE_VALUE, paginationToPage } from '../../../common/pagination';
|
||||
import { useFetchSynonymsSets } from '../../hooks/use_fetch_synonyms_sets';
|
||||
|
||||
export const SynonymSets = () => {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_VALUE.size);
|
||||
const { from } = paginationToPage({ pageIndex, pageSize, totalItemCount: 0 });
|
||||
const { data: synonyms } = useFetchSynonymsSets({ from, size: pageSize });
|
||||
|
||||
if (!synonyms) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pagination = {
|
||||
initialPageSize: 10,
|
||||
pageSizeOptions: [10, 25, 50],
|
||||
...synonyms._meta,
|
||||
pageSize,
|
||||
pageIndex,
|
||||
};
|
||||
const columns: Array<EuiBasicTableColumn<SynonymsGetSynonymsSetsSynonymsSetItem>> = [
|
||||
{
|
||||
field: 'synonyms_set',
|
||||
name: i18n.translate('xpack.searchSynonyms.synonymsSetTable.nameColumn', {
|
||||
defaultMessage: 'Synonyms Set',
|
||||
}),
|
||||
render: (name: string) => <div data-test-subj="synonyms-set-item-name">{name}</div>,
|
||||
},
|
||||
{
|
||||
field: 'count',
|
||||
name: i18n.translate('xpack.searchSynonyms.synonymsSetTable.ruleCount', {
|
||||
defaultMessage: 'Rule Count',
|
||||
}),
|
||||
render: (ruleCount: number) => (
|
||||
<div data-test-subj="synonyms-set-item-rule-count">{ruleCount}</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<EuiBasicTable
|
||||
data-test-subj="synonyms-set-table"
|
||||
items={synonyms.data}
|
||||
columns={columns}
|
||||
pagination={pagination}
|
||||
onChange={({ page: changedPage }) => {
|
||||
setPageIndex(changedPage.index);
|
||||
setPageSize(changedPage.size);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
const mockHttpGet = jest.fn();
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn().mockImplementation(async ({ queryKey, queryFn, opts }) => {
|
||||
try {
|
||||
const res = await queryFn();
|
||||
return Promise.resolve(res);
|
||||
} catch (e) {
|
||||
// opts.onError(e);
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./use_kibana', () => ({
|
||||
useKibana: jest.fn().mockReturnValue({
|
||||
services: {
|
||||
http: {
|
||||
get: mockHttpGet,
|
||||
},
|
||||
notifications: {
|
||||
toasts: {
|
||||
addError: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useFetchSynonymsSet Hook', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return synonyms set', async () => {
|
||||
const synonyms = [
|
||||
{
|
||||
id: '1',
|
||||
synonyms: ['foo', 'bar'],
|
||||
},
|
||||
];
|
||||
mockHttpGet.mockReturnValue(synonyms);
|
||||
const { useFetchSynonymsSets } = jest.requireActual('./use_fetch_synonyms_sets');
|
||||
|
||||
const { result } = renderHook(() => useFetchSynonymsSets());
|
||||
await waitFor(() => expect(result.current).resolves.toStrictEqual(synonyms));
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { useQuery } from '@tanstack/react-query';
|
||||
import type { SynonymsGetSynonymsSetsSynonymsSetItem } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { DEFAULT_PAGE_VALUE, Page, Paginate } from '../../common/pagination';
|
||||
import { APIRoutes } from '../../common/api_routes';
|
||||
import { useKibana } from './use_kibana';
|
||||
|
||||
export const useFetchSynonymsSets = (page: Page = DEFAULT_PAGE_VALUE) => {
|
||||
const {
|
||||
services: { http },
|
||||
} = useKibana();
|
||||
return useQuery({
|
||||
queryKey: ['synonyms-sets-fetch', page.from, page.size],
|
||||
queryFn: async () => {
|
||||
return await http.get<Paginate<SynonymsGetSynonymsSetsSynonymsSetItem>>(
|
||||
APIRoutes.SYNONYM_SETS,
|
||||
{
|
||||
query: { from: page.from, size: page.size },
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { fetchSynonymSets } from './fetch_synonym_sets';
|
||||
|
||||
describe('fetch synonym sets lib function', () => {
|
||||
const mockClient = {
|
||||
security: {
|
||||
hasPrivileges: jest.fn(),
|
||||
},
|
||||
synonyms: {
|
||||
getSynonymsSets: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const client = () => mockClient as unknown as ElasticsearchClient;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('should return synonym sets', async () => {
|
||||
mockClient.synonyms.getSynonymsSets.mockResolvedValue({
|
||||
count: 2,
|
||||
results: [
|
||||
{
|
||||
synonyms_set: 'my_synonyms_set',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
synonyms_set: 'my_synonyms_set_2',
|
||||
count: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await fetchSynonymSets(client(), { from: 0, size: 10 });
|
||||
expect(result).toEqual({
|
||||
_meta: {
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
totalItemCount: 2,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
synonyms_set: 'my_synonyms_set',
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
synonyms_set: 'my_synonyms_set_2',
|
||||
count: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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 { SynonymsGetSynonymsSetsSynonymsSetItem } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { ElasticsearchClient } from '@kbn/core/server';
|
||||
import { Page, Paginate, pageToPagination } from '../../common/pagination';
|
||||
|
||||
export const fetchSynonymSets = async (
|
||||
client: ElasticsearchClient,
|
||||
{ from, size }: Page
|
||||
): Promise<Paginate<SynonymsGetSynonymsSetsSynonymsSetItem>> => {
|
||||
const result = await client.synonyms.getSynonymsSets({
|
||||
from,
|
||||
size,
|
||||
});
|
||||
const _meta = pageToPagination({ from, size, total: result.count });
|
||||
return { _meta, data: result.results };
|
||||
};
|
|
@ -6,5 +6,60 @@
|
|||
*/
|
||||
|
||||
import { IRouter, Logger } from '@kbn/core/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { APIRoutes } from '../common/api_routes';
|
||||
|
||||
export function defineRoutes({ logger, router }: { logger: Logger; router: IRouter }) {}
|
||||
import { errorHandler } from './utils/error_handler';
|
||||
import { fetchSynonymSets } from './lib/fetch_synonym_sets';
|
||||
import { DEFAULT_PAGE_VALUE } from '../common/pagination';
|
||||
|
||||
export function defineRoutes({ logger, router }: { logger: Logger; router: IRouter }) {
|
||||
router.get(
|
||||
{
|
||||
path: APIRoutes.SYNONYM_SETS,
|
||||
options: {
|
||||
access: 'internal',
|
||||
tags: ['synonyms:read'],
|
||||
},
|
||||
security: {
|
||||
authz: {
|
||||
requiredPrivileges: ['synonyms:read'],
|
||||
},
|
||||
},
|
||||
validate: {
|
||||
query: schema.object({
|
||||
from: schema.number({ defaultValue: DEFAULT_PAGE_VALUE.from }),
|
||||
size: schema.number({ defaultValue: DEFAULT_PAGE_VALUE.size }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
errorHandler(logger)(async (context, request, response) => {
|
||||
const core = await context.core;
|
||||
const {
|
||||
client: { asCurrentUser },
|
||||
} = core.elasticsearch;
|
||||
const user = core.security.authc.getCurrentUser();
|
||||
if (!user) {
|
||||
return response.customError({
|
||||
statusCode: 502,
|
||||
body: 'Could not retrieve current user, security plugin is not ready',
|
||||
});
|
||||
}
|
||||
const hasSearchSynonymsPrivilege = await asCurrentUser.security.hasPrivileges({
|
||||
cluster: ['manage_search_synonyms'],
|
||||
});
|
||||
if (!hasSearchSynonymsPrivilege.has_all_requested) {
|
||||
return response.forbidden({
|
||||
body: "Your user doesn't have manage_search_synonyms privileges",
|
||||
});
|
||||
}
|
||||
const result = await fetchSynonymSets(asCurrentUser, {
|
||||
from: request.query.from,
|
||||
size: request.query.size,
|
||||
});
|
||||
return response.ok({
|
||||
body: result,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 { RequestHandlerWrapper } from '@kbn/core-http-server';
|
||||
import { KibanaServerError } from '@kbn/kibana-utils-plugin/common';
|
||||
import type { Logger } from '@kbn/logging';
|
||||
|
||||
function isKibanaServerError(error: any): error is KibanaServerError {
|
||||
return error.statusCode && error.message;
|
||||
}
|
||||
|
||||
export const errorHandler: (logger: Logger) => RequestHandlerWrapper = (logger) => (handler) => {
|
||||
return async (context, request, response) => {
|
||||
try {
|
||||
return await handler(context, request, response);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
if (isKibanaServerError(e)) {
|
||||
return response.customError({ statusCode: e.statusCode, body: e.message });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
};
|
|
@ -22,6 +22,9 @@
|
|||
"@kbn/search-navigation",
|
||||
"@kbn/doc-links",
|
||||
"@kbn/shared-ux-page-kibana-template",
|
||||
"@kbn/core-http-server",
|
||||
"@kbn/kibana-utils-plugin",
|
||||
"@kbn/logging",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue