Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Original file line number Diff line number Diff line change
@@ -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(<SortButton {...defaultProps} />);
expect(getByTestId('sortButton')).toBeInTheDocument();
});

it('should toggle the popover when the sort button is clicked', async () => {
const { getByTestId, queryByText } = render(<SortButton {...defaultProps} />);
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(<SortButton {...defaultProps} />);

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(
<SortButton {...defaultProps} onSortFieldChange={onSortFieldChange} />
);

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(
<SortButton {...defaultProps} selectedSortField={SortFieldInferenceEndpoint.service} />
);

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(
<SortButton {...defaultProps} onSortFieldChange={onSortFieldChange} />
);

fireEvent.click(getByTestId('sortButton'));
expect(queryByText('Service')).toBeInTheDocument();

fireEvent.click(getByText('Service'));

await waitFor(() => {
expect(queryByText('Service')).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -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<SortButtonProps> = ({ 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<EuiSelectableOption[]>(() => {
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 (
<EuiFilterGroup data-test-subj="sortFieldEndpoints">
<EuiPopover
id={popoverId}
button={
<EuiFilterButton
iconType="arrowDown"
onClick={togglePopover}
isSelected={isPopoverOpen}
data-test-subj="sortButton"
>
{i18n.SORT}
</EuiFilterButton>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
repositionOnScroll
>
<EuiSelectable
options={selectableOptions}
singleSelection
onChange={handleSortFieldChange}
listProps={{
bordered: false,
showIcons: true,
onFocusBadge: false,
}}
>
{(list) => list}
</EuiSelectable>
</EuiPopover>
</EuiFilterGroup>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -110,6 +112,15 @@ export const TabularPage: React.FC<TabularPageProps> = ({ inferenceEndpoints })
[setFilterOptions]
);

const onSortFieldChange = useCallback(
(field: SortFieldInferenceEndpoint) => {
setQueryParams({
sortField: field,
});
},
[setQueryParams]
);

const { paginatedSortedTableData, pagination, sorting } = useTableData(
inferenceEndpoints,
queryParams,
Expand Down Expand Up @@ -138,11 +149,13 @@ export const TabularPage: React.FC<TabularPageProps> = ({ inferenceEndpoints })
width: '300px',
},
{
field: 'model',
name: i18n.MODEL,
'data-test-subj': 'modelCell',
render: (endpointInfo: InferenceInferenceEndpointInfo) => {
render: (_model: unknown, endpointInfo: InferenceInferenceEndpointInfo) => {
return <Model endpointInfo={endpointInfo} />;
},
sortable: true,
width: '200px',
},
{
Expand All @@ -156,7 +169,7 @@ export const TabularPage: React.FC<TabularPageProps> = ({ inferenceEndpoints })

return null;
},
sortable: false,
sortable: true,
width: '285px',
},
{
Expand All @@ -170,7 +183,7 @@ export const TabularPage: React.FC<TabularPageProps> = ({ inferenceEndpoints })

return null;
},
sortable: false,
sortable: true,
width: '100px',
},
{
Expand Down Expand Up @@ -250,6 +263,12 @@ export const TabularPage: React.FC<TabularPageProps> = ({ inferenceEndpoints })
<EuiFlexItem style={{ width: '400px' }} grow={false}>
<TableSearch searchKey={searchKey} setSearchKey={setSearchKey} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SortButton
selectedSortField={queryParams.sortField}
onSortFieldChange={onSortFieldChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ServiceProviderFilter
optionKeys={filterOptions.provider}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,29 @@
*/

import type { ServiceProviderKeys } from '@kbn/inference-endpoint-ui-common';
import type { InferenceTaskType } from '@elastic/elasticsearch/lib/api/types';
import type {
InferenceInferenceEndpointInfo,
InferenceTaskType,
} from '@elastic/elasticsearch/lib/api/types';

export const INFERENCE_ENDPOINTS_TABLE_PER_PAGE_VALUES = [25, 50, 100];

export enum SortFieldInferenceEndpoint {
inference_id = 'inference_id',
// Extended type that includes 'model' as a sortable field
// 'model' is a virtual field extracted from service_settings via getModelId()
export interface SortableInferenceEndpoint extends InferenceInferenceEndpointInfo {
model?: string;
}

// Sort fields are derived from SortableInferenceEndpoint to ensure type safety
export type SortFieldInferenceEndpoint = 'inference_id' | 'service' | 'task_type' | 'model';

export const SortFieldInferenceEndpoint = {
inference_id: 'inference_id',
service: 'service',
task_type: 'task_type',
model: 'model',
} as const;

export enum SortOrder {
asc = 'asc',
desc = 'desc',
Expand Down
Loading