diff --git a/package.json b/package.json index 5f100d02f..39b51ea74 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@fontsource/roboto": "^5.1.1", "@mui/icons-material": "^6.4.3", "@mui/material": "^6.4.3", + "@mui/x-data-grid": "^7.29.13", "@mui/x-date-pickers": "^7.26.0", "@react-pdf/renderer": "^4.4.1", "@sentry/react": "^8.32.0", diff --git a/src/actions/event-actions.js b/src/actions/event-actions.js index e0ea6c8c8..321296cb3 100644 --- a/src/actions/event-actions.js +++ b/src/actions/event-actions.js @@ -81,6 +81,7 @@ export const FLAG_CHANGED = "FLAG_CHANGED"; export const REQUEST_EVENT_COMMENTS = "REQUEST_EVENT_COMMENTS"; export const RECEIVE_EVENT_COMMENTS = "RECEIVE_EVENT_COMMENTS"; export const CHANGE_SEARCH_TERM = "CHANGE_SEARCH_TERM"; +export const CHANGE_EXTRA_COLUMNS = "CHANGE_EXTRA_COLUMNS"; export const SUBMISSION_PERIOD_REOPENED = "SUBMISSION_PERIOD_REOPENED"; export const SUBMISSION_PERIOD_CLOSED = "SUBMISSION_PERIOD_CLOSED"; export const SUBMISSION_REOPEN_NOTIFIED = "SUBMISSION_REOPEN_NOTIFIED"; @@ -1529,3 +1530,7 @@ export const queryAllCompanies = debounce(async (input, callback) => { export const changeEventListSearchTerm = (term) => (dispatch) => { dispatch(createAction(CHANGE_SEARCH_TERM)({ term })); }; + +export const changeEventListExtraColumns = (extraColumns) => (dispatch) => { + dispatch(createAction(CHANGE_EXTRA_COLUMNS)({ extraColumns })); +}; diff --git a/src/actions/filter-criteria-actions.js b/src/actions/filter-criteria-actions.js index d66cf1297..627d27168 100644 --- a/src/actions/filter-criteria-actions.js +++ b/src/actions/filter-criteria-actions.js @@ -25,7 +25,7 @@ import { } from "openstack-uicore-foundation/lib/utils/actions"; import URI from "urijs"; import Swal from "sweetalert2"; -import debounce from "lodash/debounce" +import debounce from "lodash/debounce"; import { getAccessTokenSafely } from "../utils/methods"; import { @@ -87,8 +87,9 @@ export const saveFilterCriteria = (filterCriteria) => async (dispatch) => { `${window.PERSIST_FILTER_CRITERIA_API}/api/v1/filter-criterias`, filterCriteria, customErrorHandler - )(params)(dispatch).then(() => { + )(params)(dispatch).then((payload) => { dispatch(stopLoading()); + return payload; }); }; diff --git a/src/components/filters/saved-views/__tests__/saved-views.test.js b/src/components/filters/saved-views/__tests__/saved-views.test.js new file mode 100644 index 000000000..bde57eee3 --- /dev/null +++ b/src/components/filters/saved-views/__tests__/saved-views.test.js @@ -0,0 +1,237 @@ +import React from "react"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import SavedViews from ".."; +import showConfirmDialog from "../../../mui/showConfirmDialog"; +import { queryFilterCriterias } from "../../../../actions/filter-criteria-actions"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../../../mui/showConfirmDialog", () => jest.fn()); + +jest.mock("../../../../actions/filter-criteria-actions", () => ({ + queryFilterCriterias: jest.fn() +})); + +const VIEWS = [ + { id: 1, name: "Open Activities", visibility: "Everyone", criteria: [] }, + { id: 2, name: "Recently Modified", visibility: "Me", criteria: [] } +]; + +const renderViews = (props = {}) => + render( + Promise.resolve())} + onDelete={jest.fn(() => Promise.resolve())} + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + /> + ); + +const openMenu = (label = "saved_views.title") => + userEvent.click(screen.getByRole("button", { name: label })); + +describe("SavedViews", () => { + beforeEach(() => { + jest.clearAllMocks(); + queryFilterCriterias.mockImplementation((summitId, context, input, cb) => + cb(VIEWS.filter((v) => v.name.includes(input))) + ); + }); + + test("lists the show's views for this context and applies the one picked", async () => { + const onChange = jest.fn(); + renderViews({ onChange }); + + await openMenu(); + + expect(queryFilterCriterias).toHaveBeenCalledWith( + 12, + "Activities", + "", + expect.any(Function) + ); + await userEvent.click( + screen.getByRole("menuitem", { name: /Recently Modified/ }) + ); + + expect(onChange).toHaveBeenCalledWith(VIEWS[1]); + }); + + test("says there are no saved views only once they've loaded empty", async () => { + let resolveViews; + queryFilterCriterias.mockImplementation((summitId, context, input, cb) => { + resolveViews = cb; + }); + renderViews(); + + await openMenu(); + expect( + screen.queryByRole("menuitem", { name: "saved_views.no_views" }) + ).not.toBeInTheDocument(); + + act(() => resolveViews([])); + expect( + screen.getByRole("menuitem", { name: "saved_views.no_views" }) + ).toBeInTheDocument(); + }); + + test("searches views by name", async () => { + renderViews(); + + await openMenu(); + await userEvent.type( + screen.getByRole("textbox", { name: "general.search" }), + "Open" + ); + + expect(queryFilterCriterias).toHaveBeenLastCalledWith( + 12, + "Activities", + "Open", + expect.any(Function) + ); + }); + + test("deletes a view after confirming, without applying it", async () => { + const onChange = jest.fn(); + const onDelete = jest.fn(() => Promise.resolve()); + showConfirmDialog.mockResolvedValueOnce(true); + renderViews({ onChange, onDelete }); + + await openMenu(); + await userEvent.click( + screen.getByRole("button", { name: "general.delete Open Activities" }) + ); + + await waitFor(() => expect(onDelete).toHaveBeenCalledWith(1)); + await waitFor(() => + expect( + screen.queryByRole("menuitem", { name: /Open Activities/ }) + ).not.toBeInTheDocument() + ); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("keeps the view when the delete is cancelled", async () => { + const onDelete = jest.fn(); + showConfirmDialog.mockResolvedValueOnce(false); + renderViews({ onDelete }); + + await openMenu(); + await userEvent.click( + screen.getByRole("button", { name: "general.delete Open Activities" }) + ); + + await waitFor(() => expect(showConfirmDialog).toHaveBeenCalled()); + expect(onDelete).not.toHaveBeenCalled(); + }); + + test("offers clearing only while a view is active", async () => { + const onChange = jest.fn(); + const { rerender } = renderViews({ onChange }); + + await openMenu(); + expect( + screen.queryByRole("menuitem", { name: "saved_views.clear" }) + ).not.toBeInTheDocument(); + await userEvent.keyboard("{Escape}"); + + rerender( + + ); + await openMenu("Open Activities"); + await userEvent.click( + screen.getByRole("menuitem", { name: "saved_views.clear" }) + ); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + test("requires a name and visibility before saving", async () => { + const onSave = jest.fn(() => Promise.resolve()); + renderViews({ onSave }); + + await openMenu(); + await userEvent.click( + screen.getByRole("menuitem", { name: "saved_views.save_current" }) + ); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + expect(await screen.findAllByText("validation.required")).toHaveLength(2); + expect(onSave).not.toHaveBeenCalled(); + }); + + test("starts with an empty name and closes once the save resolves", async () => { + const onSave = jest.fn(() => Promise.resolve()); + renderViews({ onSave, selectedView: VIEWS[0] }); + + await openMenu("Open Activities"); + await userEvent.click( + screen.getByRole("menuitem", { name: "saved_views.save_current" }) + ); + + const name = screen.getByRole("textbox", { name: "general.name" }); + expect(name).toHaveValue(""); + await userEvent.type(name, "Open Keynotes"); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + expect(onSave).toHaveBeenCalledWith({ + name: "Open Keynotes", + visibility: "Everyone" + }); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + ); + }); + + test("closes the dialog when the save fails so the error isn't covered", async () => { + let rejectSave; + const onSave = jest.fn( + () => + new Promise((resolve, reject) => { + rejectSave = reject; + }) + ); + renderViews({ onSave }); + + await openMenu(); + await userEvent.click( + screen.getByRole("menuitem", { name: "saved_views.save_current" }) + ); + await userEvent.type( + screen.getByRole("textbox", { name: "general.name" }), + "Mine" + ); + await userEvent.click( + screen.getByRole("radio", { name: "save_filter_criteria.me" }) + ); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + + await waitFor(() => + expect( + screen.getByRole("button", { name: "general.save" }) + ).toBeDisabled() + ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + await act(async () => rejectSave(new Error("nope"))); + + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + ); + }); +}); diff --git a/src/components/filters/saved-views/components/save-view-dialog/index.js b/src/components/filters/saved-views/components/save-view-dialog/index.js new file mode 100644 index 000000000..8de22167f --- /dev/null +++ b/src/components/filters/saved-views/components/save-view-dialog/index.js @@ -0,0 +1,114 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed 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 PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { FormikProvider, useFormik } from "formik"; +import * as yup from "yup"; +import { Box } from "@mui/material"; +import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; +import MuiFormikRadioGroup from "openstack-uicore-foundation/lib/components/mui/formik-inputs/radio-group"; +import CustomDialog from "openstack-uicore-foundation/lib/components/mui/custom-dialog"; +import { requiredStringValidation } from "../../../../../utils/yup"; +import { + VISIBILITY_OPTION_EVERYONE, + VISIBILITY_OPTION_ME +} from "../../../../../utils/filter-criteria-constants"; + +const SaveViewDialog = ({ selectedView, onSave, onClose }) => { + const formik = useFormik({ + initialValues: { + name: "", + visibility: selectedView?.visibility ?? "" + }, + validationSchema: yup.object({ + name: requiredStringValidation().trim(), + visibility: requiredStringValidation() + }), + onSubmit: (values) => + onSave(values) + .finally(() => onClose()) + .catch(() => {}) + }); + + const handleClose = () => { + if (formik.isSubmitting) return; + onClose(); + }; + + return ( + + + {/* keeps Enter in the name field submitting */} + + + + + + + ); +}; + +SaveViewDialog.propTypes = { + selectedView: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + visibility: PropTypes.string + }), + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +}; + +SaveViewDialog.defaultProps = { + selectedView: null +}; + +export default SaveViewDialog; diff --git a/src/components/filters/saved-views/index.js b/src/components/filters/saved-views/index.js new file mode 100644 index 000000000..c0b0f3df9 --- /dev/null +++ b/src/components/filters/saved-views/index.js @@ -0,0 +1,191 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed 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 } from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { + Box, + Button, + Divider, + IconButton, + ListItemText, + Menu, + MenuItem, + TextField +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import DeleteIcon from "@mui/icons-material/Delete"; +import showConfirmDialog from "../../mui/showConfirmDialog"; +import { queryFilterCriterias } from "../../../actions/filter-criteria-actions"; +import SaveViewDialog from "./components/save-view-dialog"; + +// Saved filter criteria as a toolbar dropdown: pick a view to apply it, +// delete one, clear the active one, or save the current filters as a view. +const SavedViews = ({ + summitId, + context, + selectedView, + onChange, + onSave, + onDelete +}) => { + const [anchorEl, setAnchorEl] = useState(null); + // null until the views load, so "No saved views" isn't shown while loading + const [views, setViews] = useState(null); + const [search, setSearch] = useState(""); + const [showSaveDialog, setShowSaveDialog] = useState(false); + + const loadViews = (input) => + queryFilterCriterias(summitId, context, input, setViews); + + const handleOpen = (ev) => { + setAnchorEl(ev.currentTarget); + setSearch(""); + loadViews(""); + }; + + const handleClose = () => setAnchorEl(null); + + const handleSearch = (ev) => { + setSearch(ev.target.value); + loadViews(ev.target.value); + }; + + const handleSelect = (view) => { + onChange(view); + handleClose(); + }; + + const handleDelete = async (ev, view) => { + ev.stopPropagation(); + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: `${T.translate( + "select_filter_criteria.remove_filter_criteria_warning" + )}"${view.name}"`, + iconType: "warning", + confirmButtonColor: "error", + confirmButtonText: T.translate("general.yes_delete") + }); + + if (!isConfirmed) return; + + Promise.resolve(onDelete(view.id)) + .then(() => + setViews((current) => current.filter((v) => v.id !== view.id)) + ) + .catch(() => {}); + }; + + const handleOpenSaveDialog = () => { + handleClose(); + setShowSaveDialog(true); + }; + + return ( + <> + + + + + !(ev.key === "Escape" || ev.key === "Tab") && ev.stopPropagation() + } + slotProps={{ + htmlInput: { "aria-label": T.translate("general.search") } + }} + /> + + {views?.length === 0 && ( + {T.translate("saved_views.no_views")} + )} + {(views ?? []).map((view) => ( + handleSelect(view)} + > + + handleDelete(ev, view)} + > + + + + ))} + + {selectedView && ( + handleSelect(null)}> + {T.translate("saved_views.clear")} + + )} + + + {T.translate("saved_views.save_current")} + + + {showSaveDialog && ( + setShowSaveDialog(false)} + /> + )} + + ); +}; + +SavedViews.propTypes = { + summitId: PropTypes.number.isRequired, + context: PropTypes.string.isRequired, + selectedView: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + visibility: PropTypes.string + }), + onChange: PropTypes.func.isRequired, + // must return a promise; the save dialog closes once it resolves + onSave: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired +}; + +SavedViews.defaultProps = { + selectedView: null +}; + +export default SavedViews; diff --git a/src/components/mui/__tests__/bulk-edit-data-grid.test.js b/src/components/mui/__tests__/bulk-edit-data-grid.test.js new file mode 100644 index 000000000..c89aa0625 --- /dev/null +++ b/src/components/mui/__tests__/bulk-edit-data-grid.test.js @@ -0,0 +1,325 @@ +import React from "react"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import BulkEditDataGrid from "../bulk-edit-data-grid"; +import showConfirmDialog from "../showConfirmDialog"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock("../showConfirmDialog", () => jest.fn()); + +const columns = [ + { columnKey: "id", label: "Id", sortable: true }, + { columnKey: "title", label: "Title", sortable: true, editableField: true } +]; + +const data = [ + { id: 1, title: "First" }, + { id: 2, title: "Second" } +]; + +const renderGrid = (props = {}) => + render( + Promise.resolve())} + totalRows={30} + perPage={10} + currentPage={1} + onPageChange={jest.fn()} + onPerPageChange={jest.fn()} + getName={(row) => row.title} + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + /> + ); + +const selectRow = (rowName) => + userEvent.click( + within(screen.getByRole("row", { name: new RegExp(rowName) })).getByRole( + "checkbox" + ) + ); + +describe("BulkEditDataGrid", () => { + test("applies inline edits of the selected rows through onUpdate", async () => { + const onUpdate = jest.fn(() => Promise.resolve()); + renderGrid({ onUpdate }); + + await selectRow("Second"); + await userEvent.click( + screen.getByRole("button", { name: "general.edit_selected (1)" }) + ); + + const titleInput = screen.getByDisplayValue("Second"); + await userEvent.clear(titleInput); + await userEvent.type(titleInput, "Renamed"); + await userEvent.click( + screen.getByRole("button", { name: "bulk_edit_table.apply_changes" }) + ); + + expect(onUpdate).toHaveBeenCalledWith([{ id: 2, title: "Renamed" }]); + await waitFor(() => + expect( + screen.getByRole("button", { name: "general.edit_selected" }) + ).toBeDisabled() + ); + }); + + test("leaves edit mode when the rows shown change", async () => { + const { rerender } = renderGrid(); + + await selectRow("First"); + await userEvent.click( + screen.getByRole("button", { name: "general.edit_selected (1)" }) + ); + expect( + screen.getByRole("button", { name: "bulk_edit_table.apply_changes" }) + ).toBeInTheDocument(); + + rerender( + + ); + + expect( + screen.queryByRole("button", { name: "bulk_edit_table.apply_changes" }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "general.edit_selected" }) + ).toBeDisabled(); + }); + + test("column menu offers hiding a column but not managing columns", async () => { + renderGrid(); + + // the header menu button stays visually hidden until the header is + // hovered, so find it by its aria-label rather than by role + await userEvent.click(screen.getByLabelText("Title column menu")); + + expect( + await screen.findByRole("menuitem", { name: /hide column/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /manage columns/i }) + ).not.toBeInTheDocument(); + }); + + test("column menu of a column that can't be hidden has no empty hide section", async () => { + renderGrid({ + columns: [ + { columnKey: "id", label: "Id", sortable: true, hideable: false }, + { columnKey: "title", label: "Title", sortable: true } + ] + }); + + await userEvent.click(screen.getByLabelText("Id column menu")); + + // Id is the current (ascending) order, so its menu offers descending + expect( + await screen.findByRole("menuitem", { name: /sort by desc/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /hide column/i }) + ).not.toBeInTheDocument(); + // no divider left dangling under the sort items + expect( + within(screen.getByRole("menu")).queryAllByRole("separator") + ).toHaveLength(0); + }); + + test("clicking the columns button again closes the columns panel", async () => { + renderGrid(); + const columnsButton = screen.getByRole("button", { + name: "Select columns" + }); + + await userEvent.click(columnsButton); + expect( + await screen.findByRole("checkbox", { name: "Title" }) + ).toBeInTheDocument(); + + await userEvent.click(columnsButton); + await waitFor(() => + expect( + screen.queryByRole("checkbox", { name: "Title" }) + ).not.toBeInTheDocument() + ); + }); + + test("columns panel does not list the actions column", async () => { + renderGrid({ onEdit: jest.fn() }); + + await userEvent.click( + screen.getByRole("button", { name: "Select columns" }) + ); + + expect( + await screen.findByRole("checkbox", { name: "Title" }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { name: "general.actions" }) + ).not.toBeInTheDocument(); + }); + + test("unsorting goes back to the default order", async () => { + const onSort = jest.fn(); + renderGrid({ onSort, options: { sortCol: "title", sortDir: 1 } }); + + await userEvent.click(screen.getByRole("columnheader", { name: "Title" })); + expect(onSort).toHaveBeenLastCalledWith(1, "title", -1); + + await userEvent.click(screen.getByLabelText("Title column menu")); + await userEvent.click( + await screen.findByRole("menuitem", { name: /unsort/i }) + ); + + expect(onSort).toHaveBeenLastCalledWith(-1, null, null); + }); + + test("both paginations page the list", async () => { + const onPageChange = jest.fn(); + renderGrid({ onPageChange }); + + const [topNext, bottomNext] = screen.getAllByRole("button", { + name: "mui_table.next_page" + }); + await userEvent.click(topNext); + await userEvent.click(bottomNext); + + expect(onPageChange).toHaveBeenCalledTimes(2); + expect(onPageChange).toHaveBeenNthCalledWith(2, 2); + }); + + test("shows a remembered order on its column", () => { + renderGrid({ options: { sortCol: "title", sortDir: -1 } }); + + expect(screen.getByRole("columnheader", { name: "Title" })).toHaveAttribute( + "aria-sort", + "descending" + ); + }); + + test("reports header sorting as column index, key and direction", async () => { + const onSort = jest.fn(); + renderGrid({ onSort }); + + await userEvent.click(screen.getByRole("columnheader", { name: "Title" })); + + expect(onSort).toHaveBeenCalledWith(1, "title", 1); + }); + + test("searches only when Enter is pressed, not while typing", async () => { + const onSearch = jest.fn(); + renderGrid({ searchProps: { term: "", onSearch } }); + + await userEvent.click( + screen.getByRole("button", { name: "general.search" }) + ); + // opening the search focuses its input + await userEvent.keyboard("keynote"); + expect(onSearch).not.toHaveBeenCalled(); + + await userEvent.keyboard("{Enter}"); + expect(onSearch).toHaveBeenCalledWith("keynote"); + }); + + test("keeps the search box open while a search term is applied, even when cleared", async () => { + renderGrid({ searchProps: { term: "keynote", onSearch: jest.fn() } }); + + const searchbox = screen.getByRole("textbox"); + expect(searchbox).toHaveValue("keynote"); + + await userEvent.clear(searchbox); + await userEvent.tab(); + + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + test("collapses the search box when left empty with no term applied", async () => { + renderGrid({ searchProps: { term: "", onSearch: jest.fn() } }); + + await userEvent.click( + screen.getByRole("button", { name: "general.search" }) + ); + await userEvent.tab(); + + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "general.search" }) + ).toBeInTheDocument(); + }); + + test("adds from the add button and lists every import option under the import icon", async () => { + const onAdd = jest.fn(); + const onMuxImport = jest.fn(); + renderGrid({ + onAdd, + addLabel: "add activity", + importLabel: "import", + importItems: [ + { label: "import from mux", onClick: onMuxImport }, + { label: "import csv", onClick: jest.fn() } + ] + }); + + await userEvent.click(screen.getByRole("button", { name: "add activity" })); + expect(onAdd).toHaveBeenCalledTimes(1); + + await userEvent.click(screen.getByRole("button", { name: "import" })); + expect( + screen.getByRole("menuitem", { name: "import csv" }) + ).toBeInTheDocument(); + await userEvent.click( + screen.getByRole("menuitem", { name: "import from mux" }) + ); + expect(onMuxImport).toHaveBeenCalledTimes(1); + }); + + const clickDelete = (rowName) => + userEvent.click( + within(screen.getByRole("row", { name: new RegExp(rowName) })).getByRole( + "menuitem", + { name: "general.delete" } + ) + ); + + test("deletes a row after the confirm dialog is accepted", async () => { + const onDelete = jest.fn(); + showConfirmDialog.mockResolvedValueOnce(true); + renderGrid({ onDelete, deleteDialogBody: (name) => `delete ${name}?` }); + + await clickDelete("First"); + + expect(showConfirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ text: "delete First?" }) + ); + await waitFor(() => expect(onDelete).toHaveBeenCalledWith(1)); + }); + + test("keeps the row when the confirm dialog is cancelled", async () => { + const onDelete = jest.fn(); + showConfirmDialog.mockResolvedValueOnce(false); + renderGrid({ onDelete }); + + await clickDelete("First"); + + await waitFor(() => expect(showConfirmDialog).toHaveBeenCalled()); + expect(onDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/mui/bulk-edit-data-grid/components/column-menu/index.js b/src/components/mui/bulk-edit-data-grid/components/column-menu/index.js new file mode 100644 index 000000000..f3be1ecbc --- /dev/null +++ b/src/components/mui/bulk-edit-data-grid/components/column-menu/index.js @@ -0,0 +1,39 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed 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 PropTypes from "prop-types"; +import { GridColumnMenu, GridColumnMenuHideItem } from "@mui/x-data-grid"; + +// the column menu's columns item is "Hide column" + "Manage columns"; keep +// only hide (the toolbar has the columns panel), and drop the item entirely +// for columns that can't be hidden so their menu has no empty section +const ColumnMenu = (props) => { + const { colDef } = props; + return ( + + ); +}; + +ColumnMenu.propTypes = { + colDef: PropTypes.shape({ hideable: PropTypes.bool }).isRequired +}; + +export default ColumnMenu; diff --git a/src/components/mui/bulk-edit-data-grid/components/columns-button/index.js b/src/components/mui/bulk-edit-data-grid/components/columns-button/index.js new file mode 100644 index 000000000..2a62e039e --- /dev/null +++ b/src/components/mui/bulk-edit-data-grid/components/columns-button/index.js @@ -0,0 +1,64 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed 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, { useRef } from "react"; +import PropTypes from "prop-types"; +import { + GridPreferencePanelsValue, + GridToolbarColumnsButton, + gridPreferencePanelStateSelector, + useGridApiContext, + useGridSelector +} from "@mui/x-data-grid"; +import { ICON_ONLY_BUTTON_SX } from "../../constants"; + +// clicking the button while its panel is open should close it, but React 16 +// runs the button's handlers on document, next to the panel's click-away +// listener, so the button can't stop it: the panel closes on pointerup and the +// button's click reopens it. Remember whether it was open when the press +// started and close it again after the click. +const ColumnsButton = ({ buttonRef }) => { + const apiRef = useGridApiContext(); + const preferencePanel = useGridSelector( + apiRef, + gridPreferencePanelStateSelector + ); + const wasOpenRef = useRef(false); + + return ( + { + wasOpenRef.current = + preferencePanel.open && + preferencePanel.openedPanelValue === + GridPreferencePanelsValue.columns; + }, + onClick: () => { + if (wasOpenRef.current) apiRef.current.hidePreferences(); + wasOpenRef.current = false; + } + } + }} + /> + ); +}; + +ColumnsButton.propTypes = { + buttonRef: PropTypes.func.isRequired +}; + +export default ColumnsButton; diff --git a/src/components/mui/bulk-edit-data-grid/components/expandable-quick-filter/index.js b/src/components/mui/bulk-edit-data-grid/components/expandable-quick-filter/index.js new file mode 100644 index 000000000..beb3ce13d --- /dev/null +++ b/src/components/mui/bulk-edit-data-grid/components/expandable-quick-filter/index.js @@ -0,0 +1,84 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed 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, { useEffect, useRef, useState } from "react"; +import PropTypes from "prop-types"; +import T from "i18n-react/dist/i18n-react"; +import { Box, Button, Tooltip } from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; +import { ICON_ONLY_BUTTON_SX } from "../../constants"; + +const SEARCH_WIDTH = 330; + +// search icon that expands into uicore's SearchInput +const ExpandableQuickFilter = ({ placeholder, term, onSearch }) => { + const [expanded, setExpanded] = useState(!!term); + const searchRef = useRef(null); + const focusOnOpenRef = useRef(false); + + // SearchInput takes no autoFocus, so focus its input once opened from the + // icon (but not when it starts open with a term) + useEffect(() => { + if (expanded && focusOnOpenRef.current) { + searchRef.current.querySelector("input").focus(); + focusOnOpenRef.current = false; + } + }, [expanded]); + + if (!expanded) { + return ( + + + + + ) : ( + + )} + {views} + + {onAdd && ( + + )} + {importItems.length > 0 && ( + + + + } + sx={ICON_ONLY_BUTTON_SX} + /> + )} + {(onAdd || importItems.length > 0) && ( + + )} + + {filter} + {onExport && ( + <> + + + + <> + {selectedView?.name} + + ); } ); -jest.mock( - "../../../components/filters/select-filter-criteria", - () => () => null -); // Mutable shared state so tests can seed the GridFilter's current // filterValues/joinOperator before mounting the page. @@ -120,7 +119,9 @@ describe("SummitEventListPage", () => { mockGridFilterState.parsedFilter = []; mockGridFilterState.filterValues = []; mockGridFilterState.joinOperator = undefined; - saveFilterCriteria.mockReturnValue(() => Promise.resolve()); + saveFilterCriteria.mockReturnValue(() => + Promise.resolve({ response: { id: 1, name: "view", criteria: [] } }) + ); deleteFilterCriteria.mockReturnValue(() => Promise.resolve()); }); @@ -432,4 +433,161 @@ describe("SummitEventListPage", () => { { criteria: "type", operator: "==", value: "1" } ]); }); + + test("switches to the newly saved view", async () => { + saveFilterCriteria.mockReturnValue(() => + Promise.resolve({ + response: { id: 7, name: "My saved filter", criteria: [] } + }) + ); + + renderWithRedux(, { + initialState: { + currentSummitState: { + currentSummit: { + id: 12, + time_zone: { name: "UTC" }, + time_zone_id: "UTC", + selection_plans: [], + tracks: [], + event_types: [], + locations: [], + presentation_action_types: [] + } + }, + mediaUploadListState: { + media_uploads: [] + }, + currentEventListState: { + events: [], + lastPage: 1, + currentPage: 1, + order: "id", + orderDir: 1, + totalEvents: 0, + term: "", + filters: {}, + extraColumns: [], + perPage: 10 + } + } + }); + + expect(screen.queryByText("My saved filter")).not.toBeInTheDocument(); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: "save-filter-criteria" }) + ); + await flushPromises(); + }); + + expect(screen.getByText("My saved filter")).toBeInTheDocument(); + }); + + test("keeps Activity Type hidden once hidden, and shows it again with Show all", async () => { + renderWithRedux(, { + initialState: { + currentSummitState: { + currentSummit: { + id: 12, + time_zone: { name: "UTC" }, + time_zone_id: "UTC", + selection_plans: [], + tracks: [], + event_types: [], + locations: [], + presentation_action_types: [] + } + }, + mediaUploadListState: { + media_uploads: [] + }, + currentEventListState: { + events: [], + lastPage: 1, + currentPage: 1, + order: "id", + orderDir: 1, + totalEvents: 0, + term: "", + filters: {}, + extraColumns: [], + perPage: 10 + } + } + }); + + const latestGridProps = () => + mockEditableTableSpy.mock.calls[ + mockEditableTableSpy.mock.calls.length - 1 + ][0]; + + expect(latestGridProps().columnVisibilityModel.type).not.toBe(false); + + act(() => { + latestGridProps().onColumnVisibilityModelChange({ + ...latestGridProps().columnVisibilityModel, + type: false + }); + }); + expect(latestGridProps().columnVisibilityModel.type).toBe(false); + + // "Show all" reports visible columns by leaving them out of the model + act(() => { + latestGridProps().onColumnVisibilityModelChange({}); + }); + expect(latestGridProps().columnVisibilityModel.type).not.toBe(false); + }); + + test("saves the chosen optional columns to the store so they survive leaving the page", () => { + const { store } = renderWithRedux(, { + initialState: { + currentSummitState: { + currentSummit: { + id: 12, + time_zone: { name: "UTC" }, + time_zone_id: "UTC", + selection_plans: [], + tracks: [], + event_types: [], + locations: [], + presentation_action_types: [] + } + }, + mediaUploadListState: { + media_uploads: [] + }, + currentEventListState: { + events: [], + lastPage: 1, + currentPage: 1, + order: "id", + orderDir: 1, + totalEvents: 0, + term: "", + filters: {}, + extraColumns: [], + perPage: 10 + } + } + }); + + const latestGridProps = + mockEditableTableSpy.mock.calls[ + mockEditableTableSpy.mock.calls.length - 1 + ][0]; + + act(() => { + latestGridProps.onColumnVisibilityModelChange({ + selection_status: false + }); + }); + + const { extraColumns } = store + .getActions() + .find((a) => a.type === CHANGE_EXTRA_COLUMNS).payload; + expect(extraColumns).toContain("track"); + expect(extraColumns).not.toContain("selection_status"); + }); }); diff --git a/src/pages/events/summit-event-list-page/helpers.js b/src/pages/events/summit-event-list-page/helpers.js index 138a7816a..5e96eefbb 100644 --- a/src/pages/events/summit-event-list-page/helpers.js +++ b/src/pages/events/summit-event-list-page/helpers.js @@ -241,6 +241,15 @@ export const getOptionalColumns = ( // eslint-disable-next-line react/jsx-props-no-spreading {...extraProps} value={extraProps.row.track?.id} + onChange={(ev) => + extraProps.onChange({ + target: { + value: + allTracks.find((track) => track.id === ev.target.value) ?? + null + } + }) + } /> ); } @@ -385,6 +394,13 @@ export const getOptionalColumns = ( label: T.translate("event_list.streaming_type"), render: (type) => type || "N/A" }, + { + columnKey: "selection_status", + label: T.translate("event_list.selection_status"), + sortable: true, + render: (status, row) => + status === "unaccepted" && row.is_published === true ? "accepted" : status + }, { columnKey: "review_status", sortable: true, diff --git a/src/pages/events/summit-event-list-page/index.js b/src/pages/events/summit-event-list-page/index.js index 672d8945e..330148142 100644 --- a/src/pages/events/summit-event-list-page/index.js +++ b/src/pages/events/summit-event-list-page/index.js @@ -14,18 +14,15 @@ import React, { useEffect, useState } from "react"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; -import { Box, Button } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"; -import MuiDropdown from "openstack-uicore-foundation/lib/components/mui/dropdown"; import { GridFilter, useGridFilter } from "openstack-uicore-foundation/lib/components/mui/grid-filter"; -import BulkEditTable from "openstack-uicore-foundation/lib/components/mui/bulk-edit-table"; -import GridToolbar from "../../../components/mui/grid-toolbar"; +import BulkEditDataGrid from "../../../components/mui/bulk-edit-data-grid"; import { bulkUpdateEvents, + changeEventListExtraColumns, deleteEvent, exportEvents, getEvents, @@ -33,15 +30,13 @@ import { importMP4AssetsFromMUX } from "../../../actions/event-actions"; import { getMediaUploads } from "../../../actions/media-upload-actions"; -import { handleDDLSortByLabel } from "../../../utils/methods"; import { DEFAULT_CURRENT_PAGE, DEFAULT_Z_INDEX, HIGH_Z_INDEX, MAX_PER_PAGE } from "../../../utils/constants"; -import SaveFilterCriteria from "../../../components/filters/save-filter-criteria"; -import SelectFilterCriteria from "../../../components/filters/select-filter-criteria"; +import SavedViews from "../../../components/filters/saved-views"; import { deleteFilterCriteria, saveFilterCriteria @@ -82,11 +77,15 @@ const SummitEventListPage = ({ importMP4AssetsFromMUX, saveFilterCriteria, deleteFilterCriteria, - bulkUpdateEvents + bulkUpdateEvents, + changeEventListExtraColumns }) => { const [showImportModal, setShowImportModal] = useState(false); const [showImportFromMUXModal, setShowImportFromMUXModal] = useState(false); const [selectedColumns, setSelectedColumns] = useState(extraColumns ?? []); + // the grid's last column visibility; only the optional columns are + // remembered (selectedColumns), the default ones reset each visit + const [gridColumnVisibility, setGridColumnVisibility] = useState({}); const [selectedFilterCriteria, setSelectedFilterCriteria] = useState(null); const { parsedFilter, resetFilters, filterValues, setFilters, joinOperator } = useGridFilter(FILTER_ID); @@ -140,11 +139,6 @@ const SummitEventListPage = ({ setSelectedColumns(extraColumns ?? []); }, [extraColumns]); - const handleMUXImport = (ev) => { - ev.preventDefault(); - setShowImportFromMUXModal(true); - }; - const handleEdit = (row) => { history.push(`/app/summits/${currentSummit.id}/events/${row.id}`); }; @@ -174,9 +168,8 @@ const SummitEventListPage = ({ history.push(`/app/summits/${currentSummit.id}/events/new`); }; - const handleFilterCriteriaSave = ({ name, id, visibility }) => { + const handleFilterCriteriaSave = ({ name, visibility }) => { const filterToSave = { - id, show_id: currentSummit.id, name, enabled_filters: filterValues.map((f) => f.criteria), @@ -187,7 +180,9 @@ const SummitEventListPage = ({ visibility }; - saveFilterCriteria(filterToSave); + return saveFilterCriteria(filterToSave).then(({ response }) => + setSelectedFilterCriteria(response) + ); }; const optionalColumns = getOptionalColumns( @@ -197,70 +192,49 @@ const SummitEventListPage = ({ currentSummit.id ); - const columnDDLOptions = [ - ...optionalColumns.map((oc) => ({ value: oc.columnKey, label: oc.label })), - { - value: "all_companies", - label: T.translate("event_list.all_companies") - }, - { - value: "all", - label: T.translate("general.all") - } - ]; - - const handleColumnsChange = (ev) => { - const { value: newColumns } = ev.target; - - if (newColumns.includes("all")) { - setSelectedColumns( - columnDDLOptions.map((opt) => opt.value).filter((v) => v !== "all") - ); - return; - } - - const allCompanies = ["submitter_company", "speaker_company", "sponsor"]; - const hadAllCompanies = selectedColumns.includes("all_companies"); - const hasAllCompanies = newColumns.includes("all_companies"); - - if (hadAllCompanies && !hasAllCompanies) { - setSelectedColumns(newColumns.filter((c) => !allCompanies.includes(c))); - return; - } - - if (hasAllCompanies) { - const selectedCompanies = allCompanies.filter((c) => - selectedColumns.includes(c) - ).length; - const newCompanies = allCompanies.filter((c) => - newColumns.includes(c) - ).length; - - setSelectedColumns( - newCompanies < selectedCompanies - ? newColumns.filter((c) => c !== "all_companies") - : [...new Set([...newColumns, ...allCompanies])] - ); - return; - } + const columnVisibilityModel = { + ...gridColumnVisibility, + ...Object.fromEntries( + optionalColumns.map((oc) => [ + oc.columnKey, + selectedColumns.includes(oc.columnKey) + ]) + ) + }; + // the grid omits columns from the model when they're visible (e.g. after + // "Show all"), so only an explicit false hides one + const handleColumnVisibilityModelChange = (model) => { + const newColumns = optionalColumns + .map((oc) => oc.columnKey) + .filter((columnKey) => model[columnKey] !== false); + setGridColumnVisibility(model); setSelectedColumns(newColumns); + changeEventListExtraColumns(newColumns); }; const handleFilterCriteriaChange = (filterCriteria) => { setSelectedFilterCriteria(filterCriteria); }; - const handleFilterCriteriaDelete = (filterCriteriaId) => { + // any view can be deleted from the list, so only clear the filters when + // the active one goes + const handleFilterCriteriaDelete = (filterCriteriaId) => deleteFilterCriteria(filterCriteriaId).then(() => { - setSelectedFilterCriteria(null); + if (selectedFilterCriteria?.id === filterCriteriaId) { + setSelectedFilterCriteria(null); + } }); - }; const eventTypeOptions = buildNameIdDDL(currentSummit.event_types); const fixedColumns = [ - { columnKey: "id", label: T.translate("general.id"), sortable: true }, + { + columnKey: "id", + label: T.translate("general.id"), + sortable: true, + width: 90 + }, { columnKey: "type", label: T.translate("event_list.type"), @@ -283,6 +257,16 @@ const SummitEventListPage = ({ }} // eslint-disable-next-line react/jsx-props-no-spreading {...extraProps} + onChange={(ev) => + extraProps.onChange({ + target: { + value: + currentSummit.event_types.find( + (type) => type.id === ev.target.value + ) ?? null + } + }) + } /> ), render: (e) => e.name @@ -291,17 +275,9 @@ const SummitEventListPage = ({ columnKey: "title", label: T.translate("event_list.title"), sortable: true, + flex: 2, editableField: true, placeholder: T.translate("bulk_actions_page.placeholders.event_title") - }, - { - columnKey: "selection_status", - label: T.translate("event_list.selection_status"), - sortable: true, - render: (status, row) => - status === "unaccepted" && row.is_published === true - ? "accepted" - : status } ]; @@ -310,11 +286,7 @@ const SummitEventListPage = ({ sortDir: orderDir }; - const selectedOptionalColumns = optionalColumns.filter((c) => - selectedColumns.includes(c.columnKey) - ); - - const tableColumns = [...fixedColumns, ...selectedOptionalColumns]; + const tableColumns = [...fixedColumns, ...optionalColumns]; if (!currentSummit.id) return
; @@ -322,97 +294,70 @@ const SummitEventListPage = ({ return (
-

- {T.translate("event_list.event_list")} ({totalEvents}) -

- - - - - - - -
-
- - + + {T.translate("event_list.event_list")} ({totalEvents}) + + } + options={tableOptions} + data={tableData} + columns={tableColumns} + columnVisibilityModel={columnVisibilityModel} + onColumnVisibilityModelChange={handleColumnVisibilityModelChange} + filter={ + + } + views={ + + } + onAdd={handleNewEvent} + addLabel={T.translate("event_list.add_event")} + importItems={[ + { + label: T.translate("event_list.mux_import"), + onClick: () => setShowImportFromMUXModal(true) + }, + { + label: T.translate("event_list.import"), + onClick: () => setShowImportModal(true) + } + ]} + importLabel={T.translate("event_list.import")} + onExport={handleExport} + searchProps={{ + term, + placeholder: T.translate("event_list.placeholders.search_events"), + onSearch: handleSearch + }} + noRowsLabel={T.translate("event_list.no_events")} + onSort={handleSort} + onUpdate={bulkUpdateEvents} + totalRows={totalEvents} + perPage={perPage} + currentPage={currentPage} + onPageChange={handlePageChange} + onPerPageChange={handlePerPageChange} + onEdit={handleEdit} + onDelete={deleteEvent} + getName={(row) => row.title} + deleteDialogBody={(name) => + `${T.translate("event_list.delete_event_warning")} ${name}` + } />
-
- - - - - {events.length === 0 &&
{T.translate("event_list.no_events")}
} - - {events.length > 0 && ( -
- row.title} - deleteDialogBody={(name) => - `${T.translate("event_list.delete_event_warning")} ${name}` - } - showPageJump - /> -
- )} - setShowImportModal(false)} @@ -447,5 +392,6 @@ export default connect(mapStateToProps, { saveFilterCriteria, deleteFilterCriteria, bulkUpdateEvents, - getMediaUploads + getMediaUploads, + changeEventListExtraColumns })(SummitEventListPage); diff --git a/src/reducers/events/event-list-reducer.js b/src/reducers/events/event-list-reducer.js index 626813abc..fb2bdee61 100644 --- a/src/reducers/events/event-list-reducer.js +++ b/src/reducers/events/event-list-reducer.js @@ -19,7 +19,8 @@ import { RECEIVE_EVENTS, REQUEST_EVENTS, EVENT_DELETED, - CHANGE_SEARCH_TERM + CHANGE_SEARCH_TERM, + CHANGE_EXTRA_COLUMNS } from "../../actions/event-actions"; import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions"; @@ -90,6 +91,10 @@ const eventListReducer = (state = DEFAULT_STATE, action) => { const { term } = payload; return { ...state, term }; } + case CHANGE_EXTRA_COLUMNS: { + const { extraColumns } = payload; + return { ...state, extraColumns }; + } default: return state; } diff --git a/yarn.lock b/yarn.lock index ae01310da..73b2e289d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2078,6 +2078,19 @@ prop-types "^15.8.1" react-is "^19.0.0" +"@mui/x-data-grid@^7.29.13": + version "7.29.13" + resolved "https://registry.yarnpkg.com/@mui/x-data-grid/-/x-data-grid-7.29.13.tgz#3bbba18ef29981c324bd0030496abb8e04e8be06" + integrity sha512-XHrZTvpa61eqSgUIevDzXYfYCbI7cbN/aRxSCaw2cZzQZ/USdpECYbnTEhgw5XgUlvkAK0mItKZmgKM4X7zT+Q== + dependencies: + "@babel/runtime" "^7.25.7" + "@mui/utils" "^5.16.6 || ^6.0.0 || ^7.0.0" + "@mui/x-internals" "7.29.0" + clsx "^2.1.1" + prop-types "^15.8.1" + reselect "^5.1.1" + use-sync-external-store "^1.0.0" + "@mui/x-date-pickers@^7.26.0": version "7.29.4" resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz#b8808cb8e28c1d4e528b37b336effc8074e65faf" @@ -10373,6 +10386,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== +reselect@^5.1.1: + version "5.3.0" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.3.0.tgz#0a3e3ed4436bdf2ab7c5e0f392dab2c062595d61" + integrity sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg== + resolve-alpn@^1.0.0: version "1.2.1" resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" @@ -11712,6 +11730,11 @@ use-memo-one@^1.1.1: resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== +use-sync-external-store@^1.0.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz#6dcb66ef569e02f186af6b3d575f414ce746e18f" + integrity sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A== + use-sync-external-store@^1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"