[Managed content] readonly in library views (#176263)

## Summary

Close https://github.com/elastic/kibana/issues/172387

This PR stops users from doing two things in the library views
1. deleting managed content
2. editing metadata of managed content

It also includes a refactor to the `TableListView` interface to replace
the `isItemEditable` callback with a row-level action.

<img width="1596" alt="Screenshot 2024-02-06 at 3 03 06 PM"
src="add84572-d4d7-4d69-baa8-a298a0ca7b83">

<img width="553" alt="Screenshot 2024-02-06 at 3 03 24 PM"
src="9bbdb6d5-a030-4c17-8f6d-daec4f5232a2">


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials — will be
completed in https://github.com/elastic/kibana/issues/175150
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed —
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5087
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Drew Tate 2024-02-08 15:46:33 -07:00 committed by GitHub
parent 8e53ee3c7c
commit 16787801f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 137 additions and 50 deletions

View file

@ -45,6 +45,7 @@ export interface Props {
item: Item;
entityName: string;
isReadonly?: boolean;
readonlyReason?: string;
services: Pick<Services, 'TagSelector' | 'TagList' | 'notifyError'>;
onSave?: (args: {
id: string;
@ -62,6 +63,7 @@ export const ContentEditorFlyoutContent: FC<Props> = ({
item,
entityName,
isReadonly = true,
readonlyReason,
services: { TagSelector, TagList, notifyError },
onSave,
onCancel,
@ -136,6 +138,12 @@ export const ContentEditorFlyoutContent: FC<Props> = ({
<MetadataForm
form={{ ...form, isSubmitted }}
isReadonly={isReadonly}
readonlyReason={
readonlyReason ||
i18n.translate('contentManagement.contentEditor.metadataForm.readOnlyToolTip', {
defaultMessage: 'To edit these details, contact your administrator for access.',
})
}
tagsReferences={item.tags}
TagList={TagList}
TagSelector={TagSelector}

View file

@ -13,7 +13,14 @@ import type { Props as ContentEditorFlyoutContentProps } from './editor_flyout_c
type CommonProps = Pick<
ContentEditorFlyoutContentProps,
'item' | 'isReadonly' | 'services' | 'onSave' | 'onCancel' | 'entityName' | 'customValidators'
| 'item'
| 'isReadonly'
| 'readonlyReason'
| 'services'
| 'onSave'
| 'onCancel'
| 'entityName'
| 'customValidators'
>;
export type Props = CommonProps;

View file

@ -27,6 +27,7 @@ interface Props {
isSubmitted: boolean;
};
isReadonly: boolean;
readonlyReason: string;
tagsReferences: SavedObjectsReference[];
TagList?: Services['TagList'];
TagSelector?: Services['TagSelector'];
@ -40,6 +41,7 @@ export const MetadataForm: FC<Props> = ({
TagList,
TagSelector,
isReadonly,
readonlyReason,
}) => {
const {
title,
@ -54,11 +56,6 @@ export const MetadataForm: FC<Props> = ({
getWarnings,
} = form;
const readOnlyToolTip = i18n.translate(
'contentManagement.contentEditor.metadataForm.readOnlyToolTip',
{ defaultMessage: 'To edit these details, contact your administrator for access.' }
);
return (
<EuiForm isInvalid={isSubmitted && !isValid} error={getErrors()} data-test-subj="metadataForm">
<ContentEditorFlyoutWarningsCallOut warningMessages={getWarnings()} />
@ -73,7 +70,7 @@ export const MetadataForm: FC<Props> = ({
>
<EuiToolTip
position="top"
content={isReadonly ? readOnlyToolTip : undefined}
content={isReadonly ? readonlyReason : undefined}
display="block"
>
<EuiFieldText
@ -104,7 +101,7 @@ export const MetadataForm: FC<Props> = ({
>
<EuiToolTip
position="top"
content={isReadonly ? readOnlyToolTip : undefined}
content={isReadonly ? readonlyReason : undefined}
display="block"
>
<EuiTextArea

View file

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

View file

@ -37,7 +37,6 @@ export type TableListViewProps<T extends UserContentCommonSchema = UserContentCo
| 'contentEditor'
| 'titleColumnName'
| 'withoutPageTemplateWrapper'
| 'itemIsEditable'
> & {
title: string;
description?: string;
@ -74,7 +73,6 @@ export const TableListView = <T extends UserContentCommonSchema>({
titleColumnName,
additionalRightSideActions,
withoutPageTemplateWrapper,
itemIsEditable,
}: TableListViewProps<T>) => {
const PageTemplate = withoutPageTemplateWrapper
? (React.Fragment as unknown as typeof KibanaPageTemplate)
@ -120,7 +118,6 @@ export const TableListView = <T extends UserContentCommonSchema>({
id={listingId}
contentEditor={contentEditor}
titleColumnName={titleColumnName}
itemIsEditable={itemIsEditable}
withoutPageTemplateWrapper={withoutPageTemplateWrapper}
onFetchSuccess={onFetchSuccess}
setPageDataTestSubject={setPageDataTestSubject}

View file

@ -95,11 +95,6 @@ export interface TableListViewTableProps<
*/
editItem?(item: T): void;
/**
* Handler to set edit action visiblity, and content editor readonly state per item. If not provided all non-managed items are considered editable. Note: Items with the managed property set to true will always be non-editable.
*/
itemIsEditable?(item: T): boolean;
/**
* Name for the column containing the "title" value.
*/
@ -259,7 +254,6 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
findItems,
createItem,
editItem,
itemIsEditable,
deleteItems,
getDetailViewLink,
onClickTitle,
@ -440,14 +434,34 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
items,
});
const isEditable = useCallback(
(item: T) => {
// If the So is `managed` it is never editable.
if (item.managed) return false;
return itemIsEditable?.(item) ?? true;
},
[itemIsEditable]
);
const tableItemsRowActions = useMemo(() => {
return items.reduce<TableItemsRowActions>((acc, item) => {
const ret = {
...acc,
[item.id]: rowItemActions ? rowItemActions(item) : undefined,
};
if (item.managed) {
ret[item.id] = {
...ret[item.id],
delete: {
enabled: false,
reason: i18n.translate('contentManagement.tableList.managedItemNoDelete', {
defaultMessage: 'This item is managed by Elastic. It cannot be deleted.',
}),
},
edit: {
enabled: false,
reason: i18n.translate('contentManagement.tableList.managedItemNoEdit', {
defaultMessage: 'This item is managed by Elastic. Clone it before making changes.',
}),
},
};
}
return ret;
}, {});
}, [items, rowItemActions]);
const inspectItem = useCallback(
(item: T) => {
@ -464,7 +478,9 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
},
entityName,
...contentEditor,
isReadonly: contentEditor.isReadonly || !isEditable(item),
isReadonly:
contentEditor.isReadonly || tableItemsRowActions[item.id]?.edit?.enabled === false,
readonlyReason: tableItemsRowActions[item.id]?.edit?.reason,
onSave:
contentEditor.onSave &&
(async (args) => {
@ -475,7 +491,14 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
}),
});
},
[getTagIdsFromReferences, openContentEditor, entityName, contentEditor, isEditable, fetchItems]
[
getTagIdsFromReferences,
openContentEditor,
entityName,
contentEditor,
tableItemsRowActions,
fetchItems,
]
);
const tableColumns = useMemo(() => {
@ -549,7 +572,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
),
icon: 'pencil',
type: 'icon',
available: (item) => isEditable(item),
available: (item) => Boolean(tableItemsRowActions[item.id]?.edit?.enabled),
enabled: (v) => !(v as unknown as { error: string })?.error,
onClick: editItem,
'data-test-subj': `edit-action`,
@ -605,7 +628,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
addOrRemoveExcludeTagFilter,
addOrRemoveIncludeTagFilter,
DateFormatterComp,
isEditable,
tableItemsRowActions,
inspectItem,
]);
@ -617,15 +640,6 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
return selectedIds.map((selectedId) => itemsById[selectedId]);
}, [selectedIds, itemsById]);
const tableItemsRowActions = useMemo(() => {
return items.reduce<TableItemsRowActions>((acc, item) => {
return {
...acc,
[item.id]: rowItemActions ? rowItemActions(item) : undefined,
};
}, {});
}, [items, rowItemActions]);
// ------------
// Callbacks
// ------------

View file

@ -13,7 +13,7 @@ export interface Tag {
color: string;
}
export type TableRowAction = 'delete';
export type TableRowAction = 'delete' | 'edit';
export type RowActions = {
[action in TableRowAction]?: {

View file

@ -88,7 +88,6 @@ test('when showWriteControls is true, table list view is passed editing function
createItem: expect.any(Function),
deleteItems: expect.any(Function),
editItem: expect.any(Function),
itemIsEditable: expect.any(Function),
}),
expect.any(Object) // react context
);

View file

@ -147,7 +147,6 @@ describe('useDashboardListingTable', () => {
initialPageSize: 5,
listingLimit: 20,
onFetchSuccess: expect.any(Function),
itemIsEditable: expect.any(Function),
setPageDataTestSubject: expect.any(Function),
title: 'Dashboard List',
urlStateEnabled: false,

View file

@ -283,7 +283,6 @@ export const useDashboardListingTable = ({
createItem: !showWriteControls || !showCreateDashboardButton ? undefined : createItem,
deleteItems: !showWriteControls ? undefined : deleteItems,
editItem: !showWriteControls ? undefined : editItem,
itemIsEditable: () => showWriteControls,
emptyPrompt,
entityName,
entityNamePlural,

View file

@ -61,16 +61,19 @@ export function mapHitSource(
id,
references,
updatedAt,
managed,
}: {
attributes: SavedObjectAttributes;
id: string;
references: SavedObjectReference[];
updatedAt?: string;
managed?: boolean;
}
) {
const newAttributes: {
id: string;
references: SavedObjectReference[];
managed?: boolean;
url: string;
savedObjectType?: string;
editor?: { editUrl?: string };
@ -86,6 +89,7 @@ export function mapHitSource(
references,
url: urlFor(id),
updatedAt,
managed,
...attributes,
};

View file

@ -64,6 +64,7 @@ const toTableListViewSavedObject = (savedObject: Record<string, unknown>): Visua
return {
id: savedObject.id as string,
updatedAt: savedObject.updatedAt as string,
managed: savedObject.managed as boolean,
references: savedObject.references as Array<{ id: string; type: string; name: string }>,
type: savedObject.savedObjectType as string,
icon: savedObject.icon as string,
@ -90,7 +91,7 @@ type CustomTableViewProps = Pick<
| 'editItem'
| 'contentEditor'
| 'emptyPrompt'
| 'itemIsEditable'
| 'rowItemActions'
>;
const useTableListViewProps = (
@ -260,7 +261,8 @@ const useTableListViewProps = (
editItem,
emptyPrompt: noItemsFragment,
createItem: createNewVis,
itemIsEditable: ({ attributes: { readOnly } }) => visualizeCapabilities.save && !readOnly,
rowItemActions: ({ attributes: { readOnly } }) =>
!visualizeCapabilities.save || readOnly ? { edit: { enabled: false } } : undefined,
};
return props;

View file

@ -69,9 +69,14 @@ export class ListingTableService extends FtrService {
private async getAllSelectableItemsNamesOnCurrentPage(): Promise<string[]> {
const visualizationNames = [];
const links = await this.find.allByCssSelector('.euiTableRow-isSelectable .euiLink');
for (let i = 0; i < links.length; i++) {
visualizationNames.push(await links[i].getVisibleText());
// TODO - use .euiTableRow-isSelectable when it's working again (https://github.com/elastic/eui/issues/7515)
const rows = await this.find.allByCssSelector('.euiTableRow');
for (let i = 0; i < rows.length; i++) {
const checkbox = await rows[i].findByCssSelector('.euiCheckbox__input');
if (await checkbox.isEnabled()) {
const link = await rows[i].findByCssSelector('.euiLink');
visualizationNames.push(await link.getVisibleText());
}
}
this.log.debug(`Found ${visualizationNames.length} selectable visualizations on current page`);
return visualizationNames;
@ -165,6 +170,19 @@ export class ListingTableService extends FtrService {
await inspectButtons[index].click();
}
public async inspectorFieldsReadonly() {
const disabledValues = await Promise.all([
this.testSubjects.getAttribute('nameInput', 'readonly'),
this.testSubjects.getAttribute('descriptionInput', 'readonly'),
]);
return disabledValues.every((value) => value === 'true');
}
public async closeInspector() {
await this.testSubjects.click('closeFlyoutButton');
}
/**
* Edit Visualization title and description in the flyout
*/

View file

@ -36,13 +36,14 @@ export const getLensAliasConfig = (): VisTypeAlias => ({
clientOptions: { update: { overwrite: true } },
client: getLensClient,
toListItem(savedObject) {
const { id, type, updatedAt, attributes } = savedObject;
const { id, type, updatedAt, attributes, managed } = savedObject;
const { title, description } = attributes as { title: string; description?: string };
return {
id,
title,
description,
updatedAt,
managed,
editor: { editUrl: getEditPath(id), editApp: 'lens' },
icon: 'lensApp',
stage: 'production',

View file

@ -39,7 +39,7 @@ export function getMapsVisTypeAlias() {
searchFields: ['title^3'],
client: getMapClient,
toListItem(mapItem: MapItem) {
const { id, type, updatedAt, attributes } = mapItem;
const { id, type, updatedAt, attributes, managed } = mapItem;
const { title, description } = attributes;
return {
@ -47,6 +47,7 @@ export function getMapsVisTypeAlias() {
title,
description,
updatedAt,
managed,
editor: {
editUrl: getEditPath(id),
editApp: APP_ID,

View file

@ -16,12 +16,15 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
'common',
'discover',
'maps',
'visualize',
'dashboard',
]);
const kibanaServer = getService('kibanaServer');
const esArchiver = getService('esArchiver');
const testSubjects = getService('testSubjects');
const dashboardAddPanel = getService('dashboardAddPanel');
const listingTable = getService('listingTable');
const log = getService('log');
describe('Managed Content', () => {
before(async () => {
@ -32,6 +35,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
after(async () => {
esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional');
kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/managed_content');
kibanaServer.importExport.savedObjects.clean({ types: ['dashboard'] }); // we do create a new dashboard in this test
});
describe('preventing the user from overwriting managed content', () => {
@ -122,6 +126,43 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
});
describe('library views', () => {
const assertInspectorReadonly = async (name: string) => {
log.debug(`making sure table list inspector for ${name} is read-only`);
await listingTable.searchForItemWithName(name);
await listingTable.waitUntilTableIsLoaded();
await listingTable.inspectVisualization();
expect(await listingTable.inspectorFieldsReadonly()).to.be(true);
await listingTable.closeInspector();
};
it('visualize library: managed content is read-only', async () => {
await PageObjects.visualize.gotoVisualizationLandingPage();
const deletableItems = await listingTable.getAllSelectableItemsNames();
expect(deletableItems).to.eql([
'Unmanaged lens vis',
'Unmanaged legacy visualization',
'Unmanaged map',
]);
await assertInspectorReadonly('Managed lens vis');
await assertInspectorReadonly('Managed legacy visualization');
await assertInspectorReadonly('Managed map');
});
it('dashboard library: managed content is read-only', async () => {
await PageObjects.dashboard.gotoDashboardListingURL();
const deletableItems = await listingTable.getAllSelectableItemsNames();
expect(deletableItems).to.eql([]);
await assertInspectorReadonly('Managed dashboard');
});
});
describe('managed panels in dashboards', () => {
it('inlines panels when managed dashboard cloned', async () => {
await PageObjects.common.navigateToActualUrl(