Make Tags shareable across Spaces

This commit is contained in:
Nick Peihl 2023-11-08 11:48:01 -05:00
parent 707ece79fc
commit d71ced9edf
12 changed files with 155 additions and 18 deletions

View file

@ -8,6 +8,7 @@
export interface Tag {
id: string;
namespaces?: string[];
name: string;
description: string;
color: string;

View file

@ -18,6 +18,7 @@ export interface TagsCapabilities {
delete: boolean;
assign: boolean;
viewConnections: boolean;
shareIntoSpace: boolean;
}
export const getTagsCapabilities = (capabilities: Capabilities): TagsCapabilities => {
@ -29,5 +30,6 @@ export const getTagsCapabilities = (capabilities: Capabilities): TagsCapabilitie
delete: (rawTagCapabilities?.delete as boolean) ?? false,
assign: (rawTagCapabilities?.assign as boolean) ?? false,
viewConnections: (capabilities.savedObjectsManagement?.read as boolean) ?? false,
shareIntoSpace: (capabilities.savedObjectsManagement?.shareIntoSpace as boolean) ?? false,
};
};

View file

@ -17,7 +17,8 @@
],
"optionalPlugins": [
"usageCollection",
"security"
"security",
"spaces"
],
"requiredBundles": [
"kibanaReact"

View file

@ -0,0 +1,70 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useState } from 'react';
import { i18n } from '@kbn/i18n';
import type { SpacesPluginStart, ShareToSpaceFlyoutProps } from '@kbn/spaces-plugin/public';
import { tagSavedObjectTypeName } from '../../../common/constants';
interface Props {
spacesApi: SpacesPluginStart;
canShareIntoSpace: boolean;
spaceIds: string[];
id: string;
title: string;
refresh(): void;
}
const noun = i18n.translate('indexPatternManagement.indexPatternTable.savedObjectName', {
defaultMessage: 'data view',
});
export const SpacesList: FC<Props> = ({
spacesApi,
canShareIntoSpace,
spaceIds,
id,
title,
refresh,
}) => {
const [showFlyout, setShowFlyout] = useState(false);
function onClose() {
setShowFlyout(false);
}
const LazySpaceList = spacesApi.ui.components.getSpaceList;
const LazyShareToSpaceFlyout = spacesApi.ui.components.getShareToSpaceFlyout;
const shareToSpaceFlyoutProps: ShareToSpaceFlyoutProps = {
savedObjectTarget: {
type: tagSavedObjectTypeName,
namespaces: spaceIds,
id,
title,
noun,
},
onUpdate: refresh,
onClose,
};
const clickProperties = canShareIntoSpace
? { cursorStyle: 'pointer', listOnClick: () => setShowFlyout(true) }
: { cursorStyle: 'not-allowed' };
return (
<>
<LazySpaceList
namespaces={spaceIds}
displayLimit={8}
behaviorContext="outside-space"
{...clickProperties}
/>
{showFlyout && <LazyShareToSpaceFlyout {...shareToSpaceFlyoutProps} />}
</>
);
};

View file

