[ContentManagement] Inspector flyout (#144240)

This commit is contained in:
Sébastien Loix 2022-11-09 13:01:53 +00:00 committed by GitHub
parent 1ff9ceba69
commit 2590173096
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1602 additions and 82 deletions

1
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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",

View file

@ -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",

View 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"],
)

View file

@ -0,0 +1,7 @@
# @kbn/content-management-inspector
# Content inspector component
## API
TODO: https://github.com/elastic/kibana/issues/144402

View 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';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/packages/content-management/inspector'],
};

View file

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

View 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"
}

View file

@ -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';

View file

@ -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>
);
};
}

View 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 { InspectorLoader } from './inspector_loader';
export type { Props as InspectorFlyoutContentContainerProps } from './inspector_flyout_content_container';

View file

@ -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
});
});
});
});

View file

@ -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>
</>
);
};

View file

@ -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} />;
};

View file

@ -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 />
</>
);
};

View file

@ -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>
);
};

View file

@ -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>;

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { InspectorProvider, InspectorKibanaProvider } from './services';
export { useOpenInspector } from './open_inspector';
export type { OpenInspectorParams } from './open_inspector';

View 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>
);
};

View file

@ -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);
});
});

View 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]
);
}

View 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;
}

View 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[];
}

View 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",
]
}

View file

@ -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",

View file

@ -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

View file

@ -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>
);
};
}

View file

@ -83,6 +83,7 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) =>
navigateToUrl: () => undefined,
TagList,
itemHasTags: () => true,
getTagIdsFromReferences: () => [],
...params,
};

View file

@ -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>
);
};

View file

@ -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');
});
});
});

View file

@ -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(() => {

View file

@ -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>
);
}

View file

@ -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>;

View file

@ -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} />;
}

View file

@ -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,

View file

@ -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;
}

View file

@ -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

View file

@ -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={
{

View file

@ -175,7 +175,6 @@ describe('<UseField />', () => {
typeof value === 'string' ? value : JSON.stringify(value);
const setup = registerTestBed(TestComp, {
defaultProps: { onStateChangeSpy },
memoryRouter: { wrapComponent: false },
});

View file

@ -37,7 +37,6 @@ describe('UploadFile', () => {
));
const testBed = await createTestBed({
client,
kind: 'test',
onDone,
onError,

View file

@ -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}

View file

@ -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"],

View file

@ -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}

View file

@ -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>
);

View file

@ -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 ""