diff --git a/src/core/public/saved_objects/saved_objects_client.test.ts b/src/core/public/saved_objects/saved_objects_client.test.ts index 3218fc24e049..19b140ad3a12 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -33,11 +33,13 @@ import { SimpleSavedObject } from './simple_saved_object'; import { httpServiceMock } from '../http/http_service.mock'; describe('SavedObjectsClient', () => { + const updatedAt = new Date().toISOString(); const doc = { id: 'AVwSwFxtcMV38qjDZoQg', type: 'config', attributes: { title: 'Example title' }, version: 'foo', + updated_at: updatedAt, }; const http = httpServiceMock.createStartContract(); @@ -356,7 +358,7 @@ describe('SavedObjectsClient', () => { Array [ "/api/saved_objects/_bulk_create", Object { - "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\"}]", + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\",\\"updated_at\\":\\"${updatedAt}\\"}]", "method": "POST", "query": Object { "overwrite": false, @@ -374,7 +376,7 @@ describe('SavedObjectsClient', () => { Array [ "/api/saved_objects/_bulk_create", Object { - "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\"}]", + "body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\",\\"updated_at\\":\\"${updatedAt}\\"}]", "method": "POST", "query": Object { "overwrite": true, diff --git a/src/core/public/saved_objects/simple_saved_object.test.ts b/src/core/public/saved_objects/simple_saved_object.test.ts index 436b5c278e86..234adb3c6862 100644 --- a/src/core/public/saved_objects/simple_saved_object.test.ts +++ b/src/core/public/saved_objects/simple_saved_object.test.ts @@ -67,4 +67,11 @@ describe('SimpleSavedObject', () => { const savedObject = new SimpleSavedObject(client, { version } as SavedObject); expect(savedObject._version).toEqual(version); }); + + it('persists updated_at', () => { + const updatedAt = new Date().toString(); + + const savedObject = new SimpleSavedObject(client, { updated_at: updatedAt } as SavedObject); + expect(savedObject.updated_at).toEqual(updatedAt); + }); }); diff --git a/src/core/public/saved_objects/simple_saved_object.ts b/src/core/public/saved_objects/simple_saved_object.ts index fe0c66764008..7cbedf4b9bb9 100644 --- a/src/core/public/saved_objects/simple_saved_object.ts +++ b/src/core/public/saved_objects/simple_saved_object.ts @@ -51,10 +51,21 @@ export class SimpleSavedObject { public migrationVersion: SavedObjectType['migrationVersion']; public error: SavedObjectType['error']; public references: SavedObjectType['references']; + public updated_at: SavedObjectType['updated_at']; constructor( private client: SavedObjectsClientContract, - { id, type, version, attributes, error, references, migrationVersion }: SavedObjectType + { + id, + type, + version, + attributes, + error, + references, + migrationVersion, + // eslint-disable-next-line @typescript-eslint/naming-convention + updated_at, + }: SavedObjectType ) { this.id = id; this.type = type; @@ -62,6 +73,7 @@ export class SimpleSavedObject { this.references = references || []; this._version = version; this.migrationVersion = migrationVersion; + this.updated_at = updated_at; if (error) { this.error = error; } diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap index cfa5d1ce8aa5..fd45b2291f99 100644 --- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap @@ -45,13 +45,34 @@ exports[`after fetch hideWriteControls 1`] = ` "name": "Description", "sortable": true, }, + Object { + "data-test-subj": "updated-at", + "dataType": "date", + "description": "Last update of the saved object", + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + }, ] } tableListTitle="Dashboards" toastNotifications={Object {}} uiSettings={ Object { - "get": [MockFunction], + "get": [MockFunction] { + "calls": Array [ + Array [ + "dateFormat", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": 10, + }, + ], + }, } } /> @@ -147,13 +168,34 @@ exports[`after fetch initialFilter 1`] = ` "name": "Description", "sortable": true, }, + Object { + "data-test-subj": "updated-at", + "dataType": "date", + "description": "Last update of the saved object", + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + }, ] } tableListTitle="Dashboards" toastNotifications={Object {}} uiSettings={ Object { - "get": [MockFunction], + "get": [MockFunction] { + "calls": Array [ + Array [ + "dateFormat", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": 10, + }, + ], + }, } } /> @@ -249,13 +291,34 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` "name": "Description", "sortable": true, }, + Object { + "data-test-subj": "updated-at", + "dataType": "date", + "description": "Last update of the saved object", + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + }, ] } tableListTitle="Dashboards" toastNotifications={Object {}} uiSettings={ Object { - "get": [MockFunction], + "get": [MockFunction] { + "calls": Array [ + Array [ + "dateFormat", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": 10, + }, + ], + }, } } /> @@ -351,13 +414,34 @@ exports[`after fetch renders table rows 1`] = ` "name": "Description", "sortable": true, }, + Object { + "data-test-subj": "updated-at", + "dataType": "date", + "description": "Last update of the saved object", + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + }, ] } tableListTitle="Dashboards" toastNotifications={Object {}} uiSettings={ Object { - "get": [MockFunction], + "get": [MockFunction] { + "calls": Array [ + Array [ + "dateFormat", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": 10, + }, + ], + }, } } /> @@ -453,13 +537,34 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` "name": "Description", "sortable": true, }, + Object { + "data-test-subj": "updated-at", + "dataType": "date", + "description": "Last update of the saved object", + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + }, ] } tableListTitle="Dashboards" toastNotifications={Object {}} uiSettings={ Object { - "get": [MockFunction], + "get": [MockFunction] { + "calls": Array [ + Array [ + "dateFormat", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": 10, + }, + ], + }, } } /> @@ -554,13 +659,34 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` "name": "Description", "sortable": true, }, + Object { + "data-test-subj": "updated-at", + "dataType": "date", + "description": "Last update of the saved object", + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + }, ] } tableListTitle="Dashboards" toastNotifications={Object {}} uiSettings={ Object { - "get": [MockFunction], + "get": [MockFunction] { + "calls": Array [ + Array [ + "dateFormat", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": 10, + }, + ], + }, } } /> diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.js b/src/plugins/dashboard/public/application/listing/dashboard_listing.js index dc778be046b2..1864c2852aeb 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.js +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.js @@ -30,6 +30,7 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; +import moment from 'moment'; import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; import { i18n } from '@osd/i18n'; @@ -161,6 +162,7 @@ export class DashboardListing extends React.Component { } getTableColumns() { + const dateFormat = this.props.core.uiSettings.get('dateFormat'); const tableColumns = [ { field: 'title', @@ -185,6 +187,19 @@ export class DashboardListing extends React.Component { dataType: 'string', sortable: true, }, + { + field: `updated_at`, + name: i18n.translate('dashboard.listing.table.columnUpdatedAtName', { + defaultMessage: 'Last updated', + }), + dataType: 'date', + sortable: true, + description: i18n.translate('dashboard.listing.table.columnUpdatedAtDescription', { + defaultMessage: 'Last update of the saved object', + }), + ['data-test-subj']: 'updated-at', + render: (updatedAt) => updatedAt && moment(updatedAt).format(dateFormat), + }, ]; return tableColumns; } diff --git a/src/plugins/saved_objects/public/saved_object/saved_object_loader.test.ts b/src/plugins/saved_objects/public/saved_object/saved_object_loader.test.ts new file mode 100644 index 000000000000..bf0efbe50377 --- /dev/null +++ b/src/plugins/saved_objects/public/saved_object/saved_object_loader.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; +import { SavedObjectLoader } from './saved_object_loader'; + +describe('SimpleSavedObjectLoader', () => { + const createLoader = (updatedAt?: any) => { + const id = 'logstash-*'; + const type = 'index-pattern'; + + const savedObject = { + attributes: {}, + id, + type, + updated_at: updatedAt as any, + }; + + client = { + ...client, + find: jest.fn(() => + Promise.resolve({ + total: 1, + savedObjects: [savedObject], + }) + ), + } as any; + + return new SavedObjectLoader(savedObject, client); + }; + + let client: SavedObjectsClientContract; + let loader: SavedObjectLoader; + beforeEach(() => { + client = { + update: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + } as any; + }); + + afterEach(async () => { + const savedObjects = await loader.findAll(); + + expect(savedObjects.hits[0].updated_at).toEqual(undefined); + }); + + it('set updated_at as undefined if undefined', async () => { + loader = createLoader(undefined); + }); + + it("set updated_at as undefined if doesn't exist", async () => { + loader = createLoader(); + }); + + it('set updated_at as undefined if null', async () => { + loader = createLoader(null); + }); +}); diff --git a/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts b/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts index 9184d467c247..fa329d9032b8 100644 --- a/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts +++ b/src/plugins/saved_objects/public/saved_object/saved_object_loader.ts @@ -105,12 +105,17 @@ export class SavedObjectLoader { } /** - * Updates hit.attributes to contain an id and url field, and returns the updated + * Updates hit.attributes to contain an updated_at, id and url field, and returns the updated * attributes object. * @param hit - * @returns {hit.attributes} The modified hit.attributes object, with an id and url field. + * @returns {hit.attributes} The modified hit.attributes object, with an updated_at, id and url field. */ - mapSavedObjectApiHits(hit: { attributes: Record; id: string }) { + mapSavedObjectApiHits(hit: { + attributes: Record; + id: string; + updated_at?: string; + }) { + hit.attributes.updated_at = hit?.updated_at ?? hit.attributes._updatedAt; return this.mapHitSource(hit.attributes, hit.id); } diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index 65a6a98777cc..e22f7f3a0128 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -143,6 +143,15 @@ exports[`Table prevents saved objects from being deleted 1`] = ` "render": [Function], "sortable": false, }, + Object { + "data-test-subj": "updated-at", + "dataType": "date", + "description": "Last update of the saved object", + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + }, Object { "actions": Array [ Object { @@ -359,6 +368,15 @@ exports[`Table should render normally 1`] = ` "render": [Function], "sortable": false, }, + Object { + "data-test-subj": "updated-at", + "dataType": "date", + "description": "Last update of the saved object", + "field": "updated_at", + "name": "Last updated", + "render": [Function], + "sortable": true, + }, Object { "actions": Array [ Object { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index ba3b443354f8..50ec838b9860 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -30,6 +30,7 @@ import { IBasePath } from 'src/core/public'; import React, { PureComponent, Fragment } from 'react'; +import moment from 'moment'; import { EuiSearchBar, EuiBasicTable, @@ -80,6 +81,7 @@ export interface TableProps { isSearching: boolean; onShowRelationships: (object: SavedObjectWithMetadata) => void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; + dateFormat: string; } interface TableState { @@ -172,6 +174,7 @@ export class Table extends PureComponent { basePath, actionRegistry, columnRegistry, + dateFormat, } = this.props; const pagination = { @@ -251,6 +254,20 @@ export class Table extends PureComponent { ); }, } as EuiTableFieldDataColumnType>, + { + field: `updated_at`, + name: i18n.translate('savedObjectsManagement.objectsTable.table.columnUpdatedAtName', { + defaultMessage: 'Last updated', + }), + dataType: 'date', + sortable: true, + description: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnUpdatedAtDescription', + { defaultMessage: 'Last update of the saved object' } + ), + 'data-test-subj': 'updated-at', + render: (updatedAt: string) => updatedAt && moment(updatedAt).format(dateFormat), + } as EuiTableFieldDataColumnType>, ...columnRegistry.getAll().map((column) => { return { ...column.euiColumn, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 9561774d7e12..8d0d13d3334a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -109,6 +109,7 @@ export interface SavedObjectsTableProps { perPageConfig: number; goInspectObject: (obj: SavedObjectWithMetadata) => void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; + dateFormat: string; } export interface SavedObjectsTableState { @@ -811,6 +812,7 @@ export class SavedObjectsTable extends Component diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index fd15fd94494e..1640e5e2dd36 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -59,6 +59,7 @@ const SavedObjectsTablePage = ({ }) => { const capabilities = coreStart.application.capabilities; const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50); + const dateFormat = coreStart.uiSettings.get('dateFormat'); useEffect(() => { setBreadcrumbs([ @@ -93,6 +94,7 @@ const SavedObjectsTablePage = ({ ); } }} + dateFormat={dateFormat} canGoInApp={(savedObject) => { const { inAppUrl } = savedObject.meta; return inAppUrl ? Boolean(get(capabilities, inAppUrl.uiCapabilitiesPath)) : false; diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 9c39150cc036..767793efa2d8 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -109,7 +109,11 @@ export const VisualizeListing = () => { ); const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]); - const tableColumns = useMemo(() => getTableColumns(application, history), [application, history]); + const tableColumns = useMemo(() => getTableColumns(application, history, uiSettings), [ + application, + history, + uiSettings, + ]); const fetchItems = useCallback( (filter) => { diff --git a/src/plugins/visualize/public/application/utils/get_table_columns.tsx b/src/plugins/visualize/public/application/utils/get_table_columns.tsx index 356d28989551..a88714052d18 100644 --- a/src/plugins/visualize/public/application/utils/get_table_columns.tsx +++ b/src/plugins/visualize/public/application/utils/get_table_columns.tsx @@ -36,6 +36,8 @@ import { FormattedMessage } from '@osd/i18n/react'; import { ApplicationStart } from 'opensearch-dashboards/public'; import { VisualizationListItem } from 'src/plugins/visualizations/public'; +import moment from 'moment'; +import { IUiSettingsClient } from 'src/core/public'; const getBadge = (item: VisualizationListItem) => { if (item.stage === 'beta') { @@ -91,7 +93,11 @@ const renderItemTypeIcon = (item: VisualizationListItem) => { return icon; }; -export const getTableColumns = (application: ApplicationStart, history: History) => [ +export const getTableColumns = ( + application: ApplicationStart, + history: History, + uiSettings: IUiSettingsClient +) => [ { field: 'title', name: i18n.translate('visualize.listing.table.titleColumnName', { @@ -144,6 +150,20 @@ export const getTableColumns = (application: ApplicationStart, history: History) sortable: true, render: (field: string, record: VisualizationListItem) => {record.description}, }, + { + field: `updated_at`, + name: i18n.translate('visualize.listing.table.columnUpdatedAtName', { + defaultMessage: 'Last updated', + }), + dataType: 'date', + sortable: true, + description: i18n.translate('visualize.listing.table.columnUpdatedAtDescription', { + defaultMessage: 'Last update of the saved object', + }), + ['data-test-subj']: 'updated-at', + render: (updatedAt: string) => + updatedAt && moment(updatedAt).format(uiSettings.get('dateFormat')), + }, ]; export const getNoItemsMessage = (createItem: () => void) => (