diff --git a/src/components/forms/__tests__/company-form.test.js b/src/components/forms/__tests__/company-form.test.js
new file mode 100644
index 000000000..ed1dcc90d
--- /dev/null
+++ b/src/components/forms/__tests__/company-form.test.js
@@ -0,0 +1,357 @@
+// ---- Mocks must come first ----
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+jest.mock("openstack-uicore-foundation/lib/utils/query-actions", () => ({
+ getCountryList: jest.fn((callback) => {
+ callback([
+ { iso_code: "AR", name: "Argentina" },
+ { iso_code: "US", name: "United States" }
+ ]);
+ return Promise.resolve();
+ })
+}));
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/inputs/upload-input-v3",
+ () => ({
+ __esModule: true,
+ default: ({ id, onUploadComplete, onUploadStart, value }) => (
+
+
+
+ )
+ })
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield",
+ () =>
+ function MockTextField({ name }) {
+ return ;
+ }
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/formik-inputs/select",
+ () =>
+ function MockSelect({ name, children }) {
+ return {children}
;
+ }
+);
+
+jest.mock(
+ "openstack-uicore-foundation/lib/components/mui/table",
+ () =>
+ // eslint-disable-next-line react/prop-types
+ function MockTable({ data, onDelete }) {
+ return (
+
+ {data.map((row) => (
+
+ ))}
+
+ );
+ }
+);
+
+jest.mock(
+ "../../inputs/formik-text-editor",
+ () =>
+ function MockTextEditor({ name }) {
+ return ;
+ }
+);
+
+jest.mock("../../mui/showConfirmDialog", () =>
+ jest.fn(() => Promise.resolve(true))
+);
+
+jest.mock("../../../hooks/useScrollToError", () => jest.fn());
+
+jest.mock("mui-color-input", () => ({
+ MuiColorInput: ({ value, onChange, onBlur, name }) => (
+ onChange(e.target.value)}
+ onBlur={(e) => onBlur({ target: { name, value: e.target.value } })}
+ />
+ )
+}));
+
+// ---- Now imports ----
+/* eslint-disable import/first */
+import React, { useState } from "react";
+import {
+ render,
+ screen,
+ waitFor,
+ act,
+ fireEvent
+} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { FormikProvider, useFormik } from "formik";
+import CompanyForm from "../company-form";
+import showConfirmDialog from "../../mui/showConfirmDialog";
+/* eslint-enable import/first */
+
+const BASE_ENTITY = {
+ id: 0,
+ name: "",
+ url: "",
+ contact_email: "",
+ member_level: "",
+ color: "",
+ admin_email: "",
+ city: "",
+ state: "",
+ country: "",
+ industry: "",
+ products: "",
+ contributions: "",
+ description: "",
+ overview: "",
+ commitment: "",
+ logo: "",
+ big_logo: "",
+ project_sponsorships: []
+};
+
+// Mirrors the formik wiring edit-company-page.js provides in production, so
+// CompanyForm's useFormikContext() has a real context to read from.
+const Harness = ({
+ initialEntity,
+ onAttach = jest.fn(() => Promise.resolve()),
+ onRemove = jest.fn(() => Promise.resolve()),
+ onAddSponsorship,
+ onDeleteSponsorship
+}) => {
+ const [isSaving, setIsSaving] = useState(false);
+ const formik = useFormik({ initialValues: { ...initialEntity } });
+
+ return (
+
+
+ {JSON.stringify(formik.values)}
+
+ );
+};
+
+const readFormikValues = () =>
+ JSON.parse(screen.getByTestId("debug-values").textContent);
+
+describe("CompanyForm", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ window.APP_CLIENT_NAME = "";
+ });
+
+ test.each([
+ [
+ "resolves a stored ISO code to its label when editing",
+ { ...BASE_ENTITY, id: 1, name: "Acme Corp", country: "AR" },
+ "Argentina"
+ ],
+ ["leaves the country field empty for a new company", BASE_ENTITY, ""]
+ ])("%s", async (_label, entity, expectedValue) => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByRole("combobox")).toHaveValue(expectedValue);
+ });
+ });
+
+ it("stores the full country option (not just the ISO string) once resolved", async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("combobox")).toHaveValue("Argentina");
+ });
+
+ expect(readFormikValues().country).toEqual({
+ value: "AR",
+ label: "Argentina"
+ });
+ });
+
+ it("commits a newly picked color into the form on blur", async () => {
+ render(
+
+ );
+
+ const colorInput = screen.getByTestId("color-input");
+ fireEvent.change(colorInput, { target: { value: "#00ff00" } });
+ fireEvent.blur(colorInput);
+
+ await waitFor(() => {
+ expect(readFormikValues().color).toBe("#00ff00");
+ });
+ });
+
+ describe("logo upload", () => {
+ it("does not attach to the backend for a new (unsaved) company", async () => {
+ const user = userEvent.setup();
+ const onAttach = jest.fn(() => Promise.resolve());
+ const onRemove = jest.fn(() => Promise.resolve());
+
+ render(
+
+ );
+
+ await act(async () => {
+ await user.click(screen.getByTestId("trigger-upload-big_logo"));
+ });
+
+ expect(onAttach).not.toHaveBeenCalled();
+ });
+
+ it("reverts the logo preview when onAttach fails for an existing company", async () => {
+ const user = userEvent.setup();
+ let rejectAttach;
+ const onAttach = jest.fn(
+ () =>
+ new Promise((_, rej) => {
+ rejectAttach = rej;
+ })
+ );
+ const onRemove = jest.fn(() => Promise.resolve());
+
+ render(
+
+ );
+
+ await act(async () => {
+ await user.click(screen.getByTestId("trigger-upload-logo"));
+ });
+
+ expect(screen.getByTestId("upload-input-logo")).toHaveAttribute(
+ "data-logo",
+ "/uploads/logo.png"
+ );
+
+ await act(async () => {
+ rejectAttach(new Error("network error"));
+ });
+
+ await waitFor(() =>
+ expect(screen.getByTestId("upload-input-logo")).toHaveAttribute(
+ "data-logo",
+ "old-logo.png"
+ )
+ );
+ });
+ });
+
+ describe("sponsorship deletion", () => {
+ const entityWithSponsorship = {
+ ...BASE_ENTITY,
+ id: 5,
+ name: "Acme Corp",
+ project_sponsorships: [
+ {
+ id: 10,
+ sponsored_project: { id: 1, name: "Project A" },
+ name: "Gold",
+ supporting_companies: [{ id: 99, company_id: 5 }]
+ }
+ ]
+ };
+
+ beforeEach(() => {
+ window.APP_CLIENT_NAME = "openstack";
+ });
+
+ it("deletes the sponsorship when the user confirms", async () => {
+ showConfirmDialog.mockResolvedValueOnce(true);
+ const onDeleteSponsorship = jest.fn(() => Promise.resolve());
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ await user.click(screen.getByText("delete-10"));
+
+ await waitFor(() => {
+ expect(onDeleteSponsorship).toHaveBeenCalledWith(1, 10, 99);
+ });
+ });
+
+ it("does not delete the sponsorship when the user declines", async () => {
+ showConfirmDialog.mockResolvedValueOnce(false);
+ const onDeleteSponsorship = jest.fn(() => Promise.resolve());
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ await user.click(screen.getByText("delete-10"));
+
+ await waitFor(() => expect(showConfirmDialog).toHaveBeenCalled());
+ expect(onDeleteSponsorship).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/components/forms/company-form.js b/src/components/forms/company-form.js
index 5292aeb2d..5b216b71d 100644
--- a/src/components/forms/company-form.js
+++ b/src/components/forms/company-form.js
@@ -1,5 +1,5 @@
-/*
- * Copyright 2017 OpenStack Foundation
+/**
+ * Copyright 2024 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
@@ -11,416 +11,415 @@
* limitations under the License.
* */
-import React from "react";
+import React, { useState } from "react";
+import PropTypes from "prop-types";
import T from "i18n-react/dist/i18n-react";
-import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css";
-import UploadInput from "openstack-uicore-foundation/lib/components/inputs/upload-input"
-import Input from "openstack-uicore-foundation/lib/components/inputs/text-input"
-import CountryDropdown from "openstack-uicore-foundation/lib/components/inputs/country-dropdown"
-import Dropdown from "openstack-uicore-foundation/lib/components/inputs/dropdown"
-import Table from "openstack-uicore-foundation/lib/components/table";
-import TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3";
-import { isEmpty, scrollToError, shallowEqual } from "../../utils/methods";
+import { useFormikContext } from "formik";
+import {
+ Button,
+ FormControl,
+ Grid2,
+ InputLabel,
+ MenuItem,
+ Select
+} from "@mui/material";
+import UploadInputV3 from "openstack-uicore-foundation/lib/components/inputs/upload-input-v3";
+import { getCountryList } from "openstack-uicore-foundation/lib/utils/query-actions";
+import Table from "openstack-uicore-foundation/lib/components/mui/table";
+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 useScrollToError from "../../hooks/useScrollToError";
+import FormikTextEditor from "../inputs/formik-text-editor";
+import MuiFormikAsyncAutocomplete from "../mui/formik-inputs/mui-formik-async-select";
+import MuiFormikColorField from "../mui/formik-inputs/mui-formik-color-field";
+import showConfirmDialog from "../mui/showConfirmDialog";
-class CompanyForm extends React.Component {
- constructor(props) {
- super(props);
+const MEMBER_LEVELS = [
+ { label: "Platinum", value: "Platinum" },
+ { label: "Gold", value: "Gold" },
+ { label: "StartUp", value: "StartUp" },
+ { label: "Corporate", value: "Corporate" },
+ { label: "Mention", value: "Mention" },
+ { label: "None", value: "None" }
+];
- this.state = {
- entity: { ...props.entity },
- errors: props.errors,
- selectedSponsoredProject: null,
- selectedSponsorShipType: null,
- sponsorShipTypes: []
- };
-
- this.handleChange = this.handleChange.bind(this);
- this.handleUploadLogo = this.handleUploadLogo.bind(this);
- this.handleUploadBigLogo = this.handleUploadBigLogo.bind(this);
- this.handleRemoveFile = this.handleRemoveFile.bind(this);
- this.handleSubmit = this.handleSubmit.bind(this);
- this.handleSelectedSponsoredProject =
- this.handleSelectedSponsoredProject.bind(this);
- this.handleSelectedSponsorshipType =
- this.handleSelectedSponsorshipType.bind(this);
- this.onAddSponsorshipType = this.onAddSponsorshipType.bind(this);
- }
+const getLogoValue = (value) => {
+ if (!value) return [];
+ if (typeof value === "string") return [{ filename: value, file_url: value }];
+ return [{ filename: value.filename, file_url: value.filepath }];
+};
- componentDidUpdate(prevProps) {
- const state = {};
- scrollToError(this.props.errors);
-
- if (!shallowEqual(prevProps.entity, this.props.entity)) {
- state.entity = { ...this.props.entity };
- state.errors = {};
- }
+const CompanyForm = ({
+ initialEntity,
+ sponsoredProjects = [],
+ onAttach,
+ onRemove,
+ onAddSponsorship,
+ onDeleteSponsorship,
+ isSaving,
+ setIsSaving
+}) => {
+ const formik = useFormikContext();
+ const [selectedSponsoredProject, setSelectedSponsoredProject] =
+ useState(null);
+ const [selectedSponsorShipType, setSelectedSponsorShipType] = useState(null);
+ const [sponsorShipTypes, setSponsorShipTypes] = useState([]);
- if (!shallowEqual(prevProps.errors, this.props.errors)) {
- state.errors = { ...this.props.errors };
- }
-
- if (!isEmpty(state)) {
- this.setState({ ...this.state, ...state });
- }
- }
+ useScrollToError(formik, true);
- handleChange(ev) {
- const entity = { ...this.state.entity };
- const errors = { ...this.state.errors };
- let { value, id } = ev.target;
-
- if (ev.target.type === "checkbox") {
- value = ev.target.checked;
+ const handleLogoUploadComplete = (field) => (response) => {
+ const path =
+ response.path && response.name
+ ? `${response.path}${response.name}`
+ : response.file_url ?? response.path ?? "";
+ const uploadLogo = {
+ ...response,
+ filepath: path,
+ filename: response.name
+ };
+ delete uploadLogo.path;
+ delete uploadLogo.name;
+ const prevValue = formik.values[field];
+ formik.setFieldValue(field, uploadLogo);
+ if (initialEntity?.id) {
+ setIsSaving(true);
+ onAttach(initialEntity, uploadLogo, field)
+ .catch(() => formik.setFieldValue(field, prevValue))
+ .finally(() => setIsSaving(false));
+ } else {
+ setIsSaving(false);
}
+ };
- if (ev.target.type === "memberinput") {
- entity.email = "";
+ const handleLogoRemove = (field) => () => {
+ formik.setFieldValue(field, "");
+ if (initialEntity?.id) {
+ setIsSaving(true);
+ const prevValue = formik.values[field];
+ onRemove(initialEntity, field)
+ .catch(() => formik.setFieldValue(field, prevValue))
+ .finally(() => setIsSaving(false));
}
+ };
- errors[id] = "";
- entity[id] = value;
- this.setState({ entity, errors });
- }
-
- handleUploadLogo(file) {
- const formData = new FormData();
- formData.append("file", file);
- this.props.onAttach(this.state.entity, formData, "logo");
- }
-
- handleUploadBigLogo(file) {
- const formData = new FormData();
- formData.append("file", file);
- this.props.onAttach(this.state.entity, formData, "big");
- }
-
- handleRemoveFile(picAttr) {
- const entity = { ...this.state.entity };
- entity[picAttr] = "";
- this.setState({ entity });
- }
-
- handleSubmit(publish, ev) {
- ev.preventDefault();
- this.props.onSubmit(this.state.entity);
- }
-
- handleSelectedSponsoredProject(ev) {
- const { sponsoredProjects } = this.props;
+ const handleSelectedSponsoredProject = (ev) => {
const { value } = ev.target;
+ const project = sponsoredProjects.find((p) => p.id == value);
+ setSelectedSponsoredProject(value);
+ setSponsorShipTypes(
+ project
+ ? project.sponsorship_types.map((s) => ({ label: s.name, value: s.id }))
+ : []
+ );
+ setSelectedSponsorShipType(null);
+ };
- const project = sponsoredProjects.find((e) => e.id == value);
+ const handleAddSponsorshipType = () => {
+ if (
+ !onAddSponsorship ||
+ !selectedSponsoredProject ||
+ !selectedSponsorShipType ||
+ isSaving
+ )
+ return;
+ setIsSaving(true);
+ onAddSponsorship(selectedSponsoredProject, selectedSponsorShipType, {
+ id: 0,
+ company: { id: formik.values.id }
+ }).finally(() => setIsSaving(false));
+ };
- this.setState({
- ...this.state,
- selectedSponsoredProject: value,
- sponsorShipTypes: project
- ? project.sponsorship_types.map((s) => ({ label: s.name, value: s.id }))
- : [],
- selectedSponsorShipType: null
+ const handleDeleteSponsorship = async (sponsorshipId) => {
+ const sponsorship = initialEntity?.project_sponsorships?.find(
+ (ps) => ps.id === sponsorshipId
+ );
+ if (!sponsorship) return;
+ const supportingCompany = sponsorship.supporting_companies?.find(
+ (sc) => sc.company_id === formik.values.id
+ );
+ if (!supportingCompany) return;
+
+ const confirmed = await showConfirmDialog({
+ title: T.translate("general.are_you_sure"),
+ text: T.translate("edit_company.delete_supporting_company_warning")
});
- }
- handleSelectedSponsorshipType(ev) {
- const { value } = ev.target;
- this.setState({ ...this.state, selectedSponsorShipType: value });
- }
+ if (confirmed) {
+ if (isSaving) return;
+ setIsSaving(true);
+ onDeleteSponsorship(
+ sponsorship.sponsored_project.id,
+ sponsorshipId,
+ supportingCompany.id
+ ).finally(() => setIsSaving(false));
+ }
+ };
- onAddSponsorshipType(ev) {
- ev.preventDefault();
- const { selectedSponsoredProject, selectedSponsorShipType, entity } =
- this.state;
- if (!selectedSponsoredProject) return;
- if (!selectedSponsorShipType) return;
- this.props.addSponsoreProjectSponsorship(
- entity.id,
- selectedSponsoredProject,
- selectedSponsorShipType
- );
- }
+ const sponsored_project_columns = [
+ {
+ columnKey: "project_name",
+ header: T.translate("edit_company.project_name")
+ },
+ { columnKey: "name", header: T.translate("edit_company.sponsorship_type") }
+ ];
- render() {
- const { entity } = this.state;
- const { sponsoredProjects } = this.props;
+ const sponsored_projects_ddl = sponsoredProjects.map((sp) => ({
+ label: sp.name,
+ value: sp.id
+ }));
- const member_levels_ddl = [
- { label: "Platinum", value: "Platinum" },
- { label: "Gold", value: "Gold" },
- { label: "StartUp", value: "StartUp" },
- { label: "Corporate", value: "Corporate" },
- { label: "Mention", value: "Mention" },
- { label: "None", value: "None" }
- ];
+ const showOpenStackSection =
+ formik.values.id > 0 && window.APP_CLIENT_NAME === "openstack";
- const sponsored_projects_ddl =
- sponsoredProjects && Array.isArray(sponsoredProjects)
- ? sponsoredProjects.map((sp) => ({
- label: sp.name,
- value: sp.id
- }))
- : [];
+ return (
+
+
+
+ {T.translate("edit_company.name")} *
+
+
+
+
+ {T.translate("edit_company.url")}
+
+
+
+
+ {T.translate("edit_company.contact_email")}
+
+
+
- const columns = [
- {
- columnKey: "project_name",
- value: T.translate("edit_company.project_name")
- },
- { columnKey: "name", value: T.translate("edit_company.sponsorship_type") }
- ];
+
+
+ {T.translate("edit_company.member_level")}
+
+
+ {MEMBER_LEVELS.map((lvl) => (
+
+ ))}
+
+
+
+
+ {T.translate("edit_company.color")}
+
+
+
+
+
+ {T.translate("edit_company.admin_email")}
+
+
+
- const table_options = {
- actions: {
- delete: { onClick: this.props.onDeleteSponsorship }
- }
- };
+
+
+ {T.translate("edit_company.city")}
+
+
+
+
+
+ {T.translate("edit_company.state")}
+
+
+
+
+
+ {T.translate("edit_company.country")}
+
+ getCountryList(callback)}
+ formatOption={(country) => ({
+ value: country.iso_code,
+ label: country.name
+ })}
+ defaultOptions
+ />
+
+
+
+ {T.translate("edit_company.industry")}
+
+
+
+
+
+ {T.translate("edit_company.products")}
+
+
+
+
+
+ {T.translate("edit_company.contributions")}
+
+
+
- return (
-