feat: add cron picker to AlertReportModal (#12032)

* humanize cron display in list view
This commit is contained in:
ʈᵃᵢ 2020-12-14 22:29:31 -08:00 committed by GitHub
parent fda3a2fe7c
commit 6fcda5dac1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 384 additions and 53 deletions

View File

@ -24,6 +24,7 @@ chardet==3.0.4 # via aiohttp
click==7.1.2 # via apache-superset, flask, flask-appbuilder
colorama==0.4.4 # via apache-superset, flask-appbuilder
contextlib2==0.6.0.post1 # via apache-superset
cron-descriptor==1.2.24 # via apache-superset
croniter==0.3.36 # via apache-superset
cryptography==3.2.1 # via apache-superset
decorator==4.4.2 # via retry

View File

@ -30,7 +30,7 @@ combine_as_imports = true
include_trailing_comma = true
line_length = 88
known_first_party = superset
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,contextlib2,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parameterized,parsedatetime,pathlib2,pgsanity,polyline,prison,pyarrow,pyhive,pytest,pytz,redis,retry,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,werkzeug,wtforms,wtforms_json,yaml
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,contextlib2,cron_descriptor,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parameterized,parsedatetime,pathlib2,pgsanity,polyline,prison,pyarrow,pyhive,pytest,pytz,redis,retry,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,werkzeug,wtforms,wtforms_json,yaml
multi_line_output = 3
order_by_type = false

View File

@ -72,6 +72,7 @@ setup(
"colorama",
"contextlib2",
"croniter>=0.3.28",
"cron-descriptor",
"cryptography>=3.2.1",
"flask>=1.1.0, <2.0.0",
"flask-appbuilder>=3.1.1, <4.0.0",

View File

@ -43514,6 +43514,11 @@
"resolved": "https://registry.npmjs.org/react-is-mounted-hook/-/react-is-mounted-hook-1.0.3.tgz",
"integrity": "sha512-YCCYcTVYMPfTi6WhWIwM9EYBcpHoivjjkE90O5ScsE9wXSbeXGZvLDMGt4mdSNcWshhc8JD0AzgBmsleCSdSFA=="
},
"react-js-cron": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-js-cron/-/react-js-cron-1.2.0.tgz",
"integrity": "sha512-mWxTmXkqP58ughdziS3qjEUVl1O03XEo8WDvr45/kTQfbd0C6ITniAsG5wZzwmTOgmrOKQheHog7L0TP683WUA=="
},
"react-json-tree": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/react-json-tree/-/react-json-tree-0.11.2.tgz",

View File

@ -141,6 +141,7 @@
"react-dom": "^16.13.0",
"react-gravatar": "^2.6.1",
"react-hot-loader": "^4.12.20",
"react-js-cron": "^1.2.0",
"react-json-tree": "^0.11.2",
"react-jsonschema-form": "^1.2.0",
"react-loadable": "^5.5.0",

View File

