[Content management] Add "Last updated" metadata to TableListView (#132321)

This commit is contained in:
Sébastien Loix 2022-05-19 17:18:21 +01:00 committed by GitHub
parent 8de3401dff
commit a80bfb7283
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 344 additions and 67 deletions

View file

@ -98,12 +98,16 @@ export class SavedObjectLoader {
mapHitSource(
source: Record<string, unknown>,
id: string,
references: SavedObjectReference[] = []
) {
source.id = id;
source.url = this.urlFor(id);
source.references = references;
return source;
references: SavedObjectReference[] = [],
updatedAt?: string
): Record<string, unknown> {
return {
...source,
id,
url: this.urlFor(id),
references,
updatedAt,
};
}
/**
@ -116,12 +120,14 @@ export class SavedObjectLoader {
attributes,
id,
references = [],
updatedAt,
}: {
attributes: Record<string, unknown>;
id: string;
references?: SavedObjectReference[];
updatedAt?: string;
}) {
return this.mapHitSource(attributes, id, references);
return this.mapHitSource(attributes, id, references, updatedAt);
}
/**

View file

@ -129,6 +129,7 @@ exports[`TableListView render list view 1`] = `
}
/>
}
onChange={[Function]}
pagination={
Object {
"initialPageIndex": 0,
@ -155,7 +156,11 @@ exports[`TableListView render list view 1`] = `
"toolsLeft": undefined,
}
}
sorting={true}
sorting={
Object {
"sort": undefined,
}
}
tableCaption="test caption"
tableLayout="fixed"
/>

View file

@ -7,13 +7,24 @@
*/
import { EuiEmptyPrompt } from '@elastic/eui';
import { shallowWithIntl } from '@kbn/test-jest-helpers';
import { shallowWithIntl, registerTestBed, TestBed } from '@kbn/test-jest-helpers';
import { ToastsStart } from '@kbn/core/public';
import React from 'react';
import moment, { Moment } from 'moment';
import { act } from 'react-dom/test-utils';
import { themeServiceMock, applicationServiceMock } from '@kbn/core/public/mocks';
import { TableListView } from './table_list_view';
import { TableListView, TableListViewProps } from './table_list_view';
const requiredProps = {
jest.mock('lodash', () => {
const original = jest.requireActual('lodash');
return {
...original,
debounce: (handler: () => void) => handler,
};
});
const requiredProps: TableListViewProps<Record<string, unknown>> = {
entityName: 'test',
entityNamePlural: 'tests',
listingLimit: 5,
@ -30,6 +41,14 @@ const requiredProps = {
};
describe('TableListView', () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
test('render default empty prompt', async () => {
const component = shallowWithIntl(<TableListView {...requiredProps} />);
@ -81,4 +100,149 @@ describe('TableListView', () => {
expect(component).toMatchSnapshot();
});
describe('default columns', () => {
let testBed: TestBed;
const tableColumns = [
{
field: 'title',
name: 'Title',
sortable: true,
},
{
field: 'description',
name: 'Description',
sortable: true,
},
];
const twoDaysAgo = new Date(new Date().setDate(new Date().getDate() - 2));
const yesterday = new Date(new Date().setDate(new Date().getDate() - 1));
const hits = [
{
title: 'Item 1',
description: 'Item 1 description',
updatedAt: twoDaysAgo,
},
{
title: 'Item 2',
description: 'Item 2 description',
// This is the latest updated and should come first in the table
updatedAt: yesterday,
},
];
const findItems = jest.fn(() => Promise.resolve({ total: hits.length, hits }));
const defaultProps: TableListViewProps<Record<string, unknown>> = {
...requiredProps,
tableColumns,
findItems,
createItem: () => undefined,
};
const setup = registerTestBed(TableListView, { defaultProps });
test('should add a "Last updated" column if "updatedAt" is provided', async () => {
await act(async () => {
testBed = await setup();
});
const { component, table } = testBed!;
component.update();
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
['Item 2', 'Item 2 description', 'yesterday'], // Comes first as it is the latest updated
['Item 1', 'Item 1 description', '2 days ago'],
]);
});
test('should not display relative time for items updated more than 7 days ago', async () => {
const updatedAtValues: Moment[] = [];
const updatedHits = hits.map(({ title, description }, i) => {
const updatedAt = new Date(new Date().setDate(new Date().getDate() - (7 + i)));
updatedAtValues[i] = moment(updatedAt);
return {
title,
description,
updatedAt,
};
});
await act(async () => {
testBed = await setup({
findItems: jest.fn(() =>
Promise.resolve({
total: updatedHits.length,
hits: updatedHits,
})
),
});
});
const { component, table } = testBed!;
component.update();
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
// Renders the datetime with this format: "05/10/2022 @ 2:34 PM"
['Item 1', 'Item 1 description', updatedAtValues[0].format('LL')],
['Item 2', 'Item 2 description', updatedAtValues[1].format('LL')],
]);
});
test('should not add a "Last updated" column if no "updatedAt" is provided', async () => {
await act(async () => {
testBed = await setup({
findItems: jest.fn(() =>
Promise.resolve({
total: hits.length,
hits: hits.map(({ title, description }) => ({ title, description })),
})
),
});
});
const { component, table } = testBed!;
component.update();
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
['Item 1', 'Item 1 description'], // Sorted by title
['Item 2', 'Item 2 description'],
]);
});
test('should not display anything if there is no updatedAt metadata for an item', async () => {
await act(async () => {
testBed = await setup({
findItems: jest.fn(() =>
Promise.resolve({
total: hits.length + 1,
hits: [...hits, { title: 'Item 3', description: 'Item 3 description' }],
})
),
});
});
const { component, table } = testBed!;
component.update();
const { tableCellsValues } = table.getMetaData('itemsInMemTable');
expect(tableCellsValues).toEqual([
['Item 2', 'Item 2 description', 'yesterday'],
['Item 1', 'Item 1 description', '2 days ago'],
['Item 3', 'Item 3 description', '-'], // Empty column as no updatedAt provided
]);
});
});
});

