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} + + + +
+ {formFields && ( + + {formFields} + + )} + +
+
+ + {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 {