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 0000000000..04cdc0ded3
--- /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 0000000000..dc55957300
--- /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 e12c5c431f..eaf62569d7 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 4fc98727cf..767686553d 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({