@ -9,9 +9,11 @@ import React, { useRef, useEffect, FC, ReactNode } from 'react';
import { EuiInMemoryTable, EuiBasicTableColumn, EuiLink, Query } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { SpacesApi } from '@kbn/spaces-plugin/public';
import { TagsCapabilities, TagWithRelations } from '../../../common';
import { TagBadge } from '../../components';
import { TagAction } from '../actions';
import { SpacesList } from './spaces_list';
interface TagTableProps {
loading: boolean;
@ -19,6 +21,7 @@ interface TagTableProps {
tags: TagWithRelations[];
initialQuery?: Query;
allowSelection: boolean;
reloadTags: () => void;
onQueryChange: (query?: Query) => void;
selectedTags: TagWithRelations[];
onSelectionChange: (selection: TagWithRelations[]) => void;
@ -26,6 +29,7 @@ interface TagTableProps {
onShowRelations: (tag: TagWithRelations) => void;
actions: TagAction[];
actionBar: ReactNode;
spaces?: SpacesApi;
}
const tablePagination = {
@ -49,6 +53,7 @@ export const TagTable: FC<TagTableProps> = ({
tags,
initialQuery,
allowSelection,
reloadTags,
onQueryChange,
selectedTags,
onSelectionChange,
@ -56,6 +61,7 @@ export const TagTable: FC<TagTableProps> = ({
getTagRelationUrl,
actionBar,
actions,
spaces,
}) => {
const tableRef = useRef<EuiInMemoryTable<TagWithRelations>>(null);
@ -65,6 +71,29 @@ export const TagTable: FC<TagTableProps> = ({
}
}, [selectedTags]);
const spacesColumn: EuiBasicTableColumn<TagWithRelations> | undefined = spaces
? {
field: 'spaces',
name: i18n.translate('xpack.savedObjectsTagging.management.table.columns.spaces', {
defaultMessage: 'Spaces',
}),
width: '20%',
render: (_, tag) => {
return (
<SpacesList
spacesApi={spaces}
canShareIntoSpace={capabilities.shareIntoSpace}
spaceIds={tag.namespaces ?? []}
id={tag.id}
title={tag.name}
// TODO handle refresh
refresh={reloadTags}
/>
);
},
}
: undefined;
const columns: Array<EuiBasicTableColumn<TagWithRelations>> = [
{
field: 'name',
@ -126,6 +155,7 @@ export const TagTable: FC<TagTableProps> = ({
);
},
},
...(spacesColumn ? [spacesColumn] : []),
...(actions.length
? [
{

View file

@ -8,10 +8,11 @@
import React, { FC } from 'react';
import ReactDOM from 'react-dom';
import { I18nProvider } from '@kbn/i18n-react';
import { SpacesApi, SpacesContextProps } from '@kbn/spaces-plugin/public';
import { CoreSetup, ApplicationStart } from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { ManagementAppMountParams } from '@kbn/management-plugin/public';
import { getTagsCapabilities } from '../../common';
import { getTagsCapabilities, tagFeatureId } from '../../common';
import { SavedObjectTaggingPluginStart } from '../types';
import { ITagInternalClient, ITagAssignmentService, ITagsCache } from '../services';
import { TagManagementPage } from './tag_management_page';
@ -20,6 +21,7 @@ interface MountSectionParams {
tagClient: ITagInternalClient;
tagCache: ITagsCache;
assignmentService: ITagAssignmentService;
spaces?: SpacesApi;
core: CoreSetup<{}, SavedObjectTaggingPluginStart>;
mountParams: ManagementAppMountParams;
title: string;
@ -36,6 +38,8 @@ const RedirectToHomeIfUnauthorized: FC<{
return children! as React.ReactElement;
};
const getEmptyFunctionComponent: FC<SpacesContextProps> = ({ children }) => <>{children}</>;
export const mountSection = async ({
tagClient,
tagCache,
@ -43,6 +47,7 @@ export const mountSection = async ({
core,
mountParams,
title,
spaces,
}: MountSectionParams) => {
const [coreStart] = await core.getStartServices();
const { element, setBreadcrumbs, theme$ } = mountParams;
@ -50,20 +55,27 @@ export const mountSection = async ({
const assignableTypes = await assignmentService.getAssignableTypes();
coreStart.chrome.docTitle.change(title);
const SpacesContextWrapper = spaces
? spaces.ui.components.getSpacesContextProvider
: getEmptyFunctionComponent;
ReactDOM.render(
<I18nProvider>
<KibanaThemeProvider theme$={theme$}>
<RedirectToHomeIfUnauthorized applications={coreStart.application}>
<TagManagementPage
setBreadcrumbs={setBreadcrumbs}
core={coreStart}
tagClient={tagClient}
tagCache={tagCache}
assignmentService={assignmentService}
capabilities={capabilities}
assignableTypes={assignableTypes}
/>
</RedirectToHomeIfUnauthorized>
<SpacesContextWrapper feature={tagFeatureId}>
<RedirectToHomeIfUnauthorized applications={coreStart.application}>
<TagManagementPage
setBreadcrumbs={setBreadcrumbs}
core={coreStart}
tagClient={tagClient}
tagCache={tagCache}
assignmentService={assignmentService}
capabilities={capabilities}
assignableTypes={assignableTypes}
spaces={spaces}
/>
</RedirectToHomeIfUnauthorized>
</SpacesContextWrapper>
</KibanaThemeProvider>
</I18nProvider>,
element

View file

@ -12,6 +12,7 @@ import { Query } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ChromeBreadcrumb, CoreStart } from '@kbn/core/public';
import { EuiSpacer } from '@elastic/eui';
import { SpacesApi } from '@kbn/spaces-plugin/public';
import { TagWithRelations, TagsCapabilities } from '../../common';
import { getCreateModalOpener } from '../components/edition_modal';
import { ITagInternalClient, ITagAssignmentService, ITagsCache } from '../services';
@ -27,6 +28,7 @@ interface TagManagementPageParams {
tagClient: ITagInternalClient;
tagCache: ITagsCache;
assignmentService: ITagAssignmentService;
spaces?: SpacesApi;
capabilities: TagsCapabilities;
assignableTypes: string[];
}
@ -39,6 +41,7 @@ export const TagManagementPage: FC<TagManagementPageParams> = ({
assignmentService,
capabilities,
assignableTypes,
spaces,
}) => {
const { overlays, notifications, application, http, theme } = core;
const [loading, setLoading] = useState<boolean>(false);
@ -206,6 +209,7 @@ export const TagManagementPage: FC<TagManagementPageParams> = ({
setQuery(newQuery);
setSelectedTags([]);
}}
reloadTags={fetchTags}
allowSelection={bulkActions.length > 0}
selectedTags={selectedTags}
onSelectionChange={(tags) => {
@ -215,6 +219,7 @@ export const TagManagementPage: FC<TagManagementPageParams> = ({
onShowRelations={(tag) => {
showTagRelations(tag);
}}
spaces={spaces}
/>
</>
);

View file

@ -76,7 +76,7 @@ describe('SavedObjectTaggingPlugin', () => {
});
it('creates its cache with correct parameters', () => {
plugin.start(coreMock.createStart());
plugin.start(coreMock.createStart(), {});
expect(MockedTagsCache).toHaveBeenCalledTimes(1);
expect(MockedTagsCache).toHaveBeenCalledWith({
@ -94,7 +94,7 @@ describe('SavedObjectTaggingPlugin', () => {
const coreStart = coreMock.createStart();
coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(false);
plugin.start(coreStart);
plugin.start(coreStart, {});
expect(MockedTagsCache.mock.instances[0].initialize).not.toHaveBeenCalled();
});
@ -103,7 +103,7 @@ describe('SavedObjectTaggingPlugin', () => {
const coreStart = coreMock.createStart();
coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true);
plugin.start(coreStart);
plugin.start(coreStart, {});
expect(MockedTagsCache.mock.instances[0].initialize).not.toHaveBeenCalled();
});

View file

@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import { CoreSetup, CoreStart, PluginInitializerContext, Plugin } from '@kbn/core/public';
import { SpacesApi, SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { ManagementSetup } from '@kbn/management-plugin/public';
import { SavedObjectTaggingOssPluginSetup } from '@kbn/saved-objects-tagging-oss-plugin/public';
import { tagManagementSectionId } from '../common/constants';
@ -21,12 +22,17 @@ interface SetupDeps {
savedObjectsTaggingOss: SavedObjectTaggingOssPluginSetup;
}
interface StartDeps {
spaces?: SpacesPluginStart;
}
export class SavedObjectTaggingPlugin
implements Plugin<{}, SavedObjectTaggingPluginStart, SetupDeps, {}>
implements Plugin<{}, SavedObjectTaggingPluginStart, SetupDeps, StartDeps>
{
private tagClient?: TagsClient;
private tagCache?: TagsCache;
private assignmentService?: TagAssignmentService;
private spaces?: SpacesApi;
private readonly config: SavedObjectsTaggingClientConfig;
constructor(context: PluginInitializerContext) {
@ -56,6 +62,7 @@ export class SavedObjectTaggingPlugin
core,
mountParams,
title,
spaces: this.spaces,
});
},
});
@ -67,13 +74,17 @@ export class SavedObjectTaggingPlugin
return {};
}
public start({ http, application, overlays, theme, analytics, notifications }: CoreStart) {
public start(
{ http, application, overlays, theme, analytics, notifications }: CoreStart,
{ spaces }: StartDeps
) {
this.tagCache = new TagsCache({
refreshHandler: () => this.tagClient!.getAll({ asSystemRequest: true }),
refreshInterval: this.config.cacheRefreshInterval,
});
this.tagClient = new TagsClient({ analytics, http, changeListener: this.tagCache });
this.assignmentService = new TagAssignmentService({ http });
this.spaces = spaces;
// do not fetch tags on anonymous page
if (!http.anonymousPaths.isAnonymous(window.location.pathname)) {

View file

@ -6,6 +6,7 @@
*/
import type { NotificationsStart, OverlayStart, ThemeServiceStart } from '@kbn/core/public';
import { SpacesApi } from '@kbn/spaces-plugin/public';
import { SavedObjectsTaggingApiUi } from '@kbn/saved-objects-tagging-oss-plugin/public';
import { TagsCapabilities } from '../../common';
import { ITagsCache, ITagInternalClient } from '../services';
@ -30,6 +31,7 @@ interface GetUiApiOptions {
cache: ITagsCache;
client: ITagInternalClient;
notifications: NotificationsStart;
spaces?: SpacesApi;
}
export const getUiApi = ({
@ -39,6 +41,7 @@ export const getUiApi = ({
overlays,
theme,
notifications,
spaces,
}: GetUiApiOptions): SavedObjectsTaggingApiUi => {
const components = getComponents({
cache,

View file

@ -10,6 +10,7 @@ import { Tag, TagSavedObject } from '../../../common/types';
export const savedObjectToTag = (savedObject: TagSavedObject): Tag => {
return {
id: savedObject.id,
namespaces: savedObject.namespaces,
...savedObject.attributes,
};
};

View file

@ -17,6 +17,7 @@
"@kbn/usage-collection-plugin",
"@kbn/features-plugin",
"@kbn/security-plugin",
"@kbn/spaces-plugin",
"@kbn/i18n",
"@kbn/utility-types",
"@kbn/i18n-react",