Add dashboard metadata to the info flyout (#185941)

## Summary

Close https://github.com/elastic/kibana-team/issues/898

- Show createdAt, createdBy, updatedAt, updatedBy in info flyout. Add a
bit of special handling for managed objects and when info is not
available.
- I had to extract some components into a separate package to use them
in contentEditor package
- tiny tweaks to column width and "no creator" state 



![Screenshot 2024-06-12 at 17 01
45](b2093c03-67a0-49a5-8a45-93d9e57813ca)

**Unknown creator:**


![Screenshot 2024-06-12 at 17 01
53](3e520f6a-9a19-455f-b564-571c3ad81b16)

**For managed objects:**

![Screenshot 2024-06-12 at 17 01
57](36ce1465-09a4-4936-a9f1-ca5794d45a7a)

**Just created, no updates yet**

![Screenshot 2024-06-12 at 17 02
18](1431210e-ce83-4409-ab99-6184b6f87d3a)
This commit is contained in:
Anton Dosov 2024-06-18 19:25:51 +02:00 committed by GitHub
parent dde8ef93bb
commit e2a98cf965
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 715 additions and 141 deletions

1
.github/CODEOWNERS vendored
View file

@ -101,6 +101,7 @@ packages/content-management/tabbed_table_list_view @elastic/appex-sharedux
packages/content-management/table_list_view @elastic/appex-sharedux
packages/content-management/table_list_view_common @elastic/appex-sharedux
packages/content-management/table_list_view_table @elastic/appex-sharedux
packages/content-management/user_profiles @elastic/appex-sharedux
packages/kbn-content-management-utils @elastic/kibana-data-discovery
examples/controls_example @elastic/kibana-presentation
src/plugins/controls @elastic/kibana-presentation

View file

@ -217,6 +217,7 @@
"@kbn/content-management-table-list-view": "link:packages/content-management/table_list_view",
"@kbn/content-management-table-list-view-common": "link:packages/content-management/table_list_view_common",
"@kbn/content-management-table-list-view-table": "link:packages/content-management/table_list_view_table",
"@kbn/content-management-user-profiles": "link:packages/content-management/user_profiles",
"@kbn/content-management-utils": "link:packages/kbn-content-management-utils",
"@kbn/controls-example-plugin": "link:examples/controls_example",
"@kbn/controls-plugin": "link:src/plugins/controls",

View file

@ -0,0 +1,113 @@
/*
* 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 { render, screen, waitFor } from '@testing-library/react';
import { UserProfilesProvider } from '@kbn/content-management-user-profiles';
import { I18nProvider } from '@kbn/i18n-react';
import { ActivityView as ActivityViewComponent, ActivityViewProps } from './activity_view';
const mockGetUserProfile = jest.fn(async (uid: string) => ({
uid,
enabled: true,
data: {},
user: { username: uid, full_name: uid.toLocaleUpperCase() },
}));
const ActivityView = (props: ActivityViewProps) => {
return (
<I18nProvider>
<UserProfilesProvider bulkGetUserProfiles={jest.fn()} getUserProfile={mockGetUserProfile}>
<ActivityViewComponent {...props} />
</UserProfilesProvider>
</I18nProvider>
);
};
test('should render activity view', () => {
render(<ActivityView item={{}} />);
expect(screen.getByTestId('activityView')).toBeVisible();
expect(screen.getByTestId('createdByCard')).toHaveTextContent(/Unknown/);
expect(() => screen.getByTestId('updateByCard')).toThrow();
});
test('should render creator card', async () => {
render(<ActivityView item={{ createdBy: 'john', createdAt: '2024-06-13T12:55:46.825Z' }} />);
await waitFor(() => {
const createdByCard = screen.getByTestId('createdByCard');
expect(createdByCard).toHaveTextContent(/JOHN/);
expect(createdByCard).toHaveTextContent(/June 13/);
});
});
test('should not render updater card when updatedAt matches createdAt', async () => {
render(
<ActivityView
item={{
createdBy: 'john',
updatedBy: 'john',
createdAt: '2024-06-13T12:55:46.825Z',
updatedAt: '2024-06-13T12:55:46.825Z',
}}
/>
);
expect(screen.getByTestId('createdByCard')).toBeVisible();
expect(() => screen.getByTestId('updateByCard')).toThrow();
});
test('should render updater card', async () => {
render(
<ActivityView
item={{
createdBy: 'john',
updatedBy: 'pete',
createdAt: '2024-06-13T12:55:46.825Z',
updatedAt: '2024-06-14T12:55:46.825Z',
}}
/>
);
await waitFor(() => {
const createdByCard = screen.getByTestId('createdByCard');
expect(createdByCard).toHaveTextContent(/JOHN/);
expect(createdByCard).toHaveTextContent(/June 13/);
});
await waitFor(() => {
const updatedByCard = screen.getByTestId('updatedByCard');
expect(updatedByCard).toHaveTextContent(/PETE/);
expect(updatedByCard).toHaveTextContent(/June 14/);
});
});
test('should handle managed objects', async () => {
render(
<ActivityView
item={{
managed: true,
createdAt: '2024-06-13T12:55:46.825Z',
updatedAt: '2024-06-14T12:55:46.825Z',
}}
/>
);
await waitFor(() => {
const createdByCard = screen.getByTestId('createdByCard');
expect(createdByCard).toHaveTextContent(/System/);
expect(createdByCard).toHaveTextContent(/June 13/);
});
const updatedByCard = screen.getByTestId('updatedByCard');
expect(updatedByCard).toHaveTextContent(/System/);
expect(updatedByCard).toHaveTextContent(/June 14/);
});

View file

@ -0,0 +1,185 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIconTip,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React from 'react';
import {
UserAvatarTip,
useUserProfile,
NoUpdaterTip,
NoCreatorTip,
ManagedAvatarTip,
} from '@kbn/content-management-user-profiles';
import { getUserDisplayName } from '@kbn/user-profile-components';
import { Item } from '../types';
export interface ActivityViewProps {
item: Pick<Item, 'createdBy' | 'createdAt' | 'updatedBy' | 'updatedAt' | 'managed'>;
}
export const ActivityView = ({ item }: ActivityViewProps) => {
const showLastUpdated = Boolean(item.updatedAt && item.updatedAt !== item.createdAt);
const UnknownUserLabel = (
<FormattedMessage
id="contentManagement.contentEditor.activity.unkownUserLabel"
defaultMessage="Unknown"
/>
);
const ManagedUserLabel = (
<>
<ManagedAvatarTip />{' '}
<FormattedMessage
id="contentManagement.contentEditor.activity.managedUserLabel"
defaultMessage="System"
/>
</>
);
return (
<EuiFormRow
label={
<>
<FormattedMessage
id="contentManagement.contentEditor.metadataForm.activityLabel"
defaultMessage="Activity"
/>{' '}
<EuiIconTip
type={'iInCircle'}
iconProps={{ style: { verticalAlign: 'bottom' } }}
content={
<FormattedMessage
id="contentManagement.contentEditor.activity.activityLabelHelpText"
defaultMessage="Activity data is auto-generated and cannot be updated."
/>
}
/>
</>
}
fullWidth
data-test-subj={'activityView'}
>
<>
<EuiFlexGroup gutterSize={'s'}>
<EuiFlexItem grow={1} css={{ flexBasis: '50%', minWidth: 0 }}>
<ActivityCard
what={i18n.translate('contentManagement.contentEditor.activity.createdByLabelText', {
defaultMessage: 'Created by',
})}
who={
item.createdBy ? (
<UserLabel uid={item.createdBy} />
) : item.managed ? (
<>{ManagedUserLabel}</>
) : (
<>
{UnknownUserLabel}
<NoCreatorTip />
</>
)
}
when={item.createdAt}
data-test-subj={'createdByCard'}
/>
</EuiFlexItem>
<EuiFlexItem grow={1} css={{ flexBasis: '50%', minWidth: 0 }}>
{showLastUpdated && (
<ActivityCard
what={i18n.translate(
'contentManagement.contentEditor.activity.lastUpdatedByLabelText',
{ defaultMessage: 'Last updated by' }
)}
who={
item.updatedBy ? (
<UserLabel uid={item.updatedBy} />
) : item.managed ? (
<>{ManagedUserLabel}</>
) : (
<>
{UnknownUserLabel}
<NoUpdaterTip />
</>
)
}
when={item.updatedAt}
data-test-subj={'updatedByCard'}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</>
</EuiFormRow>
);
};
const dateFormatter = new Intl.DateTimeFormat(i18n.getLocale(), {
dateStyle: 'long',
timeStyle: 'short',
});
const ActivityCard = ({
what,
when,
who,
'data-test-subj': dataTestSubj,
}: {
what: string;
who: React.ReactNode;
when?: string;
'data-test-subj'?: string;
}) => {
return (
<EuiPanel hasBorder paddingSize={'s'} data-test-subj={dataTestSubj}>
<EuiText size={'s'}>
<b>{what}</b>
</EuiText>
<EuiSpacer size={'xs'} />
<EuiText size={'s'} className={'eui-textTruncate'}>
{who}
</EuiText>
{when && (
<>
<EuiSpacer size={'xs'} />
<EuiText title={when} color={'subdued'} size={'s'}>
<FormattedMessage
id="contentManagement.contentEditor.activity.lastUpdatedByDateTime"
defaultMessage="on {dateTime}"
values={{
dateTime: dateFormatter.format(new Date(when)),
}}
/>
</EuiText>
</>
)}
</EuiPanel>
);
};
const UserLabel = ({ uid }: { uid: string }) => {
const userQuery = useUserProfile(uid);
if (!userQuery.data) return null;
return (
<>
<UserAvatarTip uid={uid} /> {getUserDisplayName(userQuery.data.user)}
</>
);
};

View file

@ -28,6 +28,7 @@ import type { Item } from '../types';
import { MetadataForm } from './metadata_form';
import { useMetadataForm } from './use_metadata_form';
import type { CustomValidators } from './use_metadata_form';
import { ActivityView } from './activity_view';
const getI18nTexts = ({ entityName }: { entityName: string }) => ({
saveButtonLabel: i18n.translate('contentManagement.contentEditor.saveButtonLabel', {
@ -55,6 +56,7 @@ export interface Props {
}) => Promise<void>;
customValidators?: CustomValidators;
onCancel: () => void;
showActivityView?: boolean;
}
const capitalize = (str: string) => `${str.charAt(0).toLocaleUpperCase()}${str.substring(1)}`;
@ -68,6 +70,7 @@ export const ContentEditorFlyoutContent: FC<Props> = ({
onSave,
onCancel,
customValidators,
showActivityView,
}) => {
const { euiTheme } = useEuiTheme();
const [isSubmitting, setIsSubmitting] = useState(false);
@ -147,7 +150,9 @@ export const ContentEditorFlyoutContent: FC<Props> = ({
tagsReferences={item.tags}
TagList={TagList}
TagSelector={TagSelector}
/>
>
{showActivityView && <ActivityView item={item} />}
</MetadataForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>

View file

@ -21,6 +21,7 @@ type CommonProps = Pick<
| 'onCancel'
| 'entityName'
| 'customValidators'
| 'showActivityView'
>;
export type Props = CommonProps;

View file

@ -262,5 +262,22 @@ describe('<ContentEditorFlyoutContent />', () => {
tags: ['id-3', 'id-4'], // New selection
});
});
test('should render activity view', async () => {
await act(async () => {
testBed = await setup({ showActivityView: true });
});
const { find, component } = testBed!;
expect(find('activityView').exists()).toBe(true);
expect(find('activityView.createdByCard').exists()).toBe(true);
expect(find('activityView.updatedByCard').exists()).toBe(false);
testBed.setProps({
item: { ...savedObjectItem, updatedAt: '2021-01-01T00:00:00Z' },
});
component.update();
expect(find('activityView.updatedByCard').exists()).toBe(true);
});
});
});

View file

@ -6,20 +6,20 @@
* Side Public License, v 1.
*/
import React from 'react';
import type { FC } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFieldText,
EuiForm,
EuiFormRow,
EuiFieldText,
EuiTextArea,
EuiSpacer,
EuiTextArea,
EuiToolTip,
} from '@elastic/eui';
import { ContentEditorFlyoutWarningsCallOut } from './editor_flyout_warnings';
import type { MetadataFormState, Field } from './use_metadata_form';
import type { Field, MetadataFormState } from './use_metadata_form';
import type { SavedObjectsReference, Services } from '../services';
interface Props {
@ -42,6 +42,7 @@ export const MetadataForm: FC<Props> = ({
TagSelector,
isReadonly,
readonlyReason,
children,
}) => {
const {
title,
@ -137,6 +138,13 @@ export const MetadataForm: FC<Props> = ({
<TagSelector initialSelection={tags.value} onTagsSelected={setTags} fullWidth />
</>
)}
{children && (
<>
<EuiSpacer />
{children}
</>
)}
</EuiForm>
);
};

View file

@ -15,7 +15,13 @@ import type { ContentEditorFlyoutContentContainerProps } from './components';
export type OpenContentEditorParams = Pick<
ContentEditorFlyoutContentContainerProps,
'item' | 'onSave' | 'isReadonly' | 'readonlyReason' | 'entityName' | 'customValidators'
| 'item'
| 'onSave'
| 'isReadonly'
| 'readonlyReason'
| 'entityName'
| 'customValidators'
| 'showActivityView'
>;
export function useOpenContentEditor() {

View file

@ -8,6 +8,10 @@
import type { FC, PropsWithChildren, ReactNode } from 'react';
import React, { useCallback, useContext, useMemo } from 'react';
import {
UserProfilesProvider,
useUserProfilesServices,
} from '@kbn/content-management-user-profiles';
import type { EuiComboBoxProps } from '@elastic/eui';
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
@ -130,11 +134,19 @@ export const ContentEditorKibanaProvider: FC<
return Comp;
}, [savedObjectsTagging?.ui.components.TagList]);
const userProfilesServices = useUserProfilesServices();
const openFlyout = useCallback(
(node: ReactNode, options: OverlayFlyoutOpenOptions) => {
return coreOpenFlyout(toMountPoint(node, startServices), options);
return coreOpenFlyout(
toMountPoint(
<UserProfilesProvider {...userProfilesServices}>{node}</UserProfilesProvider>,
startServices
),
options
);
},
[coreOpenFlyout, startServices]
[coreOpenFlyout, startServices, userProfilesServices]
);
return (

View file

@ -13,4 +13,11 @@ export interface Item {
title: string;
description?: string;
tags: SavedObjectsReference[];
createdAt?: string;
createdBy?: string;
updatedAt?: string;
updatedBy?: string;
managed?: boolean;
}

View file

@ -10,7 +10,9 @@
"react",
"@kbn/ambient-ui-types",
"@kbn/ambient-storybook-types",
"@emotion/react/types/css-prop"
"@emotion/react/types/css-prop",
"@testing-library/jest-dom",
"@testing-library/react"
]
},
"include": [
@ -26,7 +28,9 @@
"@kbn/core-i18n-browser",
"@kbn/core-theme-browser",
"@kbn/test-jest-helpers",
"@kbn/react-kibana-mount"
"@kbn/react-kibana-mount",
"@kbn/content-management-user-profiles",
"@kbn/user-profile-components"
],
"exclude": [
"target/**/*"

View file

@ -11,6 +11,8 @@ import type { SavedObjectsReference } from '@kbn/content-management-content-edit
export interface UserContentCommonSchema {
id: string;
updatedAt: string;
updatedBy?: string;
createdAt?: string;
createdBy?: string;
managed?: boolean;
references: SavedObjectsReference[];

View file

@ -147,7 +147,7 @@ describe('created_by filter', () => {
// wait until first render
expect(await screen.findByTestId('itemsInMemTable')).toBeVisible();
// 5 items in the list
// 4 items in the list
expect(screen.getAllByTestId(/userContentListingTitleLink/)).toHaveLength(4);
userEvent.click(screen.getByTestId('userFilterPopoverButton'));

View file

@ -9,12 +9,13 @@ import React from 'react';
import type { ComponentType } from 'react';
import { from } from 'rxjs';
import { ContentEditorProvider } from '@kbn/content-management-content-editor';
import { UserProfilesProvider, UserProfilesServices } from '@kbn/content-management-user-profiles';
import { TagList } from '../mocks';
import { TableListViewProvider, Services } from '../services';
export const getMockServices = (overrides?: Partial<Services>) => {
const services: Services = {
export const getMockServices = (overrides?: Partial<Services & UserProfilesServices>) => {
const services: Services & UserProfilesServices = {
canEditAdvancedSettings: true,
getListingLimitSettingsUrl: () => 'http://elastic.co',
notifyError: () => undefined,
@ -25,24 +26,29 @@ export const getMockServices = (overrides?: Partial<Services>) => {
itemHasTags: () => true,
getTagManagementUrl: () => '',
getTagIdsFromReferences: () => [],
bulkGetUserProfiles: jest.fn(() => Promise.resolve([])),
getUserProfile: jest.fn(),
isTaggingEnabled: () => true,
bulkGetUserProfiles: async () => [],
getUserProfile: async () => ({ uid: '', enabled: true, data: {}, user: { username: '' } }),
...overrides,
};
return services;
};
export function WithServices<P>(Comp: ComponentType<P>, overrides: Partial<Services> = {}) {
export function WithServices<P>(
Comp: ComponentType<P>,
overrides: Partial<Services & UserProfilesServices> = {}
) {
return (props: P) => {
const services = getMockServices(overrides);
return (
<ContentEditorProvider openFlyout={jest.fn()} notifyError={() => undefined}>
<TableListViewProvider {...services}>
<Comp {...(props as any)} />
</TableListViewProvider>
</ContentEditorProvider>
<UserProfilesProvider {...services}>
<ContentEditorProvider openFlyout={jest.fn()} notifyError={() => undefined}>
<TableListViewProvider {...services}>
<Comp {...(props as any)} />
</TableListViewProvider>
</ContentEditorProvider>
</UserProfilesProvider>
);
};
}

View file

@ -11,10 +11,8 @@ import React from 'react';
import { EuiFilterButton, useEuiTheme } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { UserProfile, UserProfilesPopover } from '@kbn/user-profile-components';
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { useServices } from '../services';
import { NoUsersTip } from './user_missing_tip';
import { useUserProfiles, NoCreatorTip } from '@kbn/content-management-user-profiles';
interface Context {
enabled: boolean;
@ -25,43 +23,29 @@ interface Context {
}
const UserFilterContext = React.createContext<Context | null>(null);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30 * 60 * 1000 } },
});
export const UserFilterContextProvider: FC<Context> = ({ children, ...props }) => {
if (!props.enabled) {
return <>{children}</>;
}
return (
<QueryClientProvider client={queryClient}>
<UserFilterContext.Provider value={props}>{children}</UserFilterContext.Provider>
</QueryClientProvider>
);
return <UserFilterContext.Provider value={props}>{children}</UserFilterContext.Provider>;
};
export const NULL_USER = 'no-user';
export const UserFilterPanel: FC<{}> = () => {
const { bulkGetUserProfiles } = useServices();
const { euiTheme } = useEuiTheme();
const componentContext = React.useContext(UserFilterContext);
if (!componentContext)
throw new Error('UserFilterPanel must be used within a UserFilterContextProvider');
if (!bulkGetUserProfiles)
throw new Error('UserFilterPanel must be used with a bulkGetUserProfiles function');
const { onSelectedUsersChange, selectedUsers, showNoUserOption } = componentContext;
const [isPopoverOpen, setPopoverOpen] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState('');
const query = useQuery({
queryKey: ['user-filter-suggestions', componentContext.allUsers],
queryFn: () => bulkGetUserProfiles(componentContext.allUsers),
enabled: isPopoverOpen,
});
const query = useUserProfiles(componentContext.allUsers, { enabled: isPopoverOpen });
const usersMap = React.useMemo(() => {
if (!query.data) return {};
@ -138,7 +122,7 @@ export const UserFilterPanel: FC<{}> = () => {
id="contentManagement.tableList.listing.userFilter.emptyMessage"
defaultMessage="None of the dashboards have creators"
/>
{<NoUsersTip />}
{<NoCreatorTip />}
</p>
),
nullOptionLabel: i18n.translate(
@ -148,7 +132,7 @@ export const UserFilterPanel: FC<{}> = () => {
}
),
nullOptionProps: {
append: <NoUsersTip />,
append: <NoCreatorTip />,
},
clearButtonLabel: (
<FormattedMessage

View file

@ -1,28 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiIconTip } from '@elastic/eui';
import React from 'react';
export const NoUsersTip = () => (
<EuiIconTip
aria-label="Additional information"
position="bottom"
type="questionInCircle"
color="inherit"
iconProps={{ style: { verticalAlign: 'text-bottom', marginLeft: 2 } }}
css={{ textWrap: 'balance' }}
content={
<FormattedMessage
id="contentManagement.tableList.listing.noUsersTip"
defaultMessage="Creators are assigned when dashboards are created (after version 8.14)"
/>
}
/>
);

View file

@ -71,8 +71,6 @@ export const getStoryServices = (params: Params, action: ActionFn = () => {}) =>
itemHasTags: () => true,
getTagManagementUrl: () => '',
getTagIdsFromReferences: () => [],
bulkGetUserProfiles: () => Promise.resolve([]),
getUserProfile: jest.fn(),
isTaggingEnabled: () => true,
...params,
};

View file

@ -20,11 +20,10 @@ import type { MountPoint, OverlayRef } from '@kbn/core-mount-utils-browser';
import type { OverlayFlyoutOpenOptions } from '@kbn/core-overlays-browser';
import type { ThemeServiceStart } from '@kbn/core-theme-browser';
import type { UserProfileServiceStart } from '@kbn/core-user-profile-browser';
import type { UserProfile } from '@kbn/user-profile-components';
import type { FormattedRelative } from '@kbn/i18n-react';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { RedirectAppLinksKibanaProvider } from '@kbn/shared-ux-link-redirect-app';
import { createBatcher } from './utils/batcher';
import { UserProfilesKibanaProvider } from '@kbn/content-management-user-profiles';
import { TAG_MANAGEMENT_APP_URL } from './constants';
import type { Tag } from './types';
@ -69,9 +68,6 @@ export interface Services {
/** Handler to return the url to navigate to the kibana tags management */
getTagManagementUrl: () => string;
getTagIdsFromReferences: (references: SavedObjectsReference[]) => string[];
/** resolve user profiles for the user filter and creator functionality */
bulkGetUserProfiles: (uids: string[]) => Promise<UserProfile[]>;
getUserProfile: (uid: string) => Promise<UserProfile>;
}
const TableListViewContext = React.createContext<Services | null>(null);
@ -229,51 +225,35 @@ export const TableListViewKibanaProvider: FC<
[getTagIdsFromReferences]
);
const bulkGetUserProfiles = useCallback<(userProfileIds: string[]) => Promise<UserProfile[]>>(
async (uids: string[]) => {
if (uids.length === 0) return [];
return core.userProfile.bulkGet({ uids: new Set(uids), dataPath: 'avatar' });
},
[core.userProfile]
);
const getUserProfile = useMemo(() => {
return createBatcher({
fetcher: bulkGetUserProfiles,
resolver: (users, id) => users.find((u) => u.uid === id)!,
}).fetch;
}, [bulkGetUserProfiles]);
return (
<RedirectAppLinksKibanaProvider coreStart={core}>
<ContentEditorKibanaProvider core={core} savedObjectsTagging={savedObjectsTagging}>
<TableListViewProvider
canEditAdvancedSettings={Boolean(application.capabilities.advancedSettings?.save)}
getListingLimitSettingsUrl={() =>
application.getUrlForApp('management', {
path: `/kibana/settings?query=savedObjects:listingLimit`,
})
}
notifyError={(title, text) => {
notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text });
}}
searchQueryParser={searchQueryParser}
DateFormatterComp={(props) => <FormattedRelative {...props} />}
currentAppId$={application.currentAppId$}
navigateToUrl={application.navigateToUrl}
isTaggingEnabled={() => Boolean(savedObjectsTagging)}
getTagList={getTagList}
TagList={TagList}
itemHasTags={itemHasTags}
getTagIdsFromReferences={getTagIdsFromReferences}
getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)}
bulkGetUserProfiles={bulkGetUserProfiles}
getUserProfile={getUserProfile}
>
{children}
</TableListViewProvider>
</ContentEditorKibanaProvider>
<UserProfilesKibanaProvider core={core}>
<ContentEditorKibanaProvider core={core} savedObjectsTagging={savedObjectsTagging}>
<TableListViewProvider
canEditAdvancedSettings={Boolean(application.capabilities.advancedSettings?.save)}
getListingLimitSettingsUrl={() =>
application.getUrlForApp('management', {
path: `/kibana/settings?query=savedObjects:listingLimit`,
})
}
notifyError={(title, text) => {
notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text });
}}
searchQueryParser={searchQueryParser}
DateFormatterComp={(props) => <FormattedRelative {...props} />}
currentAppId$={application.currentAppId$}
navigateToUrl={application.navigateToUrl}
isTaggingEnabled={() => Boolean(savedObjectsTagging)}
getTagList={getTagList}
TagList={TagList}
itemHasTags={itemHasTags}
getTagIdsFromReferences={getTagIdsFromReferences}
getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)}
>
{children}
</TableListViewProvider>
</ContentEditorKibanaProvider>
</UserProfilesKibanaProvider>
</RedirectAppLinksKibanaProvider>
);
};

