[Uptime][Monitor Management UI]Add sorting to Monitor Management monitors list table. (#124103)(#121117)

* Adds sorting to Monitor Management monitors list table. Default sorting is 'asc' on the 'name' column.
* Fix column wrapping on monitor list table (Monitor Management UI).

#121117
This commit is contained in:
Abdul Wahab Zahid 2022-02-02 12:49:03 +01:00 committed by GitHub
parent e5a8cec25e
commit 2c9f8a956c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 213 additions and 113 deletions

View file

@ -10,6 +10,8 @@ import * as t from 'io-ts';
export const FetchMonitorManagementListQueryArgsType = t.partial({
page: t.number,
perPage: t.number,
sortField: t.string,
sortOrder: t.union([t.literal('desc'), t.literal('asc')]),
search: t.string,
searchFields: t.array(t.string),
});

View file

@ -18,11 +18,11 @@ import { spyOnUseFetcher } from '../../../lib/helper/spy_use_fetcher';
import { Actions } from './actions';
describe('<Actions />', () => {
const setRefresh = jest.fn();
const onUpdate = jest.fn();
const useFetcher = spyOnUseFetcher({});
it('navigates to edit monitor flow on edit pencil', () => {
render(<Actions id="test-id" setRefresh={setRefresh} />);
render(<Actions id="test-id" onUpdate={onUpdate} />);
expect(screen.getByLabelText('Edit monitor')).toHaveAttribute(
'href',
@ -34,7 +34,7 @@ describe('<Actions />', () => {
useFetcher.mockImplementation(originalUseFetcher);
const deleteMonitor = jest.spyOn(fetchers, 'deleteMonitor');
const id = 'test-id';
render(<Actions id={id} setRefresh={setRefresh} />);
render(<Actions id={id} onUpdate={onUpdate} />);
expect(deleteMonitor).not.toBeCalled();
@ -45,11 +45,11 @@ describe('<Actions />', () => {
it('calls set refresh when deletion is successful', () => {
const id = 'test-id';
render(<Actions id={id} setRefresh={setRefresh} />);
render(<Actions id={id} onUpdate={onUpdate} />);
userEvent.click(screen.getByLabelText('Delete monitor'));
expect(setRefresh).toBeCalledWith(true);
expect(onUpdate).toHaveBeenCalled();
});
it('shows loading spinner while waiting for monitor to delete', () => {
@ -59,7 +59,7 @@ describe('<Actions />', () => {
status: FETCH_STATUS.LOADING,
refetch: () => {},
});
render(<Actions id={id} setRefresh={setRefresh} />);
render(<Actions id={id} onUpdate={onUpdate} />);
expect(screen.getByLabelText('Deleting monitor...')).toBeInTheDocument();
});

View file

@ -15,11 +15,11 @@ import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'
interface Props {
id: string;
setRefresh: React.Dispatch<React.SetStateAction<boolean>>;
isDisabled?: boolean;
onUpdate: () => void;
}
export const Actions = ({ id, setRefresh, isDisabled }: Props) => {
export const Actions = ({ id, onUpdate, isDisabled }: Props) => {
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const { basePath } = useContext(UptimeSettingsContext);
@ -46,13 +46,13 @@ export const Actions = ({ id, setRefresh, isDisabled }: Props) => {
toastLifeTimeMs: 3000,
});
} else if (status === FETCH_STATUS.SUCCESS) {
setRefresh(true);
onUpdate();
notifications.toasts.success({
title: <p data-test-subj="uptimeDeleteMonitorSuccess">{MONITOR_DELETE_SUCCESS_LABEL}</p>,
toastLifeTimeMs: 3000,
});
}
}, [setIsDeleting, setRefresh, notifications.toasts, status]);
}, [setIsDeleting, onUpdate, notifications.toasts, status]);
// TODO: Add popovers to icons
return (

View file

@ -15,7 +15,7 @@ import { spyOnUseFetcher } from '../../../lib/helper/spy_use_fetcher';
import { MonitorEnabled } from './monitor_enabled';
describe('<MonitorEnabled />', () => {
const setRefresh = jest.fn();
const onUpdate = jest.fn();
const testMonitor = {
[ConfigKey.MONITOR_TYPE]: DataStream.HTTP,
[ConfigKey.ENABLED]: true,
@ -34,7 +34,7 @@ describe('<MonitorEnabled />', () => {
});
it('correctly renders "enabled" state', () => {
render(<MonitorEnabled id="test-id" monitor={testMonitor} setRefresh={setRefresh} />);
render(<MonitorEnabled id="test-id" monitor={testMonitor} onUpdate={onUpdate} />);
const switchButton = screen.getByRole('switch') as HTMLButtonElement;
assertMonitorEnabled(switchButton);
@ -45,7 +45,7 @@ describe('<MonitorEnabled />', () => {
<MonitorEnabled
id="test-id"
monitor={{ ...testMonitor, [ConfigKey.ENABLED]: false }}
setRefresh={setRefresh}
onUpdate={onUpdate}
/>
);
@ -54,7 +54,7 @@ describe('<MonitorEnabled />', () => {
});
it('toggles on click', () => {
render(<MonitorEnabled id="test-id" monitor={testMonitor} setRefresh={setRefresh} />);
render(<MonitorEnabled id="test-id" monitor={testMonitor} onUpdate={onUpdate} />);
const switchButton = screen.getByRole('switch') as HTMLButtonElement;
userEvent.click(switchButton);
@ -70,7 +70,7 @@ describe('<MonitorEnabled />', () => {
refetch: () => {},
});
render(<MonitorEnabled id="test-id" monitor={testMonitor} setRefresh={setRefresh} />);
render(<MonitorEnabled id="test-id" monitor={testMonitor} onUpdate={onUpdate} />);
const switchButton = screen.getByRole('switch') as HTMLButtonElement;
userEvent.click(switchButton);

View file

@ -16,11 +16,11 @@ import { setMonitor } from '../../../state/api';
interface Props {
id: string;
monitor: SyntheticsMonitor;
setRefresh: React.Dispatch<React.SetStateAction<boolean>>;
onUpdate: () => void;
isDisabled?: boolean;
}
export const MonitorEnabled = ({ id, monitor, setRefresh, isDisabled }: Props) => {
export const MonitorEnabled = ({ id, monitor, onUpdate, isDisabled }: Props) => {
const [isEnabled, setIsEnabled] = useState<boolean | null>(null);
const { notifications } = useKibana();
@ -53,7 +53,7 @@ export const MonitorEnabled = ({ id, monitor, setRefresh, isDisabled }: Props) =
),
toastLifeTimeMs: 3000,
});
setRefresh(true);
onUpdate();
}
}, [status]); // eslint-disable-line react-hooks/exhaustive-deps

View file

@ -5,24 +5,24 @@
* 2.0.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { ConfigKey, DataStream, HTTPFields, ScheduleUnit } from '../../../../common/runtime_types';
import { render } from '../../../lib/helper/rtl_helpers';
import { DataStream, HTTPFields, ScheduleUnit } from '../../../../common/runtime_types';
import { MonitorManagementList } from './monitor_list';
import { MonitorManagementList as MonitorManagementListState } from '../../../state/reducers/monitor_management';
import { MonitorManagementList, MonitorManagementListPageState } from './monitor_list';
describe('<ActionBar />', () => {
const setRefresh = jest.fn();
const setPageSize = jest.fn();
const setPageIndex = jest.fn();
describe('<MonitorManagementList />', () => {
const onUpdate = jest.fn();
const onPageStateChange = jest.fn();
const monitors = [];
for (let i = 0; i < 12; i++) {
monitors.push({
id: `test-monitor-id-${i}`,
attributes: {
name: `test-monitor-${i}`,
enabled: true,
schedule: {
unit: ScheduleUnit.MINUTES,
number: `${i}`,
@ -53,13 +53,20 @@ describe('<ActionBar />', () => {
} as MonitorManagementListState,
};
const pageState: MonitorManagementListPageState = {
pageIndex: 1,
pageSize: 10,
sortField: ConfigKey.NAME,
sortOrder: 'asc',
};
it.each(monitors)('navigates to edit monitor flow on edit pencil', (monitor) => {
render(
<MonitorManagementList
setRefresh={setRefresh}
setPageSize={setPageSize}
setPageIndex={setPageIndex}
onUpdate={onUpdate}
onPageStateChange={onPageStateChange}
monitorList={state.monitorManagementList}
pageState={pageState}
/>,
{ state }
);
@ -79,10 +86,10 @@ describe('<ActionBar />', () => {
it('handles changing per page', () => {
render(
<MonitorManagementList
setRefresh={setRefresh}
setPageSize={setPageSize}
setPageIndex={setPageIndex}
onUpdate={onUpdate}
onPageStateChange={onPageStateChange}
monitorList={state.monitorManagementList}
pageState={pageState}
/>,
{ state }
);
@ -91,23 +98,22 @@ describe('<ActionBar />', () => {
userEvent.click(screen.getByText('10 rows'));
expect(setPageSize).toBeCalledWith(10);
expect(onPageStateChange).toBeCalledWith(expect.objectContaining({ pageSize: 10 }));
});
it('handles refreshing and changing page when navigating to the next page', () => {
render(
it('handles refreshing and changing page when navigating to the next page', async () => {
const { getByTestId } = render(
<MonitorManagementList
setRefresh={setRefresh}
setPageSize={setPageSize}
setPageIndex={setPageIndex}
onUpdate={onUpdate}
onPageStateChange={onPageStateChange}
monitorList={state.monitorManagementList}
pageState={{ ...pageState, pageSize: 3, pageIndex: 1 }}
/>,
{ state }
);
userEvent.click(screen.getByTestId('pagination-button-next'));
userEvent.click(getByTestId('pagination-button-next'));
expect(setPageIndex).toBeCalledWith(2);
expect(setRefresh).toBeCalledWith(true);
expect(onPageStateChange).toBeCalledWith(expect.objectContaining({ pageIndex: 2 }));
});
});

View file

@ -4,139 +4,177 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useContext, useMemo, useCallback } from 'react';
import {
Criteria,
EuiBasicTable,
EuiBasicTableColumn,
EuiLink,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import { EuiTableSortingType } from '@elastic/eui/src/components/basic_table/table_types';
import { i18n } from '@kbn/i18n';
import { EuiBasicTable, EuiPanel, EuiSpacer, EuiLink } from '@elastic/eui';
import { SyntheticsMonitorSavedObject } from '../../../../common/types';
import { MonitorManagementList as MonitorManagementListState } from '../../../state/reducers/monitor_management';
import { MonitorFields, SyntheticsMonitor } from '../../../../common/runtime_types';
import React, { useCallback, useContext, useMemo } from 'react';
import {
CommonFields,
ConfigKey,
FetchMonitorManagementListQueryArgs,
ICMPSimpleFields,
MonitorFields,
ServiceLocations,
SyntheticsMonitorWithId,
TCPSimpleFields,
} from '../../../../common/runtime_types';
import { UptimeSettingsContext } from '../../../contexts';
import { useBreakpoints } from '../../../hooks';
import { MonitorManagementList as MonitorManagementListState } from '../../../state/reducers/monitor_management';
import * as labels from '../../overview/monitor_list/translations';
import { Actions } from './actions';
import { MonitorEnabled } from './monitor_enabled';
import { MonitorLocations } from './monitor_locations';
import { MonitorTags } from './tags';
import { MonitorEnabled } from './monitor_enabled';
import * as labels from '../../overview/monitor_list/translations';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
export interface MonitorManagementListPageState {
pageIndex: number;
pageSize: number;
sortField: keyof MonitorFields;
sortOrder: NonNullable<FetchMonitorManagementListQueryArgs['sortOrder']>;
}
interface Props {
setPageSize: React.Dispatch<React.SetStateAction<number>>;
setPageIndex: React.Dispatch<React.SetStateAction<number>>;
setRefresh: React.Dispatch<React.SetStateAction<boolean>>;
pageState: MonitorManagementListPageState;
monitorList: MonitorManagementListState;
onPageStateChange: (state: MonitorManagementListPageState) => void;
onUpdate: () => void;
}
export const MonitorManagementList = ({
pageState: { pageIndex, pageSize, sortField, sortOrder },
monitorList: {
list,
error: { monitorList: error },
loading: { monitorList: loading },
},
setRefresh,
setPageSize,
setPageIndex,
onPageStateChange,
onUpdate,
}: Props) => {
const { total, perPage, page: pageIndex } = list as MonitorManagementListState['list'];
const monitors = list.monitors as SyntheticsMonitorSavedObject[];
const { basePath } = useContext(UptimeSettingsContext);
const isXl = useBreakpoints().up('xl');
const pagination = useMemo(
() => ({
pageIndex: pageIndex - 1, // page index for EuiBasicTable is base 0
pageSize: perPage,
totalItemCount: total || 0,
pageSizeOptions: [10, 25, 50, 100],
}),
[pageIndex, perPage, total]
const { total } = list as MonitorManagementListState['list'];
const monitors: SyntheticsMonitorWithId[] = useMemo(
() => list.monitors.map((monitor) => ({ ...monitor.attributes, id: monitor.id })),
[list.monitors]
);
const handleOnChange = useCallback(
({ page = {} }) => {
({
page = { index: 0, size: 10 },
sort = { field: ConfigKey.NAME, direction: 'asc' },
}: Criteria<SyntheticsMonitorWithId>) => {
const { index, size } = page;
const { field, direction } = sort;
setPageIndex(index + 1); // page index for Saved Objects is base 1
setPageSize(size);
setRefresh(true);
onPageStateChange({
pageIndex: index + 1, // page index for Saved Objects is base 1
pageSize: size,
sortField: field as keyof MonitorFields,
sortOrder: direction,
});
},
[setPageIndex, setPageSize, setRefresh]
[onPageStateChange]
);
const pagination = {
pageIndex: pageIndex - 1, // page index for EuiBasicTable is base 0
pageSize,
totalItemCount: total || 0,
pageSizeOptions: [10, 25, 50, 100],
};
const sorting: EuiTableSortingType<SyntheticsMonitorWithId> = {
sort: {
field: sortField as keyof SyntheticsMonitorWithId,
direction: sortOrder,
},
};
const canEdit: boolean = !!useKibana().services?.application?.capabilities.uptime.save;
const columns = [
{
align: 'left' as const,
field: ConfigKey.NAME as string,
name: i18n.translate('xpack.uptime.monitorManagement.monitorList.monitorName', {
defaultMessage: 'Monitor name',
}),
render: ({
attributes: { name },
id,
}: {
attributes: Partial<MonitorFields>;
id: string;
}) => (
sortable: true,
render: (name: string, { id }: SyntheticsMonitorWithId) => (
<EuiLink
href={`${basePath}/app/uptime/monitor/${Buffer.from(id, 'utf8').toString('base64')}`}
>
{name}
</EuiLink>
),
truncateText: true,
},
{
align: 'left' as const,
field: 'attributes',
field: ConfigKey.MONITOR_TYPE,
name: i18n.translate('xpack.uptime.monitorManagement.monitorList.monitorType', {
defaultMessage: 'Monitor type',
}),
render: ({ type }: SyntheticsMonitor) => type,
sortable: true,
},
{
align: 'left' as const,
field: 'attributes',
field: ConfigKey.TAGS,
name: i18n.translate('xpack.uptime.monitorManagement.monitorList.tags', {
defaultMessage: 'Tags',
}),
render: ({ tags }: SyntheticsMonitor) => (tags ? <MonitorTags tags={tags} /> : null),
render: (tags: string[]) => (tags ? <MonitorTags tags={tags} /> : null),
},
{
align: 'left' as const,
field: 'attributes',
field: ConfigKey.LOCATIONS,
name: i18n.translate('xpack.uptime.monitorManagement.monitorList.locations', {
defaultMessage: 'Locations',
}),
render: ({ locations }: SyntheticsMonitor) =>
render: (locations: ServiceLocations) =>
locations ? <MonitorLocations locations={locations} /> : null,
},
{
align: 'left' as const,
field: 'attributes',
field: ConfigKey.SCHEDULE,
name: i18n.translate('xpack.uptime.monitorManagement.monitorList.schedule', {
defaultMessage: 'Schedule',
}),
render: ({ schedule }: SyntheticsMonitor) => `@every ${schedule?.number}${schedule?.unit}`,
render: (schedule: CommonFields[ConfigKey.SCHEDULE]) =>
`@every ${schedule?.number}${schedule?.unit}`,
},
{
align: 'left' as const,
field: 'attributes',
field: ConfigKey.URLS,
name: i18n.translate('xpack.uptime.monitorManagement.monitorList.URL', {
defaultMessage: 'URL',
}),
render: (attributes: MonitorFields) => attributes.urls || attributes.hosts,
sortable: true,
render: (urls: string, { hosts }: TCPSimpleFields | ICMPSimpleFields) => urls || hosts,
truncateText: true,
textOnly: true,
},
{
align: 'left' as const,
field: 'attributes',
field: ConfigKey.ENABLED as string,
name: i18n.translate('xpack.uptime.monitorManagement.monitorList.enabled', {
defaultMessage: 'Enabled',
}),
render: (attributes: SyntheticsMonitor, record: SyntheticsMonitorSavedObject) => (
render: (_enabled: boolean, monitor: SyntheticsMonitorWithId) => (
<MonitorEnabled
id={record.id}
monitor={attributes}
setRefresh={setRefresh}
id={monitor.id}
monitor={monitor}
isDisabled={!canEdit}
onUpdate={onUpdate}
/>
),
},
@ -146,9 +184,9 @@ export const MonitorManagementList = ({
name: i18n.translate('xpack.uptime.monitorManagement.monitorList.actions', {
defaultMessage: 'Actions',
}),
render: (id: string) => <Actions id={id} setRefresh={setRefresh} isDisabled={!canEdit} />,
render: (id: string) => <Actions id={id} isDisabled={!canEdit} onUpdate={onUpdate} />,
},
];
] as Array<EuiBasicTableColumn<SyntheticsMonitorWithId>>;
return (
<EuiPanel hasBorder>
@ -164,8 +202,9 @@ export const MonitorManagementList = ({
itemId="monitor_id"
items={monitors}
columns={columns}
tableLayout={'auto'}
tableLayout={isXl ? 'auto' : 'fixed'}
pagination={pagination}
sorting={sorting}
onChange={handleOnChange}
noItemsMessage={loading ? labels.LOADING : labels.NO_DATA_MESSAGE}
/>

View file

@ -22,13 +22,13 @@ export const MonitorLocations = ({ locations }: Props) => {
const locationsToDisplay = locations.slice(0, toDisplay);
return (
<EuiBadgeGroup>
<EuiBadgeGroup css={{ width: '100%' }}>
{locationsToDisplay.map((location: ServiceLocation) => (
<EuiBadge
key={location.id}
color="hollow"
className="eui-textTruncate"
style={{ maxWidth: 120 }}
css={{ display: 'flex', maxWidth: 120 }}
>
{location.label}
</EuiBadge>

View file

@ -19,10 +19,15 @@ export const MonitorTags = ({ tags }: Props) => {
const tagsToDisplay = tags.slice(0, toDisplay);
return (
<EuiBadgeGroup>
<EuiBadgeGroup css={{ width: '100%' }}>
{tagsToDisplay.map((tag) => (
// filtering only makes sense in monitor list, where we have summary
<EuiBadge key={tag} color="hollow" className="eui-textTruncate" style={{ maxWidth: 120 }}>
<EuiBadge
key={tag}
color="hollow"
className="eui-textTruncate"
css={{ display: 'flex', maxWidth: 120 }}
>
{tag}
</EuiBadge>
))}

View file

@ -5,37 +5,82 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useReducer, useCallback, Reducer } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTrackPageview } from '../../../../observability/public';
import { ConfigKey } from '../../../common/runtime_types';
import { getMonitors } from '../../state/actions';
import { monitorManagementListSelector } from '../../state/selectors';
import { MonitorManagementList } from '../../components/monitor_management/monitor_list/monitor_list';
import {
MonitorManagementList,
MonitorManagementListPageState,
} from '../../components/monitor_management/monitor_list/monitor_list';
import { useMonitorManagementBreadcrumbs } from './use_monitor_management_breadcrumbs';
export const MonitorManagementPage: React.FC = () => {
const [refresh, setRefresh] = useState(true);
const [pageIndex, setPageIndex] = useState(1); // saved objects page index is base 1
const [pageSize, setPageSize] = useState(10); // saved objects page index is base 1
const [pageState, dispatchPageAction] = useReducer<typeof monitorManagementPageReducer>(
monitorManagementPageReducer,
{
pageIndex: 1, // saved objects page index is base 1
pageSize: 10,
sortOrder: 'asc',
sortField: ConfigKey.NAME,
}
);
const onPageStateChange = useCallback(
(state) => {
dispatchPageAction({ type: 'update', payload: state });
},
[dispatchPageAction]
);
const onUpdate = useCallback(() => {
dispatchPageAction({ type: 'refresh' });
}, [dispatchPageAction]);
useTrackPageview({ app: 'uptime', path: 'manage-monitors' });
useTrackPageview({ app: 'uptime', path: 'manage-monitors', delay: 15000 });
useMonitorManagementBreadcrumbs();
const dispatch = useDispatch();
const monitorList = useSelector(monitorManagementListSelector);
const { pageIndex, pageSize, sortField, sortOrder } = pageState as MonitorManagementListPageState;
useEffect(() => {
if (refresh) {
dispatch(getMonitors({ page: pageIndex, perPage: pageSize }));
setRefresh(false);
}
}, [dispatch, refresh, pageIndex, pageSize]);
dispatch(getMonitors({ page: pageIndex, perPage: pageSize, sortField, sortOrder }));
}, [dispatch, pageState, pageIndex, pageSize, sortField, sortOrder]);
return (
<MonitorManagementList
pageState={pageState}
monitorList={monitorList}
setPageSize={setPageSize}
setPageIndex={setPageIndex}
setRefresh={setRefresh}
onPageStateChange={onPageStateChange}
onUpdate={onUpdate}
/>
);
};
type MonitorManagementPageAction =
| {
type: 'update';
payload: MonitorManagementListPageState;
}
| { type: 'refresh' };
const monitorManagementPageReducer: Reducer<
MonitorManagementListPageState,
MonitorManagementPageAction
> = (state: MonitorManagementListPageState, action: MonitorManagementPageAction) => {
switch (action.type) {
case 'update':
return {
...state,
...action.payload,
};
case 'refresh':
return { ...state };
default:
throw new Error(`Action "${(action as MonitorManagementPageAction)?.type}" not recognizable`);
}
};

View file

@ -41,12 +41,13 @@ export const getAllSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({
query: schema.object({
page: schema.maybe(schema.number()),
perPage: schema.maybe(schema.number()),
sortField: schema.maybe(schema.string()),
sortOrder: schema.maybe(schema.oneOf([schema.literal('desc'), schema.literal('asc')])),
search: schema.maybe(schema.string()),
}),
},
handler: async ({ request, savedObjectsClient }): Promise<any> => {
const { perPage = 50, page, search } = request.query;
const { perPage = 50, page, sortField, sortOrder, search } = request.query;
// TODO: add query/filtering params
const {
saved_objects: monitors,
@ -56,6 +57,8 @@ export const getAllSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({
type: syntheticsMonitorType,
perPage,
page,
sortField,
sortOrder,
filter: search ? `${syntheticsMonitorType}.attributes.name: ${search}` : '',
});
return {