diff --git a/requirements/base.txt b/requirements/base.txt index 8f0234d1f2..0aa3250aa0 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -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 diff --git a/setup.cfg b/setup.cfg index 38856a3504..3acb2257a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/setup.py b/setup.py index bc30115604..aebcb40997 100644 --- a/setup.py +++ b/setup.py @@ -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", diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index c0f0f138f1..dc230f1721 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -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", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index b4585c7721..17c582eb5b 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -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", diff --git a/superset-frontend/src/common/components/CronPicker.tsx b/superset-frontend/src/common/components/CronPicker.tsx new file mode 100644 index 0000000000..b5825ec56b --- /dev/null +++ b/superset-frontend/src/common/components/CronPicker.tsx @@ -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) => ( + +))` + .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; + } +`; diff --git a/superset-frontend/src/common/components/Select.tsx b/superset-frontend/src/common/components/Select.tsx index b3a49d483b..ca2262197e 100644 --- a/superset-frontend/src/common/components/Select.tsx +++ b/superset-frontend/src/common/components/Select.tsx @@ -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, diff --git a/superset-frontend/src/common/components/common.stories.tsx b/superset-frontend/src/common/components/common.stories.tsx index 5c73f87b3a..969d437a93 100644 --- a/superset-frontend/src/common/components/common.stories.tsx +++ b/superset-frontend/src/common/components/common.stories.tsx @@ -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 = () => ( ); +export function StyledCronPicker() { + const inputRef = useRef(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(); + + return ( +
+ { + setValue(event.target.value); + }} + onPressEnter={() => { + setValue(inputRef.current?.input.value || ''); + }} + /> + + + + + +

+ Error: {error ? error.description : 'undefined'} +

+
+ ); +} diff --git a/superset-frontend/src/common/components/index.tsx b/superset-frontend/src/common/components/index.tsx index 2bdcfb47e2..4b1fa15531 100644 --- a/superset-frontend/src/common/components/index.tsx +++ b/superset-frontend/src/common/components/index.tsx @@ -33,6 +33,7 @@ export { Button, Card, DatePicker, + Divider, Dropdown, Empty, Input, diff --git a/superset-frontend/src/views/CRUD/alert/AlertList.tsx b/superset-frontend/src/views/CRUD/alert/AlertList.tsx index 9b46377415..3ecc034cac 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertList.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertList.tsx @@ -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) => ( - // )), 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) => ( + + {crontab_humanized}, + + ), }, { 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: ( <> - {t(`${title}`)} + {title} ), buttonStyle: 'primary', @@ -245,8 +258,8 @@ function AlertList({ } const EmptyStateButton = ( - ); diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx index 1ebc5cd6cf..402e3f154c 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx @@ -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 = ({ const [currentAlert, setCurrentAlert] = useState(); const [isHidden, setIsHidden] = useState(true); const [contentType, setContentType] = useState('dashboard'); - const [scheduleFormat, setScheduleFormat] = useState( - 'dropdown-format', - ); - // Dropdown options const [sourceOptions, setSourceOptions] = useState([]); const [dashboardOptions, setDashboardOptions] = useState([]); @@ -806,12 +806,6 @@ const AlertReportModal: FunctionComponent = ({ 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 = ({ // Dropdown options const conditionOptions = CONDITIONS.map(condition => { return ( - {condition.label} + + {condition.label} + ); }); const retentionOptions = RETENTION_OPTIONS.map(option => { - return {option.label}; + return ( + + {option.label} + + ); }); return ( @@ -1102,7 +1102,7 @@ const AlertReportModal: FunctionComponent = ({ /> -
+
{t('Alert If...')} @@ -1153,32 +1153,10 @@ const AlertReportModal: FunctionComponent = ({

{t('Alert Condition Schedule')}

- -
- - - Every x Minutes (should be set of dropdown options) - -
-
- - CRON Schedule - -
- -
-
-
-
+ updateAlertState('crontab', newVal)} + />

{t('Schedule Settings')}

diff --git a/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx new file mode 100644 index 0000000000..3f7e5bce9e --- /dev/null +++ b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + const changeValue = '1,7 * * * *'; + const event = { + target: { value: changeValue }, + } as React.FocusEvent; + + const inputProps = wrapper.find(Input).props(); + if (inputProps.onBlur) { + inputProps.onBlur(event); + } + expect(onChangeMock).toHaveBeenLastCalledWith(changeValue); + }); +}); diff --git a/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx new file mode 100644 index 0000000000..f3e874faea --- /dev/null +++ b/superset-frontend/src/views/CRUD/alert/components/AlertReportCronScheduler.tsx @@ -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 = ({ + value = '* * * * *', + onChange, +}) => { + const theme = useTheme(); + const inputRef = useRef(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(); + + return ( + <> + setScheduleFormat(e.target.value)} + value={scheduleFormat} + > +
+ + +
+
+ + CRON Schedule + +
+ { + onChange(event.target.value); + }} + onPressEnter={() => { + onChange(inputRef.current?.input.value || ''); + }} + /> +
+
+
+
+ + ); +}; diff --git a/superset/models/reports.py b/superset/models/reports.py index 0cecaa8a76..0c2816b457 100644 --- a/superset/models/reports.py +++ b/superset/models/reports.py @@ -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 diff --git a/superset/reports/api.py b/superset/reports/api.py index 583001aa0c..5bdcccab87 100644 --- a/superset/reports/api.py +++ b/superset/reports/api.py @@ -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]} diff --git a/tests/reports/api_tests.py b/tests/reports/api_tests.py index b868ab4c8a..6f374e6cd8 100644 --- a/tests/reports/api_tests.py +++ b/tests/reports/api_tests.py @@ -187,6 +187,7 @@ class TestReportSchedulesApi(SupersetTestCase): "created_by", "created_on", "crontab", + "crontab_humanized", "id", "last_eval_dttm", "last_state",