Add FormSubmitButton
to handle form submission consistently (#28701)
Closes #28256 Signed-off-by: jchong <jhchong92@gmail.com>
This commit is contained in:
parent
4b6d0fb651
commit
5cacf8637c
12 changed files with 118 additions and 47 deletions
|
@ -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"
|
||||||
|
|
|
@ -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) => (
|
||||||
|
|
|
@ -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) => (
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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={
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
47
js/libs/ui-shared/src/buttons/FormSubmitButton.tsx
Normal file
47
js/libs/ui-shared/src/buttons/FormSubmitButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue