feat(Alerts and Reports): Modal redesign (#26202)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: fisjac <jfisher9882@gmail.com>
Co-authored-by: Corbin <corbindbullard@gmail.com>
Co-authored-by: Lily Kuang <lily@preset.io>
Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Ross Mabbett 2024-02-19 10:28:10 -05:00 committed by GitHub
parent 1776405903
commit 601e62a2ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1832 additions and 1267 deletions

View File

@ -20,6 +20,7 @@ import React from 'react';
import { css } from '@superset-ui/core'; import { css } from '@superset-ui/core';
import { Global } from '@emotion/react'; import { Global } from '@emotion/react';
import { mix } from 'polished'; import { mix } from 'polished';
import 'react-js-cron/dist/styles.css';
export const GlobalStyles = () => ( export const GlobalStyles = () => (
<Global <Global

View File

@ -16,8 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import React, { Children, ReactElement } from 'react'; import React, { Children, ReactElement, ReactNode } from 'react';
import { kebabCase } from 'lodash';
import { mix } from 'polished'; import { mix } from 'polished';
import cx from 'classnames'; import cx from 'classnames';
import { AntdButton } from 'src/components'; import { AntdButton } from 'src/components';
@ -43,7 +42,7 @@ export type ButtonSize = 'default' | 'small' | 'xsmall';
export type ButtonProps = Omit<AntdButtonProps, 'css'> & export type ButtonProps = Omit<AntdButtonProps, 'css'> &
Pick<TooltipProps, 'placement'> & { Pick<TooltipProps, 'placement'> & {
tooltip?: string; tooltip?: ReactNode;
className?: string; className?: string;
buttonSize?: ButtonSize; buttonSize?: ButtonSize;
buttonStyle?: ButtonStyle; buttonStyle?: ButtonStyle;
@ -214,11 +213,7 @@ export default function Button(props: ButtonProps) {
if (tooltip) { if (tooltip) {
return ( return (
<Tooltip <Tooltip placement={placement} title={tooltip}>
placement={placement}
id={`${kebabCase(tooltip)}-tooltip`}
title={tooltip}
>
{/* wrap the button in a span so that the tooltip shows up {/* wrap the button in a span so that the tooltip shows up
when the button is disabled. */} when the button is disabled. */}
{disabled ? ( {disabled ? (

View File

@ -44,7 +44,7 @@ export const LOCALE: Locale = {
prefixMonths: t('in'), prefixMonths: t('in'),
prefixMonthDays: t('on'), prefixMonthDays: t('on'),
prefixWeekDays: t('on'), prefixWeekDays: t('on'),
prefixWeekDaysForMonthAndYearPeriod: t('and'), prefixWeekDaysForMonthAndYearPeriod: t('or'),
prefixHours: t('at'), prefixHours: t('at'),
prefixMinutes: t(':'), prefixMinutes: t(':'),
prefixMinutesForHourPeriod: t('at'), prefixMinutesForHourPeriod: t('at'),
@ -110,22 +110,99 @@ export const CronPicker = styled((props: CronProps) => (
<ReactCronPicker locale={LOCALE} {...props} /> <ReactCronPicker locale={LOCALE} {...props} />
</ConfigProvider> </ConfigProvider>
))` ))`
.react-js-cron-field { ${({ theme }) => `
margin-bottom: 0px;
} /* Boilerplate styling for ReactCronPicker imported explicitly in GlobalStyles.tsx */
.react-js-cron-select:not(.react-js-cron-custom-select) > div:first-of-type,
.react-js-cron-custom-select { /* When year period is selected */
border-radius: ${({ theme }) => theme.gridUnit}px;
background-color: ${({ theme }) => :has(.react-js-cron-months) {
theme.colors.grayscale.light4} !important; display: grid !important;
} grid-template-columns: repeat(2, 50%);
.react-js-cron-custom-select > div:first-of-type { column-gap: ${theme.gridUnit}px;
border-radius: ${({ theme }) => theme.gridUnit}px; row-gap: ${theme.gridUnit * 2}px;
} div:has(.react-js-cron-hours) {
.react-js-cron-custom-select .ant-select-selection-placeholder { grid-column: span 2;
flex: auto; display: flex;
} justify-content: space-between;
.react-js-cron-custom-select .ant-select-selection-overflow-item { .react-js-cron-field {
align-self: center; width: 50%;
} }
}
}
/* When month period is selected */
:not(:has(.react-js-cron-months)) {
display: grid;
grid-template-columns: repeat(2, 50%);
column-gap: ${theme.gridUnit}px;
row-gap: ${theme.gridUnit * 2}px;
.react-js-cron-period {
grid-column: span 2;
}
div:has(.react-js-cron-hours) {
grid-column: span 2;
display: flex;
justify-content: space-between;
.react-js-cron-field {
width: 50%;
}
}
}
/* When week period is selected */
:not(:has(.react-js-cron-month-days)) {
.react-js-cron-week-days {
grid-column: span 2;
}
}
/* For proper alignment of inputs and span elements */
:not(div:has(.react-js-cron-hours)) {
display: flex;
flex-wrap: nowrap;
}
div:has(.react-js-cron-hours) {
width: 100%;
}
.react-js-cron-minutes > span {
padding-left: ${theme.gridUnit}px;
}
/* Sizing of select container */
.react-js-cron-select.ant-select {
width: 100%;
.ant-select-selector {
flex-wrap: nowrap;
}
}
.react-js-cron-field {
width: 100%;
margin-bottom: 0px;
> span {
margin-left: 0px;
}
}
.react-js-cron-custom-select .ant-select-selection-placeholder {
flex: auto;
border-radius: ${theme.gridUnit}px;
}
.react-js-cron-custom-select .ant-select-selection-overflow-item {
align-self: center;
}
.react-js-cron-select > div:first-of-type,
.react-js-cron-custom-select {
border-radius: ${theme.gridUnit}px;
}
`}
`; `;

View File

@ -41,6 +41,7 @@ export interface ModalProps {
className?: string; className?: string;
children: ReactNode; children: ReactNode;
disablePrimaryButton?: boolean; disablePrimaryButton?: boolean;
primaryTooltipMessage?: ReactNode;
primaryButtonLoading?: boolean; primaryButtonLoading?: boolean;
onHide: () => void; onHide: () => void;
onHandledPrimaryAction?: () => void; onHandledPrimaryAction?: () => void;
@ -232,6 +233,7 @@ const defaultResizableConfig = (hideFooter: boolean | undefined) => ({
const CustomModal = ({ const CustomModal = ({
children, children,
disablePrimaryButton = false, disablePrimaryButton = false,
primaryTooltipMessage,
primaryButtonLoading = false, primaryButtonLoading = false,
onHide, onHide,
onHandledPrimaryAction, onHandledPrimaryAction,
@ -274,6 +276,7 @@ const CustomModal = ({
key="submit" key="submit"
buttonStyle={primaryButtonType} buttonStyle={primaryButtonType}
disabled={disablePrimaryButton} disabled={disablePrimaryButton}
tooltip={primaryTooltipMessage}
loading={primaryButtonLoading} loading={primaryButtonLoading}
onClick={onHandledPrimaryAction} onClick={onHandledPrimaryAction}
cta cta

View File

@ -1,367 +0,0 @@
/**
* 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 { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock';
import { act } from 'react-dom/test-utils';
import Modal from 'src/components/Modal';
import { Select, AsyncSelect } from 'src/components';
import { Switch } from 'src/components/Switch';
import { Radio } from 'src/components/Radio';
import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { styledMount as mount } from 'spec/helpers/theming';
import AlertReportModal from './AlertReportModal';
const mockData = {
active: true,
id: 1,
name: 'test report',
description: 'test report description',
chart: { id: 1, slice_name: 'test chart', viz_type: 'table' },
database: { id: 1, database_name: 'test database' },
sql: 'SELECT NaN',
};
const FETCH_REPORT_ENDPOINT = 'glob:*/api/v1/report/*';
const REPORT_PAYLOAD = { result: mockData };
fetchMock.get(FETCH_REPORT_ENDPOINT, REPORT_PAYLOAD);
const mockStore = configureStore([thunk]);
const store = mockStore({});
// Report mock is default for testing
const mockedProps = {
addDangerToast: () => {},
onAdd: jest.fn(() => []),
onHide: () => {},
show: true,
isReport: true,
};
// Related mocks
const ownersEndpoint = 'glob:*/api/v1/alert/related/owners?*';
const databaseEndpoint = 'glob:*/api/v1/alert/related/database?*';
const dashboardEndpoint = 'glob:*/api/v1/alert/related/dashboard?*';
const chartEndpoint = 'glob:*/api/v1/alert/related/chart?*';
fetchMock.get(ownersEndpoint, {
result: [],
});
fetchMock.get(databaseEndpoint, {
result: [],
});
fetchMock.get(dashboardEndpoint, {
result: [],
});
fetchMock.get(chartEndpoint, {
result: [{ text: 'table chart', value: 1 }],
});
async function mountAndWait(props = mockedProps) {
const mounted = mount(
<Provider store={store}>
<AlertReportModal show {...props} />
</Provider>,
{
context: { store },
},
);
await waitForComponentToPaint(mounted);
return mounted;
}
describe('AlertReportModal', () => {
let wrapper;
beforeAll(async () => {
wrapper = await mountAndWait();
});
it('renders', () => {
expect(wrapper.find(AlertReportModal)).toExist();
});
it('renders a Modal', () => {
expect(wrapper.find(Modal)).toExist();
});
it('render a empty modal', () => {
expect(wrapper.find('input[name="name"]').text()).toEqual('');
expect(wrapper.find('input[name="description"]').text()).toEqual('');
});
it('renders add header for report when no alert is included, and isReport is true', async () => {
const addWrapper = await mountAndWait();
expect(
addWrapper.find('[data-test="alert-report-modal-title"]').text(),
).toEqual('Add Report');
});
it('renders add header for alert when no alert is included, and isReport is false', async () => {
const props = {
...mockedProps,
isReport: false,
};
const addWrapper = await mountAndWait(props);
expect(
addWrapper.find('[data-test="alert-report-modal-title"]').text(),
).toEqual('Add Alert');
});
it('renders edit modal', async () => {
const props = {
...mockedProps,
alert: mockData,
};
const editWrapper = await mountAndWait(props);
expect(
editWrapper.find('[data-test="alert-report-modal-title"]').text(),
).toEqual('Edit Report');
expect(editWrapper.find('input[name="name"]').props().value).toEqual(
'test report',
);
expect(editWrapper.find('input[name="description"]').props().value).toEqual(
'test report description',
);
});
it('renders async select with value in alert edit modal', async () => {
const props = {
...mockedProps,
alert: mockData,
isReport: false,
};
const editWrapper = await mountAndWait(props);
expect(
editWrapper.find('[aria-label="Database"]').at(0).props().value,
).toEqual({
value: 1,
label: 'test database',
});
expect(
editWrapper.find('[aria-label="Chart"]').at(0).props().value,
).toEqual({
value: 1,
label: 'test chart',
});
});
// Fields
it('renders input element for name', () => {
expect(wrapper.find('input[name="name"]')).toExist();
});
it('renders four select elements when in report mode', () => {
expect(wrapper.find(Select)).toExist();
expect(wrapper.find(AsyncSelect)).toExist();
expect(wrapper.find(Select)).toHaveLength(2);
expect(wrapper.find(AsyncSelect)).toHaveLength(2);
});
it('renders Switch element', () => {
expect(wrapper.find(Switch)).toExist();
});
it('renders input element for description', () => {
expect(wrapper.find('input[name="description"]')).toExist();
});
it('renders input element for sql in alert mode only', async () => {
const props = {
...mockedProps,
isReport: false,
};
const addWrapper = await mountAndWait(props);
expect(wrapper.find(TextAreaControl)).toHaveLength(0);
expect(addWrapper.find(TextAreaControl)).toExist();
});
it('renders input element for sql with NaN', async () => {
const props = {
...mockedProps,
alert: mockData,
isReport: false,
};
const editWrapper = await mountAndWait(props);
const input = editWrapper.find(TextAreaControl);
expect(input).toExist();
expect(input.props().initialValue).toEqual('SELECT NaN');
});
it('renders four select element when in report mode', () => {
expect(wrapper.find(Select)).toExist();
expect(wrapper.find(AsyncSelect)).toExist();
expect(wrapper.find(Select)).toHaveLength(2);
expect(wrapper.find(AsyncSelect)).toHaveLength(2);
});
it('renders six select elements when in alert mode', async () => {
const props = {
...mockedProps,
isReport: false,
};
const addWrapper = await mountAndWait(props);
expect(addWrapper.find(Select)).toExist();
expect(addWrapper.find(AsyncSelect)).toExist();
expect(addWrapper.find(Select)).toHaveLength(3);
expect(addWrapper.find(AsyncSelect)).toHaveLength(3);
});
it('renders value input element when in alert mode', async () => {
const props = {
...mockedProps,
isReport: false,
};
const addWrapper = await mountAndWait(props);
expect(wrapper.find('input[name="threshold"]')).toHaveLength(0);
expect(addWrapper.find('input[name="threshold"]')).toExist();
});
it('renders two radio buttons', () => {
expect(wrapper.find(Radio)).toExist();
expect(wrapper.find(Radio)).toHaveLength(2);
});
it('renders text option for text-based charts', async () => {
const props = {
...mockedProps,
alert: mockData,
};
const textWrapper = await mountAndWait(props);
const chartOption = textWrapper.find('input[value="chart"]');
act(() => {
chartOption.props().onChange({ target: { value: 'chart' } });
});
await waitForComponentToPaint(textWrapper);
expect(textWrapper.find('input[value="TEXT"]')).toExist();
});
it('renders input element for working timeout', () => {
expect(wrapper.find('input[name="working_timeout"]')).toExist();
});
it('renders input element for grace period for alert only', async () => {
const props = {
...mockedProps,
isReport: false,
};
const addWrapper = await mountAndWait(props);
expect(addWrapper.find('input[name="grace_period"]')).toExist();
expect(wrapper.find('input[name="grace_period"]')).toHaveLength(0);
});
it('only allows grace period values > 1', async () => {
const props = {
...mockedProps,
isReport: false,
};
const addWrapper = await mountAndWait(props);
const input = addWrapper.find('input[name="grace_period"]');
input.simulate('change', { target: { name: 'grace_period', value: 7 } });
expect(input.instance().value).toEqual('7');
input.simulate('change', { target: { name: 'grace_period', value: 0 } });
expect(input.instance().value).toEqual('');
input.simulate('change', { target: { name: 'grace_period', value: -1 } });
expect(input.instance().value).toEqual('1');
});
it('only allows working timeout values > 1', () => {
const input = wrapper.find('input[name="working_timeout"]');
input.simulate('change', { target: { name: 'working_timeout', value: 7 } });
expect(input.instance().value).toEqual('7');
input.simulate('change', { target: { name: 'working_timeout', value: 0 } });
expect(input.instance().value).toEqual('');
input.simulate('change', {
target: { name: 'working_timeout', value: -1 },
});
expect(input.instance().value).toEqual('1');
});
it('allows to add notification method', async () => {
const button = wrapper.find('[data-test="notification-add"]');
act(() => {
button.props().onClick();
});
await waitForComponentToPaint(wrapper);
// use default config: only show Email as notification option.
expect(
wrapper.find('[data-test="notification-add"]').props().status,
).toEqual('hidden');
act(() => {
wrapper
.find('[data-test="select-delivery-method"]')
.last()
.props()
.onSelect('Email');
});
await waitForComponentToPaint(wrapper);
expect(wrapper.find('textarea[name="recipients"]')).toHaveLength(1);
});
it('renders bypass cache checkbox', async () => {
const bypass = wrapper.find('[data-test="bypass-cache"]');
expect(bypass).toExist();
});
it('renders no bypass cache checkbox when alert', async () => {
const props = {
...mockedProps,
alert: mockData,
isReport: false,
};
const alertWrapper = await mountAndWait(props);
const bypass = alertWrapper.find('[data-test="bypass-cache"]');
expect(bypass).not.toExist();
});
});

View File

@ -17,68 +17,606 @@
* under the License. * under the License.
*/ */
import React from 'react'; import React from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import AlertReportModal from './AlertReportModal'; import fetchMock from 'fetch-mock';
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
import { buildErrorTooltipMessage } from './buildErrorTooltipMessage';
import AlertReportModal, { AlertReportModalProps } from './AlertReportModal';
import { AlertObject } from './types';
jest.mock('src/components/AsyncAceEditor', () => ({ jest.mock('@superset-ui/core', () => ({
...jest.requireActual('src/components/AsyncAceEditor'), ...jest.requireActual('@superset-ui/core'),
TextAreaEditor: () => <div data-test="react-ace" />, isFeatureEnabled: () => true,
})); }));
const onHide = jest.fn(); jest.mock('src/features/databases/state.ts', () => ({
useCommonConf: () => ({
ALERT_REPORTS_NOTIFICATION_METHODS: ['Email', 'Slack'],
}),
}));
test('allows change to None in log retention', async () => { const generateMockPayload = (dashboard = true) => {
render(<AlertReportModal show onHide={onHide} />, { useRedux: true }); const mockPayload = {
// open the log retention select active: false,
userEvent.click(screen.getByText('90 days')); context_markdown: 'string',
// change it to 30 days creation_method: 'alerts_reports',
userEvent.click(await screen.findByText('30 days')); crontab: '0 0 * * *',
// open again custom_width: null,
userEvent.click(screen.getAllByText('30 days')[0]); database: {
// change it to None database_name: 'examples',
userEvent.click(await screen.findByText('None')); id: 1,
// get the selected item },
const selectedItem = await waitFor(() => description: 'Some description',
screen extra: {},
.getAllByLabelText('Log retention')[0] force_screenshot: true,
.querySelector('.ant-select-selection-item'), grace_period: 14400,
id: 1,
last_eval_dttm: null,
last_state: 'Not triggered',
last_value: null,
last_value_row_json: null,
log_retention: 90,
name: 'Test Alert',
owners: [
{
first_name: 'Superset',
id: 1,
last_name: 'Admin',
},
],
recipients: [
{
id: 1,
recipient_config_json: '{"target": "test@user.com"}',
type: 'Email',
},
],
report_format: 'PNG',
sql: 'Select * From DB',
timezone: 'America/Rainy_River',
type: 'Alert',
validator_config_json: '{"threshold": 10.0, "op": "<"}',
validator_type: 'operator',
working_timeout: 3600,
};
if (dashboard) {
return {
...mockPayload,
dashboard: { id: 1, dashboard_title: 'Test Dashboard' },
};
}
return {
...mockPayload,
chart: {
id: 1,
slice_name: 'Test Chart',
viz_type: 'table',
},
};
};
// mocking resource endpoints
const FETCH_DASHBOARD_ENDPOINT = 'glob:*/api/v1/report/1';
const FETCH_CHART_ENDPOINT = 'glob:*/api/v1/report/2';
fetchMock.get(FETCH_DASHBOARD_ENDPOINT, { result: generateMockPayload(true) });
fetchMock.get(FETCH_CHART_ENDPOINT, { result: generateMockPayload(false) });
// Related mocks
const ownersEndpoint = 'glob:*/api/v1/alert/related/owners?*';
const databaseEndpoint = 'glob:*/api/v1/alert/related/database?*';
const dashboardEndpoint = 'glob:*/api/v1/alert/related/dashboard?*';
const chartEndpoint = 'glob:*/api/v1/alert/related/chart?*';
fetchMock.get(ownersEndpoint, { result: [] });
fetchMock.get(databaseEndpoint, { result: [] });
fetchMock.get(dashboardEndpoint, { result: [] });
fetchMock.get(chartEndpoint, { result: [{ text: 'table chart', value: 1 }] });
// Create a valid alert with all required fields entered for validation check
// @ts-ignore will add id in factory function
const validAlert: AlertObject = {
active: false,
changed_on_delta_humanized: 'now',
created_on: '2023-12-12T22:33:25.927764',
creation_method: 'alerts_reports',
crontab: '0 0 * * *',
dashboard_id: 0,
chart_id: 0,
force_screenshot: false,
last_state: 'Not triggered',
name: 'Test Alert',
owners: [
{
first_name: 'Superset',
id: 1,
last_name: 'Admin',
},
],
recipients: [
{
type: 'Email',
recipient_config_json: { target: 'test@user.com' },
},
],
timezone: 'America/Rainy_River',
type: 'Alert',
};
jest.mock('./buildErrorTooltipMessage', () => ({
buildErrorTooltipMessage: jest.fn(),
}));
const generateMockedProps = (
isReport = false,
useValidAlert = false,
useDashboard = true,
): AlertReportModalProps => {
let alert;
// switching ids for endpoint when testing dashboard vs chart edits
if (useDashboard) {
alert = { ...validAlert, id: 1 };
} else {
alert = { ...validAlert, id: 2 };
}
return {
addDangerToast: () => {},
addSuccessToast: () => {},
onAdd: jest.fn(() => []),
onHide: jest.fn(),
alert: useValidAlert ? alert : null,
show: true,
isReport,
};
};
// combobox selector for mocking user input
const comboboxSelect = async (
element: HTMLElement,
value: string,
newElementQuery: Function,
) => {
expect(element).toBeInTheDocument();
userEvent.type(element, `${value}{enter}`);
await waitFor(() => {
const element = newElementQuery();
expect(element).toBeInTheDocument();
});
};
// --------------- TEST SECTION ------------------
test('properly renders add alert text', () => {
const addAlertProps = generateMockedProps();
render(<AlertReportModal {...addAlertProps} />, { useRedux: true });
const addAlertHeading = screen.getByRole('heading', { name: /add alert/i });
expect(addAlertHeading).toBeInTheDocument();
const addButton = screen.getByRole('button', { name: /add/i });
expect(addButton).toBeInTheDocument();
});
test('properly renders edit alert text', async () => {
render(<AlertReportModal {...generateMockedProps(false, true)} />, {
useRedux: true,
});
const editAlertHeading = screen.getByRole('heading', {
name: /edit alert/i,
});
expect(editAlertHeading).toBeInTheDocument();
const saveButton = screen.getByRole('button', { name: /save/i });
expect(saveButton).toBeInTheDocument();
});
test('properly renders add report text', () => {
render(<AlertReportModal {...generateMockedProps(true)} />, {
useRedux: true,
});
const addReportHeading = screen.getByRole('heading', {
name: /add report/i,
});
expect(addReportHeading).toBeInTheDocument();
const addButton = screen.getByRole('button', { name: /add/i });
expect(addButton).toBeInTheDocument();
});
test('properly renders edit report text', async () => {
render(<AlertReportModal {...generateMockedProps(true, true)} />, {
useRedux: true,
});
const editReportHeading = screen.getByRole('heading', {
name: /edit report/i,
});
expect(editReportHeading).toBeInTheDocument();
const saveButton = screen.getByRole('button', { name: /save/i });
expect(saveButton).toBeInTheDocument();
});
test('renders 4 sections for reports', () => {
render(<AlertReportModal {...generateMockedProps(true)} />, {
useRedux: true,
});
const sections = screen.getAllByRole('tab');
expect(sections.length).toBe(4);
});
test('renders 5 sections for alerts', () => {
render(<AlertReportModal {...generateMockedProps(false)} />, {
useRedux: true,
});
const sections = screen.getAllByRole('tab');
expect(sections.length).toBe(5);
});
// Validation
test('renders 5 checkmarks for a valid alert', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
useRedux: true,
});
const checkmarks = await screen.findAllByRole('img', {
name: /check-circle/i,
});
expect(checkmarks.length).toEqual(5);
});
test('renders single checkmarks when creating a new alert', async () => {
render(<AlertReportModal {...generateMockedProps(false, false, false)} />, {
useRedux: true,
});
const checkmarks = await screen.findAllByRole('img', {
name: /check-circle/i,
});
expect(checkmarks.length).toEqual(1);
});
test('disables save when validation fails', () => {
render(<AlertReportModal {...generateMockedProps(false, false, false)} />, {
useRedux: true,
});
expect(screen.getByRole('button', { name: /add/i })).toBeDisabled();
});
test('calls build tooltip', async () => {
render(<AlertReportModal {...generateMockedProps(false, false, false)} />, {
useRedux: true,
});
expect(buildErrorTooltipMessage).toHaveBeenCalled();
expect(buildErrorTooltipMessage).toHaveBeenLastCalledWith({
alertConditionSection: {
errors: ['database', 'sql', 'alert condition'],
name: 'Alert condition',
hasErrors: true,
},
contentSection: {
errors: ['content type'],
name: 'Alert contents',
hasErrors: true,
},
generalSection: {
errors: ['name'],
name: 'General information',
hasErrors: true,
},
notificationSection: {
errors: ['recipients'],
name: 'Notification method',
hasErrors: true,
},
scheduleSection: { errors: [], name: 'Schedule', hasErrors: false },
});
});
// General Section
test('opens General Section on render', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
useRedux: true,
});
const general_header = within(
screen.getByRole('tab', { expanded: true }),
).queryByText(/general information/i);
expect(general_header).toBeInTheDocument();
});
test('renders all fields in General Section', () => {
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
useRedux: true,
});
const name = screen.getByPlaceholderText(/enter alert name/i);
const owners = screen.getByTestId('owners-select');
const description = screen.getByPlaceholderText(
/include description to be sent with alert/i,
); );
// check if None is selected const activeSwitch = screen.getByRole('switch');
expect(selectedItem).toHaveTextContent('None');
expect(name).toBeInTheDocument();
expect(owners).toBeInTheDocument();
expect(description).toBeInTheDocument();
expect(activeSwitch).toBeInTheDocument();
}); });
test('renders the appropriate dropdown in Message Content section', async () => { // Alert Condition Section
render(<AlertReportModal show onHide={onHide} />, { useRedux: true }); /* A Note on textbox total numbers:
Because the General Info panel is open by default, the Name and Description textboxes register as being in the document on all tests, thus the total number of textboxes in each subsequent panel's tests will always be n+2. This is most significant in the Alert Condition panel tests because the nature of the SQL field as a TextAreaContol component may only be queried by role */
const chartRadio = screen.getByRole('radio', { name: /chart/i }); test('opens Alert Condition Section on click', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
// Dashboard is initially checked by default useRedux: true,
expect( });
await screen.findByRole('radio', { userEvent.click(screen.getByTestId('alert-condition-panel'));
name: /dashboard/i, const alertConditionHeader = within(
}), screen.getByRole('tab', { expanded: true }),
).toBeChecked(); ).queryByText(/alert condition/i);
expect(chartRadio).not.toBeChecked(); expect(alertConditionHeader).toBeInTheDocument();
// Only the dashboard dropdown should show });
expect(screen.getByRole('combobox', { name: /dashboard/i })).toBeVisible(); test('renders all Alert Condition fields', async () => {
expect( render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
screen.queryByRole('combobox', { name: /chart/i }), useRedux: true,
).not.toBeInTheDocument(); });
userEvent.click(screen.getByTestId('alert-condition-panel'));
// Click the chart radio option const database = screen.getByRole('combobox', { name: /database/i });
userEvent.click(chartRadio); const sql = screen.getAllByRole('textbox')[2];
const condition = screen.getByRole('combobox', { name: /condition/i });
await waitFor(() => expect(chartRadio).toBeChecked()); const threshold = screen.getByRole('spinbutton');
expect(database).toBeInTheDocument();
expect( expect(sql).toBeInTheDocument();
await screen.findByRole('radio', { expect(condition).toBeInTheDocument();
name: /dashboard/i, expect(threshold).toBeInTheDocument();
}), });
).not.toBeChecked(); test('disables condition threshold if not null condition is selected', async () => {
// Now that chart is checked, only the chart dropdown should show render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
expect(screen.getByRole('combobox', { name: /chart/i })).toBeVisible(); useRedux: true,
expect( });
screen.queryByRole('combobox', { name: /dashboard/i }), userEvent.click(screen.getByTestId('alert-condition-panel'));
).not.toBeInTheDocument(); await screen.findByText(/smaller than/i);
const condition = screen.getByRole('combobox', { name: /condition/i });
await comboboxSelect(
condition,
'not null',
() => screen.getAllByText(/not null/i)[0],
);
expect(screen.getByRole('spinbutton')).toBeDisabled();
});
// Content Section
test('opens Contents Section on click', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('contents-panel'));
const contentsHeader = within(
screen.getByRole('tab', { expanded: true }),
).queryByText(/contents/i);
expect(contentsHeader).toBeInTheDocument();
});
test('renders screenshot options when dashboard is selected', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, true)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('contents-panel'));
await screen.findByText(/test dashboard/i);
expect(
screen.getByRole('combobox', { name: /select content type/i }),
).toBeInTheDocument();
expect(
screen.getByRole('combobox', { name: /dashboard/i }),
).toBeInTheDocument();
expect(screen.getByRole('spinbutton')).toBeInTheDocument();
expect(
screen.getByRole('checkbox', {
name: /ignore cache when generating report/i,
}),
).toBeInTheDocument();
});
test('changes to content options when chart is selected', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, true)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('contents-panel'));
await screen.findByText(/test dashboard/i);
const contentTypeSelector = screen.getByRole('combobox', {
name: /select content type/i,
});
await comboboxSelect(contentTypeSelector, 'Chart', () =>
screen.getByRole('combobox', { name: /chart/i }),
);
expect(
screen.getByRole('combobox', {
name: /select format/i,
}),
).toBeInTheDocument();
});
test('removes ignore cache checkbox when chart is selected', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, true)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('contents-panel'));
await screen.findByText(/test dashboard/i);
expect(
screen.getByRole('checkbox', {
name: /ignore cache when generating report/i,
}),
).toBeInTheDocument();
const contentTypeSelector = screen.getByRole('combobox', {
name: /select content type/i,
});
await comboboxSelect(
contentTypeSelector,
'Chart',
() => screen.getAllByText(/select chart/i)[0],
);
expect(
screen.queryByRole('checkbox', {
name: /ignore cache when generating report/i,
}),
).toBe(null);
});
test('does not show screenshot width when csv is selected', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('contents-panel'));
await screen.findByText(/test chart/i);
const contentTypeSelector = screen.getByRole('combobox', {
name: /select content type/i,
});
await comboboxSelect(contentTypeSelector, 'Chart', () =>
screen.getByText(/select chart/i),
);
const reportFormatSelector = screen.getByRole('combobox', {
name: /select format/i,
});
await comboboxSelect(
reportFormatSelector,
'CSV',
() => screen.getAllByText(/Send as CSV/i)[0],
);
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
});
// Schedule Section
test('opens Schedule Section on click', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('schedule-panel'));
const scheduleHeader = within(
screen.getByRole('tab', { expanded: true }),
).queryAllByText(/schedule/i)[0];
expect(scheduleHeader).toBeInTheDocument();
});
test('renders default Schedule fields', async () => {
render(<AlertReportModal {...generateMockedProps(false, false, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('schedule-panel'));
const scheduleType = screen.getByRole('combobox', {
name: /schedule type/i,
});
const timezone = screen.getByRole('combobox', {
name: /timezone selector/i,
});
const logRetention = screen.getByRole('combobox', {
name: /log retention/i,
});
const gracePeriod = screen.getByPlaceholderText(/time in seconds/i);
expect(scheduleType).toBeInTheDocument();
expect(timezone).toBeInTheDocument();
expect(logRetention).toBeInTheDocument();
expect(gracePeriod).toBeInTheDocument();
});
test('renders working timout as report', async () => {
render(<AlertReportModal {...generateMockedProps(true, false, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('schedule-panel'));
expect(screen.getByText(/working timeout/i)).toBeInTheDocument();
});
test('renders grace period as alert', async () => {
render(<AlertReportModal {...generateMockedProps(false, false, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('schedule-panel'));
expect(screen.getByText(/grace period/i)).toBeInTheDocument();
});
test('shows CRON Expression when CRON is selected', async () => {
render(<AlertReportModal {...generateMockedProps(true, false, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('schedule-panel'));
await comboboxSelect(
screen.getByRole('combobox', { name: /schedule type/i }),
'cron schedule',
() => screen.getByPlaceholderText(/cron expression/i),
);
expect(screen.getByPlaceholderText(/cron expression/i)).toBeInTheDocument();
});
test('defaults to day when CRON is not selected', async () => {
render(<AlertReportModal {...generateMockedProps(true, false, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('schedule-panel'));
const days = screen.getAllByTitle(/day/i, { exact: true });
expect(days.length).toBe(2);
});
// Notification Method Section
test('opens Notification Method Section on click', async () => {
render(<AlertReportModal {...generateMockedProps(false, false, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('notification-method-panel'));
const notificationMethodHeader = within(
screen.getByRole('tab', { expanded: true }),
).queryAllByText(/notification method/i)[0];
expect(notificationMethodHeader).toBeInTheDocument();
});
test('renders all notification fields', async () => {
render(<AlertReportModal {...generateMockedProps(false, false, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('notification-method-panel'));
const notificationMethod = screen.getByRole('combobox', {
name: /delivery method/i,
});
const recipients = screen.getByTestId('recipients');
const addNotificationMethod = screen.getByText(
/add another notification method/i,
);
expect(notificationMethod).toBeInTheDocument();
expect(recipients).toBeInTheDocument();
expect(addNotificationMethod).toBeInTheDocument();
});
test('adds another notification method section after clicking add notification method', async () => {
render(<AlertReportModal {...generateMockedProps(false, false, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('notification-method-panel'));
const addNotificationMethod = screen.getByText(
/add another notification method/i,
);
userEvent.click(addNotificationMethod);
expect(
screen.getAllByRole('combobox', {
name: /delivery method/i,
}).length,
).toBe(2);
await comboboxSelect(
screen.getAllByRole('combobox', {
name: /delivery method/i,
})[1],
'Slack',
() => screen.getAllByRole('textbox')[1],
);
expect(screen.getAllByTestId('recipients').length).toBe(2);
});
test('removes notification method on clicking trash can', async () => {
render(<AlertReportModal {...generateMockedProps(false, false, false)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('notification-method-panel'));
const addNotificationMethod = screen.getByText(
/add another notification method/i,
);
userEvent.click(addNotificationMethod);
await comboboxSelect(
screen.getAllByRole('combobox', {
name: /delivery method/i,
})[1],
'Email',
() => screen.getAllByRole('textbox')[1],
);
const images = screen.getAllByRole('img');
const trash = images[images.length - 1];
userEvent.click(trash);
expect(
screen.getAllByRole('combobox', { name: /delivery method/i }).length,
).toBe(1);
}); });

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,70 @@
/**
* 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 { render, screen } from 'spec/helpers/testing-library';
import { buildErrorTooltipMessage } from './buildErrorTooltipMessage';
import { SectionValidationObject } from './types';
const noErrors: SectionValidationObject = {
hasErrors: false,
errors: [],
name: 'No Errors',
};
const singleError: SectionValidationObject = {
hasErrors: true,
errors: ['first error'],
name: 'Single Error',
};
const threeErrors: SectionValidationObject = {
hasErrors: true,
errors: ['first error', 'second error', 'third error'],
name: 'Triple Error',
};
const validation = { noErrors, singleError, threeErrors };
test('builds with proper heading', () => {
render(buildErrorTooltipMessage(validation));
const heading = screen.getByText(
/not all required fields are complete\. please provide the following:/i,
);
expect(heading).toBeInTheDocument();
});
test('only builds sections that have errors', async () => {
render(buildErrorTooltipMessage(validation));
const noErrors = screen.queryByText(/no errors: /i);
const singleError = screen.getByText(/single error:/i);
const tripleError = screen.getByText(/triple error:/i);
expect(noErrors).not.toBeInTheDocument();
expect(singleError).toBeInTheDocument();
expect(tripleError).toBeInTheDocument();
});
test('properly concatenates errors', async () => {
render(buildErrorTooltipMessage(validation));
const singleError = screen.getByText(/single error: first error/i);
const tripleError = screen.getByText(
/triple error: first error, second error, third error/i,
);
expect(singleError).toBeInTheDocument();
expect(tripleError).toBeInTheDocument();
});

View File

@ -0,0 +1,49 @@
/**
* 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 } from '@superset-ui/core';
import { ValidationObject } from './types';
import { TRANSLATIONS } from './AlertReportModal';
const StyledList = styled.ul`
margin-left: ${({ theme }) => theme.gridUnit * 2}px;
padding-inline-start: ${({ theme }) => theme.gridUnit * 3}px;
`;
export const buildErrorTooltipMessage = (
validationStatus: ValidationObject,
) => {
const sectionErrors: string[] = [];
Object.values(validationStatus).forEach(section => {
if (section.hasErrors) {
const sectionTitle = `${section.name}: `;
sectionErrors.push(sectionTitle + section.errors.join(', '));
}
});
return (
<div>
{TRANSLATIONS.ERROR_TOOLTIP_MESSAGE}
<StyledList>
{sectionErrors.map(err => (
<li key={err}>{err}</li>
))}
</StyledList>
</div>
);
};

View File

@ -1,153 +0,0 @@
/**
* 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 { render, screen, waitFor, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { act } from 'react-dom/test-utils';
import {
AlertReportCronScheduler,
AlertReportCronSchedulerProps,
} from './AlertReportCronScheduler';
const createProps = (props: Partial<AlertReportCronSchedulerProps> = {}) => ({
onChange: jest.fn(),
value: '* * * * *',
...props,
});
test('should render', () => {
const props = createProps();
render(<AlertReportCronScheduler {...props} />);
// Text found in the first radio option
expect(screen.getByText('Every')).toBeInTheDocument();
// Text found in the second radio option
expect(screen.getByText('CRON Schedule')).toBeInTheDocument();
});
test('only one radio option should be enabled at a time', () => {
const props = createProps();
const { container } = render(<AlertReportCronScheduler {...props} />);
expect(screen.getByTestId('picker')).toBeChecked();
expect(screen.getByTestId('input')).not.toBeChecked();
const pickerContainer = container.querySelector(
'.react-js-cron-select',
) as HTMLElement;
const inputContainer = screen.getByTestId('input-content');
expect(within(pickerContainer).getAllByRole('combobox')[0]).toBeEnabled();
expect(inputContainer.querySelector('input[name="crontab"]')).toBeDisabled();
userEvent.click(screen.getByTestId('input'));
expect(within(pickerContainer).getAllByRole('combobox')[0]).toBeDisabled();
expect(inputContainer.querySelector('input[name="crontab"]')).toBeEnabled();
userEvent.click(screen.getByTestId('picker'));
expect(within(pickerContainer).getAllByRole('combobox')[0]).toBeEnabled();
expect(inputContainer.querySelector('input[name="crontab"]')).toBeDisabled();
});
test('picker mode updates correctly', async () => {
const onChangeCallback = jest.fn();
const props = createProps({
onChange: onChangeCallback,
});
const { container } = render(<AlertReportCronScheduler {...props} />);
expect(screen.getByTestId('picker')).toBeChecked();
const pickerContainer = container.querySelector(
'.react-js-cron-select',
) as HTMLElement;
const firstSelect = within(pickerContainer).getAllByRole('combobox')[0];
act(() => {
userEvent.click(firstSelect);
});
expect(await within(pickerContainer).findByText('day')).toBeInTheDocument();
act(() => {
userEvent.click(within(pickerContainer).getByText('day'));
});
expect(onChangeCallback).toHaveBeenLastCalledWith('* * * * *');
const secondSelect = container.querySelector(
'.react-js-cron-hours .ant-select-selector',
) as HTMLElement;
await waitFor(() => {
expect(secondSelect).toBeInTheDocument();
});
act(() => {
userEvent.click(secondSelect);
});
expect(await screen.findByText('9')).toBeInTheDocument();
act(() => {
userEvent.click(screen.getByText('9'));
});
await waitFor(() => {
expect(onChangeCallback).toHaveBeenLastCalledWith('* 9 * * *');
});
});
test('input mode updates correctly', async () => {
const onChangeCallback = jest.fn();
const props = createProps({
onChange: onChangeCallback,
});
render(<AlertReportCronScheduler {...props} />);
const inputContainer = screen.getByTestId('input-content');
userEvent.click(screen.getByTestId('input'));
const input = inputContainer.querySelector(
'input[name="crontab"]',
) as HTMLElement;
await waitFor(() => {
expect(input).toBeEnabled();
});
userEvent.clear(input);
expect(input).toHaveValue('');
const value = '* 10 2 * *';
await act(async () => {
await userEvent.type(input, value, { delay: 1 });
});
await waitFor(() => {
expect(input).toHaveValue(value);
});
act(() => {
userEvent.click(inputContainer);
});
expect(onChangeCallback).toHaveBeenLastCalledWith(value);
});

View File

@ -19,9 +19,8 @@
import React, { useState, useCallback, useRef, FocusEvent } from 'react'; import React, { useState, useCallback, useRef, FocusEvent } from 'react';
import { t, useTheme } from '@superset-ui/core'; import { t, useTheme } from '@superset-ui/core';
import { AntdInput, RadioChangeEvent } from 'src/components'; import { AntdInput, Select } from 'src/components';
import { Input } from 'src/components/Input'; import { Input } from 'src/components/Input';
import { Radio } from 'src/components/Radio';
import { CronPicker, CronError } from 'src/components/CronPicker'; import { CronPicker, CronError } from 'src/components/CronPicker';
import { StyledInputContainer } from '../AlertReportModal'; import { StyledInputContainer } from '../AlertReportModal';
@ -30,18 +29,29 @@ export interface AlertReportCronSchedulerProps {
onChange: (change: string) => any; onChange: (change: string) => any;
} }
enum ScheduleType {
Picker = 'picker',
Input = 'input',
}
const SCHEDULE_TYPE_OPTIONS = [
{
label: t('Recurring (every)'),
value: ScheduleType.Picker,
},
{
label: t('CRON Schedule'),
value: ScheduleType.Input,
},
];
export const AlertReportCronScheduler: React.FC< export const AlertReportCronScheduler: React.FC<
AlertReportCronSchedulerProps AlertReportCronSchedulerProps
> = ({ value, onChange }) => { > = ({ value, onChange }) => {
const theme = useTheme(); const theme = useTheme();
const inputRef = useRef<AntdInput>(null); const inputRef = useRef<AntdInput>(null);
const [scheduleFormat, setScheduleFormat] = useState<'picker' | 'input'>( const [scheduleFormat, setScheduleFormat] = useState<ScheduleType>(
'picker', ScheduleType.Picker,
);
const handleRadioButtonChange = useCallback(
(e: RadioChangeEvent) => setScheduleFormat(e.target.value),
[],
); );
const customSetValue = useCallback( const customSetValue = useCallback(
@ -67,40 +77,52 @@ export const AlertReportCronScheduler: React.FC<
return ( return (
<> <>
<Radio.Group onChange={handleRadioButtonChange} value={scheduleFormat}> <StyledInputContainer>
<div className="inline-container add-margin"> <div className="control-label">
<Radio data-test="picker" value="picker" /> {t('Schedule type')}
<span className="required">*</span>
</div>
<div className="input-container">
<Select
ariaLabel={t('Schedule type')}
placeholder={t('Schedule type')}
onChange={(e: ScheduleType) => {
setScheduleFormat(e);
}}
value={scheduleFormat}
options={SCHEDULE_TYPE_OPTIONS}
/>
</div>
</StyledInputContainer>
<StyledInputContainer data-test="input-content" className="styled-input">
<div className="control-label">
{t('Schedule')}
<span className="required">*</span>
</div>
{scheduleFormat === ScheduleType.Input && (
<Input
type="text"
name="crontab"
ref={inputRef}
style={error ? { borderColor: theme.colors.error.base } : {}}
placeholder={t('CRON expression')}
value={value}
onBlur={handleBlur}
onChange={e => customSetValue(e.target.value)}
onPressEnter={handlePressEnter}
/>
)}
{scheduleFormat === ScheduleType.Picker && (
<CronPicker <CronPicker
clearButton={false} clearButton={false}
value={value} value={value}
setValue={customSetValue} setValue={customSetValue}
disabled={scheduleFormat !== 'picker'} displayError={scheduleFormat === ScheduleType.Picker}
displayError={scheduleFormat === 'picker'}
onError={onError} onError={onError}
/> />
</div> )}
<div className="inline-container add-margin"> </StyledInputContainer>
<Radio data-test="input" value="input" />
<span className="input-label">{t('CRON Schedule')}</span>
<StyledInputContainer
data-test="input-content"
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={handleBlur}
onPressEnter={handlePressEnter}
/>
</div>
</StyledInputContainer>
</div>
</Radio.Group>
</> </>
); );
}; };

View File

@ -35,10 +35,6 @@ const StyledNotificationMethod = styled.div`
.inline-container { .inline-container {
margin-bottom: 10px; margin-bottom: 10px;
.input-container {
margin-left: 10px;
}
> div { > div {
margin: 0; margin: 0;
} }
@ -119,6 +115,7 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
<StyledNotificationMethod> <StyledNotificationMethod>
<div className="inline-container"> <div className="inline-container">
<StyledInputContainer> <StyledInputContainer>
<div className="control-label">{t('Notification Method')}</div>
<div className="input-container"> <div className="input-container">
<Select <Select
ariaLabel={t('Delivery method')} ariaLabel={t('Delivery method')}
@ -133,25 +130,29 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
)} )}
value={method} value={method}
/> />
{method !== undefined && index !== 0 && !!onRemove ? (
<span
role="button"
tabIndex={0}
className="delete-button"
onClick={() => onRemove(index)}
>
<Icons.Trash iconColor={theme.colors.grayscale.base} />
</span>
) : null}
</div> </div>
</StyledInputContainer> </StyledInputContainer>
{method !== undefined && !!onRemove ? (
<span
role="button"
tabIndex={0}
className="delete-button"
onClick={() => onRemove(index)}
>
<Icons.Trash iconColor={theme.colors.grayscale.base} />
</span>
) : null}
</div> </div>
{method !== undefined ? ( {method !== undefined ? (
<StyledInputContainer> <StyledInputContainer>
<div className="control-label">{t(method)}</div> <div className="control-label">
{t('%s recipients', method)}
<span className="required">*</span>
</div>
<div className="input-container"> <div className="input-container">
<textarea <textarea
name="recipients" name="recipients"
data-test="recipients"
value={recipientValue} value={recipientValue}
onChange={onRecipientsChange} onChange={onRecipientsChange}
/> />

View File

@ -0,0 +1,52 @@
/**
* 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, ChangeEvent } from 'react';
interface NumberInputProps {
timeUnit: string;
min: number;
name: string;
value: string | number;
placeholder: string;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
}
export default function NumberInput({
timeUnit,
min,
name,
value,
placeholder,
onChange,
}: NumberInputProps) {
const [isFocused, setIsFocused] = useState<boolean>(false);
return (
<input
type="text"
min={min}
name={name}
value={value ? `${value}${!isFocused ? ` ${timeUnit}` : ''}` : ''}
placeholder={placeholder}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={onChange}
/>
);
}

View File

@ -0,0 +1,75 @@
/**
* 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 { css, SupersetTheme } from '@superset-ui/core';
import { Collapse as AntdCollapse } from 'antd';
import { CollapsePanelProps } from 'antd/lib/collapse';
const anticonHeight = 12;
const antdPanelStyles = (theme: SupersetTheme) => css`
.ant-collapse-header {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 0px ${theme.gridUnit * 4}px;
.anticon.anticon-right.ant-collapse-arrow {
padding: 0;
top: calc(50% - ${anticonHeight / 2}px);
}
.collapse-panel-title {
font-size: ${theme.gridUnit * 4}px;
font-weight: ${theme.typography.weights.bold};
line-height: 130%;
}
.collapse-panel-subtitle {
color: ${theme.colors.grayscale.base};
font-size: ${theme.typography.sizes.s}px;
font-weight: ${theme.typography.weights.normal};
line-height: 150%;
margin-bottom: 0;
padding-top: ${theme.gridUnit}px;
}
.collapse-panel-asterisk {
color: var(--semantic-error-base, ${theme.colors.warning.dark1});
}
.validation-checkmark {
width: ${theme.gridUnit * 4}px;
height: ${theme.gridUnit * 4}px;
margin-left: ${theme.gridUnit}px;
color: ${theme.colors.success.base};
}
}
`;
export interface PanelProps extends CollapsePanelProps {
children?: React.ReactNode;
}
const StyledPanel = (props: PanelProps) => (
<AntdCollapse.Panel
css={(theme: SupersetTheme) => antdPanelStyles(theme)}
{...props}
/>
);
export default StyledPanel;

View File

@ -0,0 +1,53 @@
/**
* 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 { t } from '@superset-ui/core';
import { CheckCircleOutlined } from '@ant-design/icons';
const ValidatedPanelHeader = ({
title,
subtitle,
validateCheckStatus,
testId,
}: {
title: string;
subtitle: string;
validateCheckStatus: boolean;
testId?: string;
}): JSX.Element => {
const checkmark = <CheckCircleOutlined />;
return (
<div className="collapse-panel-header">
<div className="collapse-panel-title" data-test={testId}>
<span>{t(title)}</span>
{validateCheckStatus ? (
<span className="validation-checkmark">{checkmark}</span>
) : (
<span className="collapse-panel-asterisk">{' *'}</span>
)}
</div>
<p className="collapse-panel-subtitle">
{subtitle ? t(subtitle) : undefined}
</p>
</div>
);
};
export default ValidatedPanelHeader;

View File

@ -123,3 +123,21 @@ export interface AlertsReportsConfig {
ALERT_REPORTS_DEFAULT_RETENTION: number; ALERT_REPORTS_DEFAULT_RETENTION: number;
ALERT_REPORTS_DEFAULT_CRON_VALUE: string; ALERT_REPORTS_DEFAULT_CRON_VALUE: string;
} }
export type SectionValidationObject = {
hasErrors: boolean;
errors: string[];
name: string;
};
export interface ValidationObject {
[key: string]: SectionValidationObject;
}
export enum Sections {
General = 'generalSection',
Content = 'contentSection',
Alert = 'alertConditionSection',
Schedule = 'scheduleSection',
Notification = 'notificationSection',
}

View File

@ -45,10 +45,7 @@ import {
NotificationFormats, NotificationFormats,
} from 'src/features/reports/types'; } from 'src/features/reports/types';
import { reportSelector } from 'src/views/CRUD/hooks'; import { reportSelector } from 'src/views/CRUD/hooks';
import { import { StyledInputContainer } from 'src/features/alerts/AlertReportModal';
TRANSLATIONS,
StyledInputContainer,
} from 'src/features/alerts/AlertReportModal';
import { CreationMethod } from './HeaderReportDropdown'; import { CreationMethod } from './HeaderReportDropdown';
import { import {
antDErrorAlertStyles, antDErrorAlertStyles,
@ -270,14 +267,14 @@ function ReportModal({
const renderCustomWidthSection = ( const renderCustomWidthSection = (
<StyledInputContainer> <StyledInputContainer>
<div className="control-label" css={CustomWidthHeaderStyle}> <div className="control-label" css={CustomWidthHeaderStyle}>
{TRANSLATIONS.CUSTOM_SCREENSHOT_WIDTH_TEXT} {t('Screenshot width')}
</div> </div>
<div className="input-container"> <div className="input-container">
<Input <Input
type="number" type="number"
name="custom_width" name="custom_width"
value={currentReport?.custom_width || ''} value={currentReport?.custom_width || ''}
placeholder={TRANSLATIONS.CUSTOM_SCREENSHOT_WIDTH_PLACEHOLDER_TEXT} placeholder={t('Input custom width in pixels')}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => { onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setCurrentReport({ setCurrentReport({
custom_width: parseInt(event.target.value, 10) || null, custom_width: parseInt(event.target.value, 10) || null,

View File

@ -1293,7 +1293,7 @@ ALERT_REPORTS_WORKING_SOFT_TIME_OUT_LAG = int(timedelta(seconds=1).total_seconds
# Default values that user using when creating alert # Default values that user using when creating alert
ALERT_REPORTS_DEFAULT_WORKING_TIMEOUT = 3600 ALERT_REPORTS_DEFAULT_WORKING_TIMEOUT = 3600
ALERT_REPORTS_DEFAULT_RETENTION = 90 ALERT_REPORTS_DEFAULT_RETENTION = 90
ALERT_REPORTS_DEFAULT_CRON_VALUE = "0 * * * *" # every hour ALERT_REPORTS_DEFAULT_CRON_VALUE = "0 0 * * *" # every day
# If set to true no notification is sent, the worker will just log a message. # If set to true no notification is sent, the worker will just log a message.
# Useful for debugging # Useful for debugging
ALERT_REPORTS_NOTIFICATION_DRY_RUN = False ALERT_REPORTS_NOTIFICATION_DRY_RUN = False