[Files] Allow option to disable delete action in mgt UI (#155179)

This commit is contained in:
Sébastien Loix 2023-04-24 16:45:41 +01:00 committed by GitHub
parent 3d78370aa5
commit 29a10fddc9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 294 additions and 21 deletions

View file

@ -8,5 +8,5 @@
export { TableListView, TableListViewProvider, TableListViewKibanaProvider } from './src';
export type { UserContentCommonSchema } from './src';
export type { UserContentCommonSchema, RowActions } from './src';
export type { TableListViewKibanaDependencies } from './src/services';

View file

@ -17,7 +17,9 @@ import {
SearchFilterConfig,
Direction,
Query,
type EuiTableSelectionType,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useServices } from '../services';
import type { Action } from '../actions';
@ -26,6 +28,7 @@ import type {
Props as TableListViewProps,
UserContentCommonSchema,
} from '../table_list_view';
import type { TableItemsRowActions } from '../types';
import { TableSortSelect } from './table_sort_select';
import { TagFilterPanel } from './tag_filter_panel';
import { useTagFilterPanel } from './use_tag_filter_panel';
@ -51,6 +54,7 @@ interface Props<T extends UserContentCommonSchema> extends State<T>, TagManageme
tableColumns: Array<EuiBasicTableColumn<T>>;
hasUpdatedAtMetadata: boolean;
deleteItems: TableListViewProps<T>['deleteItems'];
tableItemsRowActions: TableItemsRowActions;
onSortChange: (column: SortColumnField, direction: Direction) => void;
onTableChange: (criteria: CriteriaWithPagination<T>) => void;
onTableSearchChange: (arg: { query: Query | null; queryText: string }) => void;
@ -70,6 +74,7 @@ export function Table<T extends UserContentCommonSchema>({
entityName,
entityNamePlural,
tagsToTableItemMap,
tableItemsRowActions,
deleteItems,
tableCaption,
onTableChange,
@ -105,13 +110,32 @@ export function Table<T extends UserContentCommonSchema>({
);
}, [deleteItems, dispatch, entityName, entityNamePlural, selectedIds.length]);
const selection = deleteItems
? {
const selection = useMemo<EuiTableSelectionType<T> | undefined>(() => {
if (deleteItems) {
return {
onSelectionChange: (obj: T[]) => {
dispatch({ type: 'onSelectionChange', data: obj });
},
}
: undefined;
selectable: (obj) => {
const actions = tableItemsRowActions[obj.id];
return actions?.delete?.enabled !== false;
},
selectableMessage: (selectable, obj) => {
if (!selectable) {
const actions = tableItemsRowActions[obj.id];
return (
actions?.delete?.reason ??
i18n.translate('contentManagement.tableList.actionsDisabledLabel', {
defaultMessage: 'Actions disabled for this item',
})
);
}
return '';
},
initialSelected: [],
};
}
}, [deleteItems, dispatch, tableItemsRowActions]);
const {
isPopoverOpen,
@ -214,6 +238,7 @@ export function Table<T extends UserContentCommonSchema>({
data-test-subj="itemsInMemTable"
rowHeader="attributes.title"
tableCaption={tableCaption}
isSelectable
/>
);
}

View file

@ -15,3 +15,5 @@ export type {
} from './table_list_view';
export { TableListViewProvider, TableListViewKibanaProvider } from './services';
export type { RowActions } from './types';

View file

@ -1067,4 +1067,109 @@ describe('TableListView', () => {
expect(router?.history.location?.search).toBe('?sort=title&sortdir=desc');
});
});
describe('row item actions', () => {
const hits: UserContentCommonSchema[] = [
{
id: '123',
updatedAt: twoDaysAgo.toISOString(),
type: 'dashboard',
attributes: {
title: 'Item 1',
description: 'Item 1 description',
},
references: [],
},
{
id: '456',
updatedAt: yesterday.toISOString(),
type: 'dashboard',
attributes: {
title: 'Item 2',
description: 'Item 2 description',
},
references: [],
},
];
const setupTest = async (props?: Partial<TableListViewProps>) => {
let testBed: TestBed | undefined;
const deleteItems = jest.fn();
await act(async () => {
testBed = await setup({
findItems: jest.fn().mockResolvedValue({ total: hits.length, hits }),
deleteItems,
...props,
});
});
testBed!.component.update();
return { testBed: testBed!, deleteItems };
};
test('should allow select items to be deleted', async () => {
const {
testBed: { table, find, exists, component, form },
deleteItems,
} = await setupTest();
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
['', 'Item 2Item 2 description', yesterdayToString], // First empty col is the "checkbox"
['', 'Item 1Item 1 description', twoDaysAgoToString],
]);
const selectedHit = hits[1];
expect(exists('deleteSelectedItems')).toBe(false);
act(() => {
// Select the second item
form.selectCheckBox(`checkboxSelectRow-${selectedHit.id}`);
});
component.update();
// Delete button is now visible
expect(exists('deleteSelectedItems')).toBe(true);
// Click delete and validate that confirm modal opens
expect(component.exists('.euiModal--confirmation')).toBe(false);
act(() => {
find('deleteSelectedItems').simulate('click');
});
component.update();
expect(component.exists('.euiModal--confirmation')).toBe(true);
await act(async () => {
find('confirmModalConfirmButton').simulate('click');
});
expect(deleteItems).toHaveBeenCalledWith([selectedHit]);
});
test('should allow to disable the "delete" action for a row', async () => {
const reasonMessage = 'This file cannot be deleted.';
const {
testBed: { find },
} = await setupTest({
rowItemActions: (obj) => {
if (obj.id === hits[1].id) {
return {
delete: {
enabled: false,
reason: reasonMessage,
},
};
}
},
});
const firstCheckBox = find(`checkboxSelectRow-${hits[0].id}`);
const secondCheckBox = find(`checkboxSelectRow-${hits[1].id}`);
expect(firstCheckBox.props().disabled).toBe(false);
expect(secondCheckBox.props().disabled).toBe(true);
// EUI changes the check "title" from "Select this row" to the reason to disable the checkbox
expect(secondCheckBox.props().title).toBe(reasonMessage);
});
});
});