View file

@ -27,6 +27,11 @@ import { FormattedMessage } from '@kbn/i18n-react';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { useOpenContentEditor } from '@kbn/content-management-content-editor';
import {
UserAvatarTip,
ManagedAvatarTip,
NoCreatorTip,
} from '@kbn/content-management-user-profiles';
import type {
OpenContentEditorParams,
SavedObjectsReference,
@ -47,12 +52,12 @@ import { type SortColumnField, getInitialSorting, saveSorting } from './componen
import { useTags } from './use_tags';
import { useInRouterContext, useUrlState } from './use_url_state';
import { RowActions, TableItemsRowActions } from './types';
import { UserAvatarTip } from './components/user_avatar_tip';
import { NoUsersTip } from './components/user_missing_tip';
import { ManagedAvatarTip } from './components/managed_avatar_tip';
interface ContentEditorConfig
extends Pick<OpenContentEditorParams, 'isReadonly' | 'onSave' | 'customValidators'> {
extends Pick<
OpenContentEditorParams,
'isReadonly' | 'onSave' | 'customValidators' | 'showActivityView'
> {
enabled?: boolean;
}
@ -506,6 +511,11 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
title: item.attributes.title,
description: item.attributes.description,
tags,
createdAt: item.createdAt,
createdBy: item.createdBy,
updatedAt: item.updatedAt,
updatedBy: item.updatedBy,
managed: item.managed,
},
entityName,
...contentEditor,
@ -575,7 +585,6 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
{i18n.translate('contentManagement.tableList.createdByColumnTitle', {
defaultMessage: 'Creator',
})}
<NoUsersTip />
</>
),
render: (field: string, record: { createdBy?: string; managed?: boolean }) =>
@ -583,7 +592,9 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
<UserAvatarTip uid={record.createdBy} />
) : record.managed ? (
<ManagedAvatarTip entityName={entityName} />
) : null,
) : (
<NoCreatorTip iconType={'minus'} />
),
sortable:
false /* createdBy column is not sortable because it doesn't make sense to sort by id*/,
width: '100px',
@ -601,7 +612,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
<UpdatedAtField dateTime={record.updatedAt} DateFormatterComp={DateFormatterComp} />
),
sortable: true,
width: '120px',
width: '130px',
});
}

