From a80bfb7283ea8a648514c248a8047b16f46bded6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 19 May 2022 17:18:21 +0100 Subject: [PATCH] [Content management] Add "Last updated" metadata to TableListView (#132321) --- .../public/services/saved_object_loader.ts | 20 ++- .../table_list_view.test.tsx.snap | 7 +- .../table_list_view/table_list_view.test.tsx | 170 +++++++++++++++++- .../table_list_view/table_list_view.tsx | 158 ++++++++++++---- .../public/utils/saved_visualize_utils.ts | 4 + .../vis_types/vis_type_alias_registry.ts | 4 +- .../public/helpers/saved_workspace_utils.ts | 1 + x-pack/plugins/lens/public/vis_type_alias.ts | 3 +- .../maps/common/map_saved_object_type.ts | 4 - .../maps/public/maps_vis_type_alias.ts | 8 +- .../routes/list_page/maps_list_view.tsx | 1 + .../maps/server/maps_telemetry/find_maps.ts | 6 +- .../index_pattern_stats_collector.ts | 5 +- 13 files changed, 334 insertions(+), 57 deletions(-) diff --git a/src/plugins/dashboard/public/services/saved_object_loader.ts b/src/plugins/dashboard/public/services/saved_object_loader.ts index 3c406357c0294..780daa2939aa4 100644 --- a/src/plugins/dashboard/public/services/saved_object_loader.ts +++ b/src/plugins/dashboard/public/services/saved_object_loader.ts @@ -98,12 +98,16 @@ export class SavedObjectLoader { mapHitSource( source: Record, id: string, - references: SavedObjectReference[] = [] - ) { - source.id = id; - source.url = this.urlFor(id); - source.references = references; - return source; + references: SavedObjectReference[] = [], + updatedAt?: string + ): Record { + return { + ...source, + id, + url: this.urlFor(id), + references, + updatedAt, + }; } /** @@ -116,12 +120,14 @@ export class SavedObjectLoader { attributes, id, references = [], + updatedAt, }: { attributes: Record; id: string; references?: SavedObjectReference[]; + updatedAt?: string; }) { - return this.mapHitSource(attributes, id, references); + return this.mapHitSource(attributes, id, references, updatedAt); } /** diff --git a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap index a0c34cfdfee07..2ad9af679e8c6 100644 --- a/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap +++ b/src/plugins/kibana_react/public/table_list_view/__snapshots__/table_list_view.test.tsx.snap @@ -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" /> diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx index 13423047bc3f0..ba76a6b879e61 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.test.tsx @@ -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> = { 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(); @@ -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> = { + ...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 + ]); + }); + }); }); diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index ece2fa37cc832..5baaaa78b76ec 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -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 { export interface TableListViewState { items: V[]; hasInitialFetchReturned: boolean; + hasUpdatedAtMetadata: boolean | null; isFetchingItems: boolean; isDeletingItems: boolean; showDeleteModal: boolean; @@ -72,6 +78,10 @@ export interface TableListViewState { 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 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 extends React.Component< this.fetchItems(); } + componentDidUpdate(prevProps: TableListViewProps, prevState: TableListViewState) { + 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 extends React.Component< ); } + onTableChange(criteria: Criteria) { + if (criteria.sort) { + this.setState({ tableSort: criteria.sort }); + } + } + renderTable() { const { searchFilters } = this.props; @@ -435,24 +475,6 @@ class TableListView extends React.Component< } : undefined; - const actions: EuiTableActionsColumnType['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,17 +486,6 @@ class TableListView extends React.Component< filters: searchFilters ?? [], }; - const columns = this.props.tableColumns.slice(); - if (this.props.editItem) { - columns.push({ - name: i18n.translate('kibana-react.tableListView.listing.table.actionTitle', { - defaultMessage: 'Actions', - }), - width: '100px', - actions, - }); - } - const noItemsMessage = ( extends React.Component< values={{ entityNamePlural: this.props.entityNamePlural }} /> ); + return ( extends React.Component< ); } + getTableColumns() { + const columns = this.props.tableColumns.slice(); + + // Add "Last update" column + if (this.state.hasUpdatedAtMetadata) { + const renderUpdatedAt = (dateTime?: string) => { + if (!dateTime) { + return ( + + - + + ); + } + const updatedAt = moment(dateTime); + + if (updatedAt.diff(moment(), 'days') > -7) { + return ( + + {(formattedDate: string) => ( + + {formattedDate} + + )} + + ); + } + return ( + + {updatedAt.format('LL')} + + ); + }; + + 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['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', + }), + width: '100px', + actions, + }); + } + + return columns; + } + renderCreateButton() { if (this.props.createItem) { return ( diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts index 5b8ba8ce04cb4..f5444b6269e22 100644 --- a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -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, }; diff --git a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts index 2945aaa1a0cc8..f113a0a212fe6 100644 --- a/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts +++ b/src/plugins/visualizations/public/vis_types/vis_type_alias_registry.ts @@ -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 { diff --git a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts index 72cca61832ca0..202d13f9cd539 100644 --- a/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts +++ b/x-pack/plugins/graph/public/helpers/saved_workspace_utils.ts @@ -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; } diff --git a/x-pack/plugins/lens/public/vis_type_alias.ts b/x-pack/plugins/lens/public/vis_type_alias.ts index be8a5620ce614..11a97ae82470f 100644 --- a/x-pack/plugins/lens/public/vis_type_alias.ts +++ b/x-pack/plugins/lens/public/vis_type_alias.ts @@ -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', diff --git a/x-pack/plugins/maps/common/map_saved_object_type.ts b/x-pack/plugins/maps/common/map_saved_object_type.ts index b37c1af5949c1..f16683f56ef6d 100644 --- a/x-pack/plugins/maps/common/map_saved_object_type.ts +++ b/x-pack/plugins/maps/common/map_saved_object_type.ts @@ -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; diff --git a/x-pack/plugins/maps/public/maps_vis_type_alias.ts b/x-pack/plugins/maps/public/maps_vis_type_alias.ts index e6dad590b037a..911e886a8199e 100644 --- a/x-pack/plugins/maps/public/maps_vis_type_alias.ts +++ b/x-pack/plugins/maps/public/maps_vis_type_alias.ts @@ -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; const { title, description } = attributes; + return { id, title, description, + updatedAt, editUrl: getEditPath(id), editApp: APP_ID, icon: APP_ICON, diff --git a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx index 5aa8e7877628a..9278f08bd4d2d 100644 --- a/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx +++ b/x-pack/plugins/maps/public/routes/list_page/maps_list_view.tsx @@ -113,6 +113,7 @@ async function findMaps(searchQuery: string) { title: savedObject.attributes.title, description: savedObject.attributes.description, references: savedObject.references, + updatedAt: savedObject.updatedAt, }; }), }; diff --git a/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts b/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts index cab4b98ffd784..213c1a6cde3ee 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/find_maps.ts @@ -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, - callback: (savedObject: MapSavedObject) => Promise + callback: (savedObject: SavedObject) => Promise ) { let nextPage = 1; let hasMorePages = false; diff --git a/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts b/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts index ad1c0239963b4..dcbc9c884275d 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/index_pattern_stats/index_pattern_stats_collector.ts @@ -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) { let layerList: LayerDescriptor[] = []; try { const { attributes } = injectReferences(savedObject);