Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ SPONSOR_USERS_SCOPES="show-medata/read show-medata/write access-requests/read ac
EMAIL_SCOPES="clients/read templates/read templates/write emails/read"
FILE_UPLOAD_SCOPES="files/upload"
FILE_UPLOAD_ALLOWED_EXTENSIONS="pdf,jpg,jpeg,png,ppt,key,pptx"
FILE_UPLOAD_USE_V4=
SPONSOR_PAGES_API_URL=https://sponsor-pages-api.dev.fnopen.com
SPONSOR_REPORTS_API_URL=https://sponsor-reports-api.dev.fnopen.com
SPONSOR_PAGES_SCOPES="page-template/read page-template/write show-page/read show-page/write media-upload/read"
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
"@uiw/codemirror-theme-sublime": "^4.21.23",
"@uiw/react-codemirror": "^4.21.7",
"@uiw/react-color-sketch": "^2.1.0",
"@uppy/aws-s3": "^5.1.0",
"@uppy/core": "^5.2.0",
"ably": "^2.30.0",
"address-rfc2822": "^2.2.2",
"awesome-bootstrap-checkbox": "^1.0.1",
Expand Down Expand Up @@ -95,7 +97,7 @@
"moment-duration-format": "^2.3.2",
"moment-timezone": "^0.5.33",
"mui-color-input": "^9.0.0",
"openstack-uicore-foundation": "5.0.73-beta.1",
"openstack-uicore-foundation": "5.0.75",
"p-limit": "^6.1.0",
"path-browserify": "^1.0.1",
"postcss-loader": "^6.2.1",
Expand Down
1 change: 1 addition & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ window.SPONSOR_USERS_API_URL = process.env.SPONSOR_USERS_API_URL;
window.SPONSOR_PAGES_API_URL = process.env.SPONSOR_PAGES_API_URL;
window.SPONSOR_REPORTS_API_URL = process.env.SPONSOR_REPORTS_API_URL;
window.FILE_UPLOAD_API_BASE_URL = process.env.FILE_UPLOAD_API_BASE_URL;
window.FILE_UPLOAD_USE_V4 = process.env.FILE_UPLOAD_USE_V4;
window.SIGNAGE_BASE_URL = process.env.SIGNAGE_BASE_URL;
window.INVENTORY_API_BASE_URL = process.env.INVENTORY_API_BASE_URL;
window.OAUTH2_CLIENT_ID = process.env.OAUTH2_CLIENT_ID;
Expand Down
82 changes: 62 additions & 20 deletions src/components/forms/__tests__/company-form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,50 @@ jest.mock("openstack-uicore-foundation/lib/utils/query-actions", () => ({
})
}));

function mockUploadInput({
id,
onUploadComplete,
onUploadStart,
onRemove,
value
}) {
return (
<div
data-testid={`upload-input-${id}`}
data-logo={value?.[0]?.file_url ?? ""}
>
<button
type="button"
data-testid={`trigger-upload-${id}`}
onClick={() => {
onUploadStart?.();
onUploadComplete({ path: "/uploads/", name: `${id}.png` });
}}
>
Upload
</button>
<button
type="button"
data-testid={`cancel-upload-${id}`}
onClick={() => {
onUploadStart?.();
onRemove?.();
}}
>
Cancel
</button>
</div>
);
}

jest.mock(
"openstack-uicore-foundation/lib/components/inputs/upload-input-v3",
() => ({
__esModule: true,
default: ({ id, onUploadComplete, onUploadStart, value }) => (
<div
data-testid={`upload-input-${id}`}
data-logo={value?.[0]?.file_url ?? ""}
>
<button
type="button"
data-testid={`trigger-upload-${id}`}
onClick={() => {
onUploadStart?.();
onUploadComplete({ path: "/uploads/", name: `${id}.png` });
}}
>
Upload
</button>
</div>
)
})
() => ({ __esModule: true, default: mockUploadInput })
);

jest.mock(
"openstack-uicore-foundation/lib/components/inputs/upload-input-v4",
() => ({ __esModule: true, default: mockUploadInput })
);