View file

@ -13,16 +13,21 @@ import {
EuiConfirmModal,
EuiEmptyPrompt,
EuiInMemoryTable,
Criteria,
PropertySort,
Direction,
EuiLink,
EuiSpacer,
EuiTableActionsColumnType,
SearchFilterConfig,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { FormattedMessage, FormattedRelative } from '@kbn/i18n-react';
import { ThemeServiceStart, HttpFetchError, ToastsStart, ApplicationStart } from '@kbn/core/public';
import { debounce, keyBy, sortBy, uniq } from 'lodash';
import React from 'react';
import moment from 'moment';
import { KibanaPageTemplate } from '../page_template';
import { toMountPoint } from '../util';
@ -64,6 +69,7 @@ export interface TableListViewProps<V> {
export interface TableListViewState<V> {
items: V[];
hasInitialFetchReturned: boolean;
hasUpdatedAtMetadata: boolean | null;
isFetchingItems: boolean;
isDeletingItems: boolean;
showDeleteModal: boolean;
@ -72,6 +78,10 @@ export interface TableListViewState<V> {
filter: string;
selectedIds: string[];
totalItems: number;
tableSort?: {
field: keyof V;
direction: Direction;
};
}
// saved object client does not support sorting by title because title is only mapped as analyzed
@ -94,10 +104,12 @@ class TableListView<V extends {}> extends React.Component<
initialPageSize: props.initialPageSize,
pageSizeOptions: uniq([10, 20, 50, props.initialPageSize]).sort(),
};
this.state = {
items: [],
totalItems: 0,
hasInitialFetchReturned: false,
hasUpdatedAtMetadata: null,
isFetchingItems: false,
isDeletingItems: false,
showDeleteModal: false,
@ -120,6 +132,28 @@ class TableListView<V extends {}> extends React.Component<
this.fetchItems();
}
componentDidUpdate(prevProps: TableListViewProps<V>, prevState: TableListViewState<V>) {
if (this.state.hasUpdatedAtMetadata === null && prevState.items !== this.state.items) {
// We check if the saved object have the "updatedAt" metadata
// to render or not that column in the table
const hasUpdatedAtMetadata = Boolean(
this.state.items.find((item: { updatedAt?: string }) => Boolean(item.updatedAt))
);
this.setState((prev) => {
return {
hasUpdatedAtMetadata,
tableSort: hasUpdatedAtMetadata
? {
field: 'updatedAt' as keyof V,
direction: 'desc' as const,
}
: prev.tableSort,
};
});
}
}
debouncedFetch = debounce(async (filter: string) => {
try {
const response = await this.props.findItems(filter);
@ -420,6 +454,12 @@ class TableListView<V extends {}> extends React.Component<
);
}
onTableChange(criteria: Criteria<V>) {
if (criteria.sort) {
this.setState({ tableSort: criteria.sort });
}
}
renderTable() {
const { searchFilters } = this.props;
@ -435,24 +475,6 @@ class TableListView<V extends {}> extends React.Component<
}
: undefined;
const actions: EuiTableActionsColumnType<V>['actions'] = [
{
name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', {
defaultMessage: 'Edit',
}),
description: i18n.translate(
'kibana-react.tableListView.listing.table.editActionDescription',
{
defaultMessage: 'Edit',
}
),
icon: 'pencil',
type: 'icon',
enabled: (v) => !(v as unknown as { error: string })?.error,
onClick: this.props.editItem,
},
];
const search = {
onChange: this.setFilter.bind(this),
toolsLeft: this.renderToolsLeft(),
@ -464,8 +486,102 @@ class TableListView<V extends {}> extends React.Component<
filters: searchFilters ?? [],
};
const noItemsMessage = (
<FormattedMessage
id="kibana-react.tableListView.listing.noMatchedItemsMessage"
defaultMessage="No {entityNamePlural} matched your search."
values={{ entityNamePlural: this.props.entityNamePlural }}
/>
);
return (
<EuiInMemoryTable
itemId="id"
items={this.state.items}
columns={this.getTableColumns()}
pagination={this.pagination}
loading={this.state.isFetchingItems}
message={noItemsMessage}
selection={selection}
search={search}
sorting={{ sort: this.state.tableSort as PropertySort }}
onChange={this.onTableChange.bind(this)}
data-test-subj="itemsInMemTable"
rowHeader={this.props.rowHeader}
tableCaption={this.props.tableCaption}
/>
);
}
getTableColumns() {
const columns = this.props.tableColumns.slice();
// Add "Last update" column
if (this.state.hasUpdatedAtMetadata) {
const renderUpdatedAt = (dateTime?: string) => {
if (!dateTime) {
return (
<EuiToolTip
content={i18n.translate('kibana-react.tableListView.updatedDateUnknownLabel', {
defaultMessage: 'Last updated unknown',
})}
>
<span>-</span>
</EuiToolTip>
);
}
const updatedAt = moment(dateTime);
if (updatedAt.diff(moment(), 'days') > -7) {
return (
<FormattedRelative value={new Date(dateTime).getTime()}>
{(formattedDate: string) => (
<EuiToolTip content={updatedAt.format('LL LT')}>
<span>{formattedDate}</span>
</EuiToolTip>
)}
</FormattedRelative>
);
}
return (
<EuiToolTip content={updatedAt.format('LL LT')}>
<span>{updatedAt.format('LL')}</span>
</EuiToolTip>
);
};
columns.push({
field: 'updatedAt',
name: i18n.translate('kibana-react.tableListView.lastUpdatedColumnTitle', {
defaultMessage: 'Last updated',
}),
render: (field: string, record: { updatedAt?: string }) =>
renderUpdatedAt(record.updatedAt),
sortable: true,
width: '150px',
});
}
// Add "Actions" column
if (this.props.editItem) {
const actions: EuiTableActionsColumnType<V>['actions'] = [
{
name: i18n.translate('kibana-react.tableListView.listing.table.editActionName', {
defaultMessage: 'Edit',
}),
description: i18n.translate(
'kibana-react.tableListView.listing.table.editActionDescription',
{
defaultMessage: 'Edit',
}
),
icon: 'pencil',
type: 'icon',
enabled: (v) => !(v as unknown as { error: string })?.error,
onClick: this.props.editItem,
},
];
columns.push({
name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', {
defaultMessage: 'Actions',
@ -475,29 +591,7 @@ class TableListView<V extends {}> extends React.Component<
});
}
const noItemsMessage = (
<FormattedMessage
id="kibana-react.tableListView.listing.noMatchedItemsMessage"
defaultMessage="No {entityNamePlural} matched your search."
values={{ entityNamePlural: this.props.entityNamePlural }}
/>
);
return (
<EuiInMemoryTable
itemId="id"
items={this.state.items}
columns={columns}
pagination={this.pagination}
loading={this.state.isFetchingItems}
message={noItemsMessage}
selection={selection}
search={search}
sorting={true}
data-test-subj="itemsInMemTable"
rowHeader={this.props.rowHeader}
tableCaption={this.props.tableCaption}
/>
);
return columns;
}
renderCreateButton() {

View file

@ -64,10 +64,12 @@ export function mapHitSource(
attributes,
id,
references,
updatedAt,
}: {
attributes: SavedObjectAttributes;
id: string;
references: SavedObjectReference[];
updatedAt?: string;
}
) {
const newAttributes: {
@ -76,6 +78,7 @@ export function mapHitSource(
url: string;
savedObjectType?: string;
editUrl?: string;
updatedAt?: string;
type?: BaseVisType;
icon?: BaseVisType['icon'];
image?: BaseVisType['image'];
@ -85,6 +88,7 @@ export function mapHitSource(
id,
references,
url: urlFor(id),
updatedAt,
...attributes,
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { SavedObject } from '@kbn/core/types/saved_objects';
import type { SimpleSavedObject } from '@kbn/core/public';
import { BaseVisType } from './base_vis_type';
export type VisualizationStage = 'experimental' | 'beta' | 'production';
@ -30,7 +30,7 @@ export interface VisualizationListItem {
export interface VisualizationsAppExtension {
docTypes: string[];
searchFields?: string[];
toListItem: (savedObject: SavedObject) => VisualizationListItem;
toListItem: (savedObject: SimpleSavedObject) => VisualizationListItem;
}
export interface VisTypeAlias {

View file

@ -53,6 +53,7 @@ function mapHits(hit: any, url: string): GraphWorkspaceSavedObject {
const source = hit.attributes;
source.id = hit.id;
source.url = url;
source.updatedAt = hit.updatedAt;
source.icon = 'fa-share-alt'; // looks like a graph
return source;
}

View file

@ -31,12 +31,13 @@ export const getLensAliasConfig = (): VisTypeAlias => ({
docTypes: ['lens'],
searchFields: ['title^3'],
toListItem(savedObject) {
const { id, type, attributes } = savedObject;
const { id, type, updatedAt, attributes } = savedObject;
const { title, description } = attributes as { title: string; description?: string };
return {
id,
title,
description,
updatedAt,
editUrl: getEditPath(id),
editApp: 'lens',
icon: 'lensApp',

View file

@ -7,8 +7,6 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import { SavedObject } from '@kbn/core/types/saved_objects';
export type MapSavedObjectAttributes = {
title: string;
description?: string;
@ -16,5 +14,3 @@ export type MapSavedObjectAttributes = {
layerListJSON?: string;
uiStateJSON?: string;
};
export type MapSavedObject = SavedObject<MapSavedObjectAttributes>;

View file

@ -7,8 +7,9 @@
import { i18n } from '@kbn/i18n';
import type { VisualizationsSetup, VisualizationStage } from '@kbn/visualizations-plugin/public';
import type { SimpleSavedObject } from '@kbn/core/public';
import type { SavedObject } from '@kbn/core/types/saved_objects';
import type { MapSavedObject } from '../common/map_saved_object_type';
import type { MapSavedObjectAttributes } from '../common/map_saved_object_type';
import {
APP_ID,
APP_ICON,
@ -38,12 +39,15 @@ export function getMapsVisTypeAlias(visualizations: VisualizationsSetup) {
docTypes: [MAP_SAVED_OBJECT_TYPE],
searchFields: ['title^3'],
toListItem(savedObject: SavedObject) {
const { id, type, attributes } = savedObject as MapSavedObject;
const { id, type, updatedAt, attributes } =
savedObject as SimpleSavedObject<MapSavedObjectAttributes>;
const { title, description } = attributes;
return {
id,
title,
description,
updatedAt,
editUrl: getEditPath(id),
editApp: APP_ID,
icon: APP_ICON,

View file

@ -113,6 +113,7 @@ async function findMaps(searchQuery: string) {
title: savedObject.attributes.title,
description: savedObject.attributes.description,
references: savedObject.references,
updatedAt: savedObject.updatedAt,
};
}),
};

View file

@ -6,13 +6,13 @@
*/
import { asyncForEach } from '@kbn/std';
import { ISavedObjectsRepository } from '@kbn/core/server';
import type { ISavedObjectsRepository, SavedObject } from '@kbn/core/server';
import { MAP_SAVED_OBJECT_TYPE } from '../../common/constants';
import { MapSavedObject, MapSavedObjectAttributes } from '../../common/map_saved_object_type';
import type { MapSavedObjectAttributes } from '../../common/map_saved_object_type';
export async function findMaps(
savedObjectsClient: Pick<ISavedObjectsRepository, 'find'>,
callback: (savedObject: MapSavedObject) => Promise<void>
callback: (savedObject: SavedObject<MapSavedObjectAttributes>) => Promise<void>
) {
let nextPage = 1;
let hasMorePages = false;

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { SavedObject } from '@kbn/core/server';
import { asyncForEach } from '@kbn/std';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { DataViewsService } from '@kbn/data-views-plugin/common';
@ -15,7 +16,7 @@ import {
ESSearchSourceDescriptor,
LayerDescriptor,
} from '../../../common/descriptor_types';
import { MapSavedObject } from '../../../common/map_saved_object_type';
import type { MapSavedObjectAttributes } from '../../../common/map_saved_object_type';
import { IndexPatternStats } from './types';
/*
@ -29,7 +30,7 @@ export class IndexPatternStatsCollector {
this._indexPatternsService = indexPatternService;
}
async push(savedObject: MapSavedObject) {
async push(savedObject: SavedObject<MapSavedObjectAttributes>) {
let layerList: LayerDescriptor[] = [];
try {
const { attributes } = injectReferences(savedObject);