View file

@ -33,7 +33,8 @@
"@kbn/content-management-table-list-view-common",
"@kbn/user-profile-components",
"@kbn/core-user-profile-browser",
"@kbn/react-kibana-mount"
"@kbn/react-kibana-mount",
"@kbn/content-management-user-profiles"
],
"exclude": [
"target/**/*"

View file

@ -0,0 +1,3 @@
# @kbn/content-management-user-profiles
Shared user profile components for content management components.

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { UserAvatarTip, NoUpdaterTip, NoCreatorTip, ManagedAvatarTip } from './src/components';
export { useUserProfile, useUserProfiles } from './src/queries';
export {
UserProfilesKibanaProvider,
type UserProfilesKibanaDependencies,
UserProfilesProvider,
type UserProfilesServices,
useUserProfilesServices,
} from './src/services';

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/user_profiles'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/content-management-user-profiles",
"owner": "@elastic/appex-sharedux"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/content-management-user-profiles",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

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 { UserAvatarTip } from './user_avatar_tip';
export { NoUpdaterTip, NoCreatorTip } from './user_missing_tip';
export { ManagedAvatarTip } from './managed_avatar_tip';

View file

@ -10,10 +10,16 @@ import React from 'react';
import { EuiAvatar, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export function ManagedAvatarTip({ entityName }: { entityName: string }) {
export function ManagedAvatarTip({
entityName = i18n.translate('contentManagement.userProfiles.managedAvatarTip.defaultEntityName', {
defaultMessage: 'object',
}),
}: {
entityName?: string;
}) {
return (
<EuiToolTip
content={i18n.translate('contentManagement.tableList.managedAvatarTip.avatarTooltip', {
content={i18n.translate('contentManagement.userProfiles.managedAvatarTip.avatarTooltip', {
defaultMessage:
'This {entityName} is created and managed by Elastic. Clone it to make changes.',
values: {
@ -22,7 +28,7 @@ export function ManagedAvatarTip({ entityName }: { entityName: string }) {
})}
>
<EuiAvatar
name={i18n.translate('contentManagement.tableList.managedAvatarTip.avatarLabel', {
name={i18n.translate('contentManagement.userProfiles.managedAvatarTip.avatarLabel', {
defaultMessage: 'Managed',
})}
iconType={'logoElastic'}

View file

@ -8,18 +8,10 @@
import React from 'react';
import { UserAvatarTip as UserAvatarTipComponent } from '@kbn/user-profile-components';
import { useQuery } from '@tanstack/react-query';
import { useServices } from '../services';
import { useUserProfile } from '../queries';
export function UserAvatarTip(props: { uid: string }) {
const { getUserProfile } = useServices();
const query = useQuery(
['user-profile', props.uid],
async () => {
return getUserProfile(props.uid);
},
{ staleTime: Infinity }
);
const query = useUserProfile(props.uid);
if (query.data) {
return (

View file

@ -0,0 +1,53 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import { EuiIconTip, IconType } from '@elastic/eui';
import React from 'react';
export const NoCreatorTip = (props: { iconType?: IconType }) => (
<NoUsersTip
content={
<FormattedMessage
id="contentManagement.userProfiles.noCreatorTip"
defaultMessage="Creators are assigned when objects are created (after version 8.14)"
/>
}
{...props}
/>
);
export const NoUpdaterTip = (props: { iconType?: string }) => (
<NoUsersTip
content={
<FormattedMessage
id="contentManagement.userProfiles.noUpdaterTip"
defaultMessage="Updated by is set when objects are updated (after version 8.14)"
/>
}
{...props}
/>
);
const NoUsersTip = ({
iconType: type = 'questionInCircle',
...props
}: {
content: React.ReactNode;
iconType?: IconType;
}) => (
<EuiIconTip
aria-label="Additional information"
position="top"
color="inherit"
iconProps={{ style: { verticalAlign: 'text-bottom', marginLeft: 2 } }}
css={{ textWrap: 'balance' }}
type={type}
{...props}
/>
);

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useQuery } from '@tanstack/react-query';
import { useUserProfilesServices } from './services';
export const userProfileKeys = {
get: (uid: string) => ['user-profile', uid],
bulkGet: (uids: string[]) => ['user-profile', { uids }],
};
export const useUserProfile = (uid: string) => {
const { getUserProfile } = useUserProfilesServices();
const query = useQuery(
userProfileKeys.get(uid),
async () => {
return getUserProfile(uid);
},
{ staleTime: Infinity }
);
return query;
};
export const useUserProfiles = (uids: string[], opts?: { enabled?: boolean }) => {
const { bulkGetUserProfiles } = useUserProfilesServices();
const query = useQuery({
queryKey: userProfileKeys.bulkGet(uids),
queryFn: () => bulkGetUserProfiles(uids),
enabled: opts?.enabled ?? true,
});
return query;
};

View file

@ -0,0 +1,82 @@
/*
* 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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { UserProfileServiceStart } from '@kbn/core-user-profile-browser';
import React, { FC, PropsWithChildren, useCallback, useContext, useMemo } from 'react';
import type { UserProfile } from '@kbn/user-profile-components';
import { createBatcher } from './utils/batcher';
export const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30 * 60 * 1000 } },
});
export interface UserProfilesKibanaDependencies {
core: {
userProfile: {
bulkGet: UserProfileServiceStart['bulkGet'];
};
};
}
export interface UserProfilesServices {
bulkGetUserProfiles: (uids: string[]) => Promise<UserProfile[]>;
getUserProfile: (uid: string) => Promise<UserProfile>;
}
const UserProfilesContext = React.createContext<UserProfilesServices | null>(null);
export const UserProfilesProvider: FC<PropsWithChildren<UserProfilesServices>> = ({
children,
...services
}) => {
return (
<QueryClientProvider client={queryClient}>
<UserProfilesContext.Provider value={services}>{children}</UserProfilesContext.Provider>
</QueryClientProvider>
);
};
export const UserProfilesKibanaProvider: FC<PropsWithChildren<UserProfilesKibanaDependencies>> = ({
children,
core,
}) => {
const bulkGetUserProfiles = useCallback<(userProfileIds: string[]) => Promise<UserProfile[]>>(
async (uids: string[]) => {
if (uids.length === 0) return [];
return core.userProfile.bulkGet({ uids: new Set(uids), dataPath: 'avatar' });
},
[core.userProfile]
);
const getUserProfile = useMemo(() => {
return createBatcher({
fetcher: bulkGetUserProfiles,
resolver: (users, id) => users.find((u) => u.uid === id)!,
}).fetch;
}, [bulkGetUserProfiles]);
return (
<UserProfilesProvider getUserProfile={getUserProfile} bulkGetUserProfiles={bulkGetUserProfiles}>
{children}
</UserProfilesProvider>
);
};
export function useUserProfilesServices() {
const context = useContext(UserProfilesContext);
if (!context) {
throw new Error(
'UserProfilesContext is missing. Ensure your component or React root is wrapped with <UserProfilesProvider />'
);
}
return context;
}

View file

@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/user-profile-components",
"@kbn/core-user-profile-browser",
"@kbn/i18n",
"@kbn/i18n-react",
]
}

View file

@ -154,6 +154,7 @@ describe('useDashboardListingTable', () => {
onSave: expect.any(Function),
isReadonly: false,
customValidators: expect.any(Object),
showActivityView: true,
},
createdByEnabled: true,
};

View file

@ -42,7 +42,9 @@ const toTableListViewSavedObject = (hit: DashboardItem): DashboardSavedObjectUse
type: 'dashboard',
id: hit.id,
updatedAt: hit.updatedAt!,
createdAt: hit.createdAt,
createdBy: hit.createdBy,
updatedBy: hit.updatedBy,
references: hit.references,
managed: hit.managed,
attributes: {
@ -280,6 +282,7 @@ export const useDashboardListingTable = ({
isReadonly: !showWriteControls,
onSave: updateItemMeta,
customValidators: contentEditorValidators,
showActivityView: true,
},
createItem: !showWriteControls || !showCreateDashboardButton ? undefined : createItem,
deleteItems: !showWriteControls ? undefined : deleteItems,

View file

@ -196,6 +196,8 @@
"@kbn/content-management-table-list-view-common/*": ["packages/content-management/table_list_view_common/*"],
"@kbn/content-management-table-list-view-table": ["packages/content-management/table_list_view_table"],
"@kbn/content-management-table-list-view-table/*": ["packages/content-management/table_list_view_table/*"],
"@kbn/content-management-user-profiles": ["packages/content-management/user_profiles"],
"@kbn/content-management-user-profiles/*": ["packages/content-management/user_profiles/*"],
"@kbn/content-management-utils": ["packages/kbn-content-management-utils"],
"@kbn/content-management-utils/*": ["packages/kbn-content-management-utils/*"],
"@kbn/controls-example-plugin": ["examples/controls_example"],

View file

@ -3545,6 +3545,10 @@
version "0.0.0"
uid ""
"@kbn/content-management-user-profiles@link:packages/content-management/user_profiles":
version "0.0.0"
uid ""
"@kbn/content-management-utils@link:packages/kbn-content-management-utils":
version "0.0.0"
uid ""