Add FormSubmitButton to handle form submission consistently (#28701)

Closes #28256

Signed-off-by: jchong <jhchong92@gmail.com>
This commit is contained in:
jhchong92 2024-05-14 22:34:30 +08:00 committed by GitHub
parent 4b6d0fb651
commit 5cacf8637c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 118 additions and 47 deletions

View file

@ -8,7 +8,7 @@ import {
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { SelectControl } from "@keycloak/keycloak-ui-shared"; import { FormSubmitButton, SelectControl } from "@keycloak/keycloak-ui-shared";
import { useAdminClient } from "../../admin-client"; import { useAdminClient } from "../../admin-client";
import { useAlerts } from "../../components/alert/Alerts"; import { useAlerts } from "../../components/alert/Alerts";
import { FormAccess } from "../../components/form/FormAccess"; import { FormAccess } from "../../components/form/FormAccess";
@ -28,7 +28,7 @@ export default function CreateFlow() {
const { realm } = useRealm(); const { realm } = useRealm();
const { addAlert } = useAlerts(); const { addAlert } = useAlerts();
const form = useForm<AuthenticationFlowRepresentation>(); const form = useForm<AuthenticationFlowRepresentation>();
const { handleSubmit } = form; const { handleSubmit, formState } = form;
const onSubmit = async (formValues: AuthenticationFlowRepresentation) => { const onSubmit = async (formValues: AuthenticationFlowRepresentation) => {
const flow = { ...formValues, builtIn: false, topLevel: true }; const flow = { ...formValues, builtIn: false, topLevel: true };
@ -77,9 +77,14 @@ export default function CreateFlow() {
}))} }))}
/> />
<ActionGroup> <ActionGroup>
<Button data-testid="create" type="submit"> <FormSubmitButton
formState={formState}
data-testid="create"
allowInvalid
allowNonDirty
>
{t("create")} {t("create")}
</Button> </FormSubmitButton>
<Button <Button
data-testid="cancel" data-testid="cancel"
variant="link" variant="link"

View file

