From 2769de3731bc932fcc486a6b0730bd69ce58f93b Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Fri, 11 Dec 2020 10:52:26 -0800 Subject: [PATCH 01/38] chore: remove generic type (#12003) * chore: remove generic type * Make resourceName type stricter * Fix type * Fix type * Fix lint --- .../src/components/ImportModal/ImportModal.test.tsx | 9 +++++---- .../src/components/ImportModal/index.tsx | 5 +++-- superset-frontend/src/views/CRUD/hooks.ts | 12 ++++++------ superset-frontend/src/views/CRUD/types.ts | 2 ++ 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/superset-frontend/src/components/ImportModal/ImportModal.test.tsx b/superset-frontend/src/components/ImportModal/ImportModal.test.tsx index 1e922b4d12873..718d4d63d70bb 100644 --- a/superset-frontend/src/components/ImportModal/ImportModal.test.tsx +++ b/superset-frontend/src/components/ImportModal/ImportModal.test.tsx @@ -22,6 +22,7 @@ import configureStore from 'redux-mock-store'; import { styledMount as mount } from 'spec/helpers/theming'; import { ReactWrapper } from 'enzyme'; +import { ImportResourceName } from 'src/views/CRUD/types'; import ImportModelsModal, { StyledIcon } from 'src/components/ImportModal'; import Modal from 'src/common/components/Modal'; @@ -29,8 +30,8 @@ const mockStore = configureStore([thunk]); const store = mockStore({}); const requiredProps = { - resourceName: 'model', - resourceLabel: 'model', + resourceName: 'database' as ImportResourceName, + resourceLabel: 'database', icon: , passwordsNeededMessage: 'Passwords are needed', addDangerToast: () => {}, @@ -61,8 +62,8 @@ describe('ImportModelsModal', () => { expect(wrapper.find(Modal)).toExist(); }); - it('renders "Import model" header', () => { - expect(wrapper.find('h4').text()).toEqual('Import model'); + it('renders "Import database" header', () => { + expect(wrapper.find('h4').text()).toEqual('Import database'); }); it('renders a label and a file input field', () => { diff --git a/superset-frontend/src/components/ImportModal/index.tsx b/superset-frontend/src/components/ImportModal/index.tsx index 1c26623f10c78..5b2d1ac8cf759 100644 --- a/superset-frontend/src/components/ImportModal/index.tsx +++ b/superset-frontend/src/components/ImportModal/index.tsx @@ -22,6 +22,7 @@ import { styled, t } from '@superset-ui/core'; import Icon from 'src//components/Icon'; import Modal from 'src/common/components/Modal'; import { useImportResource } from 'src/views/CRUD/hooks'; +import { ImportResourceName } from 'src/views/CRUD/types'; export const StyledIcon = styled(Icon)` margin: auto ${({ theme }) => theme.gridUnit * 2}px auto 0; @@ -97,7 +98,7 @@ const StyledInputContainer = styled.div` `; export interface ImportModelsModalProps { - resourceName: string; + resourceName: ImportResourceName; resourceLabel: string; icon: React.ReactNode; passwordsNeededMessage: string; @@ -145,7 +146,7 @@ const ImportModelsModal: FunctionComponent = ({ const { state: { passwordsNeeded }, importResource, - } = useImportResource(resourceName, resourceLabel, handleErrorMsg); + } = useImportResource(resourceName, resourceLabel, handleErrorMsg); useEffect(() => { setPasswordFields(passwordsNeeded); diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index dedcadd87afbb..794b2c4d0f1de 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -26,7 +26,7 @@ import { FilterValue } from 'src/components/ListView/types'; import Chart, { Slice } from 'src/types/Chart'; import copyTextToClipboard from 'src/utils/copy'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; -import { FavoriteStatus } from './types'; +import { FavoriteStatus, ImportResourceName } from './types'; interface ListViewResourceState { loading: boolean; @@ -313,22 +313,22 @@ export function useSingleViewResource( }; } -interface ImportResourceState { +interface ImportResourceState { loading: boolean; passwordsNeeded: string[]; } -export function useImportResource( - resourceName: string, +export function useImportResource( + resourceName: ImportResourceName, resourceLabel: string, // resourceLabel for translations handleErrorMsg: (errorMsg: string) => void, ) { - const [state, setState] = useState>({ + const [state, setState] = useState({ loading: false, passwordsNeeded: [], }); - function updateState(update: Partial>) { + function updateState(update: Partial) { setState(currentState => ({ ...currentState, ...update })); } diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index c83e42ceba8ee..4a9a2c5c47994 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -111,3 +111,5 @@ export enum QueryObjectColumns { tmp_table_name = 'tmp_table_name', tracking_url = 'tracking_url', } + +export type ImportResourceName = 'chart' | 'dashboard' | 'database' | 'dataset'; From 5d8ecc01b731b7a64d149b3a4100506a23fd0aa3 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Fri, 11 Dec 2020 11:55:33 -0800 Subject: [PATCH 02/38] fix: add default position to dash export (#12007) * fix: add default position to dash export * Add constants --- superset/dashboards/commands/export.py | 59 +++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/superset/dashboards/commands/export.py b/superset/dashboards/commands/export.py index b3867758e9fd0..8f103d73c30af 100644 --- a/superset/dashboards/commands/export.py +++ b/superset/dashboards/commands/export.py @@ -18,7 +18,9 @@ import json import logging -from typing import Iterator, Tuple +import random +import string +from typing import Any, Dict, Iterator, List, Tuple import yaml from werkzeug.utils import secure_filename @@ -28,6 +30,7 @@ from superset.dashboards.dao import DashboardDAO from superset.commands.export import ExportModelsCommand from superset.models.dashboard import Dashboard +from superset.models.slice import Slice from superset.utils.dict_import_export import EXPORT_VERSION logger = logging.getLogger(__name__) @@ -35,6 +38,55 @@ # keys stored as JSON are loaded and the prefix/suffix removed JSON_KEYS = {"position_json": "position", "json_metadata": "metadata"} +DEFAULT_CHART_HEIGHT = 50 +DEFAULT_CHART_WIDTH = 4 + + +def suffix(length: int = 8) -> str: + return "".join( + random.SystemRandom().choice(string.ascii_uppercase + string.digits) + for _ in range(length) + ) + + +def default_position(title: str, charts: List[Slice]) -> Dict[str, Any]: + chart_hashes = [f"CHART-{suffix()}" for _ in charts] + row_hash = f"ROW-N-{suffix()}" + position = { + "DASHBOARD_VERSION_KEY": "v2", + "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"}, + "GRID_ID": { + "children": [row_hash], + "id": "GRID_ID", + "parents": ["ROOT_ID"], + "type": "GRID", + }, + "HEADER_ID": {"id": "HEADER_ID", "meta": {"text": title}, "type": "HEADER"}, + row_hash: { + "children": chart_hashes, + "id": row_hash, + "meta": {"0": "ROOT_ID", "background": "BACKGROUND_TRANSPARENT"}, + "parents": ["ROOT_ID", "GRID_ID"], + "type": "ROW", + }, + } + + for chart_hash, chart in zip(chart_hashes, charts): + position[chart_hash] = { + "children": [], + "id": chart_hash, + "meta": { + "chartId": chart.id, + "height": DEFAULT_CHART_HEIGHT, + "sliceName": chart.slice_name, + "uuid": str(chart.uuid), + "width": DEFAULT_CHART_WIDTH, + }, + "parents": ["ROOT_ID", "GRID_ID", row_hash], + "type": "CHART", + } + + return position class ExportDashboardsCommand(ExportModelsCommand): @@ -64,6 +116,11 @@ def _export(model: Dashboard) -> Iterator[Tuple[str, str]]: logger.info("Unable to decode `%s` field: %s", key, value) payload[new_name] = {} + # the mapping between dashboard -> charts is inferred from the position + # attributes, so if it's not present we need to add a default config + if not payload.get("position"): + payload["position"] = default_position(model.dashboard_title, model.slices) + payload["version"] = EXPORT_VERSION file_content = yaml.safe_dump(payload, sort_keys=False) From 696308715d285777952afe142fce877dc31ef188 Mon Sep 17 00:00:00 2001 From: Moriah Kreeger Date: Fri, 11 Dec 2020 15:50:08 -0800 Subject: [PATCH 03/38] feat: alerts/reports add/edit modal (#11770) --- superset-frontend/package-lock.json | 114 +- .../views/CRUD/alert/AlertList_spec.jsx | 24 +- .../CRUD/alert/AlertReportModal_spec.jsx | 137 ++ .../src/common/components/Modal/Modal.tsx | 5 + .../src/common/components/Radio.tsx | 54 + .../src/common/components/Select.tsx | 46 + .../src/common/components/index.tsx | 2 +- .../src/middleware/asyncEvent.ts | 3 +- .../src/views/CRUD/alert/AlertList.tsx | 38 +- .../src/views/CRUD/alert/AlertReportModal.tsx | 1315 +++++++++++++++++ .../src/views/CRUD/alert/types.ts | 32 +- superset-frontend/src/views/CRUD/hooks.ts | 18 +- superset/reports/api.py | 1 + superset/reports/commands/update.py | 6 + 14 files changed, 1706 insertions(+), 89 deletions(-) create mode 100644 superset-frontend/spec/javascripts/views/CRUD/alert/AlertReportModal_spec.jsx create mode 100644 superset-frontend/src/common/components/Radio.tsx create mode 100644 superset-frontend/src/common/components/Select.tsx create mode 100644 superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 9d2d5f58d1254..c0f0f138f1195 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -22888,28 +22888,28 @@ "dependencies": { "abbrev": { "version": "1.1.1", - "resolved": "", + "resolved": false, "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true, "optional": true }, "ansi-regex": { "version": "2.1.1", - "resolved": "", + "resolved": false, "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true, "optional": true }, "aproba": { "version": "1.2.0", - "resolved": "", + "resolved": false, "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true, "optional": true }, "are-we-there-yet": { "version": "1.1.5", - "resolved": "", + "resolved": false, "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", "dev": true, "optional": true, @@ -22920,14 +22920,14 @@ }, "balanced-match": { "version": "1.0.0", - "resolved": "", + "resolved": false, "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true, "optional": true }, "brace-expansion": { "version": "1.1.11", - "resolved": "", + "resolved": false, "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "optional": true, @@ -22938,35 +22938,35 @@ }, "code-point-at": { "version": "1.1.0", - "resolved": "", + "resolved": false, "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true, "optional": true }, "concat-map": { "version": "0.0.1", - "resolved": "", + "resolved": false, "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true, "optional": true }, "console-control-strings": { "version": "1.1.0", - "resolved": "", + "resolved": false, "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true, "optional": true }, "core-util-is": { "version": "1.0.2", - "resolved": "", + "resolved": false, "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true, "optional": true }, "debug": { "version": "4.1.1", - "resolved": "", + "resolved": false, "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "dev": true, "optional": true, @@ -22976,35 +22976,35 @@ }, "deep-extend": { "version": "0.6.0", - "resolved": "", + "resolved": false, "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "optional": true }, "delegates": { "version": "1.0.0", - "resolved": "", + "resolved": false, "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true, "optional": true }, "detect-libc": { "version": "1.0.3", - "resolved": "", + "resolved": false, "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "dev": true, "optional": true }, "fs.realpath": { "version": "1.0.0", - "resolved": "", + "resolved": false, "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true, "optional": true }, "gauge": { "version": "2.7.4", - "resolved": "", + "resolved": false, "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "dev": true, "optional": true, @@ -23021,7 +23021,7 @@ }, "glob": { "version": "7.1.3", - "resolved": "", + "resolved": false, "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "dev": true, "optional": true, @@ -23036,14 +23036,14 @@ }, "has-unicode": { "version": "2.0.1", - "resolved": "", + "resolved": false, "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true, "optional": true }, "iconv-lite": { "version": "0.4.24", - "resolved": "", + "resolved": false, "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "optional": true, @@ -23053,7 +23053,7 @@ }, "ignore-walk": { "version": "3.0.1", - "resolved": "", + "resolved": false, "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", "dev": true, "optional": true, @@ -23063,7 +23063,7 @@ }, "inflight": { "version": "1.0.6", - "resolved": "", + "resolved": false, "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "optional": true, @@ -23074,21 +23074,21 @@ }, "inherits": { "version": "2.0.3", - "resolved": "", + "resolved": false, "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true, "optional": true }, "ini": { "version": "1.3.5", - "resolved": "", + "resolved": false, "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true, "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "resolved": "", + "resolved": false, "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, "optional": true, @@ -23098,14 +23098,14 @@ }, "isarray": { "version": "1.0.0", - "resolved": "", + "resolved": false, "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true, "optional": true }, "minimatch": { "version": "3.0.4", - "resolved": "", + "resolved": false, "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, "optional": true, @@ -23122,14 +23122,14 @@ }, "ms": { "version": "2.1.1", - "resolved": "", + "resolved": false, "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "dev": true, "optional": true }, "needle": { "version": "2.3.0", - "resolved": "", + "resolved": false, "integrity": "sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg==", "dev": true, "optional": true, @@ -23141,7 +23141,7 @@ }, "node-pre-gyp": { "version": "0.12.0", - "resolved": "", + "resolved": false, "integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==", "dev": true, "optional": true, @@ -23160,7 +23160,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": "", + "resolved": false, "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, @@ -23171,14 +23171,14 @@ }, "npm-bundled": { "version": "1.0.6", - "resolved": "", + "resolved": false, "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==", "dev": true, "optional": true }, "npm-packlist": { "version": "1.4.1", - "resolved": "", + "resolved": false, "integrity": "sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==", "dev": true, "optional": true, @@ -23189,7 +23189,7 @@ }, "npmlog": { "version": "4.1.2", - "resolved": "", + "resolved": false, "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "dev": true, "optional": true, @@ -23202,21 +23202,21 @@ }, "number-is-nan": { "version": "1.0.1", - "resolved": "", + "resolved": false, "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true, "optional": true }, "object-assign": { "version": "4.1.1", - "resolved": "", + "resolved": false, "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true, "optional": true }, "once": { "version": "1.4.0", - "resolved": "", + "resolved": false, "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "optional": true, @@ -23226,21 +23226,21 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": "", + "resolved": false, "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true, "optional": true }, "os-tmpdir": { "version": "1.0.2", - "resolved": "", + "resolved": false, "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true, "optional": true }, "osenv": { "version": "0.1.5", - "resolved": "", + "resolved": false, "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "dev": true, "optional": true, @@ -23251,21 +23251,21 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "", + "resolved": false, "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true, "optional": true }, "process-nextick-args": { "version": "2.0.0", - "resolved": "", + "resolved": false, "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", "dev": true, "optional": true }, "rc": { "version": "1.2.8", - "resolved": "", + "resolved": false, "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, "optional": true, @@ -23278,7 +23278,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "", + "resolved": false, "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "optional": true, @@ -23294,7 +23294,7 @@ }, "rimraf": { "version": "2.6.3", - "resolved": "", + "resolved": false, "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "dev": true, "optional": true, @@ -23304,49 +23304,49 @@ }, "safe-buffer": { "version": "5.1.2", - "resolved": "", + "resolved": false, "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "optional": true }, "safer-buffer": { "version": "2.1.2", - "resolved": "", + "resolved": false, "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "optional": true }, "sax": { "version": "1.2.4", - "resolved": "", + "resolved": false, "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true, "optional": true }, "semver": { "version": "5.7.0", - "resolved": "", + "resolved": false, "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", "dev": true, "optional": true }, "set-blocking": { "version": "2.0.0", - "resolved": "", + "resolved": false, "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true, "optional": true }, "signal-exit": { "version": "3.0.2", - "resolved": "", + "resolved": false, "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true, "optional": true }, "string-width": { "version": "1.0.2", - "resolved": "", + "resolved": false, "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, "optional": true, @@ -23358,7 +23358,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "", + "resolved": false, "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "optional": true, @@ -23368,7 +23368,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "", + "resolved": false, "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "optional": true, @@ -23378,21 +23378,21 @@ }, "strip-json-comments": { "version": "2.0.1", - "resolved": "", + "resolved": false, "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true, "optional": true }, "util-deprecate": { "version": "1.0.2", - "resolved": "", + "resolved": false, "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true, "optional": true }, "wide-align": { "version": "1.1.3", - "resolved": "", + "resolved": false, "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", "dev": true, "optional": true, @@ -23402,7 +23402,7 @@ }, "wrappy": { "version": "1.0.2", - "resolved": "", + "resolved": false, "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true, "optional": true diff --git a/superset-frontend/spec/javascripts/views/CRUD/alert/AlertList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/alert/AlertList_spec.jsx index dff305e867925..abb4e9183fcc0 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/alert/AlertList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/alert/AlertList_spec.jsx @@ -19,6 +19,7 @@ import fetchMock from 'fetch-mock'; import React from 'react'; import configureStore from 'redux-mock-store'; +import { Provider } from 'react-redux'; import thunk from 'redux-thunk'; import { styledMount as mount } from 'spec/helpers/theming'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; @@ -78,33 +79,30 @@ fetchMock.get(alertsCreatedByEndpoint, { result: [] }); fetchMock.put(alertEndpoint, { ...mockalerts[0], active: false }); fetchMock.put(alertsEndpoint, { ...mockalerts[0], active: false }); -async function mountAndWait(props) { - const mounted = mount(); - await waitForComponentToPaint(mounted); - - return mounted; -} - describe('AlertList', () => { - let wrapper; + const wrapper = mount( + + + , + ); beforeAll(async () => { - wrapper = await mountAndWait(); + await waitForComponentToPaint(wrapper); }); - it('renders', () => { + it('renders', async () => { expect(wrapper.find(AlertList)).toExist(); }); - it('renders a SubMenu', () => { + it('renders a SubMenu', async () => { expect(wrapper.find(SubMenu)).toExist(); }); - it('renders a ListView', () => { + it('renders a ListView', async () => { expect(wrapper.find(ListView)).toExist(); }); - it('renders switches', () => { + it('renders switches', async () => { expect(wrapper.find(Switch)).toHaveLength(3); }); }); diff --git a/superset-frontend/spec/javascripts/views/CRUD/alert/AlertReportModal_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/alert/AlertReportModal_spec.jsx new file mode 100644 index 0000000000000..a24b9526a7957 --- /dev/null +++ b/superset-frontend/spec/javascripts/views/CRUD/alert/AlertReportModal_spec.jsx @@ -0,0 +1,137 @@ +/** + * 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 AlertReportModal from 'src/views/CRUD/alert/AlertReportModal'; +import Modal from 'src/common/components/Modal'; +import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; +import { styledMount as mount } from 'spec/helpers/theming'; + +const mockData = { + id: 1, + name: 'test report', + description: 'test report description', +}; +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/dashboard/related/owners?*'; +const databaseEndpoint = 'glob:*/api/v1/dataset/related/database?*'; +const dashboardEndpoint = 'glob:*/api/v1/dashboard?*'; +const chartEndpoint = 'glob:*/api/v1/chart?*'; + +fetchMock.get(ownersEndpoint, { + result: [], +}); + +fetchMock.get(databaseEndpoint, { + result: [], +}); + +fetchMock.get(dashboardEndpoint, { + result: [], +}); + +fetchMock.get(chartEndpoint, { + result: [], +}); + +async function mountAndWait(props = mockedProps) { + const mounted = mount( + + + , + { + 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('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.skip('renders edit header when alert prop is included', () => { + expect( + wrapper.find('[data-test="alert-report-modal-title"]').text(), + ).toEqual('Edit Report'); + }); + + it('renders input element for name', () => { + expect(wrapper.find('input[name="name"]')).toExist(); + }); + + it('renders input element for description', () => { + expect(wrapper.find('input[name="description"]')).toExist(); + }); +}); diff --git a/superset-frontend/src/common/components/Modal/Modal.tsx b/superset-frontend/src/common/components/Modal/Modal.tsx index 4431fd32c85d2..a11e04253192f 100644 --- a/superset-frontend/src/common/components/Modal/Modal.tsx +++ b/superset-frontend/src/common/components/Modal/Modal.tsx @@ -84,6 +84,7 @@ const StyledModal = styled(BaseModal)` .ant-modal-body { padding: ${({ theme }) => theme.gridUnit * 4}px; + overflow: auto; } .ant-modal-footer { @@ -105,6 +106,10 @@ const StyledModal = styled(BaseModal)` .ant-tabs { margin-top: -${({ theme }) => theme.gridUnit * 4}px; } + + &.no-content-padding .ant-modal-body { + padding: 0; + } `; const CustomModal = ({ diff --git a/superset-frontend/src/common/components/Radio.tsx b/superset-frontend/src/common/components/Radio.tsx new file mode 100644 index 0000000000000..3134fb7bab531 --- /dev/null +++ b/superset-frontend/src/common/components/Radio.tsx @@ -0,0 +1,54 @@ +/** + * 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 { styled } from '@superset-ui/core'; +import { Radio as BaseRadio } from 'src/common/components'; + +const StyledRadio = styled(BaseRadio)` + .ant-radio-inner { + width: 18px; + height: 18px; + border-width: 2px; + border-color: ${({ theme }) => theme.colors.grayscale.base}; + } + + .ant-radio.ant-radio-checked { + .ant-radio-inner { + background-color: ${({ theme }) => theme.colors.primary.dark1}; + border-color: ${({ theme }) => theme.colors.primary.dark1}; + } + + .ant-radio-inner::after { + background-color: ${({ theme }) => theme.colors.grayscale.light5}; + } + } + + .ant-radio:hover, + .ant-radio:focus { + .ant-radio-inner { + border-color: ${({ theme }) => theme.colors.primary.dark1}; + } + } +`; +const StyledGroup = styled(BaseRadio.Group)` + font-size: inherit; +`; + +export const Radio = Object.assign(StyledRadio, { + Group: StyledGroup, +}); diff --git a/superset-frontend/src/common/components/Select.tsx b/superset-frontend/src/common/components/Select.tsx new file mode 100644 index 0000000000000..b3a49d483b3ee --- /dev/null +++ b/superset-frontend/src/common/components/Select.tsx @@ -0,0 +1,46 @@ +/** + * 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 { styled } from '@superset-ui/core'; +import { Select as BaseSelect } from 'src/common/components'; + +const StyledSelect = styled(BaseSelect)` + &.ant-select-single { + .ant-select-selector { + height: 36px; + padding: 0 11px; + background-color: ${({ theme }) => theme.colors.grayscale.light3}; + border: none; + + .ant-select-selection-search-input { + height: 100%; + } + + .ant-select-selection-item, + .ant-select-selection-placeholder { + line-height: 35px; + color: ${({ theme }) => theme.colors.grayscale.dark1}; + } + } + } +`; +const StyledOption = styled(BaseSelect.Option)``; + +export const Select = Object.assign(StyledSelect, { + Option: StyledOption, +}); diff --git a/superset-frontend/src/common/components/index.tsx b/superset-frontend/src/common/components/index.tsx index 1ae7dee24d85e..895c2e6d6e209 100644 --- a/superset-frontend/src/common/components/index.tsx +++ b/superset-frontend/src/common/components/index.tsx @@ -39,10 +39,10 @@ export { Input, Modal, Popover, + Radio, Select, Skeleton, Switch, - Radio, Tabs, Tooltip, } from 'antd'; diff --git a/superset-frontend/src/middleware/asyncEvent.ts b/superset-frontend/src/middleware/asyncEvent.ts index 637bb1b38d84f..32d4010b7df47 100644 --- a/superset-frontend/src/middleware/asyncEvent.ts +++ b/superset-frontend/src/middleware/asyncEvent.ts @@ -184,8 +184,9 @@ const initAsyncEvents = (options: AsyncEventOptions) => { if ( isFeatureEnabled(FeatureFlag.GLOBAL_ASYNC_QUERIES) && transport === TRANSPORT_POLLING - ) + ) { processEvents(); + } return action => next(action); }; diff --git a/superset-frontend/src/views/CRUD/alert/AlertList.tsx b/superset-frontend/src/views/CRUD/alert/AlertList.tsx index bed3742b01e4d..9b46377415612 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertList.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertList.tsx @@ -17,24 +17,26 @@ * under the License. */ -import { t } from '@superset-ui/core'; -import React, { useEffect, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; -import { Switch } from 'src/common/components/Switch'; +import { t } from '@superset-ui/core'; +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 ListView, { FilterOperators, Filters } from 'src/components/ListView'; -import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; +import { Switch } from 'src/common/components/Switch'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import AlertStatusIcon from 'src/views/CRUD/alert/components/AlertStatusIcon'; import RecipientIcon from 'src/views/CRUD/alert/components/RecipientIcon'; + import { useListViewResource, useSingleViewResource, } from 'src/views/CRUD/hooks'; import { createErrorHandler, createFetchRelated } from 'src/views/CRUD/utils'; +import AlertReportModal from './AlertReportModal'; import { AlertObject, AlertState } from './types'; const PAGE_SIZE = 25; @@ -85,6 +87,15 @@ function AlertList({ addDangerToast, ); + const [alertModalOpen, setAlertModalOpen] = useState(false); + const [currentAlert, setCurrentAlert] = useState(null); + + // Actions + function handleAlertEdit(alert: AlertObject | null) { + setCurrentAlert(alert); + setAlertModalOpen(true); + } + const canEdit = hasPerm('can_edit'); const canDelete = hasPerm('can_delete'); const canCreate = hasPerm('can_add'); @@ -128,6 +139,7 @@ function AlertList({ }: any) => recipients.map((r: any) => ( + // )), accessor: 'recipients', Header: t('Notification Method'), @@ -169,7 +181,7 @@ function AlertList({ { Cell: ({ row: { original } }: any) => { const history = useHistory(); - const handleEdit = () => {}; // handleAnnotationEdit(original); + const handleEdit = () => handleAlertEdit(original); const handleDelete = () => {}; // setAlertCurrentlyDeleting(original); const handleGotoExecutionLog = () => history.push(`/${original.type.toLowerCase()}/${original.id}/log`); @@ -217,6 +229,7 @@ function AlertList({ ); const subMenuButtons: SubMenuProps['buttons'] = []; + if (canCreate) { subMenuButtons.push({ name: ( @@ -225,7 +238,9 @@ function AlertList({ ), buttonStyle: 'primary', - onClick: () => {}, + onClick: () => { + handleAlertEdit(null); + }, }); } @@ -303,6 +318,17 @@ function AlertList({ ]} buttons={subMenuButtons} /> + { + setAlertModalOpen(false); + refreshData(); + }} + show={alertModalOpen} + isReport={isReportEnabled} + /> className="alerts-list-view" columns={columns} diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx new file mode 100644 index 0000000000000..1ebc5cd6cf3de --- /dev/null +++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx @@ -0,0 +1,1315 @@ +/** + * 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, { FunctionComponent, useState, useEffect } from 'react'; +import { styled, t, SupersetClient } from '@superset-ui/core'; +import rison from 'rison'; +import { useSingleViewResource } from 'src/views/CRUD/hooks'; + +import Icon from 'src/components/Icon'; +import Modal from 'src/common/components/Modal'; +import { Switch } from 'src/common/components/Switch'; +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 { AlertObject, Operator, Recipient, MetaObject } from './types'; + +type SelectValue = { + value: string; + label: string; +}; + +interface AlertReportModalProps { + addDangerToast: (msg: string) => void; + alert?: AlertObject | null; + isReport?: boolean; + onAdd?: (alert?: AlertObject) => void; + onHide: () => void; + show: boolean; +} + +const NOTIFICATION_METHODS: NotificationMethod[] = ['Email', 'Slack']; + +const CONDITIONS = [ + { + label: t('< (Smaller than)'), + value: '<', + }, + { + label: t('> (Larger than)'), + value: '>', + }, + { + label: t('<= (Smaller or equal)'), + value: '<=', + }, + { + label: t('>= (Larger or equal)'), + value: '>=', + }, + { + label: t('== (Is Equal)'), + value: '==', + }, + { + label: t('!= (Is Not Equal)'), + value: '!=', + }, +]; + +const RETENTION_OPTIONS = [ + { + label: t('None'), + value: 0, + }, + { + label: t('30 days'), + value: 30, + }, + { + label: t('60 days'), + value: 60, + }, + { + label: t('90 days'), + value: 90, + }, +]; + +const DEFAULT_RETENTION = 90; +const DEFAULT_WORKING_TIMEOUT = 3600; + +const StyledIcon = styled(Icon)` + margin: auto ${({ theme }) => theme.gridUnit * 2}px auto 0; +`; + +const StyledSectionContainer = styled.div` + display: flex; + min-width: 1000px; + flex-direction: column; + + .header-section { + display: flex; + flex: 0 0 auto; + align-items: center; + width: 100%; + padding: ${({ theme }) => theme.gridUnit * 4}px; + border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; + } + + .column-section { + display: flex; + flex: 1 1 auto; + + .column { + flex: 1 1 auto; + min-width: 33.33%; + padding: ${({ theme }) => theme.gridUnit * 4}px; + + .async-select { + margin: 10px 0 20px; + } + + &.condition { + border-right: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; + } + + &.message { + border-left: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; + } + } + } + + .inline-container { + display: flex; + flex-direction: row; + align-items: center; + + > div { + flex: 1 1 auto; + } + + &.add-margin { + margin-bottom: 5px; + } + + .styled-input { + margin: 0 0 0 10px; + + input { + flex: 0 0 auto; + } + } + } + + .hide-dropdown { + display: none; + } +`; + +const StyledSectionTitle = styled.div` + margin: ${({ theme }) => theme.gridUnit * 2}px auto + ${({ theme }) => theme.gridUnit * 4}px auto; +`; + +const StyledSwitchContainer = styled.div` + display: flex; + align-items: center; + margin-top: 10px; + + .switch-label { + margin-left: 10px; + } +`; + +const StyledInputContainer = styled.div` + flex: 1 1 auto; + margin: ${({ theme }) => theme.gridUnit * 2}px; + margin-top: 0; + + .required { + margin-left: ${({ theme }) => theme.gridUnit / 2}px; + color: ${({ theme }) => theme.colors.error.base}; + } + + .input-container { + display: flex; + align-items: center; + + label { + display: flex; + margin-right: ${({ theme }) => theme.gridUnit * 2}px; + } + + i { + margin: 0 ${({ theme }) => theme.gridUnit}px; + } + } + + input, + textarea, + .Select, + .ant-select { + flex: 1 1 auto; + } + + textarea { + height: 160px; + resize: none; + } + + input::placeholder, + textarea::placeholder, + .Select__placeholder { + color: ${({ theme }) => theme.colors.grayscale.light1}; + } + + textarea, + input[type='text'], + input[type='number'], + .Select__control, + .ant-select-single .ant-select-selector { + padding: ${({ theme }) => theme.gridUnit * 1.5}px + ${({ theme }) => theme.gridUnit * 2}px; + border-style: none; + border: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; + border-radius: ${({ theme }) => theme.gridUnit}px; + + &[name='description'] { + flex: 1 1 auto; + } + } + + .Select__control { + padding: 2px 0; + } + + .input-label { + margin-left: 10px; + } +`; + +// Notification Method components +const StyledNotificationAddButton = styled.div` + color: ${({ theme }) => theme.colors.primary.dark1}; + cursor: pointer; + + i { + margin-right: ${({ theme }) => theme.gridUnit * 2}px; + } + + &.disabled { + color: ${({ theme }) => theme.colors.grayscale.light1}; + cursor: default; + } +`; + +const StyledNotificationMethod = styled.div` + margin-bottom: 10px; + + .input-container { + textarea { + height: auto; + } + } + + .inline-container { + margin-bottom: 10px; + + .input-container { + margin-left: 10px; + } + + > div { + margin: 0; + } + + .delete-button { + margin-left: 10px; + padding-top: 3px; + } + } +`; + +type NotificationAddStatus = 'active' | 'disabled' | 'hidden'; + +interface NotificationMethodAddProps { + status: NotificationAddStatus; + onClick: () => void; +} + +const NotificationMethodAdd: FunctionComponent = ({ + status = 'active', + onClick, +}) => { + if (status === 'hidden') { + return null; + } + + const checkStatus = () => { + if (status !== 'disabled') { + onClick(); + } + }; + + return ( + + {' '} + {status === 'active' + ? t('Add notification method') + : t('Add delivery method')} + + ); +}; + +type NotificationMethod = 'Email' | 'Slack'; + +type NotificationSetting = { + method?: NotificationMethod; + recipients: string; + options: NotificationMethod[]; +}; + +interface NotificationMethodProps { + setting?: NotificationSetting | null; + index: number; + onUpdate?: (index: number, updatedSetting: NotificationSetting) => void; + onRemove?: (index: number) => void; +} + +const NotificationMethod: FunctionComponent = ({ + setting = null, + index, + onUpdate, + onRemove, +}) => { + const { method, recipients, options } = setting || {}; + const [recipientValue, setRecipientValue] = useState( + recipients || '', + ); + + if (!setting) { + return null; + } + + const onMethodChange = (method: NotificationMethod) => { + // Since we're swapping the method, reset the recipients + setRecipientValue(''); + + if (onUpdate) { + const updatedSetting = { + ...setting, + method, + recipients: '', + }; + + onUpdate(index, updatedSetting); + } + }; + + const onRecipientsChange = ( + event: React.ChangeEvent, + ) => { + const { target } = event; + + setRecipientValue(target.value); + + if (onUpdate) { + const updatedSetting = { + ...setting, + recipients: target.value, + }; + + onUpdate(index, updatedSetting); + } + }; + + // Set recipients + if (!!recipients && recipientValue !== recipients) { + setRecipientValue(recipients); + } + + const methodOptions = (options || []).map((method: NotificationMethod) => { + return ( + + {t(method)} + + ); + }); + + return ( + +
+ +
+ +
+
+ {method !== undefined && !!onRemove ? ( + onRemove(index)} + > + + + ) : null} +
+ {method !== undefined ? ( + +
{t(method)}
+
+