remix-validated-form 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. package/.eslintignore +1 -0
  2. package/.eslintrc.js +46 -0
  3. package/.github/workflows/test.yml +35 -0
  4. package/.husky/pre-commit +4 -0
  5. package/.prettierignore +8 -0
  6. package/LICENSE +21 -0
  7. package/README.md +3 -0
  8. package/jest.config.js +5 -0
  9. package/package.json +73 -0
  10. package/src/ValidatedForm.tsx +130 -0
  11. package/src/hooks.ts +27 -0
  12. package/src/index.ts +5 -0
  13. package/src/internal/formContext.ts +18 -0
  14. package/src/internal/util.ts +23 -0
  15. package/src/server.ts +5 -0
  16. package/src/validation/types.ts +12 -0
  17. package/src/validation/validation.test.ts +76 -0
  18. package/src/validation/withYup.ts +37 -0
  19. package/test-app/README.md +53 -0
  20. package/test-app/app/components/Input.tsx +24 -0
  21. package/test-app/app/components/SubmitButton.tsx +18 -0
  22. package/test-app/app/entry.client.tsx +4 -0
  23. package/test-app/app/entry.server.tsx +21 -0
  24. package/test-app/app/root.tsx +246 -0
  25. package/test-app/app/routes/default-values.tsx +34 -0
  26. package/test-app/app/routes/index.tsx +100 -0
  27. package/test-app/app/routes/noscript.tsx +10 -0
  28. package/test-app/app/routes/submission.alt.tsx +6 -0
  29. package/test-app/app/routes/submission.fetcher.tsx +6 -0
  30. package/test-app/app/routes/submission.tsx +47 -0
  31. package/test-app/app/routes/validation.tsx +40 -0
  32. package/test-app/app/styles/dark.css +7 -0
  33. package/test-app/app/styles/demos/about.css +26 -0
  34. package/test-app/app/styles/demos/remix.css +120 -0
  35. package/test-app/app/styles/global.css +98 -0
  36. package/test-app/cypress/fixtures/example.json +5 -0
  37. package/test-app/cypress/integration/default-values.ts +15 -0
  38. package/test-app/cypress/integration/sanity.ts +19 -0
  39. package/test-app/cypress/integration/submission.ts +26 -0
  40. package/test-app/cypress/integration/validation.ts +70 -0
  41. package/test-app/cypress/plugins/config.ts +38 -0
  42. package/test-app/cypress/plugins/index.ts +9 -0
  43. package/test-app/cypress/support/commands/index.ts +13 -0
  44. package/test-app/cypress/support/commands/types.d.ts +11 -0
  45. package/test-app/cypress/support/index.ts +20 -0
  46. package/test-app/cypress/tsconfig.json +11 -0
  47. package/test-app/cypress.json +3 -0
  48. package/test-app/package-lock.json +11675 -0
  49. package/test-app/package.json +40 -0
  50. package/test-app/public/favicon.ico +0 -0
  51. package/test-app/remix.config.js +10 -0
  52. package/test-app/remix.env.d.ts +2 -0
  53. package/test-app/tsconfig.json +18 -0
  54. package/tsconfig.json +15 -0
