diff --git a/src/app/explorer/info.tsx b/src/app/explorer/info.tsx
index ca1db9f..682b2db 100644
--- a/src/app/explorer/info.tsx
+++ b/src/app/explorer/info.tsx
@@ -7,6 +7,7 @@ import { ResourceSchema } from "@/state/openapi";
import { useAppSelector } from "@/hooks/store";
import { selectChildResources } from "@/state/store";
import ResourceListPage from "./resource_list";
+import { CustomMethodComponent } from "@/components/custom_method";
type ResourceProperties = {
path: string;
@@ -52,6 +53,10 @@ export default function InfoPage(props: InfoPageProps) {
return
}, [state]);
+ const customMethods = useMemo(() => {
+ return props.resource.customMethods();
+ }, [props.resource]);
+
return (
{/* Resource Instance card. */}
@@ -64,11 +69,34 @@ export default function InfoPage(props: InfoPageProps) {
+ {/* Custom Methods */}
+ {state && customMethods.length > 0 && (
+
+
+ Custom Methods
+
+
+ {customMethods.map((customMethod) => (
+
+ ))}
+
+
+ )}
+
{/* Listing Child Resources */}
{childResources.map((childResource) => (
-
-
-
+
+
+ {childResource.plural_name}
+
+
+
+
+
))}
)
diff --git a/src/components/custom_method.test.tsx b/src/components/custom_method.test.tsx
new file mode 100644
index 0000000..6d6974e
--- /dev/null
+++ b/src/components/custom_method.test.tsx
@@ -0,0 +1,218 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { CustomMethod } from '@aep_dev/aep-lib-ts';
+import { CustomMethodComponent } from './custom_method';
+import { ResourceInstance } from '@/state/fetch';
+import { ResourceSchema } from '@/state/openapi';
+
+// Mock fetch globally
+global.fetch = vi.fn();
+
+describe('CustomMethodComponent', () => {
+ const mockResourceSchema = {
+ server_url: 'http://localhost:8080',
+ } as ResourceSchema;
+
+ const createMockResourceInstance = (path: string): ResourceInstance => {
+ return {
+ id: '123',
+ path: path,
+ properties: { path: path },
+ schema: mockResourceSchema,
+ delete: vi.fn(),
+ update: vi.fn(),
+ } as unknown as ResourceInstance;
+ };
+
+ const createMockCustomMethod = (name: string, request: any = null): CustomMethod => {
+ return {
+ name,
+ method: 'POST',
+ request,
+ response: null,
+ };
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (global.fetch as any).mockClear();
+ });
+
+ it('renders and submits custom method without request fields', async () => {
+ (global.fetch as any).mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ const customMethod = createMockCustomMethod('archive', null);
+ const resourceInstance = createMockResourceInstance('books/123');
+
+ render();
+
+ expect(screen.getByText('archive')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:8080/books/123:archive',
+ expect.objectContaining({
+ method: 'POST',
+ body: undefined,
+ })
+ );
+ });
+
+ expect(screen.getByText('Response:')).toBeInTheDocument();
+ expect(screen.getByText(/"success": true/)).toBeInTheDocument();
+ });
+
+ it('renders form fields and validates before submission', async () => {
+ const requestSchema = {
+ type: 'object',
+ properties: {
+ reason: { type: 'string' },
+ priority: { type: 'integer' },
+ },
+ required: ['reason'],
+ };
+
+ const customMethod = createMockCustomMethod('archive', requestSchema);
+ const resourceInstance = createMockResourceInstance('books/123');
+
+ render();
+
+ expect(screen.getByLabelText('reason')).toBeInTheDocument();
+ expect(screen.getByLabelText('priority')).toBeInTheDocument();
+
+ // Submit without filling required field
+ fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(screen.getByText('Required')).toBeInTheDocument();
+ });
+
+ expect(global.fetch).not.toHaveBeenCalled();
+ });
+
+ it('submits form with valid data and displays response', async () => {
+ (global.fetch as any).mockResolvedValue({
+ ok: true,
+ json: async () => ({ status: 'archived', timestamp: '2024-01-01' }),
+ });
+
+ const requestSchema = {
+ type: 'object',
+ properties: {
+ reason: { type: 'string' },
+ priority: { type: 'integer' },
+ },
+ required: ['reason'],
+ };
+
+ const customMethod = createMockCustomMethod('archive', requestSchema);
+ const resourceInstance = createMockResourceInstance('books/123');
+
+ render();
+
+ fireEvent.change(screen.getByLabelText('reason'), { target: { value: 'outdated' } });
+ fireEvent.change(screen.getByLabelText('priority'), { target: { value: '5' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:8080/books/123:archive',
+ expect.objectContaining({
+ body: JSON.stringify({ reason: 'outdated', priority: 5 }),
+ })
+ );
+ });
+
+ expect(screen.getByText(/"status": "archived"/)).toBeInTheDocument();
+ });
+
+ it('handles nested objects in request schema', async () => {
+ (global.fetch as any).mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ const requestSchema = {
+ type: 'object',
+ properties: {
+ metadata: {
+ type: 'object',
+ properties: {
+ reason: { type: 'string' },
+ notes: { type: 'string' },
+ },
+ },
+ },
+ };
+
+ const customMethod = createMockCustomMethod('archive', requestSchema);
+ const resourceInstance = createMockResourceInstance('books/123');
+
+ render();
+
+ expect(screen.getByText('metadata')).toBeInTheDocument();
+ fireEvent.change(screen.getByLabelText('reason'), { target: { value: 'outdated' } });
+ fireEvent.change(screen.getByLabelText('notes'), { target: { value: 'no longer relevant' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(global.fetch).toHaveBeenCalledWith(
+ 'http://localhost:8080/books/123:archive',
+ expect.objectContaining({
+ body: JSON.stringify({
+ metadata: { reason: 'outdated', notes: 'no longer relevant' }
+ }),
+ })
+ );
+ });
+ });
+
+ it('displays error when request fails', async () => {
+ (global.fetch as any).mockResolvedValue({
+ ok: false,
+ status: 500,
+ });
+
+ const customMethod = createMockCustomMethod('archive', null);
+ const resourceInstance = createMockResourceInstance('books/123');
+
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/"error":/)).toBeInTheDocument();
+ });
+ });
+
+ it('shows loading state during submission', async () => {
+ let resolvePromise: (value: any) => void;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ (global.fetch as any).mockReturnValue(promise);
+
+ const customMethod = createMockCustomMethod('archive', null);
+ const resourceInstance = createMockResourceInstance('books/123');
+
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Submitting...' })).toBeDisabled();
+ });
+
+ resolvePromise!({ ok: true, json: async () => ({ success: true }) });
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled();
+ });
+ });
+});
diff --git a/src/components/custom_method.tsx b/src/components/custom_method.tsx
new file mode 100644
index 0000000..b7031f7
--- /dev/null
+++ b/src/components/custom_method.tsx
@@ -0,0 +1,163 @@
+import { useState, useMemo } from "react";
+import { CustomMethod } from "@aep_dev/aep-lib-ts";
+import { ResourceInstance } from "@/state/fetch";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Field, FieldGroup, FieldLabel, FieldError } from "@/components/ui/field";
+import { Form as FormProvider, FormField } from "@/components/ui/form";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { toast } from "@/hooks/use-toast";
+import { createValidationSchemaFromRawSchema } from "@/lib/utils";
+
+type CustomMethodProps = {
+ resourceInstance: ResourceInstance;
+ customMethod: CustomMethod;
+};
+
+export function CustomMethodComponent(props: CustomMethodProps) {
+ const [response, setResponse] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const validationSchema = useMemo(() => {
+ return createValidationSchemaFromRawSchema(props.customMethod.request);
+ }, [props.customMethod]);
+
+ const form = useForm({
+ resolver: zodResolver(validationSchema),
+ defaultValues: {}
+ });
+
+ const onSubmit = async (data: Record) => {
+ setIsLoading(true);
+ setResponse(null);
+
+ try {
+ // Construct the URL for the custom method
+ const url = `${props.resourceInstance.schema.server_url}/${props.resourceInstance.path}:${props.customMethod.name}`;
+
+ const response = await fetch(url, {
+ method: props.customMethod.method,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: Object.keys(data).length > 0 ? JSON.stringify(data) : undefined,
+ });
+
+ if (!response.ok) {
+ throw new Error(`Request failed with status ${response.status}`);
+ }
+
+ const responseData = await response.json();
+ setResponse(responseData);
+ toast({ description: `${props.customMethod.name} completed successfully` });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ toast({ description: `Failed to execute ${props.customMethod.name}: ${message}` });
+ setResponse({ error: message });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const getInputType = (propertyType: string) => {
+ switch (propertyType) {
+ case 'integer':
+ case 'number':
+ return 'number';
+ case 'boolean':
+ return 'checkbox';
+ default:
+ return 'text';
+ }
+ };
+
+ const renderField = (name: string, propSchema: any, parentPath: string = ''): React.ReactNode => {
+ const fieldPath = parentPath ? `${parentPath}.${name}` : name;
+
+ if (propSchema.type === 'object') {
+ const nestedProperties = propSchema.properties || {};
+ return (
+
+
+ {Object.entries(nestedProperties).map(([nestedName, nestedSchema]) =>
+ renderField(nestedName, nestedSchema, fieldPath)
+ )}
+
+ );
+ }
+
+ return (
+ {
+ const inputId = `input-${fieldPath}`;
+ return (
+
+ {name}
+ field.onChange(e.target.checked)
+ : field.onChange
+ }
+ aria-invalid={!!fieldState.error}
+ />
+ {fieldState.error && (
+ {fieldState.error.message}
+ )}
+
+ );
+ }}
+ />
+ );
+ };
+
+ const formFields = useMemo(() => {
+ if (!props.customMethod.request || !props.customMethod.request.properties) {
+ return null;
+ }
+
+ const properties = props.customMethod.request.properties;
+ return Object.entries(properties).map(([name, schema]) =>
+ renderField(name, schema)
+ );
+ }, [props.customMethod, form.control]);
+
+ return (
+
+
+ {props.customMethod.name}
+
+
+
+
+
+
+ {response && (
+
+
Response:
+
+ {JSON.stringify(response, null, 2)}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/form/form.tsx b/src/components/form/form.tsx
index de5f0ac..7a3db43 100644
--- a/src/components/form/form.tsx
+++ b/src/components/form/form.tsx
@@ -15,6 +15,7 @@ import { ResourceInstance } from "@/state/fetch";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { JsonEditor } from 'json-edit-react';
+import { createValidationSchema } from "@/lib/utils";
type FormProps = {
resource: ResourceSchema;
@@ -27,60 +28,6 @@ type FormProps = {
onSubmitOperation: (value: Record) => Promise;
}
-function createValidationSchema(properties: PropertySchema[], requiredFields: string[]): z.ZodSchema {
- // Validation happens through Zod.
- // This function converts an OpenAPI schema to a Zod schema.
- const schemaObject: Record = {};
-
- for (const property of properties) {
- if (!property) continue; // Skip null properties
- let fieldSchema: z.ZodTypeAny;
- const isRequired = requiredFields.includes(property.name);
-
- switch (property.type) {
- case 'object':
- const nestedProperties = property.properties();
- const nestedRequired = property.required();
- fieldSchema = createValidationSchema(nestedProperties, nestedRequired);
- break;
- case 'integer':
- fieldSchema = z.coerce.number().int({
- message: `${property.name} must be an integer`
- });
- break;
- case 'number':
- fieldSchema = z.coerce.number({
- message: `${property.name} must be a number`
- });
- break;
- case 'boolean':
- fieldSchema = z.coerce.boolean({
- message: `${property.name} must be true or false`
- });
- break;
- case 'string':
- default:
- if (isRequired) {
- fieldSchema = z.string().min(1, {
- message: `${property.name} is required`
- });
- } else {
- fieldSchema = z.string().optional();
- }
- break;
- }
-
- // Make numeric and boolean fields optional if not required
- if (!isRequired) {
- fieldSchema = fieldSchema.optional();
- }
-
- schemaObject[property.name] = fieldSchema;
- }
-
- return z.object(schemaObject);
-}
-
// Form is responsible for rendering a form based on the resource schema.
export function Form(props: FormProps) {
const [mode, setMode] = useState<'form' | 'json'>('form');
diff --git a/src/components/ui/item.tsx b/src/components/ui/item.tsx
new file mode 100644
index 0000000..93d2b81
--- /dev/null
+++ b/src/components/ui/item.tsx
@@ -0,0 +1,193 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
+
+function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function ItemSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const itemVariants = cva(
+ "group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 [a]:transition-colors flex flex-wrap items-center rounded-md border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ outline: "border-border",
+ muted: "bg-muted/50",
+ },
+ size: {
+ default: "gap-4 p-4 ",
+ sm: "gap-2.5 px-4 py-3",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Item({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "div"
+ return (
+
+ )
+}
+
+const itemMediaVariants = cva(
+ "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-transparent",
+ icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4",
+ image:
+ "size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function ItemMedia({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Item,
+ ItemMedia,
+ ItemContent,
+ ItemActions,
+ ItemGroup,
+ ItemSeparator,
+ ItemTitle,
+ ItemDescription,
+ ItemHeader,
+ ItemFooter,
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 2e63c57..0d51725 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,6 +1,7 @@
-import { ResourceSchema } from "@/state/openapi";
+import { ResourceSchema, PropertySchema } from "@/state/openapi";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
+import { z } from "zod";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -24,4 +25,107 @@ export function createRouteObjects(resources: ResourceSchema[]): object {
acc[`${resource.base_url()}/{resourceId}/_update`] = `${resource.singular_name} Update`;
return acc;
}, base);
+}
+
+// TODO: Consolidate createValidationSchema and createValidationSchemaFromRawSchema into a single function
+
+export function createValidationSchema(properties: PropertySchema[], requiredFields: string[]): z.ZodSchema {
+ // Validation happens through Zod.
+ // This function converts an OpenAPI schema to a Zod schema.
+ const schemaObject: Record = {};
+
+ for (const property of properties) {
+ if (!property) continue; // Skip null properties
+ let fieldSchema: z.ZodTypeAny;
+ const isRequired = requiredFields.includes(property.name);
+
+ switch (property.type) {
+ case 'object':
+ const nestedProperties = property.properties();
+ const nestedRequired = property.required();
+ fieldSchema = createValidationSchema(nestedProperties, nestedRequired);
+ break;
+ case 'integer':
+ fieldSchema = z.coerce.number().int({
+ message: `${property.name} must be an integer`
+ });
+ break;
+ case 'number':
+ fieldSchema = z.coerce.number({
+ message: `${property.name} must be a number`
+ });
+ break;
+ case 'boolean':
+ fieldSchema = z.coerce.boolean({
+ message: `${property.name} must be true or false`
+ });
+ break;
+ case 'string':
+ default:
+ if (isRequired) {
+ fieldSchema = z.string().min(1, {
+ message: `${property.name} is required`
+ });
+ } else {
+ fieldSchema = z.string().optional();
+ }
+ break;
+ }
+
+ // Make numeric and boolean fields optional if not required
+ if (!isRequired) {
+ fieldSchema = fieldSchema.optional();
+ }
+
+ schemaObject[property.name] = fieldSchema;
+ }
+
+ return z.object(schemaObject);
+}
+
+export function createValidationSchemaFromRawSchema(schema: any, isRequired: boolean = false): z.ZodTypeAny {
+ // This version works directly with raw schema objects (e.g., from CustomMethod.request)
+ if (!schema) {
+ return z.any().optional();
+ }
+
+ const properties = schema.properties || {};
+ const required = schema.required || [];
+ const schemaObject: Record = {};
+
+ for (const [name, propSchema] of Object.entries(properties) as [string, any][]) {
+ const isFieldRequired = required.includes(name);
+ let fieldSchema: z.ZodTypeAny;
+
+ switch (propSchema.type) {
+ case 'object':
+ fieldSchema = createValidationSchemaFromRawSchema(propSchema, isFieldRequired);
+ break;
+ case 'integer':
+ fieldSchema = z.coerce.number().int({ message: `${name} must be an integer` });
+ break;
+ case 'number':
+ fieldSchema = z.coerce.number({ message: `${name} must be a number` });
+ break;
+ case 'boolean':
+ fieldSchema = z.coerce.boolean({ message: `${name} must be true or false` });
+ break;
+ case 'string':
+ default:
+ if (isFieldRequired) {
+ fieldSchema = z.string().min(1, { message: `${name} is required` });
+ } else {
+ fieldSchema = z.string().optional();
+ }
+ break;
+ }
+
+ if (!isFieldRequired) {
+ fieldSchema = fieldSchema.optional();
+ }
+
+ schemaObject[name] = fieldSchema;
+ }
+
+ return z.object(schemaObject);
}
\ No newline at end of file
diff --git a/src/state/openapi.ts b/src/state/openapi.ts
index 1b7701b..9745ee4 100644
--- a/src/state/openapi.ts
+++ b/src/state/openapi.ts
@@ -1,5 +1,5 @@
import { List, ResourceInstance, Create, Get } from './fetch';
-import { Resource, Schema, APIClient } from '@aep_dev/aep-lib-ts';
+import { Resource, Schema, APIClient, CustomMethod } from '@aep_dev/aep-lib-ts';
// Adapter class that wraps aep-lib-ts Resource with additional UI-specific functionality
class ResourceSchema {
@@ -104,6 +104,10 @@ class ResourceSchema {
parentResources(): string[] {
return this.resource.parents.map(p => p.singular);
}
+
+ customMethods(): CustomMethod[] {
+ return this.resource.customMethods || [];
+ }
}
class PropertySchema {