diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/common/translations.ts b/x-pack/solutions/search/plugins/search_inference_endpoints/common/translations.ts index 2142728a2aeb4..720df01e22a16 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/common/translations.ts +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/common/translations.ts @@ -162,3 +162,23 @@ export const INFERENCE_ENDPOINTS_TABLE_CAPTION = i18n.translate( defaultMessage: 'Inference endpoints table', } ); + +export const SORT = i18n.translate('xpack.searchInferenceEndpoints.sort', { + defaultMessage: 'Sort', +}); + +export const SORT_BY_ENDPOINT = i18n.translate('xpack.searchInferenceEndpoints.sort.endpoint', { + defaultMessage: 'Endpoint', +}); + +export const SORT_BY_SERVICE = i18n.translate('xpack.searchInferenceEndpoints.sort.service', { + defaultMessage: 'Service', +}); + +export const SORT_BY_TYPE = i18n.translate('xpack.searchInferenceEndpoints.sort.type', { + defaultMessage: 'Type', +}); + +export const SORT_BY_MODEL = i18n.translate('xpack.searchInferenceEndpoints.sort.model', { + defaultMessage: 'Model', +}); diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/sort/sort_button.test.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/sort/sort_button.test.tsx new file mode 100644 index 0000000000000..c3615dec74525 --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/sort/sort_button.test.tsx @@ -0,0 +1,94 @@ +/* + * 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, fireEvent, waitFor } from '@testing-library/react'; +import { SortButton } from './sort_button'; +import { SortFieldInferenceEndpoint } from '../types'; + +describe('SortButton', () => { + const defaultProps = { + selectedSortField: SortFieldInferenceEndpoint.inference_id, + onSortFieldChange: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render the sort button', () => { + const { getByTestId } = render(); + expect(getByTestId('sortButton')).toBeInTheDocument(); + }); + + it('should toggle the popover when the sort button is clicked', async () => { + const { getByTestId, queryByText } = render(); + fireEvent.click(getByTestId('sortButton')); + expect(queryByText('Endpoint')).toBeInTheDocument(); + fireEvent.click(getByTestId('sortButton')); + await waitFor(() => { + expect(queryByText('Endpoint')).not.toBeInTheDocument(); + }); + }); + + it('should render all sort options', async () => { + const { getByTestId, getByText } = render(); + + fireEvent.click(getByTestId('sortButton')); + + await waitFor(() => { + expect(getByText('Endpoint')).toBeInTheDocument(); + expect(getByText('Service')).toBeInTheDocument(); + expect(getByText('Type')).toBeInTheDocument(); + expect(getByText('Model')).toBeInTheDocument(); + }); + }); + + it('should call onSortFieldChange when a sort option is selected', async () => { + const onSortFieldChange = jest.fn(); + const { getByTestId, getByText } = render( + + ); + + fireEvent.click(getByTestId('sortButton')); + fireEvent.click(getByText('Service')); + + await waitFor(() => { + expect(onSortFieldChange).toHaveBeenCalledWith(SortFieldInferenceEndpoint.service); + }); + }); + + it('should show selected sort field in dropdown', async () => { + const { getByTestId, getByText } = render( + + ); + + fireEvent.click(getByTestId('sortButton')); + + await waitFor(() => { + // Verify the dropdown opens and shows all options including the selected one + expect(getByText('Service')).toBeInTheDocument(); + expect(getByText('Endpoint')).toBeInTheDocument(); + }); + }); + + it('should close popover after selecting an option', async () => { + const onSortFieldChange = jest.fn(); + const { getByTestId, getByText, queryByText } = render( + + ); + + fireEvent.click(getByTestId('sortButton')); + expect(queryByText('Service')).toBeInTheDocument(); + + fireEvent.click(getByText('Service')); + + await waitFor(() => { + expect(queryByText('Service')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/sort/sort_button.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/sort/sort_button.tsx new file mode 100644 index 0000000000000..0f7cbc9f2e33c --- /dev/null +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/sort/sort_button.tsx @@ -0,0 +1,102 @@ +/* + * 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, { useCallback, useMemo, useState } from 'react'; +import { + EuiFilterButton, + EuiFilterGroup, + EuiPopover, + EuiSelectable, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { EuiSelectableOption } from '@elastic/eui'; +import * as i18n from '../../../../common/translations'; +import { SortFieldInferenceEndpoint } from '../types'; + +interface SortOption { + field: SortFieldInferenceEndpoint; + label: string; +} + +const SORT_OPTIONS: SortOption[] = [ + { field: SortFieldInferenceEndpoint.inference_id, label: i18n.SORT_BY_ENDPOINT }, + { field: SortFieldInferenceEndpoint.service, label: i18n.SORT_BY_SERVICE }, + { field: SortFieldInferenceEndpoint.task_type, label: i18n.SORT_BY_TYPE }, + { field: SortFieldInferenceEndpoint.model, label: i18n.SORT_BY_MODEL }, +]; + +interface SortButtonProps { + selectedSortField: SortFieldInferenceEndpoint; + onSortFieldChange: (field: SortFieldInferenceEndpoint) => void; +} + +export const SortButton: React.FC = ({ selectedSortField, onSortFieldChange }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const popoverId = useGeneratedHtmlId({ prefix: 'sortPopover' }); + + const togglePopover = useCallback(() => { + setIsPopoverOpen((prev) => !prev); + }, []); + + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const selectableOptions = useMemo(() => { + return SORT_OPTIONS.map((option) => ({ + key: option.field, + label: option.label, + checked: option.field === selectedSortField ? 'on' : undefined, + })); + }, [selectedSortField]); + + const handleSortFieldChange = useCallback( + (options: EuiSelectableOption[]) => { + const selectedOption = options.find((opt) => opt.checked === 'on'); + if (selectedOption?.key) { + onSortFieldChange(selectedOption.key as SortFieldInferenceEndpoint); + closePopover(); + } + }, + [onSortFieldChange, closePopover] + ); + + return ( + + + {i18n.SORT} + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + repositionOnScroll + > + + {(list) => list} + + + + ); +}; diff --git a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.tsx b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.tsx index e5513c02ed738..10ae92a52d47d 100644 --- a/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.tsx +++ b/x-pack/solutions/search/plugins/search_inference_endpoints/public/components/all_inference_endpoints/tabular_page.tsx @@ -22,10 +22,12 @@ import * as i18n from '../../../common/translations'; import { useTableData } from '../../hooks/use_table_data'; import type { FilterOptions } from './types'; +import type { SortFieldInferenceEndpoint } from './types'; import { useAllInferenceEndpointsState } from '../../hooks/use_all_inference_endpoints_state'; import { ServiceProviderFilter } from './filter/service_provider_filter'; import { TaskTypeFilter } from './filter/task_type_filter'; +import { SortButton } from './sort/sort_button'; import { TableSearch } from './search/table_search'; import { EndpointInfo } from './render_table_columns/render_endpoint/endpoint_info'; import { Model } from './render_table_columns/render_model/model'; @@ -110,6 +112,15 @@ export const TabularPage: React.FC = ({ inferenceEndpoints }) [setFilterOptions] ); + const onSortFieldChange = useCallback( + (field: SortFieldInferenceEndpoint) => { + setQueryParams({ + sortField: field, + }); + }, + [setQueryParams] + ); + const { paginatedSortedTableData, pagination, sorting } = useTableData( inferenceEndpoints, queryParams, @@ -138,11 +149,13 @@ export const TabularPage: React.FC = ({ inferenceEndpoints }) width: '300px', }, { + field: 'model', name: i18n.MODEL, 'data-test-subj': 'modelCell', - render: (endpointInfo: InferenceInferenceEndpointInfo) => { + render: (_model: unknown, endpointInfo: InferenceInferenceEndpointInfo) => { return ; }, + sortable: true, width: '200px', }, { @@ -156,7 +169,7 @@ export const TabularPage: React.FC = ({ inferenceEndpoints }) return null; }, - sortable: false, + sortable: true, width: '285px', }, { @@ -170,7 +183,7 @@ export const TabularPage: React.FC = ({ inferenceEndpoints }) return null; }, - sortable: false, + sortable: true, width: '100px', }, { @@ -250,6 +263,12 @@ export const TabularPage: React.FC = ({ inferenceEndpoints }) + + + ; + sorting: EuiTableSortingType; } export const useTableData = ( @@ -57,10 +62,28 @@ export const useTableData = ( }); }, [inferenceEndpoints, searchKey, filterOptions]); + const getSortValue = useCallback( + (endpoint: InferenceInferenceEndpointInfo, field: SortFieldInferenceEndpoint): string => { + switch (field) { + case SortFieldValues.inference_id: + return endpoint.inference_id ?? ''; + case SortFieldValues.service: + return endpoint.service ?? ''; + case SortFieldValues.task_type: + return endpoint.task_type ?? ''; + case SortFieldValues.model: + return getModelId(endpoint) ?? ''; + default: + return ''; + } + }, + [] + ); + const sortedTableData: InferenceInferenceEndpointInfo[] = useMemo(() => { return [...tableData].sort((a, b) => { - const aValue = a[queryParams.sortField]; - const bValue = b[queryParams.sortField]; + const aValue = getSortValue(a, queryParams.sortField); + const bValue = getSortValue(b, queryParams.sortField); if (queryParams.sortOrder === SortOrder.asc) { return aValue.localeCompare(bValue); @@ -68,16 +91,16 @@ export const useTableData = ( return bValue.localeCompare(aValue); } }); - }, [tableData, queryParams]); + }, [tableData, queryParams, getSortValue]); const pagination: Pagination = useMemo( () => ({ pageIndex: queryParams.page - 1, pageSize: queryParams.perPage, pageSizeOptions: INFERENCE_ENDPOINTS_TABLE_PER_PAGE_VALUES, - totalItemCount: inferenceEndpoints.length ?? 0, + totalItemCount: tableData.length, }), - [inferenceEndpoints, queryParams] + [tableData, queryParams] ); const paginatedSortedTableData: InferenceInferenceEndpointInfo[] = useMemo(() => { @@ -87,7 +110,7 @@ export const useTableData = ( return sortedTableData.slice(startIndex, endIndex); }, [sortedTableData, pagination]); - const sorting = useMemo( + const sorting: EuiTableSortingType = useMemo( () => ({ sort: { direction: queryParams.sortOrder, diff --git a/x-pack/solutions/search/test/functional_search/tests/inference_management.ts b/x-pack/solutions/search/test/functional_search/tests/inference_management.ts index 063e5320ac327..3cfd58b415d6c 100644 --- a/x-pack/solutions/search/test/functional_search/tests/inference_management.ts +++ b/x-pack/solutions/search/test/functional_search/tests/inference_management.ts @@ -68,6 +68,35 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('sort functionality', () => { + it('displays the sort dropdown', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortDropdownToBeVisible(); + }); + + it('shows all sort options in dropdown', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortDropdownOptions(); + }); + + it('can sort by different fields using dropdown', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortByField( + 'Service' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortByField( + 'Type' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortByField( + 'Model' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortByField( + 'Endpoint' + ); + }); + + it('can sort by clicking column headers', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectColumnHeaderSorting(); + }); + }); + describe('create inference flyout', () => { it('renders successfully', async () => { await pageObjects.searchInferenceManagementPage.AddInferenceFlyout.expectInferenceEndpointToBeVisible(); diff --git a/x-pack/solutions/search/test/page_objects/inference_management_page.ts b/x-pack/solutions/search/test/page_objects/inference_management_page.ts index bf0ea45cd3551..2424eed9bf9f2 100644 --- a/x-pack/solutions/search/test/page_objects/inference_management_page.ts +++ b/x-pack/solutions/search/test/page_objects/inference_management_page.ts @@ -26,6 +26,7 @@ export function SearchInferenceManagementPageProvider({ getService }: FtrProvide await testSubjects.existOrFail('search-field-endpoints'); await testSubjects.existOrFail('type-field-endpoints'); await testSubjects.existOrFail('service-field-endpoints'); + await testSubjects.existOrFail('sortFieldEndpoints'); const table = await testSubjects.find('inferenceEndpointTable'); const rows = await table.findAllByClassName('euiTableRow'); @@ -127,6 +128,66 @@ export function SearchInferenceManagementPageProvider({ getService }: FtrProvide await elserCopyEndpointId.click(); expect((await browser.getClipboardValue()).includes('.elser-2-elasticsearch')).to.be(true); }, + + async expectSortDropdownToBeVisible() { + await testSubjects.existOrFail('sortFieldEndpoints'); + await testSubjects.existOrFail('sortButton'); + }, + + async expectSortDropdownOptions() { + // Open the sort dropdown + await testSubjects.click('sortButton'); + + // Verify all sort options are displayed + const sortOptions = await testSubjects.findAll('euiSelectableListItem'); + expect(sortOptions.length).to.equal(4); + + // Get the text of all options + const optionTexts = await Promise.all(sortOptions.map((option) => option.getVisibleText())); + expect(optionTexts).to.contain('Endpoint'); + expect(optionTexts).to.contain('Service'); + expect(optionTexts).to.contain('Type'); + expect(optionTexts).to.contain('Model'); + + // Close the dropdown by clicking the button again + await testSubjects.click('sortButton'); + }, + + async expectSortByField(fieldName: string) { + // Open the sort dropdown + await testSubjects.click('sortButton'); + + // Find and click the specified sort option + const sortOptions = await testSubjects.findAll('euiSelectableListItem'); + for (const option of sortOptions) { + const text = await option.getVisibleText(); + if (text === fieldName) { + await option.click(); + break; + } + } + + // Verify the table is still displayed after sorting + await testSubjects.existOrFail('inferenceEndpointTable'); + }, + + async expectColumnHeaderSorting() { + // Click on the Endpoint column header to sort + const table = await testSubjects.find('inferenceEndpointTable'); + const headers = await table.findAllByCssSelector('th button'); + + // Find and click the Endpoint column header (first sortable column) + for (const header of headers) { + const text = await header.getVisibleText(); + if (text.includes('Endpoint')) { + await header.click(); + break; + } + } + + // Verify the table is still displayed after sorting + await testSubjects.existOrFail('inferenceEndpointTable'); + }, }, AddInferenceFlyout: { diff --git a/x-pack/solutions/search/test/serverless/functional/test_suites/inference_management.ts b/x-pack/solutions/search/test/serverless/functional/test_suites/inference_management.ts index bc4e04a7fb1e0..d9b16060095da 100644 --- a/x-pack/solutions/search/test/serverless/functional/test_suites/inference_management.ts +++ b/x-pack/solutions/search/test/serverless/functional/test_suites/inference_management.ts @@ -54,6 +54,35 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('sort functionality', () => { + it('displays the sort dropdown', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortDropdownToBeVisible(); + }); + + it('shows all sort options in dropdown', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortDropdownOptions(); + }); + + it('can sort by different fields using dropdown', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortByField( + 'Service' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortByField( + 'Type' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortByField( + 'Model' + ); + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectSortByField( + 'Endpoint' + ); + }); + + it('can sort by clicking column headers', async () => { + await pageObjects.searchInferenceManagementPage.InferenceTabularPage.expectColumnHeaderSorting(); + }); + }); + describe('create inference flyout', () => { it('renders successfully', async () => { await pageObjects.searchInferenceManagementPage.AddInferenceFlyout.expectInferenceEndpointToBeVisible();