package/.eslintignore ADDED
@@ -0,0 +1 @@
1
+ ./package-lock.json
package/.eslintrc.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @type {import('eslint').Linter.Config}
3
+ */
4
+ module.exports = {
5
+ ignorePatterns: [
6
+ "node_modules/",
7
+ ".cache/",
8
+ "browser/",
9
+ "build/",
10
+ "test-app/build",
11
+ "test-app/remix-validated-form",
12
+ ],
13
+ extends: ["react-app"],
14
+ plugins: ["prettier"],
15
+ rules: {
16
+ "import/no-anonymous-default-export": "off",
17
+ "import/order": [
18
+ "warn",
19
+ {
20
+ groups: [
21
+ "builtin",
22
+ "external",
23
+ "internal",
24
+ "unknown",
25
+ "parent",
26
+ "sibling",
27
+ "index",
28
+ ],
29
+ alphabetize: {
30
+ order: "asc",
31
+ caseInsensitive: true,
32
+ },
33
+ "newlines-between": "never",
34
+ },
35
+ ],
36
+ "prettier/prettier": "error",
37
+ },
38
+ overrides: [
39
+ {
40
+ files: ["./cypress/**"],
41
+ rules: {
42
+ "@typescript-eslint/no-unused-expressions": "off",
43
+ },
44
+ },
45
+ ],
46
+ };
@@ -0,0 +1,35 @@
1
+ name: Test and build
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - "main"
7
+ pull_request:
8
+ branches:
9
+ - "main"
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+
15
+ steps:
16
+ - uses: actions/checkout@v2
17
+
18
+ - name: Install Deps
19
+ run: npm install
20
+
21
+ - name: Lint
22
+ run: npm run lint
23
+
24
+ - name: Jest Tests
25
+ run: npm run test
26
+
27
+ - name: Build
28
+ run: npm run build
29
+
30
+ - name: Cypress Tests
31
+ uses: cypress-io/github-action@v2
32
+ with:
33
+ working-directory: ./test-app
34
+ start: npm run dev
35
+ wait-on: http://localhost:3000
@@ -0,0 +1,4 @@
1
+ #!/bin/sh
2
+ . "$(dirname "$0")/_/husky.sh"
3
+
4
+ npx lint-staged
@@ -0,0 +1,8 @@
1
+ .cache
2
+ build
3
+ browser
4
+ test-app/build
5
+ test-app/remix-validated-form
6
+ node_modules
7
+ public
8
+ package-lock.json
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Aaron Pettengill
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Remix Validated Form
2
+
3
+ A form library built for [remix](https://remix.run).
package/jest.config.js ADDED
@@ -0,0 +1,5 @@
1
+ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2
+ module.exports = {
3
+ preset: "ts-jest",
4
+ testEnvironment: "node",
5
+ };
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "remix-validated-form",
3
+ "version": "0.0.1",
4
+ "description": "Form component and utils for easy form validation in remix",
5
+ "browser": "./browser/index.js",
6
+ "main": "./build/index.js",
7
+ "sideEffects": false,
8
+ "scripts": {
9
+ "build": "npm run build:browser && npm run build:main && npm run build:tests",
10
+ "build:browser": "tsc --project tsconfig.json --module ESNext --outDir ./browser",
11
+ "build:main": "tsc --project tsconfig.json --module CommonJS --outDir ./build",
12
+ "build:tests": "tsc --project tsconfig.json --module CommonJS --outDir ./test-app/remix-validated-form",
13
+ "test": "jest src",
14
+ "lint": "eslint .",
15
+ "prettier": "prettier . --write",
16
+ "prepare": "husky install"
17
+ },
18
+ "author": {
19
+ "name": "Aaron Pettengill",
20
+ "email": "pettengill.aaron@gmail.com",
21
+ "url": "https://github.com/airjp73"
22
+ },
23
+ "license": "MIT",
24
+ "keywords": [
25
+ "remix",
26
+ "remix.run",
27
+ "react",
28
+ "form",
29
+ "yup",
30
+ "validation"
31
+ ],
32
+ "peerDependencies": {
33
+ "@remix-run/react": "^1.0.0",
34
+ "@remix-run/server-runtime": "^1.0.0",
35
+ "react": "^17.0.2"
36
+ },
37
+ "devDependencies": {
38
+ "@remix-run/react": "^1.0.0",
39
+ "@remix-run/server-runtime": "^1.0.0",
40
+ "@types/jest": "^27.0.3",
41
+ "@types/react": "^17.0.36",
42
+ "@typescript-eslint/eslint-plugin": "^4.33.0",
43
+ "@typescript-eslint/parser": "^4.33.0",
44
+ "babel-eslint": "^10.1.0",
45
+ "eslint": "^7.32.0",
46
+ "eslint-config-react-app": "^6.0.0",
47
+ "eslint-plugin-cypress": "^2.12.1",
48
+ "eslint-plugin-import": "^2.25.3",
49
+ "eslint-plugin-jsx-a11y": "^6.5.1",
50
+ "eslint-plugin-prettier": "^4.0.0",
51
+ "eslint-plugin-react": "^7.27.1",
52
+ "eslint-plugin-react-hooks": "^4.3.0",
53
+ "fetch-blob": "^3.1.3",
54
+ "husky": "^7.0.4",
55
+ "jest": "^27.3.1",
56
+ "lint-staged": "^12.1.2",
57
+ "react": "^17.0.2",
58
+ "ts-jest": "^27.0.7",
59
+ "typescript": "^4.5.2",
60
+ "yup": "^0.32.11"
61
+ },
62
+ "dependencies": {
63
+ "eslint-plugin-flowtype": "^5.10.0",
64
+ "prettier": "^2.5.0",
65
+ "tiny-invariant": "^1.2.0"
66
+ },
67
+ "lint-staged": {
68
+ "**/*.{ts,tsx,json,js,jsx}": [
69
+ "prettier --write",
70
+ "eslint --cache --fix"
71
+ ]
72
+ }
73
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ Form as RemixForm,
3
+ useActionData,
4
+ useFetcher,
5
+ useFormAction,
6
+ useTransition,
7
+ } from "@remix-run/react";
8
+ import React, {
9
+ ComponentProps,
10
+ useEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ } from "react";
15
+ import invariant from "tiny-invariant";
16
+ import { FormContext, FormContextValue } from "./internal/formContext";
17
+ import { omit, mergeRefs } from "./internal/util";
18
+ import { FieldErrors, Validator } from "./validation/types";
19
+
20
+ export type FormProps<DataType> = {
21
+ validator: Validator<DataType>;
22
+ onSubmit?: (data: DataType, event: React.FormEvent<HTMLFormElement>) => void;
23
+ fetcher?: ReturnType<typeof useFetcher>;
24
+ defaultValues?: Partial<DataType>;
25
+ formRef?: React.RefObject<HTMLFormElement>;
26
+ } & Omit<ComponentProps<typeof RemixForm>, "onSubmit">;
27
+
28
+ function useFieldErrors(
29
+ fetcher?: ReturnType<typeof useFetcher>
30
+ ): [FieldErrors, React.Dispatch<React.SetStateAction<FieldErrors>>] {
31
+ const actionData = useActionData<any>();
32
+ const dataToUse = fetcher ? fetcher.data : actionData;
33
+ const fieldErrorsFromAction = dataToUse?.fieldErrors;
34
+
35
+ const [fieldErrors, setFieldErrors] = useState<FieldErrors>(
36
+ fieldErrorsFromAction ?? {}
37
+ );
38
+ useEffect(() => {
39
+ if (fieldErrorsFromAction) setFieldErrors(fieldErrorsFromAction);
40
+ }, [fieldErrorsFromAction]);
41
+
42
+ return [fieldErrors, setFieldErrors];
43
+ }
44
+
45
+ const useIsSubmitting = (
46
+ action?: string,
47
+ fetcher?: ReturnType<typeof useFetcher>
48
+ ) => {
49
+ const actionForCurrentPage = useFormAction();
50
+ const pendingFormSubmit = useTransition().submission;
51
+ return fetcher
52
+ ? fetcher.state === "submitting"
53
+ : pendingFormSubmit &&
54
+ pendingFormSubmit.action.endsWith(action ?? actionForCurrentPage);
55
+ };
56
+
57
+ const getDataFromForm = (el: HTMLFormElement) =>
58
+ Object.fromEntries(new FormData(el));
59
+
60
+ export function ValidatedForm<DataType>({
61
+ validator,
62
+ onSubmit,
63
+ children,
64
+ fetcher,
65
+ action,
66
+ defaultValues,
67
+ formRef: formRefProp,
68
+ ...rest
69
+ }: FormProps<DataType>) {
70
+ const [fieldErrors, setFieldErrors] = useFieldErrors(fetcher);
71
+ const isSubmitting = useIsSubmitting(action, fetcher);
72
+
73
+ const formRef = useRef<HTMLFormElement>(null);
74
+
75
+ const contextValue = useMemo<FormContextValue>(
76
+ () => ({
77
+ fieldErrors,
78
+ action,
79
+ defaultValues,
80
+ isSubmitting: isSubmitting ?? false,
81
+ clearError: (fieldName) => {
82
+ setFieldErrors((prev) => omit(prev, fieldName));
83
+ },
84
+ validateField: (fieldName) => {
85
+ invariant(formRef.current, "Cannot find reference to form");
86
+ const { error } = validator.validateField(
87
+ getDataFromForm(formRef.current),
88
+ fieldName as any
89
+ );
90
+ if (error) {
91
+ setFieldErrors((prev) => ({
92
+ ...prev,
93
+ [fieldName]: error,
94
+ }));
95
+ }
96
+ },
97
+ }),
98
+ [
99
+ fieldErrors,
100
+ action,
101
+ defaultValues,
102
+ isSubmitting,
103
+ setFieldErrors,
104
+ validator,
105
+ ]
106
+ );
107
+
108
+ const Form = fetcher?.Form ?? RemixForm;
109
+
110
+ return (
111
+ <Form
112
+ ref={mergeRefs([formRef, formRefProp])}
113
+ {...rest}
114
+ action={action}
115
+ onSubmit={(event) => {
116
+ const result = validator.validate(getDataFromForm(event.currentTarget));
117
+ if (result.error) {
118
+ event.preventDefault();
119
+ setFieldErrors(result.error);
120
+ } else {
121
+ onSubmit?.(result.data, event);
122
+ }
123
+ }}
124
+ >
125
+ <FormContext.Provider value={contextValue}>
126
+ {children}
127
+ </FormContext.Provider>
128
+ </Form>
129
+ );
130
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { useContext, useMemo } from "react";
2
+ import { FormContext } from "./internal/formContext";
3
+
4
+ export const useField = (name: string) => {
5
+ const { fieldErrors, clearError, validateField, defaultValues } =
6
+ useContext(FormContext);
7
+
8
+ const field = useMemo(
9
+ () => ({
10
+ error: fieldErrors[name],
11
+ clearError: () => {
12
+ clearError(name);
13
+ },
14
+ validate: () => validateField(name),
15
+ defaultValue: defaultValues?.[name],
16
+ }),
17
+ [clearError, defaultValues, fieldErrors, name, validateField]
18
+ );
19
+
20
+ return field;
21
+ };
22
+
23
+ // test commit
24
+
25
+ export const useFormContext = () => useContext(FormContext);
26
+
27
+ export const useIsSubmitting = () => useFormContext().isSubmitting;
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./hooks";
2
+ export * from "./server";
3
+ export * from "./ValidatedForm";
4
+ export * from "./validation/types";
5
+ export * from "./validation/withYup";
@@ -0,0 +1,18 @@
1
+ import { createContext } from "react";
2
+ import { FieldErrors } from "../validation/types";
3
+
4
+ export type FormContextValue = {
5
+ fieldErrors: FieldErrors;
6
+ clearError: (...names: string[]) => void;
7
+ validateField: (fieldName: string) => void;
8
+ action?: string;
9
+ isSubmitting: boolean;
10
+ defaultValues?: { [fieldName: string]: any };
11
+ };
12
+
13
+ export const FormContext = createContext<FormContextValue>({
14
+ fieldErrors: {},
15
+ clearError: () => {},
16
+ validateField: () => {},
17
+ isSubmitting: false,
18
+ });
@@ -0,0 +1,23 @@
1
+ import type React from "react";
2
+
3
+ export const omit = (obj: any, ...keys: string[]) => {
4
+ const result = { ...obj };
5
+ for (const key of keys) {
6
+ delete result[key];
7
+ }
8
+ return result;
9
+ };
10
+
11
+ export const mergeRefs = <T = any>(
12
+ refs: Array<React.MutableRefObject<T> | React.LegacyRef<T> | undefined>
13
+ ): React.RefCallback<T> => {
14
+ return (value: T) => {
15
+ refs.filter(Boolean).forEach((ref) => {
16
+ if (typeof ref === "function") {
17
+ ref(value);
18
+ } else if (ref != null) {
19
+ (ref as React.MutableRefObject<T | null>).current = value;
20
+ }
21
+ });
22
+ };
23
+ };
package/src/server.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { json } from "@remix-run/server-runtime";
2
+ import { FieldErrors } from "./validation/types";
3
+
4
+ export const fieldErrors = (errors: FieldErrors) =>
5
+ json({ fieldErrors: errors }, { status: 422 });
@@ -0,0 +1,12 @@
1
+ export type FieldErrors = Record<string, string>;
2
+
3
+ export type ValidationResult<DataType> =
4
+ | { data: DataType; error: undefined }
5
+ | { error: FieldErrors; data: undefined };
6
+
7
+ export type ValidateFieldResult = { error?: string };
8
+
9
+ export type Validator<DataType> = {
10
+ validate: (formData: unknown) => ValidationResult<DataType>;
11
+ validateField: (formData: unknown, field: string) => ValidateFieldResult;
12
+ };
@@ -0,0 +1,76 @@
1
+ import * as yup from "yup";
2
+ import { Validator, withYup } from "..";
3
+
4
+ // If adding an adapter, write a validator that validates this shape
5
+ type Shape = {
6
+ firstName: string;
7
+ lastName: string;
8
+ age?: number;
9
+ };
10
+
11
+ type ValidationTestCase = {
12
+ name: string;
13
+ validator: Validator<Shape>;
14
+ };
15
+
16
+ const validationTestCases: ValidationTestCase[] = [
17
+ {
18
+ name: "yup",
19
+ validator: withYup(
20
+ yup.object({
21
+ firstName: yup.string().required(),
22
+ lastName: yup.string().required(),
23
+ age: yup.number(),
24
+ })
25
+ ),
26
+ },
27
+ ];
28
+
29
+ // Not going to enforce exact error strings here
30
+ const anyString = expect.any(String);
31
+
32
+ describe("Validation", () => {
33
+ describe.each(validationTestCases)("Adapter for $name", ({ validator }) => {
34
+ describe("validate", () => {
35
+ it("should return the data when valid", () => {
36
+ const obj: Shape = {
37
+ firstName: "John",
38
+ lastName: "Doe",
39
+ age: 30,
40
+ };
41
+ expect(validator.validate(obj)).toEqual({
42
+ data: obj,
43
+ error: undefined,
44
+ });
45
+ });
46
+
47
+ it("should return field errors when invalid", () => {
48
+ const obj = { age: "hi!" };
49
+ expect(validator.validate(obj)).toEqual({
50
+ data: undefined,
51
+ error: {
52
+ firstName: anyString,
53
+ lastName: anyString,
54
+ age: anyString,
55
+ },
56
+ });
57
+ });
58
+ });
59
+
60
+ describe("validateField", () => {
61
+ it("should not return an error if field is valid", () => {
62
+ const obj = { firstName: "John", lastName: 123 };
63
+ expect(validator.validateField(obj, "firstName")).toEqual({
64
+ error: undefined,
65
+ });
66
+ });
67
+
68
+ it("should return an error if field is invalid", () => {
69
+ const obj = { firstName: "John", lastName: {} };
70
+ expect(validator.validateField(obj, "lastName")).toEqual({
71
+ error: anyString,
72
+ });
73
+ });
74
+ });
75
+ });
76
+ });
@@ -0,0 +1,37 @@
1
+ import type { AnyObjectSchema, InferType, ValidationError } from "yup";
2
+ import { FieldErrors, ValidationResult, Validator } from "./types";
3
+
4
+ const validationErrorToFieldErrors = (error: ValidationError): FieldErrors => {
5
+ const fieldErrors: FieldErrors = {};
6
+ error.inner.forEach((innerError) => {
7
+ if (!innerError.path) return;
8
+ fieldErrors[innerError.path] = innerError.message;
9
+ });
10
+ return fieldErrors;
11
+ };
12
+
13
+ export const withYup = <Schema extends AnyObjectSchema>(
14
+ validationSchema: Schema
15
+ ): Validator<InferType<Schema>> => ({
16
+ validate: (data): ValidationResult<InferType<Schema>> => {
17
+ try {
18
+ const validated = validationSchema.validateSync(data, {
19
+ abortEarly: false,
20
+ });
21
+ return { data: validated, error: undefined };
22
+ } catch (err) {
23
+ return {
24
+ error: validationErrorToFieldErrors(err as ValidationError),
25
+ data: undefined,
26
+ };
27
+ }
28
+ },
29
+ validateField: (data, field) => {
30
+ try {
31
+ validationSchema.validateSyncAt(field, data);
32
+ return {};
33
+ } catch (err) {
34
+ return { error: (err as ValidationError).message };
35
+ }
36
+ },
37
+ });
@@ -0,0 +1,53 @@
1
+ # Welcome to Remix!
2
+
3
+ - [Remix Docs](https://remix.run/docs)
4
+
5
+ ## Development
6
+
7
+ From your terminal:
8
+
9
+ ```sh
10
+ npm run dev
11
+ ```
12
+
13
+ This starts your app in development mode, rebuilding assets on file changes.
14
+
15
+ ## Deployment
16
+
17
+ First, build your app for production:
18
+
19
+ ```sh
20
+ npm run build
21
+ ```
22
+
23
+ Then run the app in production mode:
24
+
25
+ ```sh
26
+ npm start
27
+ ```
28
+
29
+ Now you'll need to pick a host to deploy it to.
30
+
31
+ ### DIY
32
+
33
+ If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
34
+
35
+ Make sure to deploy the output of `remix build`
36
+
37
+ - `build/`
38
+ - `public/build/`
39
+
40
+ ### Using a Template
41
+
42
+ When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server.
43
+
44
+ ```sh
45
+ cd ..
46
+ # create a new project, and pick a pre-configured host
47
+ npx create-remix@latest
48
+ cd my-new-remix-app
49
+ # remove the new project's app (not the old one!)
50
+ rm -rf app
51
+ # copy your app over
52
+ cp -R ../my-old-remix-app/app app
53
+ ```
@@ -0,0 +1,24 @@
1
+ import { useField } from "../../remix-validated-form";
2
+
3
+ type InputProps = {
4
+ name: string;
5
+ label: string;
6
+ validateOnBlur?: boolean;
7
+ };
8
+
9
+ export const Input = ({ name, label, validateOnBlur }: InputProps) => {
10
+ const { validate, clearError, defaultValue, error } = useField(name);
11
+ return (
12
+ <div>
13
+ <label htmlFor={name}>{label}</label>
14
+ <input
15
+ id={name}
16
+ name={name}
17
+ onBlur={validateOnBlur ? validate : undefined}
18
+ onChange={clearError}
19
+ defaultValue={defaultValue}
20
+ />
21
+ {error && <span style={{ color: "red" }}>{error}</span>}
22
+ </div>
23
+ );
24
+ };
@@ -0,0 +1,18 @@
1
+ import { useIsSubmitting } from "../../remix-validated-form";
2
+
3
+ type Props = {
4
+ label?: string;
5
+ submittingLabel?: string;
6
+ };
7
+
8
+ export const SubmitButton = ({
9
+ label = "Submit",
10
+ submittingLabel = "Submitting...",
11
+ }: Props) => {
12
+ const isSubmitting = useIsSubmitting();
13
+ return (
14
+ <button type="submit" disabled={isSubmitting}>
15
+ {isSubmitting ? submittingLabel : label}
16
+ </button>
17
+ );
18
+ };
@@ -0,0 +1,4 @@
1
+ import { hydrate } from "react-dom";
2
+ import { RemixBrowser } from "remix";
3
+
4
+ hydrate(<RemixBrowser />, document);
@@ -0,0 +1,21 @@
1
+ import { renderToString } from "react-dom/server";
2
+ import { RemixServer } from "remix";
3
+ import type { EntryContext } from "remix";
4
+
5
+ export default function handleRequest(
6
+ request: Request,
7
+ responseStatusCode: number,
8
+ responseHeaders: Headers,
9
+ remixContext: EntryContext
10
+ ) {
11
+ let markup = renderToString(
12
+ <RemixServer context={remixContext} url={request.url} />
13
+ );
14
+
15
+ responseHeaders.set("Content-Type", "text/html");
16
+
17
+ return new Response("<!DOCTYPE html>" + markup, {
18
+ status: responseStatusCode,
19
+ headers: responseHeaders,
20
+ });
21
+ }