From 189196181c975b620ab18ea9d7662aa38d0e9294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 19 Sep 2022 11:29:23 +0100 Subject: [PATCH] [Table list view] Improve UX (phase 1) (#135892) --- .github/CODEOWNERS | 1 + .i18nrc.json | 1 + package.json | 2 + packages/BUILD.bazel | 2 + .../content-management/.storybook/main.js | 17 + .../content-management/.storybook/manager.js | 19 + .../content-management/table_list/BUILD.bazel | 160 ++++ .../content-management/table_list/README.mdx | 20 + .../content-management/table_list/index.ts | 11 + .../table_list/jest.config.js | 13 + .../table_list/kibana.jsonc | 7 + .../table_list/package.json | 8 + .../table_list/src/__jest__}/index.ts | 2 +- .../table_list/src/__jest__/tests.helpers.tsx | 36 + .../table_list/src/actions.ts | 74 ++ .../src/components/confirm_delete_modal.tsx | 94 +++ .../table_list/src/components/index.ts | 12 + .../src/components/listing_limit_warning.tsx | 78 ++ .../table_list/src/components/table.tsx | 131 ++++ .../src/components/updated_at_field.tsx | 52 ++ .../table_list/src/index.ts | 17 + .../table_list/src/mocks.ts | 99 +++ .../table_list/src/reducer.tsx | 152 ++++ .../table_list/src/services.tsx | 191 +++++ .../src/table_list_view.stories.tsx | 108 +++ .../table_list/src}/table_list_view.test.tsx | 241 +++--- .../table_list/src/table_list_view.tsx | 526 ++++++++++++++ .../table_list/tsconfig.json | 21 + .../src/bazel_package_dirs.js | 1 + .../src/testbed/testbed.ts | 14 +- .../src/testbed/types.ts | 6 +- src/dev/storybook/aliases.ts | 1 + .../public/application/dashboard_router.tsx | 74 +- .../dashboard_listing.test.tsx.snap | 455 +++--------- .../listing/dashboard_listing.test.tsx | 25 +- .../application/listing/dashboard_listing.tsx | 125 ++-- .../get_dashboard_list_item_link.test.ts | 10 +- .../listing/get_dashboard_list_item_link.ts | 3 +- .../test_helpers/make_default_services.ts | 7 + .../dashboard/public/dashboard_strings.ts | 9 - src/plugins/kibana_react/public/index.ts | 3 - .../table_list_view.test.tsx.snap | 163 ----- .../table_list_view/table_list_view.tsx | 686 ------------------ src/plugins/saved_objects/public/types.ts | 6 +- .../components/visualize_listing.tsx | 91 ++- .../public/visualize_app/index.tsx | 19 +- .../visualize_app/utils/get_table_columns.tsx | 97 +-- .../utils/get_visualize_list_item_link.ts | 7 +- test/scripts/jenkins_storybook.sh | 1 + x-pack/plugins/graph/public/application.tsx | 18 +- .../graph/public/apps/listing_route.tsx | 87 +-- .../plugins/graph/public/types/persistence.ts | 3 + .../home/data_streams_tab.test.ts | 4 +- x-pack/plugins/maps/public/kibana_services.ts | 2 +- .../maps/public/lazy_load_bundle/index.ts | 12 +- x-pack/plugins/maps/public/plugin.ts | 3 +- x-pack/plugins/maps/public/render_app.tsx | 68 +- .../routes/list_page/maps_list_view.tsx | 100 +-- .../translations/translations/fr-FR.json | 28 - .../translations/translations/ja-JP.json | 30 - .../translations/translations/zh-CN.json | 30 - .../dashboard/group2/dashboard_tagging.ts | 2 +- .../apps/lens/group3/lens_tagging.ts | 2 +- .../functional/tests/dashboard_integration.ts | 2 +- .../functional/tests/maps_integration.ts | 2 +- .../functional/tests/visualize_integration.ts | 2 +- yarn.lock | 8 + 67 files changed, 2449 insertions(+), 1852 deletions(-) create mode 100644 packages/content-management/.storybook/main.js create mode 100644 packages/content-management/.storybook/manager.js create mode 100644 packages/content-management/table_list/BUILD.bazel create mode 100644 packages/content-management/table_list/README.mdx create mode 100644 packages/content-management/table_list/index.ts create mode 100644 packages/content-management/table_list/jest.config.js create mode 100644 packages/content-management/table_list/kibana.jsonc create mode 100644 packages/content-management/table_list/package.json rename {src/plugins/kibana_react/public/table_list_view => packages/content-management/table_list/src/__jest__}/index.ts (88%) create mode 100644 packages/content-management/table_list/src/__jest__/tests.helpers.tsx create mode 100644 packages/content-management/table_list/src/actions.ts create mode 100644 packages/content-management/table_list/src/components/confirm_delete_modal.tsx create mode 100644 packages/content-management/table_list/src/components/index.ts create mode 100644 packages/content-management/table_list/src/components/listing_limit_warning.tsx create mode 100644 packages/content-management/table_list/src/components/table.tsx create mode 100644 packages/content-management/table_list/src/components/updated_at_field.tsx create mode 100644 packages/content-management/table_list/src/index.ts create mode 100644 packages/content-management/table_list/src/mocks.ts create mode 100644 packages/content-management/table_list/src/reducer.tsx create mode 100644 packages/content-management/table_list/src/services.tsx create mode 100644 packages/content-management/table_list/src/table_list_view.stories.tsx rename {src/plugins/kibana_react/public/table_list_view => packages/content-management/table_list/src}/table_list_view.test.tsx (53%) create mode 100644 packages/content-management/table_list/src/table_list_view.tsx create mode 100644 packages/content-management/table_list/tsconfig.json delete mode 100644 src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap delete mode 100644 src/plugins/kibana_react/public/table_list_view/table_list_view.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 514effa288865..1b6ace5538262 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/.i18nrc.json b/.i18nrc.json index dbe3715bc7556..20b2588ca2fab 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -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" diff --git a/package.json b/package.json index 5ac95b11986e1..e1d0340c23da5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 0d697c2852c2a..f55c3c39befb1 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -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", diff --git a/packages/content-management/.storybook/main.js b/packages/content-management/.storybook/main.js new file mode 100644 index 0000000000000..0aaf1046299de --- /dev/null +++ b/packages/content-management/.storybook/main.js @@ -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, + }, +}; diff --git a/packages/content-management/.storybook/manager.js b/packages/content-management/.storybook/manager.js new file mode 100644 index 0000000000000..bc576ed60a8aa --- /dev/null +++ b/packages/content-management/.storybook/manager.js @@ -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, +}); diff --git a/packages/content-management/table_list/BUILD.bazel b/packages/content-management/table_list/BUILD.bazel new file mode 100644 index 0000000000000..36d84a426a017 --- /dev/null +++ b/packages/content-management/table_list/BUILD.bazel @@ -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"], +) diff --git a/packages/content-management/table_list/README.mdx b/packages/content-management/table_list/README.mdx new file mode 100644 index 0000000000000..1847207c3bdcd --- /dev/null +++ b/packages/content-management/table_list/README.mdx @@ -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 `` 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. diff --git a/packages/content-management/table_list/index.ts b/packages/content-management/table_list/index.ts new file mode 100644 index 0000000000000..c6550a12da30a --- /dev/null +++ b/packages/content-management/table_list/index.ts @@ -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'; diff --git a/packages/content-management/table_list/jest.config.js b/packages/content-management/table_list/jest.config.js new file mode 100644 index 0000000000000..546d16dd86cf0 --- /dev/null +++ b/packages/content-management/table_list/jest.config.js @@ -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: ['/packages/content-management/table_list'], +}; diff --git a/packages/content-management/table_list/kibana.jsonc b/packages/content-management/table_list/kibana.jsonc new file mode 100644 index 0000000000000..7f22b8c8f56c4 --- /dev/null +++ b/packages/content-management/table_list/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/content-management-table-list", + "owner": "@elastic/shared-ux", + "runtimeDeps": [], + "typeDeps": [] +} diff --git a/packages/content-management/table_list/package.json b/packages/content-management/table_list/package.json new file mode 100644 index 0000000000000..f4cc8ba690d20 --- /dev/null +++ b/packages/content-management/table_list/package.json @@ -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" +} diff --git a/src/plugins/kibana_react/public/table_list_view/index.ts b/packages/content-management/table_list/src/__jest__/index.ts similarity index 88% rename from src/plugins/kibana_react/public/table_list_view/index.ts rename to packages/content-management/table_list/src/__jest__/index.ts index 8fdc5a27df01c..2f3d8863ff1d6 100644 --- a/src/plugins/kibana_react/public/table_list_view/index.ts +++ b/packages/content-management/table_list/src/__jest__/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export * from './table_list_view'; +export { WithServices } from './tests.helpers'; diff --git a/packages/content-management/table_list/src/__jest__/tests.helpers.tsx b/packages/content-management/table_list/src/__jest__/tests.helpers.tsx new file mode 100644 index 0000000000000..381e4974b4e36 --- /dev/null +++ b/packages/content-management/table_list/src/__jest__/tests.helpers.tsx @@ -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) => { + const services: Services = { + canEditAdvancedSettings: true, + getListingLimitSettingsUrl: () => 'http://elastic.co', + notifyError: () => undefined, + currentAppId$: from('mockedApp'), + navigateToUrl: () => undefined, + ...overrides, + }; + + return services; +}; + +export function WithServices

(Comp: ComponentType

, overrides: Partial = {}) { + return (props: P) => { + const services = getMockServices(overrides); + return ( + + + + ); + }; +} diff --git a/packages/content-management/table_list/src/actions.ts b/packages/content-management/table_list/src/actions.ts new file mode 100644 index 0000000000000..ad82aa7379812 --- /dev/null +++ b/packages/content-management/table_list/src/actions.ts @@ -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 { + 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; +} + +/** + * 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 { + type: 'onSelectionChange'; + data: T[]; +} + +/** Action to update the state of the table whenever the sort or page size changes */ +export interface OnTableChangeAction { + type: 'onTableChange'; + data: CriteriaWithPagination; +} + +/** 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 = + | OnFetchItemsAction + | OnFetchItemsSuccessAction + | OnFetchItemsErrorAction + | DeleteItemsActions + | OnSelectionChangeAction + | OnTableChangeAction + | ShowConfirmDeleteItemsModalAction + | OnSearchQueryChangeAction; diff --git a/packages/content-management/table_list/src/components/confirm_delete_modal.tsx b/packages/content-management/table_list/src/components/confirm_delete_modal.tsx new file mode 100644 index 0000000000000..af2585788701a --- /dev/null +++ b/packages/content-management/table_list/src/components/confirm_delete_modal.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { + /** 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({ + isDeletingItems, + items, + entityName, + entityNamePlural, + onCancel, + onConfirm, +}: Props) { + const { deleteBtnLabel, deletingBtnLabel, title, description, cancelBtnLabel } = getI18nTexts( + items, + entityName, + entityNamePlural + ); + + return ( + +

{description}

+ + ); +} diff --git a/packages/content-management/table_list/src/components/index.ts b/packages/content-management/table_list/src/components/index.ts new file mode 100644 index 0000000000000..b9226155ba44d --- /dev/null +++ b/packages/content-management/table_list/src/components/index.ts @@ -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'; diff --git a/packages/content-management/table_list/src/components/listing_limit_warning.tsx b/packages/content-management/table_list/src/components/listing_limit_warning.tsx new file mode 100644 index 0000000000000..f5317a9598baa --- /dev/null +++ b/packages/content-management/table_list/src/components/listing_limit_warning.tsx @@ -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 { + entityNamePlural: string; + canEditAdvancedSettings: boolean; + advancedSettingsLink: string; + totalItems: number; + listingLimit: number; +} + +export function ListingLimitWarning({ + entityNamePlural, + totalItems, + listingLimit, + canEditAdvancedSettings, + advancedSettingsLink, +}: Props) { + return ( + <> + + } + color="warning" + iconType="help" + > +

+ listingLimit, + }} + />{' '} + {canEditAdvancedSettings ? ( + + + + ), + }} + /> + ) : ( + + )} +

+
+ + + ); +} diff --git a/packages/content-management/table_list/src/components/table.tsx b/packages/content-management/table_list/src/components/table.tsx new file mode 100644 index 0000000000000..feb83dbc30a40 --- /dev/null +++ b/packages/content-management/table_list/src/components/table.tsx @@ -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 = Pick< + TableListViewState, + 'items' | 'selectedIds' | 'searchQuery' | 'tableSort' | 'pagination' +>; + +interface Props extends State { + dispatch: Dispatch>; + entityName: string; + entityNamePlural: string; + isFetchingItems: boolean; + tableCaption: string; + tableColumns: Array>; + deleteItems: TableListViewProps['deleteItems']; +} + +export function Table({ + dispatch, + items, + isFetchingItems, + searchQuery, + selectedIds, + pagination, + tableColumns, + tableSort, + entityName, + entityNamePlural, + deleteItems, + tableCaption, +}: Props) { + const { getSearchBarFilters } = useServices(); + + const renderToolsLeft = useCallback(() => { + if (!deleteItems || selectedIds.length === 0) { + return; + } + + return ( + dispatch({ type: 'showConfirmDeleteItemsModal' })} + data-test-subj="deleteSelectedItems" + > + + + ); + }, [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 = ( + + ); + + return ( + + 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) => + dispatch({ type: 'onTableChange', data: criteria }) + } + data-test-subj="itemsInMemTable" + rowHeader="attributes.title" + tableCaption={tableCaption} + /> + ); +} diff --git a/packages/content-management/table_list/src/components/updated_at_field.tsx b/packages/content-management/table_list/src/components/updated_at_field.tsx new file mode 100644 index 0000000000000..76055c63f00e4 --- /dev/null +++ b/packages/content-management/table_list/src/components/updated_at_field.tsx @@ -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 ( + + - + + ); + } + const updatedAt = moment(dateTime); + + if (updatedAt.diff(moment(), 'days') > -7) { + return ( + + {(formattedDate: string) => ( + + {formattedDate} + + )} + + ); + } + return ( + + {updatedAt.format('LL')} + + ); +}; diff --git a/packages/content-management/table_list/src/index.ts b/packages/content-management/table_list/src/index.ts new file mode 100644 index 0000000000000..df0d1e22bc106 --- /dev/null +++ b/packages/content-management/table_list/src/index.ts @@ -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'; diff --git a/packages/content-management/table_list/src/mocks.ts b/packages/content-management/table_list/src/mocks.ts new file mode 100644 index 0000000000000..3c6bb3c68cad1 --- /dev/null +++ b/packages/content-management/table_list/src/mocks.ts @@ -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, 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, + }, +}); diff --git a/packages/content-management/table_list/src/reducer.tsx b/packages/content-management/table_list/src/reducer.tsx new file mode 100644 index 0000000000000..605e3872c077d --- /dev/null +++ b/packages/content-management/table_list/src/reducer.tsx @@ -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( + 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 }) => ( + + ), + sortable: true, + width: '150px', + }, + ], + }; + } + + return {}; +} + +export function getReducer({ DateFormatterComp }: Dependencies) { + return (state: State, action: Action): State => { + 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(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)), + }; + } + } + }; +} diff --git a/packages/content-management/table_list/src/services.tsx b/packages/content-management/table_list/src/services.tsx new file mode 100644 index 0000000000000..ca376c2f83058 --- /dev/null +++ b/packages/content-management/table_list/src/services.tsx @@ -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; + +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; + navigateToUrl: (url: string) => Promise | void; + searchQueryParser?: (searchQuery: string) => { + searchQuery: string; + references?: SavedObjectsFindOptionsReference[]; + }; + getTagsColumnDefinition?: () => EuiTableFieldDataColumnType | undefined; + getSearchBarFilters?: () => SearchFilterConfig[]; + DateFormatterComp?: DateFormatter; +} + +const TableListViewContext = React.createContext(null); + +/** + * Abstract external service Provider. + */ +export const TableListViewProvider: FC = ({ children, ...services }) => { + return {children}; +}; + +/** + * Kibana-specific service types. + */ +export interface TableListViewKibanaDependencies { + /** CoreStart contract */ + core: { + application: { + capabilities: { + advancedSettings?: { + save: boolean; + }; + }; + getUrlForApp: (app: string, options: { path: string }) => string; + currentAppId$: Observable; + navigateToUrl: (url: string) => Promise | 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; + parseSearchQuery: ( + query: string, + options?: { + useName?: boolean; + tagField?: string; + } + ) => { + searchTerm: string; + tagReferences: SavedObjectsFindOptionsReference[]; + valid: boolean; + }; + getSearchBarFilter: (options?: { + useName?: boolean; + tagField?: string; + }) => SearchFilterConfig; + }; + }; + /** The component from the @kbn/i18n-react package */ + FormattedRelative: typeof FormattedRelative; +} + +/** + * Kibana-specific Provider that maps to known dependency types. + */ +export const TableListViewKibanaProvider: FC = ({ + 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 ( + + + 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) => } + currentAppId$={core.application.currentAppId$} + navigateToUrl={core.application.navigateToUrl} + > + {children} + + + ); +}; + +/** + * 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 or .' + ); + } + + return context; +} diff --git a/packages/content-management/table_list/src/table_list_view.stories.tsx b/packages/content-management/table_list/src/table_list_view.stories.tsx new file mode 100644 index 0000000000000..7b197c0fa1b5b --- /dev/null +++ b/packages/content-management/table_list/src/table_list_view.stories.tsx @@ -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 ( + + { + 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} + /> + + ); +}; + +ConnectedComponent.argTypes = argTypes; diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx b/packages/content-management/table_list/src/table_list_view.test.tsx similarity index 53% rename from src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx rename to packages/content-management/table_list/src/table_list_view.test.tsx index 0c4d935f524d0..d5c0fda746bfe 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx +++ b/packages/content-management/table_list/src/table_list_view.test.tsx @@ -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> = { +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,105 +54,92 @@ describe('TableListView', () => { jest.useRealTimers(); }); + const setup = registerTestBed( + WithServices(TableListView), + { + defaultProps: { ...requiredProps }, + memoryRouter: { wrapComponent: false }, + } + ); + test('render default empty prompt', async () => { - const component = shallowWithIntl(); + 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(); }); - 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( {}} />); + 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(); - }); - - test('render custom empty prompt', () => { - const component = shallowWithIntl( - } /> - ); - - // Using setState to check the final render while sidestepping the debounced promise management - component.setState({ - hasInitialFetchReturned: true, - isFetchingItems: false, - }); + const { component, exists } = testBed!; + component.update(); - expect(component).toMatchSnapshot(); + expect(component.find(EuiEmptyPrompt).length).toBe(1); + expect(exists('newItemButton')).toBe(true); }); - test('render list view', () => { - const component = shallowWithIntl(); + test('render custom empty prompt', async () => { + let testBed: TestBed; + + const CustomEmptyPrompt = () => { + return Table empty} />; + }; - // Using setState to check the final render while sidestepping the debounced promise management - component.setState({ - hasInitialFetchReturned: true, - isFetchingItems: false, - items: [{}], + await act(async () => { + testBed = await setup({ emptyPrompt: }); }); - expect(component).toMatchSnapshot(); + const { component, exists } = testBed!; + component.update(); + + 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> = { - ...requiredProps, - tableColumns, - findItems, - createItem: () => undefined, - }; - - const setup = registerTestBed(TableListView, { defaultProps }); - test('should add a "Last updated" column if "updatedAt" is provided', async () => { + let testBed: TestBed; + await act(async () => { - testBed = await setup(); + testBed = await setup({ + findItems: jest.fn().mockResolvedValue({ total: hits.length, hits }), + }); }); const { component, table } = testBed!; @@ -156,33 +148,33 @@ describe('TableListView', () => { 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'], + ['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(({ title, description }, i) => { + const updatedHits = hits.map(({ id, attributes }, i) => { const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i))); - updatedAtValues[i] = moment(updatedAt); + updatedAtValues.push(moment(updatedAt)); return { - title, - description, + id, updatedAt, + attributes, }; }); await act(async () => { testBed = await setup({ - findItems: jest.fn(() => - Promise.resolve({ - total: updatedHits.length, - hits: updatedHits, - }) - ), + findItems: jest.fn().mockResolvedValue({ + total: updatedHits.length, + hits: updatedHits, + }), }); }); @@ -192,21 +184,22 @@ describe('TableListView', () => { const { tableCellsValues } = table.getMetaData('itemsInMemTable'); expect(tableCellsValues).toEqual([ - // Renders the datetime with this format: "05/10/2022 @ 2:34 PM" + // 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> = { - ...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!; diff --git a/packages/content-management/table_list/src/table_list_view.tsx b/packages/content-management/table_list/src/table_list_view.tsx new file mode 100644 index 0000000000000..afa41885052e1 --- /dev/null +++ b/packages/content-management/table_list/src/table_list_view.tsx @@ -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 { + entityName: string; + entityNamePlural: string; + tableListTitle: string; + listingLimit: number; + initialFilter: string; + initialPageSize: number; + emptyPrompt?: JSX.Element; + /** Add an additional custom column */ + customTableColumn?: EuiBasicTableColumn; + /** + * 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; + editItem?(item: T): void; +} + +export interface State { + items: T[]; + hasInitialFetchReturned: boolean; + isFetchingItems: boolean; + isDeletingItems: boolean; + showDeleteModal: boolean; + fetchError?: IHttpFetchError; + searchQuery: string; + selectedIds: string[]; + totalItems: number; + tableColumns: Array>; + 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({ + tableListTitle, + entityName, + entityNamePlural, + initialFilter: initialQuery, + headingId, + initialPageSize, + listingLimit, + customTableColumn, + emptyPrompt, + findItems, + createItem, + editItem, + deleteItems, + getDetailViewLink, + onClickTitle, + id = 'userContent', + children, +}: Props) { + 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({ DateFormatterComp }); + }, [DateFormatterComp]); + + const redirectAppLinksCoreStart = useMemo( + () => ({ + application: { + navigateToUrl, + currentAppId$, + }, + }), + [navigateToUrl, currentAppId$] + ); + + const [state, dispatch] = useReducer<(state: State, action: Action) => State>(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 {record.attributes.title}; + } + + return ( + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + { + e.preventDefault(); + onClickTitle(record); + } + : undefined + } + data-test-subj={`${id}ListingTitleLink-${record.attributes.title + .split(' ') + .join('-')}`} + > + {record.attributes.title} + + + ); + }, + }, + { + 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['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( + , + error + ); + } + + fetchItems(); + + dispatch({ type: 'onItemsDeleted' }); + }, [deleteItems, entityName, fetchItems, isDeletingItems, notifyError, selectedItems]); + + const renderCreateButton = useCallback(() => { + if (createItem) { + return ( + + + + ); + } + }, [createItem, entityName]); + + const renderNoItemsMessage = useCallback(() => { + if (emptyPrompt) { + return emptyPrompt; + } else { + return ( + + { + + } + + } + actions={renderCreateButton()} + /> + ); + } + }, [emptyPrompt, entityNamePlural, renderCreateButton]); + + const renderFetchError = useCallback(() => { + return ( + + + } + color="danger" + iconType="alert" + > +

+ +

+
+ +
+ ); + }, [entityName, fetchError]); + + // ------------ + // Effects + // ------------ + useDebounce(fetchItems, 300, [fetchItems]); + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + + // ------------ + // Render + // ------------ + if (!hasInitialFetchReturned) { + return null; + } + + if (!fetchError && hasNoItems) { + return ( + + + {renderNoItemsMessage()} + + + ); + } + + return ( + + {tableListTitle}} + rightSideItems={[renderCreateButton() ?? ]} + data-test-subj="top-nav" + /> + + {/* Any children passed to the component */} + {children} + + {/* Too many items error */} + {showLimitError && ( + + )} + + {/* Error while fetching items */} + {showFetchError && renderFetchError()} + + {/* Table of items */} + + 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 && ( + + isDeletingItems={isDeletingItems} + entityName={entityName} + entityNamePlural={entityNamePlural} + items={selectedItems} + onConfirm={deleteSelectedItems} + onCancel={() => dispatch({ type: 'onCancelDeleteItems' })} + /> + )} + + + ); +} + +const TableListView = React.memo(TableListViewComp) as typeof TableListViewComp; + +export { TableListView }; + +// eslint-disable-next-line import/no-default-export +export default TableListView; diff --git a/packages/content-management/table_list/tsconfig.json b/packages/content-management/table_list/tsconfig.json new file mode 100644 index 0000000000000..f9da39a3d7eb9 --- /dev/null +++ b/packages/content-management/table_list/tsconfig.json @@ -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", + ] +} diff --git a/packages/kbn-bazel-packages/src/bazel_package_dirs.js b/packages/kbn-bazel-packages/src/bazel_package_dirs.js index 7e3f728a21ca7..dbb8826bcd733 100644 --- a/packages/kbn-bazel-packages/src/bazel_package_dirs.js +++ b/packages/kbn-bazel-packages/src/bazel_package_dirs.js @@ -26,6 +26,7 @@ const BAZEL_PACKAGE_DIRS = [ 'packages/analytics/shippers/elastic_v3', 'packages/core/*', 'packages/home', + 'packages/content-management', 'x-pack/packages/ml', ]; diff --git a/packages/kbn-test-jest-helpers/src/testbed/testbed.ts b/packages/kbn-test-jest-helpers/src/testbed/testbed.ts index ddd574ace64b8..a05133b35f581 100644 --- a/packages/kbn-test-jest-helpers/src/testbed/testbed.ts +++ b/packages/kbn-test-jest-helpers/src/testbed/testbed.ts @@ -55,18 +55,18 @@ const defaultConfig: TestBedConfig = { }); ``` */ -export function registerTestBed( +export function registerTestBed( Component: ComponentType, config: AsyncTestBedConfig -): AsyncSetupFunc; -export function registerTestBed( +): AsyncSetupFunc>; +export function registerTestBed( Component: ComponentType, config?: TestBedConfig -): SyncSetupFunc; -export function registerTestBed( +): SyncSetupFunc>; +export function registerTestBed( Component: ComponentType, config?: AsyncTestBedConfig | TestBedConfig -): SetupFunc { +): SetupFunc> { const { defaultProps = defaultConfig.defaultProps, memoryRouter = defaultConfig.memoryRouter!, @@ -263,7 +263,7 @@ export function registerTestBed( .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) diff --git a/packages/kbn-test-jest-helpers/src/testbed/types.ts b/packages/kbn-test-jest-helpers/src/testbed/types.ts index 15996646ec80a..6390be14be00e 100644 --- a/packages/kbn-test-jest-helpers/src/testbed/types.ts +++ b/packages/kbn-test-jest-helpers/src/testbed/types.ts @@ -10,9 +10,9 @@ import { Store } from 'redux'; import { ReactWrapper as GenericReactWrapper } from 'enzyme'; import { LocationDescriptor } from 'history'; -export type AsyncSetupFunc = (props?: any) => Promise>; -export type SyncSetupFunc = (props?: any) => TestBed; -export type SetupFunc = (props?: any) => TestBed | Promise>; +export type AsyncSetupFunc = (props?: P) => Promise>; +export type SyncSetupFunc = (props?: P) => TestBed; +export type SetupFunc = (props?: P) => TestBed | Promise>; export type ReactWrapper = GenericReactWrapper; export interface EuiTableMetaData { diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 43afb75e8265f..1705c9ac2ec9c 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -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', diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index cc683ba63149c..9927f4c43c1bf 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -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 - - - - - - - - - - + + + + + + + + + + + + diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap index 064daca6db30b..6fac25e51ddf1 100644 --- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -21,30 +21,7 @@ exports[`after fetch When given a title that matches multiple dashboards, filter redirectTo={[MockFunction]} title="search by title" > - + + /> + `; @@ -162,30 +114,7 @@ exports[`after fetch initialFilter 1`] = ` } redirectTo={[MockFunction]} > - + + /> + `; @@ -302,30 +206,7 @@ exports[`after fetch renders all table rows 1`] = ` } redirectTo={[MockFunction]} > - + + /> + `; @@ -442,30 +298,7 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` } redirectTo={[MockFunction]} > - + + /> + `; @@ -582,30 +390,7 @@ exports[`after fetch renders call to action with continue when no dashboards exi } redirectTo={[MockFunction]} > - + + /> + `; @@ -733,30 +492,7 @@ exports[`after fetch showWriteControls 1`] = ` } redirectTo={[MockFunction]} > - + + /> + `; diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx index d690c91574eef..dae5308cc0234 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.tsx @@ -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 ( {/* Can't get rid of KibanaContextProvider here yet because of 'call to action when no dashboards exist' tests below */} - {children} + () => () => undefined} + > + {children} + ); diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx index 0d72523e913e2..40753c556a56a 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.tsx @@ -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 +): 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(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 && ( setShowNoDataPage(false)} /> )} {!showNoDataPage && ( - 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) + } > ); }; - -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 }) => ( - - {field} - - ), - }, - { - field: 'description', - name: dashboardListingTable.getDescriptionColumnName(), - render: (field: string, record: { description: string }) => {record.description}, - sortable: true, - }, - ...(tableColumnDefinition ? [tableColumnDefinition] : []), - ] as unknown as Array>>; -}; diff --git a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts index 31ebeb9e93c9b..f44b4036dfcfd 100644 --- a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts +++ b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts @@ -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))))"` ); diff --git a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts index 39106f12551bc..0ee6f016ad6d5 100644 --- a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts +++ b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts @@ -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)}`, diff --git a/src/plugins/dashboard/public/application/test_helpers/make_default_services.ts b/src/plugins/dashboard/public/application/test_helpers/make_default_services.ts index 2712c92888bf3..3ffe9dc3b70e9 100644 --- a/src/plugins/dashboard/public/application/test_helpers/make_default_services.ts +++ b/src/plugins/dashboard/public/application/test_helpers/make_default_services.ts @@ -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({ diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts index 7cea0a45c0e25..5679ac28f838b 100644 --- a/src/plugins/dashboard/public/dashboard_strings.ts +++ b/src/plugins/dashboard/public/dashboard_strings.ts @@ -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 = { diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 2244d3d5503e0..3311f42bff55d 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -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'; diff --git a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap deleted file mode 100644 index 1f99e74ef97dc..0000000000000 --- a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap +++ /dev/null @@ -1,163 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TableListView render custom empty prompt 1`] = ` - - - -`; - -exports[`TableListView render default empty prompt 1`] = ` - - - - - } - /> - -`; - -exports[`TableListView render default empty prompt with create action when createItem supplied 1`] = ` - - - - - } - title={ -