jest.mock(
Expand Down Expand Up @@ -160,6 +182,7 @@ const Harness = ({
onDeleteSponsorship={onDeleteSponsorship}
/>
<pre data-testid="debug-values">{JSON.stringify(formik.values)}</pre>
<pre data-testid="debug-saving">{String(isSaving)}</pre>
</FormikProvider>
);
};
Expand Down Expand Up @@ -252,6 +275,25 @@ describe("CompanyForm", () => {
expect(onAttach).not.toHaveBeenCalled();
});

it("clears the saving state when a new company cancels an in-flight V4 upload", async () => {
const user = userEvent.setup();
const onRemove = jest.fn(() => Promise.resolve());
window.FILE_UPLOAD_USE_V4 = "true";

try {
render(<Harness initialEntity={BASE_ENTITY} onRemove={onRemove} />);

await act(async () => {
await user.click(screen.getByTestId("cancel-upload-logo"));
});

expect(screen.getByTestId("debug-saving")).toHaveTextContent("false");
expect(onRemove).not.toHaveBeenCalled();
} finally {
delete window.FILE_UPLOAD_USE_V4;
}
});

it("reverts the logo preview when onAttach fails for an existing company", async () => {
const user = userEvent.setup();
let rejectAttach;
Expand Down
6 changes: 2 additions & 4 deletions src/components/forms/badge-feature-type-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { useFormikContext } from "formik";
import { Box, Grid2, InputLabel } from "@mui/material";
import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield";
import FormikTextEditor from "openstack-uicore-foundation/lib/components/mui/formik-inputs/texteditor";
import UploadInputV3 from "openstack-uicore-foundation/lib/components/inputs/upload-input-v3";
import FileUploadInput from "../inputs/file-upload-input";
import useScrollToError from "../../hooks/useScrollToError";
import { KB, MAX_BADGE_FEATURE_IMAGE_UPLOAD_SIZE } from "../../utils/constants";

Expand Down Expand Up @@ -101,16 +101,14 @@ const BadgeFeatureTypeForm = ({ entity, onRemoveImage, onUploadingChange }) => {
<Grid2 container spacing={2} sx={{ mb: 2 }}>
<Grid2 size={12}>
<InputLabel>{T.translate("edit_badge_feature.image")}</InputLabel>
<UploadInputV3
<FileUploadInput
id="image"
name="image"
value={getImageValue(formik.values.image)}
onUploadComplete={handleUploadComplete}
onUploadStart={() => onUploadingChange(true)}
onError={() => onUploadingChange(false)}
onRemove={handleRemoveImage}
postUrl={`${window.FILE_UPLOAD_API_BASE_URL}/api/v1/files/upload`}
djsConfig={{ withCredentials: true }}
maxFiles={1}
canAdd={!formik.values.image}
mediaType={{
Expand Down
13 changes: 6 additions & 7 deletions src/components/forms/company-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ import {
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 FileUploadInput from "../inputs/file-upload-input";
import useScrollToError from "../../hooks/useScrollToError";
import FormikTextEditor from "../inputs/formik-text-editor";
import MuiFormikAsyncAutocomplete from "../mui/formik-inputs/mui-formik-async-select";
Expand Down Expand Up @@ -99,6 +99,9 @@ const CompanyForm = ({
onRemove(initialEntity, field)
.catch(() => formik.setFieldValue(field, prevValue))
.finally(() => setIsSaving(false));
} else {
// also reached when cancelling an in-flight upload
setIsSaving(false);
}
};

Expand Down Expand Up @@ -370,16 +373,14 @@ const CompanyForm = ({
<Grid2 container size={12} mt={2} mb={2}>
<Grid2 size={6}>
<InputLabel>{T.translate("edit_company.logo")}</InputLabel>
<UploadInputV3
<FileUploadInput
id="logo"
name="logo"
value={getLogoValue(formik.values.logo)}
onUploadComplete={handleLogoUploadComplete("logo")}
onUploadStart={() => setIsSaving(true)}
onError={() => setIsSaving(false)}
onRemove={handleLogoRemove("logo")}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker Cancelling a logo upload on a new company (Add company page) leaves Save permanently disabled under V4, and uicore#385 does not fix this case.

onUploadStart sets isSaving to true (line 378). In V3, cancelling an in-flight upload resets it by accident:

  1. removeFile calls cancelUpload, which emits canceled.
  2. Dropzone 5.7.2's default canceled handler re-emits it as error (dropzone/dist/dropzone.js:937-938).
  3. uicore's DropzoneJS passes every error to props.onError (dropzone/index.js:692-698), which here is setIsSaving(false).
  4. removedfile then calls onRemove.

So V3 calls both onError and onRemove on a cancel.

V4 calls neither. OpenStackweb/openstack-uicore-foundation#385 adds only onRemove to handleDeleteUploading, not onError. That is the right call, since a cancel is not an error. But handleLogoRemove (line 94) resets isSaving only when initialEntity?.id is set:

const handleLogoRemove = (field) => () => {
  formik.setFieldValue(field, "");
  if (initialEntity?.id) {
    setIsSaving(true);
    ...
      .finally(() => setIsSaving(false));
  }
};

With id 0, isSaving stays true. Then Save is disabled (edit-company-page.js:169) and onSubmit returns early (if (isSaving) return), so the user has to reload. An existing company recovers through the removeLogo .finally, so the PR description's "Company form (existing company)" bullet doesn't cover this case. With the flag on and #385 pinned, the Add company form will still get stuck.

Repro (after #385 is pinned): set FILE_UPLOAD_USE_V4=true, open Companies → Add new, start a logo upload, delete the row while it is uploading, then try to save.

Suggested fix: reset isSaving on the no-id branch so the form doesn't depend on the upload input calling onError on a cancel. That matches the else branch of handleLogoUploadComplete above and handleRemoveImage in badge-feature-type-form.js:

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));
  } else {
    setIsSaving(false);
  }
};

Please add a test for it too: with the flag set to "true", a new company cancels an in-flight logo upload and Save is enabled again.

@caseylocker caseylocker Nov 11, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and fixed in 55c4e92. With id 0, handleLogoRemove never reset isSaving, and under V4 nothing else does, since OpenStackweb/openstack-uicore-foundation#385 adds onRemove on cancel but not onError.

I took your else branch as written. It matches the else in handleLogoUploadComplete, and the form no longer depends on the input reporting a cancel as an error.

Test added in company-form.test.js: with the flag set to "true", a new company starts a logo upload, cancels it, and the saving state clears without calling the delete API. The upload input is mocked in that file, so the test drives the V4 callback contract (onUploadStart, then onRemove, no onError) and not Uppy itself. It fails with the else branch removed.

I also added the new-company case to the PR description under gate 2.

postUrl={`${window.FILE_UPLOAD_API_BASE_URL}/api/v1/files/upload`}
djsConfig={{ withCredentials: true }}
maxFiles={1}
canAdd={!formik.values.logo}
mediaType={{
Expand All @@ -389,16 +390,14 @@ const CompanyForm = ({
</Grid2>
<Grid2 size={6}>
<InputLabel>{T.translate("edit_company.big_logo")}</InputLabel>
<UploadInputV3
<FileUploadInput
id="big_logo"
name="big_logo"
value={getLogoValue(formik.values.big_logo)}
onUploadComplete={handleLogoUploadComplete("big_logo")}
onUploadStart={() => setIsSaving(true)}
onError={() => setIsSaving(false)}
onRemove={handleLogoRemove("big_logo")}
postUrl={`${window.FILE_UPLOAD_API_BASE_URL}/api/v1/files/upload`}
djsConfig={{ withCredentials: true }}
maxFiles={1}
canAdd={!formik.values.big_logo}
mediaType={{
Expand Down
10 changes: 3 additions & 7 deletions src/components/forms/event-material-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ 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 UploadInputV3 from "openstack-uicore-foundation/lib/components/inputs/upload-input-v3";
import TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3";
import FileUploadInput from "../inputs/file-upload-input";
import { scrollToError, hasErrors } from "../../utils/methods";
import {
ALLOWED_SLIDES_FORMATS,
Expand Down Expand Up @@ -237,14 +237,12 @@ const EventMaterialForm = ({ entity, errors, event, onSubmit }) => {
{" "}
{T.translate("edit_event_material.slide")} (max size 500Mb)
</label>
<UploadInputV3
<FileUploadInput
id="slide"
onUploadComplete={onMediaUploadComplete}
value={mediaInputValue}
mediaType={slideMediaType}
postUrl={`${window.FILE_UPLOAD_API_BASE_URL}/api/v1/files/upload`}
error={hasErrors("slide", errorsState)}
djsConfig={{ withCredentials: true }}
parallelChunkUploads
/>
</div>
Expand Down Expand Up @@ -305,15 +303,13 @@ const EventMaterialForm = ({ entity, errors, event, onSubmit }) => {
entityState.media_upload_type.max_size / KB
}Mb )`}
</label>
<UploadInputV3
<FileUploadInput
id={`media_upload_${mediaType.id}`}
onUploadComplete={onMediaUploadComplete}
value={mediaInputValue}
mediaType={mediaType}
onRemove={handleRemoveFile}
postUrl={`${window.FILE_UPLOAD_API_BASE_URL}/api/v1/files/upload`}
error={hasErrors(mediaType.name, errorsState)}
djsConfig={{ withCredentials: true }}
parallelChunkUploads
/>
</div>
Expand Down
81 changes: 81 additions & 0 deletions src/components/inputs/__tests__/file-upload-input.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from "react";
import { render } from "@testing-library/react";
import FileUploadInput from "../file-upload-input";

const renderedProps = {};

jest.mock(
"openstack-uicore-foundation/lib/components/inputs/upload-input-v3",
() => ({
__esModule: true,
default: (props) => {
renderedProps.v3 = props;
return null;
}
})
);

jest.mock(
"openstack-uicore-foundation/lib/components/inputs/upload-input-v4",
() => ({
__esModule: true,
default: (props) => {
renderedProps.v4 = props;
return null;
}
})
);

const API_BASE = "https://file-upload-api.test";

describe("FileUploadInput", () => {
const onUploadComplete = jest.fn();

beforeEach(() => {
delete renderedProps.v3;
delete renderedProps.v4;
window.FILE_UPLOAD_API_BASE_URL = API_BASE;
});

afterEach(() => {
delete window.FILE_UPLOAD_API_BASE_URL;
delete window.FILE_UPLOAD_USE_V4;
});

const renderInput = () =>
render(
<FileUploadInput
id="slide"
value={[]}
onUploadComplete={onUploadComplete}
parallelChunkUploads
/>
);

it("uploads direct to storage through V4 when the flag is 'true'", () => {
window.FILE_UPLOAD_USE_V4 = "true";

renderInput();

expect(renderedProps.v3).toBeUndefined();
expect(renderedProps.v4.postUrl).toBe(`${API_BASE}/api/v1/files`);
expect(renderedProps.v4.onUploadComplete).toBe(onUploadComplete);
expect(renderedProps.v4).not.toHaveProperty("djsConfig");
expect(renderedProps.v4).not.toHaveProperty("parallelChunkUploads");
});

it.each([undefined, "false", "1"])(
"keeps the chunked V3 upload when the flag is %p",
(flag) => {
if (flag !== undefined) window.FILE_UPLOAD_USE_V4 = flag;

renderInput();

expect(renderedProps.v4).toBeUndefined();
expect(renderedProps.v3.postUrl).toBe(`${API_BASE}/api/v1/files/upload`);
expect(renderedProps.v3.djsConfig).toEqual({ withCredentials: true });
expect(renderedProps.v3.parallelChunkUploads).toBe(true);
expect(renderedProps.v3.onUploadComplete).toBe(onUploadComplete);
}
);
});
Loading
Loading