@ -0,0 +1,117 @@
/**
* 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 { styled, t } from '@superset-ui/core';
import ReactCronPicker, { Locale, CronProps } from 'react-js-cron';
export * from 'react-js-cron';
export const LOCALE: Locale = {
everyText: t('every'),
emptyMonths: t('every month'),
emptyMonthDays: t('every day of the month'),
emptyMonthDaysShort: t('day of the month'),
emptyWeekDays: t('every day of the week'),
emptyWeekDaysShort: t('day of the week'),
emptyHours: t('every hour'),
emptyMinutes: t('every minute UTC'),
emptyMinutesForHourPeriod: t('every'),
yearOption: t('year'),
monthOption: t('month'),
weekOption: t('week'),
dayOption: t('day'),
hourOption: t('hour'),
minuteOption: t('minute'),
rebootOption: t('reboot'),
prefixPeriod: t('Every'),
prefixMonths: t('in'),
prefixMonthDays: t('on'),
prefixWeekDays: t('on'),
prefixWeekDaysForMonthAndYearPeriod: t('and'),
prefixHours: t('at'),
prefixMinutes: t(':'),
prefixMinutesForHourPeriod: t('at'),
suffixMinutesForHourPeriod: t('minute(s) UTC'),
errorInvalidCron: t('Invalid cron expression'),
clearButtonText: t('Clear'),
weekDays: [
// Order is important, the index will be used as value
t('Sunday'), // Sunday must always be first, it's "0"
t('Monday'),
t('Tuesday'),
t('Wednesday'),
t('Thursday'),
t('Friday'),
t('Saturday'),
],
months: [
// Order is important, the index will be used as value
t('January'),
t('February'),
t('March'),
t('April'),
t('May'),
t('June'),
t('July'),
t('August'),
t('September'),
t('October'),
t('November'),
t('December'),
],
// Order is important, the index will be used as value
altWeekDays: [
t('SUN'), // Sunday must always be first, it's "0"
t('MON'),
t('TUE'),
t('WED'),
t('THU'),
t('FRI'),
t('SAT'),
],
// Order is important, the index will be used as value
altMonths: [
t('JAN'),
t('FEB'),
t('MAR'),
t('APR'),
t('MAY'),
t('JUN'),
t('JUL'),
t('AUG'),
t('SEP'),
t('OCT'),
t('NOV'),
t('DEC'),
],
};
export const CronPicker = styled((props: CronProps) => (
<ReactCronPicker locale={LOCALE} {...props} />
))`
.react-js-cron-select:not(.react-js-cron-custom-select) > div:first-of-type,
.react-js-cron-custom-select {
border-radius: ${({ theme }) => theme.gridUnit}px;
background-color: ${({ theme }) =>
theme.colors.grayscale.light4} !important;
}
.react-js-cron-custom-select > div:first-of-type {
border-radius: ${({ theme }) => theme.gridUnit}px;
}
`;

View File

@ -39,7 +39,7 @@ const StyledSelect = styled(BaseSelect)`
}
}
`;
const StyledOption = styled(BaseSelect.Option)``;
const StyledOption = BaseSelect.Option;
export const Select = Object.assign(StyledSelect, {
Option: StyledOption,

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import React, { useState, useRef, useCallback } from 'react';
import { action } from '@storybook/addon-actions';
import { withKnobs, boolean, select } from '@storybook/addon-knobs';
import Button from 'src/components/Button';
@ -24,8 +24,8 @@ import Modal from './Modal';
import Tabs, { EditableTabs } from './Tabs';
import AntdPopover from './Popover';
import { Tooltip as AntdTooltip } from './Tooltip';
import { Menu } from '.';
import { Switch as AntdSwitch } from './Switch';
import { Menu, Input, Divider } from '.';
import { Dropdown } from './Dropdown';
import InfoTooltip from './InfoTooltip';
import {
@ -35,6 +35,7 @@ import {
import Badge from './Badge';
import ProgressBar from './ProgressBar';
import Collapse from './Collapse';
import { CronPicker, CronError } from './CronPicker';
export default {
title: 'Common Components',
@ -321,3 +322,43 @@ export const CollapseTextLight = () => (
</Collapse.Panel>
</Collapse>
);
export function StyledCronPicker() {
const inputRef = useRef<Input>(null);
const defaultValue = '30 5 * * 1,6';
const [value, setValue] = useState(defaultValue);
const customSetValue = useCallback(
(newValue: string) => {
setValue(newValue);
inputRef.current?.setValue(newValue);
},
[inputRef],
);
const [error, onError] = useState<CronError>();
return (
<div>
<Input
ref={inputRef}
onBlur={event => {
setValue(event.target.value);
}}
onPressEnter={() => {
setValue(inputRef.current?.input.value || '');
}}
/>
<Divider />
<CronPicker
clearButton={false}
value={value}
setValue={customSetValue}
onError={onError}
/>
<p style={{ marginTop: 20 }}>
Error: {error ? error.description : 'undefined'}
</p>
</div>
);
}

View File

@ -33,6 +33,7 @@ export {
Button,
Card,
DatePicker,
Divider,
Dropdown,
Empty,
Input,

View File

@ -24,6 +24,7 @@ import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
import Button from 'src/components/Button';
import FacePile from 'src/components/FacePile';
import { IconName } from 'src/components/Icon';
import { Tooltip } from 'src/common/components/Tooltip';
import ListView, { FilterOperators, Filters } from 'src/components/ListView';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
import { Switch } from 'src/common/components/Switch';
@ -55,7 +56,7 @@ function AlertList({
isReportEnabled = false,
user,
}: AlertListProps) {
const title = isReportEnabled ? 'report' : 'alert';
const title = isReportEnabled ? t('report') : t('alert');
const pathName = isReportEnabled ? 'Reports' : 'Alerts';
const initalFilters = useMemo(
() => [
@ -139,20 +140,31 @@ function AlertList({
}: any) =>
recipients.map((r: any) => (
<RecipientIcon key={r.id} type={r.type} />
// <Icon key={r.id} name={r.type.toLowerCase() as IconName} />
)),
accessor: 'recipients',
Header: t('Notification Method'),
disableSortBy: true,
size: 'xl',
},
{
Header: t('Schedule'),
accessor: 'crontab',
accessor: 'crontab_humanized',
size: 'xl',
Cell: ({
row: {
original: { crontab_humanized = '' },
},
}: any) => (
<Tooltip title={crontab_humanized} placement="topLeft">
<span>{crontab_humanized}</span>,
</Tooltip>
),
},
{
accessor: 'created_by',
disableSortBy: true,
hidden: true,
size: 'xl',
},
{
Cell: ({
@ -163,7 +175,7 @@ function AlertList({
Header: t('Owners'),
id: 'owners',
disableSortBy: true,
size: 'lg',
size: 'xl',
},
{
Cell: ({ row: { original } }: any) => (
@ -177,6 +189,7 @@ function AlertList({
Header: t('Active'),
accessor: 'active',
id: 'active',
size: 'xl',
},
{
Cell: ({ row: { original } }: any) => {
@ -234,7 +247,7 @@ function AlertList({
subMenuButtons.push({
name: (
<>
<i className="fa fa-plus" /> {t(`${title}`)}
<i className="fa fa-plus" /> {title}
</>
),
buttonStyle: 'primary',
@ -245,8 +258,8 @@ function AlertList({
}
const EmptyStateButton = (
<Button buttonStyle="primary" onClick={() => {}}>
<i className="fa fa-plus" /> {t(`${title}`)}
<Button buttonStyle="primary" onClick={() => handleAlertEdit(null)}>
<i className="fa fa-plus" /> {title}
</Button>
);

View File

@ -28,8 +28,9 @@ import { Select } from 'src/common/components/Select';
import { Radio } from 'src/common/components/Radio';
import { AsyncSelect } from 'src/components/Select';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import Owner from 'src/types/Owner';
import { AlertReportCronScheduler } from './components/AlertReportCronScheduler';
import { AlertObject, Operator, Recipient, MetaObject } from './types';
type SelectValue = {
@ -121,7 +122,7 @@ const StyledSectionContainer = styled.div`
.column {
flex: 1 1 auto;
min-width: 33.33%;
min-width: calc(33.33% - ${({ theme }) => theme.gridUnit * 8}px);
padding: ${({ theme }) => theme.gridUnit * 4}px;
.async-select {
@ -142,6 +143,9 @@ const StyledSectionContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
&.wrap {
flex-wrap: wrap;
}
> div {
flex: 1 1 auto;
@ -180,7 +184,7 @@ const StyledSwitchContainer = styled.div`
}
`;
const StyledInputContainer = styled.div`
export const StyledInputContainer = styled.div`
flex: 1 1 auto;
margin: ${({ theme }) => theme.gridUnit * 2}px;
margin-top: 0;
@ -449,10 +453,6 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
const [currentAlert, setCurrentAlert] = useState<AlertObject | null>();
const [isHidden, setIsHidden] = useState<boolean>(true);
const [contentType, setContentType] = useState<string>('dashboard');
const [scheduleFormat, setScheduleFormat] = useState<string>(
'dropdown-format',
);
// Dropdown options
const [sourceOptions, setSourceOptions] = useState<MetaObject[]>([]);
const [dashboardOptions, setDashboardOptions] = useState<MetaObject[]>([]);
@ -806,12 +806,6 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
updateAlertState('validator_config_json', config);
};
const onScheduleFormatChange = (event: any) => {
const { target } = event;
setScheduleFormat(target.value);
};
const onLogRetentionChange = (retention: number) => {
updateAlertState('log_retention', retention);
};
@ -976,12 +970,18 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
// Dropdown options
const conditionOptions = CONDITIONS.map(condition => {
return (
<Select.Option value={condition.value}>{condition.label}</Select.Option>
<Select.Option key={condition.value} value={condition.value}>
{condition.label}
</Select.Option>
);
});
const retentionOptions = RETENTION_OPTIONS.map(option => {
return <Select.Option value={option.value}>{option.label}</Select.Option>;
return (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
);
});
return (
@ -1102,7 +1102,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
/>
</div>
</StyledInputContainer>
<div className="inline-container">
<div className="inline-container wrap">
<StyledInputContainer>
<div className="control-label">
{t('Alert If...')}
@ -1153,32 +1153,10 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
<StyledSectionTitle>
<h4>{t('Alert Condition Schedule')}</h4>
</StyledSectionTitle>
<Radio.Group
onChange={onScheduleFormatChange}
value={scheduleFormat}
>
<div className="inline-container add-margin">
<Radio value="dropdown-format" />
<span className="input-label">
Every x Minutes (should be set of dropdown options)
</span>
</div>
<div className="inline-container add-margin">
<Radio value="cron-format" />
<span className="input-label">CRON Schedule</span>
<StyledInputContainer className="styled-input">
<div className="input-container">
<input
type="text"
name="crontab"
value={currentAlert ? currentAlert.crontab || '' : ''}
placeholder={t('CRON Expression')}
onChange={onTextChange}
/>
</div>
</StyledInputContainer>
</div>
</Radio.Group>
<AlertReportCronScheduler
value={(currentAlert && currentAlert.crontab) || undefined}
onChange={newVal => updateAlertState('crontab', newVal)}
/>
<StyledSectionTitle>
<h4>{t('Schedule Settings')}</h4>
</StyledSectionTitle>

View File

@ -0,0 +1,72 @@
/**
* 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 { CronPicker } from 'src/common/components/CronPicker';
import { Input } from 'src/common/components';
import { AlertReportCronScheduler } from './AlertReportCronScheduler';
describe('AlertReportCronScheduler', () => {
let wrapper: ReactWrapper;
it('calls onChnage when value chnages', () => {
const onChangeMock = jest.fn();
wrapper = mount(
<AlertReportCronScheduler value="* * * * *" onChange={onChangeMock} />,
);
const changeValue = '1,7 * * * *';
wrapper.find(CronPicker).props().setValue(changeValue);
expect(onChangeMock).toHaveBeenLastCalledWith(changeValue);
});
it('sets input value when cron picker changes', () => {
const onChangeMock = jest.fn();
wrapper = mount(
<AlertReportCronScheduler value="* * * * *" onChange={onChangeMock} />,
);
const changeValue = '1,7 * * * *';
wrapper.find(CronPicker).props().setValue(changeValue);
expect(wrapper.find(Input).state().value).toEqual(changeValue);
});
it('calls onChange when input value changes', () => {
const onChangeMock = jest.fn();
wrapper = mount(
<AlertReportCronScheduler value="* * * * *" onChange={onChangeMock} />,
);
const changeValue = '1,7 * * * *';
const event = {
target: { value: changeValue },
} as React.FocusEvent<HTMLInputElement>;
const inputProps = wrapper.find(Input).props();
if (inputProps.onBlur) {
inputProps.onBlur(event);
}
expect(onChangeMock).toHaveBeenLastCalledWith(changeValue);
});
});

View File

@ -0,0 +1,92 @@
/**
* 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, { useState, useCallback, useRef, FunctionComponent } from 'react';
import { t, useTheme } from '@superset-ui/core';
import { Input } from 'src/common/components';
import { Radio } from 'src/common/components/Radio';
import { CronPicker, CronError } from 'src/common/components/CronPicker';
import { StyledInputContainer } from '../AlertReportModal';
interface AlertReportCronSchedulerProps {
value?: string;
onChange: (change: string) => any;
}
export const AlertReportCronScheduler: FunctionComponent<AlertReportCronSchedulerProps> = ({
value = '* * * * *',
onChange,
}) => {
const theme = useTheme();
const inputRef = useRef<Input>(null);
const [scheduleFormat, setScheduleFormat] = useState<'picker' | 'input'>(
'picker',
);
const customSetValue = useCallback(
(newValue: string) => {
onChange(newValue);
inputRef.current?.setValue(newValue);
},
[inputRef, onChange],
);
const [error, onError] = useState<CronError>();
return (
<>
<Radio.Group
onChange={e => setScheduleFormat(e.target.value)}
value={scheduleFormat}
>
<div className="inline-container add-margin">
<Radio value="picker" />
<CronPicker
clearButton={false}
value={value}
setValue={customSetValue}
disabled={scheduleFormat !== 'picker'}
displayError={scheduleFormat === 'picker'}
onError={onError}
/>
</div>
<div className="inline-container add-margin">
<Radio value="input" />
<span className="input-label">CRON Schedule</span>
<StyledInputContainer className="styled-input">
<div className="input-container">
<Input
type="text"
name="crontab"
ref={inputRef}
style={error ? { borderColor: theme.colors.error.base } : {}}
placeholder={t('CRON Expression')}
disabled={scheduleFormat !== 'input'}
onBlur={event => {
onChange(event.target.value);
}}
onPressEnter={() => {
onChange(inputRef.current?.input.value || '');
}}
/>
</div>
</StyledInputContainer>
</div>
</Radio.Group>
</>
);
};

View File

@ -17,7 +17,9 @@
"""A collection of ORM sqlalchemy models for Superset"""
import enum
from cron_descriptor import get_description
from flask_appbuilder import Model
from flask_appbuilder.models.decorators import renders
from sqlalchemy import (
Boolean,
Column,
@ -131,6 +133,10 @@ class ReportSchedule(Model, AuditMixinNullable):
def __repr__(self) -> str:
return str(self.name)
@renders("crontab")
def crontab_humanized(self) -> str:
return get_description(self.crontab)
class ReportRecipients(
Model, AuditMixinNullable

View File

@ -104,6 +104,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
"created_by.last_name",
"created_on",
"crontab",
"crontab_humanized",
"id",
"last_eval_dttm",
"last_state",
@ -147,6 +148,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
"created_on",
"name",
"type",
"crontab_humanized",
]
search_columns = ["name", "active", "created_by", "type", "last_state"]
search_filters = {"name": [ReportScheduleAllTextFilter]}

View File

@ -187,6 +187,7 @@ class TestReportSchedulesApi(SupersetTestCase):
"created_by",
"created_on",
"crontab",
"crontab_humanized",
"id",
"last_eval_dttm",
"last_state",