diff --git a/superset-frontend/src/types/Chart.ts b/superset-frontend/src/types/Chart.ts index 5148d32d0bf4..cf78dab43eb8 100644 --- a/superset-frontend/src/types/Chart.ts +++ b/superset-frontend/src/types/Chart.ts @@ -23,17 +23,18 @@ import Owner from './Owner'; -export default interface Chart { +export interface Chart { id: number; url: string; viz_type: string; slice_name: string; creator: string; changed_on: string; + changed_on_delta_humanized?: string; + changed_on_utc?: string; description: string | null; cache_timeout: number | null; thumbnail_url?: string; - changed_on_delta_humanized?: string; owners?: Owner[]; datasource_name_text?: string; } @@ -44,4 +45,7 @@ export type Slice = { slice_name: string; description: string | null; cache_timeout: number | null; + url?: string; }; + +export default Chart; diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index 4a9a2c5c4799..8e54bbd04a9e 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -34,7 +34,8 @@ export interface DashboardTableProps { export interface Dashboard { changed_by_name: string; changed_by_url: string; - changed_on_delta_humanized: string; + changed_on_delta_humanized?: string; + changed_on_utc?: string; changed_by: string; dashboard_title: string; slice_name?: string; @@ -47,13 +48,15 @@ export interface Dashboard { } export type SavedQueryObject = { + id: number; + changed_on: string; + changed_on_delta_humanized: string; database: { database_name: string; id: number; }; db_id: number; description?: string; - id: number; label: string; schema: string; sql: string | null; diff --git a/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx b/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx index 5e5cc1302d2a..b52dfe2cd856 100644 --- a/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx @@ -23,25 +23,43 @@ import { styled, t } from '@superset-ui/core'; import Loading from 'src/components/Loading'; import ListViewCard from 'src/components/ListViewCard'; import SubMenu from 'src/components/Menu/SubMenu'; +import { Chart } from 'src/types/Chart'; +import { Dashboard, SavedQueryObject } from 'src/views/CRUD/types'; +import { mq, CardStyles } from 'src/views/CRUD/utils'; + import { ActivityData } from './Welcome'; -import { mq, CardStyles } from '../utils'; import EmptyState from './EmptyState'; -interface ActivityObjects { - action?: string; - item_title?: string; - slice_name: string; - time: string; - changed_on_utc: string; - url: string; - sql: string; - dashboard_title: string; - label: string; - id: string; - table: object; +/** + * Return result from /superset/recent_activity/{user_id} + */ +interface RecentActivity { + action: string; + item_type: 'slice' | 'dashboard'; item_url: string; + item_title: string; + time: number; + time_delta_humanized?: string; +} + +interface RecentSlice extends RecentActivity { + item_type: 'slice'; +} + +interface RecentDashboard extends RecentActivity { + item_type: 'dashboard'; } +/** + * Recent activity objects fetched by `getRecentAcitivtyObjs`. + */ +type ActivityObject = + | RecentSlice + | RecentDashboard + | Chart + | Dashboard + | SavedQueryObject; + interface ActivityProps { user: { userId: string | number; @@ -79,31 +97,70 @@ const ActivityContainer = styled.div` } `; +const UNTITLED = t('[Untitled]'); +const UNKNOWN_TIME = t('Unknown'); + +const getEntityTitle = (entity: ActivityObject) => { + if ('dashboard_title' in entity) return entity.dashboard_title || UNTITLED; + if ('slice_name' in entity) return entity.slice_name || UNTITLED; + if ('label' in entity) return entity.label || UNTITLED; + return entity.item_title || UNTITLED; +}; + +const getEntityIconName = (entity: ActivityObject) => { + if ('sql' in entity) return 'sql'; + const url = 'item_url' in entity ? entity.item_url : entity.url; + if (url?.includes('dashboard')) { + return 'nav-dashboard'; + } + if (url?.includes('explore')) { + return 'nav-charts'; + } + return ''; +}; + +const getEntityUrl = (entity: ActivityObject) => { + if ('sql' in entity) return `/superset/sqllab?savedQueryId=${entity.id}`; + if ('url' in entity) return entity.url; + return entity.item_url; +}; + +const getEntityLastActionOn = (entity: ActivityObject) => { + // translation keys for last action on + const LAST_VIEWED = `Last viewed %s`; + const LAST_MODIFIED = `Last modified %s`; + + // for Recent viewed items + if ('time_delta_humanized' in entity) { + return t(LAST_VIEWED, entity.time_delta_humanized); + } + + if ('changed_on_delta_humanized' in entity) { + return t(LAST_MODIFIED, entity.changed_on_delta_humanized); + } + + let time: number | string | undefined | null; + let translationKey = LAST_MODIFIED; + if ('time' in entity) { + // eslint-disable-next-line prefer-destructuring + time = entity.time; + translationKey = LAST_VIEWED; + } + if ('changed_on' in entity) time = entity.changed_on; + if ('changed_on_utc' in entity) time = entity.changed_on_utc; + + return t( + translationKey, + time == null ? UNKNOWN_TIME : moment(time).fromNow(), + ); +}; + export default function ActivityTable({ loading, activeChild, setActiveChild, activityData, }: ActivityProps) { - const getFilterTitle = (e: ActivityObjects) => { - if (e.dashboard_title) return e.dashboard_title; - if (e.label) return e.label; - if (e.url && !e.table) return e.item_title; - if (e.item_title) return e.item_title; - return e.slice_name; - }; - - const getIconName = (e: ActivityObjects) => { - if (e.sql) return 'sql'; - if (e.url?.includes('dashboard') || e.item_url?.includes('dashboard')) { - return 'nav-dashboard'; - } - if (e.url?.includes('explore') || e.item_url?.includes('explore')) { - return 'nav-charts'; - } - return ''; - }; - const tabs = [ { name: 'Edited', @@ -139,35 +196,30 @@ export default function ActivityTable({ }); } - const renderActivity = () => { - const getRecentRef = (e: ActivityObjects) => { - if (activeChild === 'Viewed') { - return e.item_url; - } - return e.sql ? `/superset/sqllab?savedQueryId=${e.id}` : e.url; - }; - return activityData[activeChild].map((e: ActivityObjects) => ( - { - window.location.href = getRecentRef(e); - }} - key={e.id} - > - } - url={e.sql ? `/superset/sqllab?savedQueryId=${e.id}` : e.url} - title={getFilterTitle(e)} - description={`Last Edited: ${moment( - e.changed_on_utc, - 'MM/DD/YYYY HH:mm:ss', - )}`} - avatar={getIconName(e)} - actions={null} - /> - - )); - }; + const renderActivity = () => + activityData[activeChild].map((entity: ActivityObject) => { + const url = getEntityUrl(entity); + const lastActionOn = getEntityLastActionOn(entity); + return ( + { + window.location.href = url; + }} + key={url} + > + } + url={url} + title={getEntityTitle(entity)} + description={lastActionOn} + avatar={getEntityIconName(entity)} + actions={null} + /> + + ); + }); + if (loading) return ; return ( <> diff --git a/superset/views/core.py b/superset/views/core.py index 8cc0b22c3e02..62b1b498e7be 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -23,6 +23,7 @@ from urllib import parse import backoff +import humanize import pandas as pd import simplejson as json from flask import abort, flash, g, Markup, redirect, render_template, request, Response @@ -1395,6 +1396,9 @@ def recent_activity( # pylint: disable=no-self-use "item_url": item_url, "item_title": item_title, "time": log.dttm, + "time_delta_humanized": humanize.naturaltime( + datetime.now() - log.dttm + ), } ) return json_success(json.dumps(payload, default=utils.json_int_dttm_ser))