Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 150 additions & 9 deletions src/components/form/form.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -40,11 +40,24 @@ describe("Form", () => {
headers = "",
parentParams = new Map<string, string>(),
resourceInstance?: ResourceInstance,
onSubmitOperation?: (value: Record<string, unknown>) => Promise<void>,
onSubmitOperation?: (
value: Record<string, unknown>,
additionalFieldsValues: Record<string, unknown>,
) => Promise<void>,
additionalFields?: AdditionalField[],
) => {
const defaultOnSubmitOperation =
onSubmitOperation ||
((value: Record<string, unknown>) => resource.create(value, headers));
((
value: Record<string, unknown>,
additionalFieldsValues: Record<string, unknown>,
) => {
let id: string | undefined = undefined;
if (additionalFieldsValues.id) {
id = additionalFieldsValues.id as string;
}
return resource.create(value, id, headers);
});

return render(
<BrowserRouter>
Expand All @@ -56,6 +69,7 @@ describe("Form", () => {
onError={mockOnError}
resourceInstance={resourceInstance}
onSubmitOperation={defaultOnSubmitOperation}
additionalFields={additionalFields}
/>
</BrowserRouter>,
);
Expand Down Expand Up @@ -179,6 +193,7 @@ describe("Form", () => {
age: 25,
active: true,
},
undefined,
"",
);
});
Expand Down Expand Up @@ -306,6 +321,7 @@ describe("Form", () => {
{
requiredField: "Required Value",
},
undefined,
"",
);
});
Expand Down Expand Up @@ -359,6 +375,7 @@ describe("Form", () => {
{
requiredName: "Test Name",
},
undefined,
"",
);
});
Expand All @@ -385,6 +402,7 @@ describe("Form", () => {
{
requiredName: "Test Name",
},
undefined,
"",
);
});
Expand Down Expand Up @@ -593,6 +611,7 @@ describe("Form", () => {
city: "Springfield",
},
},
undefined,
"",
);
});
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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,
},
{},
);
});
});

Expand Down Expand Up @@ -1049,6 +1077,7 @@ describe("Form", () => {
name: "Jane Doe",
age: 30,
}),
undefined,
"",
);
});
Expand Down Expand Up @@ -1244,6 +1273,7 @@ describe("Form", () => {
{
name: "Test Name",
},
undefined,
"",
);
});
Expand All @@ -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);
});
});
});
69 changes: 62 additions & 7 deletions src/components/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
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<string, unknown>) => Promise<unknown>;
onSubmitOperation: (
value: Record<string, unknown>,
additionalFieldsValues: Record<string, unknown>,
) => Promise<unknown>;
additionalFields?: AdditionalField[];
};

// Form is responsible for rendering a form based on the resource schema.
Expand All @@ -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<string, z.ZodTypeAny> = {};
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<Record<string, z.ZodTypeAny>>).extend(
additionalSchemaObj,
);
}, [props.resource, props.additionalFields]);

const defaultValues = useMemo(() => {
return props.resourceInstance?.properties || {};
Expand Down Expand Up @@ -92,10 +118,17 @@ export function Form(props: FormProps) {
};

const onSubmit = (value: Record<string, unknown>) => {
const additionalFieldValues: Record<string, unknown> = {};
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();
})
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -235,7 +276,10 @@ export function Form(props: FormProps) {

{mode === "form" ? (
<form>
<FieldGroup>{formBuilder}</FieldGroup>
<FieldGroup>
{additionalFieldElements}
{formBuilder}
</FieldGroup>
</form>
) : (
<div className="space-y-2">
Expand Down Expand Up @@ -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
}
/>
);
}
9 changes: 9 additions & 0 deletions src/example_oas.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading