[Table list view] Improve UX (phase 1) (#135892)

This commit is contained in:
Sébastien Loix 2022-09-19 11:29:23 +01:00 committed by GitHub
parent d05b534350
commit 189196181c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 2471 additions and 1874 deletions

1
.github/CODEOWNERS vendored
View file

@ -665,6 +665,7 @@ packages/analytics/shippers/elastic_v3/browser @elastic/kibana-core
packages/analytics/shippers/elastic_v3/common @elastic/kibana-core
packages/analytics/shippers/elastic_v3/server @elastic/kibana-core
packages/analytics/shippers/fullstory @elastic/kibana-core
packages/content-management/table_list @elastic/shared-ux
packages/core/analytics/core-analytics-browser @elastic/kibana-core
packages/core/analytics/core-analytics-browser-internal @elastic/kibana-core
packages/core/analytics/core-analytics-browser-mocks @elastic/kibana-core

View file

@ -7,6 +7,7 @@
"bfetch": "src/plugins/bfetch",
"charts": "src/plugins/charts",
"console": "src/plugins/console",
"contentManagement": "packages/content-management",
"core": [
"src/core",
"packages/core"

View file

@ -146,6 +146,7 @@
"@kbn/config": "link:bazel-bin/packages/kbn-config",
"@kbn/config-mocks": "link:bazel-bin/packages/kbn-config-mocks",
"@kbn/config-schema": "link:bazel-bin/packages/kbn-config-schema",
"@kbn/content-management-table-list": "link:bazel-bin/packages/content-management/table_list",
"@kbn/core-analytics-browser": "link:bazel-bin/packages/core/analytics/core-analytics-browser",
"@kbn/core-analytics-browser-internal": "link:bazel-bin/packages/core/analytics/core-analytics-browser-internal",
"@kbn/core-analytics-browser-mocks": "link:bazel-bin/packages/core/analytics/core-analytics-browser-mocks",
@ -843,6 +844,7 @@
"@types/kbn__config": "link:bazel-bin/packages/kbn-config/npm_module_types",
"@types/kbn__config-mocks": "link:bazel-bin/packages/kbn-config-mocks/npm_module_types",
"@types/kbn__config-schema": "link:bazel-bin/packages/kbn-config-schema/npm_module_types",
"@types/kbn__content-management-table-list": "link:bazel-bin/packages/content-management/table_list/npm_module_types",
"@types/kbn__core-analytics-browser": "link:bazel-bin/packages/core/analytics/core-analytics-browser/npm_module_types",
"@types/kbn__core-analytics-browser-internal": "link:bazel-bin/packages/core/analytics/core-analytics-browser-internal/npm_module_types",
"@types/kbn__core-analytics-browser-mocks": "link:bazel-bin/packages/core/analytics/core-analytics-browser-mocks/npm_module_types",

View file

@ -14,6 +14,7 @@ filegroup(
"//packages/analytics/shippers/elastic_v3/common:build",
"//packages/analytics/shippers/elastic_v3/server:build",
"//packages/analytics/shippers/fullstory:build",
"//packages/content-management/table_list:build",
"//packages/core/analytics/core-analytics-browser:build",
"//packages/core/analytics/core-analytics-browser-internal:build",
"//packages/core/analytics/core-analytics-browser-mocks:build",
@ -327,6 +328,7 @@ filegroup(
"//packages/analytics/shippers/elastic_v3/common:build_types",
"//packages/analytics/shippers/elastic_v3/server:build_types",
"//packages/analytics/shippers/fullstory:build_types",
"//packages/content-management/table_list:build_types",
"//packages/core/analytics/core-analytics-browser:build_types",
"//packages/core/analytics/core-analytics-browser-internal:build_types",
"//packages/core/analytics/core-analytics-browser-mocks:build_types",

View file

@ -0,0 +1,17 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
const defaultConfig = require('@kbn/storybook').defaultConfig;
module.exports = {
...defaultConfig,
stories: ['../**/*.stories.tsx'],
reactOptions: {
strictMode: true,
},
};

View file

@ -0,0 +1,19 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
const { addons } = require('@storybook/addons');
const { create } = require('@storybook/theming');
const { PANEL_ID } = require('@storybook/addon-actions');
addons.setConfig({
theme: create({
base: 'light',
brandTitle: 'Content Management Storybook',
}),
showPanel: () => true,
selectedPanel: PANEL_ID,
});

View file

@ -0,0 +1,160 @@
load("@npm//@bazel/typescript:index.bzl", "ts_config")
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project")
PKG_DIRNAME = "table_list"
PKG_REQUIRE_NAME = "@kbn/content-management-table-list"
SOURCE_FILES = glob(
[
"**/*.ts",
"**/*.tsx",
],
exclude = [
"**/*.config.js",
"**/*.mock.*",
"**/*.test.*",
"**/*.stories.*",
"**/__snapshots__",
"**/integration_tests",
"**/mocks",
"**/scripts",
"**/storybook",
"**/test_fixtures",
"**/test_helpers",
],
)
SRCS = SOURCE_FILES
filegroup(
name = "srcs",
srcs = SRCS,
)
NPM_MODULE_EXTRA_FILES = [
"package.json",
]
# In this array place runtime dependencies, including other packages and NPM packages
# which must be available for this code to run.
#
# To reference other packages use:
# "//repo/relative/path/to/package"
# eg. "//packages/kbn-utils"
#
# To reference a NPM package use:
# "@npm//name-of-package"
# eg. "@npm//lodash"
RUNTIME_DEPS = [
"//packages/kbn-i18n-react",
"//packages/kbn-i18n",
"//packages/core/http/core-http-browser",
"//packages/core/theme/core-theme-browser",
"//packages/kbn-safer-lodash-set",
"//packages/shared-ux/page/kibana_template/impl",
"@npm//@elastic/eui",
"@npm//@emotion/react",
"@npm//@emotion/css",
"@npm//lodash",
"@npm//moment",
"@npm//react-use",
"@npm//react",
"@npm//rxjs",
]
# In this array place dependencies necessary to build the types, which will include the
# :npm_module_types target of other packages and packages from NPM, including @types/*
# packages.
#
# To reference the types for another package use:
# "//repo/relative/path/to/package:npm_module_types"
# eg. "//packages/kbn-utils:npm_module_types"
#
# References to NPM packages work the same as RUNTIME_DEPS
TYPES_DEPS = [
"//packages/kbn-i18n:npm_module_types",
"//packages/kbn-i18n-react:npm_module_types",
"//packages/core/http/core-http-browser:npm_module_types",
"//packages/core/theme/core-theme-browser:npm_module_types",
"//packages/kbn-ambient-storybook-types",
"//packages/kbn-ambient-ui-types",
"//packages/kbn-safer-lodash-set:npm_module_types",
"//packages/shared-ux/page/kibana_template/impl:npm_module_types",
"//packages/shared-ux/page/kibana_template/types",
"@npm//@types/node",
"@npm//@types/jest",
"@npm//@types/lodash",
"@npm//@types/react",
"@npm//@elastic/eui",
"@npm//react-use",
"@npm//rxjs",
]
jsts_transpiler(
name = "target_node",
srcs = SRCS,
build_pkg_name = package_name(),
)
jsts_transpiler(
name = "target_web",
srcs = SRCS,
build_pkg_name = package_name(),
web = True,
)
ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = [
"//:tsconfig.base.json",
"//:tsconfig.bazel.json",
],
)
ts_project(
name = "tsc_types",
args = ['--pretty'],
srcs = SRCS,
deps = TYPES_DEPS,
declaration = True,
declaration_map = True,
emit_declaration_only = True,
out_dir = "target_types",
tsconfig = ":tsconfig",
)
js_library(
name = PKG_DIRNAME,
srcs = NPM_MODULE_EXTRA_FILES,
deps = RUNTIME_DEPS + [":target_node", ":target_web"],
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
pkg_npm(
name = "npm_module",
deps = [":" + PKG_DIRNAME],
)
filegroup(
name = "build",
srcs = [":npm_module"],
visibility = ["//visibility:public"],
)
pkg_npm_types(
name = "npm_module_types",
srcs = SRCS,
deps = [":tsc_types"],
package_name = PKG_REQUIRE_NAME,
tsconfig = ":tsconfig",
visibility = ["//visibility:public"],
)
filegroup(
name = "build_types",
srcs = [":npm_module_types"],
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,20 @@
---
id: sharedUX/contentManagement/TableList
slug: /shared-ux/content-management/table-list
title: Table list view
summary: A table to render user generated saved objects.
tags: ['shared-ux', 'content-management']
date: 2022-08-09
---
The `<TableListView />` render a eui page to display a list of user content saved object.
**Uncomplete documentation**. Will be updated.
## API
TODO
## EUI Promotion Status
This component is not currently considered for promotion to EUI.

View file

@ -0,0 +1,11 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { TableListView, TableListViewProvider, TableListViewKibanaProvider } from './src';
export type { UserContentCommonSchema } from './src';

View file

@ -0,0 +1,13 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/content-management/table_list'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/content-management-table-list",
"owner": "@elastic/shared-ux",
"runtimeDeps": [],
"typeDeps": []
}

View file

@ -0,0 +1,8 @@
{
"name": "@kbn/content-management-table-list",
"private": true,
"version": "1.0.0",
"main": "./target_node/index.js",
"browser": "./target_web/index.js",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export * from './table_list_view';
export { WithServices } from './tests.helpers';

View file

@ -0,0 +1,36 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import type { ComponentType } from 'react';
import { from } from 'rxjs';
import { TableListViewProvider, Services } from '../services';
export const getMockServices = (overrides?: Partial<Services>) => {
const services: Services = {
canEditAdvancedSettings: true,
getListingLimitSettingsUrl: () => 'http://elastic.co',
notifyError: () => undefined,
currentAppId$: from('mockedApp'),
navigateToUrl: () => undefined,
...overrides,
};
return services;
};
export function WithServices<P>(Comp: ComponentType<P>, overrides: Partial<Services> = {}) {
return (props: P) => {
const services = getMockServices(overrides);
return (
<TableListViewProvider {...services}>
<Comp {...(props as any)} />
</TableListViewProvider>
);
};
}

View file

@ -0,0 +1,74 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { CriteriaWithPagination } from '@elastic/eui';
/** Action to trigger a fetch of the table items */
export interface OnFetchItemsAction {
type: 'onFetchItems';
}
/** Action to return the fetched table items */
export interface OnFetchItemsSuccessAction<T> {
type: 'onFetchItemsSuccess';
data: {
response: {
total: number;
hits: T[];
};
};
}
/** Action to return any error while fetching the table items */
export interface OnFetchItemsErrorAction {
type: 'onFetchItemsError';
data: IHttpFetchError<Error>;
}
/**
* Actions to update the state of items deletions
* - onDeleteItems: emit before deleting item(s)
* - onItemsDeleted: emit after deleting item(s)
* - onCancelDeleteItems: emit to cancel deleting items (and close the modal)
*/
export interface DeleteItemsActions {
type: 'onCancelDeleteItems' | 'onDeleteItems' | 'onItemsDeleted';
}
/** Action to update the selection of items in the table (for batch operations) */
export interface OnSelectionChangeAction<T> {
type: 'onSelectionChange';
data: T[];
}
/** Action to update the state of the table whenever the sort or page size changes */
export interface OnTableChangeAction<T> {
type: 'onTableChange';
data: CriteriaWithPagination<T>;
}
/** Action to display the delete confirmation modal */
export interface ShowConfirmDeleteItemsModalAction {
type: 'showConfirmDeleteItemsModal';
}
/** Action to update the search bar query text */
export interface OnSearchQueryChangeAction {
type: 'onSearchQueryChange';
data: string;
}
export type Action<T> =
| OnFetchItemsAction
| OnFetchItemsSuccessAction<T>
| OnFetchItemsErrorAction
| DeleteItemsActions
| OnSelectionChangeAction<T>
| OnTableChangeAction<T>
| ShowConfirmDeleteItemsModalAction
| OnSearchQueryChangeAction;

View file

@ -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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiConfirmModal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
function getI18nTexts(items: unknown[], entityName: string, entityNamePlural: string) {
return {
deleteBtnLabel: i18n.translate(
'contentManagement.tableList.listing.deleteSelectedItemsConfirmModal.confirmButtonLabel',
{
defaultMessage: 'Delete',
}
),
deletingBtnLabel: i18n.translate(
'contentManagement.tableList.listing.deleteSelectedItemsConfirmModal.confirmButtonLabelDeleting',
{
defaultMessage: 'Deleting',
}
),
title: i18n.translate('contentManagement.tableList.listing.deleteSelectedConfirmModal.title', {
defaultMessage: 'Delete {itemCount} {entityName}?',
values: {
itemCount: items.length,
entityName: items.length === 1 ? entityName : entityNamePlural,
},
}),
description: i18n.translate(
'contentManagement.tableList.listing.deleteConfirmModalDescription',
{
defaultMessage: `You can't recover deleted {entityNamePlural}.`,
values: {
entityNamePlural,
},
}
),
cancelBtnLabel: i18n.translate(
'contentManagement.tableList.listing.deleteSelectedItemsConfirmModal.cancelButtonLabel',
{
defaultMessage: 'Cancel',
}
),
};
}
interface Props<T> {
/** Flag to indicate if the items are being deleted */
isDeletingItems: boolean;
/** Array of items to delete */
items: T[];
/** The name of the entity to delete (singular) */
entityName: string;
/** The name of the entity to delete (plural) */
entityNamePlural: string;
/** Handler to be called when clicking the "Cancel" button */
onCancel: () => void;
/** Handler to be called when clicking the "Confirm" button */
onConfirm: () => void;
}
export function ConfirmDeleteModal<T>({
isDeletingItems,
items,
entityName,
entityNamePlural,
onCancel,
onConfirm,
}: Props<T>) {
const { deleteBtnLabel, deletingBtnLabel, title, description, cancelBtnLabel } = getI18nTexts(
items,
entityName,
entityNamePlural
);
return (
<EuiConfirmModal
title={title}
buttonColor="danger"
onCancel={onCancel}
onConfirm={onConfirm}
cancelButtonText={cancelBtnLabel}
confirmButtonText={isDeletingItems ? deletingBtnLabel : deleteBtnLabel}
defaultFocusedButton="cancel"
>
<p>{description}</p>
</EuiConfirmModal>
);
}

View file

@ -0,0 +1,12 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { Table } from './table';
export { UpdatedAtField } from './updated_at_field';
export { ConfirmDeleteModal } from './confirm_delete_modal';
export { ListingLimitWarning } from './listing_limit_warning';

View file

@ -0,0 +1,78 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
interface Props<T> {
entityNamePlural: string;
canEditAdvancedSettings: boolean;
advancedSettingsLink: string;
totalItems: number;
listingLimit: number;
}
export function ListingLimitWarning<T>({
entityNamePlural,
totalItems,
listingLimit,
canEditAdvancedSettings,
advancedSettingsLink,
}: Props<T>) {
return (
<>
<EuiCallOut
title={
<FormattedMessage
id="contentManagement.tableList.listing.listingLimitExceededTitle"
defaultMessage="Listing limit exceeded"
/>
}
color="warning"
iconType="help"
>
<p>
<FormattedMessage
id="contentManagement.tableList.listing.listingLimitExceededDescription"
defaultMessage="You have {totalItems} {entityNamePlural}, but your {listingLimitText} setting prevents
the table below from displaying more than {listingLimitValue}."
values={{
entityNamePlural,
totalItems,
listingLimitValue: listingLimit,
listingLimitText: <strong>listingLimit</strong>,
}}
/>{' '}
{canEditAdvancedSettings ? (
<FormattedMessage
id="contentManagement.tableList.listing.listingLimitExceededDescriptionPermissions"
defaultMessage="You can change this setting under {advancedSettingsLink}."
values={{
advancedSettingsLink: (
<EuiLink href={advancedSettingsLink}>
<FormattedMessage
id="contentManagement.tableList.listing.listingLimitExceeded.advancedSettingsLinkText"
defaultMessage="Advanced Settings"
/>
</EuiLink>
),
}}
/>
) : (
<FormattedMessage
id="contentManagement.tableList.listing.listingLimitExceededDescriptionNoPermissions"
defaultMessage="Contact your system administrator to change this setting."
/>
)}
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
}

View file

@ -0,0 +1,131 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { Dispatch, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiBasicTableColumn,
EuiButton,
EuiInMemoryTable,
CriteriaWithPagination,
PropertySort,
} from '@elastic/eui';
import { useServices } from '../services';
import type { Action } from '../actions';
import type {
State as TableListViewState,
Props as TableListViewProps,
UserContentCommonSchema,
} from '../table_list_view';
type State<T extends UserContentCommonSchema> = Pick<
TableListViewState<T>,
'items' | 'selectedIds' | 'searchQuery' | 'tableSort' | 'pagination'
>;
interface Props<T extends UserContentCommonSchema> extends State<T> {
dispatch: Dispatch<Action<T>>;
entityName: string;
entityNamePlural: string;
isFetchingItems: boolean;
tableCaption: string;
tableColumns: Array<EuiBasicTableColumn<T>>;
deleteItems: TableListViewProps<T>['deleteItems'];
}
export function Table<T extends UserContentCommonSchema>({
dispatch,
items,
isFetchingItems,
searchQuery,
selectedIds,
pagination,
tableColumns,
tableSort,
entityName,
entityNamePlural,
deleteItems,
tableCaption,
}: Props<T>) {
const { getSearchBarFilters } = useServices();
const renderToolsLeft = useCallback(() => {
if (!deleteItems || selectedIds.length === 0) {
return;
}
return (
<EuiButton
color="danger"
iconType="trash"
onClick={() => dispatch({ type: 'showConfirmDeleteItemsModal' })}
data-test-subj="deleteSelectedItems"
>
<FormattedMessage
id="contentManagement.tableList.listing.deleteButtonMessage"
defaultMessage="Delete {itemCount} {entityName}"
values={{
itemCount: selectedIds.length,
entityName: selectedIds.length === 1 ? entityName : entityNamePlural,
}}
/>
</EuiButton>
);
}, [deleteItems, dispatch, entityName, entityNamePlural, selectedIds.length]);
const selection = deleteItems
? {
onSelectionChange: (obj: T[]) => {
dispatch({ type: 'onSelectionChange', data: obj });
},
}
: undefined;
const searchFilters = getSearchBarFilters ? getSearchBarFilters() : [];
const search = {
onChange: ({ queryText }: { queryText: string }) =>
dispatch({ type: 'onSearchQueryChange', data: queryText }),
toolsLeft: renderToolsLeft(),
defaultQuery: searchQuery,
box: {
incremental: true,
'data-test-subj': 'tableListSearchBox',
},
filters: searchFilters,
};
const noItemsMessage = (
<FormattedMessage
id="contentManagement.tableList.listing.noMatchedItemsMessage"
defaultMessage="No {entityNamePlural} matched your search."
values={{ entityNamePlural }}
/>
);
return (
<EuiInMemoryTable<T>
itemId="id"
items={items}
columns={tableColumns}
pagination={pagination}
loading={isFetchingItems}
message={noItemsMessage}
selection={selection}
search={search}
sorting={tableSort ? { sort: tableSort as PropertySort } : undefined}
onChange={(criteria: CriteriaWithPagination<T>) =>
dispatch({ type: 'onTableChange', data: criteria })
}
data-test-subj="itemsInMemTable"
rowHeader="attributes.title"
tableCaption={tableCaption}
/>
);
}

View file

@ -0,0 +1,52 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiToolTip } from '@elastic/eui';
import moment from 'moment';
import { DateFormatter } from '../services';
const DefaultDateFormatter: DateFormatter = ({ value, children }) =>
children(new Date(value).toDateString());
export const UpdatedAtField: FC<{ dateTime?: string; DateFormatterComp?: DateFormatter }> = ({
dateTime,
DateFormatterComp = DefaultDateFormatter,
}) => {
if (!dateTime) {
return (
<EuiToolTip
content={i18n.translate('contentManagement.tableList.updatedDateUnknownLabel', {
defaultMessage: 'Last updated unknown',
})}
>
<span>-</span>
</EuiToolTip>
);
}
const updatedAt = moment(dateTime);
if (updatedAt.diff(moment(), 'days') > -7) {
return (
<DateFormatterComp value={new Date(dateTime).getTime()}>
{(formattedDate: string) => (
<EuiToolTip content={updatedAt.format('LL LT')}>
<span>{formattedDate}</span>
</EuiToolTip>
)}
</DateFormatterComp>
);
}
return (
<EuiToolTip content={updatedAt.format('LL LT')}>
<span>{updatedAt.format('LL')}</span>
</EuiToolTip>
);
};

View file

@ -0,0 +1,17 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { TableListView } from './table_list_view';
export type {
Props as TableListViewProps,
State as TableListViewState,
UserContentCommonSchema,
} from './table_list_view';
export { TableListViewProvider, TableListViewKibanaProvider } from './services';

View file

@ -0,0 +1,99 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { from } from 'rxjs';
import { Services } from './services';
/**
* Parameters drawn from the Storybook arguments collection that customize a component story.
*/
export type Params = Record<keyof ReturnType<typeof getStoryArgTypes>, any>;
type ActionFn = (name: string) => any;
/**
* Returns Storybook-compatible service abstractions for the `NoDataCard` Provider.
*/
export const getStoryServices = (params: Params, action: ActionFn = () => {}) => {
const services: Services = {
canEditAdvancedSettings: true,
getListingLimitSettingsUrl: () => 'http://elastic.co',
notifyError: (title, text) => {
action('notifyError')({ title, text });
},
currentAppId$: from('mockedApp'),
navigateToUrl: () => undefined,
...params,
};
return services;
};
/**
* Returns the Storybook arguments for `NoDataCard`, for its stories and for
* consuming component stories.
*/
export const getStoryArgTypes = () => ({
tableListTitle: {
control: {
type: 'text',
},
defaultValue: 'My dashboards',
},
entityName: {
control: {
type: 'text',
},
defaultValue: 'Dashboard',
},
entityNamePlural: {
control: {
type: 'text',
},
defaultValue: 'Dashboards',
},
canCreateItem: {
control: 'boolean',
defaultValue: true,
},
canEditItem: {
control: 'boolean',
defaultValue: true,
},
canDeleteItem: {
control: 'boolean',
defaultValue: true,
},
showCustomColumn: {
control: 'boolean',
defaultValue: false,
},
numberOfItemsToRender: {
control: {
type: 'number',
},
defaultValue: 15,
},
initialFilter: {
control: {
type: 'text',
},
defaultValue: '',
},
initialPageSize: {
control: {
type: 'number',
},
defaultValue: 10,
},
listingLimit: {
control: {
type: 'number',
},
defaultValue: 20,
},
});

View file

@ -0,0 +1,152 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { sortBy } from 'lodash';
import { i18n } from '@kbn/i18n';
import { UpdatedAtField } from './components';
import type { State, UserContentCommonSchema } from './table_list_view';
import type { Action } from './actions';
import type { Services } from './services';
interface Dependencies {
DateFormatterComp: Services['DateFormatterComp'];
}
function onInitialItemsFetch<T extends UserContentCommonSchema>(
items: T[],
{ DateFormatterComp }: Dependencies
) {
// We check if the saved object have the "updatedAt" metadata
// to render or not that column in the table
const hasUpdatedAtMetadata = Boolean(items.find((item) => Boolean(item.updatedAt)));
if (hasUpdatedAtMetadata) {
// Add "Last update" column and sort by that column initially
return {
tableSort: {
field: 'updatedAt' as keyof T,
direction: 'desc' as const,
},
tableColumns: [
{
field: 'updatedAt',
name: i18n.translate('contentManagement.tableList.lastUpdatedColumnTitle', {
defaultMessage: 'Last updated',
}),
render: (field: string, record: { updatedAt?: string }) => (
<UpdatedAtField dateTime={record.updatedAt} DateFormatterComp={DateFormatterComp} />
),
sortable: true,
width: '150px',
},
],
};
}
return {};
}
export function getReducer<T extends UserContentCommonSchema>({ DateFormatterComp }: Dependencies) {
return (state: State<T>, action: Action<T>): State<T> => {
switch (action.type) {
case 'onFetchItems': {
return {
...state,
isFetchingItems: true,
};
}
case 'onFetchItemsSuccess': {
const items = action.data.response.hits;
// We only get the state on the initial fetch of items
// After that we don't want to reset the columns or change the sort after fetching
const { tableColumns, tableSort } = state.hasInitialFetchReturned
? { tableColumns: undefined, tableSort: undefined }
: onInitialItemsFetch(items, { DateFormatterComp });
return {
...state,
hasInitialFetchReturned: true,
isFetchingItems: false,
items: !state.searchQuery ? sortBy<T>(items, 'title') : items,
totalItems: action.data.response.total,
tableColumns: tableColumns
? [...state.tableColumns, ...tableColumns]
: state.tableColumns,
tableSort: tableSort ?? state.tableSort,
pagination: {
...state.pagination,
totalItemCount: items.length,
},
};
}
case 'onFetchItemsError': {
return {
...state,
isFetchingItems: false,
items: [],
totalItems: 0,
fetchError: action.data,
};
}
case 'onSearchQueryChange': {
return {
...state,
searchQuery: action.data,
isFetchingItems: true,
};
}
case 'onTableChange': {
const tableSort = action.data.sort ?? state.tableSort;
return {
...state,
pagination: {
...state.pagination,
pageIndex: action.data.page.index,
pageSize: action.data.page.size,
},
tableSort,
};
}
case 'showConfirmDeleteItemsModal': {
return {
...state,
showDeleteModal: true,
};
}
case 'onDeleteItems': {
return {
...state,
isDeletingItems: true,
};
}
case 'onCancelDeleteItems': {
return {
...state,
showDeleteModal: false,
};
}
case 'onItemsDeleted': {
return {
...state,
isDeletingItems: false,
selectedIds: [],
showDeleteModal: false,
};
}
case 'onSelectionChange': {
return {
...state,
selectedIds: action.data
.map((item) => item?.id)
.filter((id): id is string => Boolean(id)),
};
}
}
};
}

View file

@ -0,0 +1,191 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC, useContext, useMemo } from 'react';
import type { EuiTableFieldDataColumnType, SearchFilterConfig } from '@elastic/eui';
import type { Observable } from 'rxjs';
import type { FormattedRelative } from '@kbn/i18n-react';
import { RedirectAppLinksKibanaProvider } from '@kbn/shared-ux-link-redirect-app';
import { UserContentCommonSchema } from './table_list_view';
type UnmountCallback = () => void;
type MountPoint = (element: HTMLElement) => UnmountCallback;
type NotifyFn = (title: JSX.Element, text?: string) => void;
export interface SavedObjectsReference {
id: string;
name: string;
type: string;
}
export type SavedObjectsFindOptionsReference = Omit<SavedObjectsReference, 'name'>;
export type DateFormatter = (props: {
value: number;
children: (formattedDate: string) => JSX.Element;
}) => JSX.Element;
/**
* Abstract external services for this component.
*/
export interface Services {
canEditAdvancedSettings: boolean;
getListingLimitSettingsUrl: () => string;
notifyError: NotifyFn;
currentAppId$: Observable<string | undefined>;
navigateToUrl: (url: string) => Promise<void> | void;
searchQueryParser?: (searchQuery: string) => {
searchQuery: string;
references?: SavedObjectsFindOptionsReference[];
};
getTagsColumnDefinition?: () => EuiTableFieldDataColumnType<UserContentCommonSchema> | undefined;
getSearchBarFilters?: () => SearchFilterConfig[];
DateFormatterComp?: DateFormatter;
}
const TableListViewContext = React.createContext<Services | null>(null);
/**
* Abstract external service Provider.
*/
export const TableListViewProvider: FC<Services> = ({ children, ...services }) => {
return <TableListViewContext.Provider value={services}>{children}</TableListViewContext.Provider>;
};
/**
* Kibana-specific service types.
*/
export interface TableListViewKibanaDependencies {
/** CoreStart contract */
core: {
application: {
capabilities: {
advancedSettings?: {
save: boolean;
};
};
getUrlForApp: (app: string, options: { path: string }) => string;
currentAppId$: Observable<string | undefined>;
navigateToUrl: (url: string) => Promise<void> | void;
};
notifications: {
toasts: {
addDanger: (notifyArgs: { title: MountPoint; text?: string }) => void;
};
};
};
/**
* Handler from the '@kbn/kibana-react-plugin/public' Plugin
*
* ```
* import { toMountPoint } from '@kbn/kibana-react-plugin/public';
* ```
*/
toMountPoint: (
node: React.ReactNode,
options?: { theme$: Observable<{ readonly darkMode: boolean }> }
) => MountPoint;
/**
* The public API from the savedObjectsTaggingOss plugin.
* It is returned by calling `getTaggingApi()` from the SavedObjectTaggingOssPluginStart
*
* ```js
* const savedObjectsTagging = savedObjectsTaggingOss?.getTaggingApi()
* ```
*/
savedObjectsTagging?: {
ui: {
getTableColumnDefinition: () => EuiTableFieldDataColumnType<UserContentCommonSchema>;
parseSearchQuery: (
query: string,
options?: {
useName?: boolean;
tagField?: string;
}
) => {
searchTerm: string;
tagReferences: SavedObjectsFindOptionsReference[];
valid: boolean;
};
getSearchBarFilter: (options?: {
useName?: boolean;
tagField?: string;
}) => SearchFilterConfig;
};
};
/** The <FormattedRelative /> component from the @kbn/i18n-react package */
FormattedRelative: typeof FormattedRelative;
}
/**
* Kibana-specific Provider that maps to known dependency types.
*/
export const TableListViewKibanaProvider: FC<TableListViewKibanaDependencies> = ({
children,
...services
}) => {
const { core, toMountPoint, savedObjectsTagging, FormattedRelative } = services;
const getSearchBarFilters = useMemo(() => {
if (savedObjectsTagging) {
return () => [savedObjectsTagging.ui.getSearchBarFilter({ useName: true })];
}
}, [savedObjectsTagging]);
const searchQueryParser = useMemo(() => {
if (savedObjectsTagging) {
return (searchQuery: string) => {
const res = savedObjectsTagging.ui.parseSearchQuery(searchQuery, { useName: true });
return {
searchQuery: res.searchTerm,
references: res.tagReferences,
};
};
}
}, [savedObjectsTagging]);
return (
<RedirectAppLinksKibanaProvider coreStart={core}>
<TableListViewProvider
canEditAdvancedSettings={Boolean(core.application.capabilities.advancedSettings?.save)}
getListingLimitSettingsUrl={() =>
core.application.getUrlForApp('management', {
path: `/kibana/settings?query=savedObjects:listingLimit`,
})
}
notifyError={(title, text) => {
core.notifications.toasts.addDanger({ title: toMountPoint(title), text });
}}
getTagsColumnDefinition={savedObjectsTagging?.ui.getTableColumnDefinition}
getSearchBarFilters={getSearchBarFilters}
searchQueryParser={searchQueryParser}
DateFormatterComp={(props) => <FormattedRelative {...props} />}
currentAppId$={core.application.currentAppId$}
navigateToUrl={core.application.navigateToUrl}
>
{children}
</TableListViewProvider>
</RedirectAppLinksKibanaProvider>
);
};
/**
* React hook for accessing pre-wired services.
*/
export function useServices() {
const context = useContext(TableListViewContext);
if (!context) {
throw new Error(
'TableListViewContext is missing. Ensure your component or React root is wrapped with <TableListViewProvider /> or <TableListViewKibanaProvider />.'
);
}
return context;
}

View file

@ -0,0 +1,108 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import Chance from 'chance';
import moment from 'moment';
import { action } from '@storybook/addon-actions';
import { Params, getStoryArgTypes, getStoryServices } from './mocks';
import { TableListView as Component, UserContentCommonSchema } from './table_list_view';
import { TableListViewProvider } from './services';
import mdx from '../README.mdx';
const chance = new Chance();
export default {
title: 'Table list view',
description: 'A table list to display user content saved objects',
parameters: {
docs: {
page: mdx,
},
},
};
const createMockItems = (total: number): UserContentCommonSchema[] => {
return [...Array(total)].map((_, i) => {
const type = itemTypes[Math.floor(Math.random() * 4)];
return {
id: i.toString(),
type,
references: [],
updatedAt: moment().subtract(i, 'day').format('YYYY-MM-DDTHH:mm:ss'),
attributes: {
title: chance.sentence({ words: 5 }),
description: `Description of item ${i}`,
},
};
});
};
const argTypes = getStoryArgTypes();
const itemTypes = ['foo', 'bar', 'baz', 'elastic'];
const mockItems: UserContentCommonSchema[] = createMockItems(500);
export const ConnectedComponent = (params: Params) => {
return (
<TableListViewProvider {...getStoryServices(params, action)}>
<Component
// Added key to force a refresh of the component state
key={`${params.initialFilter}-${params.initialPageSize}`}
findItems={(searchQuery) => {
const hits = mockItems
.filter((_, i) => i < params.numberOfItemsToRender)
.filter((item) => item.attributes.title.includes(searchQuery));
return Promise.resolve({
total: hits.length,
hits,
});
}}
getDetailViewLink={() => 'http://elastic.co'}
createItem={
params.canCreateItem
? () => {
action('Create item')();
}
: undefined
}
editItem={
params.canEditItem
? ({ attributes: { title } }) => {
action('Edit item')(title);
}
: undefined
}
deleteItems={
params.canDeleteItem
? async (items) => {
action('Delete item(s)')(
items.map(({ attributes: { title } }) => title).join(', ')
);
}
: undefined
}
customTableColumn={
params.showCustomColumn
? {
field: 'attributes.type',
name: 'Type',
}
: undefined
}
{...params}
/>
</TableListViewProvider>
);
};
ConnectedComponent.argTypes = argTypes;

View file

@ -7,13 +7,15 @@
*/
import { EuiEmptyPrompt } from '@elastic/eui';
import { shallowWithIntl, registerTestBed, TestBed } from '@kbn/test-jest-helpers';
import { ToastsStart } from '@kbn/core/public';
import React from 'react';
import { registerTestBed, TestBed } from '@kbn/test-jest-helpers';
import React, { useEffect } from 'react';
import moment, { Moment } from 'moment';
import { act } from 'react-dom/test-utils';
import { themeServiceMock, applicationServiceMock } from '@kbn/core/public/mocks';
import { TableListView, TableListViewProps } from './table_list_view';
import { WithServices } from './__jest__';
import { TableListView, Props as TableListViewProps } from './table_list_view';
const mockUseEffect = useEffect;
jest.mock('lodash', () => {
const original = jest.requireActual('lodash');
@ -24,20 +26,23 @@ jest.mock('lodash', () => {
};
});
const requiredProps: TableListViewProps<Record<string, unknown>> = {
jest.mock('react-use/lib/useDebounce', () => {
return (cb: () => void, ms: number, deps: any[]) => {
mockUseEffect(() => {
cb();
}, deps);
};
});
const requiredProps: TableListViewProps = {
entityName: 'test',
entityNamePlural: 'tests',
listingLimit: 500,
initialFilter: '',
initialPageSize: 20,
tableColumns: [],
tableListTitle: 'test title',
rowHeader: 'name',
tableCaption: 'test caption',
toastNotifications: {} as ToastsStart,
findItems: jest.fn(() => Promise.resolve({ total: 0, hits: [] })),
theme: themeServiceMock.createStartContract(),
application: applicationServiceMock.createStartContract(),
findItems: jest.fn().mockResolvedValue({ total: 0, hits: [] }),
getDetailViewLink: () => 'http://elastic.co',
};
describe('TableListView', () => {
@ -49,140 +54,91 @@ describe('TableListView', () => {
jest.useRealTimers();
});
test('render default empty prompt', async () => {
const component = shallowWithIntl(<TableListView {...requiredProps} />);
const setup = registerTestBed<string, TableListViewProps>(
WithServices<TableListViewProps>(TableListView),
{
defaultProps: { ...requiredProps },
memoryRouter: { wrapComponent: false },
}
);
// Using setState to check the final render while sidestepping the debounced promise management
component.setState({
hasInitialFetchReturned: true,
isFetchingItems: false,
test('render default empty prompt', async () => {
let testBed: TestBed;
await act(async () => {
testBed = await setup();
});
expect(component).toMatchSnapshot();
const { component, exists } = testBed!;
component.update();
expect(component.find(EuiEmptyPrompt).length).toBe(1);
expect(exists('newItemButton')).toBe(false);
});
// avoid trapping users in empty prompt that can not create new items
test('render default empty prompt with create action when createItem supplied', async () => {
const component = shallowWithIntl(<TableListView {...requiredProps} createItem={() => {}} />);
let testBed: TestBed;
// Using setState to check the final render while sidestepping the debounced promise management
component.setState({
hasInitialFetchReturned: true,
isFetchingItems: false,
await act(async () => {
testBed = await setup({ createItem: () => undefined });
});
expect(component).toMatchSnapshot();
const { component, exists } = testBed!;
component.update();
expect(component.find(EuiEmptyPrompt).length).toBe(1);
expect(exists('newItemButton')).toBe(true);
});
test('render custom empty prompt', () => {
const component = shallowWithIntl(
<TableListView {...requiredProps} emptyPrompt={<EuiEmptyPrompt />} />
);
test('render custom empty prompt', async () => {
let testBed: TestBed;
// Using setState to check the final render while sidestepping the debounced promise management
component.setState({
hasInitialFetchReturned: true,
isFetchingItems: false,
const CustomEmptyPrompt = () => {
return <EuiEmptyPrompt data-test-subj="custom-empty-prompt" title={<h1>Table empty</h1>} />;
};
await act(async () => {
testBed = await setup({ emptyPrompt: <CustomEmptyPrompt /> });
});
expect(component).toMatchSnapshot();
});
const { component, exists } = testBed!;
component.update();
test('render list view', () => {
const component = shallowWithIntl(<TableListView {...requiredProps} />);
// Using setState to check the final render while sidestepping the debounced promise management
component.setState({
hasInitialFetchReturned: true,
isFetchingItems: false,
items: [{}],
});
expect(component).toMatchSnapshot();
expect(exists('custom-empty-prompt')).toBe(true);
});
describe('default columns', () => {
let testBed: TestBed;
const tableColumns = [
{
field: 'title',
name: 'Title',
sortable: true,
},
{
field: 'description',
name: 'Description',
sortable: true,
},
];
const twoDaysAgo = new Date(new Date().setDate(new Date().getDate() - 2));
const twoDaysAgoToString = new Date(twoDaysAgo.getTime()).toDateString();
const yesterday = new Date(new Date().setDate(new Date().getDate() - 1));
const yesterdayToString = new Date(yesterday.getTime()).toDateString();
const hits = [
{
title: 'Item 1',
description: 'Item 1 description',
id: '123',
updatedAt: twoDaysAgo,
attributes: {
title: 'Item 1',
description: 'Item 1 description',
},
},
{
title: 'Item 2',
description: 'Item 2 description',
id: '456',
// This is the latest updated and should come first in the table
updatedAt: yesterday,
attributes: {
title: 'Item 2',
description: 'Item 2 description',
},
},
];
const findItems = jest.fn(() => Promise.resolve({ total: hits.length, hits }));
const defaultProps: TableListViewProps<Record<string, unknown>> = {
...requiredProps,
tableColumns,
findItems,
createItem: () => undefined,
};
const setup = registerTestBed(TableListView, { defaultProps });
test('should add a "Last updated" column if "updatedAt" is provided', async () => {
await act(async () => {
testBed = await setup();
});
const { component, table } = testBed!;
component.update();
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
['Item 2', 'Item 2 description', 'yesterday'], // Comes first as it is the latest updated
['Item 1', 'Item 1 description', '2 days ago'],
]);
});
test('should not display relative time for items updated more than 7 days ago', async () => {
const updatedAtValues: Moment[] = [];
const updatedHits = hits.map(({ title, description }, i) => {
const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i)));
updatedAtValues[i] = moment(updatedAt);
return {
title,
description,
updatedAt,
};
});
let testBed: TestBed;
await act(async () => {
testBed = await setup({
findItems: jest.fn(() =>
Promise.resolve({
total: updatedHits.length,
hits: updatedHits,
})
),
findItems: jest.fn().mockResolvedValue({ total: hits.length, hits }),
});
});
@ -192,21 +148,58 @@ describe('TableListView', () => {
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
// Renders the datetime with this format: "05/10/2022 @ 2:34 PM"
['Item 2', 'Item 2 description', yesterdayToString], // Comes first as it is the latest updated
['Item 1', 'Item 1 description', twoDaysAgoToString],
]);
});
test('should not display relative time for items updated more than 7 days ago', async () => {
let testBed: TestBed;
const updatedAtValues: Moment[] = [];
const updatedHits = hits.map(({ id, attributes }, i) => {
const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i)));
updatedAtValues.push(moment(updatedAt));
return {
id,
updatedAt,
attributes,
};
});
await act(async () => {
testBed = await setup({
findItems: jest.fn().mockResolvedValue({
total: updatedHits.length,
hits: updatedHits,
}),
});
});
const { component, table } = testBed!;
component.update();
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
// Renders the datetime with this format: "July 28, 2022"
['Item 1', 'Item 1 description', updatedAtValues[0].format('LL')],
['Item 2', 'Item 2 description', updatedAtValues[1].format('LL')],
]);
});
test('should not add a "Last updated" column if no "updatedAt" is provided', async () => {
let testBed: TestBed;
await act(async () => {
testBed = await setup({
findItems: jest.fn(() =>
Promise.resolve({
total: hits.length,
hits: hits.map(({ title, description }) => ({ title, description })),
})
),
findItems: jest.fn().mockResolvedValue({
total: hits.length,
// Not including the "updatedAt" metadata
hits: hits.map(({ attributes }) => ({ attributes })),
}),
});
});
@ -222,14 +215,17 @@ describe('TableListView', () => {
});
test('should not display anything if there is no updatedAt metadata for an item', async () => {
let testBed: TestBed;
await act(async () => {
testBed = await setup({
findItems: jest.fn(() =>
Promise.resolve({
total: hits.length + 1,
hits: [...hits, { title: 'Item 3', description: 'Item 3 description' }],
})
),
findItems: jest.fn().mockResolvedValue({
total: hits.length + 1,
hits: [
...hits,
{ id: '789', attributes: { title: 'Item 3', description: 'Item 3 description' } },
],
}),
});
});
@ -239,46 +235,33 @@ describe('TableListView', () => {
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
['Item 2', 'Item 2 description', 'yesterday'],
['Item 1', 'Item 1 description', '2 days ago'],
['Item 2', 'Item 2 description', yesterdayToString],
['Item 1', 'Item 1 description', twoDaysAgoToString],
['Item 3', 'Item 3 description', '-'], // Empty column as no updatedAt provided
]);
});
});
describe('pagination', () => {
let testBed: TestBed;
const tableColumns = [
{
field: 'title',
name: 'Title',
sortable: true,
},
];
const initialPageSize = 20;
const totalItems = 30;
const hits = new Array(totalItems).fill(' ').map((_, i) => ({
title: `Item ${i < 10 ? `0${i}` : i}`, // prefix with "0" for correct A-Z sorting
const hits = [...Array(totalItems)].map((_, i) => ({
attributes: {
title: `Item ${i < 10 ? `0${i}` : i}`, // prefix with "0" for correct A-Z sorting
},
}));
const findItems = jest.fn().mockResolvedValue({ total: hits.length, hits });
const defaultProps: TableListViewProps<Record<string, unknown>> = {
...requiredProps,
const props = {
initialPageSize,
tableColumns,
findItems,
createItem: () => undefined,
findItems: jest.fn().mockResolvedValue({ total: hits.length, hits }),
};
const setup = registerTestBed(TableListView, { defaultProps });
test('should limit the number of row to the `initialPageSize` provided', async () => {
let testBed: TestBed;
await act(async () => {
testBed = await setup();
testBed = await setup(props);
});
const { component, table } = testBed!;
@ -295,8 +278,10 @@ describe('TableListView', () => {
});
test('should navigate to page 2', async () => {
let testBed: TestBed;
await act(async () => {
testBed = await setup();
testBed = await setup(props);
});
const { component, table } = testBed!;

View file

@ -0,0 +1,526 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, {
useReducer,
useCallback,
useEffect,
useRef,
useMemo,
ReactNode,
MouseEvent,
} from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import {
EuiBasicTableColumn,
EuiButton,
EuiCallOut,
EuiEmptyPrompt,
Pagination,
Direction,
EuiSpacer,
EuiTableActionsColumnType,
EuiLink,
} from '@elastic/eui';
import { keyBy, uniq, get } from 'lodash';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { Table, ConfirmDeleteModal, ListingLimitWarning } from './components';
import { useServices } from './services';
import type { SavedObjectsReference, SavedObjectsFindOptionsReference } from './services';
import type { Action } from './actions';
import { getReducer } from './reducer';
export interface Props<T extends UserContentCommonSchema = UserContentCommonSchema> {
entityName: string;
entityNamePlural: string;
tableListTitle: string;
listingLimit: number;
initialFilter: string;
initialPageSize: number;
emptyPrompt?: JSX.Element;
/** Add an additional custom column */
customTableColumn?: EuiBasicTableColumn<T>;
/**
* Id of the heading element describing the table. This id will be used as `aria-labelledby` of the wrapper element.
* If the table is not empty, this component renders its own h1 element using the same id.
*/
headingId?: string;
/** An optional id for the listing. Used to generate unique data-test-subj. Default: "userContent" */
id?: string;
children?: ReactNode | undefined;
findItems(
searchQuery: string,
references?: SavedObjectsFindOptionsReference[]
): Promise<{ total: number; hits: T[] }>;
/** Handler to set the item title "href" value. If it returns undefined there won't be a link for this item. */
getDetailViewLink?: (entity: T) => string | undefined;
/** Handler to execute when clicking the item title */
onClickTitle?: (item: T) => void;
createItem?(): void;
deleteItems?(items: T[]): Promise<void>;
editItem?(item: T): void;
}
export interface State<T extends UserContentCommonSchema = UserContentCommonSchema> {
items: T[];
hasInitialFetchReturned: boolean;
isFetchingItems: boolean;
isDeletingItems: boolean;
showDeleteModal: boolean;
fetchError?: IHttpFetchError<Error>;
searchQuery: string;
selectedIds: string[];
totalItems: number;
tableColumns: Array<EuiBasicTableColumn<T>>;
pagination: Pagination;
tableSort?: {
field: keyof T;
direction: Direction;
};
}
export interface UserContentCommonSchema {
id: string;
updatedAt: string;
references: SavedObjectsReference[];
type: string;
attributes: {
title: string;
description?: string;
};
}
function TableListViewComp<T extends UserContentCommonSchema>({
tableListTitle,
entityName,
entityNamePlural,
initialFilter: initialQuery,
headingId,
initialPageSize,
listingLimit,
customTableColumn,
emptyPrompt,
findItems,
createItem,
editItem,
deleteItems,
getDetailViewLink,
onClickTitle,
id = 'userContent',
children,
}: Props<T>) {
if (!getDetailViewLink && !onClickTitle) {
throw new Error(
`[TableListView] One o["getDetailViewLink" or "onClickTitle"] prop must be provided.`
);
}
if (getDetailViewLink && onClickTitle) {
throw new Error(
`[TableListView] Either "getDetailViewLink" or "onClickTitle" can be provided. Not both.`
);
}
const isMounted = useRef(false);
const fetchIdx = useRef(0);
const {
canEditAdvancedSettings,
getListingLimitSettingsUrl,
getTagsColumnDefinition,
searchQueryParser,
notifyError,
DateFormatterComp,
navigateToUrl,
currentAppId$,
} = useServices();
const reducer = useMemo(() => {
return getReducer<T>({ DateFormatterComp });
}, [DateFormatterComp]);
const redirectAppLinksCoreStart = useMemo(
() => ({
application: {
navigateToUrl,
currentAppId$,
},
}),
[navigateToUrl, currentAppId$]
);
const [state, dispatch] = useReducer<(state: State<T>, action: Action<T>) => State<T>>(reducer, {
items: [],
totalItems: 0,
hasInitialFetchReturned: false,
isFetchingItems: false,
isDeletingItems: false,
showDeleteModal: false,
selectedIds: [],
tableColumns: [
{
field: 'attributes.title',
name: i18n.translate('contentManagement.tableList.titleColumnName', {
defaultMessage: 'Title',
}),
sortable: true,
render: (field: keyof T, record: T) => {
// The validation is handled at the top of the component
const href = getDetailViewLink ? getDetailViewLink(record) : undefined;
if (!href && !onClickTitle) {
// This item is not clickable
return <span>{record.attributes.title}</span>;
}
return (
<RedirectAppLinks coreStart={redirectAppLinksCoreStart}>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiLink
href={getDetailViewLink ? getDetailViewLink(record) : undefined}
onClick={
onClickTitle
? (e: MouseEvent) => {
e.preventDefault();
onClickTitle(record);
}
: undefined
}
data-test-subj={`${id}ListingTitleLink-${record.attributes.title
.split(' ')
.join('-')}`}
>
{record.attributes.title}
</EuiLink>
</RedirectAppLinks>
);
},
},
{
field: 'attributes.description',
name: i18n.translate('contentManagement.tableList.descriptionColumnName', {
defaultMessage: 'Description',
}),
},
],
searchQuery: initialQuery,
pagination: {
pageIndex: 0,
totalItemCount: 0,
pageSize: initialPageSize,
pageSizeOptions: uniq([10, 20, 50, initialPageSize]).sort(),
},
});
const {
searchQuery,
hasInitialFetchReturned,
isFetchingItems,
items,
fetchError,
showDeleteModal,
isDeletingItems,
selectedIds,
totalItems,
tableColumns: stateTableColumns,
pagination,
tableSort,
} = state;
const hasNoItems = !isFetchingItems && items.length === 0 && !searchQuery;
const pageDataTestSubject = `${entityName}LandingPage`;
const showFetchError = Boolean(fetchError);
const showLimitError = !showFetchError && totalItems > listingLimit;
const tableColumns = useMemo(() => {
const columns = stateTableColumns.slice();
if (customTableColumn) {
columns.push(customTableColumn);
}
const tagsColumnDef = getTagsColumnDefinition ? getTagsColumnDefinition() : undefined;
if (tagsColumnDef) {
columns.push(tagsColumnDef);
}
// Add "Actions" column
if (editItem) {
const actions: EuiTableActionsColumnType<T>['actions'] = [
{
name: (item) => {
return i18n.translate('contentManagement.tableList.listing.table.editActionName', {
defaultMessage: 'Edit {itemDescription}',
values: {
itemDescription: get(item, 'attributes.title'),
},
});
},
description: i18n.translate(
'contentManagement.tableList.listing.table.editActionDescription',
{
defaultMessage: 'Edit',
}
),
icon: 'pencil',
type: 'icon',
enabled: (v) => !(v as unknown as { error: string })?.error,
onClick: editItem,
},
];
columns.push({
name: i18n.translate('contentManagement.tableList.listing.table.actionTitle', {
defaultMessage: 'Actions',
}),
width: '100px',
actions,
});
}
return columns;
}, [stateTableColumns, customTableColumn, getTagsColumnDefinition, editItem]);
const itemsById = useMemo(() => {
return keyBy(items, 'id');
}, [items]);
const selectedItems = useMemo(() => {
return selectedIds.map((selectedId) => itemsById[selectedId]);
}, [selectedIds, itemsById]);
// ------------
// Callbacks
// ------------
const fetchItems = useCallback(async () => {
dispatch({ type: 'onFetchItems' });
try {
const idx = ++fetchIdx.current;
const { searchQuery: searchQueryParsed, references } = searchQueryParser
? searchQueryParser(searchQuery)
: { searchQuery, references: undefined };
const response = await findItems(searchQueryParsed, references);
if (!isMounted.current) {
return;
}
if (idx === fetchIdx.current) {
dispatch({
type: 'onFetchItemsSuccess',
data: {
response,
},
});
}
} catch (err) {
dispatch({
type: 'onFetchItemsError',
data: err,
});
}
}, [searchQueryParser, searchQuery, findItems]);
const deleteSelectedItems = useCallback(async () => {
if (isDeletingItems) {
return;
}
dispatch({ type: 'onDeleteItems' });
try {
await deleteItems!(selectedItems);
} catch (error) {
notifyError(
<FormattedMessage
id="contentManagement.tableList.listing.unableToDeleteDangerMessage"
defaultMessage="Unable to delete {entityName}(s)"
values={{ entityName }}
/>,
error
);
}
fetchItems();
dispatch({ type: 'onItemsDeleted' });
}, [deleteItems, entityName, fetchItems, isDeletingItems, notifyError, selectedItems]);
const renderCreateButton = useCallback(() => {
if (createItem) {
return (
<EuiButton
onClick={createItem}
data-test-subj="newItemButton"
iconType="plusInCircleFilled"
fill
>
<FormattedMessage
id="contentManagement.tableList.listing.createNewItemButtonLabel"
defaultMessage="Create {entityName}"
values={{ entityName }}
/>
</EuiButton>
);
}
}, [createItem, entityName]);
const renderNoItemsMessage = useCallback(() => {
if (emptyPrompt) {
return emptyPrompt;
} else {
return (
<EuiEmptyPrompt
title={
<h1>
{
<FormattedMessage
id="contentManagement.tableList.listing.noAvailableItemsMessage"
defaultMessage="No {entityNamePlural} available."
values={{ entityNamePlural }}
/>
}
</h1>
}
actions={renderCreateButton()}
/>
);
}
}, [emptyPrompt, entityNamePlural, renderCreateButton]);
const renderFetchError = useCallback(() => {
return (
<React.Fragment>
<EuiCallOut
title={
<FormattedMessage
id="contentManagement.tableList.listing.fetchErrorTitle"
defaultMessage="Fetching listing failed"
/>
}
color="danger"
iconType="alert"
>
<p>
<FormattedMessage
id="contentManagement.tableList.listing.fetchErrorDescription"
defaultMessage="The {entityName} listing could not be fetched: {message}."
values={{
entityName,
message: fetchError!.body?.message || fetchError!.message,
}}
/>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</React.Fragment>
);
}, [entityName, fetchError]);
// ------------
// Effects
// ------------
useDebounce(fetchItems, 300, [fetchItems]);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
// ------------
// Render
// ------------
if (!hasInitialFetchReturned) {
return null;
}
if (!fetchError && hasNoItems) {
return (
<KibanaPageTemplate panelled isEmptyState={true} data-test-subj={pageDataTestSubject}>
<KibanaPageTemplate.Section
aria-labelledby={hasInitialFetchReturned ? headingId : undefined}
>
{renderNoItemsMessage()}
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
);
}
return (
<KibanaPageTemplate panelled data-test-subj={pageDataTestSubject}>
<KibanaPageTemplate.Header
pageTitle={<span id={headingId}>{tableListTitle}</span>}
rightSideItems={[renderCreateButton() ?? <span />]}
data-test-subj="top-nav"
/>
<KibanaPageTemplate.Section aria-labelledby={hasInitialFetchReturned ? headingId : undefined}>
{/* Any children passed to the component */}
{children}
{/* Too many items error */}
{showLimitError && (
<ListingLimitWarning
canEditAdvancedSettings={canEditAdvancedSettings}
advancedSettingsLink={getListingLimitSettingsUrl()}
entityNamePlural={entityNamePlural}
totalItems={totalItems}
listingLimit={listingLimit}
/>
)}
{/* Error while fetching items */}
{showFetchError && renderFetchError()}
{/* Table of items */}
<Table<T>
dispatch={dispatch}
items={items}
isFetchingItems={isFetchingItems}
searchQuery={searchQuery}
tableColumns={tableColumns}
tableSort={tableSort}
pagination={pagination}
selectedIds={selectedIds}
entityName={entityName}
entityNamePlural={entityNamePlural}
deleteItems={deleteItems}
tableCaption={tableListTitle}
/>
{/* Delete modal */}
{showDeleteModal && (
<ConfirmDeleteModal<T>
isDeletingItems={isDeletingItems}
entityName={entityName}
entityNamePlural={entityNamePlural}
items={selectedItems}
onConfirm={deleteSelectedItems}
onCancel={() => dispatch({ type: 'onCancelDeleteItems' })}
/>
)}
</KibanaPageTemplate.Section>
</KibanaPageTemplate>
);
}
const TableListView = React.memo(TableListViewComp) as typeof TableListViewComp;
export { TableListView };
// eslint-disable-next-line import/no-default-export
export default TableListView;

View file

@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.bazel.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "target_types",
"stripInternal": false,
"types": [
"jest",
"node",
"react",
"@kbn/ambient-ui-types",
"@kbn/ambient-storybook-types"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
]
}

View file

@ -26,6 +26,7 @@ const BAZEL_PACKAGE_DIRS = [
'packages/analytics/shippers/elastic_v3',
'packages/core/*',
'packages/home',
'packages/content-management',
'x-pack/packages/ml',
];

View file

@ -55,18 +55,18 @@ const defaultConfig: TestBedConfig = {
});
```
*/
export function registerTestBed<T extends string = string>(
export function registerTestBed<T extends string = string, P extends object = any>(
Component: ComponentType<any>,
config: AsyncTestBedConfig
): AsyncSetupFunc<T>;
export function registerTestBed<T extends string = string>(
): AsyncSetupFunc<T, Partial<P>>;
export function registerTestBed<T extends string = string, P extends object = any>(
Component: ComponentType<any>,
config?: TestBedConfig
): SyncSetupFunc<T>;
export function registerTestBed<T extends string = string>(
): SyncSetupFunc<T, Partial<P>>;
export function registerTestBed<T extends string = string, P extends object = any>(
Component: ComponentType<any>,
config?: AsyncTestBedConfig | TestBedConfig
): SetupFunc<T> {
): SetupFunc<T, Partial<P>> {
const {
defaultProps = defaultConfig.defaultProps,
memoryRouter = defaultConfig.memoryRouter!,
@ -263,7 +263,7 @@ export function registerTestBed<T extends string = string>(
.slice(1) // we remove the first row as it is the table header
.map((row) => ({
reactWrapper: row,
columns: row.find('td').map((col) => ({
columns: row.find('.euiTableCellContent').map((col) => ({
reactWrapper: col,
// We can't access the td value with col.text() because
// eui adds an extra div in td on mobile => (.euiTableRowCell__mobileHeader)

View file

@ -10,9 +10,9 @@ import { Store } from 'redux';
import { ReactWrapper as GenericReactWrapper } from 'enzyme';
import { LocationDescriptor } from 'history';
export type AsyncSetupFunc<T> = (props?: any) => Promise<TestBed<T>>;
export type SyncSetupFunc<T> = (props?: any) => TestBed<T>;
export type SetupFunc<T> = (props?: any) => TestBed<T> | Promise<TestBed<T>>;
export type AsyncSetupFunc<T, P extends object = any> = (props?: P) => Promise<TestBed<T>>;
export type SyncSetupFunc<T, P extends object = any> = (props?: P) => TestBed<T>;
export type SetupFunc<T, P extends object = any> = (props?: P) => TestBed<T> | Promise<TestBed<T>>;
export type ReactWrapper = GenericReactWrapper<any>;
export interface EuiTableMetaData {

View file

@ -14,6 +14,7 @@ export const storybookAliases = {
cloud: 'x-pack/plugins/cloud/.storybook',
coloring: 'packages/kbn-coloring/.storybook',
chart_icons: 'packages/kbn-chart-icons/.storybook',
content_management: 'packages/content-management/.storybook',
controls: 'src/plugins/controls/storybook',
custom_integrations: 'src/plugins/custom_integrations/storybook',
dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook',

View file

@ -10,11 +10,15 @@ import './index.scss';
import React from 'react';
import { History } from 'history';
import { Provider } from 'react-redux';
import { I18nProvider, FormattedRelative } from '@kbn/i18n-react';
import { parse, ParsedQuery } from 'query-string';
import { render, unmountComponentAtNode } from 'react-dom';
import { Switch, Route, RouteComponentProps, HashRouter, Redirect } from 'react-router-dom';
import { I18nProvider } from '@kbn/i18n-react';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import {
TableListViewKibanaDependencies,
TableListViewKibanaProvider,
} from '@kbn/content-management-table-list';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
import { AppMountParameters, CoreSetup } from '@kbn/core/public';
@ -35,6 +39,7 @@ import {
} from '../types';
import { DashboardStart, DashboardStartDependencies } from '../plugin';
import { pluginServices } from '../services/plugin_services';
import { DashboardApplicationService } from '../services/application/types';
export const dashboardUrlParams = {
showTopMenu: 'show-top-menu',
@ -50,15 +55,24 @@ export interface DashboardMountProps {
mountContext: DashboardMountContextProps;
}
// because the type of `application.capabilities.advancedSettings` is so generic, the provider
// requiring the `save` key to be part of it is causing type issues - so, creating a custom type
type TableListViewApplicationService = DashboardApplicationService & {
capabilities: { advancedSettings: { save: boolean } };
};
export async function mountApp({ core, element, appUnMounted, mountContext }: DashboardMountProps) {
const [, , dashboardStart] = await core.getStartServices(); // TODO: Remove as part of https://github.com/elastic/kibana/pull/138774
const { DashboardMountContext } = await import('./hooks/dashboard_mount_context');
const {
application,
chrome: { setBadge, docTitle },
dashboardCapabilities: { showWriteControls },
data: dataStart,
embeddable,
notifications,
savedObjectsTagging,
settings: { uiSettings },
} = pluginServices.getServices();
@ -164,26 +178,42 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
<KibanaContextProvider services={dashboardServices}>
<DashboardMountContext.Provider value={mountContext}>
<KibanaThemeProvider theme$={core.theme.theme$}>
<HashRouter>
<Switch>
<Route
path={[
DashboardConstants.CREATE_NEW_DASHBOARD_URL,
`${DashboardConstants.VIEW_DASHBOARD_URL}/:id`,
]}
render={renderDashboard}
/>
<Route
exact
path={DashboardConstants.LANDING_PAGE_PATH}
render={renderListingPage}
/>
<Route exact path="/">
<Redirect to={DashboardConstants.LANDING_PAGE_PATH} />
</Route>
<Route render={renderNoMatch} />
</Switch>
</HashRouter>
<TableListViewKibanaProvider
{...{
core: {
application: application as TableListViewApplicationService,
notifications,
},
toMountPoint,
savedObjectsTagging: savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved
? ({
ui: savedObjectsTagging,
} as TableListViewKibanaDependencies['savedObjectsTagging'])
: undefined,
FormattedRelative,
}}
>
<HashRouter>
<Switch>
<Route
path={[
DashboardConstants.CREATE_NEW_DASHBOARD_URL,
`${DashboardConstants.VIEW_DASHBOARD_URL}/:id`,
]}
render={renderDashboard}
/>
<Route
exact
path={DashboardConstants.LANDING_PAGE_PATH}
render={renderListingPage}
/>
<Route exact path="/">
<Redirect to={DashboardConstants.LANDING_PAGE_PATH} />
</Route>
<Route render={renderNoMatch} />
</Switch>
</HashRouter>
</TableListViewKibanaProvider>
</KibanaThemeProvider>
</DashboardMountContext.Provider>
</KibanaContextProvider>

View file

@ -21,30 +21,7 @@ exports[`after fetch When given a title that matches multiple dashboards, filter
redirectTo={[MockFunction]}
title="search by title"
>
<TableListView
application={
Object {
"capabilities": Object {
"advancedSettings": undefined,
"maps": undefined,
"navLinks": Object {},
"visualize": undefined,
},
"currentAppId$": Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
},
"getUrlForApp": [MockFunction],
"navigateToApp": [MockFunction],
"navigateToUrl": [MockFunction],
}
}
<Memo(TableListViewComp)
createItem={[Function]}
deleteItems={[Function]}
editItem={[Function]}
@ -96,48 +73,23 @@ exports[`after fetch When given a title that matches multiple dashboards, filter
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter="\\"search by title\\""
rowHeader="title"
searchFilters={Array []}
tableCaption="Dashboards"
tableColumns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"field": "description",
"name": "Description",
"render": [Function],
"sortable": true,
},
]
}
tableListTitle="Dashboards"
theme={
Object {
"theme$": Observable {
"_subscribe": [Function],
},
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"dashboardUnsavedOne",
"dashboardUnsavedTwo",
]
}
}
toastNotifications={
Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
}
}
/>
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;
@ -162,30 +114,7 @@ exports[`after fetch initialFilter 1`] = `
}
redirectTo={[MockFunction]}
>
<TableListView
application={
Object {
"capabilities": Object {
"advancedSettings": undefined,
"maps": undefined,
"navLinks": Object {},
"visualize": undefined,
},
"currentAppId$": Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
},
"getUrlForApp": [MockFunction],
"navigateToApp": [MockFunction],
"navigateToUrl": [MockFunction],
}
}
<Memo(TableListViewComp)
createItem={[Function]}
deleteItems={[Function]}
editItem={[Function]}
@ -237,48 +166,23 @@ exports[`after fetch initialFilter 1`] = `
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter="testFilter"
rowHeader="title"
searchFilters={Array []}
tableCaption="Dashboards"
tableColumns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"field": "description",
"name": "Description",
"render": [Function],
"sortable": true,
},
]
}
tableListTitle="Dashboards"
theme={
Object {
"theme$": Observable {
"_subscribe": [Function],
},
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"dashboardUnsavedOne",
"dashboardUnsavedTwo",
]
}
}
toastNotifications={
Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
}
}
/>
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;
@ -302,30 +206,7 @@ exports[`after fetch renders all table rows 1`] = `
}
redirectTo={[MockFunction]}
>
<TableListView
application={
Object {
"capabilities": Object {
"advancedSettings": undefined,
"maps": undefined,
"navLinks": Object {},
"visualize": undefined,
},
"currentAppId$": Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
},
"getUrlForApp": [MockFunction],
"navigateToApp": [MockFunction],
"navigateToUrl": [MockFunction],
}
}
<Memo(TableListViewComp)
createItem={[Function]}
deleteItems={[Function]}
editItem={[Function]}
@ -377,48 +258,23 @@ exports[`after fetch renders all table rows 1`] = `
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter=""
rowHeader="title"
searchFilters={Array []}
tableCaption="Dashboards"
tableColumns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"field": "description",
"name": "Description",
"render": [Function],
"sortable": true,
},
]
}
tableListTitle="Dashboards"
theme={
Object {
"theme$": Observable {
"_subscribe": [Function],
},
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"dashboardUnsavedOne",
"dashboardUnsavedTwo",
]
}
}
toastNotifications={
Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
}
}
/>
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;
@ -442,30 +298,7 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = `
}
redirectTo={[MockFunction]}
>
<TableListView
application={
Object {
"capabilities": Object {
"advancedSettings": undefined,
"maps": undefined,
"navLinks": Object {},
"visualize": undefined,
},
"currentAppId$": Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
},
"getUrlForApp": [MockFunction],
"navigateToApp": [MockFunction],
"navigateToUrl": [MockFunction],
}
}
<Memo(TableListViewComp)
createItem={[Function]}
deleteItems={[Function]}
editItem={[Function]}
@ -517,48 +350,23 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = `
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter=""
rowHeader="title"
searchFilters={Array []}
tableCaption="Dashboards"
tableColumns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"field": "description",
"name": "Description",
"render": [Function],
"sortable": true,
},
]
}
tableListTitle="Dashboards"
theme={
Object {
"theme$": Observable {
"_subscribe": [Function],
},
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"dashboardUnsavedOne",
"dashboardUnsavedTwo",
]
}
}
toastNotifications={
Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
}
}
/>
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;
@ -582,30 +390,7 @@ exports[`after fetch renders call to action with continue when no dashboards exi
}
redirectTo={[MockFunction]}
>
<TableListView
application={
Object {
"capabilities": Object {
"advancedSettings": undefined,
"maps": undefined,
"navLinks": Object {},
"visualize": undefined,
},
"currentAppId$": Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
},
"getUrlForApp": [MockFunction],
"navigateToApp": [MockFunction],
"navigateToUrl": [MockFunction],
}
}
<Memo(TableListViewComp)
createItem={[Function]}
deleteItems={[Function]}
editItem={[Function]}
@ -668,48 +453,22 @@ exports[`after fetch renders call to action with continue when no dashboards exi
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter=""
rowHeader="title"
searchFilters={Array []}
tableCaption="Dashboards"
tableColumns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"field": "description",
"name": "Description",
"render": [Function],
"sortable": true,
},
]
}
tableListTitle="Dashboards"
theme={
Object {
"theme$": Observable {
"_subscribe": [Function],
},
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"unsavedDashboard",
]
}
}
toastNotifications={
Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
}
}
/>
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;
@ -733,30 +492,7 @@ exports[`after fetch showWriteControls 1`] = `
}
redirectTo={[MockFunction]}
>
<TableListView
application={
Object {
"capabilities": Object {
"advancedSettings": undefined,
"maps": undefined,
"navLinks": Object {},
"visualize": undefined,
},
"currentAppId$": Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
},
"getUrlForApp": [MockFunction],
"navigateToApp": [MockFunction],
"navigateToUrl": [MockFunction],
}
}
<Memo(TableListViewComp)
emptyPrompt={
<EuiEmptyPrompt
body={
@ -778,47 +514,22 @@ exports[`after fetch showWriteControls 1`] = `
entityName="dashboard"
entityNamePlural="dashboards"
findItems={[Function]}
getDetailViewLink={[Function]}
headingId="dashboardListingHeading"
id="dashboard"
initialFilter=""
rowHeader="title"
searchFilters={Array []}
tableCaption="Dashboards"
tableColumns={
Array [
Object {
"field": "title",
"name": "Title",
"render": [Function],
"sortable": true,
},
Object {
"field": "description",
"name": "Description",
"render": [Function],
"sortable": true,
},
]
}
tableListTitle="Dashboards"
theme={
Object {
"theme$": Observable {
"_subscribe": [Function],
},
>
<DashboardUnsavedListing
redirectTo={[MockFunction]}
refreshUnsavedDashboards={[Function]}
unsavedDashboardIds={
Array [
"dashboardUnsavedOne",
"dashboardUnsavedTwo",
]
}
}
toastNotifications={
Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
}
}
/>
/>
</Memo(TableListViewComp)>
</DashboardListing>
`;

View file

@ -9,10 +9,14 @@
import React from 'react';
import { mount } from 'enzyme';
import { I18nProvider } from '@kbn/i18n-react';
import { I18nProvider, FormattedRelative } from '@kbn/i18n-react';
import { SimpleSavedObject } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import {
TableListViewKibanaDependencies,
TableListViewKibanaProvider,
} from '@kbn/content-management-table-list';
import { DashboardAppServices } from '../../types';
import { DashboardListing, DashboardListingProps } from './dashboard_listing';
@ -39,13 +43,28 @@ function mountWith({
const wrappingComponent: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const DashboardServicesProvider = pluginServices.getContextProvider();
const { application, notifications, savedObjectsTagging } = pluginServices.getServices();
return (
<I18nProvider>
{/* Can't get rid of KibanaContextProvider here yet because of 'call to action when no dashboards exist' tests below */}
<KibanaContextProvider services={services}>
<DashboardServicesProvider>{children}</DashboardServicesProvider>
<TableListViewKibanaProvider
core={{
application:
application as unknown as TableListViewKibanaDependencies['core']['application'],
notifications,
}}
savedObjectsTagging={
{
ui: { ...savedObjectsTagging },
} as unknown as TableListViewKibanaDependencies['savedObjectsTagging']
}
FormattedRelative={FormattedRelative}
toMountPoint={() => () => () => undefined}
>
{children}
</TableListViewKibanaProvider>
</KibanaContextProvider>
</I18nProvider>
);

View file

@ -11,18 +11,18 @@ import {
EuiLink,
EuiButton,
EuiEmptyPrompt,
EuiBasicTableColumn,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { ApplicationStart, SavedObjectsFindOptionsReference } from '@kbn/core/public';
import type { SavedObjectsFindOptionsReference } from '@kbn/core/public';
import useMount from 'react-use/lib/useMount';
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
import type { SavedObjectReference } from '@kbn/core/types';
import { useExecutionContext, useKibana } from '@kbn/kibana-react-plugin/public';
import { syncGlobalQueryStateWithUrl } from '@kbn/data-plugin/public';
import { TableListView, useKibana } from '@kbn/kibana-react-plugin/public';
import type { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import { TableListView, type UserContentCommonSchema } from '@kbn/content-management-table-list';
import { attemptLoadDashboardByTitle } from '../lib';
import { DashboardAppServices, DashboardRedirect } from '../../types';
@ -43,6 +43,30 @@ import { DASHBOARD_PANELS_UNSAVED_ID } from '../../services/dashboard_session_st
const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
interface DashboardSavedObjectUserContent extends UserContentCommonSchema {
attributes: {
title: string;
description?: string;
timeRestore: boolean;
};
}
const toTableListViewSavedObject = (
savedObject: Record<string, unknown>
): DashboardSavedObjectUserContent => {
return {
id: savedObject.id as string,
updatedAt: savedObject.updatedAt! as string,
references: savedObject.references as SavedObjectReference[],
type: 'dashboard',
attributes: {
title: (savedObject.title as string) ?? '',
description: savedObject.description as string,
timeRestore: savedObject.timeRestore as boolean,
},
};
};
export interface DashboardListingProps {
kbnUrlStateStorage: IKbnUrlStateStorage;
redirectTo: DashboardRedirect;
@ -67,10 +91,8 @@ export const DashboardListing = ({
dashboardCapabilities: { showWriteControls },
dashboardSessionStorage,
data: { query },
notifications: { toasts },
savedObjects: { client },
savedObjectsTagging: { getSearchBarFilter, parseSearchQuery },
settings: { uiSettings, theme },
settings: { uiSettings },
} = pluginServices.getServices();
const [showNoDataPage, setShowNoDataPage] = useState<boolean>(false);
@ -122,11 +144,6 @@ export const DashboardListing = ({
const initialPageSize = uiSettings.get(SAVED_OBJECTS_PER_PAGE_SETTING);
const defaultFilter = title ? `"${title}"` : '';
const tableColumns = useMemo(
() => getTableColumns(kbnUrlStateStorage, uiSettings.get('state:storeInSessionStorage')),
[uiSettings, kbnUrlStateStorage]
);
const createItem = useCallback(() => {
if (!dashboardSessionStorage.dashboardHasUnsavedEdits()) {
redirectTo({ destination: 'dashboard' });
@ -244,23 +261,20 @@ export const DashboardListing = ({
]);
const fetchItems = useCallback(
(filter: string) => {
let searchTerm = filter;
let references: SavedObjectsFindOptionsReference[] | undefined;
if (parseSearchQuery) {
const parsed = parseSearchQuery(filter, {
useName: true,
(searchTerm: string, references?: SavedObjectsFindOptionsReference[]) => {
return savedDashboards
.find(searchTerm, {
hasReference: references,
size: listingLimit,
})
.then(({ total, hits }) => {
return {
total,
hits: hits.map(toTableListViewSavedObject),
};
});
searchTerm = parsed.searchTerm;
references = parsed.tagReferences;
}
return savedDashboards.find(searchTerm, {
size: listingLimit,
hasReference: references,
});
},
[listingLimit, savedDashboards, parseSearchQuery]
[listingLimit, savedDashboards]
);
const deleteItems = useCallback(
@ -278,42 +292,32 @@ export const DashboardListing = ({
[redirectTo]
);
const searchFilters = useMemo(() => {
const searchBarFilter = getSearchBarFilter?.({ useName: true });
return searchBarFilter ? [searchBarFilter] : [];
}, [getSearchBarFilter]);
const { getEntityName, getTableCaption, getTableListTitle, getEntityNamePlural } =
dashboardListingTable;
const { getEntityName, getTableListTitle, getEntityNamePlural } = dashboardListingTable;
return (
<>
{showNoDataPage && (
<DashboardAppNoDataPage onDataViewCreated={() => setShowNoDataPage(false)} />
)}
{!showNoDataPage && (
<TableListView
<TableListView<DashboardSavedObjectUserContent>
createItem={!showWriteControls ? undefined : createItem}
deleteItems={!showWriteControls ? undefined : deleteItems}
initialPageSize={initialPageSize}
editItem={!showWriteControls ? undefined : editItem}
initialFilter={initialFilter ?? defaultFilter}
toastNotifications={toasts}
headingId="dashboardListingHeading"
findItems={fetchItems}
rowHeader="title"
entityNamePlural={getEntityNamePlural()}
tableListTitle={getTableListTitle()}
tableCaption={getTableCaption()}
entityName={getEntityName()}
{...{
emptyPrompt,
searchFilters,
listingLimit,
tableColumns,
}}
theme={theme}
// The below type conversion is necessary until the TableListView component allows partial services
application={application as unknown as ApplicationStart}
id="dashboard"
getDetailViewLink={({ id, attributes: { timeRestore } }) =>
getDashboardListItemLink(kbnUrlStateStorage, id, timeRestore)
}
>
<DashboardUnsavedListing
redirectTo={redirectTo}
@ -327,38 +331,3 @@ export const DashboardListing = ({
</>
);
};
const getTableColumns = (kbnUrlStateStorage: IKbnUrlStateStorage, useHash: boolean) => {
const {
savedObjectsTagging: { getTableColumnDefinition },
} = pluginServices.getServices();
const tableColumnDefinition = getTableColumnDefinition?.();
return [
{
field: 'title',
name: dashboardListingTable.getTitleColumnName(),
sortable: true,
render: (field: string, record: { id: string; title: string; timeRestore: boolean }) => (
<EuiLink
href={getDashboardListItemLink(
kbnUrlStateStorage,
useHash,
record.id,
record.timeRestore
)}
data-test-subj={`dashboardListingTitleLink-${record.title.split(' ').join('-')}`}
>
{field}
</EuiLink>
),
},
{
field: 'description',
name: dashboardListingTable.getDescriptionColumnName(),
render: (field: string, record: { description: string }) => <span>{record.description}</span>,
sortable: true,
},
...(tableColumnDefinition ? [tableColumnDefinition] : []),
] as unknown as Array<EuiBasicTableColumn<Record<string, unknown>>>;
};

View file

@ -23,12 +23,12 @@ kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { time: { from: 'now-7d', to: '
describe('listing dashboard link', () => {
test('creates a link to a dashboard without the timerange query if time is saved on the dashboard', async () => {
const url = getDashboardListItemLink(kbnUrlStateStorage, false, DASHBOARD_ID, true);
const url = getDashboardListItemLink(kbnUrlStateStorage, DASHBOARD_ID, true);
expect(url).toMatchInlineSnapshot(`"http://localhost/#/?_g=()"`);
});
test('creates a link to a dashboard with the timerange query if time is not saved on the dashboard', async () => {
const url = getDashboardListItemLink(kbnUrlStateStorage, false, DASHBOARD_ID, false);
const url = getDashboardListItemLink(kbnUrlStateStorage, DASHBOARD_ID, false);
expect(url).toMatchInlineSnapshot(`"http://localhost/#/?_g=(time:(from:now-7d,to:now))"`);
});
});
@ -44,7 +44,7 @@ describe('when global time changes', () => {
});
test('propagates the correct time on the query', async () => {
const url = getDashboardListItemLink(kbnUrlStateStorage, false, DASHBOARD_ID, false);
const url = getDashboardListItemLink(kbnUrlStateStorage, DASHBOARD_ID, false);
expect(url).toMatchInlineSnapshot(
`"http://localhost/#/?_g=(time:(from:'2021-01-05T11:45:53.375Z',to:'2021-01-21T11:46:00.990Z'))"`
);
@ -59,7 +59,7 @@ describe('when global refreshInterval changes', () => {
});
test('propagates the refreshInterval on the query', async () => {
const url = getDashboardListItemLink(kbnUrlStateStorage, false, DASHBOARD_ID, false);
const url = getDashboardListItemLink(kbnUrlStateStorage, DASHBOARD_ID, false);
expect(url).toMatchInlineSnapshot(
`"http://localhost/#/?_g=(refreshInterval:(pause:!f,value:300))"`
);
@ -95,7 +95,7 @@ describe('when global filters change', () => {
});
test('propagates the filters on the query', async () => {
const url = getDashboardListItemLink(kbnUrlStateStorage, false, DASHBOARD_ID, false);
const url = getDashboardListItemLink(kbnUrlStateStorage, DASHBOARD_ID, false);
expect(url).toMatchInlineSnapshot(
`"http://localhost/#/?_g=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1)),('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))))"`
);

View file

@ -18,13 +18,14 @@ import { pluginServices } from '../../services/plugin_services';
export const getDashboardListItemLink = (
kbnUrlStateStorage: IKbnUrlStateStorage,
useHash: boolean,
id: string,
timeRestore: boolean
) => {
const {
application: { getUrlForApp },
settings: { uiSettings },
} = pluginServices.getServices();
const useHash = uiSettings.get('state:storeInSessionStorage'); // use hash
let url = getUrlForApp(DashboardConstants.DASHBOARDS_ID, {
path: `#${createDashboardEditUrl(id)}`,

View file

@ -24,6 +24,13 @@ export function makeDefaultServices(): DashboardAppServices {
id: `dashboard${i}`,
title: `dashboard${i} - ${search} - title`,
description: `dashboard${i} desc`,
references: [],
timeRestore: true,
type: '',
url: '',
updatedAt: '',
panelsJSON: '',
lastSavedTitle: '',
});
}
return Promise.resolve({

View file

@ -442,15 +442,6 @@ export const dashboardListingTable = {
defaultMessage: 'dashboards',
}),
getTableListTitle: () => getDashboardPageTitle(),
getTableCaption: () => getDashboardPageTitle(),
getTitleColumnName: () =>
i18n.translate('dashboard.listing.table.titleColumnName', {
defaultMessage: 'Title',
}),
getDescriptionColumnName: () =>
i18n.translate('dashboard.listing.table.descriptionColumnName', {
defaultMessage: 'Description',
}),
};
export const dashboardUnsavedListingStrings = {

View file

@ -41,9 +41,6 @@ export { useUiSetting, useUiSetting$ } from './ui_settings';
export { useExecutionContext } from './use_execution_context';
export type { TableListViewProps, TableListViewState } from './table_list_view';
export { TableListView } from './table_list_view';
export type { ToolbarButtonProps } from './toolbar_button';
export { POSITIONS, WEIGHTS, TOOLBAR_BUTTON_SIZES, ToolbarButton } from './toolbar_button';

View file

@ -1,163 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TableListView render custom empty prompt 1`] = `
<KibanaPageTemplate
data-test-subj="testLandingPage"
isEmptyState={true}
pageBodyProps={
Object {
"aria-labelledby": undefined,
}
}
>
<EuiEmptyPrompt />
</KibanaPageTemplate>
`;
exports[`TableListView render default empty prompt 1`] = `
<KibanaPageTemplate
data-test-subj="testLandingPage"
isEmptyState={true}
pageBodyProps={
Object {
"aria-labelledby": undefined,
}
}
>
<EuiEmptyPrompt
title={
<h1>
<FormattedMessage
defaultMessage="No {entityNamePlural} available."
id="kibana-react.tableListView.listing.noAvailableItemsMessage"
values={
Object {
"entityNamePlural": "tests",
}
}
/>
</h1>
}
/>
</KibanaPageTemplate>
`;
exports[`TableListView render default empty prompt with create action when createItem supplied 1`] = `
<KibanaPageTemplate
data-test-subj="testLandingPage"
isEmptyState={true}
pageBodyProps={
Object {
"aria-labelledby": undefined,
}
}
>
<EuiEmptyPrompt
actions={
<EuiButton
data-test-subj="newItemButton"
fill={true}
iconType="plusInCircleFilled"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Create {entityName}"
id="kibana-react.tableListView.listing.createNewItemButtonLabel"
values={
Object {
"entityName": "test",
}
}
/>
</EuiButton>
}
title={
<h1>
<FormattedMessage
defaultMessage="No {entityNamePlural} available."
id="kibana-react.tableListView.listing.noAvailableItemsMessage"
values={
Object {
"entityNamePlural": "tests",
}
}
/>
</h1>
}
/>
</KibanaPageTemplate>
`;
exports[`TableListView render list view 1`] = `
<KibanaPageTemplate
data-test-subj="testLandingPage"
pageBodyProps={
Object {
"aria-labelledby": undefined,
}
}
pageHeader={
Object {
"data-test-subj": "top-nav",
"pageTitle": <span>
test title
</span>,
"rightSideItems": Array [
undefined,
],
}
}
>
<EuiInMemoryTable
columns={Array []}
data-test-subj="itemsInMemTable"
itemId="id"
items={
Array [
Object {},
]
}
loading={false}
message={
<FormattedMessage
defaultMessage="No {entityNamePlural} matched your search."
id="kibana-react.tableListView.listing.noMatchedItemsMessage"
values={
Object {
"entityNamePlural": "tests",
}
}
/>
}
onChange={[Function]}
pagination={
Object {
"pageIndex": 0,
"pageSize": 20,
"pageSizeOptions": Array [
10,
20,
50,
],
"totalItemCount": 1,
}
}
responsive={true}
rowHeader="name"
search={
Object {
"box": Object {
"data-test-subj": "tableListSearchBox",
"incremental": true,
},
"defaultQuery": "",
"filters": Array [],
"onChange": [Function],
"toolsLeft": undefined,
}
}
tableCaption="test caption"
tableLayout="fixed"
/>
</KibanaPageTemplate>
`;

View file

@ -1,686 +0,0 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
EuiBasicTableColumn,
EuiButton,
EuiCallOut,
EuiConfirmModal,
EuiEmptyPrompt,
EuiInMemoryTable,
Pagination,
CriteriaWithPagination,
PropertySort,
Direction,
EuiLink,
EuiSpacer,
EuiTableActionsColumnType,
SearchFilterConfig,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import type { ThemeServiceStart, ToastsStart, ApplicationStart } from '@kbn/core/public';
import { debounce, keyBy, sortBy, uniq, get } from 'lodash';
import React from 'react';
import moment from 'moment';
import { KibanaPageTemplate } from '../page_template';
import { toMountPoint } from '../util';
export interface TableListViewProps<V> {
createItem?(): void;
deleteItems?(items: V[]): Promise<void>;
editItem?(item: V): void;
entityName: string;
entityNamePlural: string;
findItems(query: string): Promise<{ total: number; hits: V[] }>;
listingLimit: number;
initialFilter: string;
initialPageSize: number;
/**
* Should be an EuiEmptyPrompt (but TS doesn't support this typing)
*/
emptyPrompt?: JSX.Element;
tableColumns: Array<EuiBasicTableColumn<V>>;
tableListTitle: string;
toastNotifications: ToastsStart;
/**
* Id of the heading element describing the table. This id will be used as `aria-labelledby` of the wrapper element.
* If the table is not empty, this component renders its own h1 element using the same id.
*/
headingId?: string;
/**
* Indicates which column should be used as the identifying cell in each row.
*/
rowHeader: string;
/**
* Describes the content of the table. If not specified, the caption will be "This table contains {itemCount} rows."
*/
tableCaption: string;
searchFilters?: SearchFilterConfig[];
theme: ThemeServiceStart;
application: ApplicationStart;
}
export interface TableListViewState<V> {
items: V[];
hasInitialFetchReturned: boolean;
hasUpdatedAtMetadata: boolean | null;
isFetchingItems: boolean;
isDeletingItems: boolean;
showDeleteModal: boolean;
showLimitError: boolean;
fetchError?: IHttpFetchError<Error>;
filter: string;
selectedIds: string[];
totalItems: number;
pagination: Pagination;
tableSort?: {
field: keyof V;
direction: Direction;
};
}
// saved object client does not support sorting by title because title is only mapped as analyzed
// the legacy implementation got around this by pulling `listingLimit` items and doing client side sorting
// and not supporting server-side paging.
// This component does not try to tackle these problems (yet) and is just feature matching the legacy component
// TODO support server side sorting/paging once title and description are sortable on the server.
class TableListView<V extends {}> extends React.Component<
TableListViewProps<V>,
TableListViewState<V>
> {
private _isMounted = false;
constructor(props: TableListViewProps<V>) {
super(props);
this.state = {
items: [],
totalItems: 0,
hasInitialFetchReturned: false,
hasUpdatedAtMetadata: null,
isFetchingItems: false,
isDeletingItems: false,
showDeleteModal: false,
showLimitError: false,
filter: props.initialFilter,
selectedIds: [],
pagination: {
pageIndex: 0,
totalItemCount: 0,
pageSize: props.initialPageSize,
pageSizeOptions: uniq([10, 20, 50, props.initialPageSize]).sort(),
},
};
}
UNSAFE_componentWillMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
this.debouncedFetch.cancel();
}
componentDidMount() {
this.fetchItems();
}
componentDidUpdate(prevProps: TableListViewProps<V>, prevState: TableListViewState<V>) {
if (this.state.hasUpdatedAtMetadata === null && prevState.items !== this.state.items) {
// We check if the saved object have the "updatedAt" metadata
// to render or not that column in the table
const hasUpdatedAtMetadata = Boolean(
this.state.items.find((item: { updatedAt?: string }) => Boolean(item.updatedAt))
);
this.setState((prev) => {
return {
hasUpdatedAtMetadata,
tableSort: hasUpdatedAtMetadata
? {
field: 'updatedAt' as keyof V,
direction: 'desc' as const,
}
: prev.tableSort,
pagination: {
...prev.pagination,
totalItemCount: this.state.items.length,
},
};
});
}
}
debouncedFetch = debounce(async (filter: string) => {
try {
const response = await this.props.findItems(filter);
if (!this._isMounted) {
return;
}
// We need this check to handle the case where search results come back in a different
// order than they were sent out. Only load results for the most recent search.
// Also, in case filter is empty, items are being pre-sorted alphabetically.
if (filter === this.state.filter) {
this.setState({
hasInitialFetchReturned: true,
isFetchingItems: false,
items: !filter ? sortBy<V>(response.hits, 'title') : response.hits,
totalItems: response.total,
showLimitError: response.total > this.props.listingLimit,
});
}
} catch (fetchError) {
this.setState({
hasInitialFetchReturned: true,
isFetchingItems: false,
items: [],
totalItems: 0,
showLimitError: false,
fetchError,
});
}
}, 300);
fetchItems = () => {
this.setState(
{
isFetchingItems: true,
fetchError: undefined,
},
this.debouncedFetch.bind(null, this.state.filter)
);
};
deleteSelectedItems = async () => {
if (this.state.isDeletingItems || !this.props.deleteItems) {
return;
}
this.setState({
isDeletingItems: true,
});
try {
const itemsById = keyBy(this.state.items, 'id');
await this.props.deleteItems(this.state.selectedIds.map((id) => itemsById[id]));
} catch (error) {
this.props.toastNotifications.addDanger({
title: toMountPoint(
<FormattedMessage
id="kibana-react.tableListView.listing.unableToDeleteDangerMessage"
defaultMessage="Unable to delete {entityName}(s)"
values={{ entityName: this.props.entityName }}
/>,
{ theme$: this.props.theme.theme$ }
),
text: `${error}`,
});
}
this.fetchItems();
this.setState({
isDeletingItems: false,
selectedIds: [],
});
this.closeDeleteModal();
};
closeDeleteModal = () => {
this.setState({ showDeleteModal: false });
};
openDeleteModal = () => {
this.setState({ showDeleteModal: true });
};
setFilter({ queryText }: { queryText: string }) {
// If the user is searching, we want to clear the sort order so that
// results are ordered by Elasticsearch's relevance.
this.setState(
{
filter: queryText,
},
this.fetchItems
);
}
hasNoItems() {
if (!this.state.isFetchingItems && this.state.items.length === 0 && !this.state.filter) {
return true;
}
return false;
}
renderConfirmDeleteModal() {
let deleteButton = (
<FormattedMessage
id="kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabel"
defaultMessage="Delete"
/>
);
if (this.state.isDeletingItems) {
deleteButton = (
<FormattedMessage
id="kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabelDeleting"
defaultMessage="Deleting"
/>
);
}
return (
<EuiConfirmModal
title={
<FormattedMessage
id="kibana-react.tableListView.listing.deleteSelectedConfirmModal.title"
defaultMessage="Delete {itemCount} {entityName}?"
values={{
itemCount: this.state.selectedIds.length,
entityName:
this.state.selectedIds.length === 1
? this.props.entityName
: this.props.entityNamePlural,
}}
/>
}
buttonColor="danger"
onCancel={this.closeDeleteModal}
onConfirm={this.deleteSelectedItems}
cancelButtonText={
<FormattedMessage
id="kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
}
confirmButtonText={deleteButton}
defaultFocusedButton="cancel"
>
<p>
<FormattedMessage
id="kibana-react.tableListView.listing.deleteConfirmModalDescription"
defaultMessage="You can't recover deleted {entityNamePlural}."
values={{ entityNamePlural: this.props.entityNamePlural }}
/>
</p>
</EuiConfirmModal>
);
}
renderListingLimitWarning() {
if (this.state.showLimitError) {
const canEditAdvancedSettings = this.props.application.capabilities.advancedSettings.save;
const setting = 'savedObjects:listingLimit';
const advancedSettingsLink = this.props.application.getUrlForApp('management', {
path: `/kibana/settings?query=${setting}`,
});
return (
<React.Fragment>
<EuiCallOut
title={
<FormattedMessage
id="kibana-react.tableListView.listing.listingLimitExceededTitle"
defaultMessage="Listing limit exceeded"
/>
}
color="warning"
iconType="help"
>
<p>
{canEditAdvancedSettings ? (
<FormattedMessage
id="kibana-react.tableListView.listing.listingLimitExceededDescription"
defaultMessage="You have {totalItems} {entityNamePlural}, but your {listingLimitText} setting prevents
the table below from displaying more than {listingLimitValue}. You can change this setting under {advancedSettingsLink}."
values={{
entityNamePlural: this.props.entityNamePlural,
totalItems: this.state.totalItems,
listingLimitValue: this.props.listingLimit,
listingLimitText: <strong>listingLimit</strong>,
advancedSettingsLink: (
<EuiLink href={advancedSettingsLink}>
<FormattedMessage
id="kibana-react.tableListView.listing.listingLimitExceeded.advancedSettingsLinkText"
defaultMessage="Advanced Settings"
/>
</EuiLink>
),
}}
/>
) : (
<FormattedMessage
id="kibana-react.tableListView.listing.listingLimitExceededDescriptionNoPermissions"
defaultMessage="You have {totalItems} {entityNamePlural}, but your {listingLimitText} setting prevents
the table below from displaying more than {listingLimitValue}. Contact your system administrator to change this setting."
values={{
entityNamePlural: this.props.entityNamePlural,
totalItems: this.state.totalItems,
listingLimitValue: this.props.listingLimit,
listingLimitText: <strong>listingLimit</strong>,
}}
/>
)}
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</React.Fragment>
);
}
}
renderFetchError() {
if (this.state.fetchError) {
return (
<React.Fragment>
<EuiCallOut
title={
<FormattedMessage
id="kibana-react.tableListView.listing.fetchErrorTitle"
defaultMessage="Fetching listing failed"
/>
}
color="danger"
iconType="alert"
>
<p>
<FormattedMessage
id="kibana-react.tableListView.listing.fetchErrorDescription"
defaultMessage="The {entityName} listing could not be fetched: {message}."
values={{
entityName: this.props.entityName,
message: this.state.fetchError.body?.message || this.state.fetchError.message,
}}
/>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</React.Fragment>
);
}
}
renderNoItemsMessage() {
if (this.props.emptyPrompt) {
return this.props.emptyPrompt;
} else {
return (
<EuiEmptyPrompt
title={
<h1>
{
<FormattedMessage
id="kibana-react.tableListView.listing.noAvailableItemsMessage"
defaultMessage="No {entityNamePlural} available."
values={{ entityNamePlural: this.props.entityNamePlural }}
/>
}
</h1>
}
actions={this.renderCreateButton()}
/>
);
}
}
renderToolsLeft() {
const selection = this.state.selectedIds;
if (selection.length === 0) {
return;
}
const onClick = () => {
this.openDeleteModal();
};
return (
<EuiButton
color="danger"
iconType="trash"
onClick={onClick}
data-test-subj="deleteSelectedItems"
>
<FormattedMessage
id="kibana-react.tableListView.listing.deleteButtonMessage"
defaultMessage="Delete {itemCount} {entityName}"
values={{
itemCount: selection.length,
entityName:
selection.length === 1 ? this.props.entityName : this.props.entityNamePlural,
}}
/>
</EuiButton>
);
}
onTableChange(criteria: CriteriaWithPagination<V>) {
this.setState((prev) => {
const tableSort = criteria.sort ?? prev.tableSort;
return {
pagination: {
...prev.pagination,
pageIndex: criteria.page.index,
pageSize: criteria.page.size,
},
tableSort,
};
});
if (criteria.sort) {
this.setState({ tableSort: criteria.sort });
}
}
renderTable() {
const { searchFilters } = this.props;
const selection = this.props.deleteItems
? {
onSelectionChange: (obj: V[]) => {
this.setState({
selectedIds: obj
.map((item) => (item as Record<string, undefined | string>)?.id)
.filter((id): id is string => Boolean(id)),
});
},
}
: undefined;
const search = {
onChange: this.setFilter.bind(this),
toolsLeft: this.renderToolsLeft(),
defaultQuery: this.state.filter,
box: {
incremental: true,
'data-test-subj': 'tableListSearchBox',
},
filters: searchFilters ?? [],
};
const noItemsMessage = (
<FormattedMessage
id="kibana-react.tableListView.listing.noMatchedItemsMessage"
defaultMessage="No {entityNamePlural} matched your search."
values={{ entityNamePlural: this.props.entityNamePlural }}
/>
);
return (
<EuiInMemoryTable
itemId="id"
items={this.state.items}
columns={this.getTableColumns()}
pagination={this.state.pagination}
loading={this.state.isFetchingItems}
message={noItemsMessage}
selection={selection}
search={search}
sorting={this.state.tableSort ? { sort: this.state.tableSort as PropertySort } : undefined}
onChange={this.onTableChange.bind(this)}
data-test-subj="itemsInMemTable"
rowHeader={this.props.rowHeader}
tableCaption={this.props.tableCaption}
/>
);
}
getTableColumns() {
const columns = this.props.tableColumns.slice();
// Add "Last update" column
if (this.state.hasUpdatedAtMetadata) {
const renderUpdatedAt = (dateTime?: string) => {
if (!dateTime) {
return (
<EuiToolTip
content={i18n.translate('kibana-react.tableListView.updatedDateUnknownLabel', {
defaultMessage: 'Last updated unknown',
})}
>
<span>-</span>
</EuiToolTip>
);
}
const updatedAt = moment(dateTime);
if (updatedAt.diff(moment(), 'days') > -7) {
return (
<FormattedRelative value={new Date(dateTime).getTime()}>
{(formattedDate: string) => (
<EuiToolTip content={updatedAt.format('LL LT')}>
<span>{formattedDate}</span>
</EuiToolTip>
)}
</FormattedRelative>
);
}
return (
<EuiToolTip content={updatedAt.format('LL LT')}>
<span>{updatedAt.format('LL')}</span>
</EuiToolTip>
);
};
columns.push({
field: 'updatedAt',
name: i18n.translate('kibana-react.tableListView.lastUpdatedColumnTitle', {
defaultMessage: 'Last updated',
}),
render: (field: string, record: { updatedAt?: string }) =>
renderUpdatedAt(record.updatedAt),
sortable: true,
width: '150px',
});
}
// Add "Actions" column
if (this.props.editItem) {
const actions: EuiTableActionsColumnType<V>['actions'] = [
{
name: (item) =>
i18n.translate('kibana-react.tableListView.listing.table.editActionName', {
defaultMessage: 'Edit {itemDescription}',
values: {
itemDescription: get(item, this.props.rowHeader),
},
}),
description: i18n.translate(
'kibana-react.tableListView.listing.table.editActionDescription',
{
defaultMessage: 'Edit',
}
),
icon: 'pencil',
type: 'icon',
enabled: (v) => !(v as unknown as { error: string })?.error,
onClick: this.props.editItem,
},
];
columns.push({
name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', {
defaultMessage: 'Actions',
}),
width: '100px',
actions,
});
}
return columns;
}
renderCreateButton() {
if (this.props.createItem) {
return (
<EuiButton
onClick={this.props.createItem}
data-test-subj="newItemButton"
iconType="plusInCircleFilled"
fill
>
<FormattedMessage
id="kibana-react.tableListView.listing.createNewItemButtonLabel"
defaultMessage="Create {entityName}"
values={{ entityName: this.props.entityName }}
/>
</EuiButton>
);
}
}
render() {
const pageDTS = `${this.props.entityName}LandingPage`;
if (!this.state.hasInitialFetchReturned) {
return <></>;
}
if (!this.state.fetchError && this.hasNoItems()) {
return (
<KibanaPageTemplate
data-test-subj={pageDTS}
pageBodyProps={{
'aria-labelledby': this.state.hasInitialFetchReturned
? this.props.headingId
: undefined,
}}
isEmptyState={true}
>
{this.renderNoItemsMessage()}
</KibanaPageTemplate>
);
}
return (
<KibanaPageTemplate
data-test-subj={pageDTS}
pageHeader={{
pageTitle: <span id={this.props.headingId}>{this.props.tableListTitle}</span>,
rightSideItems: [this.renderCreateButton()],
'data-test-subj': 'top-nav',
}}
pageBodyProps={{
'aria-labelledby': this.state.hasInitialFetchReturned ? this.props.headingId : undefined,
}}
>
{this.state.showDeleteModal && this.renderConfirmDeleteModal()}
{this.props.children}
{this.renderListingLimitWarning()}
{this.renderFetchError()}
{this.renderTable()}
</KibanaPageTemplate>
);
}
}
export { TableListView };
// eslint-disable-next-line import/no-default-export
export default TableListView;

View file

@ -21,10 +21,10 @@ import type { DataView } from '@kbn/data-views-plugin/common';
* @deprecated
* @removeBy 8.8.0
*/
export interface SavedObject {
export interface SavedObject<T extends SavedObjectAttributes = SavedObjectAttributes> {
_serialize: () => { attributes: SavedObjectAttributes; references: SavedObjectReference[] };
_source: Record<string, unknown>;
applyESResp: (resp: EsResponse) => Promise<SavedObject>;
applyESResp: (resp: EsResponse) => Promise<SavedObject<T>>;
copyOnSave: boolean;
creationOpts: (opts: SavedObjectCreationOpts) => Record<string, unknown>;
defaults: any;
@ -35,7 +35,7 @@ export interface SavedObject {
getFullPath: () => string;
hydrateIndexPattern?: (id?: string) => Promise<null | DataView>;
id?: string;
init?: () => Promise<SavedObject>;
init?: () => Promise<SavedObject<T>>;
isSaving: boolean;
isTitleChanged: () => boolean;
lastSavedTitle: string;

View file

@ -18,7 +18,9 @@ import useMount from 'react-use/lib/useMount';
import { useLocation } from 'react-router-dom';
import { SavedObjectsFindOptionsReference } from '@kbn/core/public';
import { useKibana, TableListView, useExecutionContext } from '@kbn/kibana-react-plugin/public';
import { useKibana, useExecutionContext } from '@kbn/kibana-react-plugin/public';
import { TableListView } from '@kbn/content-management-table-list';
import type { UserContentCommonSchema } from '@kbn/content-management-table-list';
import { findListItems } from '../../utils/saved_visualize_utils';
import { showNewVisModal } from '../../wizard';
import { getTypes } from '../../services';
@ -27,9 +29,47 @@ import {
SAVED_OBJECTS_LIMIT_SETTING,
SAVED_OBJECTS_PER_PAGE_SETTING,
} from '../..';
import type { VisualizationListItem } from '../..';
import { VisualizeServices } from '../types';
import { VisualizeConstants } from '../../../common/constants';
import { getTableColumns, getNoItemsMessage } from '../utils';
import { getNoItemsMessage, getCustomColumn } from '../utils';
import { getVisualizeListItemLink } from '../utils/get_visualize_list_item_link';
import { VisualizationStage } from '../../vis_types/vis_type_alias_registry';
interface VisualizeUserContent extends VisualizationListItem, UserContentCommonSchema {
type: string;
attributes: {
title: string;
description?: string;
editApp: string;
editUrl: string;
error?: string;
};
}
const toTableListViewSavedObject = (savedObject: Record<string, unknown>): VisualizeUserContent => {
return {
id: savedObject.id as string,
updatedAt: savedObject.updatedAt as string,
references: savedObject.references as Array<{ id: string; type: string; name: string }>,
type: savedObject.savedObjectType as string,
editUrl: savedObject.editUrl as string,
editApp: savedObject.editApp as string,
icon: savedObject.icon as string,
stage: savedObject.stage as VisualizationStage,
savedObjectType: savedObject.savedObjectType as string,
typeTitle: savedObject.typeTitle as string,
title: (savedObject.title as string) ?? '',
error: (savedObject.error as string) ?? '',
attributes: {
title: (savedObject.title as string) ?? '',
description: savedObject.description as string,
editApp: savedObject.editApp as string,
editUrl: savedObject.editUrl as string,
error: savedObject.error as string,
},
};
};
export const VisualizeListing = () => {
const {
@ -42,12 +82,10 @@ export const VisualizeListing = () => {
toastNotifications,
stateTransferService,
savedObjects,
savedObjectsTagging,
uiSettings,
visualizeCapabilities,
dashboardCapabilities,
kbnUrlStateStorage,
theme,
},
} = useKibana<VisualizeServices>();
const { pathname } = useLocation();
@ -96,7 +134,7 @@ export const VisualizeListing = () => {
}, []);
const editItem = useCallback(
({ editUrl, editApp }) => {
({ attributes: { editUrl, editApp } }: VisualizeUserContent) => {
if (editApp) {
application.navigateToApp(editApp, { path: editUrl });
return;
@ -108,22 +146,9 @@ export const VisualizeListing = () => {
);
const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]);
const tableColumns = useMemo(
() => getTableColumns(core, kbnUrlStateStorage, savedObjectsTagging),
[core, kbnUrlStateStorage, savedObjectsTagging]
);
const fetchItems = useCallback(
(filter) => {
let searchTerm = filter;
let references: SavedObjectsFindOptionsReference[] | undefined;
if (savedObjectsTagging) {
const parsedQuery = savedObjectsTagging.ui.parseSearchQuery(filter, { useName: true });
searchTerm = parsedQuery.searchTerm;
references = parsedQuery.tagReferences;
}
(searchTerm: string, references?: SavedObjectsFindOptionsReference[]) => {
const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING);
return findListItems(
savedObjects.client,
@ -133,10 +158,12 @@ export const VisualizeListing = () => {
references
).then(({ total, hits }: { total: number; hits: Array<Record<string, unknown>> }) => ({
total,
hits: hits.filter((result: any) => isLabsEnabled || result.type?.stage !== 'experimental'),
hits: hits
.filter((result: any) => isLabsEnabled || result.type?.stage !== 'experimental')
.map(toTableListViewSavedObject),
}));
},
[listingLimit, uiSettings, savedObjectsTagging, savedObjects.client]
[listingLimit, uiSettings, savedObjects.client]
);
const deleteItems = useCallback(
@ -154,12 +181,6 @@ export const VisualizeListing = () => {
[savedObjects.client, toastNotifications]
);
const searchFilters = useMemo(() => {
return savedObjectsTagging
? [savedObjectsTagging.ui.getSearchBarFilter({ useName: true })]
: [];
}, [savedObjectsTagging]);
const calloutMessage = (
<FormattedMessage
data-test-subj="visualize-dashboard-flow-prompt"
@ -185,22 +206,19 @@ export const VisualizeListing = () => {
);
return (
<TableListView
<TableListView<VisualizeUserContent>
id="vis"
headingId="visualizeListingHeading"
// we allow users to create visualizations even if they can't save them
// for data exploration purposes
createItem={createNewVis}
tableCaption={i18n.translate('visualizations.listing.table.listTitle', {
defaultMessage: 'Visualize Library',
})}
findItems={fetchItems}
deleteItems={visualizeCapabilities.delete ? deleteItems : undefined}
editItem={visualizeCapabilities.save ? editItem : undefined}
tableColumns={tableColumns}
customTableColumn={getCustomColumn()}
listingLimit={listingLimit}
initialPageSize={initialPageSize}
initialFilter={''}
rowHeader="title"
emptyPrompt={noItemsFragment}
entityName={i18n.translate('visualizations.listing.table.entityName', {
defaultMessage: 'visualization',
@ -211,10 +229,9 @@ export const VisualizeListing = () => {
tableListTitle={i18n.translate('visualizations.listing.table.listTitle', {
defaultMessage: 'Visualize Library',
})}
toastNotifications={toastNotifications}
searchFilters={searchFilters}
theme={theme}
application={application}
getDetailViewLink={({ attributes: { editApp, editUrl, error } }) =>
getVisualizeListItemLink(core.application, kbnUrlStateStorage, editApp, editUrl, error)
}
>
{dashboardCapabilities.createNew && (
<>

View file

@ -11,7 +11,13 @@ import ReactDOM from 'react-dom';
import { Router } from 'react-router-dom';
import { AppMountParameters } from '@kbn/core/public';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import {
KibanaContextProvider,
KibanaThemeProvider,
toMountPoint,
} from '@kbn/kibana-react-plugin/public';
import { FormattedRelative } from '@kbn/i18n-react';
import { TableListViewKibanaProvider } from '@kbn/content-management-table-list';
import { VisualizeApp } from './app';
import { VisualizeServices } from './types';
import { addHelpMenuToAppChrome, addBadgeToAppChrome } from './utils';
@ -33,7 +39,16 @@ export const renderApp = (
<KibanaContextProvider services={services}>
<services.presentationUtil.ContextProvider>
<services.i18n.Context>
<VisualizeApp onAppLeave={onAppLeave} />
<TableListViewKibanaProvider
{...{
core: services.core,
toMountPoint,
savedObjectsTagging: services.savedObjectsTagging,
FormattedRelative,
}}
>
<VisualizeApp onAppLeave={onAppLeave} />
</TableListViewKibanaProvider>
</services.i18n.Context>
</services.presentationUtil.ContextProvider>
</KibanaContextProvider>

View file

@ -7,23 +7,10 @@
*/
import React from 'react';
import {
EuiBetaBadge,
EuiButton,
EuiEmptyPrompt,
EuiIcon,
EuiLink,
EuiBadge,
EuiBasicTableColumn,
} from '@elastic/eui';
import { EuiBetaBadge, EuiButton, EuiEmptyPrompt, EuiIcon, EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { IKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plugin/public';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import type { CoreStart } from '@kbn/core/public';
import { VisualizationListItem } from '../..';
import { getVisualizeListItemLink } from './get_visualize_list_item_link';
const getBadge = (item: VisualizationListItem) => {
if (item.stage === 'beta') {
@ -79,67 +66,27 @@ const renderItemTypeIcon = (item: VisualizationListItem) => {
return icon;
};
export const getTableColumns = (
core: CoreStart,
kbnUrlStateStorage: IKbnUrlStateStorage,
taggingApi?: SavedObjectsTaggingApi
) =>
[
{
field: 'title',
name: i18n.translate('visualizations.listing.table.titleColumnName', {
defaultMessage: 'Title',
}),
sortable: true,
render: (field: string, { editApp, editUrl, title, error }: VisualizationListItem) =>
// In case an error occurs i.e. the vis has wrong type, we render the vis but without the link
!error ? (
<RedirectAppLinks coreStart={core}>
<EuiLink
href={getVisualizeListItemLink(
core.application,
kbnUrlStateStorage,
editApp,
editUrl
)}
data-test-subj={`visListingTitleLink-${title.split(' ').join('-')}`}
>
{field}
</EuiLink>
</RedirectAppLinks>
) : (
field
),
},
{
field: 'typeTitle',
name: i18n.translate('visualizations.listing.table.typeColumnName', {
defaultMessage: 'Type',
}),
sortable: true,
render: (field: string, record: VisualizationListItem) =>
!record.error ? (
<span>
{renderItemTypeIcon(record)}
{record.typeTitle}
{getBadge(record)}
</span>
) : (
<EuiBadge iconType="alert" color="warning">
{record.error}
</EuiBadge>
),
},
{
field: 'description',
name: i18n.translate('visualizations.listing.table.descriptionColumnName', {
defaultMessage: 'Description',
}),
sortable: true,
render: (field: string, record: VisualizationListItem) => <span>{record.description}</span>,
},
...(taggingApi ? [taggingApi.ui.getTableColumnDefinition()] : []),
] as unknown as Array<EuiBasicTableColumn<Record<string, unknown>>>;
export const getCustomColumn = () => {
return {
field: 'typeTitle',
name: i18n.translate('visualizations.listing.table.typeColumnName', {
defaultMessage: 'Type',
}),
sortable: true,
render: (field: string, record: VisualizationListItem) =>
!record.error ? (
<span>
{renderItemTypeIcon(record)}
{record.typeTitle}
{getBadge(record)}
</span>
) : (
<EuiBadge iconType="alert" color="warning">
{record.error}
</EuiBadge>
),
};
};
export const getNoItemsMessage = (createItem: () => void) => (
<EuiEmptyPrompt

View file

@ -17,8 +17,13 @@ export const getVisualizeListItemLink = (
application: ApplicationStart,
kbnUrlStateStorage: IKbnUrlStateStorage,
editApp: string | undefined,
editUrl: string
editUrl: string,
error: string | undefined = undefined
) => {
if (error) {
return undefined;
}
// for visualizations the editApp is undefined
let url = application.getUrlForApp(editApp ?? VISUALIZE_APP_NAME, {
path: editApp ? editUrl : `#${editUrl}`,

View file

@ -7,6 +7,7 @@ cd "$KIBANA_DIR"
yarn storybook --site apm
yarn storybook --site canvas
yarn storybook --site ci_composite
yarn storybook --site content_management
yarn storybook --site custom_integrations
yarn storybook --site dashboard
yarn storybook --site dashboard_enhanced

View file

@ -25,12 +25,14 @@ import { DataPlugin, DataViewsContract } from '@kbn/data-plugin/public';
import { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import { NavigationPublicPluginStart as NavigationStart } from '@kbn/navigation-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { FormattedRelative } from '@kbn/i18n-react';
import { TableListViewKibanaProvider } from '@kbn/content-management-table-list';
import './index.scss';
import('./font_awesome');
import { SavedObjectsStart } from '@kbn/saved-objects-plugin/public';
import { SpacesApi } from '@kbn/spaces-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaThemeProvider, toMountPoint } from '@kbn/kibana-react-plugin/public';
import { GraphSavePolicy } from './types';
import { graphRouter } from './router';
import { checkLicense } from '../common/check_license';
@ -110,7 +112,19 @@ export const renderApp = ({ history, element, ...deps }: GraphDependencies) => {
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
const app = <KibanaThemeProvider theme$={theme$}>{graphRouter(deps)}</KibanaThemeProvider>;
const app = (
<KibanaThemeProvider theme$={theme$}>
<TableListViewKibanaProvider
{...{
core,
toMountPoint,
FormattedRelative,
}}
>
{graphRouter(deps)}
</TableListViewKibanaProvider>
</KibanaThemeProvider>
);
ReactDOM.render(app, element);
element.setAttribute('class', 'gphAppWrapper');

View file

@ -11,7 +11,8 @@ import { FormattedMessage, I18nProvider } from '@kbn/i18n-react';
import { EuiEmptyPrompt, EuiLink, EuiButton } from '@elastic/eui';
import { ApplicationStart } from '@kbn/core/public';
import { useHistory, useLocation } from 'react-router-dom';
import { TableListView } from '@kbn/kibana-react-plugin/public';
import { TableListView } from '@kbn/content-management-table-list';
import type { UserContentCommonSchema } from '@kbn/content-management-table-list';
import { deleteSavedWorkspace, findSavedWorkspace } from '../helpers/saved_workspace_utils';
import { getEditPath, getEditUrl, getNewPath, setBreadcrumbs } from '../services/url';
import { GraphWorkspaceSavedObject } from '../types';
@ -20,6 +21,27 @@ import { GraphServices } from '../application';
const SAVED_OBJECTS_LIMIT_SETTING = 'savedObjects:listingLimit';
const SAVED_OBJECTS_PER_PAGE_SETTING = 'savedObjects:perPage';
interface GraphUserContent extends UserContentCommonSchema {
type: string;
attributes: {
title: string;
description?: string;
};
}
const toTableListViewSavedObject = (savedObject: GraphWorkspaceSavedObject): GraphUserContent => {
return {
id: savedObject.id!,
updatedAt: savedObject.updatedAt!,
references: savedObject.references,
type: savedObject.type,
attributes: {
title: savedObject.title,
description: savedObject.description,
},
};
};
export interface ListingRouteProps {
deps: Omit<GraphServices, 'savedObjects'>;
}
@ -47,25 +69,23 @@ export function ListingRoute({
{ savedObjectsClient, basePath: coreStart.http.basePath },
search,
listingLimit
);
).then(({ total, hits }) => ({
total,
hits: hits.map(toTableListViewSavedObject),
}));
},
[coreStart.http.basePath, listingLimit, savedObjectsClient]
);
const editItem = useCallback(
(savedWorkspace: GraphWorkspaceSavedObject) => {
(savedWorkspace: { id: string }) => {
history.push(getEditPath(savedWorkspace));
},
[history]
);
const getViewUrl = useCallback(
(savedWorkspace: GraphWorkspaceSavedObject) => getEditUrl(addBasePath, savedWorkspace),
[addBasePath]
);
const deleteItems = useCallback(
async (savedWorkspaces: GraphWorkspaceSavedObject[]) => {
async (savedWorkspaces: Array<{ id: string }>) => {
await deleteSavedWorkspace(
savedObjectsClient,
savedWorkspaces.map((cur) => cur.id!)
@ -76,17 +96,13 @@ export function ListingRoute({
return (
<I18nProvider>
<TableListView
tableCaption={i18n.translate('xpack.graph.listing.graphsTitle', {
defaultMessage: 'Graphs',
})}
<TableListView<GraphUserContent>
id="graph"
headingId="graphListingHeading"
rowHeader="title"
createItem={capabilities.graph.save ? createItem : undefined}
findItems={findItems}
deleteItems={capabilities.graph.delete ? deleteItems : undefined}
editItem={capabilities.graph.save ? editItem : undefined}
tableColumns={getTableColumns(getViewUrl)}
listingLimit={listingLimit}
initialFilter={initialFilter}
initialPageSize={initialPageSize}
@ -95,7 +111,6 @@ export function ListingRoute({
createItem,
coreStart.application
)}
toastNotifications={coreStart.notifications.toasts}
entityName={i18n.translate('xpack.graph.listing.table.entityName', {
defaultMessage: 'graph',
})}
@ -105,8 +120,7 @@ export function ListingRoute({
tableListTitle={i18n.translate('xpack.graph.listing.graphsTitle', {
defaultMessage: 'Graphs',
})}
theme={coreStart.theme}
application={coreStart.application}
getDetailViewLink={({ id }) => getEditUrl(addBasePath, { id })}
/>
</I18nProvider>
);
@ -188,40 +202,3 @@ function getNoItemsMessage(
/>
);
}
// TODO this is an EUI type but EUI doesn't provide this typing yet
interface DataColumn {
field: string;
name: string;
sortable?: boolean;
render?: (value: string, item: GraphWorkspaceSavedObject) => React.ReactNode;
dataType?: 'auto' | 'string' | 'number' | 'date' | 'boolean';
}
function getTableColumns(getViewUrl: (record: GraphWorkspaceSavedObject) => string): DataColumn[] {
return [
{
field: 'title',
name: i18n.translate('xpack.graph.listing.table.titleColumnName', {
defaultMessage: 'Title',
}),
sortable: true,
render: (field, record) => (
<EuiLink
href={getViewUrl(record)}
data-test-subj={`graphListingTitleLink-${record.title.split(' ').join('-')}`}
>
{field}
</EuiLink>
),
},
{
field: 'description',
name: i18n.translate('xpack.graph.listing.table.descriptionColumnName', {
defaultMessage: 'Description',
}),
dataType: 'string',
sortable: true,
},
];
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { SavedObjectReference } from '@kbn/core/public';
import { AdvancedSettings, UrlTemplate, WorkspaceField } from './app_state';
import { WorkspaceNode, WorkspaceEdge } from './workspace_state';
@ -32,6 +33,8 @@ export interface GraphWorkspaceSavedObject {
// Only set for legacy saved objects.
legacyIndexPatternRef?: string;
_source: Record<string, unknown>;
updatedAt?: string;
references: SavedObjectReference[];
}
export interface SerializedWorkspaceState {

View file

@ -238,7 +238,7 @@ describe('Data Streams tab', () => {
const { table, actions } = testBed;
await actions.clickIndicesAt(0);
expect(table.getMetaData('indexTable').tableCellsValues).toEqual([
['', '', '', '', '', '', '', 'dataStream1'],
['', 'data-stream-index', '', '', '', '', '', '', 'dataStream1'],
]);
});
@ -374,7 +374,7 @@ describe('Data Streams tab', () => {
const { table, actions } = testBed;
await actions.clickIndicesAt(0);
expect(table.getMetaData('indexTable').tableCellsValues).toEqual([
['', '', '', '', '', '', '', '%dataStream'],
['', 'data-stream-index', '', '', '', '', '', '', '%dataStream'],
]);
});
});

View file

@ -59,6 +59,7 @@ export const getCoreI18n = () => coreStart.i18n;
export const getSearchService = () => pluginsStart.data.search;
export const getEmbeddableService = () => pluginsStart.embeddable;
export const getNavigateToApp = () => coreStart.application.navigateToApp;
export const getUrlForApp = () => coreStart.application.getUrlForApp;
export const getNavigateToUrl = () => coreStart.application.navigateToUrl;
export const getSavedObjectsTagging = () => pluginsStart.savedObjectsTagging;
export const getPresentationUtilContext = () => pluginsStart.presentationUtil.ContextProvider;
@ -66,7 +67,6 @@ export const getSecurityService = () => pluginsStart.security;
export const getSpacesApi = () => pluginsStart.spaces;
export const getTheme = () => coreStart.theme;
export const getUsageCollection = () => pluginsStart.usageCollection;
export const getApplication = () => coreStart.application;
export const isScreenshotMode = () => {
return pluginsStart.screenshotMode ? pluginsStart.screenshotMode.isScreenshotMode() : false;
};

View file

@ -6,8 +6,9 @@
*/
import { DataViewsContract } from '@kbn/data-views-plugin/common';
import { AppMountParameters } from '@kbn/core/public';
import { AppMountParameters, CoreStart } from '@kbn/core/public';
import { IContainer } from '@kbn/embeddable-plugin/public';
import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public';
import { LayerDescriptor } from '../../common/descriptor_types';
import type {
MapEmbeddableConfig,
@ -29,7 +30,14 @@ export interface LazyLoadedMapModules {
) => MapEmbeddableType;
getIndexPatternService: () => DataViewsContract;
getMapsCapabilities: () => any;
renderApp: (params: AppMountParameters, AppUsageTracker: React.FC) => Promise<() => void>;
renderApp: (
params: AppMountParameters,
deps: {
coreStart: CoreStart;
AppUsageTracker: React.FC;
savedObjectsTagging?: SavedObjectTaggingPluginStart;
}
) => Promise<() => void>;
createSecurityLayerDescriptors: (
indexPatternId: string,
indexPatternTitle: string

View file

@ -190,10 +190,11 @@ export class MapsPlugin
euiIconType: APP_ICON_SOLUTION,
category: DEFAULT_APP_CATEGORIES.kibana,
async mount(params: AppMountParameters) {
const [coreStart, { savedObjectsTagging }] = await core.getStartServices();
const UsageTracker =
plugins.usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment;
const { renderApp } = await lazyLoadMapModules();
return renderApp(params, UsageTracker);
return renderApp(params, { coreStart, AppUsageTracker: UsageTracker, savedObjectsTagging });
},
});

View file

@ -9,14 +9,17 @@ import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Router, Switch, Route, Redirect, RouteComponentProps } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import type { AppMountParameters } from '@kbn/core/public';
import type { CoreStart, AppMountParameters } from '@kbn/core/public';
import { ExitFullScreenButtonKibanaProvider } from '@kbn/shared-ux-button-exit-full-screen';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaThemeProvider, toMountPoint } from '@kbn/kibana-react-plugin/public';
import {
createKbnUrlStateStorage,
withNotifyOnErrors,
IKbnUrlStateStorage,
} from '@kbn/kibana-utils-plugin/public';
import { FormattedRelative } from '@kbn/i18n-react';
import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public';
import { TableListViewKibanaProvider } from '@kbn/content-management-table-list';
import {
getCoreChrome,
getCoreI18n,
@ -67,7 +70,15 @@ function setAppChrome() {
export async function renderApp(
{ element, history, onAppLeave, setHeaderActionMenu, theme$ }: AppMountParameters,
AppUsageTracker: React.FC
{
coreStart,
AppUsageTracker,
savedObjectsTagging,
}: {
coreStart: CoreStart;
savedObjectsTagging?: SavedObjectTaggingPluginStart;
AppUsageTracker: React.FC;
}
) {
goToSpecifiedPath = (path) => history.push(path);
kbnUrlStateStorage = createKbnUrlStateStorage({
@ -117,27 +128,36 @@ export async function renderApp(
<AppUsageTracker>
<I18nContext>
<KibanaThemeProvider theme$={theme$}>
<Router history={history}>
<Switch>
<Route path={`/map/:savedMapId`} render={renderMapApp} />
<Route exact path={`/map`} render={renderMapApp} />
// Redirect other routes to list, or if hash-containing, their non-hash equivalents
<Route
path={``}
render={({ location: { pathname, hash } }) => {
if (hash) {
// Remove leading hash
const newPath = hash.substr(1);
return <Redirect to={newPath} />;
} else if (pathname === '/' || pathname === '') {
return <ListPage stateTransfer={stateTransfer} />;
} else {
return <Redirect to="/" />;
}
}}
/>
</Switch>
</Router>
<TableListViewKibanaProvider
{...{
core: coreStart,
toMountPoint,
savedObjectsTagging,
FormattedRelative,
}}
>
<Router history={history}>
<Switch>
<Route path={`/map/:savedMapId`} render={renderMapApp} />
<Route exact path={`/map`} render={renderMapApp} />
// Redirect other routes to list, or if hash-containing, their non-hash equivalents
<Route
path={``}
render={({ location: { pathname, hash } }) => {
if (hash) {
// Remove leading hash
const newPath = hash.substr(1);
return <Redirect to={newPath} />;
} else if (pathname === '/' || pathname === '') {
return <ListPage stateTransfer={stateTransfer} />;
} else {
return <Redirect to="/" />;
}
}}
/>
</Switch>
</Router>
</TableListViewKibanaProvider>
</KibanaThemeProvider>
</I18nContext>
</AppUsageTracker>,

View file

@ -5,26 +5,23 @@
* 2.0.
*/
import React, { MouseEvent } from 'react';
import React from 'react';
import { SavedObjectReference } from '@kbn/core/types';
import type { SavedObjectsFindOptionsReference } from '@kbn/core/public';
import { METRIC_TYPE } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import { EuiLink } from '@elastic/eui';
import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/basic_table';
import { TableListView } from '@kbn/kibana-react-plugin/public';
import { TableListView } from '@kbn/content-management-table-list';
import type { UserContentCommonSchema } from '@kbn/content-management-table-list';
import { SimpleSavedObject } from '@kbn/core-saved-objects-api-browser';
import { goToSpecifiedPath } from '../../render_app';
import { APP_ID, getEditPath, MAP_PATH, MAP_SAVED_OBJECT_TYPE } from '../../../common/constants';
import {
getMapsCapabilities,
getToasts,
getCoreChrome,
getExecutionContext,
getNavigateToApp,
getSavedObjectsClient,
getSavedObjectsTagging,
getUiSettings,
getTheme,
getApplication,
getUsageCollection,
} from '../../kibana_services';
import { getAppTitle } from '../../../common/i18n_getters';
@ -40,41 +37,11 @@ interface MapItem {
references?: SavedObjectReference[];
}
const savedObjectsTagging = getSavedObjectsTagging();
const searchFilters = savedObjectsTagging
? [savedObjectsTagging.ui.getSearchBarFilter({ useName: true })]
: [];
const tableColumns: Array<EuiBasicTableColumn<any>> = [
{
field: 'title',
name: i18n.translate('xpack.maps.mapListing.titleFieldTitle', {
defaultMessage: 'Title',
}),
sortable: true,
render: (field: string, record: MapItem) => (
<EuiLink
onClick={(e: MouseEvent) => {
e.preventDefault();
goToSpecifiedPath(getEditPath(record.id));
}}
data-test-subj={`mapListingTitleLink-${record.title.split(' ').join('-')}`}
>
{field}
</EuiLink>
),
},
{
field: 'description',
name: i18n.translate('xpack.maps.mapListing.descriptionFieldTitle', {
defaultMessage: 'Description',
}),
dataType: 'string',
sortable: true,
},
];
if (savedObjectsTagging) {
tableColumns.push(savedObjectsTagging.ui.getTableColumnDefinition());
interface MapUserContent extends UserContentCommonSchema {
type: string;
attributes: {
title: string;
};
}
function navigateToNewMap() {
@ -85,18 +52,20 @@ function navigateToNewMap() {
});
}
async function findMaps(searchQuery: string) {
let searchTerm = searchQuery;
let tagReferences;
if (savedObjectsTagging) {
const parsed = savedObjectsTagging.ui.parseSearchQuery(searchQuery, {
useName: true,
});
searchTerm = parsed.searchTerm;
tagReferences = parsed.tagReferences;
}
const toTableListViewSavedObject = (
savedObject: SimpleSavedObject<MapSavedObjectAttributes>
): MapUserContent => {
return {
...savedObject,
updatedAt: savedObject.updatedAt!,
attributes: {
...savedObject.attributes,
title: savedObject.attributes.title ?? '',
},
};
};
async function findMaps(searchTerm: string, tagReferences?: SavedObjectsFindOptionsReference[]) {
const resp = await getSavedObjectsClient().find<MapSavedObjectAttributes>({
type: MAP_SAVED_OBJECT_TYPE,
search: searchTerm ? `${searchTerm}*` : undefined,
@ -110,15 +79,7 @@ async function findMaps(searchQuery: string) {
return {
total: resp.total,
hits: resp.savedObjects.map((savedObject) => {
return {
id: savedObject.id,
title: savedObject.attributes.title,
description: savedObject.attributes.description,
references: savedObject.references,
updatedAt: savedObject.updatedAt,
};
}),
hits: resp.savedObjects.map(toTableListViewSavedObject),
};
}
@ -144,13 +105,12 @@ export function MapsListView() {
getCoreChrome().setBreadcrumbs([{ text: getAppTitle() }]);
return (
<TableListView
<TableListView<MapUserContent>
id="map"
headingId="mapsListingPage"
rowHeader="title"
createItem={isReadOnly ? undefined : navigateToNewMap}
findItems={findMaps}
deleteItems={isReadOnly ? undefined : deleteMaps}
tableColumns={tableColumns}
listingLimit={listingLimit}
initialFilter={''}
initialPageSize={initialPageSize}
@ -160,14 +120,8 @@ export function MapsListView() {
entityNamePlural={i18n.translate('xpack.maps.mapListing.entityNamePlural', {
defaultMessage: 'maps',
})}
tableCaption={i18n.translate('xpack.maps.mapListing.tableCaption', {
defaultMessage: 'Maps',
})}
tableListTitle={getAppTitle()}
toastNotifications={getToasts()}
searchFilters={searchFilters}
theme={getTheme()}
application={getApplication()}
onClickTitle={({ id }) => goToSpecifiedPath(getEditPath(id))}
/>
);
}

View file

@ -972,10 +972,8 @@
"dashboard.listing.createNewDashboard.title": "Créer votre premier tableau de bord",
"dashboard.listing.readonlyNoItemsBody": "Aucun tableau de bord n'est disponible. Pour modifier vos autorisations afin dafficher les tableaux de bord dans cet espace, contactez votre administrateur.",
"dashboard.listing.readonlyNoItemsTitle": "Aucun tableau de bord à afficher",
"dashboard.listing.table.descriptionColumnName": "Description",
"dashboard.listing.table.entityName": "tableau de bord",
"dashboard.listing.table.entityNamePlural": "tableaux de bord",
"dashboard.listing.table.titleColumnName": "Titre",
"dashboard.listing.unsaved.discardTitle": "Abandonner les modifications",
"dashboard.listing.unsaved.editTitle": "Poursuivre les modifications",
"dashboard.listing.unsaved.loading": "Chargement",
@ -4308,15 +4306,6 @@
"kibana-react.noDataPage.intro": "Ajoutez vos données pour commencer, ou {link} sur {solution}.",
"kibana-react.noDataPage.welcomeTitle": "Bienvenue dans Elastic {solution}.",
"kibana-react.solutionNav.mobileTitleText": "Menu {solutionName}",
"kibana-react.tableListView.listing.createNewItemButtonLabel": "Créer {entityName}",
"kibana-react.tableListView.listing.deleteButtonMessage": "Supprimer {itemCount} {entityName}",
"kibana-react.tableListView.listing.deleteConfirmModalDescription": "Vous ne pourrez pas récupérer les {entityNamePlural} supprimés.",
"kibana-react.tableListView.listing.deleteSelectedConfirmModal.title": "Supprimer {itemCount} {entityName} ?",
"kibana-react.tableListView.listing.fetchErrorDescription": "Le listing {entityName} n'a pas pu être récupéré : {message}.",
"kibana-react.tableListView.listing.listingLimitExceededDescription": "Vous avez {totalItems} {entityNamePlural}, mais votre paramètre {listingLimitText} empêche le tableau ci-dessous d'en afficher plus de {listingLimitValue}. Vous pouvez modifier ce paramètre sous {advancedSettingsLink}.",
"kibana-react.tableListView.listing.listingLimitExceededDescriptionNoPermissions": "Vous avez {totalItems} {entityNamePlural}, mais votre paramètre {listingLimitText} empêche le tableau ci-dessous d'en afficher plus de {listingLimitValue}. Contactez l'administrateur système pour modifier ce paramètre.",
"kibana-react.tableListView.listing.table.editActionName": "Modifier {itemDescription}",
"kibana-react.tableListView.listing.unableToDeleteDangerMessage": "Impossible de supprimer la/le/les {entityName}(s)",
"kibana-react.dualRangeControl.maxInputAriaLabel": "Maximum de la plage",
"kibana-react.dualRangeControl.minInputAriaLabel": "Minimum de la plage",
"kibana-react.dualRangeControl.mustSetBothErrorMessage": "Les valeurs inférieure et supérieure doivent être définies.",
@ -4344,16 +4333,6 @@
"kibana-react.pageFooter.makeDefaultRouteLink": "Choisir comme page de destination",
"kibana-react.solutionNav.collapsibleLabel": "Réduire la navigation latérale",
"kibana-react.solutionNav.openLabel": "Ouvrir la navigation latérale",
"kibana-react.tableListView.lastUpdatedColumnTitle": "Dernière mise à jour",
"kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.cancelButtonLabel": "Annuler",
"kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabel": "Supprimer",
"kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabelDeleting": "Suppression",
"kibana-react.tableListView.listing.fetchErrorTitle": "Échec de la récupération du listing",
"kibana-react.tableListView.listing.listingLimitExceeded.advancedSettingsLinkText": "Paramètres avancés",
"kibana-react.tableListView.listing.listingLimitExceededTitle": "Limite de listing dépassée",
"kibana-react.tableListView.listing.table.actionTitle": "Actions",
"kibana-react.tableListView.listing.table.editActionDescription": "Modifier",
"kibana-react.tableListView.updatedDateUnknownLabel": "Dernière mise à jour inconnue",
"management.landing.header": "Bienvenue dans Gestion de la Suite {version}",
"management.breadcrumb": "Gestion de la Suite",
"management.landing.subhead": "Gérez vos index, vues de données, objets enregistrés, paramètres Kibana et plus encore.",
@ -6235,11 +6214,9 @@
"visualizations.listing.createNew.title": "Créer votre première visualisation",
"visualizations.listing.experimentalTitle": "Version d'évaluation technique",
"visualizations.listing.experimentalTooltip": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera au maximum de corriger tout problème, mais les fonctionnalités en version d'évaluation technique ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale.",
"visualizations.listing.table.descriptionColumnName": "Description",
"visualizations.listing.table.entityName": "visualisation",
"visualizations.listing.table.entityNamePlural": "visualisations",
"visualizations.listing.table.listTitle": "Bibliothèque Visualize",
"visualizations.listing.table.titleColumnName": "Titre",
"visualizations.listing.table.typeColumnName": "Type",
"visualizations.listingPageTitle": "Bibliothèque Visualize",
"visualizations.missedDataView.dataViewReconfigure": "Recréez-la dans la page de gestion des vues de données.",
@ -13625,10 +13602,8 @@
"xpack.graph.listing.graphsTitle": "Graphes",
"xpack.graph.listing.noDataSource.sampleDataInstallLinkText": "exemple de données",
"xpack.graph.listing.noItemsMessage": "Il semble que vous n'avez pas de graphe.",
"xpack.graph.listing.table.descriptionColumnName": "Description",
"xpack.graph.listing.table.entityName": "graphe",
"xpack.graph.listing.table.entityNamePlural": "graphes",
"xpack.graph.listing.table.titleColumnName": "Titre",
"xpack.graph.missingWorkspaceErrorMessage": "Impossible de charger le graphe avec l'ID",
"xpack.graph.newGraphTitle": "Graphe non enregistré",
"xpack.graph.noDataSourceNotificationMessageText.managementDataViewLinkText": "Gestion > Vues de données",
@ -18457,12 +18432,9 @@
"xpack.maps.map.initializeErrorTitle": "Initialisation de la carte impossible",
"xpack.maps.mapActions.addFeatureError": "Impossible dajouter la fonctionnalité à lindex.",
"xpack.maps.mapActions.removeFeatureError": "Impossible de retirer la fonctionnalité de lindex.",
"xpack.maps.mapListing.descriptionFieldTitle": "Description",
"xpack.maps.mapListing.entityName": "carte",
"xpack.maps.mapListing.entityNamePlural": "cartes",
"xpack.maps.mapListing.errorAttemptingToLoadSavedMaps": "Impossible de charger les cartes",
"xpack.maps.mapListing.tableCaption": "Cartes",
"xpack.maps.mapListing.titleFieldTitle": "Titre",
"xpack.maps.mapSavedObjectLabel": "Carte",
"xpack.maps.mapSettingsPanel.addCustomIcon": "Ajouter une icône personnalisée",
"xpack.maps.mapSettingsPanel.autoFitToBoundsLocationLabel": "Ajuster automatiquement la carte aux limites de données",

View file

@ -970,10 +970,8 @@
"dashboard.listing.createNewDashboard.title": "初めてのダッシュボードを作成してみましょう。",
"dashboard.listing.readonlyNoItemsBody": "使用可能なダッシュボードはありません。権限を変更してこのスペースにダッシュボードを表示するには、管理者に問い合わせてください。",
"dashboard.listing.readonlyNoItemsTitle": "表示するダッシュボードがありません",
"dashboard.listing.table.descriptionColumnName": "説明",
"dashboard.listing.table.entityName": "ダッシュボード",
"dashboard.listing.table.entityNamePlural": "ダッシュボード",
"dashboard.listing.table.titleColumnName": "タイトル",
"dashboard.listing.unsaved.discardTitle": "変更を破棄",
"dashboard.listing.unsaved.editTitle": "編集を続行",
"dashboard.listing.unsaved.loading": "読み込み中",
@ -4304,17 +4302,6 @@
"kibana-react.noDataPage.intro": "データを追加して開始するか、{solution}については{link}をご覧ください。",
"kibana-react.noDataPage.welcomeTitle": "Elastic {solution}へようこそ。",
"kibana-react.solutionNav.mobileTitleText": "{solutionName}メニュー",
"kibana-react.tableListView.listing.createNewItemButtonLabel": "Create {entityName}",
"kibana-react.tableListView.listing.deleteButtonMessage": "{itemCount} 件の {entityName} を削除",
"kibana-react.tableListView.listing.deleteConfirmModalDescription": "削除された {entityNamePlural} は復元できません。",
"kibana-react.tableListView.listing.deleteSelectedConfirmModal.title": "{itemCount} 件の {entityName} を削除",
"kibana-react.tableListView.listing.fetchErrorDescription": "{entityName}リストを取得できませんでした。{message}",
"kibana-react.tableListView.listing.listingLimitExceededDescription": "{totalItems} 件の {entityNamePlural} がありますが、{listingLimitText} の設定により {listingLimitValue} 件までしか下の表に表示できません。{advancedSettingsLink} の下でこの設定を変更できます。",
"kibana-react.tableListView.listing.listingLimitExceededDescriptionNoPermissions": "{totalItems} 件の {entityNamePlural} がありますが、{listingLimitText} の設定により {listingLimitValue} 件までしか下の表に表示できません。この設定を変更するには、システム管理者に問い合わせてください。",
"kibana-react.tableListView.listing.noAvailableItemsMessage": "利用可能な {entityNamePlural} がありません。",
"kibana-react.tableListView.listing.noMatchedItemsMessage": "検索条件に一致する {entityNamePlural} がありません。",
"kibana-react.tableListView.listing.table.editActionName": "{itemDescription}の編集",
"kibana-react.tableListView.listing.unableToDeleteDangerMessage": "{entityName} を削除できません",
"kibana-react.dualRangeControl.maxInputAriaLabel": "範囲最大",
"kibana-react.dualRangeControl.minInputAriaLabel": "範囲最小",
"kibana-react.dualRangeControl.mustSetBothErrorMessage": "下と上の値の両方を設定する必要があります",
@ -4342,16 +4329,6 @@
"kibana-react.pageFooter.makeDefaultRouteLink": "これをランディングページにする",
"kibana-react.solutionNav.collapsibleLabel": "サイドナビゲーションを折りたたむ",
"kibana-react.solutionNav.openLabel": "サイドナビゲーションを開く",
"kibana-react.tableListView.lastUpdatedColumnTitle": "最終更新",
"kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.cancelButtonLabel": "キャンセル",
"kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabel": "削除",
"kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabelDeleting": "削除中",
"kibana-react.tableListView.listing.fetchErrorTitle": "リストを取得できませんでした",
"kibana-react.tableListView.listing.listingLimitExceeded.advancedSettingsLinkText": "高度な設定",
"kibana-react.tableListView.listing.listingLimitExceededTitle": "リスティング制限超過",
"kibana-react.tableListView.listing.table.actionTitle": "アクション",
"kibana-react.tableListView.listing.table.editActionDescription": "編集",
"kibana-react.tableListView.updatedDateUnknownLabel": "最終更新日が不明です",
"management.landing.header": "Stack Management {version}へようこそ",
"management.breadcrumb": "スタック管理",
"management.landing.subhead": "インデックス、データビュー、保存されたオブジェクト、Kibanaの設定、その他を管理します。",
@ -6231,11 +6208,9 @@
"visualizations.listing.createNew.title": "最初のビジュアライゼーションの作成",
"visualizations.listing.experimentalTitle": "テクニカルプレビュー",
"visualizations.listing.experimentalTooltip": "この機能はテクニカルプレビュー中であり、将来のリリースでは変更されたり完全に削除されたりする場合があります。Elasticは最善の努力を講じてすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。",
"visualizations.listing.table.descriptionColumnName": "説明",
"visualizations.listing.table.entityName": "ビジュアライゼーション",
"visualizations.listing.table.entityNamePlural": "ビジュアライゼーション",
"visualizations.listing.table.listTitle": "Visualizeライブラリ",
"visualizations.listing.table.titleColumnName": "タイトル",
"visualizations.listing.table.typeColumnName": "型",
"visualizations.listingPageTitle": "Visualizeライブラリ",
"visualizations.missedDataView.dataViewReconfigure": "データビュー管理ページで再作成",
@ -13613,10 +13588,8 @@
"xpack.graph.listing.graphsTitle": "グラフ",
"xpack.graph.listing.noDataSource.sampleDataInstallLinkText": "サンプルデータ",
"xpack.graph.listing.noItemsMessage": "グラフがないようです。",
"xpack.graph.listing.table.descriptionColumnName": "説明",
"xpack.graph.listing.table.entityName": "グラフ",
"xpack.graph.listing.table.entityNamePlural": "グラフ",
"xpack.graph.listing.table.titleColumnName": "タイトル",
"xpack.graph.missingWorkspaceErrorMessage": "ID でグラフを読み込めませんでした",
"xpack.graph.newGraphTitle": "保存されていないグラフ",
"xpack.graph.noDataSourceNotificationMessageText.managementDataViewLinkText": "管理 > データビュー",
@ -18442,12 +18415,9 @@
"xpack.maps.map.initializeErrorTitle": "マップを初期化できません",
"xpack.maps.mapActions.addFeatureError": "機能をインデックスに追加できません。",
"xpack.maps.mapActions.removeFeatureError": "インデックスから機能を削除できません。",
"xpack.maps.mapListing.descriptionFieldTitle": "説明",
"xpack.maps.mapListing.entityName": "マップ",
"xpack.maps.mapListing.entityNamePlural": "マップ",
"xpack.maps.mapListing.errorAttemptingToLoadSavedMaps": "マップを読み込めません",
"xpack.maps.mapListing.tableCaption": "マップ",
"xpack.maps.mapListing.titleFieldTitle": "タイトル",
"xpack.maps.mapSavedObjectLabel": "マップ",
"xpack.maps.mapSettingsPanel.addCustomIcon": "カスタムアイコンを追加",
"xpack.maps.mapSettingsPanel.autoFitToBoundsLocationLabel": "自動的にマップをデータ境界に合わせる",

View file

@ -972,10 +972,8 @@
"dashboard.listing.createNewDashboard.title": "创建您的首个仪表板",
"dashboard.listing.readonlyNoItemsBody": "没有可用的仪表板。要更改您的权限以查看此工作区中的仪表板,请联系管理员。",
"dashboard.listing.readonlyNoItemsTitle": "没有可查看的仪表板",
"dashboard.listing.table.descriptionColumnName": "描述",
"dashboard.listing.table.entityName": "仪表板",
"dashboard.listing.table.entityNamePlural": "仪表板",
"dashboard.listing.table.titleColumnName": "标题",
"dashboard.listing.unsaved.discardTitle": "放弃更改",
"dashboard.listing.unsaved.editTitle": "继续编辑",
"dashboard.listing.unsaved.loading": "正在加载",
@ -4309,17 +4307,6 @@
"kibana-react.noDataPage.intro": "添加您的数据以开始,或{link}{solution}。",
"kibana-react.noDataPage.welcomeTitle": "欢迎使用 Elastic {solution}",
"kibana-react.solutionNav.mobileTitleText": "{solutionName} 菜单",
"kibana-react.tableListView.listing.createNewItemButtonLabel": "创建 {entityName}",
"kibana-react.tableListView.listing.deleteButtonMessage": "删除 {itemCount} 个 {entityName}",
"kibana-react.tableListView.listing.deleteConfirmModalDescription": "无法恢复删除的 {entityNamePlural}。",
"kibana-react.tableListView.listing.deleteSelectedConfirmModal.title": "删除 {itemCount} 个 {entityName}",
"kibana-react.tableListView.listing.fetchErrorDescription": "无法提取 {entityName} 列表:{message}。",
"kibana-react.tableListView.listing.listingLimitExceededDescription": "您有 {totalItems} 个{entityNamePlural},但您的“{listingLimitText}”设置阻止下表显示 {listingLimitValue} 个以上。您可以在“{advancedSettingsLink}”下更改此设置。",
"kibana-react.tableListView.listing.listingLimitExceededDescriptionNoPermissions": "您有 {totalItems} 个{entityNamePlural},但您的“{listingLimitText}”设置阻止下表显示 {listingLimitValue} 个以上。请联系系统管理员更改此设置。",
"kibana-react.tableListView.listing.noAvailableItemsMessage": "没有可用的{entityNamePlural}。",
"kibana-react.tableListView.listing.noMatchedItemsMessage": "没有任何{entityNamePlural}匹配您的搜索。",
"kibana-react.tableListView.listing.table.editActionName": "编辑 {itemDescription}",
"kibana-react.tableListView.listing.unableToDeleteDangerMessage": "无法删除{entityName}",
"kibana-react.dualRangeControl.maxInputAriaLabel": "范围最大值",
"kibana-react.dualRangeControl.minInputAriaLabel": "范围最小值",
"kibana-react.dualRangeControl.mustSetBothErrorMessage": "下限值和上限值都须设置",
@ -4347,16 +4334,6 @@
"kibana-react.pageFooter.makeDefaultRouteLink": "将此设为我的登陆页面",
"kibana-react.solutionNav.collapsibleLabel": "折叠侧边导航",
"kibana-react.solutionNav.openLabel": "打开侧边导航",
"kibana-react.tableListView.lastUpdatedColumnTitle": "上次更新时间",
"kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.cancelButtonLabel": "取消",
"kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabel": "删除",
"kibana-react.tableListView.listing.deleteSelectedItemsConfirmModal.confirmButtonLabelDeleting": "正在删除",
"kibana-react.tableListView.listing.fetchErrorTitle": "提取列表失败",
"kibana-react.tableListView.listing.listingLimitExceeded.advancedSettingsLinkText": "高级设置",
"kibana-react.tableListView.listing.listingLimitExceededTitle": "已超过列表限制",
"kibana-react.tableListView.listing.table.actionTitle": "操作",
"kibana-react.tableListView.listing.table.editActionDescription": "编辑",
"kibana-react.tableListView.updatedDateUnknownLabel": "上次更新时间未知",
"management.landing.header": "欢迎使用 Stack Management {version}",
"management.breadcrumb": "Stack Management",
"management.landing.subhead": "管理您的索引、数据视图、已保存对象、Kibana 设置等等。",
@ -6238,11 +6215,9 @@
"visualizations.listing.createNew.title": "创建您的首个可视化",
"visualizations.listing.experimentalTitle": "技术预览",
"visualizations.listing.experimentalTooltip": "此功能处于技术预览状态在未来版本中可能会更改或完全移除。Elastic 将尽最大努力来修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。",
"visualizations.listing.table.descriptionColumnName": "描述",
"visualizations.listing.table.entityName": "可视化",
"visualizations.listing.table.entityNamePlural": "可视化",
"visualizations.listing.table.listTitle": "Visualize 库",
"visualizations.listing.table.titleColumnName": "标题",
"visualizations.listing.table.typeColumnName": "类型",
"visualizations.listingPageTitle": "Visualize 库",
"visualizations.missedDataView.dataViewReconfigure": "在数据视图管理页面中重新创建",
@ -13630,10 +13605,8 @@
"xpack.graph.listing.graphsTitle": "图表",
"xpack.graph.listing.noDataSource.sampleDataInstallLinkText": "样例数据",
"xpack.graph.listing.noItemsMessage": "似乎您没有任何图表。",
"xpack.graph.listing.table.descriptionColumnName": "描述",
"xpack.graph.listing.table.entityName": "图表",
"xpack.graph.listing.table.entityNamePlural": "图表",
"xpack.graph.listing.table.titleColumnName": "标题",
"xpack.graph.missingWorkspaceErrorMessage": "无法使用 ID 加载图表",
"xpack.graph.newGraphTitle": "未保存图表",
"xpack.graph.noDataSourceNotificationMessageText.managementDataViewLinkText": "“管理”>“数据视图”",
@ -18464,12 +18437,9 @@
"xpack.maps.map.initializeErrorTitle": "无法初始化地图",
"xpack.maps.mapActions.addFeatureError": "无法添加特征到索引。",
"xpack.maps.mapActions.removeFeatureError": "无法从索引中移除特征。",
"xpack.maps.mapListing.descriptionFieldTitle": "描述",
"xpack.maps.mapListing.entityName": "地图",
"xpack.maps.mapListing.entityNamePlural": "地图",
"xpack.maps.mapListing.errorAttemptingToLoadSavedMaps": "无法加载地图",
"xpack.maps.mapListing.tableCaption": "Maps",
"xpack.maps.mapListing.titleFieldTitle": "标题",
"xpack.maps.mapSavedObjectLabel": "地图",
"xpack.maps.mapSettingsPanel.addCustomIcon": "添加定制图标",
"xpack.maps.mapSettingsPanel.autoFitToBoundsLocationLabel": "使地图自动适应数据边界",

View file

@ -38,7 +38,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
`tag-searchbar-option-${PageObjects.tagManagement.testSubjFriendly(dashboardTag)}`
);
// click elsewhere to close the filter dropdown
const searchFilter = await find.byCssSelector('.euiPageBody .euiFieldSearch');
const searchFilter = await find.byCssSelector('.euiPageTemplate .euiFieldSearch');
await searchFilter.click();
// wait until the table refreshes
await listingTable.waitUntilTableIsLoaded();

View file

@ -112,7 +112,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
`tag-searchbar-option-${PageObjects.tagManagement.testSubjFriendly(lensTag)}`
);
// click elsewhere to close the filter dropdown
const searchFilter = await find.byCssSelector('.euiPageBody .euiFieldSearch');
const searchFilter = await find.byCssSelector('.euiPageTemplate .euiFieldSearch');
await searchFilter.click();
// wait until the table refreshes
await listingTable.waitUntilTableIsLoaded();

View file

@ -31,7 +31,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
);
}
// click elsewhere to close the filter dropdown
const searchFilter = await find.byCssSelector('.euiPageBody .euiFieldSearch');
const searchFilter = await find.byCssSelector('.euiPageTemplate .euiFieldSearch');
await searchFilter.click();
// wait until the table refreshes
await listingTable.waitUntilTableIsLoaded();

View file

@ -30,7 +30,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
);
}
// click elsewhere to close the filter dropdown
const searchFilter = await find.byCssSelector('.euiPageBody .euiFieldSearch');
const searchFilter = await find.byCssSelector('.euiPageTemplate .euiFieldSearch');
await searchFilter.click();
};

View file

@ -31,7 +31,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
);
}
// click elsewhere to close the filter dropdown
const searchFilter = await find.byCssSelector('.euiPageBody .euiFieldSearch');
const searchFilter = await find.byCssSelector('.euiPageTemplate .euiFieldSearch');
await searchFilter.click();
// wait until the table refreshes
await listingTable.waitUntilTableIsLoaded();

View file

@ -2704,6 +2704,10 @@
version "0.0.0"
uid ""
"@kbn/content-management-table-list@link:bazel-bin/packages/content-management/table_list":
version "0.0.0"
uid ""
"@kbn/core-analytics-browser-internal@link:bazel-bin/packages/core/analytics/core-analytics-browser-internal":
version "0.0.0"
uid ""
@ -6717,6 +6721,10 @@
version "0.0.0"
uid ""
"@types/kbn__content-management-table-list@link:bazel-bin/packages/content-management/table_list/npm_module_types":
version "0.0.0"
uid ""
"@types/kbn__core-analytics-browser-internal@link:bazel-bin/packages/core/analytics/core-analytics-browser-internal/npm_module_types":
version "0.0.0"
uid ""