- -

- } - /> -
-`; - -exports[`TableListView render list view 1`] = ` - - test title - , - "rightSideItems": Array [ - undefined, - ], - } - } -> - - } - 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" - /> - -`; diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx deleted file mode 100644 index bcb07cab0ae01..0000000000000 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ /dev/null @@ -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 { - createItem?(): void; - deleteItems?(items: V[]): Promise; - 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>; - 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 { - items: V[]; - hasInitialFetchReturned: boolean; - hasUpdatedAtMetadata: boolean | null; - isFetchingItems: boolean; - isDeletingItems: boolean; - showDeleteModal: boolean; - showLimitError: boolean; - fetchError?: IHttpFetchError; - 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 extends React.Component< - TableListViewProps, - TableListViewState -> { - private _isMounted = false; - - constructor(props: TableListViewProps) { - 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, prevState: TableListViewState) { - 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(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( - , - { 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 = ( - - ); - if (this.state.isDeletingItems) { - deleteButton = ( - - ); - } - - return ( - - } - buttonColor="danger" - onCancel={this.closeDeleteModal} - onConfirm={this.deleteSelectedItems} - cancelButtonText={ - - } - confirmButtonText={deleteButton} - defaultFocusedButton="cancel" - > -

- -

-
- ); - } - - 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 ( - - - } - color="warning" - iconType="help" - > -

