diff --git a/examples/todoapp/src/App.tsx b/examples/todoapp/src/App.tsx index 1ad1f60b..e332036f 100644 --- a/examples/todoapp/src/App.tsx +++ b/examples/todoapp/src/App.tsx @@ -21,7 +21,11 @@ function App() { input: [ { field: "id", config: { hidden: true } } { field: "hasDone", config: { hidden: true } } - { field: "name", config: { gridCol: 9 } } + { + field: "name" + config: { gridCol: 9 } + constraint: { maxLength: 50 } + } { field: "priority", config: { gridCol: 3 } } ] ) { diff --git a/packages/chakra-ui/src/components/default/form.tsx b/packages/chakra-ui/src/components/default/form.tsx index 3b03935a..48eecc1a 100644 --- a/packages/chakra-ui/src/components/default/form.tsx +++ b/packages/chakra-ui/src/components/default/form.tsx @@ -3,7 +3,7 @@ import { FormComponentProps, FieldType, } from "@fabrix-framework/fabrix"; -import { Switch, Input, Stack, Button, Box, Text } from "@chakra-ui/react"; +import { Text, Switch, Input, Stack, Button, Box } from "@chakra-ui/react"; import { Select } from "chakra-react-select"; import { useController } from "@fabrix-framework/fabrix/rhf"; import { LabelledHeading } from "./shared"; @@ -121,6 +121,7 @@ const SelectFormField = ( onBlur={field.onBlur} onChange={(e) => e && field.onChange(e.value)} /> + ); }; @@ -136,6 +137,7 @@ const TextFormField = (props: FormFieldComponentProps) => { + ); }; @@ -173,6 +175,7 @@ const BooleanFormField = (props: FormFieldComponentProps) => { + ); }; diff --git a/packages/fabrix/tests/mocks/data.ts b/packages/fabrix/__tests__/mocks/data.ts similarity index 73% rename from packages/fabrix/tests/mocks/data.ts rename to packages/fabrix/__tests__/mocks/data.ts index df853cf9..41a4c433 100644 --- a/packages/fabrix/tests/mocks/data.ts +++ b/packages/fabrix/__tests__/mocks/data.ts @@ -4,11 +4,11 @@ export const users = [ { id: faker.string.uuid(), name: "first user", - code: "u001", + email: faker.internet.email(), }, { id: faker.string.uuid(), name: "second user", - code: "u002", + email: faker.internet.email(), }, ]; diff --git a/packages/fabrix/tests/mocks/handlers.ts b/packages/fabrix/__tests__/mocks/handlers.ts similarity index 94% rename from packages/fabrix/tests/mocks/handlers.ts rename to packages/fabrix/__tests__/mocks/handlers.ts index 9fa5836f..963ba65e 100644 --- a/packages/fabrix/tests/mocks/handlers.ts +++ b/packages/fabrix/__tests__/mocks/handlers.ts @@ -7,7 +7,7 @@ const mockSchema = buildSchema(` type User { id: ID! name: String! - code: String! + email: String! } type UsersResult { @@ -26,9 +26,9 @@ enum UserCategory { input CreateUserInput { id: ID name: String! - code: String! + email: String age: Int! - category: UserCategory! + category: UserCategory } type Mutation { diff --git a/packages/fabrix/tests/mocks/server.ts b/packages/fabrix/__tests__/mocks/server.ts similarity index 100% rename from packages/fabrix/tests/mocks/server.ts rename to packages/fabrix/__tests__/mocks/server.ts diff --git a/packages/fabrix/__tests__/mutation.test.tsx b/packages/fabrix/__tests__/mutation.test.tsx new file mode 100644 index 00000000..89b39ff8 --- /dev/null +++ b/packages/fabrix/__tests__/mutation.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { screen, within } from "@testing-library/react"; +import { FabrixComponent } from "@renderer"; +import { testWithUnmount } from "./supports/render"; + +describe("mutation", () => { + it("should render the form", async () => { + await testWithUnmount( + , + async () => { + const form = await screen.findByRole("form"); + expect(form).toBeInTheDocument(); + + const inputs = await within(form).findAllByRole("textbox"); + expect(inputs.length).toBe(5); + }, + ); + }); + + it("should render the form with customized labels", async () => { + await testWithUnmount( + , + async () => { + const form = await screen.findByRole("form"); + expect(form).toBeInTheDocument(); + + expect(within(form).queryByLabelText("id")).not.toBeInTheDocument(); + expect(within(form).getByLabelText("UserName")).toBeInTheDocument(); + }, + ); + }); +}); diff --git a/packages/fabrix/__tests__/mutationWithValidation.test.tsx b/packages/fabrix/__tests__/mutationWithValidation.test.tsx new file mode 100644 index 00000000..0e586b00 --- /dev/null +++ b/packages/fabrix/__tests__/mutationWithValidation.test.tsx @@ -0,0 +1,268 @@ +import { describe, expect, it } from "vitest"; +import { FabrixComponent } from "@renderer"; +import { faker } from "@faker-js/faker"; +import { testWithUnmount } from "./supports/render"; +import { findForm } from "./supports/utils"; + +describe("String", () => { + it("minLength/maxLength", async () => { + await testWithUnmount( + , + async () => { + const form = await findForm(); + await form.set("name", faker.string.alpha(4)); + await form.submit(); + expect(form.getAlert("name")).toHaveTextContent( + "must NOT have fewer than 5 characters", + ); + + await form.set("name", faker.string.alpha(5)); + await form.submit(); + expect(form.getAlert("name")).not.toBeInTheDocument(); + + await form.set("name", faker.string.alpha(10)); + await form.submit(); + expect(form.getAlert("name")).not.toBeInTheDocument(); + + await form.set("name", faker.string.alpha(11)); + await form.submit(); + expect(form.getAlert("name")).toHaveTextContent( + "must NOT have more than 10 characters", + ); + }, + ); + }); + + it("pattern", async () => { + await testWithUnmount( + , + async () => { + const form = await findForm(); + await form.set("name", "John Doe"); + await form.submit(); + expect(form.getAlert("name")).toHaveTextContent( + 'must match pattern "^[a-z]+$"', + ); + + await form.set("name", "johndoe"); + await form.submit(); + expect(form.getAlert("name")).not.toBeInTheDocument(); + }, + ); + }); + + it("format (email)", async () => { + await testWithUnmount( + , + async () => { + const form = await findForm(); + await form.set("email", "john.doe"); + await form.submit(); + expect(form.getAlert("email")).toHaveTextContent( + 'must match format "email"', + ); + + await form.set("email", faker.internet.email()); + await form.submit(); + expect(form.getAlert("email")).not.toBeInTheDocument(); + }, + ); + }); + + it("oneOf", async () => { + await testWithUnmount( + , + async () => { + const form = await findForm(); + await form.set("name", "EmployeeX"); + await form.submit(); + expect(form.getAlert("name")).toHaveTextContent( + "must be equal to one of the allowed value", + ); + + await form.set("name", "EmployeeA"); + await form.submit(); + expect(form.getAlert("name")).not.toBeInTheDocument(); + }, + ); + }); +}); + +describe("Int/Float", () => { + it("min/max", async () => { + await testWithUnmount( + , + async () => { + const form = await findForm(); + await form.set("name", "John Doe"); + await form.set("category", "ADMIN"); + + await form.set("age", "19"); + await form.submit(); + expect(form.getAlert("age")).toHaveTextContent("must be >= 20"); + + await form.set("age", "20"); + await form.submit(); + expect(form.getAlert("age")).not.toBeInTheDocument(); + + await form.set("age", "30"); + await form.submit(); + expect(form.getAlert("age")).not.toBeInTheDocument(); + + await form.set("age", "31"); + await form.submit(); + expect(form.getAlert("age")).toHaveTextContent("must be <= 30"); + }, + ); + }); + + it("exclusive min/max", async () => { + await testWithUnmount( + , + async () => { + const form = await findForm(); + await form.set("name", "John Doe"); + await form.set("category", "ADMIN"); + + await form.set("age", "20"); + await form.submit(); + expect(form.getAlert("age")).toHaveTextContent("must be > 20"); + + await form.set("age", "21"); + await form.submit(); + expect(form.getAlert("age")).not.toBeInTheDocument(); + + await form.set("age", "29"); + await form.submit(); + expect(form.getAlert("age")).not.toBeInTheDocument(); + + await form.set("age", "30"); + await form.submit(); + expect(form.getAlert("age")).toHaveTextContent("must be < 30"); + }, + ); + }); + + it("multipleOf", async () => { + await testWithUnmount( + , + async () => { + const form = await findForm(); + await form.set("name", "John Doe"); + + await form.set("age", "19"); + await form.submit(); + expect(form.getAlert("age")).toHaveTextContent("must be multiple of 5"); + + await form.set("age", "20"); + await form.submit(); + expect(form.getAlert("age")).not.toBeInTheDocument(); + }, + ); + }); + + it("oneOf", async () => { + await testWithUnmount( + , + async () => { + const form = await findForm(); + await form.set("name", "John Doe"); + await form.set("category", "ADMIN"); + + await form.set("age", "19"); + await form.submit(); + expect(form.getAlert("age")).toHaveTextContent( + "must be equal to one of the allowed value", + ); + + await form.set("age", "20"); + await form.submit(); + expect(form.getAlert("age")).not.toBeInTheDocument(); + + await form.set("age", "30"); + await form.submit(); + expect(form.getAlert("age")).not.toBeInTheDocument(); + }, + ); + }); +}); diff --git a/packages/fabrix/src/renders.test.tsx b/packages/fabrix/__tests__/query.test.tsx similarity index 68% rename from packages/fabrix/src/renders.test.tsx rename to packages/fabrix/__tests__/query.test.tsx index 930e2caf..1b654e04 100644 --- a/packages/fabrix/src/renders.test.tsx +++ b/packages/fabrix/__tests__/query.test.tsx @@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest"; import { screen, within } from "@testing-library/react"; import { FabrixComponent } from "@renderer"; import { ComponentRegistry } from "@registry"; -import { users } from "../tests/mocks/data"; -import { testWithUnmount } from "../tests/render"; +import { users } from "./mocks/data"; +import { testWithUnmount } from "./supports/render"; describe("query", () => { it("should render the table with collection", async () => { @@ -15,7 +15,7 @@ describe("query", () => { collection { id name - code + email } } } @@ -42,7 +42,7 @@ describe("query", () => { collection { id name - code + email } } } @@ -103,7 +103,7 @@ describe("query", () => { collection { id name - code + email } } } @@ -123,52 +123,3 @@ describe("query", () => { ); }); }); - -describe("mutation", () => { - it("should render the form", async () => { - await testWithUnmount( - , - async () => { - const form = await screen.findByRole("form"); - expect(form).toBeInTheDocument(); - - const inputs = await within(form).findAllByRole("textbox"); - expect(inputs.length).toBe(5); - }, - ); - }); - - it("should render the form with customized labels", async () => { - await testWithUnmount( - , - async () => { - const form = await screen.findByRole("form"); - expect(form).toBeInTheDocument(); - - expect(within(form).queryByLabelText("id")).not.toBeInTheDocument(); - expect(within(form).getByLabelText("name")).toHaveTextContent( - "UserName", - ); - }, - ); - }); -}); diff --git a/packages/fabrix/tests/setup.ts b/packages/fabrix/__tests__/setup.ts similarity index 100% rename from packages/fabrix/tests/setup.ts rename to packages/fabrix/__tests__/setup.ts diff --git a/packages/fabrix/tests/components.tsx b/packages/fabrix/__tests__/supports/components.tsx similarity index 61% rename from packages/fabrix/tests/components.tsx rename to packages/fabrix/__tests__/supports/components.tsx index 0707c98a..fcbb71d4 100644 --- a/packages/fabrix/tests/components.tsx +++ b/packages/fabrix/__tests__/supports/components.tsx @@ -6,6 +6,7 @@ import { TableComponentProps, } from "@registry"; import { ReactNode } from "react"; +import { useController } from "react-hook-form"; const fieldView = (props: FieldComponentProps) => { const { value } = props; @@ -48,13 +49,32 @@ const formView = (props: FormComponentProps) => { }; const formFieldView = (props: FormFieldComponentProps) => { + const { field, formState } = useController({ + name: props.name, + defaultValue: "", + }); + const error = formState.errors[props.name]; + const isNumber = + props.type?.type === "Scalar" && + (props.type.name === "Int" || props.type.name === "Float"); + return ( -
- - -
+
+ + { + if (e.target.value && isNumber) { + field.onChange(parseFloat(e.target.value)); + } else { + field.onChange(e.target.value); + } + }} + /> + {error &&
{error?.message?.toString()}
} +
); }; diff --git a/packages/fabrix/tests/render.tsx b/packages/fabrix/__tests__/supports/render.tsx similarity index 100% rename from packages/fabrix/tests/render.tsx rename to packages/fabrix/__tests__/supports/render.tsx diff --git a/packages/fabrix/__tests__/supports/utils.ts b/packages/fabrix/__tests__/supports/utils.ts new file mode 100644 index 00000000..e349980f --- /dev/null +++ b/packages/fabrix/__tests__/supports/utils.ts @@ -0,0 +1,25 @@ +import { screen } from "@testing-library/react"; +import { within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +export const findForm = async () => { + const form = await screen.findByRole("form"); + const user = userEvent.setup(); + + return { + element: within(form), + set: async (label: string, value: string) => { + const input = within(form).getByLabelText(label); + await user.clear(input); + await user.type(input, value); + }, + submit: async () => { + const submit = within(form).getByRole("button"); + await user.click(submit); + }, + getAlert: (label: string) => { + const field = within(form).getByLabelText(`field:${label}`); + return within(field).queryByRole("alert"); + }, + }; +}; diff --git a/packages/fabrix/package.json b/packages/fabrix/package.json index 59fb23c0..65f04b32 100644 --- a/packages/fabrix/package.json +++ b/packages/fabrix/package.json @@ -27,6 +27,10 @@ "author": "", "license": "MIT", "dependencies": { + "@hookform/resolvers": "^3.9.0", + "ajv": "^8.17.1", + "ajv-errors": "^3.0.0", + "ajv-formats": "^3.0.1", "deepmerge-ts": "^7.1.0", "graphql-tag": "^2.12.6", "react-hook-form": "^7.53.0", diff --git a/packages/fabrix/src/directive.ts b/packages/fabrix/src/directive.ts index de53bb24..9ecbae98 100644 --- a/packages/fabrix/src/directive.ts +++ b/packages/fabrix/src/directive.ts @@ -14,35 +14,6 @@ export const parseDirectiveArguments = ( return parsedValue.data; }; -type FieldConfig = { - config: C; - field?: string | null; -}; - -/** - * A helper function to build a record from object array that has `field` key - */ -export const buildRecordByFieldName = < - C extends Record, - V extends FieldConfig = FieldConfig, - T extends Array = Array, ->( - input: T, -) => { - return input.reduce>( - (acc, value) => - value.field - ? { - ...acc, - [value.field]: { - config: value.config, - }, - } - : acc, - {}, - ); -}; - export const findDirective = ( directives: ReadonlyArray | undefined, ) => { diff --git a/packages/fabrix/src/directive/schema.ts b/packages/fabrix/src/directive/schema.ts index 23fcc00a..7b3498f1 100644 --- a/packages/fabrix/src/directive/schema.ts +++ b/packages/fabrix/src/directive/schema.ts @@ -35,6 +35,26 @@ export const formFieldSchema = baseFieldSchema.merge( }), ); +export const formFieldConstraintSchema = z + .object({ + // String constraints + minLength: z.number().nullish(), + maxLength: z.number().nullish(), + pattern: z.string().nullish(), + format: z.string().nullish(), + + // Number constraints + min: z.number().nullish(), + max: z.number().nullish(), + exclusiveMin: z.number().nullish(), + exclusiveMax: z.number().nullish(), + multipleOf: z.number().nullish(), + + // String/Number constraints + oneOf: z.array(z.string().or(z.number())).nullish(), + }) + .nullish(); + export const viewFieldSchema = baseFieldSchema.merge( z.object({ gridCol: z @@ -89,6 +109,7 @@ export const directiveSchemaMap = { hidden: defaultValues.hidden, }), ), + constraint: formFieldConstraintSchema, }), ), }), diff --git a/packages/fabrix/src/inferer.ts b/packages/fabrix/src/inferer.ts deleted file mode 100644 index 37bc6261..00000000 --- a/packages/fabrix/src/inferer.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { FabrixContextType } from "@context"; -import { formFieldSchema, viewFieldSchema } from "@directive/schema"; -import { resolveFieldType } from "@renderers/shared"; -import { Fields, FieldVariables, Path } from "@visitor"; -import { deepmerge } from "deepmerge-ts"; -import { GraphQLInputObjectType, GraphQLNonNull } from "graphql"; - -export type FieldWithDirective< - C extends Record = Record, - M extends Record = Record, -> = { - field: Path; - config: C; - meta: M; -}; - -type DirectiveInput< - C extends Record = Record, -> = Array<{ - field: Path; - config: C; -}>; - -/* - * Merge the default field configs with the input field configs - */ -export const mergeFieldConfigs = < - C extends Record, - M extends Record, ->( - fieldConfigs: Array>, - directiveInput: DirectiveInput, -) => { - const allFieldKeys = new Set([ - ...fieldConfigs.map((f) => f.field.asKey()), - ...directiveInput.map((f) => f.field.asKey()), - ]); - - return Array.from(allFieldKeys).flatMap((key) => { - const directiveValue = directiveInput.find((f) => f.field.asKey() === key); - const fieldValue = fieldConfigs.find((f) => f.field.asKey() === key); - const field = directiveValue?.field || fieldValue?.field; - if (!field) { - return []; - } - - const mergeConfig = () => { - if (fieldValue && directiveValue) { - return { - config: deepmerge<[C, C]>(fieldValue.config, directiveValue.config), - meta: fieldValue.meta, - }; - } else if (fieldValue) { - return { config: fieldValue.config, meta: fieldValue.meta }; - } else if (directiveValue) { - return { config: directiveValue.config, meta: {} }; - } else { - return null; - } - }; - - const mergedValue = mergeConfig(); - if (!mergedValue) { - return []; - } - - return { - field, - config: mergedValue.config, - meta: mergedValue.meta, - }; - }); -}; - -/** - * Infer the field configuration from the fields - */ -export const buildDefaultViewFieldConfigs = (fields: Fields) => - fields.unwrap().flatMap((field) => { - const config = viewFieldSchema.parse({ - index: 0, - label: field.getName(), - }); - - // This field configs are used to be merged into the field configs on directives - // So we need to skip the root path here. - const path = field.value.path.rootOffset(1); - if (!path) { - return []; - } - - return { - field: path, - config, - meta: {}, - }; - }); - -/** - * Infer the field configuration from the input object type for the form - */ -export const buildDefaultFormFieldConfigs = ( - context: FabrixContextType, - fieldVariables: FieldVariables, -) => { - if (!("input" in fieldVariables)) { - return []; - } - - if (context.schemaLoader.status === "loading") { - return []; - } - - const inputType = context.schemaLoader.schemaSet.serverSchema.getType( - fieldVariables.input.type, - ); - if (!inputType) { - return []; - } - - // Only support object type for "input" argument - if (!(inputType instanceof GraphQLInputObjectType)) { - return []; - } - - const fields = inputType.getFields(); - return Object.keys(fields).map((key, index) => { - const field = fields[key]; - const path = new Path(key.split(".")); - - return { - field: path, - meta: { - fieldType: resolveFieldType( - field.type instanceof GraphQLNonNull ? field.type.ofType : field.type, - ), - isRequired: field.type instanceof GraphQLNonNull, - }, - config: formFieldSchema.parse({ - index, - label: field.name, - }), - }; - }); -}; diff --git a/packages/fabrix/src/readers/field.ts b/packages/fabrix/src/readers/field.ts new file mode 100644 index 00000000..794526f2 --- /dev/null +++ b/packages/fabrix/src/readers/field.ts @@ -0,0 +1,46 @@ +import { viewFieldSchema } from "@directive/schema"; +import { FieldConfigWithMeta, FieldConfig } from "@readers/shared"; +import { Fields } from "@visitor"; +import { deepmerge } from "deepmerge-ts"; + +/** + * Infer the field configuration from the fields + */ +export const buildDefaultViewFieldConfigs = (fields: Fields) => + fields.unwrap().flatMap((field) => { + const config = viewFieldSchema.parse({ + index: 0, + label: field.getName(), + }); + + // This field configs are used to be merged into the field configs on directives + // So we need to skip the root path here. + const path = field.value.path.rootOffset(1); + if (!path) { + return []; + } + + return { + field: path, + config, + meta: null, + }; + }); + +export const viewFieldMerger = >( + fieldValue: FieldConfigWithMeta | undefined, + directiveValue: FieldConfig | undefined, +) => { + if (fieldValue && directiveValue) { + return { + config: deepmerge(fieldValue.config, directiveValue.config), + meta: fieldValue.meta, + }; + } else if (fieldValue) { + return { config: fieldValue.config, meta: fieldValue.meta }; + } else if (directiveValue) { + return { config: directiveValue.config, meta: null }; + } else { + return null; + } +}; diff --git a/packages/fabrix/src/readers/form.ts b/packages/fabrix/src/readers/form.ts new file mode 100644 index 00000000..763ddbc8 --- /dev/null +++ b/packages/fabrix/src/readers/form.ts @@ -0,0 +1,98 @@ +import { FabrixContextType } from "@context"; +import { + formFieldConstraintSchema, + FormFieldSchema, + formFieldSchema, +} from "@directive/schema"; +import { resolveFieldType } from "@renderers/shared"; +import { FieldVariables, Path } from "@visitor"; +import { deepmerge } from "deepmerge-ts"; +import { + GraphQLInputObjectType, + GraphQLInputType, + GraphQLNonNull, +} from "graphql"; +import { z } from "zod"; +import { FieldConfigWithMeta, FieldConfig } from "./shared"; + +const buildFieldMeta = (type: GraphQLInputType) => ({ + fieldType: resolveFieldType( + type instanceof GraphQLNonNull ? type.ofType : type, + ), + isRequired: type instanceof GraphQLNonNull, +}); + +export type FieldMeta = ReturnType | null; + +/** + * Infer the field configuration from the input object type for the form + */ +export const buildDefaultFormFieldConfigs = ( + context: FabrixContextType, + fieldVariables: FieldVariables, +) => { + if (!("input" in fieldVariables)) { + return []; + } + + if (context.schemaLoader.status === "loading") { + return []; + } + + const inputType = context.schemaLoader.schemaSet.serverSchema.getType( + fieldVariables.input.type, + ); + if (!inputType) { + return []; + } + + // Only support object type for "input" argument + if (!(inputType instanceof GraphQLInputObjectType)) { + return []; + } + + const fields = inputType.getFields(); + return Object.keys(fields).map((key, index) => { + const field = fields[key]; + const path = new Path(key.split(".")); + + return { + field: path, + meta: buildFieldMeta(field.type), + config: formFieldSchema.parse({ + index, + label: field.name, + }), + }; + }); +}; + +export type FormFieldExtra = { + constraint?: z.infer; +}; + +export const formFieldMerger = ( + fieldValue: FieldConfigWithMeta | undefined, + directiveValue: FieldConfig | undefined, +) => { + if (fieldValue && directiveValue) { + return { + config: deepmerge(fieldValue.config, directiveValue.config), + meta: fieldValue.meta, + constraint: directiveValue.constraint, + }; + } else if (fieldValue) { + return { + config: fieldValue.config, + meta: fieldValue.meta, + }; + } else if (directiveValue) { + return { + config: directiveValue.config, + meta: null, + constraint: directiveValue.constraint, + }; + } else { + return null; + } +}; diff --git a/packages/fabrix/src/readers/shared.ts b/packages/fabrix/src/readers/shared.ts new file mode 100644 index 00000000..6a8a17e1 --- /dev/null +++ b/packages/fabrix/src/readers/shared.ts @@ -0,0 +1,67 @@ +import { FieldType } from "@renderers/shared"; +import { Path } from "@visitor"; + +type FieldMeta = { + fieldType: FieldType; + isRequired: boolean; +} | null; + +export type FieldConfig< + C extends Record, + E extends Record = Record, +> = { + field: Path; + config: C; +} & E; + +export type FieldConfigWithMeta> = + FieldConfig< + C, + { + meta: FieldMeta; + } + >; + +type Merger< + C extends Record, + E extends Record, +> = ( + fieldValue: FieldConfigWithMeta | undefined, + directiveValue: FieldConfig | undefined, +) => Omit & E, "field"> | null; + +/* + * Merge the default field configs with the input field configs + */ +export const mergeFieldConfigs = < + C extends Record, + E extends Record, +>( + fieldConfigs: Array>, + directiveInput: Array>, + merger: Merger, +) => { + const allFieldKeys = new Set([ + ...fieldConfigs.map((f) => f.field.asKey()), + ...directiveInput.map((f) => f.field.asKey()), + ]); + + return Array.from(allFieldKeys).flatMap((key) => { + const directiveValue = directiveInput.find((f) => f.field.asKey() === key); + const fieldValue = fieldConfigs.find((f) => f.field.asKey() === key); + const field = directiveValue?.field || fieldValue?.field; + if (!field) { + return []; + } + + const mergedValue = merger(fieldValue, directiveValue); + if (!mergedValue) { + return []; + } + + return { + field, + ...mergedValue, + }; + }); +}; diff --git a/packages/fabrix/src/renderer.tsx b/packages/fabrix/src/renderer.tsx index 837e8926..2a64e064 100644 --- a/packages/fabrix/src/renderer.tsx +++ b/packages/fabrix/src/renderer.tsx @@ -10,11 +10,9 @@ import { CommonFabrixComponentRendererProps, } from "@renderers/shared"; import { directiveSchemaMap } from "@directive/schema"; -import { - buildDefaultViewFieldConfigs, - buildDefaultFormFieldConfigs, - mergeFieldConfigs, -} from "@inferer"; +import { mergeFieldConfigs } from "@readers/shared"; +import { buildDefaultViewFieldConfigs, viewFieldMerger } from "@readers/field"; +import { buildDefaultFormFieldConfigs, formFieldMerger } from "@readers/form"; import { buildRootDocument, Field, Fields, FieldVariables } from "@/visitor"; import { FabrixComponentData } from "@/fetcher"; @@ -73,6 +71,7 @@ const getFieldConfig = ( fields: mergeFieldConfigs( buildDefaultViewFieldConfigs(childFields), directive.input, + viewFieldMerger, ), }, }; @@ -100,6 +99,7 @@ const getFieldConfig = ( fields: mergeFieldConfigs( buildDefaultFormFieldConfigs(context, fieldVariables), directive.input, + formFieldMerger, ), }, }; diff --git a/packages/fabrix/src/renderers/fields.tsx b/packages/fabrix/src/renderers/fields.tsx index 8f19f103..c9924bff 100644 --- a/packages/fabrix/src/renderers/fields.tsx +++ b/packages/fabrix/src/renderers/fields.tsx @@ -1,6 +1,6 @@ import { createElement, useCallback, useMemo } from "react"; import { ViewFieldSchema } from "@directive/schema"; -import { FieldWithDirective } from "@inferer"; +import { FieldConfigWithMeta } from "@readers/shared"; import { FabrixContextType } from "@context"; import { useDataFetch, Value } from "../fetcher"; import { RendererCommonProps } from "../renderer"; @@ -13,7 +13,7 @@ import { resolveFieldTypesFromTypename, } from "./shared"; -type ViewField = FieldWithDirective; +type ViewField = FieldConfigWithMeta; type Fields = Array; export const ViewRenderer = ( diff --git a/packages/fabrix/src/renderers/form.tsx b/packages/fabrix/src/renderers/form.tsx index 4c40a749..7da6e68f 100644 --- a/packages/fabrix/src/renderers/form.tsx +++ b/packages/fabrix/src/renderers/form.tsx @@ -2,32 +2,20 @@ import { createElement, useCallback } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { useMutation } from "urql"; import { FormFieldSchema } from "@directive/schema"; -import { FieldWithDirective } from "@inferer"; +import { FieldConfigWithMeta } from "@readers/shared"; +import { FormFieldExtra } from "@readers/form"; import { FabrixContextType } from "../context"; import { buildClassName, CommonFabrixComponentRendererProps, - FieldType, + defaultFieldType, getFieldConfigByKey, Loader, } from "./shared"; +import { buildAjvSchema } from "./form/validation"; +import { ajvResolver } from "./form/ajvResolver"; -const getClearedValue = (values: Record) => - Object.keys(values).reduce((acc, key) => { - return { - ...acc, - [key]: null, - }; - }, {}); - -export type FormFieldMeta = - | { - fieldType: FieldType; - isRequired: boolean; - } - | Record; - -export type FormField = FieldWithDirective; +export type FormField = FieldConfigWithMeta & FormFieldExtra; export const FormRenderer = ( props: CommonFabrixComponentRendererProps<{ @@ -35,21 +23,18 @@ export const FormRenderer = ( }>, ) => { const { context, fieldConfigs, query, componentFieldsRenderer } = props; - const formContext = useForm(); + const formContext = useForm({ + resolver: ajvResolver(buildAjvSchema(fieldConfigs.fields)), + }); const [mutationResult, runMutation] = useMutation(query.documentResolver()); + const runSubmit = formContext.handleSubmit(async (input) => { + // TODO: sending values should be specifiable by the user through something like `path` + await runMutation({ + input, + }); - const runSubmit = useCallback(() => { - runMutation({ - // TODO: here should be specifiable by the user through `path` - input: formContext.getValues(), - }) - .then(() => { - formContext.reset(getClearedValue(formContext.getValues())); - }) - .catch((error) => { - throw error; - }); - }, [formContext, runMutation]); + formContext.reset(); + }); const renderFields = useCallback(() => { if (componentFieldsRenderer) { @@ -141,9 +126,9 @@ const renderField = (props: { return createElement(component, { key: indexKey, value: fieldConfig.defaultValue, - type: field.meta.fieldType, + type: field.meta?.fieldType ?? defaultFieldType, name: field.field.asKey(), - isRequired: field.meta.isRequired, + isRequired: field.meta?.isRequired ?? false, attributes: { className, label: fieldConfig.label, diff --git a/packages/fabrix/src/renderers/form/ajvResolver.ts b/packages/fabrix/src/renderers/form/ajvResolver.ts new file mode 100644 index 00000000..ced58a38 --- /dev/null +++ b/packages/fabrix/src/renderers/form/ajvResolver.ts @@ -0,0 +1,112 @@ +// Implementation copy-pasted from the original (https://github.com/react-hook-form/resolvers/blob/master/ajv/src/ajv.ts) +// we would like to extends Ajv to use ajv-formats for our requirements, but built-in RHF resolvers are not extendable. + +import { toNestErrors, validateFieldsNatively } from "@hookform/resolvers"; +import Ajv, { DefinedError, JSONSchemaType, Options } from "ajv"; +import ajvErrors from "ajv-errors"; +import ajvFormats from "ajv-formats"; +import { + FieldError, + FieldValues, + ResolverOptions, + ResolverResult, + appendErrors, +} from "react-hook-form"; + +type Resolver = ( + schema: JSONSchemaType, + schemaOptions?: Options, + factoryOptions?: { mode?: "async" | "sync" }, +) => ( + values: TFieldValues, + context: TContext | undefined, + options: ResolverOptions, +) => Promise>; + +const parseErrorSchema = ( + ajvErrors: DefinedError[], + validateAllFieldCriteria: boolean, +) => { + // Ajv will return empty instancePath when require error + ajvErrors.forEach((error) => { + if (error.keyword === "required") { + error.instancePath += "/" + error.params.missingProperty; + } + }); + + return ajvErrors.reduce>((previous, error) => { + // `/deepObject/data` -> `deepObject.data` + const path = error.instancePath.substring(1).replace(/\//g, "."); + + if (!previous[path]) { + previous[path] = { + message: error.message, + type: error.keyword, + }; + } + + if (validateAllFieldCriteria) { + const types = previous[path].types; + const messages = types && types[error.keyword]; + + previous[path] = appendErrors( + path, + validateAllFieldCriteria, + previous, + error.keyword, + messages + ? ([] as string[]).concat(messages as string[], error.message || "") + : error.message, + ) as FieldError; + } + + return previous; + }, {}); +}; + +export const ajvResolver: Resolver = + (schema, schemaOptions, resolverOptions = {}) => + async (values, _, options) => { + const ajv = new Ajv( + Object.assign( + {}, + { + allErrors: true, + validateSchema: true, + }, + schemaOptions, + ), + ); + + ajvErrors(ajv); + ajvFormats(ajv); + + const validate = ajv.compile( + Object.assign( + { $async: resolverOptions && resolverOptions.mode === "async" }, + schema, + ), + ); + + const valid = validate(values); + + if (options.shouldUseNativeValidation) { + validateFieldsNatively({}, options); + } + + return Promise.resolve( + valid + ? { values, errors: {} } + : { + values: {}, + errors: toNestErrors( + parseErrorSchema( + validate.errors as DefinedError[], + !options.shouldUseNativeValidation && + options.criteriaMode === "all", + ), + options, + ), + }, + ); + }; diff --git a/packages/fabrix/src/renderers/form/validation.ts b/packages/fabrix/src/renderers/form/validation.ts new file mode 100644 index 00000000..0eba121e --- /dev/null +++ b/packages/fabrix/src/renderers/form/validation.ts @@ -0,0 +1,57 @@ +import { FormField } from "@renderers/form"; + +const convertToAjvProperty = (field: FormField) => { + switch (field.meta?.fieldType?.type) { + case "Scalar": + switch (field.meta.fieldType.name) { + case "Int": + case "Float": + return { + type: "number", + maximum: field.constraint?.max, + minimum: field.constraint?.min, + exclusiveMaximum: field.constraint?.exclusiveMax, + exclusiveMinimum: field.constraint?.exclusiveMin, + multipleOf: field.constraint?.multipleOf, + enum: field.constraint?.oneOf, + } as const; + case "String": + default: { + return { + type: "string", + maxLength: field.constraint?.maxLength, + minLength: field.constraint?.minLength, + format: field.constraint?.format, + pattern: field.constraint?.pattern, + enum: field.constraint?.oneOf, + } as const; + } + case "Boolean": + return { + type: "boolean", + } as const; + } + default: + // TODO: handle other types (e.g. object, array) + return null; + } +}; + +export const buildAjvSchema = (fields: Array) => { + const visibleFields = fields.filter((field) => !field.config.hidden); + const requiredFields = visibleFields.filter( + (field) => field.meta?.isRequired, + ); + + return { + type: "object", + properties: visibleFields.reduce((acc, field) => { + const property = convertToAjvProperty(field); + return property === null + ? acc + : { ...acc, [field.field.asKey()]: property }; + }, {}), + required: requiredFields.map((field) => field.field.asKey()), + additionalProperties: true, + } as const; +}; diff --git a/packages/fabrix/src/renderers/shared.tsx b/packages/fabrix/src/renderers/shared.tsx index 98d8bbaf..fe87a3fe 100644 --- a/packages/fabrix/src/renderers/shared.tsx +++ b/packages/fabrix/src/renderers/shared.tsx @@ -10,7 +10,7 @@ import { } from "graphql"; import { DirectiveAttributes } from "@registry"; import { FabrixContextType } from "@context"; -import { FieldWithDirective } from "@inferer"; +import { FieldConfigWithMeta } from "@readers/shared"; import { FabrixComponentData } from "../fetcher"; type FabrixComponentFieldsRendererExtraProps = Partial & { @@ -93,11 +93,8 @@ export const assertObjectValue: ( export type FieldTypes = ReturnType; -export const getFieldConfigByKey = < - C extends Record = Record, - M extends Record = Record, ->( - fields: Array>, +export const getFieldConfigByKey = >( + fields: Array>, name: string, ) => fields.find((f) => f.field.asKey() == name); @@ -152,27 +149,34 @@ export const resolveFieldTypesFromTypename = ( }, {}); }; -export type FieldType = - | { - type: "Scalar"; - name: string; - } - | { - type: "Enum"; - name: string; - meta: { - values: string[]; - }; - } - | { - type: "Object"; - name: string; - } - | { - type: "List"; - innerType: NonNullable; - } - | null; +type ScalarType = { + type: "Scalar"; + name: string; +}; + +type EnumType = { + type: "Enum"; + name: string; + meta: { + values: string[]; + }; +}; + +type ObjectType = { + type: "Object"; + name: string; +}; + +type ListType = { + type: "List"; + innerType: NonNullable; +}; + +export type FieldType = ScalarType | EnumType | ObjectType | ListType | null; +export const defaultFieldType = { + type: "Scalar" as const, + name: "String", +}; export const resolveFieldType = ( field: GraphQLOutputType | GraphQLNullableType, diff --git a/packages/fabrix/vitest.config.ts b/packages/fabrix/vitest.config.ts index a8739ab3..e2cb7e0b 100644 --- a/packages/fabrix/vitest.config.ts +++ b/packages/fabrix/vitest.config.ts @@ -5,8 +5,8 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [viteReact(), tsconfigPaths()], test: { - include: ["src/**/*.test.tsx"], + include: ["src/**/*.test.tsx", "__tests__/**/*.test.tsx"], environment: "happy-dom", - setupFiles: ["./tests/setup.ts"], + setupFiles: ["./__tests__/setup.ts"], }, }); diff --git a/packages/graphql-config/src/schema.ts b/packages/graphql-config/src/schema.ts index 05eb2284..40014ee4 100644 --- a/packages/graphql-config/src/schema.ts +++ b/packages/graphql-config/src/schema.ts @@ -2,6 +2,7 @@ import { DocumentNode, Kind } from "graphql"; import CommonSchema from "./schema/common.graphql"; import ViewDirectiveSchema from "./schema/view.graphql"; import FormDirectiveSchema from "./schema/form.graphql"; +import ConstraintSchema from "./schema/constraint.graphql"; const mergeDocumentNodes = (docs: DocumentNode[]) => ({ kind: Kind.DOCUMENT, @@ -12,4 +13,5 @@ export const schemaDefinition = mergeDocumentNodes([ CommonSchema, ViewDirectiveSchema, FormDirectiveSchema, + ConstraintSchema, ]); diff --git a/packages/graphql-config/src/schema/constraint.graphql b/packages/graphql-config/src/schema/constraint.graphql new file mode 100644 index 00000000..8a36731a --- /dev/null +++ b/packages/graphql-config/src/schema/constraint.graphql @@ -0,0 +1,30 @@ +input FabrixFormConstraint { + """ + String + """ + minLength: Int + maxLength: Int + pattern: String + format: String + oneOf: [String] + + """ + Int + """ + min: Int + max: Int + exclusiveMin: Int + exclusiveMax: Int + multipleOf: Int + oneOf: [Int] + + """ + Float + """ + min: Float + max: Float + exclusiveMin: Float + exclusiveMax: Float + multipleOf: Float + oneOf: [Float] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71c396c0..a51ed07d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,18 @@ importers: packages/fabrix: dependencies: + '@hookform/resolvers': + specifier: ^3.9.0 + version: 3.9.0(react-hook-form@7.53.0(react@18.3.1)) + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-errors: + specifier: ^3.0.0 + version: 3.0.0(ajv@8.17.1) + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) deepmerge-ts: specifier: ^7.1.0 version: 7.1.3 @@ -235,7 +247,7 @@ importers: version: 18.3.1 '@vitejs/plugin-react': specifier: ^4.3.3 - version: 4.3.3(vite@5.4.6(@types/node@22.7.5)) + version: 4.3.3(vite@5.4.8(@types/node@22.7.5)) eslint: specifier: ^9.6.0 version: 9.12.0 @@ -256,7 +268,7 @@ importers: version: 5.6.3 vite-tsconfig-paths: specifier: ^4.3.2 - version: 4.3.2(typescript@5.6.3)(vite@5.4.6(@types/node@22.7.5)) + version: 4.3.2(typescript@5.6.3)(vite@5.4.8(@types/node@22.7.5)) vitest: specifier: ^2.0.3 version: 2.1.2(@types/node@22.7.5)(happy-dom@15.7.4)(msw@2.4.9(typescript@5.6.3)) @@ -311,7 +323,7 @@ importers: version: 9.1.0(eslint@9.12.0) eslint-plugin-import: specifier: ^2.29.1 - version: 2.31.0(eslint@9.12.0) + version: 2.31.0(@typescript-eslint/parser@8.8.1(eslint@9.12.0)(typescript@5.6.3))(eslint@9.12.0) typescript: specifier: ^5 version: 5.6.3 @@ -500,10 +512,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.25.6': - resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.25.7': resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==} engines: {node: '>=6.9.0'} @@ -582,9 +590,6 @@ packages: '@emotion/hash@0.9.2': resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - '@emotion/is-prop-valid@1.3.0': - resolution: {integrity: sha512-SHetuSLvJDzuNbOdtPVbq6yMMMlLoW5Q94uDqJZqy50gcmAjxFkVqmzqSGEFq9gT2iMuIeKV1PXVWmvUhuZLlQ==} - '@emotion/is-prop-valid@1.3.1': resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==} @@ -918,10 +923,6 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.11.0': - resolution: {integrity: sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint-community/regexpp@4.11.1': resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -992,6 +993,11 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@hookform/resolvers@3.9.0': + resolution: {integrity: sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==} + peerDependencies: + react-hook-form: ^7.0.0 + '@humanfs/core@0.19.0': resolution: {integrity: sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==} engines: {node: '>=18.18.0'} @@ -1552,9 +1558,25 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-errors@3.0.0: + resolution: {integrity: sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==} + peerDependencies: + ajv: ^8.0.1 + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1860,15 +1882,6 @@ packages: supports-color: optional: true - debug@4.3.6: - resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -2131,6 +2144,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.0.3: + resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -2231,9 +2247,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-func-name@2.0.2: - resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} - get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} @@ -2538,6 +2551,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2599,9 +2615,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loupe@3.1.1: - resolution: {integrity: sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==} - loupe@3.1.2: resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} @@ -2689,9 +2702,6 @@ packages: ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2886,10 +2896,6 @@ packages: yaml: optional: true - postcss@8.4.45: - resolution: {integrity: sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.4.47: resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} engines: {node: ^10 || ^12 || >=14} @@ -3046,6 +3052,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -3161,10 +3171,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - source-map-js@1.2.0: - resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} - engines: {node: '>=0.10.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3562,37 +3568,6 @@ packages: vite: optional: true - vite@5.4.6: - resolution: {integrity: sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - vite@5.4.8: resolution: {integrity: sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3967,10 +3942,6 @@ snapshots: '@babel/core': 7.25.2 '@babel/helper-plugin-utils': 7.24.8 - '@babel/runtime@7.25.6': - dependencies: - regenerator-runtime: 0.14.1 - '@babel/runtime@7.25.7': dependencies: regenerator-runtime: 0.14.1 @@ -4081,7 +4052,7 @@ snapshots: '@emotion/babel-plugin@11.12.0': dependencies: '@babel/helper-module-imports': 7.24.7 - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.25.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.1 @@ -4114,20 +4085,15 @@ snapshots: '@emotion/hash@0.9.2': {} - '@emotion/is-prop-valid@1.3.0': - dependencies: - '@emotion/memoize': 0.9.0 - '@emotion/is-prop-valid@1.3.1': dependencies: '@emotion/memoize': 0.9.0 - optional: true '@emotion/memoize@0.9.0': {} '@emotion/react@11.13.3(@types/react@18.3.11)(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.25.7 '@emotion/babel-plugin': 11.12.0 '@emotion/cache': 11.13.1 '@emotion/serialize': 1.3.1 @@ -4153,9 +4119,9 @@ snapshots: '@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.25.7 '@emotion/babel-plugin': 11.12.0 - '@emotion/is-prop-valid': 1.3.0 + '@emotion/is-prop-valid': 1.3.1 '@emotion/react': 11.13.3(@types/react@18.3.11)(react@18.3.1) '@emotion/serialize': 1.3.1 '@emotion/use-insertion-effect-with-fallbacks': 1.1.0(react@18.3.1) @@ -4322,8 +4288,6 @@ snapshots: eslint: 9.12.0 eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.11.0': {} - '@eslint-community/regexpp@4.11.1': {} '@eslint/compat@1.2.0(eslint@9.12.0)': @@ -4333,7 +4297,7 @@ snapshots: '@eslint/config-array@0.18.0': dependencies: '@eslint/object-schema': 2.1.4 - debug: 4.3.6 + debug: 4.3.7 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -4343,7 +4307,7 @@ snapshots: '@eslint/eslintrc@3.1.0': dependencies: ajv: 6.12.6 - debug: 4.3.6 + debug: 4.3.7 espree: 10.2.0 globals: 14.0.0 ignore: 5.3.2 @@ -4399,6 +4363,10 @@ snapshots: dependencies: graphql: 16.9.0 + '@hookform/resolvers@3.9.0(react-hook-form@7.53.0(react@18.3.1))': + dependencies: + react-hook-form: 7.53.0(react@18.3.1) + '@humanfs/core@0.19.0': {} '@humanfs/node@0.16.5': @@ -4673,7 +4641,7 @@ snapshots: '@testing-library/react@16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.1)(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.25.6 + '@babel/runtime': 7.25.7 '@testing-library/dom': 10.4.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -4894,17 +4862,6 @@ snapshots: transitivePeerDependencies: - graphql - '@vitejs/plugin-react@4.3.3(vite@5.4.6(@types/node@22.7.5))': - dependencies: - '@babel/core': 7.25.2 - '@babel/plugin-transform-react-jsx-self': 7.24.7(@babel/core@7.25.2) - '@babel/plugin-transform-react-jsx-source': 7.24.7(@babel/core@7.25.2) - '@types/babel__core': 7.20.5 - react-refresh: 0.14.2 - vite: 5.4.6(@types/node@22.7.5) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-react@4.3.3(vite@5.4.8(@types/node@22.7.5))': dependencies: '@babel/core': 7.25.2 @@ -4923,14 +4880,14 @@ snapshots: chai: 5.1.1 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.4.9(typescript@5.6.3))(vite@5.4.6(@types/node@22.7.5))': + '@vitest/mocker@2.1.2(@vitest/spy@2.1.2)(msw@2.4.9(typescript@5.6.3))(vite@5.4.8(@types/node@22.7.5))': dependencies: '@vitest/spy': 2.1.2 estree-walker: 3.0.3 magic-string: 0.30.11 optionalDependencies: msw: 2.4.9(typescript@5.6.3) - vite: 5.4.6(@types/node@22.7.5) + vite: 5.4.8(@types/node@22.7.5) '@vitest/pretty-format@2.1.2': dependencies: @@ -4976,6 +4933,14 @@ snapshots: acorn@8.12.1: {} + ajv-errors@3.0.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -4983,6 +4948,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -5172,7 +5144,7 @@ snapshots: assertion-error: 2.0.1 check-error: 2.1.1 deep-eql: 5.0.2 - loupe: 3.1.1 + loupe: 3.1.2 pathval: 2.0.0 chakra-react-select@5.0.1(@chakra-ui/react@2.10.2(@emotion/react@11.13.3(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(framer-motion@11.11.8(@emotion/is-prop-valid@1.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@emotion/react@11.13.3(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -5331,10 +5303,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.6: - dependencies: - ms: 2.1.2 - debug@4.3.7: dependencies: ms: 2.1.3 @@ -5565,16 +5533,17 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.12.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.8.1(eslint@9.12.0)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.12.0): dependencies: debug: 3.2.7 optionalDependencies: + '@typescript-eslint/parser': 8.8.1(eslint@9.12.0)(typescript@5.6.3) eslint: 9.12.0 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.31.0(eslint@9.12.0): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.8.1(eslint@9.12.0)(typescript@5.6.3))(eslint@9.12.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -5585,7 +5554,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.12.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(eslint-import-resolver-node@0.3.9)(eslint@9.12.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.8.1(eslint@9.12.0)(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.12.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -5596,6 +5565,8 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.8 tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.8.1(eslint@9.12.0)(typescript@5.6.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -5635,7 +5606,7 @@ snapshots: eslint@9.12.0: dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0) - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/regexpp': 4.11.1 '@eslint/config-array': 0.18.0 '@eslint/core': 0.6.0 '@eslint/eslintrc': 3.1.0 @@ -5649,7 +5620,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.6 + debug: 4.3.7 escape-string-regexp: 4.0.0 eslint-scope: 8.1.0 eslint-visitor-keys: 4.1.0 @@ -5758,6 +5729,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.0.3: {} + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -5853,8 +5826,6 @@ snapshots: get-caller-file@2.0.5: {} - get-func-name@2.0.2: {} - get-intrinsic@1.2.4: dependencies: es-errors: 1.3.0 @@ -6131,6 +6102,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -6181,10 +6154,6 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@3.1.1: - dependencies: - get-func-name: 2.0.2 - loupe@3.1.2: {} lru-cache@10.4.3: {} @@ -6251,8 +6220,6 @@ snapshots: ms@2.0.0: {} - ms@2.1.2: {} - ms@2.1.3: {} msw@2.4.9(typescript@5.6.3): @@ -6423,18 +6390,11 @@ snapshots: postcss: 8.4.47 tsx: 4.19.1 - postcss@8.4.45: - dependencies: - nanoid: 3.3.7 - picocolors: 1.1.0 - source-map-js: 1.2.0 - postcss@8.4.47: dependencies: nanoid: 3.3.7 picocolors: 1.1.0 source-map-js: 1.2.1 - optional: true prelude-ls@1.2.1: {} @@ -6601,6 +6561,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + requires-port@1.0.0: {} resolve-from@4.0.0: {} @@ -6751,10 +6713,7 @@ snapshots: signal-exit@4.1.0: {} - source-map-js@1.2.0: {} - - source-map-js@1.2.1: - optional: true + source-map-js@1.2.1: {} source-map@0.5.7: {} @@ -6950,7 +6909,7 @@ snapshots: cac: 6.7.14 chokidar: 3.6.0 consola: 3.2.3 - debug: 4.3.6 + debug: 4.3.7 esbuild: 0.23.1 execa: 5.1.1 joycon: 3.1.1 @@ -7130,9 +7089,9 @@ snapshots: vite-node@2.1.2(@types/node@22.7.5): dependencies: cac: 6.7.14 - debug: 4.3.6 + debug: 4.3.7 pathe: 1.1.2 - vite: 5.4.6(@types/node@22.7.5) + vite: 5.4.8(@types/node@22.7.5) transitivePeerDependencies: - '@types/node' - less @@ -7150,30 +7109,21 @@ snapshots: graphql-tag: 2.12.6(graphql@16.9.0) magic-string: 0.30.11 - vite-tsconfig-paths@4.3.2(typescript@5.6.3)(vite@5.4.6(@types/node@22.7.5)): + vite-tsconfig-paths@4.3.2(typescript@5.6.3)(vite@5.4.8(@types/node@22.7.5)): dependencies: debug: 4.3.7 globrex: 0.1.2 tsconfck: 3.1.3(typescript@5.6.3) optionalDependencies: - vite: 5.4.6(@types/node@22.7.5) + vite: 5.4.8(@types/node@22.7.5) transitivePeerDependencies: - supports-color - typescript - vite@5.4.6(@types/node@22.7.5): - dependencies: - esbuild: 0.21.5 - postcss: 8.4.45 - rollup: 4.21.2 - optionalDependencies: - '@types/node': 22.7.5 - fsevents: 2.3.3 - vite@5.4.8(@types/node@22.7.5): dependencies: esbuild: 0.21.5 - postcss: 8.4.45 + postcss: 8.4.47 rollup: 4.21.2 optionalDependencies: '@types/node': 22.7.5 @@ -7182,14 +7132,14 @@ snapshots: vitest@2.1.2(@types/node@22.7.5)(happy-dom@15.7.4)(msw@2.4.9(typescript@5.6.3)): dependencies: '@vitest/expect': 2.1.2 - '@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.4.9(typescript@5.6.3))(vite@5.4.6(@types/node@22.7.5)) + '@vitest/mocker': 2.1.2(@vitest/spy@2.1.2)(msw@2.4.9(typescript@5.6.3))(vite@5.4.8(@types/node@22.7.5)) '@vitest/pretty-format': 2.1.2 '@vitest/runner': 2.1.2 '@vitest/snapshot': 2.1.2 '@vitest/spy': 2.1.2 '@vitest/utils': 2.1.2 chai: 5.1.1 - debug: 4.3.6 + debug: 4.3.7 magic-string: 0.30.11 pathe: 1.1.2 std-env: 3.7.0 @@ -7197,7 +7147,7 @@ snapshots: tinyexec: 0.3.0 tinypool: 1.0.1 tinyrainbow: 1.2.0 - vite: 5.4.6(@types/node@22.7.5) + vite: 5.4.8(@types/node@22.7.5) vite-node: 2.1.2(@types/node@22.7.5) why-is-node-running: 2.3.0 optionalDependencies: