mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[ContentManagement] Inspector flyout (#144240)
This commit is contained in:
parent
1ff9ceba69
commit
2590173096
46 changed files with 1602 additions and 82 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -702,6 +702,7 @@ 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/analytics/shippers/gainsight @elastic/kibana-core
|
||||
packages/content-management/inspector @elastic/shared-ux
|
||||
packages/content-management/table_list @elastic/kibana-global-experience
|
||||
packages/core/analytics/core-analytics-browser @elastic/kibana-core
|
||||
packages/core/analytics/core-analytics-browser-internal @elastic/kibana-core
|
||||
|
|
|
@ -148,6 +148,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-inspector": "link:bazel-bin/packages/content-management/inspector",
|
||||
"@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",
|
||||
|
|
|
@ -15,6 +15,7 @@ filegroup(
|
|||
"//packages/analytics/shippers/elastic_v3/server:build",
|
||||
"//packages/analytics/shippers/fullstory:build",
|
||||
"//packages/analytics/shippers/gainsight:build",
|
||||
"//packages/content-management/inspector:build",
|
||||
"//packages/content-management/table_list:build",
|
||||
"//packages/core/analytics/core-analytics-browser:build",
|
||||
"//packages/core/analytics/core-analytics-browser-internal:build",
|
||||
|
@ -375,6 +376,7 @@ filegroup(
|
|||
"//packages/analytics/shippers/elastic_v3/server:build_types",
|
||||
"//packages/analytics/shippers/fullstory:build_types",
|
||||
"//packages/analytics/shippers/gainsight:build_types",
|
||||
"//packages/content-management/inspector: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",
|
||||
|
|
151
packages/content-management/inspector/BUILD.bazel
Normal file
151
packages/content-management/inspector/BUILD.bazel
Normal file
|
@ -0,0 +1,151 @@
|
|||
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 = "inspector"
|
||||
PKG_REQUIRE_NAME = "@kbn/content-management-inspector"
|
||||
|
||||
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/mount-utils/core-mount-utils-browser",
|
||||
"//packages/core/overlays/core-overlays-browser",
|
||||
"@npm//@elastic/eui",
|
||||
"@npm//@emotion/react",
|
||||
"@npm//react",
|
||||
"@npm//@emotion/css"
|
||||
]
|
||||
|
||||
# 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/kbn-ambient-storybook-types",
|
||||
"//packages/kbn-ambient-ui-types",
|
||||
"//packages/core/mount-utils/core-mount-utils-browser:npm_module_types",
|
||||
"//packages/core/overlays/core-overlays-browser:npm_module_types",
|
||||
"@npm//@types/node",
|
||||
"@npm//@types/jest",
|
||||
"@npm//@types/react",
|
||||
"@npm//@emotion/css",
|
||||
"@npm//@emotion/react",
|
||||
"@npm//@elastic/eui",
|
||||
"@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"],
|
||||
)
|
7
packages/content-management/inspector/README.md
Normal file
7
packages/content-management/inspector/README.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
# @kbn/content-management-inspector
|
||||
|
||||
# Content inspector component
|
||||
|
||||
## API
|
||||
|
||||
TODO: https://github.com/elastic/kibana/issues/144402
|
10
packages/content-management/inspector/index.ts
Normal file
10
packages/content-management/inspector/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 { InspectorProvider, InspectorKibanaProvider, useOpenInspector } from './src';
|
||||
export type { OpenInspectorParams } from './src';
|
13
packages/content-management/inspector/jest.config.js
Normal file
13
packages/content-management/inspector/jest.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../..',
|
||||
roots: ['<rootDir>/packages/content-management/inspector'],
|
||||
};
|
7
packages/content-management/inspector/kibana.jsonc
Normal file
7
packages/content-management/inspector/kibana.jsonc
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/content-management-inspector",
|
||||
"owner": "@elastic/shared-ux",
|
||||
"runtimeDeps": [],
|
||||
"typeDeps": [],
|
||||
}
|
8
packages/content-management/inspector/package.json
Normal file
8
packages/content-management/inspector/package.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@kbn/content-management-inspector",
|
||||
"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"
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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 { WithServices, getMockServices } from './tests.helpers';
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* 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 { TagSelector, TagList } from '../mocks';
|
||||
import { InspectorProvider } from '../services';
|
||||
import type { Services } from '../services';
|
||||
|
||||
export const getMockServices = (overrides?: Partial<Services>) => {
|
||||
const services = {
|
||||
openFlyout: jest.fn(() => ({
|
||||
onClose: Promise.resolve(),
|
||||
close: () => Promise.resolve(),
|
||||
})),
|
||||
TagList,
|
||||
TagSelector,
|
||||
notifyError: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return services;
|
||||
};
|
||||
|
||||
export function WithServices<P>(Comp: ComponentType<P>, overrides: Partial<Services> = {}) {
|
||||
return (props: P) => {
|
||||
const services = getMockServices(overrides);
|
||||
return (
|
||||
<InspectorProvider {...services}>
|
||||
<Comp {...props} />
|
||||
</InspectorProvider>
|
||||
);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 { InspectorLoader } from './inspector_loader';
|
||||
export type { Props as InspectorFlyoutContentContainerProps } from './inspector_flyout_content_container';
|
|
@ -0,0 +1,250 @@
|
|||
/*
|
||||
* 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 { act } from 'react-dom/test-utils';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { registerTestBed } from '@kbn/test-jest-helpers';
|
||||
import type { TestBed } from '@kbn/test-jest-helpers';
|
||||
import { getMockServices } from '../__jest__';
|
||||
import { InspectorFlyoutContent } from './inspector_flyout_content';
|
||||
import type { Props as InspectorFlyoutContentProps } from './inspector_flyout_content';
|
||||
|
||||
describe('<InspectorFlyoutContent />', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('metadata', () => {
|
||||
let testBed: TestBed;
|
||||
|
||||
const savedObjectItem: InspectorFlyoutContentProps['item'] = {
|
||||
id: '123',
|
||||
title: 'Foo',
|
||||
description: 'Some description',
|
||||
tags: [
|
||||
{ id: 'id-1', name: 'tag1', type: 'tag' },
|
||||
{ id: 'id-2', name: 'tag2', type: 'tag' },
|
||||
],
|
||||
};
|
||||
|
||||
const mockedServices = getMockServices();
|
||||
|
||||
const defaultProps: InspectorFlyoutContentProps = {
|
||||
item: savedObjectItem,
|
||||
entityName: 'foo',
|
||||
services: mockedServices,
|
||||
onCancel: jest.fn(),
|
||||
};
|
||||
|
||||
const setup = registerTestBed<string, InspectorFlyoutContentProps>(InspectorFlyoutContent, {
|
||||
memoryRouter: { wrapComponent: false },
|
||||
defaultProps,
|
||||
});
|
||||
|
||||
test('should set the correct flyout title', async () => {
|
||||
await act(async () => {
|
||||
testBed = await setup();
|
||||
});
|
||||
const { find } = testBed!;
|
||||
expect(find('flyoutTitle').text()).toBe('Inspector');
|
||||
});
|
||||
|
||||
test('should render the form with the provided item', async () => {
|
||||
await act(async () => {
|
||||
testBed = await setup();
|
||||
});
|
||||
const { find } = testBed!;
|
||||
|
||||
expect(find('metadataForm.nameInput').props().value).toBe(savedObjectItem.title);
|
||||
expect(find('metadataForm.descriptionInput').props().value).toBe(savedObjectItem.description);
|
||||
});
|
||||
|
||||
test('should be in readOnly mode by default', async () => {
|
||||
await act(async () => {
|
||||
testBed = await setup();
|
||||
});
|
||||
const { find, exists } = testBed!;
|
||||
|
||||
expect(find('metadataForm.nameInput').props().readOnly).toBe(true);
|
||||
expect(find('metadataForm.descriptionInput').props().readOnly).toBe(true);
|
||||
expect(exists('saveButton')).toBe(false);
|
||||
|
||||
// Show tag list and *not* the tag selector
|
||||
expect(exists('tagList')).toBe(true);
|
||||
expect(exists('tagSelector')).toBe(false);
|
||||
});
|
||||
|
||||
test('should display the "Update" button when not readOnly', async () => {
|
||||
await act(async () => {
|
||||
testBed = await setup({ isReadonly: false });
|
||||
});
|
||||
|
||||
const { find } = testBed!;
|
||||
|
||||
expect(find('saveButton').text()).toBe('Update foo');
|
||||
});
|
||||
|
||||
test('should send back the updated item to the onSave() handler', async () => {
|
||||
const onSave = jest.fn();
|
||||
|
||||
await act(async () => {
|
||||
testBed = await setup({ onSave, isReadonly: false });
|
||||
});
|
||||
|
||||
const {
|
||||
find,
|
||||
component,
|
||||
form: { setInputValue },
|
||||
} = testBed!;
|
||||
|
||||
await act(async () => {
|
||||
find('saveButton').simulate('click');
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
id: '123',
|
||||
title: 'Foo',
|
||||
description: 'Some description',
|
||||
tags: ['id-1', 'id-2'],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
setInputValue('metadataForm.nameInput', 'newTitle');
|
||||
setInputValue('metadataForm.descriptionInput', 'newDescription');
|
||||
});
|
||||
|
||||
component.update();
|
||||
|
||||
await act(async () => {
|
||||
find('saveButton').simulate('click');
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
id: '123',
|
||||
title: 'newTitle',
|
||||
description: 'newDescription',
|
||||
tags: ['id-1', 'id-2'],
|
||||
});
|
||||
});
|
||||
|
||||
test('should validate that the form is valid', async () => {
|
||||
const onSave = jest.fn();
|
||||
|
||||
await act(async () => {
|
||||
testBed = await setup({ onSave, isReadonly: false });
|
||||
});
|
||||
|
||||
const {
|
||||
find,
|
||||
component,
|
||||
form: { setInputValue, getErrorsMessages },
|
||||
} = testBed!;
|
||||
|
||||
await act(async () => {
|
||||
setInputValue('metadataForm.nameInput', ''); // empty is not allowed
|
||||
});
|
||||
|
||||
component.update();
|
||||
|
||||
await act(async () => {
|
||||
find('saveButton').simulate('click');
|
||||
});
|
||||
component.update();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500); // There is a 500ms delay to display input errors
|
||||
});
|
||||
component.update();
|
||||
|
||||
expect(getErrorsMessages()).toEqual(['A name is required.']);
|
||||
const errorCallout = component.find('.euiForm__errors').at(0);
|
||||
expect(errorCallout.text()).toContain('Please address the highlighted errors.');
|
||||
expect(errorCallout.text()).toContain('A name is required.');
|
||||
});
|
||||
|
||||
test('should notify saving errors', async () => {
|
||||
const notifyError = jest.fn();
|
||||
const onSave = async () => {
|
||||
throw new Error('Houston we got a problem');
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
testBed = await setup({ onSave, isReadonly: false, services: { notifyError } });
|
||||
});
|
||||
|
||||
const { find, component } = testBed!;
|
||||
|
||||
component.update();
|
||||
|
||||
await act(async () => {
|
||||
find('saveButton').simulate('click');
|
||||
});
|
||||
|
||||
expect(notifyError).toHaveBeenCalledWith(
|
||||
<FormattedMessage
|
||||
defaultMessage="Unable to save {entityName}"
|
||||
id="contentManagement.inspector.metadataForm.unableToSaveDangerMessage"
|
||||
values={{ entityName: 'foo' }}
|
||||
/>,
|
||||
'Houston we got a problem'
|
||||
);
|
||||
});
|
||||
|
||||
test('should update the tag selection', async () => {
|
||||
const onSave = jest.fn();
|
||||
|
||||
await act(async () => {
|
||||
testBed = await setup({ onSave, isReadonly: false });
|
||||
});
|
||||
const { find, component } = testBed!;
|
||||
|
||||
await act(async () => {
|
||||
find('tagSelector.tag-id-1').simulate('click');
|
||||
find('tagSelector.tag-id-2').simulate('click');
|
||||
});
|
||||
|
||||
component.update();
|
||||
|
||||
await act(async () => {
|
||||
find('saveButton').simulate('click');
|
||||
});
|
||||
|
||||
const lastArgs = onSave.mock.calls[onSave.mock.calls.length - 1][0];
|
||||
|
||||
expect(lastArgs).toEqual({
|
||||
id: '123',
|
||||
title: 'Foo',
|
||||
description: 'Some description',
|
||||
tags: [], // No more tags selected
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
find('tagSelector.tag-id-3').simulate('click');
|
||||
find('tagSelector.tag-id-4').simulate('click');
|
||||
});
|
||||
|
||||
component.update();
|
||||
|
||||
await act(async () => {
|
||||
find('saveButton').simulate('click');
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
id: '123',
|
||||
title: 'Foo',
|
||||
description: 'Some description',
|
||||
tags: ['id-3', 'id-4'], // New selection
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* 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, { useCallback, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import {
|
||||
EuiFlyoutHeader,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiTitle,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiIcon,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import type { Services } from '../services';
|
||||
import type { Item } from '../types';
|
||||
import { MetadataForm } from './metadata_form';
|
||||
import { useMetadataForm } from './use_metadata_form';
|
||||
|
||||
const getI18nTexts = ({ entityName }: { entityName: string }) => ({
|
||||
title: i18n.translate('contentManagement.inspector.flyoutTitle', {
|
||||
defaultMessage: 'Inspector',
|
||||
}),
|
||||
saveButtonLabel: i18n.translate('contentManagement.inspector.saveButtonLabel', {
|
||||
defaultMessage: 'Update {entityName}',
|
||||
values: {
|
||||
entityName,
|
||||
},
|
||||
}),
|
||||
cancelButtonLabel: i18n.translate('contentManagement.inspector.cancelButtonLabel', {
|
||||
defaultMessage: 'Cancel',
|
||||
}),
|
||||
});
|
||||
|
||||
export interface Props {
|
||||
item: Item;
|
||||
entityName: string;
|
||||
isReadonly?: boolean;
|
||||
services: Pick<Services, 'TagSelector' | 'TagList' | 'notifyError'>;
|
||||
onSave?: (args: {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
}) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const InspectorFlyoutContent: FC<Props> = ({
|
||||
item,
|
||||
entityName,
|
||||
isReadonly = true,
|
||||
services: { TagSelector, TagList, notifyError },
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const i18nTexts = getI18nTexts({ entityName });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const form = useMetadataForm({ item });
|
||||
|
||||
const onClickSave = useCallback(async () => {
|
||||
if (form.isValid) {
|
||||
if (onSave) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onSave({
|
||||
id: item.id,
|
||||
title: form.title.value,
|
||||
description: form.description.value,
|
||||
tags: form.tags.value,
|
||||
});
|
||||
} catch (error) {
|
||||
notifyError(
|
||||
<FormattedMessage
|
||||
id="contentManagement.inspector.metadataForm.unableToSaveDangerMessage"
|
||||
defaultMessage="Unable to save {entityName}"
|
||||
values={{ entityName }}
|
||||
/>,
|
||||
error.message
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitted(true);
|
||||
}, [form, onSave, item.id, notifyError, entityName]);
|
||||
|
||||
const onClickCancel = () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const iconCSS = css`
|
||||
margin-right: ${euiTheme.size.m};
|
||||
`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlyoutHeader>
|
||||
<EuiTitle data-test-subj="flyoutTitle">
|
||||
<h2>
|
||||
<EuiIcon type="inspect" css={iconCSS} size="l" />
|
||||
<span>{i18nTexts.title}</span>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
|
||||
<EuiFlyoutBody>
|
||||
<MetadataForm
|
||||
form={{ ...form, isSubmitted }}
|
||||
isReadonly={isReadonly}
|
||||
tagsReferences={item.tags}
|
||||
TagList={TagList}
|
||||
TagSelector={TagSelector}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>
|
||||
<>
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
iconType="cross"
|
||||
flush="left"
|
||||
onClick={onClickCancel}
|
||||
data-test-subj="closeFlyoutButton"
|
||||
>
|
||||
{i18nTexts.cancelButtonLabel}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
|
||||
{isReadonly === false && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
color="primary"
|
||||
onClick={onClickSave}
|
||||
data-test-subj="saveButton"
|
||||
fill
|
||||
disabled={isSubmitted && !form.isValid}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{i18nTexts.saveButtonLabel}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
</EuiFlyoutFooter>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 { FC } from 'react';
|
||||
|
||||
import { InspectorFlyoutContent } from './inspector_flyout_content';
|
||||
import type { Props as InspectorFlyoutContentProps } from './inspector_flyout_content';
|
||||
|
||||
type CommonProps = Pick<
|
||||
InspectorFlyoutContentProps,
|
||||
'item' | 'isReadonly' | 'services' | 'onSave' | 'onCancel' | 'entityName'
|
||||
>;
|
||||
|
||||
export type Props = CommonProps;
|
||||
|
||||
export const InspectorFlyoutContentContainer: FC<Props> = (props) => {
|
||||
return <InspectorFlyoutContent {...props} />;
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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, { useState, useCallback, useEffect } from 'react';
|
||||
import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui';
|
||||
|
||||
import type { Props } from './inspector_flyout_content_container';
|
||||
|
||||
export const InspectorLoader: React.FC<Props> = (props) => {
|
||||
const [Editor, setEditor] = useState<React.ComponentType<Props> | null>(null);
|
||||
|
||||
const loadEditor = useCallback(async () => {
|
||||
const { InspectorFlyoutContentContainer } = await import(
|
||||
'./inspector_flyout_content_container'
|
||||
);
|
||||
setEditor(() => InspectorFlyoutContentContainer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// On mount: load the editor asynchronously
|
||||
loadEditor();
|
||||
}, [loadEditor]);
|
||||
|
||||
return Editor ? (
|
||||
<Editor {...props} />
|
||||
) : (
|
||||
<>
|
||||
<EuiFlyoutHeader />
|
||||
<EuiFlyoutBody />
|
||||
<EuiFlyoutFooter />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* 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 { FC } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiForm, EuiFormRow, EuiFieldText, EuiTextArea, EuiSpacer } from '@elastic/eui';
|
||||
|
||||
import type { MetadataFormState } from './use_metadata_form';
|
||||
import type { SavedObjectsReference, Services } from '../services';
|
||||
|
||||
interface Props {
|
||||
form: MetadataFormState & {
|
||||
isSubmitted: boolean;
|
||||
};
|
||||
isReadonly: boolean;
|
||||
tagsReferences: SavedObjectsReference[];
|
||||
TagList?: Services['TagList'];
|
||||
TagSelector?: Services['TagSelector'];
|
||||
}
|
||||
|
||||
export const MetadataForm: FC<Props> = ({
|
||||
form,
|
||||
tagsReferences,
|
||||
TagList,
|
||||
TagSelector,
|
||||
isReadonly,
|
||||
}) => {
|
||||
const {
|
||||
title,
|
||||
setTitle,
|
||||
description,
|
||||
setDescription,
|
||||
tags,
|
||||
setTags,
|
||||
isSubmitted,
|
||||
isValid,
|
||||
getErrors,
|
||||
} = form;
|
||||
|
||||
return (
|
||||
<EuiForm
|
||||
isInvalid={isSubmitted && isValid === false}
|
||||
error={getErrors()}
|
||||
data-test-subj="metadataForm"
|
||||
>
|
||||
<EuiFormRow
|
||||
label={i18n.translate('contentManagement.inspector.metadataForm.nameInputLabel', {
|
||||
defaultMessage: 'Name',
|
||||
})}
|
||||
error={title.errorMessage}
|
||||
isInvalid={!title.isChangingValue && !title.isValid}
|
||||
fullWidth
|
||||
>
|
||||
<EuiFieldText
|
||||
isInvalid={!title.isChangingValue && !title.isValid}
|
||||
value={title.value}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
}}
|
||||
fullWidth
|
||||
data-test-subj="nameInput"
|
||||
readOnly={isReadonly}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
<EuiSpacer />
|
||||
|
||||
<EuiFormRow
|
||||
label={i18n.translate('contentManagement.inspector.metadataForm.descriptionInputLabel', {
|
||||
defaultMessage: 'Description',
|
||||
})}
|
||||
error={description.errorMessage}
|
||||
isInvalid={!description.isChangingValue && !description.isValid}
|
||||
fullWidth
|
||||
>
|
||||
<EuiTextArea
|
||||
isInvalid={!description.isChangingValue && !description.isValid}
|
||||
value={description.value}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
fullWidth
|
||||
data-test-subj="descriptionInput"
|
||||
readOnly={isReadonly}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
{TagList && isReadonly === true && (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
<EuiFormRow
|
||||
label={i18n.translate('contentManagement.inspector.metadataForm.tagsLabel', {
|
||||
defaultMessage: 'Tags',
|
||||
})}
|
||||
fullWidth
|
||||
>
|
||||
<TagList references={tagsReferences} />
|
||||
</EuiFormRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{TagSelector && isReadonly === false && (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
<TagSelector initialSelection={tags.value} onTagsSelected={setTags} fullWidth />
|
||||
</>
|
||||
)}
|
||||
</EuiForm>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* 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 { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import type { Item } from '../types';
|
||||
|
||||
interface Field<T = string> {
|
||||
value: T;
|
||||
isValid: boolean;
|
||||
isChangingValue: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface Fields {
|
||||
title: Field<string>;
|
||||
description: Field<string>;
|
||||
tags: Field<string[]>;
|
||||
}
|
||||
|
||||
const validators: { [key in keyof Fields]: ((value: unknown) => string | null) | null } = {
|
||||
title: (value) => {
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return i18n.translate('contentManagement.inspector.metadataForm.nameIsEmptyError', {
|
||||
defaultMessage: 'A name is required.',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
description: null,
|
||||
tags: null,
|
||||
};
|
||||
|
||||
type SetFieldValueFn<T = unknown> = (value: T) => void;
|
||||
type SetFieldValueGetter<T = unknown> = (fieldName: keyof Fields) => SetFieldValueFn<T>;
|
||||
|
||||
export const useMetadataForm = ({ item }: { item: Item }) => {
|
||||
const changingValueTimeout = useRef<{ [key in keyof Fields]?: NodeJS.Timeout | null }>({});
|
||||
const [fields, setFields] = useState<Fields>({
|
||||
title: { value: item.title, isValid: true, isChangingValue: false },
|
||||
description: { value: item.description ?? '', isValid: true, isChangingValue: false },
|
||||
tags: {
|
||||
value: item.tags ? item.tags.map(({ id }) => id) : [],
|
||||
isValid: true,
|
||||
isChangingValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
const setFieldValue = useCallback<SetFieldValueGetter>(
|
||||
(fieldName) => (value) => {
|
||||
const validator = validators[fieldName];
|
||||
let isValid = true;
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
if (validator) {
|
||||
errorMessage = validator(value);
|
||||
isValid = errorMessage === null;
|
||||
}
|
||||
|
||||
const timeoutId = changingValueTimeout.current[fieldName];
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
changingValueTimeout.current[fieldName] = null;
|
||||
|
||||
setFields((prev) => {
|
||||
const field = prev[fieldName];
|
||||
return {
|
||||
...prev,
|
||||
[fieldName]: {
|
||||
...field,
|
||||
isValid,
|
||||
isChangingValue: true,
|
||||
errorMessage,
|
||||
value,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// We add a 500s delay so possible errors of the field don't show up
|
||||
// _immediately_ as the user writes and we avoid flickering of error message.
|
||||
changingValueTimeout.current[fieldName] = setTimeout(() => {
|
||||
setFields((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[fieldName]: {
|
||||
...prev[fieldName],
|
||||
isChangingValue: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, 500);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const setTitle: SetFieldValueFn<string> = useMemo(() => setFieldValue('title'), [setFieldValue]);
|
||||
|
||||
const setDescription: SetFieldValueFn<string> = useMemo(
|
||||
() => setFieldValue('description'),
|
||||
[setFieldValue]
|
||||
);
|
||||
|
||||
const setTags: SetFieldValueFn<string[]> = useMemo(() => setFieldValue('tags'), [setFieldValue]);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
return Object.values(fields).every((field: Field) => field.isValid);
|
||||
}, [fields]);
|
||||
|
||||
const getErrors = useCallback(() => {
|
||||
return Object.values(fields)
|
||||
.map(({ errorMessage }: Field) => errorMessage)
|
||||
.filter(Boolean) as string[];
|
||||
}, [fields]);
|
||||
|
||||
const isValid = validate();
|
||||
|
||||
return {
|
||||
title: fields.title,
|
||||
setTitle,
|
||||
description: fields.description,
|
||||
setDescription,
|
||||
tags: fields.tags,
|
||||
setTags,
|
||||
isValid,
|
||||
getErrors,
|
||||
};
|
||||
};
|
||||
|
||||
export type MetadataFormState = ReturnType<typeof useMetadataForm>;
|
11
packages/content-management/inspector/src/index.ts
Normal file
11
packages/content-management/inspector/src/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { InspectorProvider, InspectorKibanaProvider } from './services';
|
||||
export { useOpenInspector } from './open_inspector';
|
||||
export type { OpenInspectorParams } from './open_inspector';
|
62
packages/content-management/inspector/src/mocks.tsx
Normal file
62
packages/content-management/inspector/src/mocks.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* 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, { useEffect, useState, useCallback } from 'react';
|
||||
import type { TagSelectorProps, SavedObjectsReference } from './services';
|
||||
|
||||
const tagsList = ['id-1', 'id-2', 'id-3', 'id-4', 'id-5'];
|
||||
|
||||
export const TagSelector = ({ initialSelection, onTagsSelected }: TagSelectorProps) => {
|
||||
const [selected, setSelected] = useState(initialSelection);
|
||||
|
||||
const onTagClick = useCallback((tagId: string) => {
|
||||
setSelected((prev) =>
|
||||
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onTagsSelected(selected);
|
||||
}, [selected, onTagsSelected]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul data-test-subj="tagSelector">
|
||||
{tagsList.map((tagId, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
data-test-subj={`tag-${tagId}`}
|
||||
onClick={() => {
|
||||
onTagClick(tagId);
|
||||
}}
|
||||
>
|
||||
{tagId}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TagListProps {
|
||||
references?: SavedObjectsReference[];
|
||||
}
|
||||
|
||||
export const TagList = ({ references }: TagListProps) => {
|
||||
if (!references) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul data-test-subj="tagList">
|
||||
{references.map((tag) => (
|
||||
<li key={tag.name}>{tag.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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 { registerTestBed } from '@kbn/test-jest-helpers';
|
||||
import { WithServices, getMockServices } from './__jest__';
|
||||
import type { Services } from './services';
|
||||
import { InspectorLoader } from './components';
|
||||
import { useOpenInspector } from './open_inspector';
|
||||
|
||||
describe('useOpenInspector() hook', () => {
|
||||
const savedObjectItem = { title: 'Foo', tags: [] };
|
||||
|
||||
const TestComp = () => {
|
||||
const openInspector = useOpenInspector();
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
openInspector({ item: savedObjectItem, entityName: 'Foo' });
|
||||
}}
|
||||
data-test-subj="openInspectorButton"
|
||||
>
|
||||
Open inspector
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const mockedServices = getMockServices();
|
||||
const openFlyout = mockedServices.openFlyout as jest.MockedFunction<Services['openFlyout']>;
|
||||
|
||||
const setup = registerTestBed(WithServices(TestComp, mockedServices), {
|
||||
memoryRouter: { wrapComponent: false },
|
||||
});
|
||||
|
||||
test('should call the "openFlyout" provided', () => {
|
||||
const { find } = setup();
|
||||
|
||||
find('openInspectorButton').simulate('click');
|
||||
|
||||
expect(openFlyout).toHaveBeenCalled();
|
||||
const args = openFlyout.mock.calls[0][0] as any;
|
||||
expect(args?.type).toBe(InspectorLoader);
|
||||
expect(args?.props.item).toBe(savedObjectItem);
|
||||
expect(args?.props.services).toEqual(mockedServices);
|
||||
});
|
||||
});
|
45
packages/content-management/inspector/src/open_inspector.tsx
Normal file
45
packages/content-management/inspector/src/open_inspector.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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, { useCallback, useRef } from 'react';
|
||||
import type { OverlayRef } from '@kbn/core-mount-utils-browser';
|
||||
|
||||
import { useServices } from './services';
|
||||
|
||||
import { InspectorLoader } from './components';
|
||||
import type { InspectorFlyoutContentContainerProps } from './components';
|
||||
|
||||
export type OpenInspectorParams = Pick<
|
||||
InspectorFlyoutContentContainerProps,
|
||||
'item' | 'onSave' | 'isReadonly' | 'entityName'
|
||||
>;
|
||||
|
||||
export function useOpenInspector() {
|
||||
const services = useServices();
|
||||
const { openFlyout } = services;
|
||||
const flyout = useRef<OverlayRef | null>(null);
|
||||
|
||||
return useCallback(
|
||||
(args: OpenInspectorParams) => {
|
||||
// Validate arguments
|
||||
if (args.isReadonly === false && args.onSave === undefined) {
|
||||
throw new Error(`A value for [onSave()] must be provided when [isReadonly] is false.`);
|
||||
}
|
||||
|
||||
flyout.current = openFlyout(
|
||||
<InspectorLoader {...args} onCancel={() => flyout.current?.close()} services={services} />,
|
||||
{
|
||||
maxWidth: 600,
|
||||
size: 'm',
|
||||
ownFocus: true,
|
||||
hideCloseButton: true,
|
||||
}
|
||||
);
|
||||
},
|
||||
[openFlyout, services]
|
||||
);
|
||||
}
|
153
packages/content-management/inspector/src/services.tsx
Normal file
153
packages/content-management/inspector/src/services.tsx
Normal file
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* 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, { useContext, useCallback, useMemo } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import type { Observable } from 'rxjs';
|
||||
import type { EuiComboBoxProps } from '@elastic/eui';
|
||||
import type { MountPoint, OverlayRef } from '@kbn/core-mount-utils-browser';
|
||||
import type { OverlayFlyoutOpenOptions } from '@kbn/core-overlays-browser';
|
||||
|
||||
type NotifyFn = (title: JSX.Element, text?: string) => void;
|
||||
|
||||
export type TagSelectorProps = EuiComboBoxProps<unknown> & {
|
||||
initialSelection: string[];
|
||||
onTagsSelected: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
export interface SavedObjectsReference {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract external services for this component.
|
||||
*/
|
||||
export interface Services {
|
||||
openFlyout(node: ReactNode, options?: OverlayFlyoutOpenOptions): OverlayRef;
|
||||
notifyError: NotifyFn;
|
||||
TagList?: FC<{ references: SavedObjectsReference[] }>;
|
||||
TagSelector?: React.FC<TagSelectorProps>;
|
||||
}
|
||||
|
||||
const InspectorContext = React.createContext<Services | null>(null);
|
||||
|
||||
/**
|
||||
* Abstract external service Provider.
|
||||
*/
|
||||
export const InspectorProvider: FC<Services> = ({ children, ...services }) => {
|
||||
return <InspectorContext.Provider value={services}>{children}</InspectorContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Kibana-specific service types.
|
||||
*/
|
||||
export interface InspectorKibanaDependencies {
|
||||
/** CoreStart contract */
|
||||
core: {
|
||||
overlays: {
|
||||
openFlyout(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef;
|
||||
};
|
||||
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: {
|
||||
components: {
|
||||
TagList: React.FC<{
|
||||
object: {
|
||||
references: SavedObjectsReference[];
|
||||
};
|
||||
onClick?: (tag: { name: string; description: string; color: string }) => void;
|
||||
}>;
|
||||
SavedObjectSaveModalTagSelector: React.FC<TagSelectorProps>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kibana-specific Provider that maps to known dependency types.
|
||||
*/
|
||||
export const InspectorKibanaProvider: FC<InspectorKibanaDependencies> = ({
|
||||
children,
|
||||
...services
|
||||
}) => {
|
||||
const { core, toMountPoint, savedObjectsTagging } = services;
|
||||
const { openFlyout: coreOpenFlyout } = core.overlays;
|
||||
|
||||
const TagList = useMemo(() => {
|
||||
const Comp: Services['TagList'] = ({ references }) => {
|
||||
if (!savedObjectsTagging?.ui.components.TagList) {
|
||||
return null;
|
||||
}
|
||||
const PluginTagList = savedObjectsTagging.ui.components.TagList;
|
||||
return <PluginTagList object={{ references }} />;
|
||||
};
|
||||
|
||||
return Comp;
|
||||
}, [savedObjectsTagging?.ui.components.TagList]);
|
||||
|
||||
const openFlyout = useCallback(
|
||||
(node: ReactNode, options: OverlayFlyoutOpenOptions) => {
|
||||
return coreOpenFlyout(toMountPoint(node), options);
|
||||
},
|
||||
[toMountPoint, coreOpenFlyout]
|
||||
);
|
||||
|
||||
return (
|
||||
<InspectorProvider
|
||||
openFlyout={openFlyout}
|
||||
notifyError={(title, text) => {
|
||||
core.notifications.toasts.addDanger({ title: toMountPoint(title), text });
|
||||
}}
|
||||
TagList={TagList}
|
||||
TagSelector={savedObjectsTagging?.ui.components.SavedObjectSaveModalTagSelector}
|
||||
>
|
||||
{children}
|
||||
</InspectorProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook for accessing pre-wired services.
|
||||
*/
|
||||
export function useServices() {
|
||||
const context = useContext(InspectorContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'InspectorContext is missing. Ensure your component or React root is wrapped with <InspectorProvider /> or <InspectorKibanaProvider />.'
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
16
packages/content-management/inspector/src/types.ts
Normal file
16
packages/content-management/inspector/src/types.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 { SavedObjectsReference } from './services';
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
tags: SavedObjectsReference[];
|
||||
}
|
22
packages/content-management/inspector/tsconfig.json
Normal file
22
packages/content-management/inspector/tsconfig.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"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",
|
||||
"@emotion/react/types/css-prop"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
]
|
||||
}
|
|
@ -49,6 +49,7 @@ NPM_MODULE_EXTRA_FILES = [
|
|||
RUNTIME_DEPS = [
|
||||
"//packages/kbn-i18n-react",
|
||||
"//packages/kbn-i18n",
|
||||
"//packages/content-management/inspector",
|
||||
"//packages/core/http/core-http-browser",
|
||||
"//packages/core/theme/core-theme-browser",
|
||||
"//packages/kbn-safer-lodash-set",
|
||||
|
@ -75,8 +76,11 @@ RUNTIME_DEPS = [
|
|||
TYPES_DEPS = [
|
||||
"//packages/kbn-i18n:npm_module_types",
|
||||
"//packages/kbn-i18n-react:npm_module_types",
|
||||
"//packages/content-management/inspector:npm_module_types",
|
||||
"//packages/core/http/core-http-browser:npm_module_types",
|
||||
"//packages/core/theme/core-theme-browser:npm_module_types",
|
||||
"//packages/core/mount-utils/core-mount-utils-browser:npm_module_types",
|
||||
"//packages/core/overlays/core-overlays-browser:npm_module_types",
|
||||
"//packages/kbn-ambient-storybook-types",
|
||||
"//packages/kbn-ambient-ui-types",
|
||||
"//packages/kbn-safer-lodash-set:npm_module_types",
|
||||
|
|
|
@ -13,7 +13,7 @@ The `<TableListView />` render a eui page to display a list of user content save
|
|||
|
||||
## API
|
||||
|
||||
TODO
|
||||
TODO: https://github.com/elastic/kibana/issues/144402
|
||||
|
||||
## EUI Promotion Status
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import React from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { from } from 'rxjs';
|
||||
import { InspectorProvider } from '@kbn/content-management-inspector';
|
||||
|
||||
import { TagList } from '../mocks';
|
||||
import { TableListViewProvider, Services } from '../services';
|
||||
|
@ -21,6 +22,7 @@ export const getMockServices = (overrides?: Partial<Services>) => {
|
|||
navigateToUrl: () => undefined,
|
||||
TagList,
|
||||
itemHasTags: () => true,
|
||||
getTagIdsFromReferences: () => [],
|
||||
...overrides,
|
||||
};
|
||||
|
||||
|
@ -31,9 +33,11 @@ export function WithServices<P>(Comp: ComponentType<P>, overrides: Partial<Servi
|
|||
return (props: P) => {
|
||||
const services = getMockServices(overrides);
|
||||
return (
|
||||
<TableListViewProvider {...services}>
|
||||
<Comp {...(props as any)} />
|
||||
</TableListViewProvider>
|
||||
<InspectorProvider openFlyout={jest.fn()} notifyError={() => undefined}>
|
||||
<TableListViewProvider {...services}>
|
||||
<Comp {...(props as any)} />
|
||||
</TableListViewProvider>
|
||||
</InspectorProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -83,6 +83,7 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) =>
|
|||
navigateToUrl: () => undefined,
|
||||
TagList,
|
||||
itemHasTags: () => true,
|
||||
getTagIdsFromReferences: () => [],
|
||||
...params,
|
||||
};
|
||||
|
||||
|
|
|
@ -10,10 +10,11 @@ import React, { FC, useContext, useMemo, useCallback } from 'react';
|
|||
import type { SearchFilterConfig } from '@elastic/eui';
|
||||
import type { Observable } from 'rxjs';
|
||||
import type { FormattedRelative } from '@kbn/i18n-react';
|
||||
import type { MountPoint, OverlayRef } from '@kbn/core-mount-utils-browser';
|
||||
import type { OverlayFlyoutOpenOptions } from '@kbn/core-overlays-browser';
|
||||
import { RedirectAppLinksKibanaProvider } from '@kbn/shared-ux-link-redirect-app';
|
||||
import { InspectorKibanaProvider } from '@kbn/content-management-inspector';
|
||||
|
||||
type UnmountCallback = () => void;
|
||||
type MountPoint = (element: HTMLElement) => UnmountCallback;
|
||||
type NotifyFn = (title: JSX.Element, text?: string) => void;
|
||||
|
||||
export interface SavedObjectsReference {
|
||||
|
@ -47,6 +48,7 @@ export interface Services {
|
|||
TagList: FC<{ references: SavedObjectsReference[]; onClick?: (tag: { name: string }) => void }>;
|
||||
/** Predicate function to indicate if the saved object references include tags */
|
||||
itemHasTags: (references: SavedObjectsReference[]) => boolean;
|
||||
getTagIdsFromReferences: (references: SavedObjectsReference[]) => string[];
|
||||
}
|
||||
|
||||
const TableListViewContext = React.createContext<Services | null>(null);
|
||||
|
@ -79,6 +81,9 @@ export interface TableListViewKibanaDependencies {
|
|||
addDanger: (notifyArgs: { title: MountPoint; text?: string }) => void;
|
||||
};
|
||||
};
|
||||
overlays: {
|
||||
openFlyout(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Handler from the '@kbn/kibana-react-plugin/public' Plugin
|
||||
|
@ -108,6 +113,10 @@ export interface TableListViewKibanaDependencies {
|
|||
};
|
||||
onClick?: (tag: { name: string; description: string; color: string }) => void;
|
||||
}>;
|
||||
SavedObjectSaveModalTagSelector: React.FC<{
|
||||
initialSelection: string[];
|
||||
onTagsSelected: (ids: string[]) => void;
|
||||
}>;
|
||||
};
|
||||
parseSearchQuery: (
|
||||
query: string,
|
||||
|
@ -170,39 +179,53 @@ export const TableListViewKibanaProvider: FC<TableListViewKibanaDependencies> =
|
|||
return Comp;
|
||||
}, [savedObjectsTagging?.ui.components.TagList]);
|
||||
|
||||
const itemHasTags = useCallback(
|
||||
const getTagIdsFromReferences = useCallback(
|
||||
(references: SavedObjectsReference[]) => {
|
||||
if (!savedObjectsTagging?.ui.getTagIdsFromReferences) {
|
||||
return false;
|
||||
return [];
|
||||
}
|
||||
|
||||
return savedObjectsTagging.ui.getTagIdsFromReferences(references).length > 0;
|
||||
return savedObjectsTagging.ui.getTagIdsFromReferences(references);
|
||||
},
|
||||
[savedObjectsTagging?.ui]
|
||||
);
|
||||
|
||||
const itemHasTags = useCallback(
|
||||
(references: SavedObjectsReference[]) => {
|
||||
return getTagIdsFromReferences(references).length > 0;
|
||||
},
|
||||
[getTagIdsFromReferences]
|
||||
);
|
||||
|
||||
return (
|
||||
<RedirectAppLinksKibanaProvider coreStart={core}>
|
||||
<TableListViewProvider
|
||||
canEditAdvancedSettings={Boolean(core.application.capabilities.advancedSettings?.save)}
|
||||
getListingLimitSettingsUrl={() =>
|
||||
core.application.getUrlForApp('management', {
|
||||
path: `/kibana/settings?query=savedObjects:listingLimit`,
|
||||
})
|
||||
}
|
||||
notifyError={(title, text) => {
|
||||
core.notifications.toasts.addDanger({ title: toMountPoint(title), text });
|
||||
}}
|
||||
getSearchBarFilters={getSearchBarFilters}
|
||||
searchQueryParser={searchQueryParser}
|
||||
DateFormatterComp={(props) => <FormattedRelative {...props} />}
|
||||
currentAppId$={core.application.currentAppId$}
|
||||
navigateToUrl={core.application.navigateToUrl}
|
||||
TagList={TagList}
|
||||
itemHasTags={itemHasTags}
|
||||
<InspectorKibanaProvider
|
||||
core={core}
|
||||
toMountPoint={toMountPoint}
|
||||
savedObjectsTagging={savedObjectsTagging}
|
||||
>
|
||||
{children}
|
||||
</TableListViewProvider>
|
||||
<TableListViewProvider
|
||||
canEditAdvancedSettings={Boolean(core.application.capabilities.advancedSettings?.save)}
|
||||
getListingLimitSettingsUrl={() =>
|
||||
core.application.getUrlForApp('management', {
|
||||
path: `/kibana/settings?query=savedObjects:listingLimit`,
|
||||
})
|
||||
}
|
||||
notifyError={(title, text) => {
|
||||
core.notifications.toasts.addDanger({ title: toMountPoint(title), text });
|
||||
}}
|
||||
getSearchBarFilters={getSearchBarFilters}
|
||||
searchQueryParser={searchQueryParser}
|
||||
DateFormatterComp={(props) => <FormattedRelative {...props} />}
|
||||
currentAppId$={core.application.currentAppId$}
|
||||
navigateToUrl={core.application.navigateToUrl}
|
||||
TagList={TagList}
|
||||
itemHasTags={itemHasTags}
|
||||
getTagIdsFromReferences={getTagIdsFromReferences}
|
||||
>
|
||||
{children}
|
||||
</TableListViewProvider>
|
||||
</InspectorKibanaProvider>
|
||||
</RedirectAppLinksKibanaProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -506,4 +506,51 @@ describe('TableListView', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspector', () => {
|
||||
const setupInspector = registerTestBed<string, TableListViewProps>(
|
||||
WithServices<TableListViewProps>(TableListView),
|
||||
{
|
||||
defaultProps: { ...requiredProps },
|
||||
memoryRouter: { wrapComponent: false },
|
||||
}
|
||||
);
|
||||
|
||||
const hits = [
|
||||
{
|
||||
id: '123',
|
||||
updatedAt: new Date(new Date().setDate(new Date().getDate() - 1)),
|
||||
attributes: {
|
||||
title: 'Item 1',
|
||||
description: 'Item 1 description',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
updatedAt: new Date(new Date().setDate(new Date().getDate() - 2)),
|
||||
attributes: {
|
||||
title: 'Item 2',
|
||||
description: 'Item 2 description',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
test('should have an "inpect" button if the inspector is enabled', async () => {
|
||||
let testBed: TestBed;
|
||||
|
||||
await act(async () => {
|
||||
testBed = await setupInspector({
|
||||
findItems: jest.fn().mockResolvedValue({ total: hits.length, hits }),
|
||||
inspector: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
const { component, table } = testBed!;
|
||||
component.update();
|
||||
|
||||
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
|
||||
expect(tableCellsValues[0][2]).toBe('Inspect Item 1');
|
||||
expect(tableCellsValues[1][2]).toBe('Inspect Item 2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -24,6 +24,8 @@ 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 { useOpenInspector } from '@kbn/content-management-inspector';
|
||||
import type { OpenInspectorParams } from '@kbn/content-management-inspector';
|
||||
|
||||
import {
|
||||
Table,
|
||||
|
@ -38,6 +40,10 @@ import type { Action } from './actions';
|
|||
import { getReducer } from './reducer';
|
||||
import type { SortColumnField } from './components';
|
||||
|
||||
interface InspectorConfig extends Pick<OpenInspectorParams, 'isReadonly' | 'onSave'> {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Props<T extends UserContentCommonSchema = UserContentCommonSchema> {
|
||||
entityName: string;
|
||||
entityNamePlural: string;
|
||||
|
@ -67,6 +73,7 @@ export interface Props<T extends UserContentCommonSchema = UserContentCommonSche
|
|||
createItem?(): void;
|
||||
deleteItems?(items: T[]): Promise<void>;
|
||||
editItem?(item: T): void;
|
||||
inspector?: InspectorConfig;
|
||||
}
|
||||
|
||||
export interface State<T extends UserContentCommonSchema = UserContentCommonSchema> {
|
||||
|
@ -115,6 +122,7 @@ function TableListViewComp<T extends UserContentCommonSchema>({
|
|||
getDetailViewLink,
|
||||
onClickTitle,
|
||||
id = 'userContent',
|
||||
inspector = { enabled: false },
|
||||
children,
|
||||
}: Props<T>) {
|
||||
if (!getDetailViewLink && !onClickTitle) {
|
||||
|
@ -129,17 +137,26 @@ function TableListViewComp<T extends UserContentCommonSchema>({
|
|||
);
|
||||
}
|
||||
|
||||
if (inspector.isReadonly === false && inspector.onSave === undefined) {
|
||||
throw new Error(
|
||||
`[TableListView] A value for [inspector.onSave()] must be provided when [inspector.isReadonly] is false.`
|
||||
);
|
||||
}
|
||||
|
||||
const isMounted = useRef(false);
|
||||
const fetchIdx = useRef(0);
|
||||
|
||||
const {
|
||||
canEditAdvancedSettings,
|
||||
getListingLimitSettingsUrl,
|
||||
getTagIdsFromReferences,
|
||||
searchQueryParser,
|
||||
notifyError,
|
||||
DateFormatterComp,
|
||||
} = useServices();
|
||||
|
||||
const openInspector = useOpenInspector();
|
||||
|
||||
const reducer = useMemo(() => {
|
||||
return getReducer<T>();
|
||||
}, []);
|
||||
|
@ -185,6 +202,26 @@ function TableListViewComp<T extends UserContentCommonSchema>({
|
|||
const showFetchError = Boolean(fetchError);
|
||||
const showLimitError = !showFetchError && totalItems > listingLimit;
|
||||
|
||||
const inspectItem = useCallback(
|
||||
(item: T) => {
|
||||
const tags = getTagIdsFromReferences(item.references).map((_id) => {
|
||||
return item.references.find(({ id: refId }) => refId === _id) as SavedObjectsReference;
|
||||
});
|
||||
|
||||
openInspector({
|
||||
item: {
|
||||
id: item.id,
|
||||
title: item.attributes.title,
|
||||
description: item.attributes.description,
|
||||
tags,
|
||||
},
|
||||
entityName,
|
||||
...inspector,
|
||||
});
|
||||
},
|
||||
[openInspector, inspector, getTagIdsFromReferences, entityName]
|
||||
);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
const columns: Array<EuiBasicTableColumn<T>> = [
|
||||
{
|
||||
|
@ -226,9 +263,11 @@ function TableListViewComp<T extends UserContentCommonSchema>({
|
|||
}
|
||||
|
||||
// Add "Actions" column
|
||||
if (editItem) {
|
||||
const actions: EuiTableActionsColumnType<T>['actions'] = [
|
||||
{
|
||||
if (editItem || inspector.enabled !== false) {
|
||||
const actions: EuiTableActionsColumnType<T>['actions'] = [];
|
||||
|
||||
if (editItem) {
|
||||
actions.push({
|
||||
name: (item) => {
|
||||
return i18n.translate('contentManagement.tableList.listing.table.editActionName', {
|
||||
defaultMessage: 'Edit {itemDescription}',
|
||||
|
@ -247,8 +286,30 @@ function TableListViewComp<T extends UserContentCommonSchema>({
|
|||
type: 'icon',
|
||||
enabled: (v) => !(v as unknown as { error: string })?.error,
|
||||
onClick: editItem,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (inspector.enabled !== false) {
|
||||
actions.push({
|
||||
name: (item) => {
|
||||
return i18n.translate('contentManagement.tableList.listing.table.inspectActionName', {
|
||||
defaultMessage: 'Inspect {itemDescription}',
|
||||
values: {
|
||||
itemDescription: get(item, 'attributes.title'),
|
||||
},
|
||||
});
|
||||
},
|
||||
description: i18n.translate(
|
||||
'contentManagement.tableList.listing.table.inspectActionDescription',
|
||||
{
|
||||
defaultMessage: 'Inspect',
|
||||
}
|
||||
),
|
||||
icon: 'inspect',
|
||||
type: 'icon',
|
||||
onClick: inspectItem,
|
||||
});
|
||||
}
|
||||
|
||||
columns.push({
|
||||
name: i18n.translate('contentManagement.tableList.listing.table.actionTitle', {
|
||||
|
@ -269,6 +330,8 @@ function TableListViewComp<T extends UserContentCommonSchema>({
|
|||
onClickTitle,
|
||||
searchQuery,
|
||||
DateFormatterComp,
|
||||
inspector,
|
||||
inspectItem,
|
||||
]);
|
||||
|
||||
const itemsById = useMemo(() => {
|
||||
|
|
|
@ -9,9 +9,11 @@
|
|||
import React, { ComponentType } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
export const WithStore = (store: any) => (WrappedComponent: ComponentType) => (props: any) =>
|
||||
(
|
||||
<Provider store={store}>
|
||||
<WrappedComponent {...props} />
|
||||
</Provider>
|
||||
);
|
||||
export function WithStore<T extends object = Record<string, any>>(store: any) {
|
||||
return (WrappedComponent: ComponentType<T>) => (props: any) =>
|
||||
(
|
||||
<Provider store={store}>
|
||||
<WrappedComponent {...props} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -40,12 +40,11 @@ export const WithMemoryRouter =
|
|||
</MemoryRouter>
|
||||
);
|
||||
|
||||
export const WithRoute =
|
||||
(
|
||||
componentRoutePath: LocationDescriptor | LocationDescriptor[] = ['/'],
|
||||
onRouter = (router: any) => {}
|
||||
) =>
|
||||
(WrappedComponent: ComponentType) => {
|
||||
export function WithRoute<T extends object = Record<string, any>>(
|
||||
componentRoutePath: LocationDescriptor | LocationDescriptor[] = ['/'],
|
||||
onRouter = (router: any) => {}
|
||||
) {
|
||||
return (WrappedComponent: ComponentType<T>) => {
|
||||
// Create a class component that will catch the router
|
||||
// and forward it to our "onRouter()" handler.
|
||||
const CatchRouter = withRouter(
|
||||
|
@ -57,7 +56,7 @@ export const WithRoute =
|
|||
}
|
||||
|
||||
render() {
|
||||
return <WrappedComponent {...this.props} />;
|
||||
return <WrappedComponent {...(this.props as any)} />;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -69,6 +68,7 @@ export const WithRoute =
|
|||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
interface Router {
|
||||
history: Partial<History>;
|
||||
|
|
|
@ -16,18 +16,23 @@ import { WithMemoryRouter, WithRoute } from '../router_helpers';
|
|||
import { WithStore } from '../redux_helpers';
|
||||
import { MemoryRouterConfig } from './types';
|
||||
|
||||
interface Config {
|
||||
Component: ComponentType;
|
||||
interface Config<T extends object = Record<string, any>> {
|
||||
Component: ComponentType<T>;
|
||||
memoryRouter: MemoryRouterConfig;
|
||||
store: Store | null;
|
||||
props: any;
|
||||
props: T;
|
||||
onRouter: (router: any) => void;
|
||||
}
|
||||
|
||||
const getCompFromConfig = ({ Component, memoryRouter, store, onRouter }: Config): ComponentType => {
|
||||
function getCompFromConfig<T extends object = Record<string, any>>({
|
||||
Component,
|
||||
memoryRouter,
|
||||
store,
|
||||
onRouter,
|
||||
}: Config<T>): ComponentType<T> {
|
||||
const wrapWithRouter = memoryRouter.wrapComponent !== false;
|
||||
|
||||
let Comp: ComponentType = store !== null ? WithStore(store)(Component) : Component;
|
||||
let Comp: ComponentType<T> = store !== null ? WithStore<T>(store)(Component) : Component;
|
||||
|
||||
if (wrapWithRouter) {
|
||||
const { componentRoutePath, initialEntries, initialIndex } = memoryRouter!;
|
||||
|
@ -36,18 +41,22 @@ const getCompFromConfig = ({ Component, memoryRouter, store, onRouter }: Config)
|
|||
Comp = WithMemoryRouter(
|
||||
initialEntries,
|
||||
initialIndex
|
||||
)(WithRoute(componentRoutePath, onRouter)(Comp));
|
||||
)(WithRoute<T>(componentRoutePath, onRouter)(Comp));
|
||||
}
|
||||
|
||||
return Comp;
|
||||
};
|
||||
}
|
||||
|
||||
export const mountComponentSync = (config: Config): ReactWrapper => {
|
||||
const Comp = getCompFromConfig(config);
|
||||
export function mountComponentSync<T extends object = Record<string, any>>(
|
||||
config: Config<T>
|
||||
): ReactWrapper {
|
||||
const Comp = getCompFromConfig<T>(config);
|
||||
return mountWithIntl(<Comp {...config.props} />);
|
||||
};
|
||||
}
|
||||
|
||||
export const mountComponentAsync = async (config: Config): Promise<ReactWrapper> => {
|
||||
export async function mountComponentAsync<T extends object = Record<string, any>>(
|
||||
config: Config<T>
|
||||
): Promise<ReactWrapper> {
|
||||
const Comp = getCompFromConfig(config);
|
||||
|
||||
let component: ReactWrapper;
|
||||
|
@ -56,10 +65,12 @@ export const mountComponentAsync = async (config: Config): Promise<ReactWrapper>
|
|||
component = mountWithIntl(<Comp {...config.props} />);
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
return component.update();
|
||||
};
|
||||
return component!.update();
|
||||
}
|
||||
|
||||
export const getJSXComponentWithProps = (Component: ComponentType, props: any) => (
|
||||
<Component {...props} />
|
||||
);
|
||||
export function getJSXComponentWithProps<T extends object = Record<string, any>>(
|
||||
Component: ComponentType<T>,
|
||||
props: T
|
||||
) {
|
||||
return <Component {...props} />;
|
||||
}
|
||||
|
|
|
@ -56,16 +56,16 @@ const defaultConfig: TestBedConfig = {
|
|||
```
|
||||
*/
|
||||
export function registerTestBed<T extends string = string, P extends object = any>(
|
||||
Component: ComponentType<any>,
|
||||
config: AsyncTestBedConfig
|
||||
Component: ComponentType<P>,
|
||||
config: AsyncTestBedConfig<P>
|
||||
): AsyncSetupFunc<T, Partial<P>>;
|
||||
export function registerTestBed<T extends string = string, P extends object = any>(
|
||||
Component: ComponentType<any>,
|
||||
config?: TestBedConfig
|
||||
Component: ComponentType<P>,
|
||||
config?: TestBedConfig<P>
|
||||
): SyncSetupFunc<T, Partial<P>>;
|
||||
export function registerTestBed<T extends string = string, P extends object = any>(
|
||||
Component: ComponentType<any>,
|
||||
config?: AsyncTestBedConfig | TestBedConfig
|
||||
Component: ComponentType<P>,
|
||||
config?: AsyncTestBedConfig<P> | TestBedConfig<P>
|
||||
): SetupFunc<T, Partial<P>> {
|
||||
const {
|
||||
defaultProps = defaultConfig.defaultProps,
|
||||
|
|
|
@ -133,21 +133,23 @@ export interface TestBed<T = string> {
|
|||
};
|
||||
}
|
||||
|
||||
export interface BaseTestBedConfig {
|
||||
export interface BaseTestBedConfig<T extends object = Record<string, any>> {
|
||||
/** The default props to pass to the mounted component. */
|
||||
defaultProps?: Record<string, any>;
|
||||
defaultProps?: Partial<T>;
|
||||
/** Configuration object for the react-router `MemoryRouter. */
|
||||
memoryRouter?: MemoryRouterConfig;
|
||||
/** An optional redux store. You can also provide a function that returns a store. */
|
||||
store?: (() => Store) | Store | null;
|
||||
}
|
||||
|
||||
export interface AsyncTestBedConfig extends BaseTestBedConfig {
|
||||
export interface AsyncTestBedConfig<T extends object = Record<string, any>>
|
||||
extends BaseTestBedConfig<T> {
|
||||
/* Mount the component asynchronously. When using "hooked" components with _useEffect()_ calls, you need to set this to "true". */
|
||||
doMountAsync: true;
|
||||
}
|
||||
|
||||
export interface TestBedConfig extends BaseTestBedConfig {
|
||||
export interface TestBedConfig<T extends object = Record<string, any>>
|
||||
extends BaseTestBedConfig<T> {
|
||||
/* Mount the component asynchronously. When using "hooked" components with _useEffect()_ calls, you need to set this to "true". */
|
||||
doMountAsync?: false;
|
||||
}
|
||||
|
|
|
@ -67,6 +67,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
|
|||
data: dataStart,
|
||||
embeddable,
|
||||
notifications,
|
||||
overlays,
|
||||
savedObjectsTagging,
|
||||
settings: { uiSettings },
|
||||
} = pluginServices.getServices();
|
||||
|
@ -171,6 +172,7 @@ export async function mountApp({ core, element, appUnMounted, mountContext }: Da
|
|||
core: {
|
||||
application: application as TableListViewApplicationService,
|
||||
notifications,
|
||||
overlays,
|
||||
},
|
||||
toMountPoint,
|
||||
savedObjectsTagging: savedObjectsTagging.hasApi // TODO: clean up this logic once https://github.com/elastic/kibana/issues/140433 is resolved
|
||||
|
|
|
@ -32,7 +32,8 @@ function mountWith({ props: incomingProps }: { props?: DashboardListingProps })
|
|||
const wrappingComponent: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const { application, notifications, savedObjectsTagging } = pluginServices.getServices();
|
||||
const { application, notifications, savedObjectsTagging, overlays } =
|
||||
pluginServices.getServices();
|
||||
|
||||
return (
|
||||
<I18nProvider>
|
||||
|
@ -41,6 +42,7 @@ function mountWith({ props: incomingProps }: { props?: DashboardListingProps })
|
|||
application:
|
||||
application as unknown as TableListViewKibanaDependencies['core']['application'],
|
||||
notifications,
|
||||
overlays,
|
||||
}}
|
||||
savedObjectsTagging={
|
||||
{
|
||||
|
|
|
@ -175,7 +175,6 @@ describe('<UseField />', () => {
|
|||
typeof value === 'string' ? value : JSON.stringify(value);
|
||||
|
||||
const setup = registerTestBed(TestComp, {
|
||||
defaultProps: { onStateChangeSpy },
|
||||
memoryRouter: { wrapComponent: false },
|
||||
});
|
||||
|
||||
|
|
|
@ -37,7 +37,6 @@ describe('UploadFile', () => {
|
|||
));
|
||||
|
||||
const testBed = await createTestBed({
|
||||
client,
|
||||
kind: 'test',
|
||||
onDone,
|
||||
onError,
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { SearchFilterConfig, EuiTableFieldDataColumnType } from '@elastic/eui';
|
||||
import { SearchFilterConfig, EuiTableFieldDataColumnType, EuiComboBoxProps } from '@elastic/eui';
|
||||
import type { FunctionComponent } from 'react';
|
||||
import { SavedObject, SavedObjectReference } from '@kbn/core/types';
|
||||
import { SavedObjectsFindOptionsReference } from '@kbn/core/public';
|
||||
|
@ -245,7 +245,12 @@ export interface TagSelectorComponentProps {
|
|||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectSaveModalTagSelectorComponentProps {
|
||||
export type SavedObjectSaveModalTagSelectorComponentProps = EuiComboBoxProps<
|
||||
| Tag
|
||||
| {
|
||||
type: '__create_option__';
|
||||
}
|
||||
> & {
|
||||
/**
|
||||
* Ids of the initially selected tags.
|
||||
* Changing the value of this prop after initial mount will not rerender the component (see component description for more details)
|
||||
|
@ -255,7 +260,7 @@ export interface SavedObjectSaveModalTagSelectorComponentProps {
|
|||
* tags selection callback
|
||||
*/
|
||||
onTagsSelected: (ids: string[]) => void;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for the {@link SavedObjectsTaggingApiUi.getTableColumnDefinition | getTableColumnDefinition api}
|
||||
|
|
|
@ -18,6 +18,8 @@
|
|||
"@kbn/analytics-shippers-fullstory/*": ["packages/analytics/shippers/fullstory/*"],
|
||||
"@kbn/analytics-shippers-gainsight": ["packages/analytics/shippers/gainsight"],
|
||||
"@kbn/analytics-shippers-gainsight/*": ["packages/analytics/shippers/gainsight/*"],
|
||||
"@kbn/content-management-inspector": ["packages/content-management/inspector"],
|
||||
"@kbn/content-management-inspector/*": ["packages/content-management/inspector/*"],
|
||||
"@kbn/content-management-table-list": ["packages/content-management/table_list"],
|
||||
"@kbn/content-management-table-list/*": ["packages/content-management/table_list/*"],
|
||||
"@kbn/core-analytics-browser": ["packages/core/analytics/core-analytics-browser"],
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
EuiIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiComboBoxProps,
|
||||
} from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { Tag } from '../../../common';
|
||||
|
@ -42,14 +43,14 @@ function isCreateOption(
|
|||
return value.type === '__create_option__';
|
||||
}
|
||||
|
||||
export interface TagSelectorProps {
|
||||
export type TagSelectorProps = EuiComboBoxProps<Tag | CreateOption> & {
|
||||
tags: Tag[];
|
||||
selected: string[];
|
||||
onTagsSelected: (ids: string[]) => void;
|
||||
'data-test-subj'?: string;
|
||||
allowCreate: boolean;
|
||||
openCreateModal: CreateModalOpener;
|
||||
}
|
||||
};
|
||||
|
||||
const renderCreateOption = () => {
|
||||
return (
|
||||
|
@ -166,7 +167,7 @@ export const TagSelector: FC<TagSelectorProps> = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
<EuiComboBox<Tag | CreateOption>
|
||||
placeholder={''}
|
||||
options={options}
|
||||
selectedOptions={selectedOptions}
|
||||
|
|
|
@ -29,6 +29,7 @@ export const getConnectedSavedObjectModalTagSelectorComponent = ({
|
|||
return ({
|
||||
initialSelection,
|
||||
onTagsSelected: notifySelectionChange,
|
||||
...rest
|
||||
}: SavedObjectSaveModalTagSelectorComponentProps) => {
|
||||
const tags = useObservable(cache.getState$(), cache.getState());
|
||||
const [selected, setSelected] = useState<string[]>(initialSelection);
|
||||
|
@ -58,6 +59,7 @@ export const getConnectedSavedObjectModalTagSelectorComponent = ({
|
|||
data-test-subj="savedObjectTagSelector"
|
||||
allowCreate={capabilities.create}
|
||||
openCreateModal={openCreateModal}
|
||||
{...rest}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
|
|
|
@ -2761,6 +2761,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/content-management-inspector@link:bazel-bin/packages/content-management/inspector":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/content-management-table-list@link:bazel-bin/packages/content-management/table_list":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue