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 0d9b83892766..33c847dbd833 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', { @@ -138,6 +144,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) => ( diff --git a/yarn.lock b/yarn.lock index 5771780e3767..e4fd8689c493 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5518,7 +5518,7 @@ cacheable-lookup@^5.0.3: resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== -cacheable-request@^7.0.1, cacheable-request@^7.0.2: +cacheable-request@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27" integrity sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew== @@ -9096,13 +9096,13 @@ gauge@~2.7.3: wide-align "^1.1.0" geckodriver@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/geckodriver/-/geckodriver-3.0.1.tgz#ded3512f3c6ddc490139b9d5e8fd6925d41c5631" - integrity sha512-cHmbNFqt4eelymsuVt7B5nh+qYGpPCltM7rd+k+CBaTvxGGr4j6STeOYahXMNdSeUbCVhqP345OuqWnvHYAz4Q== + version "3.0.2" + resolved "https://registry.yarnpkg.com/geckodriver/-/geckodriver-3.0.2.tgz#6bd69166a24859c5edbc6ece9868339378b6c97b" + integrity sha512-GHOQzQnTeZOJdcdEXLuzmcRwkbHuei1VivXkn2BLyleKiT6lTvl0T7vm+d0wvr/EZC7jr0m1u1pBHSfqtuFuNQ== dependencies: adm-zip "0.5.9" bluebird "3.7.2" - got "11.8.2" + got "11.8.5" https-proxy-agent "5.0.0" tar "6.1.11" @@ -9431,17 +9431,17 @@ globjoin@^0.1.4: resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43" integrity sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM= -got@11.8.2: - version "11.8.2" - resolved "https://registry.yarnpkg.com/got/-/got-11.8.2.tgz#7abb3959ea28c31f3576f1576c1effce23f33599" - integrity sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ== +got@11.8.5: + version "11.8.5" + resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046" + integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ== dependencies: "@sindresorhus/is" "^4.0.0" "@szmarczak/http-timer" "^4.0.5" "@types/cacheable-request" "^6.0.1" "@types/responselike" "^1.0.0" cacheable-lookup "^5.0.3" - cacheable-request "^7.0.1" + cacheable-request "^7.0.2" decompress-response "^6.0.0" http2-wrapper "^1.0.0-beta.5.2" lowercase-keys "^2.0.0"