- {canEditAdvancedSettings ? ( - listingLimit, - advancedSettingsLink: ( - - - - ), - }} - /> - ) : ( - listingLimit, - }} - /> - )} -

-
- -
- ); - } - } - - renderFetchError() { - if (this.state.fetchError) { - return ( - - - } - color="danger" - iconType="alert" - > -

- -

-
- -
- ); - } - } - - renderNoItemsMessage() { - if (this.props.emptyPrompt) { - return this.props.emptyPrompt; - } else { - return ( - - { - - } - - } - actions={this.renderCreateButton()} - /> - ); - } - } - - renderToolsLeft() { - const selection = this.state.selectedIds; - - if (selection.length === 0) { - return; - } - - const onClick = () => { - this.openDeleteModal(); - }; - - return ( - - - - ); - } - - onTableChange(criteria: CriteriaWithPagination) { - 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)?.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 = ( - - ); - - return ( - - ); - } - - getTableColumns() { - const columns = this.props.tableColumns.slice(); - - // Add "Last update" column - if (this.state.hasUpdatedAtMetadata) { - const renderUpdatedAt = (dateTime?: string) => { - if (!dateTime) { - return ( - - - - - ); - } - const updatedAt = moment(dateTime); - - if (updatedAt.diff(moment(), 'days') > -7) { - return ( - - {(formattedDate: string) => ( - - {formattedDate} - - )} - - ); - } - return ( - - {updatedAt.format('LL')} - - ); - }; - - 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['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 ( - - - - ); - } - } - - render() { - const pageDTS = `${this.props.entityName}LandingPage`; - - if (!this.state.hasInitialFetchReturned) { - return <>; - } - - if (!this.state.fetchError && this.hasNoItems()) { - return ( - - {this.renderNoItemsMessage()} - - ); - } - - return ( - {this.props.tableListTitle}, - 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()} - - ); - } -} - -export { TableListView }; - -// eslint-disable-next-line import/no-default-export -export default TableListView; diff --git a/src/plugins/saved_objects/public/types.ts b/src/plugins/saved_objects/public/types.ts index 1159a02dc0c33..ce3f41e5bb623 100644 --- a/src/plugins/saved_objects/public/types.ts +++ b/src/plugins/saved_objects/public/types.ts @@ -21,10 +21,10 @@ import type { DataView } from '@kbn/data-views-plugin/common'; * @deprecated * @removeBy 8.8.0 */ -export interface SavedObject { +export interface SavedObject { _serialize: () => { attributes: SavedObjectAttributes; references: SavedObjectReference[] }; _source: Record; - applyESResp: (resp: EsResponse) => Promise; + applyESResp: (resp: EsResponse) => Promise>; copyOnSave: boolean; creationOpts: (opts: SavedObjectCreationOpts) => Record; defaults: any; @@ -35,7 +35,7 @@ export interface SavedObject { getFullPath: () => string; hydrateIndexPattern?: (id?: string) => Promise; id?: string; - init?: () => Promise; + init?: () => Promise>; isSaving: boolean; isTitleChanged: () => boolean; lastSavedTitle: string; diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx index d3c2c75bca853..048de833df802 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_listing.tsx @@ -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): 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(); 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> }) => ({ 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 = ( { ); return ( - + 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 && ( <> diff --git a/src/plugins/visualizations/public/visualize_app/index.tsx b/src/plugins/visualizations/public/visualize_app/index.tsx index c29eef172ffe6..e432275c755e6 100644 --- a/src/plugins/visualizations/public/visualize_app/index.tsx +++ b/src/plugins/visualizations/public/visualize_app/index.tsx @@ -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 = ( - + + + diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_table_columns.tsx b/src/plugins/visualizations/public/visualize_app/utils/get_table_columns.tsx index adb9e3d9ef3e9..8d907b763a035 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_table_columns.tsx +++ b/src/plugins/visualizations/public/visualize_app/utils/get_table_columns.tsx @@ -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 ? ( - - - {field} - - - ) : ( - field - ), - }, - { - field: 'typeTitle', - name: i18n.translate('visualizations.listing.table.typeColumnName', { - defaultMessage: 'Type', - }), - sortable: true, - render: (field: string, record: VisualizationListItem) => - !record.error ? ( - - {renderItemTypeIcon(record)} - {record.typeTitle} - {getBadge(record)} - - ) : ( - - {record.error} - - ), - }, - { - field: 'description', - name: i18n.translate('visualizations.listing.table.descriptionColumnName', { - defaultMessage: 'Description', - }), - sortable: true, - render: (field: string, record: VisualizationListItem) => {record.description}, - }, - ...(taggingApi ? [taggingApi.ui.getTableColumnDefinition()] : []), - ] as unknown as Array>>; +export const getCustomColumn = () => { + return { + field: 'typeTitle', + name: i18n.translate('visualizations.listing.table.typeColumnName', { + defaultMessage: 'Type', + }), + sortable: true, + render: (field: string, record: VisualizationListItem) => + !record.error ? ( + + {renderItemTypeIcon(record)} + {record.typeTitle} + {getBadge(record)} + + ) : ( + + {record.error} + + ), + }; +}; export const getNoItemsMessage = (createItem: () => void) => ( { + if (error) { + return undefined; + } + // for visualizations the editApp is undefined let url = application.getUrlForApp(editApp ?? VISUALIZE_APP_NAME, { path: editApp ? editUrl : `#${editUrl}`, diff --git a/test/scripts/jenkins_storybook.sh b/test/scripts/jenkins_storybook.sh index a07a634d2dba2..1c6faa93d01d1 100755 --- a/test/scripts/jenkins_storybook.sh +++ b/test/scripts/jenkins_storybook.sh @@ -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 diff --git a/x-pack/plugins/graph/public/application.tsx b/x-pack/plugins/graph/public/application.tsx index bc0ee0b3ee55b..d3ba129dbb316 100644 --- a/x-pack/plugins/graph/public/application.tsx +++ b/x-pack/plugins/graph/public/application.tsx @@ -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 = {graphRouter(deps)}; + const app = ( + + + {graphRouter(deps)} + + + ); ReactDOM.render(app, element); element.setAttribute('class', 'gphAppWrapper'); diff --git a/x-pack/plugins/graph/public/apps/listing_route.tsx b/x-pack/plugins/graph/public/apps/listing_route.tsx index cf9a5ea88d2a8..af869f7afaa21 100644 --- a/x-pack/plugins/graph/public/apps/listing_route.tsx +++ b/x-pack/plugins/graph/public/apps/listing_route.tsx @@ -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; } @@ -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 ( - + 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 })} /> ); @@ -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) => ( - - {field} - - ), - }, - { - field: 'description', - name: i18n.translate('xpack.graph.listing.table.descriptionColumnName', { - defaultMessage: 'Description', - }), - dataType: 'string', - sortable: true, - }, - ]; -} diff --git a/x-pack/plugins/graph/public/types/persistence.ts b/x-pack/plugins/graph/public/types/persistence.ts index 640348d96f6ac..f5c1366ff8661 100644 --- a/x-pack/plugins/graph/public/types/persistence.ts +++ b/x-pack/plugins/graph/public/types/persistence.ts @@ -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; + updatedAt?: string; + references: SavedObjectReference[]; } export interface SerializedWorkspaceState { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index 09caa5e20364a..208bec0a1d6e6 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -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'], ]); }); }); diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 5774a46684644..41f88de18720c 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -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; }; diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts index cdf61a6ab9173..a2f4f4004797e 100644 --- a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts @@ -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 diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 8a0f85393587b..9890676f2cec5 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -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 }); }, }); diff --git a/x-pack/plugins/maps/public/render_app.tsx b/x-pack/plugins/maps/public/render_app.tsx index 3deea0f82b272..196332d5e873e 100644 --- a/x-pack/plugins/maps/public/render_app.tsx +++ b/x-pack/plugins/maps/public/render_app.tsx @@ -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( - - - - - // Redirect other routes to list, or if hash-containing, their non-hash equivalents - { - if (hash) { - // Remove leading hash - const newPath = hash.substr(1); - return ; - } else if (pathname === '/' || pathname === '') { - return ; - } else { - return ; - } - }} - /> - - + + + + + + // Redirect other routes to list, or if hash-containing, their non-hash equivalents + { + if (hash) { + // Remove leading hash + const newPath = hash.substr(1); + return ; + } else if (pathname === '/' || pathname === '') { + return ; + } else { + return ; + } + }} + /> + + + , diff --git a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx index 37a2f75df1544..e9c3102e2aa85 100644 --- a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx @@ -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> = [ - { - field: 'title', - name: i18n.translate('xpack.maps.mapListing.titleFieldTitle', { - defaultMessage: 'Title', - }), - sortable: true, - render: (field: string, record: MapItem) => ( - { - e.preventDefault(); - goToSpecifiedPath(getEditPath(record.id)); - }} - data-test-subj={`mapListingTitleLink-${record.title.split(' ').join('-')}`} - > - {field} - - ), - }, - { - 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 +): 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({ 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 ( - + 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))} /> ); } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index a430b41307f26..b066ae3b70d59 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -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 d’afficher 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 d’ajouter la fonctionnalité à l’index.", "xpack.maps.mapActions.removeFeatureError": "Impossible de retirer la fonctionnalité de l’index.", - "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", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 757dd00a6e1a8..7f1d687e3e25e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -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": "自動的にマップをデータ境界に合わせる", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f48178da735a6..e9e751a71ee83 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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": "使地图自动适应数据边界", diff --git a/x-pack/test/functional/apps/dashboard/group2/dashboard_tagging.ts b/x-pack/test/functional/apps/dashboard/group2/dashboard_tagging.ts index ca0093135b7da..535e287cfc5eb 100644 --- a/x-pack/test/functional/apps/dashboard/group2/dashboard_tagging.ts +++ b/x-pack/test/functional/apps/dashboard/group2/dashboard_tagging.ts @@ -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(); diff --git a/x-pack/test/functional/apps/lens/group3/lens_tagging.ts b/x-pack/test/functional/apps/lens/group3/lens_tagging.ts index d69b49403fc31..d4d9f084a64f7 100644 --- a/x-pack/test/functional/apps/lens/group3/lens_tagging.ts +++ b/x-pack/test/functional/apps/lens/group3/lens_tagging.ts @@ -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(); diff --git a/x-pack/test/saved_object_tagging/functional/tests/dashboard_integration.ts b/x-pack/test/saved_object_tagging/functional/tests/dashboard_integration.ts index 2a31c0518798e..22c8b159b12ed 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/dashboard_integration.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/dashboard_integration.ts @@ -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(); diff --git a/x-pack/test/saved_object_tagging/functional/tests/maps_integration.ts b/x-pack/test/saved_object_tagging/functional/tests/maps_integration.ts index 13440b3d0cc74..97402fe5c76c7 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/maps_integration.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/maps_integration.ts @@ -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(); }; diff --git a/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts b/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts index a1059331e3312..faf9e8aed8306 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/visualize_integration.ts @@ -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(); diff --git a/yarn.lock b/yarn.lock index d7ab8357a85de..ed8c1e12b5af6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 ""