Unverified Commit 075386da authored by Daniel's avatar Daniel Committed by GitHub

Added last_updated_timestamp to table details page (#386)

* Added `last_updated_timestamp` to table details page
* Added configurable date formats and utility functions
parent 1163e81c
import * as React from 'react'; import * as React from 'react';
import moment from 'moment-timezone';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
// TODO: Use css-modules instead of 'import' // TODO: Use css-modules instead of 'import'
...@@ -8,6 +7,8 @@ import { GlobalState } from 'ducks/rootReducer'; ...@@ -8,6 +7,8 @@ import { GlobalState } from 'ducks/rootReducer';
import { getLastIndexed } from 'ducks/tableMetadata/reducer'; import { getLastIndexed } from 'ducks/tableMetadata/reducer';
import { GetLastIndexedRequest } from 'ducks/tableMetadata/types'; import { GetLastIndexedRequest } from 'ducks/tableMetadata/types';
import { formatDateTimeLong } from 'utils/dateUtils';
// Props // Props
interface StateFromProps { interface StateFromProps {
lastIndexed: number; lastIndexed: number;
...@@ -29,8 +30,7 @@ export class Footer extends React.Component<FooterProps> { ...@@ -29,8 +30,7 @@ export class Footer extends React.Component<FooterProps> {
} }
generateDateTimeString = () => { generateDateTimeString = () => {
// 'moment.local' will utilize the client's local timezone. return formatDateTimeLong({ epochTimestamp: this.props.lastIndexed });
return moment.unix(this.props.lastIndexed).local().format('MMMM Do YYYY [at] h:mm:ss a');
}; };
render() { render() {
......
import * as React from 'react'; import * as React from 'react';
import moment from 'moment-timezone'; import * as moment from 'moment-timezone';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { Footer, FooterProps, mapDispatchToProps, mapStateToProps } from '../'; import { Footer, FooterProps, mapDispatchToProps, mapStateToProps } from '../';
......
import * as React from 'react'; import * as React from 'react';
import * as moment from 'moment-timezone';
import { TableColumnStats } from 'interfaces/index'; import { TableColumnStats } from 'interfaces/index';
import { formatDate } from 'utils/dateUtils';
import './styles.scss'; import './styles.scss';
...@@ -14,13 +14,9 @@ export class ColumnStats extends React.Component<ColumnStatsProps> { ...@@ -14,13 +14,9 @@ export class ColumnStats extends React.Component<ColumnStatsProps> {
super(props); super(props);
} }
formatDate = (unixEpochSeconds) => {
return moment(unixEpochSeconds * 1000).format("MMM DD, YYYY");
};
getStatsInfoText = (startEpoch: number, endEpoch: number) => { getStatsInfoText = (startEpoch: number, endEpoch: number) => {
const startDate = startEpoch ? this.formatDate(startEpoch) : null; const startDate = startEpoch ? formatDate({ epochTimestamp: startEpoch }) : null;
const endDate = endEpoch ? this.formatDate(endEpoch) : null; const endDate = endEpoch ? formatDate({ epochTimestamp: endEpoch }) : null;
let infoText = 'Stats reflect data collected'; let infoText = 'Stats reflect data collected';
if (startDate && endDate) { if (startDate && endDate) {
......
...@@ -25,14 +25,6 @@ describe('ColumnStats', () => { ...@@ -25,14 +25,6 @@ describe('ColumnStats', () => {
const { wrapper, props } = setup(); const { wrapper, props } = setup();
const instance = wrapper.instance(); const instance = wrapper.instance();
describe('formatDate', () => {
it('formats a date in the correct format', () => {
const epochTime = 1571616000;
const expectedDateString = "Oct 21, 2019";
expect(instance.formatDate(epochTime)).toBe(expectedDateString);
});
});
describe('getStatsInfoText', () => { describe('getStatsInfoText', () => {
it('generates correct info text for a daily partition', () => { it('generates correct info text for a daily partition', () => {
const startEpoch = 1568160000; const startEpoch = 1568160000;
......
...@@ -3,7 +3,6 @@ export const NO_WATERMARK_LINE_2 = "Data available for all dates"; ...@@ -3,7 +3,6 @@ export const NO_WATERMARK_LINE_2 = "Data available for all dates";
export const LOW_WATERMARK_LABEL = "From:"; export const LOW_WATERMARK_LABEL = "From:";
export const HIGH_WATERMARK_LABEL = "To:"; export const HIGH_WATERMARK_LABEL = "To:";
export const WATERMARK_INPUT_FORMAT = "YYYY-MM-DD"; export const WATERMARK_INPUT_FORMAT = "YYYY-MM-DD";
export const WATERMARK_DISPLAY_FORMAT = "MMM DD, YYYY";
export enum WatermarkType { export enum WatermarkType {
HIGH = "high_watermark", HIGH = "high_watermark",
......
import * as React from 'react'; import * as React from 'react';
import * as moment from 'moment-timezone';
import './styles.scss'; import './styles.scss';
import { Watermark } from 'interfaces'; import { Watermark } from 'interfaces';
import { import {
HIGH_WATERMARK_LABEL, HIGH_WATERMARK_LABEL,
NO_WATERMARK_LINE_1, NO_WATERMARK_LINE_2, LOW_WATERMARK_LABEL, NO_WATERMARK_LINE_1, NO_WATERMARK_LINE_2, LOW_WATERMARK_LABEL,
WATERMARK_DISPLAY_FORMAT,
WATERMARK_INPUT_FORMAT, WATERMARK_INPUT_FORMAT,
WatermarkType WatermarkType
} from './constants'; } from './constants';
import { formatDate } from 'utils/dateUtils';
export interface WatermarkLabelProps { export interface WatermarkLabelProps {
watermarks: Watermark[]; watermarks: Watermark[];
...@@ -22,7 +20,10 @@ class WatermarkLabel extends React.Component<WatermarkLabelProps> { ...@@ -22,7 +20,10 @@ class WatermarkLabel extends React.Component<WatermarkLabelProps> {
} }
formatWatermarkDate = (dateString: string) => { formatWatermarkDate = (dateString: string) => {
return moment(dateString, WATERMARK_INPUT_FORMAT).format(WATERMARK_DISPLAY_FORMAT); return formatDate({
dateString,
dateStringFormat: WATERMARK_INPUT_FORMAT,
});
}; };
getWatermarkValue = (type: WatermarkType) => { getWatermarkValue = (type: WatermarkType) => {
......
...@@ -30,6 +30,7 @@ import { TableMetadata } from 'interfaces/TableMetadata'; ...@@ -30,6 +30,7 @@ import { TableMetadata } from 'interfaces/TableMetadata';
import { EditableSection } from 'components/TableDetail/EditableSection'; import { EditableSection } from 'components/TableDetail/EditableSection';
import { getDatabaseDisplayName, getDatabaseIconClass, notificationsEnabled } from 'config/config-utils'; import { getDatabaseDisplayName, getDatabaseIconClass, notificationsEnabled } from 'config/config-utils';
import { formatDateTimeShort } from 'utils/dateUtils';
import './styles'; import './styles';
import RequestDescriptionText from './RequestDescriptionText'; import RequestDescriptionText from './RequestDescriptionText';
...@@ -184,6 +185,13 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps ...@@ -184,6 +185,13 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps
<WatermarkLabel watermarks={ data.watermarks }/> <WatermarkLabel watermarks={ data.watermarks }/>
</> </>
} }
{
!!data.last_updated_timestamp &&
<>
<div className="section-title title-3">Last Updated</div>
<div className="body-2">{ formatDateTimeShort({ epochTimestamp: data.last_updated_timestamp }) }</div>
</>
}
<div className="section-title title-3">Frequent Users</div> <div className="section-title title-3">Frequent Users</div>
<FrequentUsers readers={ data.table_readers }/> <FrequentUsers readers={ data.table_readers }/>
</section> </section>
...@@ -191,11 +199,9 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps ...@@ -191,11 +199,9 @@ class TableDetail extends React.Component<TableDetailProps & RouteComponentProps
<EditableSection title="Tags"> <EditableSection title="Tags">
<TagInput/> <TagInput/>
</EditableSection> </EditableSection>
<EditableSection title="Owners"> <EditableSection title="Owners">
<OwnerEditor /> <OwnerEditor />
</EditableSection> </EditableSection>
</section> </section>
</section> </section>
</section> </section>
......
...@@ -8,6 +8,7 @@ import { TableResource } from 'interfaces'; ...@@ -8,6 +8,7 @@ import { TableResource } from 'interfaces';
import BookmarkIcon from 'components/common/Bookmark/BookmarkIcon'; import BookmarkIcon from 'components/common/Bookmark/BookmarkIcon';
import { getDatabaseDisplayName, getDatabaseIconClass } from 'config/config-utils'; import { getDatabaseDisplayName, getDatabaseIconClass } from 'config/config-utils';
import { formatDate } from 'utils/dateUtils';
export interface TableListItemProps { export interface TableListItemProps {
table: TableResource; table: TableResource;
...@@ -19,12 +20,6 @@ class TableListItem extends React.Component<TableListItemProps, {}> { ...@@ -19,12 +20,6 @@ class TableListItem extends React.Component<TableListItemProps, {}> {
super(props); super(props);
} }
getDateLabel = () => {
const { table } = this.props;
const dateTokens = new Date(table.last_updated_timestamp * 1000).toDateString().split(' ');
return `${dateTokens[1]} ${dateTokens[2]}, ${dateTokens[3]}`;
};
getLink = () => { getLink = () => {
const { table, logging } = this.props; const { table, logging } = this.props;
return `/table_detail/${table.cluster}/${table.database}/${table.schema}/${table.name}` return `/table_detail/${table.cluster}/${table.database}/${table.schema}/${table.name}`
...@@ -37,7 +32,6 @@ class TableListItem extends React.Component<TableListItemProps, {}> { ...@@ -37,7 +32,6 @@ class TableListItem extends React.Component<TableListItemProps, {}> {
render() { render() {
const { table } = this.props; const { table } = this.props;
const hasLastUpdated = !!table.last_updated_timestamp;
return ( return (
<li className="list-group-item"> <li className="list-group-item">
...@@ -59,11 +53,11 @@ class TableListItem extends React.Component<TableListItemProps, {}> { ...@@ -59,11 +53,11 @@ class TableListItem extends React.Component<TableListItemProps, {}> {
</div> </div>
<div className="resource-badges"> <div className="resource-badges">
{ {
hasLastUpdated && !!table.last_updated_timestamp &&
<div> <div>
<div className="title-3">Last Updated</div> <div className="title-3">Last Updated</div>
<div className="body-secondary-3"> <div className="body-secondary-3">
{ this.getDateLabel() } { formatDate({ epochTimestamp: table.last_updated_timestamp }) }
</div> </div>
</div> </div>
} }
......
...@@ -8,6 +8,7 @@ import TableListItem, { TableListItemProps } from '../'; ...@@ -8,6 +8,7 @@ import TableListItem, { TableListItemProps } from '../';
import { ResourceType } from 'interfaces'; import { ResourceType } from 'interfaces';
import * as ConfigUtils from 'config/config-utils'; import * as ConfigUtils from 'config/config-utils';
import { formatDate } from 'utils/dateUtils';
const MOCK_DISPLAY_NAME = 'displayName'; const MOCK_DISPLAY_NAME = 'displayName';
const MOCK_ICON_CLASS = 'test-class'; const MOCK_ICON_CLASS = 'test-class';
...@@ -41,14 +42,6 @@ describe('TableListItem', () => { ...@@ -41,14 +42,6 @@ describe('TableListItem', () => {
return { props, wrapper }; return { props, wrapper };
}; };
/* Note: Jest is configured to use UTC */
describe('getDateLabel', () => {
it('getDateLabel returns correct string', () => {
const { props, wrapper } = setup();
expect(wrapper.instance().getDateLabel()).toEqual('Mar 29, 2019');
});
});
describe('getLink', () => { describe('getLink', () => {
it('getLink returns correct string', () => { it('getLink returns correct string', () => {
const { props, wrapper } = setup(); const { props, wrapper } = setup();
...@@ -139,7 +132,8 @@ describe('TableListItem', () => { ...@@ -139,7 +132,8 @@ describe('TableListItem', () => {
}); });
it('renders getDateLabel value', () => { it('renders getDateLabel value', () => {
expect(resourceBadges.children().at(0).children().at(1).text()).toEqual(wrapper.instance().getDateLabel()); const expectedString = formatDate({ epochTimestamp: props.table.last_updated_timestamp });
expect(resourceBadges.children().at(0).children().at(1).text()).toEqual(expectedString);
}); });
}); });
...@@ -160,7 +154,7 @@ describe('TableListItem', () => { ...@@ -160,7 +154,7 @@ describe('TableListItem', () => {
}); });
it('renders correct end icon', () => { it('renders correct end icon', () => {
const expectedClassName = 'icon icon-right' const expectedClassName = 'icon icon-right';
expect(resourceBadges.find('img').props().className).toEqual(expectedClassName); expect(resourceBadges.find('img').props().className).toEqual(expectedClassName);
}); });
}); });
......
...@@ -6,6 +6,11 @@ const configDefault: AppConfig = { ...@@ -6,6 +6,11 @@ const configDefault: AppConfig = {
curatedTags: [], curatedTags: [],
showAllTags: true, showAllTags: true,
}, },
date: {
default: 'MMM DD, YYYY',
dateTimeShort: 'MMM DD, YYYY ha z',
dateTimeLong: 'MMMM Do YYYY [at] h:mm:ss a',
},
editableText: { editableText: {
tableDescLength: 750, tableDescLength: 750,
columnDescLength: 250, columnDescLength: 250,
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
export interface AppConfig { export interface AppConfig {
badges: BadgeConfig; badges: BadgeConfig;
browse: BrowseConfig; browse: BrowseConfig;
date: DateFormatConfig;
editableText: EditableTextConfig; editableText: EditableTextConfig;
google: GoogleAnalyticsConfig; google: GoogleAnalyticsConfig;
indexUsers: IndexUsersConfig; indexUsers: IndexUsersConfig;
...@@ -21,6 +22,7 @@ export interface AppConfig { ...@@ -21,6 +22,7 @@ export interface AppConfig {
export interface AppConfigCustom { export interface AppConfigCustom {
badges?: BadgeConfig; badges?: BadgeConfig;
browse?: BrowseConfig; browse?: BrowseConfig;
date?: DateFormatConfig;
editableText?: EditableTextConfig; editableText?: EditableTextConfig;
google?: GoogleAnalyticsConfig google?: GoogleAnalyticsConfig
indexUsers?: IndexUsersConfig; indexUsers?: IndexUsersConfig;
...@@ -77,6 +79,16 @@ interface BadgeConfig { ...@@ -77,6 +79,16 @@ interface BadgeConfig {
[badge: string]: BadgeStyleConfig; [badge: string]: BadgeStyleConfig;
} }
/**
* DateConfig - Configure various date formats
*
*/
interface DateFormatConfig {
default: string;
dateTimeLong: string;
dateTimeShort: string;
}
/** ResourceConfig - For customizing values related to how various resources /** ResourceConfig - For customizing values related to how various resources
* are displayed in the UI. * are displayed in the UI.
* *
......
...@@ -40,7 +40,7 @@ import { ...@@ -40,7 +40,7 @@ import {
setPageIndex, setResource, setPageIndex, setResource,
} from './reducer'; } from './reducer';
import { autoSelectResource, getPageIndex, getSearchState } from './utils'; import { autoSelectResource, getPageIndex, getSearchState } from './utils';
import { BrowserHistory, updateSearchUrl } from 'utils/navigation-utils'; import { BrowserHistory, updateSearchUrl } from 'utils/navigationUtils';
export function* inlineSearchWorker(action: InlineSearchRequest): SagaIterator { export function* inlineSearchWorker(action: InlineSearchRequest): SagaIterator {
const { term } = action.payload; const { term } = action.payload;
......
...@@ -60,10 +60,10 @@ import { ...@@ -60,10 +60,10 @@ import {
SubmitSearch, SubmitSearch,
UrlDidUpdate, UrlDidUpdate,
} from '../types'; } from '../types';
import * as NavigationUtils from '../../../utils/navigation-utils'; import * as NavigationUtils from 'utils/navigationUtils';
import * as SearchUtils from 'ducks/search/utils'; import * as SearchUtils from 'ducks/search/utils';
import globalState from '../../../fixtures/globalState'; import globalState from 'fixtures/globalState';
const updateSearchUrlSpy = jest.spyOn(NavigationUtils, 'updateSearchUrl'); const updateSearchUrlSpy = jest.spyOn(NavigationUtils, 'updateSearchUrl');
const searchState = globalState.search; const searchState = globalState.search;
......
...@@ -186,6 +186,7 @@ export const initialTableDataState: TableMetadata = { ...@@ -186,6 +186,7 @@ export const initialTableDataState: TableMetadata = {
is_editable: false, is_editable: false,
is_view: false, is_view: false,
key: '', key: '',
last_updated_timestamp: 0,
schema: '', schema: '',
name: '', name: '',
description: '', description: '',
......
...@@ -116,6 +116,7 @@ const globalState: GlobalState = { ...@@ -116,6 +116,7 @@ const globalState: GlobalState = {
is_editable: false, is_editable: false,
is_view: false, is_view: false,
key: '', key: '',
last_updated_timestamp: 0,
schema: '', schema: '',
name: '', name: '',
description: '', description: '',
......
...@@ -22,7 +22,7 @@ import TableDetail from './components/TableDetail'; ...@@ -22,7 +22,7 @@ import TableDetail from './components/TableDetail';
import rootReducer from './ducks/rootReducer'; import rootReducer from './ducks/rootReducer';
import rootSaga from './ducks/rootSaga'; import rootSaga from './ducks/rootSaga';
import { BrowserHistory } from 'utils/navigation-utils'; import { BrowserHistory } from 'utils/navigationUtils';
const sagaMiddleware = createSagaMiddleware(); const sagaMiddleware = createSagaMiddleware();
const createStoreWithMiddleware = applyMiddleware(ReduxPromise, sagaMiddleware)(createStore); const createStoreWithMiddleware = applyMiddleware(ReduxPromise, sagaMiddleware)(createStore);
......
...@@ -76,6 +76,7 @@ export interface TableMetadata { ...@@ -76,6 +76,7 @@ export interface TableMetadata {
is_editable: boolean; is_editable: boolean;
is_view: boolean; is_view: boolean;
key: string; key: string;
last_updated_timestamp: number;
schema: string; schema: string;
name: string; name: string;
description: string; description: string;
......
import * as Moment from 'moment-timezone';
import AppConfig from 'config/config';
const timezone = Moment.tz.guess();
interface TimestampDateConfig {
timestamp: number;
}
interface EpochDateConfig {
epochTimestamp: number;
}
interface StringDateConfig {
dateString: string;
dateStringFormat: string;
}
type DateConfig = TimestampDateConfig | EpochDateConfig | StringDateConfig;
// This function is only exported for testing
export function getMomentDate(config: DateConfig): Moment {
let moment;
const timestamp = (config as TimestampDateConfig).timestamp;
const epoch = (config as EpochDateConfig).epochTimestamp;
const { dateString, dateStringFormat } = config as StringDateConfig;
if (timestamp !== undefined) {
moment = Moment(timestamp);
} else if (epoch !== undefined) {
moment = Moment(epoch * 1000);
} else if (dateString && dateStringFormat) {
moment = Moment(dateString, dateStringFormat)
} else {
throw new Error('Cannot format date with invalid DateConfig object.')
}
return moment.tz(timezone);
}
export function formatDate(config: DateConfig) {
const date = getMomentDate(config);
return date.format(AppConfig.date.default);
}
export function formatDateTimeShort(config: DateConfig) {
const date = getMomentDate(config);
return date.format(AppConfig.date.dateTimeShort);
}
export function formatDateTimeLong(config: DateConfig) {
const date = getMomentDate(config);
return date.format(AppConfig.date.dateTimeLong);
}
import * as NavigationUtils from 'utils/navigation-utils'; import * as DateUtils from 'utils/dateUtils';
import * as NavigationUtils from 'utils/navigationUtils';
import * as qs from 'simple-query-string'; import * as qs from 'simple-query-string';
import { ResourceType } from 'interfaces/Resources'; import { ResourceType } from 'interfaces/Resources';
describe('Navigation utils', () => { describe('navigationUtils', () => {
describe('updateSearchUrl', () => { describe('updateSearchUrl', () => {
let historyReplaceSpy; let historyReplaceSpy;
...@@ -46,3 +47,53 @@ describe('Navigation utils', () => { ...@@ -46,3 +47,53 @@ describe('Navigation utils', () => {
}); });
}); });
}); });
describe('dateUtils', () => {
describe('getMomentDate', () => {
it('parses a timestamp', () => {
const config = { timestamp: 1580421964000 };
const moment = DateUtils.getMomentDate(config);
const dateString = moment.format('MMM DD, YYYY');
expect(dateString).toEqual('Jan 30, 2020')
});
it('parses an epoch timestamp', () => {
const config = { epochTimestamp: 1580421964 };
const moment = DateUtils.getMomentDate(config);
const dateString = moment.format('MMM DD, YYYY');
expect(dateString).toEqual('Jan 30, 2020')
});
it('parses an date string', () => {
const config = { dateString: "2020-Jan-30", dateStringFormat: "YYYY-MMM-DD" };
const moment = DateUtils.getMomentDate(config);
const dateString = moment.format('MMM DD, YYYY');
expect(dateString).toEqual('Jan 30, 2020')
});
});
describe('formatDate', () => {
it('formats a date to the default format', () => {
const config = { timestamp: 1580421964000 };
const dateString = DateUtils.formatDate(config);
expect(dateString).toEqual('Jan 30, 2020')
});
});
describe('formatDateTimeShort', () => {
it('formats a date to the date time short format', () => {
const config = { timestamp: 1580421964000 };
const dateString = DateUtils.formatDateTimeShort(config);
// This test may fail in your IDE if your timezone is not set to GMT
expect(dateString).toEqual('Jan 30, 2020 10pm GMT')
});
});
describe('formatDateTimeLong', () => {
const config = { timestamp: 1580421964000 };
const dateString = DateUtils.formatDateTimeLong(config);
expect(dateString).toEqual('January 30th 2020 at 10:06:04 pm')
})
});
...@@ -18,6 +18,16 @@ _TODO: Please add doc_ ...@@ -18,6 +18,16 @@ _TODO: Please add doc_
1. Add your logo to the folder in `amundsen_application/static/images/`. 1. Add your logo to the folder in `amundsen_application/static/images/`.
2. Set the the `logoPath` key on the to the location of your image. 2. Set the the `logoPath` key on the to the location of your image.
## Date
This config allows you to specify various date formats across the app. There are three date formats in use shown below. These correspond to the `formatDate`, `formatDateTimeShort` and `formatDateTimeLong` utility functions.
default: 'MMM DD, YYYY'
dateTimeShort: 'MMM DD, YYYY ha z'
dateTimeLong: 'MMMM Do YYYY [at] h:mm:ss a'
Reference for formatting: https://devhints.io/datetime#momentjs-format
## Google Analytics ## Google Analytics
_TODO: Please add doc_ _TODO: Please add doc_
......
...@@ -56,6 +56,7 @@ class MetadataTest(unittest.TestCase): ...@@ -56,6 +56,7 @@ class MetadataTest(unittest.TestCase):
'database': 'test_db', 'database': 'test_db',
'is_view': False, 'is_view': False,
'key': 'test_db://test_cluster.test_schema/test_table', 'key': 'test_db://test_cluster.test_schema/test_table',
'last_updated_timestamp': 1563872712,
'owners': [], 'owners': [],
'schema': 'test_schema', 'schema': 'test_schema',
'name': 'test_table', 'name': 'test_table',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment