diff --git a/superset-frontend/src/components/LastUpdated/LastUpdated.test.tsx b/superset-frontend/src/components/LastUpdated/LastUpdated.test.tsx new file mode 100644 index 000000000000..04cdc0ded358 --- /dev/null +++ b/superset-frontend/src/components/LastUpdated/LastUpdated.test.tsx @@ -0,0 +1,43 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { ReactWrapper } from 'enzyme'; +import { styledMount as mount } from 'spec/helpers/theming'; +import LastUpdated from '.'; + +describe('LastUpdated', () => { + let wrapper: ReactWrapper; + const updatedAt = new Date('Sat Dec 12 2020 00:00:00 GMT-0800'); + + it('renders the base component (no refresh)', () => { + const wrapper = mount(); + expect(/^Last Updated .+$/.test(wrapper.text())).toBe(true); + }); + + it('renders a refresh action', () => { + const mockAction = jest.fn(); + wrapper = mount(); + const props = wrapper.find('[data-test="refresh"]').props(); + if (props.onClick) { + props.onClick({} as React.MouseEvent); + } + expect(mockAction).toHaveBeenCalled(); + }); +}); diff --git a/superset-frontend/src/components/LastUpdated/index.tsx b/superset-frontend/src/components/LastUpdated/index.tsx new file mode 100644 index 000000000000..dc55957300ed --- /dev/null +++ b/superset-frontend/src/components/LastUpdated/index.tsx @@ -0,0 +1,80 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useEffect, useState, FunctionComponent } from 'react'; +import moment, { Moment, MomentInput } from 'moment'; +import { t, styled } from '@superset-ui/core'; +import Icon from 'src/components/Icon'; + +const REFRESH_INTERVAL = 60000; // every minute + +interface LastUpdatedProps { + updatedAt: MomentInput; + update?: React.MouseEventHandler; +} +moment.updateLocale('en', { + calendar: { + lastDay: '[Yesterday at] LTS', + sameDay: '[Today at] LTS', + nextDay: '[Tomorrow at] LTS', + lastWeek: '[last] dddd [at] LTS', + nextWeek: 'dddd [at] LTS', + sameElse: 'L', + }, +}); + +const TextStyles = styled.span` + color: ${({ theme }) => theme.colors.grayscale.base}; +`; + +const Refresh = styled(Icon)` + color: ${({ theme }) => theme.colors.primary.base}; + width: auto; + height: ${({ theme }) => theme.gridUnit * 5}px; + position: relative; + top: ${({ theme }) => theme.gridUnit}px; + left: ${({ theme }) => theme.gridUnit}px; + cursor: pointer; +`; + +export const LastUpdated: FunctionComponent = ({ + updatedAt, + update, +}) => { + const [timeSince, setTimeSince] = useState(moment(updatedAt)); + + useEffect(() => { + setTimeSince(() => moment(updatedAt)); + + // update UI every minute in case day changes + const interval = setInterval(() => { + setTimeSince(() => moment(updatedAt)); + }, REFRESH_INTERVAL); + + return () => clearInterval(interval); + }, [updatedAt]); + + return ( + + {t('Last Updated %s', timeSince.isValid() ? timeSince.calendar() : '--')} + {update && } + + ); +}; + +export default LastUpdated; diff --git a/superset-frontend/src/components/Menu/SubMenu.tsx b/superset-frontend/src/components/Menu/SubMenu.tsx index e12c5c431f4f..eaf62569d7e8 100644 --- a/superset-frontend/src/components/Menu/SubMenu.tsx +++ b/superset-frontend/src/components/Menu/SubMenu.tsx @@ -24,8 +24,9 @@ import { Nav, Navbar } from 'react-bootstrap'; import Button, { OnClickHandler } from 'src/components/Button'; const StyledHeader = styled.header` + margin-bottom: ${({ theme }) => theme.gridUnit * 4}px; .navbar { - margin-bottom: ${({ theme }) => theme.gridUnit * 4}px; + margin-bottom: 0; } .navbar-header .navbar-brand { font-weight: ${({ theme }) => theme.typography.weights.bold}; @@ -108,7 +109,6 @@ export interface SubMenuProps { buttons?: Array; name?: string | ReactNode; tabs?: MenuChild[]; - children?: MenuChild[]; activeChild?: MenuChild['name']; /* If usesRouter is true, a react-router component will be used instead of href. * ONLY set usesRouter to true if SubMenu is wrapped in a react-router ; @@ -174,6 +174,7 @@ const SubMenu: React.FunctionComponent = props => { ))} + {props.children} ); }; diff --git a/superset-frontend/src/views/CRUD/alert/AlertList.tsx b/superset-frontend/src/views/CRUD/alert/AlertList.tsx index 4fc98727cf3f..767686553da5 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertList.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertList.tsx @@ -19,7 +19,7 @@ import React, { useState, useMemo, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; -import { t, SupersetClient, makeApi } from '@superset-ui/core'; +import { t, SupersetClient, makeApi, styled } from '@superset-ui/core'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; import Button from 'src/components/Button'; import FacePile from 'src/components/FacePile'; @@ -37,6 +37,8 @@ import AlertStatusIcon from 'src/views/CRUD/alert/components/AlertStatusIcon'; import RecipientIcon from 'src/views/CRUD/alert/components/RecipientIcon'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import DeleteModal from 'src/components/DeleteModal'; +import LastUpdated from 'src/components/LastUpdated'; + import { useListViewResource, useSingleViewResource, @@ -61,6 +63,13 @@ const deleteAlerts = makeApi({ endpoint: '/api/v1/report/', }); +const RefreshContainer = styled.div` + width: 100%; + padding: 0 ${({ theme }) => theme.gridUnit * 4}px + ${({ theme }) => theme.gridUnit * 3}px; + background-color: ${({ theme }) => theme.colors.grayscale.light5}; +`; + function AlertList({ addDangerToast, isReportEnabled = false, @@ -86,6 +95,7 @@ function AlertList({ resourceCount: alertsCount, resourceCollection: alerts, bulkSelectEnabled, + lastFetched, }, hasPerm, fetchData, @@ -397,7 +407,11 @@ function AlertList({ }, ]} buttons={subMenuButtons} - /> + > + + refreshData()} /> + + { permissions: string[]; lastFetchDataConfig: FetchDataConfig | null; bulkSelectEnabled: boolean; + lastFetched?: string; } export function useListViewResource( @@ -43,7 +44,7 @@ export function useListViewResource( handleErrorMsg: (errorMsg: string) => void, infoEnable = true, defaultCollectionValue: D[] = [], - baseFilters: FilterValue[] = [], // must be memoized + baseFilters?: FilterValue[], // must be memoized ) { const [state, setState] = useState>({ count: 0, @@ -112,7 +113,7 @@ export function useListViewResource( loading: true, }); - const filterExps = baseFilters + const filterExps = (baseFilters || []) .concat(filterValues) .map(({ id: col, operator: opr, value }) => ({ col, @@ -136,6 +137,7 @@ export function useListViewResource( updateState({ collection: json.result, count: json.count, + lastFetched: new Date().toISOString(), }); }, createErrorHandler(errMsg => @@ -152,7 +154,7 @@ export function useListViewResource( updateState({ loading: false }); }); }, - [baseFilters.length ? baseFilters : null], + [baseFilters], ); return { @@ -161,6 +163,7 @@ export function useListViewResource( resourceCount: state.count, resourceCollection: state.collection, bulkSelectEnabled: state.bulkSelectEnabled, + lastFetched: state.lastFetched, }, setResourceCollection: (update: D[]) => updateState({