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();