diff --git a/packages/elements-react/src/components/form/__snapshots__/form-resolver.test.tsx.snap b/packages/elements-react/src/components/form/__snapshots__/form-resolver.test.tsx.snap new file mode 100644 index 00000000..ef96dcd8 --- /dev/null +++ b/packages/elements-react/src/components/form/__snapshots__/form-resolver.test.tsx.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`case={"method":"code","code":"","email":""} 1`] = ` +{ + "errors": { + "code": { + "context": { + "property": "code", + }, + "id": 4000002, + "text": "Property code is missing", + "type": "error", + }, + }, + "values": { + "code": "", + "email": "", + "method": "code", + }, +} +`; + +exports[`case={"method":"code","code":"123456","email":""} 1`] = ` +{ + "errors": {}, + "values": { + "code": "123456", + "email": "", + "method": "code", + }, +} +`; + +exports[`case={"method":"code","code":"123456","email":"some@example.com"} 1`] = ` +{ + "errors": {}, + "values": { + "code": "123456", + "email": "some@example.com", + "method": "code", + }, +} +`; + +exports[`case={"method":"code"} 1`] = ` +{ + "errors": { + "code": { + "context": { + "property": "code", + }, + "id": 4000002, + "text": "Property code is missing", + "type": "error", + }, + }, + "values": { + "method": "code", + }, +} +`; + +exports[`case={"method":"password"} 1`] = ` +{ + "errors": {}, + "values": { + "method": "password", + }, +} +`; diff --git a/packages/elements-react/src/components/form/form-provider.tsx b/packages/elements-react/src/components/form/form-provider.tsx index 78e31d88..a9e78a68 100644 --- a/packages/elements-react/src/components/form/form-provider.tsx +++ b/packages/elements-react/src/components/form/form-provider.tsx @@ -3,9 +3,10 @@ import { UiNode, UiNodeGroupEnum } from "@ory/client-fetch" import { PropsWithChildren } from "react" -import { useOryFlow } from "../../context" import { FormProvider, useForm } from "react-hook-form" +import { useOryFlow } from "../../context" import { computeDefaultValues } from "./form-helpers" +import { useOryFormResolver } from "./form-resolver" export function OryFormProvider({ children, @@ -21,6 +22,8 @@ export function OryFormProvider({ const methods = useForm({ // TODO: Generify this, so we have typesafety in the submit handler. defaultValues: computeDefaultValues(defaultNodes), + resolver: useOryFormResolver(), }) + return {children} } diff --git a/packages/elements-react/src/components/form/form-resolver.test.tsx b/packages/elements-react/src/components/form/form-resolver.test.tsx new file mode 100644 index 00000000..bf3d88b9 --- /dev/null +++ b/packages/elements-react/src/components/form/form-resolver.test.tsx @@ -0,0 +1,58 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { renderHook } from "@testing-library/react" +import { FormValues } from "../../types" +import { useOryFormResolver } from "./form-resolver" +import { OryFlowProvider } from "../../context/flow-context" +import { defaultConfiguration } from "../../tests/jest/test-utils" +import { PropsWithChildren } from "react" +import { FlowType, LoginFlow } from "@ory/client-fetch" + +const testCases = [ + { + method: "code", + code: "", + email: "", + }, + { + method: "code", + }, + { + method: "code", + code: "123456", + email: "", + }, + { + method: "code", + code: "123456", + email: "some@example.com", + }, + { + method: "password", + }, +] satisfies FormValues[] + +const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + +) + +for (const testCase of testCases) { + test("case=" + JSON.stringify(testCase), () => { + const formResolver = renderHook(() => useOryFormResolver(), { + wrapper, + }) + expect(formResolver.result.current(testCase)).toMatchSnapshot() + }) +} diff --git a/packages/elements-react/src/components/form/form-resolver.ts b/packages/elements-react/src/components/form/form-resolver.ts new file mode 100644 index 00000000..822cc563 --- /dev/null +++ b/packages/elements-react/src/components/form/form-resolver.ts @@ -0,0 +1,44 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +import { useOryFlow } from "../../context" +import { FormValues } from "../../types" + +function isCodeResendRequest(data: FormValues) { + return data.email ?? data.resend +} + +/** + * Creates a resolver for the Ory form + * + * The resolver does form validation for missing fields in the form. + * + * @returns a react-hook-form resolver for the Ory form + */ +export function useOryFormResolver() { + const flowContainer = useOryFlow() + + return (data: FormValues) => { + if (flowContainer.formState.current === "method_active") { + if (data.method === "code" && !data.code && !isCodeResendRequest(data)) { + return { + values: data, + errors: { + code: { + id: 4000002, + context: { + property: "code", + }, + type: "error", + text: "Property code is missing", + }, + }, + } + } + } + return { + values: data, + errors: {}, + } + } +} diff --git a/packages/elements-react/src/components/form/nodes/input.tsx b/packages/elements-react/src/components/form/nodes/input.tsx index e4681a59..ff3649c0 100644 --- a/packages/elements-react/src/components/form/nodes/input.tsx +++ b/packages/elements-react/src/components/form/nodes/input.tsx @@ -30,9 +30,12 @@ export const NodeInput = ({ // ...attrs } = attributes + const isResendNode = node.meta.label?.id === 1070008 + const isScreenSelectionNode = + "name" in node.attributes && node.attributes.name === "screen" const setFormValue = () => { - if (attrs.value) { + if (attrs.value && !(isResendNode || isScreenSelectionNode)) { setValue(attrs.name, attrs.value) } } @@ -64,9 +67,6 @@ export const NodeInput = ({ const isPinCodeInput = (attrs.name === "code" && node.group === "code") || (attrs.name === "totp_code" && node.group === "totp") - const isResendNode = node.meta.label?.id === 1070008 - const isScreenSelectionNode = - "name" in node.attributes && node.attributes.name === "screen" switch (attributes.type) { case UiNodeInputAttributesTypeEnum.Submit: diff --git a/packages/elements-react/src/theme/default/components/form/label.tsx b/packages/elements-react/src/theme/default/components/form/label.tsx index eead86c5..a95850e4 100644 --- a/packages/elements-react/src/theme/default/components/form/label.tsx +++ b/packages/elements-react/src/theme/default/components/form/label.tsx @@ -1,7 +1,12 @@ // Copyright © 2024 Ory Corp // SPDX-License-Identifier: Apache-2.0 -import { FlowType, getNodeLabel, UiNode } from "@ory/client-fetch" +import { + FlowType, + getNodeLabel, + instanceOfUiText, + UiNode, +} from "@ory/client-fetch" import { OryNodeLabelProps, messageTestId, @@ -31,7 +36,7 @@ export function DefaultLabel({ const label = getNodeLabel(node) const { Message } = useComponents() const { config, flowType, flow } = useOryFlow() - const { setValue } = useFormContext() + const { setValue, formState } = useFormContext() const isPassword = attributes.type === "password" @@ -43,6 +48,8 @@ export function DefaultLabel({ } } + const fieldError = formState.errors[attributes.name] + return (
{label && ( @@ -86,6 +93,9 @@ export function DefaultLabel({ {node.messages.map((message) => ( ))} + {fieldError && instanceOfUiText(fieldError) && ( + + )}
) }