@ -6,6 +6,7 @@ import { FormProvider, useForm, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { import {
FormSubmitButton,
SelectControl, SelectControl,
TextAreaControl, TextAreaControl,
TextControl, TextControl,
@ -32,12 +33,7 @@ type ScopeFormProps = {
export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const form = useForm<ClientScopeDefaultOptionalType>({ mode: "onChange" }); const form = useForm<ClientScopeDefaultOptionalType>({ mode: "onChange" });
const { const { control, handleSubmit, setValue, formState } = form;
control,
handleSubmit,
setValue,
formState: { isDirty, isValid },
} = form;
const { realm } = useRealm(); const { realm } = useRealm();
const providers = useLoginProviders(); const providers = useLoginProviders();
@ -194,13 +190,7 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => {
min={0} min={0}
/> />
<ActionGroup> <ActionGroup>
<Button <FormSubmitButton formState={formState}>{t("save")}</FormSubmitButton>
variant="primary"
type="submit"
isDisabled={!isDirty || !isValid}
>
{t("save")}
</Button>
<Button <Button
variant="link" variant="link"
component={(props) => ( component={(props) => (

View file

@ -11,7 +11,7 @@ import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { TextControl } from "@keycloak/keycloak-ui-shared"; import { FormSubmitButton, TextControl } from "@keycloak/keycloak-ui-shared";
import { useAdminClient } from "../../admin-client"; import { useAdminClient } from "../../admin-client";
import { useAlerts } from "../../components/alert/Alerts"; import { useAlerts } from "../../components/alert/Alerts";
import { FormAccess } from "../../components/form/FormAccess"; import { FormAccess } from "../../components/form/FormAccess";
@ -39,7 +39,7 @@ export default function ImportForm() {
const navigate = useNavigate(); const navigate = useNavigate();
const { realm } = useRealm(); const { realm } = useRealm();
const form = useForm<FormFields>(); const form = useForm<FormFields>();
const { handleSubmit, setValue } = form; const { handleSubmit, setValue, formState } = form;
const [imported, setImported] = useState<ClientRepresentation>({}); const [imported, setImported] = useState<ClientRepresentation>({});
const { addAlert, addError } = useAlerts(); const { addAlert, addError } = useAlerts();
@ -119,9 +119,13 @@ export default function ImportForm() {
<TextControl name="protocol" label={t("type")} readOnly /> <TextControl name="protocol" label={t("type")} readOnly />
<CapabilityConfig unWrap={true} /> <CapabilityConfig unWrap={true} />
<ActionGroup> <ActionGroup>
<Button variant="primary" type="submit"> <FormSubmitButton
formState={formState}
allowInvalid
allowNonDirty
>
{t("save")} {t("save")}
</Button> </FormSubmitButton>
<Button <Button
variant="link" variant="link"
component={(props) => ( component={(props) => (

View file

@ -57,6 +57,7 @@ export default function CreateClientRole() {
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
<RoleForm <RoleForm
form={form}
onSubmit={onSubmit} onSubmit={onSubmit}
cancelLink={toClient({ cancelLink={toClient({
realm, realm,

View file

@ -1,14 +1,24 @@
import { ActionGroup, Button, PageSection } from "@patternfly/react-core"; import { ActionGroup, Button, PageSection } from "@patternfly/react-core";
import { SubmitHandler, useFormContext, useWatch } from "react-hook-form"; import {
SubmitHandler,
UseFormReturn,
useFormContext,
useWatch,
} from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, To } from "react-router-dom"; import { Link, To } from "react-router-dom";
import { TextAreaControl, TextControl } from "@keycloak/keycloak-ui-shared"; import {
FormSubmitButton,
TextAreaControl,
TextControl,
} from "@keycloak/keycloak-ui-shared";
import { FormAccess } from "../form/FormAccess"; import { FormAccess } from "../form/FormAccess";
import { AttributeForm } from "../key-value-form/AttributeForm"; import { AttributeForm } from "../key-value-form/AttributeForm";
import { ViewHeader } from "../view-header/ViewHeader"; import { ViewHeader } from "../view-header/ViewHeader";
export type RoleFormProps = { export type RoleFormProps = {
form: UseFormReturn<AttributeForm>;
onSubmit: SubmitHandler<AttributeForm>; onSubmit: SubmitHandler<AttributeForm>;
cancelLink: To; cancelLink: To;
role: "manage-realm" | "manage-clients"; role: "manage-realm" | "manage-clients";
@ -16,6 +26,7 @@ export type RoleFormProps = {
}; };
export const RoleForm = ({ export const RoleForm = ({
form: { formState },
onSubmit, onSubmit,
cancelLink, cancelLink,
role, role,
@ -65,9 +76,14 @@ export const RoleForm = ({
isDisabled={roleName?.includes("default-roles") ?? false} isDisabled={roleName?.includes("default-roles") ?? false}
/> />
<ActionGroup> <ActionGroup>
<Button data-testid="save" type="submit" variant="primary"> <FormSubmitButton
formState={formState}
data-testid="save"
allowInvalid
allowNonDirty
>
{t("save")} {t("save")}
</Button> </FormSubmitButton>
<Button <Button
data-testid="cancel" data-testid="cancel"
variant="link" variant="link"

View file

@ -9,7 +9,7 @@ import {
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TextControl } from "@keycloak/keycloak-ui-shared"; import { FormSubmitButton, TextControl } from "@keycloak/keycloak-ui-shared";
import { useAdminClient } from "../admin-client"; import { useAdminClient } from "../admin-client";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
@ -34,7 +34,7 @@ export const GroupsModal = ({
const form = useForm({ const form = useForm({
defaultValues: { name: rename?.name }, defaultValues: { name: rename?.name },
}); });
const { handleSubmit } = form; const { handleSubmit, formState } = form;
const submitForm = async (group: GroupRepresentation) => { const submitForm = async (group: GroupRepresentation) => {
group.name = group.name?.trim(); group.name = group.name?.trim();
@ -71,15 +71,16 @@ export const GroupsModal = ({
isOpen={true} isOpen={true}
onClose={handleModalToggle} onClose={handleModalToggle}
actions={[ actions={[
<Button <FormSubmitButton
formState={formState}
data-testid={`${rename ? "rename" : "create"}Group`} data-testid={`${rename ? "rename" : "create"}Group`}
key="confirm" key="confirm"
variant="primary"
type="submit"
form="group-form" form="group-form"
allowInvalid
allowNonDirty
> >
{t(rename ? "rename" : "create")} {t(rename ? "rename" : "create")}
</Button>, </FormSubmitButton>,
<Button <Button
id="modal-cancel" id="modal-cancel"
data-testid="cancel" data-testid="cancel"

View file

@ -48,6 +48,7 @@ export default function CreateRealmRole() {
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
<RoleForm <RoleForm
form={form}
onSubmit={onSubmit} onSubmit={onSubmit}
cancelLink={toRealmRoles({ realm })} cancelLink={toRealmRoles({ realm })}
role="manage-realm" role="manage-realm"

View file

@ -346,6 +346,7 @@ export default function RealmRoleTabs() {
{...detailsTab} {...detailsTab}
> >
<RoleForm <RoleForm
form={form}
onSubmit={onSubmit} onSubmit={onSubmit}
role={clientRoleMatch ? "manage-clients" : "manage-realm"} role={clientRoleMatch ? "manage-clients" : "manage-realm"}
cancelLink={ cancelLink={

View file

@ -4,7 +4,7 @@ import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { TextControl } from "@keycloak/keycloak-ui-shared"; import { FormSubmitButton, TextControl } from "@keycloak/keycloak-ui-shared";
import { useAdminClient } from "../../admin-client"; import { useAdminClient } from "../../admin-client";
import { DefaultSwitchControl } from "../../components/SwitchControl"; import { DefaultSwitchControl } from "../../components/SwitchControl";
import { useAlerts } from "../../components/alert/Alerts"; import { useAlerts } from "../../components/alert/Alerts";
@ -30,7 +30,7 @@ export default function NewRealmForm() {
mode: "onChange", mode: "onChange",
}); });
const { handleSubmit, setValue } = form; const { handleSubmit, setValue, formState } = form;
const handleFileChange = (obj?: object) => { const handleFileChange = (obj?: object) => {
const defaultRealm = { id: "", realm: "", enabled: true }; const defaultRealm = { id: "", realm: "", enabled: true };
@ -81,9 +81,13 @@ export default function NewRealmForm() {
defaultValue={true} defaultValue={true}
/> />
<ActionGroup> <ActionGroup>
<Button variant="primary" type="submit"> <FormSubmitButton
formState={formState}
allowInvalid
allowNonDirty
>
{t("create")} {t("create")}
</Button> </FormSubmitButton>
<Button variant="link" onClick={() => navigate(-1)}> <Button variant="link" onClick={() => navigate(-1)}>
{t("cancel")} {t("cancel")}
</Button> </Button>

View file

@ -4,6 +4,7 @@ import { UserProfileMetadata } from "@keycloak/keycloak-admin-client/lib/defs/us
import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; import type UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation";
import { import {
FormErrorText, FormErrorText,
FormSubmitButton,
HelpItem, HelpItem,
SwitchControl, SwitchControl,
TextControl, TextControl,
@ -78,14 +79,9 @@ export const UserForm = ({
const { whoAmI } = useWhoAmI(); const { whoAmI } = useWhoAmI();
const currentLocale = whoAmI.getLocale(); const currentLocale = whoAmI.getLocale();
const { const { handleSubmit, setValue, watch, control, reset, formState } = form;
handleSubmit, const { errors } = formState;
setValue,
watch,
control,
reset,
formState: { errors },
} = form;
const watchUsernameInput = watch("username"); const watchUsernameInput = watch("username");
const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>( const [selectedGroups, setSelectedGroups] = useState<GroupRepresentation[]>(
[], [],
@ -330,18 +326,19 @@ export const UserForm = ({
)} )}
<ActionGroup> <ActionGroup>
<Button <FormSubmitButton
formState={formState}
data-testid={!user?.id ? "create-user" : "save-user"} data-testid={!user?.id ? "create-user" : "save-user"}
isDisabled={ isDisabled={
!user?.id && !user?.id &&
!watchUsernameInput && !watchUsernameInput &&
realm.registrationEmailAsUsername === false realm.registrationEmailAsUsername === false
} }
variant="primary" allowNonDirty
type="submit" allowInvalid
> >
{user?.id ? t("save") : t("create")} {user?.id ? t("save") : t("create")}
</Button> </FormSubmitButton>
<Button <Button
data-testid="cancel-create-user" data-testid="cancel-create-user"
variant="link" variant="link"

View file

@ -0,0 +1,47 @@
import { Button, ButtonProps } from "@patternfly/react-core";
import { PropsWithChildren } from "react";
import { FieldValues, FormState } from "react-hook-form";
export type FormSubmitButtonProps = Omit<ButtonProps, "isDisabled"> & {
formState: FormState<FieldValues>;
allowNonDirty?: boolean;
allowInvalid?: boolean;
isDisabled?: boolean;
};
const isSubmittable = (
formState: FormState<FieldValues>,
allowNonDirty: boolean,
allowInvalid: boolean,
) => {
return (
(formState.isValid || allowInvalid) &&
(formState.isDirty || allowNonDirty) &&
!formState.isLoading &&
!formState.isValidating &&
!formState.isSubmitting
);
};
export const FormSubmitButton = ({
formState,
isDisabled = false,
allowInvalid = false,
allowNonDirty = false,
children,
...rest
}: PropsWithChildren<FormSubmitButtonProps>) => {
return (
<Button
variant="primary"
isDisabled={
(formState && !isSubmittable(formState, allowNonDirty, allowInvalid)) ||
isDisabled
}
{...rest}
type="submit"
>
{children}
</Button>
);
};

View file

@ -36,6 +36,10 @@ export {
export { IconMapper } from "./icons/IconMapper"; export { IconMapper } from "./icons/IconMapper";
export { FormPanel } from "./scroll-form/FormPanel"; export { FormPanel } from "./scroll-form/FormPanel";
export { ScrollForm, mainPageContentId } from "./scroll-form/ScrollForm"; export { ScrollForm, mainPageContentId } from "./scroll-form/ScrollForm";
export {
FormSubmitButton,
type FormSubmitButtonProps,
} from "./buttons/FormSubmitButton";
export { UserProfileFields } from "./user-profile/UserProfileFields"; export { UserProfileFields } from "./user-profile/UserProfileFields";
export { export {
beerify, beerify,