diff --git a/changes/27454-validation b/changes/27454-validation new file mode 100644 index 000000000000..fd08c518e385 --- /dev/null +++ b/changes/27454-validation @@ -0,0 +1 @@ +- Accept any "http://" or "https://" prefixed Fleet web URL diff --git a/frontend/components/forms/RegistrationForm/FleetDetails/FleetDetails.tests.jsx b/frontend/components/forms/RegistrationForm/FleetDetails/FleetDetails.tests.jsx index 7ca5b9249dad..e447bfeaf31f 100644 --- a/frontend/components/forms/RegistrationForm/FleetDetails/FleetDetails.tests.jsx +++ b/frontend/components/forms/RegistrationForm/FleetDetails/FleetDetails.tests.jsx @@ -5,6 +5,8 @@ import { renderWithSetup } from "test/test-utils"; import FleetDetails from "components/forms/RegistrationForm/FleetDetails"; +import INVALID_SERVER_URL_MESSAGE from "utilities/error_messages"; + describe("FleetDetails - form", () => { const handleSubmitSpy = jest.fn(); it("renders", () => { @@ -29,24 +31,30 @@ describe("FleetDetails - form", () => { ).toBeInTheDocument(); }); - it("validates the fleet web address field starts with https://", async () => { + it("validates the Fleet server URL field starts with 'https://' or 'http://'", async () => { const { user } = renderWithSetup( ); - await user.type( - screen.getByRole("textbox", { name: "Fleet web address" }), - "http://gnar.Fleet.co" - ); - await user.click(screen.getByRole("button", { name: "Next" })); + const inputField = screen.getByRole("textbox", { + name: "Fleet web address", + }); + const nextButton = screen.getByRole("button", { name: "Next" }); + + await user.type(inputField, "gnar.Fleet.co"); + await user.click(nextButton); expect(handleSubmitSpy).not.toHaveBeenCalled(); - expect( - screen.getByText("Fleet web address must start with https://") - ).toBeInTheDocument(); + expect(screen.getByText(INVALID_SERVER_URL_MESSAGE)).toBeInTheDocument(); + + await user.type(inputField, "localhost:8080"); + await user.click(nextButton); + + expect(handleSubmitSpy).not.toHaveBeenCalled(); + expect(screen.getByText(INVALID_SERVER_URL_MESSAGE)).toBeInTheDocument(); }); - it("submits the form when valid", async () => { + it("submits the form with valid https link", async () => { const { user } = renderWithSetup( ); @@ -61,4 +69,19 @@ describe("FleetDetails - form", () => { server_url: "https://gnar.Fleet.co", }); }); + it("submits the form with valid http link", async () => { + const { user } = renderWithSetup( + + ); + // when + await user.type( + screen.getByRole("textbox", { name: "Fleet web address" }), + "http://localhost:8080" + ); + await user.click(screen.getByRole("button", { name: "Next" })); + // then + expect(handleSubmitSpy).toHaveBeenCalledWith({ + server_url: "http://localhost:8080", + }); + }); }); diff --git a/frontend/components/forms/RegistrationForm/FleetDetails/helpers.js b/frontend/components/forms/RegistrationForm/FleetDetails/helpers.js index f66547dfa737..d1c08acf1c7f 100644 --- a/frontend/components/forms/RegistrationForm/FleetDetails/helpers.js +++ b/frontend/components/forms/RegistrationForm/FleetDetails/helpers.js @@ -1,4 +1,8 @@ -import { size, startsWith } from "lodash"; +import { size } from "lodash"; + +import validUrl from "components/forms/validators/valid_url"; + +import INVALID_SERVER_URL_MESSAGE from "utilities/error_messages"; const validate = (formData) => { const errors = {}; @@ -6,10 +10,14 @@ const validate = (formData) => { if (!fleetWebAddress) { errors.server_url = "Fleet web address must be completed"; - } - - if (fleetWebAddress && !startsWith(fleetWebAddress, "https://")) { - errors.server_url = "Fleet web address must start with https://"; + } else if ( + !validUrl({ + url: fleetWebAddress, + protocols: ["http", "https"], + allowLocalHost: true, + }) + ) { + errors.server_url = INVALID_SERVER_URL_MESSAGE; } const valid = !size(errors); diff --git a/frontend/components/forms/validators/valid_url/valid_url.ts b/frontend/components/forms/validators/valid_url/valid_url.ts index 1707e991f1c8..4b94cd76ca40 100644 --- a/frontend/components/forms/validators/valid_url/valid_url.ts +++ b/frontend/components/forms/validators/valid_url/valid_url.ts @@ -6,8 +6,13 @@ interface IValidUrl { url: string; /** Validate protocols specified */ protocols?: ("http" | "https")[]; + allowLocalHost?: boolean; } -export default ({ url, protocols }: IValidUrl): boolean => { - return isURL(url, { protocols }); +export default ({ url, protocols, allowLocalHost = false }: IValidUrl) => { + return isURL(url, { + protocols, + require_protocol: !!protocols?.length, + require_tld: !allowLocalHost, + }); }; diff --git a/frontend/docs/patterns.md b/frontend/docs/patterns.md index f417de7482c8..f17bad4f5ea3 100644 --- a/frontend/docs/patterns.md +++ b/frontend/docs/patterns.md @@ -208,9 +208,10 @@ When building a React-controlled form: - Use the native HTML `form` element to wrap the form. - Use a `Button` component with `type="submit"` for its submit button. - Write a submit handler, e.g. `handleSubmit`, that accepts an `evt: -React.FormEvent` argument and, critically, calls `evt.preventDefault()` in its -body. This prevents the HTML `form`'s default submit behavior from interfering with our custom +React.FormEvent` argument and, critically: + - calls `evt.preventDefault()` in its body. This prevents the HTML `form`'s default submit behavior from interfering with our custom handler's logic. + - does nothing (e.g., returns `null`) if the form is in an invalid state, preventing submission by any means. - Assign that handler to the `form`'s `onSubmit` property (*not* the submit button's `onClick`) ### Data validation @@ -248,7 +249,9 @@ const onInputChange = ({ name, value }: IFormField) => { // new errors are only set onBlur const errsToSet: Record = {}; Object.keys(formErrors).forEach((k) => { + // @ts-ignore if (newErrs[k]) { + // @ts-ignore errsToSet[k] = newErrs[k]; } }); @@ -270,7 +273,6 @@ const onInputBlur = () => { ```tsx const onFormSubmit = (evt: React.MouseEvent) => { evt.preventDefault(); - // return null if there are errors const errs = validateFormData(formData); if (Object.keys(errs).length > 0) { diff --git a/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx b/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx index f18a9b594826..c8f61836bef1 100644 --- a/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx +++ b/frontend/pages/admin/OrgSettingsPage/cards/WebAddress/WebAddress.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { size } from "lodash"; import Button from "components/buttons/Button"; // @ts-ignore @@ -7,6 +8,8 @@ import validUrl from "components/forms/validators/valid_url"; import SectionHeader from "components/SectionHeader"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import INVALID_SERVER_URL_MESSAGE from "utilities/error_messages"; + import { IAppConfigFormProps, IFormField } from "../constants"; interface IWebAddressFormData { @@ -16,9 +19,25 @@ interface IWebAddressFormData { interface IWebAddressFormErrors { server_url?: string | null; } - const baseClass = "app-config-form"; +const validateFormData = ({ serverURL }: IWebAddressFormData) => { + const errors: IWebAddressFormErrors = {}; + if (!serverURL) { + errors.server_url = "Fleet server URL must be present"; + } else if ( + !validUrl({ + url: serverURL, + protocols: ["http", "https"], + allowLocalHost: true, + }) + ) { + errors.server_url = INVALID_SERVER_URL_MESSAGE; + } + + return errors; +}; + const WebAddress = ({ appConfig, handleSubmit, @@ -35,23 +54,34 @@ const WebAddress = ({ const [formErrors, setFormErrors] = useState({}); const onInputChange = ({ name, value }: IFormField) => { - setFormData({ ...formData, [name]: value }); - setFormErrors({}); + const newFormData = { ...formData, [name]: value }; + setFormData(newFormData); + const newErrs = validateFormData(newFormData); + // only set errors that are updates of existing errors + // new errors are only set onBlur + const errsToSet: Record = {}; + Object.keys(formErrors).forEach((k) => { + // @ts-ignore + if (newErrs[k]) { + // @ts-ignore + errsToSet[k] = newErrs[k]; + } + }); + setFormErrors(errsToSet); }; - const validateForm = () => { - const errors: IWebAddressFormErrors = {}; - if (!serverURL) { - errors.server_url = "Fleet server URL must be present"; - } else if (!validUrl({ url: serverURL, protocols: ["http", "https"] })) { - errors.server_url = `${serverURL} is not a valid URL`; - } - - setFormErrors(errors); + const onInputBlur = () => { + setFormErrors(validateFormData(formData)); }; - const onFormSubmit = (evt: React.MouseEvent) => { + const onFormSubmit = (evt: React.FormEvent) => { evt.preventDefault(); + // return null if there are errors + const errs = validateFormData(formData); + if (size(errs)) { + setFormErrors(errs); + return; + } // Formatting of API not UI const formDataToSubmit = { @@ -79,7 +109,7 @@ const WebAddress = ({ name="serverURL" value={serverURL} parseTarget - onBlur={validateForm} + onBlur={onInputBlur} error={formErrors.server_url} tooltip="The base URL of this instance for use in Fleet links." disabled={gitOpsModeEnabled} @@ -90,7 +120,7 @@ const WebAddress = ({