diff --git a/src/actions/summitdoc-actions.js b/src/actions/summitdoc-actions.js
index 478811d18..92e10dedc 100644
--- a/src/actions/summitdoc-actions.js
+++ b/src/actions/summitdoc-actions.js
@@ -12,21 +12,19 @@
* */
import T from "i18n-react/dist/i18n-react";
import {
- getRequest,
- deleteRequest,
- createAction,
- stopLoading,
- startLoading,
- showMessage,
- showSuccessMessage,
authErrorHandler,
+ createAction,
+ deleteRequest,
+ escapeFilterValue,
+ getRequest,
+ postFile,
postRequest,
putRequest,
- postFile,
- escapeFilterValue
+ snackbarSuccessHandler,
+ startLoading,
+ stopLoading
} from "openstack-uicore-foundation/lib/utils/actions";
import { getAccessTokenSafely, wrapFormFile } from "../utils/methods";
-import history from "../history";
import { DEFAULT_PER_PAGE } from "../utils/constants";
export const REQUEST_SUMMITDOCS = "REQUEST_SUMMITDOCS";
@@ -49,13 +47,12 @@ export const getSummitDocs =
orderDir = 1
) =>
async (dispatch, getState) => {
+ dispatch(startLoading());
const { currentSummitState } = getState();
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;
const filter = [];
- dispatch(startLoading());
-
if (term) {
const escapedTerm = escapeFilterValue(term);
filter.push(`name=@${escapedTerm}`);
@@ -65,9 +62,10 @@ export const getSummitDocs =
page,
per_page: perPage,
access_token: accessToken,
- expand: "event_types",
- relations: "event_types.none",
- fields: "id,description,label,event_types.id,event_types.name"
+ expand: "event_types,selection_plan",
+ relations: "event_types.none,selection_plan.none",
+ fields:
+ "id,description,label,event_types.id,event_types.name,selection_plan.name,show_always"
};
if (filter.length > 0) {
@@ -85,19 +83,20 @@ export const getSummitDocs =
createAction(RECEIVE_SUMMITDOCS),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/summit-documents`,
authErrorHandler,
- { order, orderDir, term }
- )(params)(dispatch).then(() => {
- dispatch(stopLoading());
- });
+ { order, orderDir, term, currentPage: page, perPage }
+ )(params)(dispatch)
+ .finally(() => {
+ dispatch(stopLoading());
+ })
+ .catch(() => {});
};
export const getSummitDoc = (summitDocId) => async (dispatch, getState) => {
+ dispatch(startLoading());
const { currentSummitState } = getState();
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;
- dispatch(startLoading());
-
const params = {
access_token: accessToken
};
@@ -107,9 +106,11 @@ export const getSummitDoc = (summitDocId) => async (dispatch, getState) => {
createAction(RECEIVE_SUMMITDOC),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/summit-documents/${summitDocId}`,
authErrorHandler
- )(params)(dispatch).then(() => {
- dispatch(stopLoading());
- });
+ )(params)(dispatch)
+ .finally(() => {
+ dispatch(stopLoading());
+ })
+ .catch(() => {});
};
export const resetSummitDocForm = () => (dispatch) => {
@@ -117,34 +118,34 @@ export const resetSummitDocForm = () => (dispatch) => {
};
export const addFileToDoc = (entity, file) => async (dispatch, getState) => {
+ dispatch(startLoading());
const { currentSummitState } = getState();
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;
- dispatch(startLoading());
-
const params = {
access_token: accessToken
};
- postRequest(
+ return postRequest(
null,
createAction(SUMMITDOC_FILE_ADDED),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/summit-documents/${entity.id}/file`,
wrapFormFile(file),
authErrorHandler
- )(params)(dispatch).then(() => {
- dispatch(stopLoading());
- });
+ )(params)(dispatch)
+ .finally(() => {
+ dispatch(stopLoading());
+ })
+ .catch(() => {});
};
export const removeFileFromDoc = (entity) => async (dispatch, getState) => {
+ dispatch(startLoading());
const { currentSummitState } = getState();
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;
- dispatch(startLoading());
-
const params = {
access_token: accessToken
};
@@ -155,60 +156,65 @@ export const removeFileFromDoc = (entity) => async (dispatch, getState) => {
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/summit-documents/${entity.id}/file`,
null,
authErrorHandler
- )(params)(dispatch).then(() => {
- dispatch(stopLoading());
- });
+ )(params)(dispatch)
+ .finally(() => {
+ dispatch(stopLoading());
+ })
+ .catch(() => {});
};
+// TODO: replace with snackbarErrorHandler once it handles 401s (re-login redirect) correctly.
export const saveSummitDoc = (entity, file) => async (dispatch, getState) => {
+ dispatch(startLoading());
const { currentSummitState } = getState();
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;
- dispatch(startLoading());
-
const normalizedEntity = normalizeEntity(entity);
const params = { access_token: accessToken };
if (entity.id) {
- putRequest(
+ return putRequest(
createAction(UPDATE_SUMMITDOC),
createAction(SUMMITDOC_UPDATED),
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/summit-documents/${entity.id}`,
normalizedEntity,
authErrorHandler,
entity
- )(params)(dispatch).then(() => {
- dispatch(showSuccessMessage(T.translate("summitdoc.saved")));
- });
- } else {
- const successMessage = {
- title: T.translate("general.done"),
- html: T.translate("summitdoc.created"),
- type: "success"
- };
+ )(params)(dispatch)
+ .then(() => {
+ dispatch(
+ snackbarSuccessHandler({
+ title: T.translate("general.done"),
+ html: T.translate("summitdoc.saved")
+ })
+ );
+ })
+ .finally(() => dispatch(stopLoading()));
+ }
- postFile(
- createAction(UPDATE_SUMMITDOC),
- createAction(SUMMITDOC_ADDED),
- `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/summit-documents`,
- file,
- normalizedEntity,
- authErrorHandler,
- entity
- )(params)(dispatch).then((payload) => {
+ return postFile(
+ createAction(UPDATE_SUMMITDOC),
+ createAction(SUMMITDOC_ADDED),
+ `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/summit-documents`,
+ file,
+ normalizedEntity,
+ authErrorHandler,
+ entity
+ )(params)(dispatch)
+ .then(() => {
dispatch(
- showMessage(successMessage, () => {
- history.push(
- `/app/summits/${currentSummit.id}/summitdocs/${payload.response.id}`
- );
+ snackbarSuccessHandler({
+ title: T.translate("general.done"),
+ html: T.translate("summitdoc.created")
})
);
- });
- }
+ })
+ .finally(() => dispatch(stopLoading()));
};
export const deleteSummitDoc = (summitDocId) => async (dispatch, getState) => {
+ dispatch(startLoading());
const { currentSummitState } = getState();
const accessToken = await getAccessTokenSafely();
const { currentSummit } = currentSummitState;
@@ -223,9 +229,11 @@ export const deleteSummitDoc = (summitDocId) => async (dispatch, getState) => {
`${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/summit-documents/${summitDocId}`,
null,
authErrorHandler
- )(params)(dispatch).then(() => {
- dispatch(stopLoading());
- });
+ )(params)(dispatch)
+ .finally(() => {
+ dispatch(stopLoading());
+ })
+ .catch(() => {});
};
const normalizeEntity = (entity) => {
diff --git a/src/components/forms/__tests__/summitdoc-form.test.js b/src/components/forms/__tests__/summitdoc-form.test.js
new file mode 100644
index 000000000..57161b5cd
--- /dev/null
+++ b/src/components/forms/__tests__/summitdoc-form.test.js
@@ -0,0 +1,367 @@
+// ---- Mocks must come first ----
+
+// 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 }
+}));
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/inputs/upload-input",
+ () => ({
+ __esModule: true,
+ default: ({ value, handleUpload, handleRemove, disabled }) => (
+
+
+ {value && (
+
+ )}
+
+ )
+ })
+);
+
+// The vendor Formik-input wrappers (openstack-uicore-foundation) own their own
+// error/FormHelperText rendering and have their own tests; here we only need
+// them to read/write real Formik state, mirroring company-form.test.js and
+// payment-profile-dialog.test.js's mocking convention for these components.
+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, disabled }) {
+ const [field] = useField(name);
+ return (
+
+ );
+ }
+ };
+ }
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/select",
+ () => {
+ const React = require("react");
+ const { useField } = require("formik");
+ return {
+ __esModule: true,
+ default: function MockMuiFormikSelect({ name, disabled }) {
+ const [field] = useField(name);
+ return (
+
+ {JSON.stringify(field.value)}
+
+ );
+ }
+ };
+ }
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/checkbox",
+ () => {
+ const React = require("react");
+ const { useField } = require("formik");
+ return {
+ __esModule: true,
+ default: function MockMuiFormikCheckbox({ name, label, ...props }) {
+ const [field] = useField({ name, type: "checkbox" });
+ return (
+
+ );
+ }
+ };
+ }
+);
+
+// ---- Now imports ----
+/* eslint-disable import/first */
+import React, { useState } from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import "@testing-library/jest-dom";
+import { FormikProvider, useFormik } from "formik";
+import SummitDocForm from "../summitdoc-form";
+import {
+ buildValues,
+ validationSchema
+} from "../../../pages/summitdocs/edit-summitdoc-page";
+/* eslint-enable import/first */
+
+const currentSummit = {
+ event_types: [
+ { id: 1, name: "Keynote" },
+ { id: 2, name: "Panel" }
+ ],
+ selection_plans: [{ id: 10, name: "Plan A" }]
+};
+
+const VALID_ENTITY = {
+ id: 0,
+ name: "A doc",
+ label: "A label",
+ description: "A description",
+ event_types: [1],
+ file_preview: "",
+ selection_plan_id: null,
+ show_always: false,
+ web_link: ""
+};
+
+// Mirrors the formik wiring edit-summitdoc-page.js provides in production, so
+// SummitDocForm's useFormikContext() has a real context to read from.
+const Harness = ({
+ entity,
+ onSubmit = jest.fn(),
+ addFileToDoc = jest.fn(),
+ removeFileFromDoc = jest.fn()
+}) => {
+ const [file, setFile] = useState(null);
+ const formik = useFormik({
+ initialValues: buildValues(entity),
+ validationSchema,
+ onSubmit: (values) => onSubmit(values, file)
+ });
+
+ 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("SummitDocForm", () => {
+ it("clears and disables event types when show_always is checked", async () => {
+ render();
+
+ expect(screen.getByTestId("select-event_types")).toHaveTextContent("[1]");
+
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "summitdoc.show_always" })
+ );
+
+ expect(readFormikValues().event_types).toEqual([]);
+ expect(screen.getByTestId("select-event_types")).toHaveAttribute(
+ "data-disabled",
+ "true"
+ );
+ });
+
+ it("disables the file upload when a web link is entered", async () => {
+ render();
+
+ await userEvent.type(
+ screen.getByTestId("textfield-web_link"),
+ "http://example.com"
+ );
+
+ expect(screen.getByRole("button", { name: "upload-file" })).toBeDisabled();
+ });
+
+ it("disables the web link field once a file is present", () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId("textfield-web_link")).toBeDisabled();
+ });
+
+ it("shows an existing doc's server-side file and disables web_link for it", () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByRole("button", { name: "remove-file" })
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("textfield-web_link")).toBeDisabled();
+ });
+
+ it("holds the file in local state for a new doc and submits it together with the entity on save", async () => {
+ const onSubmit = jest.fn();
+ const addFileToDoc = jest.fn();
+ render(
+
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: "upload-file" }));
+
+ expect(addFileToDoc).not.toHaveBeenCalled();
+ expect(
+ screen.getByRole("button", { name: "remove-file" })
+ ).toBeInTheDocument();
+
+ await clickSave();
+
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ file_preview: "blob:new-file" }),
+ { preview: "blob:new-file" }
+ );
+ });
+
+ it("uploads/removes the file directly against the API for an existing doc", async () => {
+ const addFileToDoc = jest.fn();
+ const removeFileFromDoc = jest.fn();
+ render(
+
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: "upload-file" }));
+ expect(addFileToDoc).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 5 }),
+ { preview: "blob:new-file" }
+ );
+
+ await userEvent.click(screen.getByRole("button", { name: "remove-file" }));
+ expect(removeFileFromDoc).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 5 })
+ );
+ });
+
+ it("blocks submit and surfaces required-field errors (including event_types) on an empty doc", async () => {
+ const onSubmit = jest.fn();
+ render(
+
+ );
+
+ await clickSave();
+
+ const errors = readFormikErrors();
+ expect(errors.name).toBeTruthy();
+ expect(errors.label).toBeTruthy();
+ expect(errors.description).toBeTruthy();
+ expect(errors.event_types).toBeTruthy();
+ expect(errors.web_link).toBeTruthy();
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it("does not require web_link when editing a doc that already has a server-side file", async () => {
+ const onSubmit = jest.fn();
+ render(
+
+ );
+
+ // Editing an unrelated field re-runs validation against the whole
+ // schema - this used to flash a false "web_link required" error
+ // because entity.file (the existing file) never reached formik state.
+ await userEvent.type(
+ screen.getByTestId("textfield-description"),
+ " updated"
+ );
+
+ expect(readFormikErrors().web_link).toBeUndefined();
+ });
+
+ it("does not require event_types when show_always is checked, and submits successfully", async () => {
+ const onSubmit = jest.fn();
+ render(
+
+ );
+
+ await userEvent.click(
+ screen.getByRole("checkbox", { name: "summitdoc.show_always" })
+ );
+ await clickSave();
+
+ expect(readFormikErrors().event_types).toBeUndefined();
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ show_always: true, event_types: [] }),
+ null
+ );
+ });
+});
diff --git a/src/components/forms/summitdoc-form.js b/src/components/forms/summitdoc-form.js
index 3af66746b..74b4fd754 100644
--- a/src/components/forms/summitdoc-form.js
+++ b/src/components/forms/summitdoc-form.js
@@ -9,249 +9,227 @@
* 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 "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css";
-import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"
-import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"
-import TextArea from "openstack-uicore-foundation/lib/components/inputs/textarea-input"
+import { useFormikContext } from "formik";
+import Box from "@mui/material/Box";
+import { Grid2 } from "@mui/material";
+import MenuItem from "@mui/material/MenuItem";
+import Tooltip from "@mui/material/Tooltip";
+import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
+import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield";
+import MuiFormikSelect from "openstack-uicore-foundation/lib/components/mui/formik-inputs/select";
+import MuiFormikCheckbox from "openstack-uicore-foundation/lib/components/mui/formik-inputs/checkbox";
import UploadInput from "openstack-uicore-foundation/lib/components/inputs/upload-input";
-import { isEmpty, scrollToError, shallowEqual } from "../../utils/methods";
-
-class SummitDocForm 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);
- this.handleUploadFile = this.handleUploadFile.bind(this);
- this.handleRemoveFile = this.handleRemoveFile.bind(this);
- }
-
- componentDidUpdate(prevProps, prevState, snapshot) {
- 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) {
- let entity = { ...this.state.entity };
- let errors = { ...this.state.errors };
- let { value, id } = ev.target;
-
- if (ev.target.type === "checkbox") {
- value = ev.target.checked;
- }
-
- if (ev.target.type === "number") {
- value = parseInt(ev.target.value);
- }
-
- errors[id] = "";
- entity[id] = value;
-
- if (id === "show_always" && value) {
- entity.event_types = [];
- }
-
- this.setState({ entity: entity, errors: errors });
- }
-
- handleSubmit(ev) {
- const { entity, file } = this.state;
- ev.preventDefault();
-
- this.props.onSubmit(entity, file);
- }
-
- hasErrors(field) {
- let { errors } = this.state;
- if (field in errors) {
- return errors[field];
- }
-
- return "";
- }
-
- handleUploadFile(file) {
- let entity = { ...this.state.entity };
-
- if (entity.id) {
- this.props.addFileToDoc(entity, file);
+import useScrollToError from "../../hooks/useScrollToError";
+
+const SummitDocForm = ({
+ currentSummit,
+ addFileToDoc,
+ removeFileFromDoc,
+ setFile
+}) => {
+ const formik = useFormikContext();
+ const { values, setFieldValue, setValues } = formik;
+
+ useScrollToError(formik, true);
+
+ const eventTypesDDL = currentSummit.event_types.map((et) => ({
+ value: et.id,
+ label: et.name
+ }));
+
+ const selectionPlansDDL = currentSummit.selection_plans.map((sp) => ({
+ value: sp.id,
+ label: sp.name
+ }));
+
+ const handleShowAlwaysChange = (ev) => {
+ const { checked } = ev.target;
+ // Update both fields in one call - two sequential setFieldValue calls
+ // each trigger their own validation pass against a stale snapshot of
+ // the other field, flashing a spurious "required" error on event_types.
+ setValues({
+ ...values,
+ show_always: checked,
+ event_types: checked ? [] : values.event_types
+ });
+ };
+
+ const handleUploadFile = (uploadedFile) => {
+ if (values.id) {
+ addFileToDoc(values, uploadedFile);
} else {
- entity.file_preview = file.preview;
- this.setState({ file: file, entity: entity });
+ setFieldValue("file_preview", uploadedFile.preview);
+ setFile(uploadedFile);
}
- }
-
- handleRemoveFile(ev) {
- let entity = { ...this.state.entity };
+ };
- if (entity.id) {
- this.props.removeFileFromDoc(entity);
+ const handleRemoveFile = () => {
+ if (values.id) {
+ removeFileFromDoc(values);
} else {
- entity.file_preview = "";
- this.setState({ file: null, entity: entity });
+ setFieldValue("file_preview", "");
+ setFile(null);
}
- }
-
- render() {
- const { entity } = this.state;
- const { currentSummit } = this.props;
-
- let event_types_ddl = currentSummit.event_types.map((et) => ({
- value: et.id,
- label: et.name
- }));
-
- let selection_plans_ddl = currentSummit.selection_plans.map((et) => ({
- value: et.id,
- label: et.name
- }));
-
- return (
-
- );
- }
-}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+SummitDocForm.propTypes = {
+ currentSummit: PropTypes.object.isRequired,
+ addFileToDoc: PropTypes.func.isRequired,
+ removeFileFromDoc: PropTypes.func.isRequired,
+ setFile: PropTypes.func.isRequired
+};
export default SummitDocForm;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index c7e8c64fc..cbfc7ea20 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -3355,6 +3355,7 @@
"label": "Label",
"description": "Description",
"event_types": "Activity Types",
+ "all_types": "All Types",
"show_always": "Show Always",
"file": "File",
"delete_warning": "Are you sure you want to delete doc ",
diff --git a/src/pages/summitdocs/__tests__/summitdoc-list-page.test.js b/src/pages/summitdocs/__tests__/summitdoc-list-page.test.js
new file mode 100644
index 000000000..14730bc7f
--- /dev/null
+++ b/src/pages/summitdocs/__tests__/summitdoc-list-page.test.js
@@ -0,0 +1,183 @@
+import React from "react";
+import { act, 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 SummitDocListPage from "../summitdoc-list-page";
+import {
+ getSummitDocs,
+ deleteSummitDoc
+} from "../../../actions/summitdoc-actions";
+import { DEFAULT_CURRENT_PAGE } from "../../../utils/constants";
+
+jest.mock("../../../actions/summitdoc-actions", () => ({
+ getSummitDocs: jest.fn(),
+ deleteSummitDoc: jest.fn()
+}));
+
+jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({
+ __esModule: true,
+ default: ({ onEdit, onDelete, onSort, onPageChange, onPerPageChange }) => (
+
+
+
+
+
+
+
+ )
+}));
+
+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 initialState = {
+ currentSummitState: { currentSummit: createMockSummit() },
+ summitDocListState: {
+ summitDocs: [
+ {
+ id: 1,
+ name: "test-doc",
+ label: "test-label",
+ description: "test-description",
+ event_types_string: "Keynote, Panel"
+ }
+ ],
+ totalSummitDocs: 1,
+ perPage: 10,
+ currentPage: 1,
+ term: "",
+ order: "id",
+ orderDir: 1,
+ lastPage: 1
+ }
+};
+
+describe("SummitDocListPage", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ getSummitDocs.mockReturnValue(() => Promise.resolve());
+ deleteSummitDoc.mockReturnValue(() => Promise.resolve());
+ });
+
+ it("deletes the summit doc by id (confirm is handled inside MuiTable)", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(screen.getByRole("button", { name: "delete-row" }));
+ });
+
+ expect(deleteSummitDoc).toHaveBeenCalledWith(1);
+ });
+
+ it("resets to the first page on search", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "search-trigger" })
+ );
+ });
+
+ expect(getSummitDocs).toHaveBeenLastCalledWith(
+ "newterm",
+ DEFAULT_CURRENT_PAGE,
+ 10,
+ "id",
+ 1
+ );
+ });
+
+ it("resets to the first page on per-page change", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(screen.getByRole("button", { name: "perpage-50" }));
+ });
+
+ expect(getSummitDocs).toHaveBeenLastCalledWith(
+ "",
+ DEFAULT_CURRENT_PAGE,
+ 50,
+ "id",
+ 1
+ );
+ });
+
+ it("keeps the current page on sort", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(screen.getByRole("button", { name: "sort-col" }));
+ });
+
+ expect(getSummitDocs).toHaveBeenLastCalledWith("", 1, 10, "label", -1);
+ });
+
+ it("navigates to the edit page", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(screen.getByRole("button", { name: "edit-row" }));
+ });
+
+ expect(mockHistory.push).toHaveBeenCalledWith(
+ "/app/summits/456/summitdocs/1"
+ );
+ });
+
+ it("navigates to the add page", async () => {
+ renderWithRedux(, {
+ initialState
+ });
+
+ await act(async () => {
+ await userEvent.click(
+ screen.getByRole("button", { name: "summitdoc.add" })
+ );
+ });
+
+ expect(mockHistory.push).toHaveBeenCalledWith(
+ "/app/summits/456/summitdocs/new"
+ );
+ });
+});
diff --git a/src/pages/summitdocs/edit-summitdoc-page.js b/src/pages/summitdocs/edit-summitdoc-page.js
index 1a48bab25..94bb8e7cb 100644
--- a/src/pages/summitdocs/edit-summitdoc-page.js
+++ b/src/pages/summitdocs/edit-summitdoc-page.js
@@ -11,10 +11,14 @@
* limitations under the License.
* */
-import React from "react";
+import React, { useEffect, useState } from "react";
import { connect } from "react-redux";
import T from "i18n-react/dist/i18n-react";
import { Breadcrumb } from "react-breadcrumbs";
+import { FormikProvider, useFormik } from "formik";
+import * as yup from "yup";
+import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
import SummitDocForm from "../../components/forms/summitdoc-form";
import { getSummitById } from "../../actions/summit-actions";
import {
@@ -25,62 +29,125 @@ import {
saveSummitDoc
} from "../../actions/summitdoc-actions";
import AddNewButton from "../../components/buttons/add-new-button";
+import { requiredStringValidation } from "../../utils/yup";
// import '../../styles/edit-summitdoc-page.less';
-class EditSummitDocPage extends React.Component {
- constructor(props) {
- const summitDocId = props.match.params.summitdoc_id;
- super(props);
+export const buildValues = (entity) => ({
+ id: entity?.id ?? 0,
+ name: entity?.name ?? "",
+ label: entity?.label ?? "",
+ description: entity?.description ?? "",
+ event_types: entity?.event_types ?? [],
+ file_preview: entity?.file_preview ?? "",
+ file: entity?.file ?? null,
+ selection_plan_id: entity?.selection_plan_id ?? "",
+ show_always: !!entity?.show_always,
+ web_link: entity?.web_link ?? ""
+});
+
+export const validationSchema = yup.object().shape({
+ name: requiredStringValidation(),
+ label: requiredStringValidation(),
+ description: requiredStringValidation(),
+ event_types: yup.array().when("show_always", {
+ is: true,
+ then: (schema) => schema,
+ otherwise: (schema) => schema.min(1, T.translate("validation.required"))
+ }),
+ file_preview: yup.string().nullable(),
+ file: yup.mixed().nullable(),
+ web_link: yup
+ .string()
+ .nullable()
+ .when(["file_preview", "file"], {
+ is: (filePreview, file) => !filePreview && !file,
+ then: (schema) => schema.required(T.translate("validation.required")),
+ otherwise: (schema) => schema
+ })
+});
+const EditSummitDocPage = ({
+ currentSummit,
+ entity,
+ match,
+ history,
+ getSummitDoc,
+ resetSummitDocForm,
+ saveSummitDoc,
+ addFileToDoc,
+ removeFileFromDoc
+}) => {
+ const summitDocId = match.params.summitdoc_id;
+ const [file, setFile] = useState(null);
+
+ useEffect(() => {
if (!summitDocId) {
- props.resetSummitDocForm();
+ resetSummitDocForm();
} else {
- props.getSummitDoc(summitDocId);
+ getSummitDoc(summitDocId);
}
- }
+ }, [summitDocId]);
- componentDidUpdate(prevProps, prevState, snapshot) {
- const oldId = prevProps.match.params.summitdoc_id;
- const newId = this.props.match.params.summitdoc_id;
+ const formik = useFormik({
+ initialValues: buildValues(entity),
+ validationSchema,
+ onSubmit: (values) =>
+ saveSummitDoc(values, file)
+ .then(() => {
+ history.push(`/app/summits/${currentSummit.id}/summitdocs`);
+ })
+ .catch(() => {})
+ });
- if (newId !== oldId) {
- if (!newId) {
- this.props.resetSummitDocForm();
- } else {
- this.props.getSummitDoc(newId);
- }
- }
- }
+ useEffect(() => {
+ formik.resetForm({ values: buildValues(entity) });
+ setFile(null);
+ }, [entity.id]);
+
+ // addFileToDoc/removeFileFromDoc update entity.file directly via redux,
+ // independent of entity.id - resync just this field so it doesn't go
+ // stale, without resetting the rest of the in-progress form.
+ useEffect(() => {
+ formik.setFieldValue("file", entity.file ?? null);
+ }, [entity.file]);
- render() {
- const { currentSummit, entity, errors, match } = this.props;
- const title = entity.id
- ? T.translate("general.edit")
- : T.translate("general.add");
- const breadcrumb = entity.id ? entity.label : T.translate("general.new");
+ const title = entity.id
+ ? T.translate("general.edit")
+ : T.translate("general.add");
+ const breadcrumb = entity.id ? entity.label : T.translate("general.new");
- return (
-
-
-
- {title} {T.translate("summitdoc.summitdoc")}
-
-
-
- {currentSummit && (
-
- )}
-
- );
- }
-}
+ return (
+
+
+
+ {title} {T.translate("summitdoc.summitdoc")}
+
+
+
+ {currentSummit && (
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
const mapStateToProps = ({ currentSummitState, summitDocState }) => ({
currentSummit: currentSummitState.currentSummit,
diff --git a/src/pages/summitdocs/summitdoc-list-page.js b/src/pages/summitdocs/summitdoc-list-page.js
index cfad0996b..b8e14c3fd 100644
--- a/src/pages/summitdocs/summitdoc-list-page.js
+++ b/src/pages/summitdocs/summitdoc-list-page.js
@@ -9,175 +9,138 @@
* 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 Swal from "sweetalert2";
-import { Pagination } from "react-bootstrap";
-import FreeTextSearch from "openstack-uicore-foundation/lib/components/free-text-search"
-import Table from "openstack-uicore-foundation/lib/components/table";
+import Button from "@mui/material/Button";
+import AddIcon from "@mui/icons-material/Add";
+import MuiTable from "openstack-uicore-foundation/lib/components/mui/table";
+import GridToolbar from "../../components/mui/grid-toolbar";
import { getSummitById } from "../../actions/summit-actions";
import {
getSummitDocs,
deleteSummitDoc
} from "../../actions/summitdoc-actions";
+import { DEFAULT_CURRENT_PAGE } from "../../utils/constants";
+
+const SummitDocListPage = ({
+ currentSummit,
+ summitDocs,
+ currentPage,
+ perPage,
+ term,
+ order,
+ orderDir,
+ totalSummitDocs,
+ history,
+ getSummitDocs,
+ deleteSummitDoc
+}) => {
+ useEffect(() => {
+ if (currentSummit) {
+ getSummitDocs(term, currentPage, perPage, order, orderDir);
+ }
+ }, [currentSummit]);
-class SummitDocListPage extends React.Component {
- constructor(props) {
- super(props);
+ const handleEdit = (row) => {
+ history.push(`/app/summits/${currentSummit.id}/summitdocs/${row.id}`);
+ };
- this.handleEdit = this.handleEdit.bind(this);
- this.handlePageChange = this.handlePageChange.bind(this);
- this.handleSort = this.handleSort.bind(this);
- this.handleSearch = this.handleSearch.bind(this);
- this.handleNew = this.handleNew.bind(this);
- this.handleDelete = this.handleDelete.bind(this);
- }
+ const handlePageChange = (page) => {
+ getSummitDocs(term, page, perPage, order, orderDir);
+ };
- componentDidMount() {
- const { currentSummit } = this.props;
- if (currentSummit) {
- this.props.getSummitDocs();
- }
- }
-
- handleEdit(summitdoc_id) {
- const { currentSummit, history } = this.props;
- history.push(`/app/summits/${currentSummit.id}/summitdocs/${summitdoc_id}`);
- }
-
- handlePageChange(page) {
- const { term, order, orderDir, perPage } = this.props;
- this.props.getSummitDocs(term, page, perPage, order, orderDir);
- }
-
- handleSort(index, key, dir, func) {
- const { term, page, perPage } = this.props;
- this.props.getSummitDocs(term, page, perPage, key, dir);
- }
-
- handleSearch(term) {
- const { order, orderDir, page, perPage } = this.props;
- this.props.getSummitDocs(term, page, perPage, order, orderDir);
- }
-
- handleNew(ev) {
- const { currentSummit, history } = this.props;
+ const handlePerPageChange = (newPerPage) => {
+ getSummitDocs(term, DEFAULT_CURRENT_PAGE, newPerPage, order, orderDir);
+ };
+
+ const handleSort = (key, dir) => {
+ getSummitDocs(term, currentPage, perPage, key, dir);
+ };
+
+ const handleSearch = (newTerm) => {
+ getSummitDocs(newTerm, DEFAULT_CURRENT_PAGE, perPage, order, orderDir);
+ };
+
+ const handleNew = (ev) => {
+ ev.preventDefault();
history.push(`/app/summits/${currentSummit.id}/summitdocs/new`);
- }
-
- handleDelete(summitDocId) {
- const { deleteSummitDoc, summitDocs } = this.props;
- let summitDoc = summitDocs.find((s) => s.id === summitDocId);
-
- Swal.fire({
- title: T.translate("general.are_you_sure"),
- text: T.translate("summitdoc.delete_warning") + " " + summitDoc.label,
- type: "warning",
- showCancelButton: true,
- confirmButtonColor: "#DD6B55",
- confirmButtonText: T.translate("general.yes_delete")
- }).then(function (result) {
- if (result.value) {
- deleteSummitDoc(summitDocId);
- }
- });
- }
-
- render() {
- const {
- currentSummit,
- summitDocs,
- lastPage,
- currentPage,
- term,
- order,
- orderDir,
- totalSummitDocs
- } = this.props;
-
- const columns = [
- { columnKey: "id", value: T.translate("general.id"), sortable: true },
- {
- columnKey: "label",
- value: T.translate("summitdoc.label"),
- sortable: true
- },
- { columnKey: "description", value: T.translate("summitdoc.description") },
- {
- columnKey: "event_types_string",
- value: T.translate("summitdoc.event_types")
- }
- ];
-
- const table_options = {
- sortCol: order,
- sortDir: orderDir,
- actions: {
- edit: { onClick: this.handleEdit },
- delete: { onClick: this.handleDelete }
- }
- };
-
- if (!currentSummit.id) return ;
-
- return (
-
-
- {" "}
- {T.translate("summitdoc.list")} ({totalSummitDocs})
-
-
-
-
-
-
-
-
+ };
+
+ const columns = [
+ { columnKey: "id", header: T.translate("general.id"), sortable: true },
+ {
+ columnKey: "label",
+ header: T.translate("summitdoc.label"),
+ sortable: true
+ },
+ {
+ columnKey: "description",
+ header: T.translate("summitdoc.description")
+ },
+ {
+ columnKey: "selection_plan",
+ header: T.translate("summitdoc.selection_plan")
+ },
+ {
+ columnKey: "event_types_string",
+ header: T.translate("summitdoc.event_types")
+ }
+ ];
+
+ const tableOptions = { sortCol: order, sortDir: orderDir };
+
+ if (!currentSummit.id) return
;
+
+ return (
+
+
+ {" "}
+ {T.translate("summitdoc.list")} ({totalSummitDocs})
+
+
+ }>
+ {T.translate("summitdoc.add")}
+
+
+
+ {summitDocs.length === 0 && (
+
{T.translate("summitdoc.no_items")}
+ )}
+
+ {summitDocs.length > 0 && (
+
+ row.label}
+ deleteDialogBody={(name) =>
+ `${T.translate("summitdoc.delete_warning")} ${name}`
+ }
+ confirmButtonColor="error"
+ />
-
- {summitDocs.length === 0 && (
-
{T.translate("summitdoc.no_items")}
- )}
-
- {summitDocs.length > 0 && (
-
- )}
-
- );
- }
-}
+ )}
+
+ );
+};
const mapStateToProps = ({ currentSummitState, summitDocListState }) => ({
currentSummit: currentSummitState.currentSummit,
diff --git a/src/reducers/summitdoc/summitdoc-list-reducer.js b/src/reducers/summitdoc/summitdoc-list-reducer.js
index d07ba9a8a..58358817c 100644
--- a/src/reducers/summitdoc/summitdoc-list-reducer.js
+++ b/src/reducers/summitdoc/summitdoc-list-reducer.js
@@ -9,16 +9,16 @@
* 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 T from "i18n-react/dist/i18n-react";
import {
RECEIVE_SUMMITDOCS,
REQUEST_SUMMITDOCS,
SUMMITDOC_DELETED
} from "../../actions/summitdoc-actions";
-
import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions";
-import { LOGOUT_USER } from "openstack-uicore-foundation/lib/security/actions";
const DEFAULT_STATE = {
summitDocs: [],
@@ -39,32 +39,37 @@ const summitDocListReducer = (state = DEFAULT_STATE, action) => {
return DEFAULT_STATE;
}
case REQUEST_SUMMITDOCS: {
- let { order, orderDir, term } = payload;
+ const { order, orderDir, term, currentPage, perPage } = payload;
- return { ...state, order, orderDir, term };
+ return { ...state, order, orderDir, term, currentPage, perPage };
}
case RECEIVE_SUMMITDOCS: {
- let { total, last_page, current_page } = payload.response;
- let summitDocs = payload.response.data.map((s) => {
- return {
- id: s.id,
- name: s.name,
- label: s.label,
- description: s.description,
- event_types_string: s.event_types.map((et) => et.name).join(", ")
- };
- });
+ const {
+ total: totalSummitDocs,
+ last_page: lastPage,
+ current_page: currentPage
+ } = payload.response;
+ const summitDocs = payload.response.data.map((s) => ({
+ id: s.id,
+ name: s.name,
+ label: s.label,
+ description: s.description,
+ event_types_string: s.show_always
+ ? T.translate("summitdoc.all_types")
+ : s.event_types.map((et) => et.name).join(", "),
+ selection_plan: s.selection_plan?.name
+ }));
return {
...state,
- summitDocs: summitDocs,
- currentPage: current_page,
- totalSummitDocs: total,
- lastPage: last_page
+ summitDocs,
+ currentPage,
+ totalSummitDocs,
+ lastPage
};
}
case SUMMITDOC_DELETED: {
- let { summitDocId } = payload;
+ const { summitDocId } = payload;
return {
...state,
summitDocs: state.summitDocs.filter((s) => s.id !== summitDocId)