View file

@ -42,6 +42,7 @@ import { getReducer } from './reducer';
import type { SortColumnField } from './components';
import { useTags } from './use_tags';
import { useInRouterContext, useUrlState } from './use_url_state';
import { RowActions, TableItemsRowActions } from './types';
interface ContentEditorConfig
extends Pick<OpenContentEditorParams, 'isReadonly' | 'onSave' | 'customValidators'> {
@ -67,6 +68,11 @@ export interface Props<T extends UserContentCommonSchema = UserContentCommonSche
headingId?: string;
/** An optional id for the listing. Used to generate unique data-test-subj. Default: "userContent" */
id?: string;
/**
* Configuration of the table row item actions. Disable specific action for a table row item.
* Currently only the "delete" ite action can be disabled.
*/
rowItemActions?: (obj: T) => RowActions | undefined;
children?: ReactNode | undefined;
findItems(
searchQuery: string,
@ -241,6 +247,7 @@ function TableListViewComp<T extends UserContentCommonSchema>({
urlStateEnabled = true,
customTableColumn,
emptyPrompt,
rowItemActions,
findItems,
createItem,
editItem,
@ -580,6 +587,15 @@ function TableListViewComp<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
// ------------
@ -854,6 +870,20 @@ function TableListViewComp<T extends UserContentCommonSchema>({
};
}, []);
const PageTemplate = useMemo<typeof KibanaPageTemplate>(() => {
return withoutPageTemplateWrapper
? ((({
children: _children,
'data-test-subj': dataTestSubj,
}: {
children: React.ReactNode;
['data-test-subj']?: string;
}) => (
<div data-test-subj={dataTestSubj}>{_children}</div>
)) as unknown as typeof KibanaPageTemplate)
: KibanaPageTemplate;
}, [withoutPageTemplateWrapper]);
// ------------
// Render
// ------------
@ -861,10 +891,6 @@ function TableListViewComp<T extends UserContentCommonSchema>({
return null;
}
const PageTemplate = withoutPageTemplateWrapper
? (React.Fragment as unknown as typeof KibanaPageTemplate)
: KibanaPageTemplate;
if (!showFetchError && hasNoItems) {
return (
<PageTemplate panelled isEmptyState={true} data-test-subj={pageDataTestSubject}>
@ -929,6 +955,7 @@ function TableListViewComp<T extends UserContentCommonSchema>({
tagsToTableItemMap={tagsToTableItemMap}
deleteItems={deleteItems}
tableCaption={tableListTitle}
tableItemsRowActions={tableItemsRowActions}
onTableChange={onTableChange}
onTableSearchChange={onTableSearchChange}
onSortChange={onSortChange}

View file

@ -12,3 +12,16 @@ export interface Tag {
description: string;
color: string;
}
export type TableRowAction = 'delete';
export type RowActions = {
[action in TableRowAction]?: {
enabled: boolean;
reason?: string;
};
};
export interface TableItemsRowActions {
[id: string]: RowActions | undefined;
}

View file

@ -27,6 +27,7 @@ export interface BaseFilesClient<M = unknown> {
find: (
args: {
kind?: string | string[];
kindToExclude?: string | string[];
status?: string | string[];
extension?: string | string[];
name?: string | string[];

View file

@ -250,6 +250,22 @@ export interface FileKindBrowser extends FileKindBase {
* @default 4MiB
*/
maxSizeBytes?: number;
/**
* Allowed actions that can be done in the File Management UI. If not provided, all actions are allowed
*
*/
managementUiActions?: {
/** Allow files to be listed in management UI */
list?: {
enabled: boolean;
};
/** Allow files to be deleted in management UI */
delete?: {
enabled: boolean;
/** If delete is not enabled in management UI, specify the reason (will appear in a tooltip). */
reason?: string;
};
};
}
/**

View file

@ -35,7 +35,10 @@ export interface FilesSetup {
registerFileKind(fileKind: FileKindBrowser): void;
}
export type FilesStart = Pick<FilesSetup, 'filesClientFactory'>;
export type FilesStart = Pick<FilesSetup, 'filesClientFactory'> & {
getFileKindDefinition: (id: string) => FileKindBrowser;
getAllFindKindDefinitions: () => FileKindBrowser[];
};
/**
* Bringing files to Kibana
@ -77,6 +80,12 @@ export class FilesPlugin implements Plugin<FilesSetup, FilesStart> {
start(core: CoreStart): FilesStart {
return {
filesClientFactory: this.filesClientFactory!,
getFileKindDefinition: (id: string): FileKindBrowser => {
return this.registry.get(id);
},
getAllFindKindDefinitions: (): FileKindBrowser[] => {
return this.registry.getAll();
},
};
}
}

View file

@ -24,6 +24,7 @@ export function filterArgsToKuery({
extension,
mimeType,
kind,
kindToExclude,
meta,
name,
status,
@ -50,12 +51,27 @@ export function filterArgsToKuery({
}
};
const addExcludeFilters = (fieldName: keyof FileMetadata | string, values: string[] = []) => {
if (values.length) {
const andExpressions = values
.filter(Boolean)
.map((value) =>
nodeTypes.function.buildNode(
'not',
nodeBuilder.is(`${attrPrefix}.${fieldName}`, escapeKuery(value))
)
);
kueryExpressions.push(nodeBuilder.and(andExpressions));
}
};
addFilters('name', name, true);
addFilters('FileKind', kind);
addFilters('Status', status);
addFilters('extension', extension);
addFilters('mime_type', mimeType);
addFilters('user.id', user);
addExcludeFilters('FileKind', kindToExclude);
if (meta) {
const addMetaFilters = pipe(

View file

@ -82,6 +82,10 @@ export interface FindFileArgs extends Pagination {
* File kind(s), see {@link FileKind}.
*/
kind?: string[];
/**
* File kind(s) to exclude from search, see {@link FileKind}.
*/
kindToExclude?: string[];
/**
* File name(s).
*/

View file

@ -157,26 +157,39 @@ describe('FileService', () => {
createDisposableFile({ fileKind, name: 'foo-2' }),
createDisposableFile({ fileKind, name: 'foo-3' }),
createDisposableFile({ fileKind, name: 'test-3' }),
createDisposableFile({ fileKind: fileKindNonDefault, name: 'foo-1' }),
]);
{
const { files, total } = await fileService.find({
kind: [fileKind],
kind: [fileKind, fileKindNonDefault],
name: ['foo*'],
perPage: 2,
page: 1,
});
expect(files.length).toBe(2);
expect(total).toBe(3);
expect(total).toBe(4);
}
{
const { files, total } = await fileService.find({
kind: [fileKind],
kind: [fileKind, fileKindNonDefault],
name: ['foo*'],
perPage: 2,
page: 2,
});
expect(files.length).toBe(1);
expect(files.length).toBe(2);
expect(total).toBe(4);
}
// Filter out fileKind
{
const { files, total } = await fileService.find({
kindToExclude: [fileKindNonDefault],
name: ['foo*'],
perPage: 10,
page: 1,
});
expect(files.length).toBe(3); // foo-1 from fileKindNonDefault not returned
expect(total).toBe(3);
}
});

View file

@ -30,6 +30,7 @@ export function toArrayOrUndefined(val?: string | string[]): undefined | string[
const rt = {
body: schema.object({
kind: schema.maybe(stringOrArrayOfStrings),
kindToExclude: schema.maybe(stringOrArrayOfStrings),
status: schema.maybe(stringOrArrayOfStrings),
extension: schema.maybe(stringOrArrayOfStrings),
name: schema.maybe(nameStringOrArrayOfNameStrings),
@ -50,12 +51,13 @@ export type Endpoint = CreateRouteDefinition<
const handler: CreateHandler<Endpoint> = async ({ files }, req, res) => {
const { fileService } = await files;
const {
body: { meta, extension, kind, name, status },
body: { meta, extension, kind, name, status, kindToExclude },
query,
} = req;
const { files: results, total } = await fileService.asCurrentUser().find({
kind: toArrayOrUndefined(kind),
kindToExclude: toArrayOrUndefined(kindToExclude),
name: toArrayOrUndefined(name),
status: toArrayOrUndefined(status),
extension: toArrayOrUndefined(extension),

View file

@ -12,20 +12,33 @@ import { EuiButtonEmpty } from '@elastic/eui';
import { TableListView, UserContentCommonSchema } from '@kbn/content-management-table-list';
import numeral from '@elastic/numeral';
import type { FileJSON } from '@kbn/files-plugin/common';
import { useFilesManagementContext } from './context';
import { i18nTexts } from './i18n_texts';
import { EmptyPrompt, DiagnosticsFlyout, FileFlyout } from './components';
type FilesUserContentSchema = UserContentCommonSchema;
type FilesUserContentSchema = Omit<UserContentCommonSchema, 'attributes'> & {
attributes: {
title: string;
description?: string;
fileKind: string;
};
};
function naivelyFuzzify(query: string): string {
return query.includes('*') ? query : `*${query}*`;
}
export const App: FunctionComponent = () => {
const { filesClient } = useFilesManagementContext();
const { filesClient, getFileKindDefinition, getAllFindKindDefinitions } =
useFilesManagementContext();
const [showDiagnosticsFlyout, setShowDiagnosticsFlyout] = useState<boolean>(false);
const [selectedFile, setSelectedFile] = useState<undefined | FileJSON>(undefined);
const kindToExcludeFromSearch = getAllFindKindDefinitions()
.filter(({ managementUiActions }) => managementUiActions?.list?.enabled === false)
.map(({ id }) => id);
return (
<div data-test-subj="filesManagementApp">
<TableListView<FilesUserContentSchema>
@ -37,7 +50,10 @@ export const App: FunctionComponent = () => {
entityNamePlural={i18nTexts.entityNamePlural}
findItems={(searchQuery) =>
filesClient
.find({ name: searchQuery ? naivelyFuzzify(searchQuery) : undefined })
.find({
name: searchQuery ? naivelyFuzzify(searchQuery) : undefined,
kindToExclude: kindToExcludeFromSearch,
})
.then(({ files, total }) => ({
hits: files.map((file) => ({
id: file.id,
@ -71,6 +87,12 @@ export const App: FunctionComponent = () => {
{i18nTexts.diagnosticsFlyoutTitle}
</EuiButtonEmpty>,
]}
rowItemActions={({ attributes }) => {
const definition = getFileKindDefinition(attributes.fileKind);
return {
delete: definition?.managementUiActions?.delete,
};
}}
/>
{showDiagnosticsFlyout && (
<DiagnosticsFlyout onClose={() => setShowDiagnosticsFlyout(false)} />

View file

@ -12,9 +12,16 @@ import type { AppContext } from './types';
const FilesManagementAppContext = createContext<AppContext>(null as unknown as AppContext);
export const FilesManagementAppContextProvider: FC<AppContext> = ({ children, filesClient }) => {
export const FilesManagementAppContextProvider: FC<AppContext> = ({
children,
filesClient,
getFileKindDefinition,
getAllFindKindDefinitions,
}) => {
return (
<FilesManagementAppContext.Provider value={{ filesClient }}>
<FilesManagementAppContext.Provider
value={{ filesClient, getFileKindDefinition, getAllFindKindDefinitions }}
>
{children}
</FilesManagementAppContext.Provider>
);

View file

@ -101,4 +101,7 @@ export const i18nTexts = {
defaultMessage: 'Upload error',
}),
} as Record<FileStatus, string>,
rowCheckboxDisabled: i18n.translate('filesManagement.table.checkBoxDisabledLabel', {
defaultMessage: 'This file cannot be deleted.',
}),
};

View file

@ -30,6 +30,10 @@ export const mountManagementSection = (
startDeps: StartDependencies,
{ element, history }: ManagementAppMountParams
) => {
const {
files: { filesClientFactory, getAllFindKindDefinitions, getFileKindDefinition },
} = startDeps;
ReactDOM.render(
<I18nProvider>
<QueryClientProvider client={queryClient}>
@ -41,7 +45,9 @@ export const mountManagementSection = (
}}
>
<FilesManagementAppContextProvider
filesClient={startDeps.files.filesClientFactory.asUnscoped()}
filesClient={filesClientFactory.asUnscoped()}
getFileKindDefinition={getFileKindDefinition}
getAllFindKindDefinitions={getAllFindKindDefinitions}
>
<Router history={history}>
<Route path="/" component={App} />

View file

@ -11,6 +11,8 @@ import { ManagementSetup } from '@kbn/management-plugin/public';
export interface AppContext {
filesClient: FilesClient;
getFileKindDefinition: FilesStart['getFileKindDefinition'];
getAllFindKindDefinitions: FilesStart['getAllFindKindDefinitions'];
}
export interface SetupDependencies {