diff --git a/src/components/form/form.test.tsx b/src/components/form/form.test.tsx index 80656c8..694624a 100644 --- a/src/components/form/form.test.tsx +++ b/src/components/form/form.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, expect, it, vi, beforeEach, type Mock } from "vitest"; import { BrowserRouter } from "react-router-dom"; -import { Form } from "./form"; +import { Form, AdditionalField } from "./form"; import { ResourceSchema, PropertySchema } from "@/state/openapi"; import { Schema } from "@aep_dev/aep-lib-ts"; import { ResourceInstance } from "@/state/fetch"; @@ -40,11 +40,24 @@ describe("Form", () => { headers = "", parentParams = new Map(), resourceInstance?: ResourceInstance, - onSubmitOperation?: (value: Record) => Promise, + onSubmitOperation?: ( + value: Record, + additionalFieldsValues: Record, + ) => Promise, + additionalFields?: AdditionalField[], ) => { const defaultOnSubmitOperation = onSubmitOperation || - ((value: Record) => resource.create(value, headers)); + (( + value: Record, + additionalFieldsValues: Record, + ) => { + let id: string | undefined = undefined; + if (additionalFieldsValues.id) { + id = additionalFieldsValues.id as string; + } + return resource.create(value, id, headers); + }); return render( @@ -56,6 +69,7 @@ describe("Form", () => { onError={mockOnError} resourceInstance={resourceInstance} onSubmitOperation={defaultOnSubmitOperation} + additionalFields={additionalFields} /> , ); @@ -179,6 +193,7 @@ describe("Form", () => { age: 25, active: true, }, + undefined, "", ); }); @@ -306,6 +321,7 @@ describe("Form", () => { { requiredField: "Required Value", }, + undefined, "", ); }); @@ -359,6 +375,7 @@ describe("Form", () => { { requiredName: "Test Name", }, + undefined, "", ); }); @@ -385,6 +402,7 @@ describe("Form", () => { { requiredName: "Test Name", }, + undefined, "", ); }); @@ -593,6 +611,7 @@ describe("Form", () => { city: "Springfield", }, }, + undefined, "", ); }); @@ -727,7 +746,10 @@ describe("Form", () => { fireEvent.click(screen.getByRole("button", { name: "Submit" })); await waitFor(() => { - expect(mockUpdateOperation).toHaveBeenCalledWith({ name: "New Name" }); + expect(mockUpdateOperation).toHaveBeenCalledWith( + { name: "New Name" }, + {}, + ); }); expect(mockOnSuccess).toHaveBeenCalled(); @@ -749,7 +771,10 @@ describe("Form", () => { fireEvent.click(screen.getByRole("button", { name: "Submit" })); await waitFor(() => { - expect(mockCreateOperation).toHaveBeenCalledWith({ name: "Test Name" }); + expect(mockCreateOperation).toHaveBeenCalledWith( + { name: "Test Name" }, + {}, + ); }); expect(mockOnSuccess).toHaveBeenCalled(); @@ -816,10 +841,13 @@ describe("Form", () => { fireEvent.click(screen.getByRole("button", { name: "Submit" })); await waitFor(() => { - expect(mockUpdateOperation).toHaveBeenCalledWith({ - name: "Modified Name", - age: 30, - }); + expect(mockUpdateOperation).toHaveBeenCalledWith( + { + name: "Modified Name", + age: 30, + }, + {}, + ); }); }); @@ -1049,6 +1077,7 @@ describe("Form", () => { name: "Jane Doe", age: 30, }), + undefined, "", ); }); @@ -1244,6 +1273,7 @@ describe("Form", () => { { name: "Test Name", }, + undefined, "", ); }); @@ -1253,4 +1283,115 @@ describe("Form", () => { expect(call[0]).not.toHaveProperty("id"); }); }); + + describe("Additional fields", () => { + it("renders additional fields when provided", () => { + const properties = [new PropertySchema("name", "string")]; + const resource = createMockResourceSchema(properties); + const additionalFields: AdditionalField[] = [ + { name: "id", type: "string" }, + ]; + renderForm( + resource, + "", + new Map(), + undefined, + undefined, + additionalFields, + ); + + expect(screen.getByLabelText("id")).toBeInTheDocument(); + expect(screen.getByLabelText("name")).toBeInTheDocument(); + }); + + it("does not render additional fields when not provided", () => { + const properties = [new PropertySchema("name", "string")]; + const resource = createMockResourceSchema(properties); + renderForm(resource); + + expect(screen.queryByLabelText("id")).not.toBeInTheDocument(); + expect(screen.getByLabelText("name")).toBeInTheDocument(); + }); + + it("includes additional field values in submission", async () => { + const properties = [new PropertySchema("name", "string")]; + const resource = createMockResourceSchema(properties); + const additionalFields: AdditionalField[] = [ + { name: "id", type: "string" }, + ]; + renderForm( + resource, + "", + new Map(), + undefined, + undefined, + additionalFields, + ); + + fireEvent.change(screen.getByLabelText("id"), { + target: { value: "my-resource-id" }, + }); + fireEvent.change(screen.getByLabelText("name"), { + target: { value: "Test Name" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => { + expect(resource.create).toHaveBeenCalledWith( + { + name: "Test Name", + }, + "my-resource-id", + "", + ); + }); + }); + + it("allows submission with empty optional additional field", async () => { + const properties = [new PropertySchema("name", "string")]; + const resource = createMockResourceSchema(properties); + const additionalFields: AdditionalField[] = [ + { name: "id", type: "string" }, + ]; + renderForm( + resource, + "", + new Map(), + undefined, + undefined, + additionalFields, + ); + + fireEvent.change(screen.getByLabelText("name"), { + target: { value: "Test Name" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); + + await waitFor(() => { + expect(resource.create).toHaveBeenCalledWith( + { + name: "Test Name", + }, + undefined, + "", + ); + }); + }); + + it("works with real OpenAPI schema that supports user-settable id", async () => { + const fileContents = fs.readFileSync("src/example_oas.json", "utf8"); + const openAPI = await parseOpenAPI(fileContents); + const publisherResource = openAPI.resourceForName("publishers"); + + expect(publisherResource.supportsUserSettableCreate).toBe(true); + }); + + it("returns false for supportsUserSettableCreate when no id param", async () => { + const fileContents = fs.readFileSync("src/example_oas.json", "utf8"); + const openAPI = await parseOpenAPI(fileContents); + const bookResource = openAPI.resourceForName("books"); + + expect(bookResource.supportsUserSettableCreate).toBe(false); + }); + }); }); diff --git a/src/components/form/form.tsx b/src/components/form/form.tsx index 330e29b..e05812c 100644 --- a/src/components/form/form.tsx +++ b/src/components/form/form.tsx @@ -24,15 +24,24 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { JsonEditor } from "json-edit-react"; import { createValidationSchema } from "@/lib/utils"; +export type AdditionalField = { + name: string; + type: string; + required?: boolean; +}; + type FormProps = { resource: ResourceSchema; parentParams: Map; headers: string; onSuccess: () => void; onError: (error: unknown) => void; - // Current resource state used to fill in the form's default values for updating (optional) resourceInstance?: ResourceInstance; - onSubmitOperation: (value: Record) => Promise; + onSubmitOperation: ( + value: Record, + additionalFieldsValues: Record, + ) => Promise; + additionalFields?: AdditionalField[]; }; // Form is responsible for rendering a form based on the resource schema. @@ -44,8 +53,25 @@ export function Form(props: FormProps) { const validationSchema = useMemo(() => { const properties = props.resource.properties(); const requiredFields = props.resource.required(); - return createValidationSchema(properties, requiredFields); - }, [props.resource]); + const baseSchema = createValidationSchema(properties, requiredFields); + + if (!props.additionalFields?.length) return baseSchema; + + const additionalSchemaObj: Record = {}; + for (const field of props.additionalFields) { + const fieldSchema = + field.type === "string" + ? field.required + ? z.string().min(1, { message: `${field.name} is required` }) + : z.string().optional() + : z.string().optional(); + additionalSchemaObj[field.name] = fieldSchema; + } + + return (baseSchema as z.ZodObject>).extend( + additionalSchemaObj, + ); + }, [props.resource, props.additionalFields]); const defaultValues = useMemo(() => { return props.resourceInstance?.properties || {}; @@ -92,10 +118,17 @@ export function Form(props: FormProps) { }; const onSubmit = (value: Record) => { + const additionalFieldValues: Record = {}; + if (props.additionalFields) { + for (const field of props.additionalFields) { + additionalFieldValues[field.name] = value[field.name]; + delete value[field.name]; + } + } // Value is the properly formed JSON body. // Just need to submit it and call the appropriate callback. props - .onSubmitOperation(value) + .onSubmitOperation(value, additionalFieldValues) .then(() => { props.onSuccess(); }) @@ -204,6 +237,14 @@ export function Form(props: FormProps) { [form.control], ); + const additionalFieldElements = useMemo(() => { + if (!props.additionalFields?.length) return null; + return props.additionalFields.map((af) => { + const p = new PropertySchema(af.name, af.type); + return renderField(p); + }); + }, [props.additionalFields, renderField]); + const formBuilder = useMemo(() => { return props.resource.properties().map((p) => renderField(p)); }, [props.resource, renderField]); @@ -235,7 +276,10 @@ export function Form(props: FormProps) { {mode === "form" ? (
- {formBuilder} + + {additionalFieldElements} + {formBuilder} +
) : (
@@ -298,7 +342,18 @@ export default function CreateForm(props: { resource: ResourceSchema }) { headers={headers} onSuccess={handleSuccess} onError={handleError} - onSubmitOperation={(value) => props.resource.create(value, headers)} + onSubmitOperation={(value, additionalFieldsValues) => { + let id: string | undefined = undefined; + if (additionalFieldsValues.id) { + id = additionalFieldsValues.id as string; + } + return props.resource.create(value, id, headers); + }} + additionalFields={ + props.resource.supportsUserSettableCreate + ? [{ name: "id", type: "string" }] + : undefined + } /> ); } diff --git a/src/example_oas.json b/src/example_oas.json index 425a8a6..d7901e6 100644 --- a/src/example_oas.json +++ b/src/example_oas.json @@ -151,6 +151,15 @@ "post": { "description": "Create method for publisher", "operationId": "CreatePublisher", + "parameters": [ + { + "name": "id", + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Successful response", diff --git a/src/state/fetch.test.ts b/src/state/fetch.test.ts index b9dc7d2..2274c3a 100644 --- a/src/state/fetch.test.ts +++ b/src/state/fetch.test.ts @@ -66,7 +66,10 @@ describe("fetch.ts", () => { expect(mockFetch).toHaveBeenCalledWith("http://localhost:8080/test/123", { method: "DELETE", - headers: new Map([["Authorization", "Bearer token"]]), + headers: new Map([ + ["Content-Type", "application/json"], + ["Authorization", "Bearer token"], + ]), }); }); @@ -89,7 +92,10 @@ describe("fetch.ts", () => { expect(mockFetch).toHaveBeenCalledWith("http://localhost:8080/test/123", { method: "PATCH", - headers: new Map([["Authorization", "Bearer token"]]), + headers: new Map([ + ["Content-Type", "application/json"], + ["Authorization", "Bearer token"], + ]), body: JSON.stringify(updateData), }); }); @@ -273,7 +279,7 @@ describe("fetch.ts", () => { expect(mockFetch).toHaveBeenCalledWith("http://test.com/api", { method: "POST", - headers: new Map(), + headers: new Map([["Content-Type", "application/json"]]), body: JSON.stringify(createData), }); }); @@ -323,7 +329,7 @@ describe("fetch.ts", () => { expect(mockFetch).toHaveBeenCalledWith("http://test.com/api/1", { method: "DELETE", - headers: new Map(), + headers: new Map([["Content-Type", "application/json"]]), }); }); @@ -357,7 +363,7 @@ describe("fetch.ts", () => { expect(mockFetch).toHaveBeenCalledWith("http://test.com/api/1", { method: "PATCH", - headers: new Map(), + headers: new Map([["Content-Type", "application/json"]]), body: JSON.stringify(patchData), }); }); @@ -485,7 +491,7 @@ describe("fetch.ts", () => { await List("http://test.com/api", schema, ""); expect(mockFetch).toHaveBeenCalledWith("http://test.com/api", { - headers: new Map(), + headers: new Map([["Content-Type", "application/json"]]), }); }); }); diff --git a/src/state/fetch.ts b/src/state/fetch.ts index e6cf7bf..fd69f69 100644 --- a/src/state/fetch.ts +++ b/src/state/fetch.ts @@ -115,12 +115,14 @@ class ResourceInstance { } function getHeaders(headers: string): object { + const defaultHeaders = new Map(); + defaultHeaders.set("Content-Type", "application/json"); if (!headers) { - return new Map(); + return defaultHeaders; } console.log("headers " + headers); - const headersMap = new Map(); + const headersMap = new Map(defaultHeaders); const headersArray = headers.split(","); headersArray.forEach((header) => { const [key, value] = header.split(":"); diff --git a/src/state/openapi.ts b/src/state/openapi.ts index f29112f..72a9c5d 100644 --- a/src/state/openapi.ts +++ b/src/state/openapi.ts @@ -26,6 +26,10 @@ class ResourceSchema { return this.resource.schema; } + get supportsUserSettableCreate(): boolean { + return this.resource.createMethod?.supportsUserSettableCreate ?? false; + } + public substituteUrlParameters(url: string): string { const paramRegex = /\{([^}]+)\}/g; let match; @@ -68,11 +72,15 @@ class ResourceSchema { return Get(url, this, headers); } - create(body: Record, headers: string = ""): Promise { + create( + body: Record, + id: string = "", + headers: string = "", + ): Promise { const baseUrl = this.base_url(); let url = `${this.server_url}${baseUrl}`; - if (this.properties().find((prop) => prop.name === "id")) { - url += `?id=${(body as Record).id}`; + if (id) { + url += `?id=${id}`; } url = this.substituteUrlParameters(url); return Create(url, body, headers);