diff --git a/frontend/src/components/urlInput/urlInput.tsx b/frontend/src/components/urlInput/urlInput.tsx index d5b1d5d3e..605a3a911 100644 --- a/frontend/src/components/urlInput/urlInput.tsx +++ b/frontend/src/components/urlInput/urlInput.tsx @@ -31,9 +31,17 @@ interface URLInputProps { lens: Lens; type: ValidSiteTypeEnum; errors?: ErrorsType; + pendingURLError?: string; + onPendingURLChange?: (pendingURL: string) => void; } -const URLInput: FC = ({ lens, type, errors }) => { +const URLInput: FC = ({ + lens, + type, + errors, + pendingURLError, + onPendingURLChange, +}) => { const interop = lens.interop(); const { fields: urls, @@ -55,6 +63,11 @@ const URLInput: FC = ({ lens, type, errors }) => { s.valid_types.includes(type), ); + const setPendingURL = (url: string) => { + setNewURL(url); + onPendingURLChange?.(url.trim()); + }; + const handleAdd = () => { if (!newURL || !selectedSite) return; const cleanedURL = cleanURL(selectedSite?.regex, newURL); @@ -69,7 +82,7 @@ const URLInput: FC = ({ lens, type, errors }) => { if (selectRef.current) selectRef.current.value = ""; if (inputRef.current) inputRef.current.value = ""; setSelectedSite(undefined); - setNewURL(""); + setPendingURL(""); }; const handleInput = (url: string) => { @@ -89,6 +102,7 @@ const URLInput: FC = ({ lens, type, errors }) => { const updatedURL = cleanURL(site.regex, url); if (updatedURL) { inputRef.current.value = updatedURL; + setPendingURL(updatedURL); return true; } } @@ -99,7 +113,6 @@ const URLInput: FC = ({ lens, type, errors }) => { const match = handleInput(e.clipboardData.getData("text/plain")); if (match) { e.preventDefault(); - setNewURL(e.currentTarget.value); } }; @@ -159,14 +172,15 @@ const URLInput: FC = ({ lens, type, errors }) => { ref={inputRef} onBlur={(e) => handleInput(e.currentTarget.value)} placeholder="URL" - onChange={(e) => setNewURL(e.currentTarget.value)} + onChange={(e) => setPendingURL(e.currentTarget.value)} onPaste={handlePaste} - className="w-50" + className={`w-50 ${pendingURLError ? "is-invalid" : ""}`} /> + {pendingURLError &&
{pendingURLError}
} ); }; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 8cffd8c19..b9a9403a1 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,7 +1,9 @@ export { default as useAuth } from "./useAuth"; +export { useBeforeUnload } from "./useBeforeUnload"; export { useCurrentUser } from "./useCurrentUser"; export { default as useEditFilter } from "./useEditFilter"; export { default as usePagination } from "./usePagination"; +export { usePendingURLField } from "./usePendingURLField"; export { useQueryParams } from "./useQueryParams"; export { useToast } from "./useToast"; export { useUser } from "./useUser"; diff --git a/frontend/src/hooks/usePendingURLField.ts b/frontend/src/hooks/usePendingURLField.ts new file mode 100644 index 000000000..110992602 --- /dev/null +++ b/frontend/src/hooks/usePendingURLField.ts @@ -0,0 +1,20 @@ +import { useCallback } from "react"; +import type { + FieldValues, + Path, + PathValue, + UseFormSetValue, +} from "react-hook-form"; + +export const usePendingURLField = ( + setValue: UseFormSetValue, + name: Path, +) => + useCallback( + (pendingURL: string) => { + setValue(name, pendingURL as PathValue>, { + shouldValidate: true, + }); + }, + [name, setValue], + ); diff --git a/frontend/src/pages/performers/performerForm/PerformerForm.tsx b/frontend/src/pages/performers/performerForm/PerformerForm.tsx index 89c65491d..4a1107c28 100644 --- a/frontend/src/pages/performers/performerForm/PerformerForm.tsx +++ b/frontend/src/pages/performers/performerForm/PerformerForm.tsx @@ -34,7 +34,7 @@ import { type PerformerEditOptionsInput, ValidSiteTypeEnum, } from "src/graphql"; -import { useBeforeUnload } from "src/hooks/useBeforeUnload"; +import { useBeforeUnload, usePendingURLField } from "src/hooks"; import DiffPerformer from "./diff"; import ExistingPerformerAlert from "./ExistingPerformerAlert"; import { type PerformerFormData, PerformerSchema } from "./schema"; @@ -183,10 +183,12 @@ const PerformerForm: FC = ({ piercings: initial?.piercings ?? performer?.piercings ?? [], images: initial?.images ?? performer?.images ?? [], urls: initial?.urls ?? performer?.urls ?? [], + pendingUrl: "", }, }); const lens = useLens({ control }); + const onPendingURLChange = usePendingURLField(setValue, "pendingUrl"); const [activeTab, setActiveTab] = useState("personal"); const [updateAliases, setUpdateAliases] = useState( @@ -300,6 +302,7 @@ const PerformerForm: FC = ({ error: errors.urls?.find?.((u) => u?.url?.message)?.url?.message, tab: "links", }, + { error: errors.pendingUrl?.message, tab: "links" }, ].filter((e) => e.error) as { error: string; tab: string }[]; return ( @@ -668,6 +671,8 @@ const PerformerForm: FC = ({ lens={lens.focus("urls").defined()} type={ValidSiteTypeEnum.PERFORMER} errors={errors.urls} + pendingURLError={errors.pendingUrl?.message} + onPendingURLChange={onPendingURLChange} /> setActiveTab("images")} /> diff --git a/frontend/src/pages/performers/performerForm/__tests__/PerformerForm.test.tsx b/frontend/src/pages/performers/performerForm/__tests__/PerformerForm.test.tsx index 9ba796787..30414ae3a 100644 --- a/frontend/src/pages/performers/performerForm/__tests__/PerformerForm.test.tsx +++ b/frontend/src/pages/performers/performerForm/__tests__/PerformerForm.test.tsx @@ -714,6 +714,24 @@ describe("PerformerForm", () => { expect(callback).not.toHaveBeenCalled(); }); + it("blocks submit when a URL is entered but not added", async () => { + const callback = vi.fn(); + const { user } = renderEdit(callback); + await user.click(screen.getByRole("tab", { name: "Links" })); + const urlInput = (await waitFor(() => { + const el = document.querySelector('.URLInput input[placeholder="URL"]'); + if (!el) throw new Error("URLInput not ready"); + return el; + })) as HTMLInputElement; + await user.type(urlInput, "https://example.org/pending"); + await submit(user); + const matches = await screen.findAllByText( + "Click Add to include the entered URL before submitting", + ); + expect(matches.length).toBeGreaterThan(0); + expect(callback).not.toHaveBeenCalled(); + }); + it("hides breast type when gender is MALE", async () => { const { user } = renderEdit(); expect(screen.getByLabelText("Breast type")).toBeInTheDocument(); diff --git a/frontend/src/pages/performers/performerForm/schema.ts b/frontend/src/pages/performers/performerForm/schema.ts index 532fa0c4b..6e09f08cb 100644 --- a/frontend/src/pages/performers/performerForm/schema.ts +++ b/frontend/src/pages/performers/performerForm/schema.ts @@ -157,6 +157,13 @@ export const PerformerSchema = yup.object({ }), ) .ensure(), + pendingUrl: yup + .string() + .test( + "no-pending-url", + "Click Add to include the entered URL before submitting", + (value) => !value, + ), note: yup.string().required("Edit note is required"), }); diff --git a/frontend/src/pages/scenes/sceneForm/SceneForm.tsx b/frontend/src/pages/scenes/sceneForm/SceneForm.tsx index 110d841ee..8878ec994 100644 --- a/frontend/src/pages/scenes/sceneForm/SceneForm.tsx +++ b/frontend/src/pages/scenes/sceneForm/SceneForm.tsx @@ -29,7 +29,7 @@ import { type SceneEditDetailsInput, ValidSiteTypeEnum, } from "src/graphql"; -import { useBeforeUnload } from "src/hooks/useBeforeUnload"; +import { useBeforeUnload, usePendingURLField } from "src/hooks"; import { formatDuration, parseDuration, performerHref } from "src/utils"; import DiffScene from "./diff"; import ExistingSceneAlert from "./ExistingSceneAlert"; @@ -66,6 +66,7 @@ const SceneForm: FC = ({ control, handleSubmit, watch, + setValue, formState: { errors }, } = useForm({ resolver: yupResolver(SceneSchema), @@ -83,6 +84,7 @@ const SceneForm: FC = ({ images: initial?.images ?? scene?.images ?? [], studio: initial?.studio ?? scene?.studio ?? undefined, tags: initial?.tags ?? scene?.tags ?? [], + pendingUrl: "", performers: (initial?.performers ?? scene?.performers ?? []).map((p) => ({ performerId: p.performer.id, name: p.performer.name, @@ -106,6 +108,7 @@ const SceneForm: FC = ({ }); const lens = useLens({ control }); + const onPendingURLChange = usePendingURLField(setValue, "pendingUrl"); const fieldData = watch(); const [oldSceneChanges, newSceneChanges] = useMemo( @@ -318,6 +321,7 @@ const SceneForm: FC = ({ error: errors.urls?.find?.((u) => u?.url?.message)?.url?.message, tab: "links", }, + { error: errors.pendingUrl?.message, tab: "links" }, ].filter((e) => e.error) as { error: string; tab: string }[]; return ( @@ -500,6 +504,8 @@ const SceneForm: FC = ({ lens={lens.focus("urls").defined()} type={ValidSiteTypeEnum.SCENE} errors={errors.urls} + pendingURLError={errors.pendingUrl?.message} + onPendingURLChange={onPendingURLChange} /> setActiveTab("images")} /> diff --git a/frontend/src/pages/scenes/sceneForm/__tests__/SceneForm.test.tsx b/frontend/src/pages/scenes/sceneForm/__tests__/SceneForm.test.tsx index 2ffdf4dc2..c20272ccf 100644 --- a/frontend/src/pages/scenes/sceneForm/__tests__/SceneForm.test.tsx +++ b/frontend/src/pages/scenes/sceneForm/__tests__/SceneForm.test.tsx @@ -482,6 +482,24 @@ describe("SceneForm", () => { expect(callback).not.toHaveBeenCalled(); }); + it("blocks submit when a URL is entered but not added", async () => { + const callback = vi.fn(); + const { user } = renderEdit(callback); + await user.click(screen.getByRole("tab", { name: "Links" })); + const urlInput = (await waitFor(() => { + const el = document.querySelector('.URLInput input[placeholder="URL"]'); + if (!el) throw new Error("URLInput not ready"); + return el; + })) as HTMLInputElement; + await user.type(urlInput, "https://pending.example"); + await submit(user); + const matches = await screen.findAllByText( + "Click Add to include the entered URL before submitting", + ); + expect(matches.length).toBeGreaterThan(0); + expect(callback).not.toHaveBeenCalled(); + }); + it("disables submit when saving=true", async () => { const { user } = renderForm( , diff --git a/frontend/src/pages/scenes/sceneForm/schema.ts b/frontend/src/pages/scenes/sceneForm/schema.ts index c849e9b45..4a3986ebe 100644 --- a/frontend/src/pages/scenes/sceneForm/schema.ts +++ b/frontend/src/pages/scenes/sceneForm/schema.ts @@ -121,6 +121,13 @@ export const SceneSchema = yup.object({ }), ) .ensure(), + pendingUrl: yup + .string() + .test( + "no-pending-url", + "Click Add to include the entered URL before submitting", + (value) => !value, + ), note: yup.string().required("Edit note is required"), });