diff --git a/package.json b/package.json index ab1950522..2cb0e2bb8 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@mui/icons-material": "^6.4.3", "@mui/material": "^6.4.3", "@mui/x-date-pickers": "^7.26.0", + "@mui/x-tree-view": "^7.26.0", "@react-pdf/renderer": "^4.4.1", "@sentry/react": "^8.32.0", "@sentry/webpack-plugin": "^2.22.4", @@ -101,7 +102,6 @@ "prop-types": "^15.8.1", "qr-scanner": "^1.4.2", "react": "^16.13.1", - "react-accessible-treeview": "^2.6.2", "react-beautiful-dnd": "^13.1.1", "react-bootstrap": "^0.31.5", "react-breadcrumbs": "^2.1.6", diff --git a/src/actions/email-flows-events-actions.js b/src/actions/email-flows-events-actions.js index 3c6f23b65..4540ed1b0 100644 --- a/src/actions/email-flows-events-actions.js +++ b/src/actions/email-flows-events-actions.js @@ -9,7 +9,7 @@ * 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 T from "i18n-react/dist/i18n-react"; import { @@ -18,11 +18,12 @@ import { createAction, stopLoading, startLoading, - showSuccessMessage, + setSnackbarMessage, authErrorHandler, escapeFilterValue } from "openstack-uicore-foundation/lib/utils/actions"; import { getAccessTokenSafely } from "../utils/methods"; +import { DEFAULT_PER_PAGE } from "../utils/constants"; export const REQUEST_EMAIL_FLOW_EVENTS = "REQUEST_EMAIL_FLOW_EVENTS"; export const RECEIVE_EMAIL_FLOW_EVENTS = "RECEIVE_EMAIL_FLOW_EVENTS"; @@ -30,13 +31,12 @@ export const RECEIVE_EMAIL_FLOW_EVENT = "RECEIVE_EMAIL_FLOW_EVENT"; export const RESET_EMAIL_FLOW_EVENT_FORM = "RESET_EMAIL_FLOW_EVENT_FORM"; export const UPDATE_EMAIL_FLOW_EVENT = "UPDATE_EMAIL_FLOW_EVENT"; export const EMAIL_FLOW_EVENT_UPDATED = "EMAIL_FLOW_EVENT_UPDATED"; -export const EMAIL_FLOW_EVENT_DELETED = "EMAIL_FLOW_EVENT_DELETED"; export const getEmailFlowEvents = ( - term = null, + term = "", page = 1, - perPage = 10, + perPage = DEFAULT_PER_PAGE, order = "email_template_identifier", orderDir = 1 ) => @@ -49,7 +49,7 @@ export const getEmailFlowEvents = const filter = []; const params = { - page: page, + page, per_page: perPage, access_token: accessToken }; @@ -64,7 +64,7 @@ export const getEmailFlowEvents = // order if (order != null && orderDir != null) { const orderDirSign = orderDir === 1 ? "+" : "-"; - params["order"] = `${orderDirSign}${order}`; + params.order = `${orderDirSign}${order}`; } if (filter.length > 0) { @@ -75,11 +75,14 @@ export const getEmailFlowEvents = createAction(REQUEST_EMAIL_FLOW_EVENTS), createAction(RECEIVE_EMAIL_FLOW_EVENTS), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/email-flows-events`, + // TODO: replace with snackbarErrorHandler once it handles 401's (re-login redirect) authErrorHandler, { order, orderDir, term } - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - }); + )(params)(dispatch) + .finally(() => { + dispatch(stopLoading()); + }) + .catch(() => {}); }; export const getEmailFlowEvent = (eventId) => async (dispatch, getState) => { @@ -98,13 +101,16 @@ export const getEmailFlowEvent = (eventId) => async (dispatch, getState) => { null, createAction(RECEIVE_EMAIL_FLOW_EVENT), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/email-flows-events/${eventId}`, + // TODO: replace with snackbarErrorHandler once it handles 401's (re-login redirect) authErrorHandler - )(params)(dispatch).then(() => { - dispatch(stopLoading()); - }); + )(params)(dispatch) + .finally(() => { + dispatch(stopLoading()); + }) + .catch(() => {}); }; -export const resetEmailFlowEventForm = () => (dispatch, getState) => { +export const resetEmailFlowEventForm = () => (dispatch) => { dispatch(createAction(RESET_EMAIL_FLOW_EVENT_FORM)({})); }; @@ -119,14 +125,25 @@ export const saveEmailFlowEvent = (entity) => async (dispatch, getState) => { dispatch(startLoading()); - putRequest( + return putRequest( createAction(UPDATE_EMAIL_FLOW_EVENT), createAction(EMAIL_FLOW_EVENT_UPDATED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/email-flows-events/${entity.id}`, entity, + // TODO: replace with snackbarErrorHandler once it handles 401's (re-login redirect) authErrorHandler, entity - )(params)(dispatch).then((payload) => { - dispatch(showSuccessMessage(T.translate("edit_email_flow_event.saved"))); - }); + )(params)(dispatch) + .then(() => { + dispatch( + setSnackbarMessage({ + html: T.translate("edit_email_flow_event.saved"), + type: "success" + }) + ); + }) + .finally(() => { + dispatch(stopLoading()); + }) + .catch(() => {}); }; diff --git a/src/components/CustomTheme.js b/src/components/CustomTheme.js index d16841edf..5d49c3483 100644 --- a/src/components/CustomTheme.js +++ b/src/components/CustomTheme.js @@ -4,24 +4,29 @@ import { createTheme } from "@mui/material/styles"; import { MuiBaseCustomTheme } from "openstack-uicore-foundation/lib/utils/theme"; import PropTypes from "prop-types"; -const theme = createTheme(MuiBaseCustomTheme, { - palette: { - primary: { - main: "#2196F3", - dark: "#1E88E5", - contrast: "#FFFFFF" - }, - background: { - light: "#F7F7F9", - light_gray: "#eaeaea" - }, - text: { - primary: "#000000DE", - secondary: "#00000099", - link: "#2196f3", - disabled: "#00000061" - } +// theme.typography. functions are silently ignored by MUI (it only +// applies plain-object entries), so a palette-derived typography color can't +// read the theme at runtime - build the palette first and reference it directly. +const palette = { + primary: { + main: "#2196F3", + dark: "#1E88E5", + contrast: "#FFFFFF" }, + background: { + light: "#F7F7F9", + light_gray: "#eaeaea" + }, + text: { + primary: "#000000DE", + secondary: "#00000099", + link: "#2196f3", + disabled: "#00000061" + } +}; + +const theme = createTheme(MuiBaseCustomTheme, { + palette, typography: { fontFamily: ["Roboto", "sans-serif"].join(","), body1: { @@ -36,11 +41,17 @@ const theme = createTheme(MuiBaseCustomTheme, { fontSize: "12px", fontWeight: 400 }, - subtitle2: ({ theme: t }) => ({ + subtitle2: { fontSize: "14px", fontWeight: 500, - color: t.palette.text.primary - }), + color: palette.text.primary + }, + label: { + fontSize: "14px", + fontWeight: 400, + lineHeight: "1.4375em", + color: palette.text.secondary + }, h4: { fontSize: "34px", fontWeight: 500, @@ -65,6 +76,11 @@ const theme = createTheme(MuiBaseCustomTheme, { } }, components: { + MuiTypography: { + variantMapping: { + label: "p" + } + }, MuiFormHelperText: { styleOverrides: { root: { diff --git a/src/components/forms/email-flow-event-form/__tests__/index.test.js b/src/components/forms/email-flow-event-form/__tests__/index.test.js new file mode 100644 index 000000000..3bd6e9ee3 --- /dev/null +++ b/src/components/forms/email-flow-event-form/__tests__/index.test.js @@ -0,0 +1,174 @@ +// jsdom does not implement scrollIntoView; polyfill so the errors effect +// (which calls scrollToError -> firstNode.scrollIntoView) does not throw. +window.HTMLElement.prototype.scrollIntoView = jest.fn(); + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +// Stands in for the real react-select AsyncSelect based input: exposes a +// button that fires the same {target: {id, value}} shape the real component +// dispatches on selection. +jest.mock("../../../inputs/email-template-input", () => ({ + __esModule: true, + default: ({ id, onChange }) => ( + + ) +})); + +// Mirrors the mocking convention used for marketing-setting-form.test.js: +// read/write real Formik state via useField, letting the wrapper's own +// error/FormHelperText rendering stay untested here (covered upstream). +jest.mock( + "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield", + () => { + const React = require("react"); + const { useField } = require("formik"); + return { + __esModule: true, + default: function MockMuiFormikTextField({ name }) { + const [field] = useField(name); + return ; + } + }; + } +); + +/* eslint-disable import/first */ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { FormikProvider, useFormik } from "formik"; +import EmailFlowEventForm from "../index"; +import { + buildValues, + validationSchema +} from "../../../../pages/email_flow_events/edit-email-flow-event-page"; +/* eslint-enable import/first */ + +const BASE_ENTITY = { + id: 5, + flow_name: "REGISTRATION", + event_type_name: "Attendee Registered", + email_template_identifier: "", + recipients: [], + template_schema: null +}; + +// Mirrors the formik wiring edit-email-flow-event-page.js provides in +// production, so EmailFlowEventForm's useFormikContext() has a real context. +const Harness = ({ entity, onSubmit = jest.fn() }) => { + const formik = useFormik({ + initialValues: buildValues(entity), + validationSchema, + onSubmit + }); + + return ( + + + +
{JSON.stringify(formik.values)}
+
{JSON.stringify(formik.errors)}
+
+ ); +}; + +const readFormikValues = () => + JSON.parse(screen.getByTestId("debug-values").textContent); +const readFormikErrors = () => + JSON.parse(screen.getByTestId("debug-errors").textContent); + +const clickSave = () => + userEvent.click(screen.getByRole("button", { name: "general.save" })); + +describe("EmailFlowEventForm", () => { + it("renders the read-only flow name and event type", () => { + render(); + + expect(screen.getByText("REGISTRATION")).toBeInTheDocument(); + expect(screen.getByText("Attendee Registered")).toBeInTheDocument(); + }); + + it("round-trips typed recipients into formik values", () => { + render(); + + fireEvent.change(screen.getByTestId("textfield-recipients"), { + target: { value: "a@example.com,b@example.com" } + }); + + expect(readFormikValues().recipients).toBe("a@example.com,b@example.com"); + }); + + it("updates the email template identifier via EmailTemplateInput", async () => { + render(); + + await userEvent.click( + screen.getByRole("button", { name: "pick-template" }) + ); + + expect(readFormikValues().email_template_identifier).toBe("NEW_TEMPLATE"); + }); + + it("shows the see-template link and CopyClipboard when a template is set", () => { + render( + + ); + + expect(screen.getByRole("link", { name: "see template" })).toHaveAttribute( + "href", + "/app/emails/templates/MY_TEMPLATE" + ); + }); + + it("blocks submit and surfaces an error for an invalid recipient email", async () => { + const onSubmit = jest.fn(); + render(); + + fireEvent.change(screen.getByTestId("textfield-recipients"), { + target: { value: "not-an-email" } + }); + await clickSave(); + + expect(readFormikErrors().recipients).toBeTruthy(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("allows submit with valid comma-separated recipient emails", async () => { + const onSubmit = jest.fn(); + render(); + + fireEvent.change(screen.getByTestId("textfield-recipients"), { + target: { value: "a@example.com, b@example.com" } + }); + await clickSave(); + + expect(readFormikErrors().recipients).toBeUndefined(); + expect(onSubmit).toHaveBeenCalled(); + }); + + it("allows submit with empty recipients (optional field)", async () => { + const onSubmit = jest.fn(); + render(); + + await clickSave(); + + expect(readFormikErrors().recipients).toBeUndefined(); + expect(onSubmit).toHaveBeenCalled(); + }); +}); diff --git a/src/components/forms/email-flow-event-form/index.js b/src/components/forms/email-flow-event-form/index.js index d4df1cae1..76d5d22ee 100644 --- a/src/components/forms/email-flow-event-form/index.js +++ b/src/components/forms/email-flow-event-form/index.js @@ -12,167 +12,98 @@ * */ import React from "react"; import T from "i18n-react/dist/i18n-react"; -import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css"; -import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"; +import { useFormikContext } from "formik"; +import Box from "@mui/material/Box"; +import FormLabel from "@mui/material/FormLabel"; +import Typography from "@mui/material/Typography"; +import { Grid2 } from "@mui/material"; +import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; +import useScrollToError from "../../../hooks/useScrollToError"; import EmailTemplateInput from "../../inputs/email-template-input"; -import { - hasErrors, - isEmpty, - scrollToError, - shallowEqual, - validateEmail -} from "../../../utils/methods"; import TemplateSchemaTree from "./template-schema-tree"; import CopyClipboard from "../../buttons/copy-clipboard"; -class EmailFlowEventForm extends React.Component { - constructor(props) { - super(props); - - this.state = { - entity: { ...props.entity }, - errors: props.errors - }; - - this.handleChange = this.handleChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - } - - componentDidUpdate(prevProps) { - const state = {}; - scrollToError(this.props.errors); - - if (!shallowEqual(prevProps.entity, this.props.entity)) { - state.entity = { ...this.props.entity }; - state.errors = {}; - } - - if (!shallowEqual(prevProps.errors, this.props.errors)) { - state.errors = { ...this.props.errors }; - } - - if (!isEmpty(state)) { - this.setState({ ...this.state, ...state }); - } - } - - handleChange(ev) { - const newEntity = { ...this.state.entity }; - const newErrors = { ...this.state.errors }; - let { value, id } = ev.target; - - if (ev.target.type === "checkbox") { - value = ev.target.checked; - } - - newErrors[id] = ""; - - // this is an array - if (id === "recipients") { - value = value.split(",").map((email) => email.trim()); - // then validate emails - value.forEach((email) => { - if (!validateEmail(email)) { - newErrors[id] = `email ${email} is not valid`; - } - }); - } - newEntity[id] = value; - this.setState({ entity: newEntity, errors: newErrors }); - } - - handleSubmit(ev) { - const { errors } = this.state; - ev.preventDefault(); - if (hasErrors("recipients", errors) !== "") return; - this.props.onSubmit(this.state.entity); - } - - render() { - const { entity, errors } = this.state; - - return ( -
- -
-
- -
- {entity.flow_name} -
-
- -
+const EmailFlowEventForm = ({ entity }) => { + const formik = useFormikContext(); + const { values, setFieldValue } = formik; + + useScrollToError(formik, true); + + const handleTemplateChange = (ev) => { + setFieldValue(ev.target.id, ev.target.value); + }; + + return ( + + + + + {T.translate("edit_email_flow_event.flow_name")} + + {entity.flow_name} + + + + {T.translate("edit_email_flow_event.event_type")} + + {entity.event_type_name} -
-
-
-
- - -
-
-
-
- - -
-
- -
-
- - -
-
- -
- -
-
- -
-
-
- ); - } -} + + + + + + + + {T.translate("edit_email_flow_event.email_template_identifier")} * + {values.email_template_identifier && ( + <> +    + + see template + +    + + + )} + + + + + + {T.translate("edit_email_flow_event.recipient")} + + + + + + {T.translate("edit_email_flow_event.variables")} + + + + + + ); +}; export default EmailFlowEventForm; diff --git a/src/components/forms/email-flow-event-form/template-schema-tree.js b/src/components/forms/email-flow-event-form/template-schema-tree.js index 440ca90b2..ef616249c 100644 --- a/src/components/forms/email-flow-event-form/template-schema-tree.js +++ b/src/components/forms/email-flow-event-form/template-schema-tree.js @@ -1,99 +1,103 @@ import React, { useEffect, useState } from "react"; -import TreeView, { flattenTree } from "react-accessible-treeview"; - -const ExpandIndicator = ({ isExpanded }) => { - return isExpanded ? " - " : " + "; -}; +import { Grid2 } from "@mui/material"; +import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView"; +import { TreeItem } from "@mui/x-tree-view/TreeItem"; + +const formatLabel = (name, type) => + type && type !== "object" ? `${name} (${type})` : name; + +const populateChildren = (entries) => + entries.map((s) => ({ name: formatLabel(s[0], s[1].type) })); + +const expand = (name, def) => { + if (!def) return null; + + if (def.type === "array") { + return expand(formatLabel(name, def.type), def.items); + } + if (def.type === "object") { + const res = expand(formatLabel(name, def.type), def.properties); + const props = Object.entries(def.properties); + + res.children = populateChildren(props); + + // expand nested objects + props.forEach((prop, index) => { + const propDef = prop[1]; + let expanded = null; + if (propDef.type === "array") { + expanded = expand(prop[0], { + type: propDef.type, + items: propDef.items + }); + } else if (propDef.type === "object") { + expanded = expand(prop[0], { + type: propDef.type, + properties: propDef.properties + }); + } + const child = res.children[index]; + if (child && expanded) child.children = expanded.children; + }); -const TemplateSchemaTree = ({ template_schema }) => { - const [data, setData] = useState(null); + return res; + } - useEffect(() => { - if (template_schema) { - const treeData = Object.entries(template_schema) - .map((o) => expand(o[0], o[1])) - .filter((o) => o); - setData(flattenTree({ name: "", children: treeData })); - } - }, [template_schema]); + const entries = Object.entries(def); - const formatLabel = (name, type) => - type && type !== "object" ? `${name} (${type})` : name; + if (entries.length === 1) { + return { name: formatLabel(name, def.type), children: [] }; + } - const populateChildren = (entries) => - entries.map((s) => { - return { name: formatLabel(s[0], s[1].type) }; - }); + return { name, children: populateChildren(entries) }; +}; - const expand = (name, def) => { - if (!def) return null; - - if (def.type === "array") { - return expand(formatLabel(name, def.type), def.items); - } else if (def.type === "object") { - const res = expand(formatLabel(name, def.type), def.properties); - const props = Object.entries(def.properties); - - res.children = populateChildren(props); - - //expand nested objects - - props.forEach((prop, index) => { - const def = prop[1]; - let expanded = null; - if (def.type === "array") { - expanded = expand(prop[0], { type: def.type, items: def.items }); - } else if (def.type === "object") { - expanded = expand(prop[0], { - type: def.type, - properties: def.properties - }); - } - const child = res.children[index]; - if (child && expanded) child.children = expanded.children; - }); - - return res; - } +const toTreeItems = (nodes, idPrefix) => + nodes.map((node, index) => { + const itemId = `${idPrefix}-${index}`; + return ( + + {node.children?.length > 0 ? toTreeItems(node.children, itemId) : null} + + ); + }); - const entries = Object.entries(def); +const TemplateSchemaTree = ({ templateSchema }) => { + const [treeData, setTreeData] = useState([]); - if (entries.length === 1) { - return { name: formatLabel(name, def.type), children: [] }; + useEffect(() => { + if (templateSchema) { + const data = Object.entries(templateSchema) + .map((entry) => expand(entry[0], entry[1])) + .filter((node) => node); + setTreeData(data); + } else { + setTreeData([]); } + }, [templateSchema]); + + if (treeData.length === 0) return null; - return { name, children: populateChildren(entries) }; - }; + const COLUMN_COUNT = 2; + const midpoint = Math.ceil(treeData.length / COLUMN_COUNT); + const columns = [treeData.slice(0, midpoint), treeData.slice(midpoint)]; return ( -
- {data && ( - { - return ( -
+ {columns.map( + (columnNodes, columnIndex) => + columnNodes.length > 0 && ( + // eslint-disable-next-line react/no-array-index-key + + - {isBranch && } - {element.name} -
- ); - }} - /> + {toTreeItems(columnNodes, `col${columnIndex}`)} + + + ) )} -
+ ); }; diff --git a/src/layouts/email-flow-event-layout.js b/src/layouts/email-flow-event-layout.js index d6e5ac4f1..443280bc1 100644 --- a/src/layouts/email-flow-event-layout.js +++ b/src/layouts/email-flow-event-layout.js @@ -1,4 +1,4 @@ -/** +/* * * Copyright 2020 OpenStack Foundation * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -9,7 +9,7 @@ * 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 { Switch, Route, withRouter } from "react-router-dom"; @@ -20,35 +20,25 @@ import EmailFlowEventListPage from "../pages/email_flow_events/email-flow-events import EditEmailFlowEventPage from "../pages/email_flow_events/edit-email-flow-event-page"; import NoMatchPage from "../pages/no-match-page"; -class EmailFlowEventLayout extends React.Component { - render() { - const { match } = this.props; - return ( -
- - - - - - -
- ); - } -} +const EmailFlowEventLayout = ({ match }) => ( +
+ + + + + + +
+); export default Restrict(withRouter(EmailFlowEventLayout), "email-flow-events"); diff --git a/src/pages/email_flow_events/__tests__/email-flow-events-list-page.test.js b/src/pages/email_flow_events/__tests__/email-flow-events-list-page.test.js new file mode 100644 index 000000000..85ceb7d2a --- /dev/null +++ b/src/pages/email_flow_events/__tests__/email-flow-events-list-page.test.js @@ -0,0 +1,185 @@ +import React from "react"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import { renderWithRedux, createMockSummit } from "../../../utils/test-utils"; +import EmailFlowEventListPage from "../email-flow-events-list-page"; +import { getEmailFlowEvents } from "../../../actions/email-flows-events-actions"; + +jest.mock("../../../actions/email-flows-events-actions", () => ({ + getEmailFlowEvents: jest.fn() +})); + +jest.mock("../../../actions/summit-actions", () => ({ + getSummitById: jest.fn() +})); + +jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ + __esModule: true, + default: ({ data, onEdit, onSort, onPageChange }) => ( +
+ {data.map((row) => ( +
+ {row.flow_name} + +
+ ))} + + +
+ ) +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/mui/search-input", + () => ({ + __esModule: true, + default: ({ onSearch }) => ( + + ) + }) +); + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +const mockHistory = { push: jest.fn() }; + +const emailFlowEvents = [ + { + id: 1, + flow_name: "REGISTRATION", + event_type_name: "Attendee Registered", + email_template_identifier: "REG_TEMPLATE" + }, + { + id: 2, + flow_name: "SPEAKERS", + event_type_name: "Speaker Confirmed", + email_template_identifier: "SPEAKER_TEMPLATE" + } +]; + +const initialState = { + currentSummitState: { currentSummit: createMockSummit() }, + emailFlowEventsListState: { + emailFlowEvents, + order: "email_template_identifier", + orderDir: 1, + totalEmailFlowEvents: 2, + term: null, + currentPage: 1, + lastPage: 1, + perPage: 10 + } +}; + +describe("EmailFlowEventListPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + getEmailFlowEvents.mockReturnValue(() => Promise.resolve()); + }); + + it("loads email flow events on mount using persisted params", () => { + renderWithRedux(, { + initialState + }); + + expect(getEmailFlowEvents).toHaveBeenCalledWith( + null, + 1, + 10, + "email_template_identifier", + 1 + ); + }); + + it("navigates to the edit route", async () => { + renderWithRedux(, { + initialState + }); + + await userEvent.click(screen.getByRole("button", { name: "edit-2" })); + + expect(mockHistory.push).toHaveBeenCalledWith( + `/app/summits/${createMockSummit().id}/email-flow-events/2` + ); + }); + + it("resets to the first page on search", async () => { + renderWithRedux(, { + initialState + }); + + await userEvent.click( + screen.getByRole("button", { name: "search-trigger" }) + ); + + expect(getEmailFlowEvents).toHaveBeenCalledWith( + "newterm", + 1, + 10, + "email_template_identifier", + 1 + ); + }); + + it("keeps the current page on sort", async () => { + renderWithRedux(, { + initialState + }); + + await userEvent.click(screen.getByRole("button", { name: "sort-col" })); + + expect(getEmailFlowEvents).toHaveBeenCalledWith( + null, + 1, + 10, + "flow_name", + -1 + ); + }); + + it("changes page while keeping term/order", async () => { + renderWithRedux(, { + initialState + }); + + await userEvent.click(screen.getByRole("button", { name: "page-2" })); + + expect(getEmailFlowEvents).toHaveBeenCalledWith( + null, + 2, + 10, + "email_template_identifier", + 1 + ); + }); + + it("shows the empty state when there are no email flow events", () => { + renderWithRedux(, { + initialState: { + ...initialState, + emailFlowEventsListState: { + ...initialState.emailFlowEventsListState, + emailFlowEvents: [], + totalEmailFlowEvents: 0 + } + } + }); + + expect( + screen.getByText("email_flow_event_list.no_email_flow_events") + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/email_flow_events/edit-email-flow-event-page.js b/src/pages/email_flow_events/edit-email-flow-event-page.js index 06c270bb2..45488974e 100644 --- a/src/pages/email_flow_events/edit-email-flow-event-page.js +++ b/src/pages/email_flow_events/edit-email-flow-event-page.js @@ -11,10 +11,14 @@ * limitations under the License. * */ -import React from "react"; +import React, { useEffect } from "react"; import { connect } from "react-redux"; import { Breadcrumb } from "react-breadcrumbs"; import T from "i18n-react/dist/i18n-react"; +import { FormikProvider, useFormik } from "formik"; +import * as yup from "yup"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; import EmailFlowEventForm from "../../components/forms/email-flow-event-form"; import { getSummitById } from "../../actions/summit-actions"; import { @@ -22,59 +26,105 @@ import { resetEmailFlowEventForm, saveEmailFlowEvent } from "../../actions/email-flows-events-actions"; -import "../../styles/edit-email-flow-event-page.less"; -import AddNewButton from "../../components/buttons/add-new-button"; +import { truncateText, validateEmail } from "../../utils/methods"; -class EditEmailFlowEventPage extends React.Component { - constructor(props) { - const { match } = props; - const eventId = match.params.event_id; - super(props); +const BREADCRUMB_LENGTH = 40; - props.getEmailFlowEvent(eventId); - } - - componentDidUpdate(prevProps, prevState, snapshot) { - const oldId = prevProps.match.params.event_id; - const newId = this.props.match.params.event_id; +export const buildValues = (entity) => ({ + id: entity?.id ?? 0, + email_template_identifier: entity?.email_template_identifier ?? "", + recipients: (entity?.recipients ?? []).join(",") +}); - if (oldId !== newId) { - if (!newId) { - this.props.resetEmailFlowEventForm(); - } else { - this.props.getEmailFlowEvent(newId); +export const validationSchema = yup.object().shape({ + recipients: yup + .string() + .test("valid-emails", "Invalid email", function validateRecipients(value) { + if (!value) return true; + const emails = value.split(",").map((email) => email.trim()); + const invalidEmail = emails.find((email) => !validateEmail(email)); + if (invalidEmail) { + return this.createError({ + message: `email ${invalidEmail} is not valid` + }); } + return true; + }) +}); + +const EditEmailFlowEventPage = ({ + currentSummit, + entity, + errors, + match, + getEmailFlowEvent, + resetEmailFlowEventForm, + saveEmailFlowEvent +}) => { + const eventId = match.params.event_id; + + useEffect(() => { + if (eventId) { + getEmailFlowEvent(eventId); + } else { + resetEmailFlowEventForm(); + } + }, [eventId]); + + const formik = useFormik({ + initialValues: buildValues(entity), + validationSchema, + onSubmit: (values) => { + const normalizedValues = { + ...values, + recipients: values.recipients + ? values.recipients.split(",").map((email) => email.trim()) + : [] + }; + return saveEmailFlowEvent(normalizedValues); } - } + }); + + useEffect(() => { + formik.resetForm({ values: buildValues(entity) }); + }, [entity.id]); + + useEffect(() => { + const errorFields = Object.keys(errors || {}); + formik.setErrors(errorFields.length > 0 ? errors : {}); + }, [errors]); - render() { - const { currentSummit, entity, errors, match, history } = this.props; - const title = T.translate("general.edit"); - const breadcrumb = entity.id - ? entity.flow_name - : T.translate("general.new"); + const title = T.translate("general.edit"); + const breadcrumb = + truncateText(entity?.event_type_name, BREADCRUMB_LENGTH) || ""; - return ( -
- -

- {title} {entity.flow_name}{" "} - {T.translate("edit_email_flow_event.email_flow_event")} - -

-
- {currentSummit && ( - - )} -
- ); - } -} + return ( +
+ +

+ {title} {entity.flow_name}{" "} + {T.translate("edit_email_flow_event.email_flow_event")} +

+
+ {currentSummit && ( + + + + + + + + + )} +
+ ); +}; const mapStateToProps = ({ currentSummitState, diff --git a/src/pages/email_flow_events/email-flow-events-list-page.js b/src/pages/email_flow_events/email-flow-events-list-page.js index 6c7ac7b2c..aa8b0b91e 100644 --- a/src/pages/email_flow_events/email-flow-events-list-page.js +++ b/src/pages/email_flow_events/email-flow-events-list-page.js @@ -9,147 +9,113 @@ * 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 React, { useEffect } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; -import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search" -import Table from "openstack-uicore-foundation/lib/components/table"; +import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; +import GridToolbar from "../../components/mui/grid-toolbar"; import { getSummitById } from "../../actions/summit-actions"; import { getEmailFlowEvents } from "../../actions/email-flows-events-actions"; -import { Pagination } from "react-bootstrap"; - -class EmailFlowEventListPage extends React.Component { - constructor(props) { - super(props); - this.handleEdit = this.handleEdit.bind(this); - this.handleSort = this.handleSort.bind(this); - this.handlePageChange = this.handlePageChange.bind(this); - this.handleSearch = this.handleSearch.bind(this); - this.state = {}; - } - - handleSearch(term) { - const { order, orderDir, page, perPage } = this.props; - this.props.getEmailFlowEvents(term, page, perPage, order, orderDir); - } - - componentDidMount() { - const { currentSummit, term, order, orderDir, page, perPage } = this.props; +import { DEFAULT_CURRENT_PAGE } from "../../utils/constants"; + +const EmailFlowEventListPage = ({ + currentSummit, + emailFlowEvents, + order, + orderDir, + totalEmailFlowEvents, + currentPage, + perPage, + term, + history, + getEmailFlowEvents +}) => { + useEffect(() => { if (currentSummit) { - this.props.getEmailFlowEvents(term, page, perPage, order, orderDir); + getEmailFlowEvents(term, currentPage, perPage, order, orderDir); } - } + }, [currentSummit]); - handleEdit(event_id) { - const { currentSummit, history } = this.props; + const handleEdit = (row) => { history.push( - `/app/summits/${currentSummit.id}/email-flow-events/${event_id}` - ); - } - - handlePageChange(page) { - const { term, order, orderDir, perPage } = this.props; - this.props.getEmailFlowEvents(term, page, perPage, order, orderDir); - } - - handleSort(index, key, dir, func) { - const { term, page, perPage } = this.props; - this.props.getEmailFlowEvents(term, page, perPage, key, dir); - } - - render() { - const { - currentSummit, - emailFlowEvents, - order, - orderDir, - totalEmailFlowEvents, - lastPage, - currentPage, - term - } = this.props; - - const columns = [ - { - columnKey: "flow_name", - value: T.translate("email_flow_event_list.flow_name"), - sortable: true - }, - { - columnKey: "event_type_name", - value: T.translate("email_flow_event_list.event_type_name"), - title: true - }, - { - columnKey: "email_template_identifier", - value: T.translate("email_flow_event_list.email_template_identifier"), - title: true - } - ]; - - const table_options = { - sortCol: order, - sortDir: orderDir, - actions: { - edit: { onClick: this.handleEdit } - } - }; - - if (!currentSummit.id) return
; - - return ( -
-

- {" "} - {T.translate("email_flow_event_list.email_flow_event_list")} ( - {totalEmailFlowEvents}) -

-
-
- -
-
- - {emailFlowEvents.length === 0 && ( -
{T.translate("email_flow_event_list.no_email_flow_events")}
- )} - - {emailFlowEvents.length > 0 && ( -
- - - - - )} - + `/app/summits/${currentSummit.id}/email-flow-events/${row.id}` ); - } -} + }; + + const handleSearch = (newTerm) => { + getEmailFlowEvents(newTerm, DEFAULT_CURRENT_PAGE, perPage, order, orderDir); + }; + + const handlePageChange = (page) => { + getEmailFlowEvents(term, page, perPage, order, orderDir); + }; + + const handlePerPageChange = (newPerPage) => { + getEmailFlowEvents(term, DEFAULT_CURRENT_PAGE, newPerPage, order, orderDir); + }; + + const handleSort = (key, dir) => { + getEmailFlowEvents(term, DEFAULT_CURRENT_PAGE, perPage, key, dir); + }; + + const columns = [ + { + columnKey: "flow_name", + header: T.translate("email_flow_event_list.flow_name"), + sortable: true + }, + { + columnKey: "event_type_name", + header: T.translate("email_flow_event_list.event_type_name"), + cellSx: { maxWidth: 400 } + }, + { + columnKey: "email_template_identifier", + header: T.translate("email_flow_event_list.email_template_identifier"), + cellSx: { maxWidth: 400 } + } + ]; + + const tableOptions = { sortCol: order, sortDir: orderDir }; + + return ( +
+

+ {T.translate("email_flow_event_list.email_flow_event_list")} ( + {totalEmailFlowEvents}) +

+ + + + {emailFlowEvents.length === 0 && ( +
{T.translate("email_flow_event_list.no_email_flow_events")}
+ )} + + {emailFlowEvents.length > 0 && ( + + )} +
+ ); +}; const mapStateToProps = ({ currentSummitState, emailFlowEventsListState }) => ({ currentSummit: currentSummitState.currentSummit, diff --git a/src/pages/email_flow_events/email-flow-events-settings-page.js b/src/pages/email_flow_events/email-flow-events-settings-page.js index 18951af80..31bd0f62c 100644 --- a/src/pages/email_flow_events/email-flow-events-settings-page.js +++ b/src/pages/email_flow_events/email-flow-events-settings-page.js @@ -1,4 +1,4 @@ -/** +/* * * Copyright 2020 OpenStack Foundation * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -9,9 +9,9 @@ * 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 React, { useEffect } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import EmailFlowEventSettingsForm from "../../components/forms/email-flow-event-settings-form"; @@ -21,36 +21,35 @@ import { saveMarketingEmailSettings } from "../../actions/email-actions"; import { deleteSetting } from "../../actions/marketing-actions"; -import "../../styles/edit-email-flow-event-page.less"; -class EmailFlowEventSettingsPage extends React.Component { - constructor(props) { - super(props); - - props.getMarketingEmailSettings(); - } - - render() { - const { currentSummit, email_marketing_settings, errors, match, history } = - this.props; +const EmailFlowEventSettingsPage = ({ + currentSummit, + email_marketing_settings, + errors, + getMarketingEmailSettings, + saveMarketingEmailSettings, + deleteSetting +}) => { + useEffect(() => { + getMarketingEmailSettings(); + }, []); - return ( -
-

{T.translate("email_flow_events_settings.email_settings")}

-
- {currentSummit && ( - - )} -
- ); - } -} + return ( +
+

{T.translate("email_flow_events_settings.email_settings")}

+
+ {currentSummit && ( + + )} +
+ ); +}; const mapStateToProps = ({ currentSummitState, diff --git a/src/pages/promocodes/promocode-list-page.js b/src/pages/promocodes/promocode-list-page.js index d34c473ec..98503f2f7 100644 --- a/src/pages/promocodes/promocode-list-page.js +++ b/src/pages/promocodes/promocode-list-page.js @@ -16,10 +16,10 @@ import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import Swal from "sweetalert2"; import { Pagination } from "react-bootstrap"; -import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search" -import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown" -import MemberInput from "openstack-uicore-foundation/lib/components/inputs/member-input" -import Table from "openstack-uicore-foundation/lib/components/table" +import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search"; +import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; +import MemberInput from "openstack-uicore-foundation/lib/components/inputs/member-input"; +import Table from "openstack-uicore-foundation/lib/components/table"; import TagInput from "openstack-uicore-foundation/lib/components/inputs/tag-input"; import { getSummitById } from "../../actions/summit-actions"; import { @@ -35,7 +35,7 @@ import { TRIM_TEXT_LENGTH_50, TRIM_TEXT_LENGTH_40 } from "../../utils/constants"; -import { trim } from "../../utils/methods"; +import { truncateText } from "../../utils/methods"; const fieldNames = [ { columnKey: "class_name", value: "type" }, @@ -393,10 +393,14 @@ class PromocodeListPage extends React.Component { ...p, owner_email: ( - {trim(p?.owner_email, TRIM_TEXT_LENGTH_40)} + {truncateText(p?.owner_email, TRIM_TEXT_LENGTH_40)} ), - owner: {trim(p?.owner, TRIM_TEXT_LENGTH_40)} + owner: ( + + {truncateText(p?.owner, TRIM_TEXT_LENGTH_40)} + + ) })); return ( diff --git a/src/pages/registration/registration-stats-page.js b/src/pages/registration/registration-stats-page.js index aad41d6a4..e37216009 100644 --- a/src/pages/registration/registration-stats-page.js +++ b/src/pages/registration/registration-stats-page.js @@ -9,29 +9,30 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - **/ + * */ import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react"; -import { trim } from "../../utils/methods"; -import { formatCurrency } from "../../helpers/formatCurrency"; import { Breadcrumb } from "react-breadcrumbs"; +import AjaxLoader from "openstack-uicore-foundation/lib/components/ajaxloader"; +import SteppedSelect from "openstack-uicore-foundation/lib/components/inputs/stepped-select"; +import { truncateText } from "../../utils/methods"; +import { formatCurrency } from "../../helpers/formatCurrency"; import DateIntervalFilter from "../../components/filters/date-interval-filter"; import { getRegistrationData, changeTimeUnit } from "../../actions/summit-stats-actions"; import PieGraph from "../../components/graphs/registration-pie-graph"; -import AjaxLoader from "openstack-uicore-foundation/lib/components/ajaxloader" -import SteppedSelect from "openstack-uicore-foundation/lib/components/inputs/stepped-select"; import LineGraph from "../../components/graphs/registration-line-graph"; +import { BPS } from "../../utils/constants"; const DATA_POOLING_INTERVAL = 20000; -const trimString = (str, length = 75) => { - return trim(str.replace(/ *\([^)]*\) */g, ""), length); -}; +const DEFAULT_TRIM_LENGTH = 75; + +const trimString = (str, length = DEFAULT_TRIM_LENGTH) => truncateText(str.replace(/ *\([^)]*\) */g, ""), length); const RegistrationStatsPage = ({ currentSummit, match, loading, ...props }) => { const [fromDate, setFromDate] = useState(null); @@ -203,7 +204,7 @@ const RegistrationStatsPage = ({ currentSummit, match, loading, ...props }) => { }))} labels={sortedTicketTypes.map((tt) => { const percent = Math.round( - (tt.sold_qty / totalTicketsSold) * 100 + (tt.sold_qty / totalTicketsSold) * BPS ); return `${trimString(tt.type)}: ${percent}%`; })} @@ -227,7 +228,7 @@ const RegistrationStatsPage = ({ currentSummit, match, loading, ...props }) => { }))} labels={sortedTicketPerBadgeTypes.map((tt) => { const percent = Math.round( - (tt.badges_qty / totalTicketsSold) * 100 + (tt.badges_qty / totalTicketsSold) * BPS ); return `${trimString(tt.type)}: ${percent}%`; })} @@ -251,7 +252,7 @@ const RegistrationStatsPage = ({ currentSummit, match, loading, ...props }) => { }))} labels={sortedTicketsPerBadgeFeature.map((tt) => { const percent = Math.round( - (tt.sold_qty / totalTicketsSoldWBadgeFeature) * 100 + (tt.sold_qty / totalTicketsSoldWBadgeFeature) * BPS ); return `${trimString(tt.type)}: ${percent}%`; })} @@ -278,7 +279,7 @@ const RegistrationStatsPage = ({ currentSummit, match, loading, ...props }) => { }))} labels={sortedTicketTypes.map((tt) => { const percent = Math.round( - (tt.checkin_qty / totalTicketsCheckedIn) * 100 + (tt.checkin_qty / totalTicketsCheckedIn) * BPS ); return `${trimString(tt.type)}: ${percent}%`; })} @@ -300,7 +301,7 @@ const RegistrationStatsPage = ({ currentSummit, match, loading, ...props }) => { }))} labels={sortedTicketPerBadgeTypes.map((tt) => { const percent = Math.round( - (tt.checkin_qty / totalTicketsCheckedIn) * 100 + (tt.checkin_qty / totalTicketsCheckedIn) * BPS ); return `${trimString(tt.type)}: ${percent}%`; })} @@ -322,7 +323,7 @@ const RegistrationStatsPage = ({ currentSummit, match, loading, ...props }) => { }))} labels={sortedTicketsPerBadgeFeature.map((tt) => { const percent = Math.round( - (tt.checkin_qty / totalTicketsCheckedInWBadgeFeature) * 100 + (tt.checkin_qty / totalTicketsCheckedInWBadgeFeature) * BPS ); return `${trimString(tt.type)}: ${percent}%`; })} diff --git a/src/reducers/email_flow_events/email-flow-events-list-reducer.js b/src/reducers/email_flow_events/email-flow-events-list-reducer.js index ff529c4a3..d60fba50e 100644 --- a/src/reducers/email_flow_events/email-flow-events-list-reducer.js +++ b/src/reducers/email_flow_events/email-flow-events-list-reducer.js @@ -9,25 +9,25 @@ * 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 { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; import { RECEIVE_EMAIL_FLOW_EVENTS, REQUEST_EMAIL_FLOW_EVENTS } from "../../actions/email-flows-events-actions"; - import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; -import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions"; +import { DEFAULT_PER_PAGE } from "../../utils/constants"; const DEFAULT_STATE = { emailFlowEvents: [], order: "email_template_identifier", orderDir: 1, totalEmailFlowEvents: 0, - term: null, + term: "", currentPage: 1, lastPage: 1, - perPage: 10 + perPage: DEFAULT_PER_PAGE }; const emailFlowEventsListReducer = (state = DEFAULT_STATE, action) => { @@ -38,18 +38,22 @@ const emailFlowEventsListReducer = (state = DEFAULT_STATE, action) => { return DEFAULT_STATE; } case REQUEST_EMAIL_FLOW_EVENTS: { - let { order, orderDir, term } = payload; + const { order, orderDir, term } = payload; return { ...state, order, orderDir, term }; } case RECEIVE_EMAIL_FLOW_EVENTS: { - let { current_page, total, last_page } = payload.response; - let emailFlowEvents = payload.response.data; + const { + current_page: currentPage, + total, + last_page: lastPage + } = payload.response; + return { ...state, - emailFlowEvents: emailFlowEvents, + emailFlowEvents: payload.response.data, totalEmailFlowEvents: total, - currentPage: current_page, - lastPage: last_page + currentPage, + lastPage }; } default: diff --git a/src/styles/edit-email-flow-event-page.less b/src/styles/edit-email-flow-event-page.less deleted file mode 100644 index 9c5dc0677..000000000 --- a/src/styles/edit-email-flow-event-page.less +++ /dev/null @@ -1,25 +0,0 @@ -.tree { - list-style: none; - margin: 0; - padding: 5px; -} - -.tree-node, -.tree-node-group { - list-style: none; - margin: 0; - padding: 0; -} - -.tree-branch-wrapper, -.tree-node__leaf { - outline: none; -} - -.tree-node__branch { - display: block; -} - -.tree-node { - cursor: pointer; -} diff --git a/src/styles/general.less b/src/styles/general.less index cc0256ffe..8111563af 100644 --- a/src/styles/general.less +++ b/src/styles/general.less @@ -159,12 +159,6 @@ h3 { font-weight: bold; } -.email-flow-table-wrapper { - > div:first-child { - overflow-y: auto; - } -} - .refund-input-wrapper { input { padding-right: 25px; diff --git a/src/utils/methods.js b/src/utils/methods.js index 6c1620847..255806e06 100644 --- a/src/utils/methods.js +++ b/src/utils/methods.js @@ -47,7 +47,7 @@ import { const DAY_IN_SECONDS = 86400; // 86400 seconds per day const ELLIPSIS = 3; -export const trim = (string, length) => +export const truncateText = (string, length) => string?.length > length ? `${string.substring(0, length - ELLIPSIS)}...` : string; diff --git a/yarn.lock b/yarn.lock index a73dddca0..dafb94b27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2099,6 +2099,19 @@ "@babel/runtime" "^7.25.7" "@mui/utils" "^5.16.6 || ^6.0.0 || ^7.0.0" +"@mui/x-tree-view@^7.26.0": + version "7.29.10" + resolved "https://registry.yarnpkg.com/@mui/x-tree-view/-/x-tree-view-7.29.10.tgz#aa41547f3cf52a447195a8a2777eca158c630032" + integrity sha512-/ZcM582yIaQN2PmadIlQYRJzc3yXV7bh463J4GHtTmFw+PEjzUfzETBWe3VxmU3EPgIFzVQPjqAAJwylmQSJOg== + dependencies: + "@babel/runtime" "^7.25.7" + "@mui/utils" "^5.16.6 || ^6.0.0 || ^7.0.0" + "@mui/x-internals" "7.29.0" + "@types/react-transition-group" "^4.4.11" + clsx "^2.1.1" + prop-types "^15.8.1" + react-transition-group "^4.4.5" + "@napi-rs/wasm-runtime@^0.2.11": version "0.2.12" resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" @@ -9856,11 +9869,6 @@ raw-body@~2.5.3: iconv-lite "~0.4.24" unpipe "~1.0.0" -react-accessible-treeview@^2.6.2: - version "2.11.2" - resolved "https://registry.yarnpkg.com/react-accessible-treeview/-/react-accessible-treeview-2.11.2.tgz#1113b269ded9dcea7773f629ff97ba98f8192cb0" - integrity sha512-qui0g/gBDpP7VbtqelgJezAzAjKOY3IVi1Rq1NRJ7Z627RXKyKiQ4ooxLK2yauxTvNyU0ke9S0a2d9YUMbJJbA== - react-beautiful-dnd@^13.1.1: version "13.1.1" resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz#b0f3087a5840920abf8bb2325f1ffa46d8c4d0a2"