mirror of https://github.com/apache/superset.git
feat(alerts/reports): add refresh action (#12071)
This commit is contained in:
parent
895fa19d8d
commit
d1dfe82d6c
|
@ -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(<LastUpdated updatedAt={updatedAt} />);
|
||||
expect(/^Last Updated .+$/.test(wrapper.text())).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a refresh action', () => {
|
||||
const mockAction = jest.fn();
|
||||
wrapper = mount(<LastUpdated updatedAt={updatedAt} update={mockAction} />);
|
||||
const props = wrapper.find('[data-test="refresh"]').props();
|
||||
if (props.onClick) {
|
||||
props.onClick({} as React.MouseEvent);
|
||||
}
|
||||
expect(mockAction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -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<SVGSVGElement>;
|
||||
}
|
||||
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<LastUpdatedProps> = ({
|
||||
updatedAt,
|
||||
update,
|
||||
}) => {
|
||||
const [timeSince, setTimeSince] = useState<Moment>(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 (
|
||||
<TextStyles>
|
||||
{t('Last Updated %s', timeSince.isValid() ? timeSince.calendar() : '--')}
|
||||
{update && <Refresh name="refresh" onClick={update} />}
|
||||
</TextStyles>
|
||||
);
|
||||
};
|
||||
|
||||
export default LastUpdated;
|
|
@ -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<ButtonProps>;
|
||||
name?: string | ReactNode;
|
||||
tabs?: MenuChild[];
|
||||
children?: MenuChild[];
|
||||
activeChild?: MenuChild['name'];
|
||||
/* If usesRouter is true, a react-router <Link> component will be used instead of href.
|
||||
* ONLY set usesRouter to true if SubMenu is wrapped in a react-router <Router>;
|
||||
|
@ -174,6 +174,7 @@ const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
|
|||
))}
|
||||
</Nav>
|
||||
</Navbar>
|
||||
{props.children}
|
||||
</StyledHeader>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<number[], { message: string }>({
|
|||
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}
|
||||
/>
|
||||
>
|
||||
<RefreshContainer>
|
||||
<LastUpdated updatedAt={lastFetched} update={() => refreshData()} />
|
||||
</RefreshContainer>
|
||||
</SubMenu>
|
||||
<AlertReportModal
|
||||
alert={currentAlert}
|
||||
addDangerToast={addDangerToast}
|
||||
|
|
|
@ -35,6 +35,7 @@ interface ListViewResourceState<D extends object = any> {
|
|||
permissions: string[];
|
||||
lastFetchDataConfig: FetchDataConfig | null;
|
||||
bulkSelectEnabled: boolean;
|
||||
lastFetched?: string;
|
||||
}
|
||||
|
||||
export function useListViewResource<D extends object = any>(
|
||||
|
@ -43,7 +44,7 @@ export function useListViewResource<D extends object = any>(
|
|||
handleErrorMsg: (errorMsg: string) => void,
|
||||
infoEnable = true,
|
||||
defaultCollectionValue: D[] = [],
|
||||
baseFilters: FilterValue[] = [], // must be memoized
|
||||
baseFilters?: FilterValue[], // must be memoized
|
||||
) {
|
||||
const [state, setState] = useState<ListViewResourceState<D>>({
|
||||
count: 0,
|
||||
|
@ -112,7 +113,7 @@ export function useListViewResource<D extends object = any>(
|
|||
loading: true,
|
||||
});
|
||||
|
||||
const filterExps = baseFilters
|
||||
const filterExps = (baseFilters || [])
|
||||
.concat(filterValues)
|
||||
.map(({ id: col, operator: opr, value }) => ({
|
||||
col,
|
||||
|
@ -136,6 +137,7 @@ export function useListViewResource<D extends object = any>(
|
|||
updateState({
|
||||
collection: json.result,
|
||||
count: json.count,
|
||||
lastFetched: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
|
@ -152,7 +154,7 @@ export function useListViewResource<D extends object = any>(
|
|||
updateState({ loading: false });
|
||||
});
|
||||
},
|
||||
[baseFilters.length ? baseFilters : null],
|
||||
[baseFilters],
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -161,6 +163,7 @@ export function useListViewResource<D extends object = any>(
|
|||
resourceCount: state.count,
|
||||
resourceCollection: state.collection,
|
||||
bulkSelectEnabled: state.bulkSelectEnabled,
|
||||
lastFetched: state.lastFetched,
|
||||
},
|
||||
setResourceCollection: (update: D[]) =>
|
||||
updateState({
|
||||
|
|
Loading…
Reference in New Issue