Custom Entity CRUD Panel
Below is a step-by-step guide on how to create a Customer entity in Open Agents Builder (OAB) and build a simple admin UI for listing, creating, editing, and deleting customers. It demonstrates how to:
- Define a
Customermodel in the frontend (withfromDTO,toDTO, etc.). - Create a
CustomerApiClientthat communicates with your custom backend endpoints. - Wrap it all in a
CustomerContextfor React components. - Make listing and detail/edit pages that use this context and API client.
1. Prerequisite: Custom Backend Endpoints
Before writing the frontend, ensure you have the server-side endpoints available, for example:
GET /api/customerreturns all customers or optionally filters by an ID query param.PUT /api/customercreates/updates a customer.DELETE /api/customer/[id]removes a customer.
You can also add optional endpoints for pagination (e.g. POST /api/customer/query) if you plan to handle large data sets.
2. Create the Customer Model
We define a Customer class that handles transformations between DTO (Data Transfer Object) and our model class in the frontend. It’s a consistent approach that keeps your code clean:
import { CustomerDTO } from "@/data/dto";import { getCurrentTS } from "@/lib/utils";
export class Customer { id?: number; name: string; email?: string; company?: string | null; createdAt: string; updatedAt: string;
constructor(dto: CustomerDTO) { this.id = dto.id; this.name = dto.name; this.email = dto.email; this.company = dto.company || null; this.createdAt = dto.createdAt; this.updatedAt = dto.updatedAt; }
static fromDTO(dto: CustomerDTO): Customer { return new Customer(dto); }
toDTO(): CustomerDTO { return { id: this.id, name: this.name, email: this.email, company: this.company, createdAt: this.createdAt, updatedAt: this.updatedAt, }; }
/** * Optionally, a helper to create or update from a form: */ static fromForm(data: Record<string, any>, existing?: Customer): Customer { return new Customer({ ...existing, id: existing?.id || data.id, name: data.name || "", email: data.email || "", company: data.company || null, createdAt: existing?.createdAt || getCurrentTS(), updatedAt: getCurrentTS(), } as CustomerDTO); }}Key Points
fromDTO(...)andtoDTO()let us convert seamlessly between backend responses and local objects.fromForm(...)is an optional helper for React forms, so we can fill the model with the user’s input.
3. Create a CustomerApiClient
You likely have a base AdminApiClient (or similar) that handles things like environment-based URLs, encryption, or authentication. We extend it for customers:
import { AdminApiClient, ApiEncryptionConfig } from "./admin-api-client";import { CustomerDTO, CustomerDTOEncSettings, PaginatedQuery, PaginatedResult,} from "@/data/dto";import { DatabaseContextType } from "@/contexts/db-context";import { SaaSContextType } from "@/contexts/saas-context";import { Customer } from "./models/customer";
export type GetCustomersResponse = CustomerDTO[];export type PutCustomerRequest = CustomerDTO;
export type PutCustomerResponseSuccess = { message: string; data: CustomerDTO; status: 200;};
export type PutCustomerResponseError = { message: string; status: 400; issues?: any[];};
export type PutCustomerResponse = | PutCustomerResponseSuccess | PutCustomerResponseError;
export type DeleteCustomerResponse = { message: string; status: number;};
export class CustomerApiClient extends AdminApiClient { constructor( baseUrl: string, dbContext?: DatabaseContextType | null, saasContext?: SaaSContextType | null, encryptionConfig?: ApiEncryptionConfig ) { super(baseUrl, dbContext, saasContext, encryptionConfig); }
// GET /api/customer async getAll(): Promise<GetCustomersResponse> { return this.request<GetCustomersResponse>( "/api/customer", "GET", CustomerDTOEncSettings ); }
// GET /api/customer?id=... async getById(id: number): Promise<GetCustomersResponse> { return this.request<GetCustomersResponse>( `/api/customer?id=${encodeURIComponent(id.toString())}`, "GET", CustomerDTOEncSettings ); }
// PUT /api/customer async put(record: PutCustomerRequest): Promise<PutCustomerResponse> { return this.request<PutCustomerResponse>( "/api/customer", "PUT", CustomerDTOEncSettings, record ); }
// DELETE /api/customer/[id] async delete(customerId: number): Promise<DeleteCustomerResponse> { return this.request<DeleteCustomerResponse>( `/api/customer/${customerId}`, "DELETE", CustomerDTOEncSettings ); }
/** * If you need pagination: * POST /api/customer/query */ async query(params: PaginatedQuery): Promise<PaginatedResult<CustomerDTO[]>> { return this.request<PaginatedResult<CustomerDTO[]>>( "/api/customer/query", "POST", CustomerDTOEncSettings, params ); }}Highlights
- We rely on a shared
request(...)method inAdminApiClientto manage fetch calls, headers, encryption, etc. - Each method corresponds to a backend route (
getAll(),getById(),put(),delete(), optionallyquery()for pagination).
4. Create a CustomerContext
Now we’ll wrap React components in a context that orchestrates the CustomerApiClient calls, transforms data into the Customer model, and updates local state.
"use client";
import React, { createContext, useState, useContext, ReactNode } from "react";import { DatabaseContext } from "./db-context";import { SaaSContext } from "./saas-context";import { DataLoadingStatus } from "@/data/client/models";import { useTranslation } from "react-i18next";import { getErrorMessage } from "@/lib/utils";import { CustomerApiClient } from "@/data/client/customer-api-client";import { Customer } from "@/data/client/models/customer";import { PaginatedQuery, PaginatedResult, CustomerDTO,} from "@/data/dto";import { toast } from "sonner";
type CustomerContextType = { current: Customer | null; loaderStatus: DataLoadingStatus; setCurrent: (customer: Customer | null) => void;
// CRUD: getAllCustomers: () => Promise<Customer[]>; getCustomerById: (id: number) => Promise<Customer>; updateCustomer: (customer: Customer) => Promise<Customer>; deleteCustomer: (customer: Customer) => Promise<void>;
// Optional for pagination queryCustomers: (params: PaginatedQuery) => Promise<PaginatedResult<Customer[]>>;
refreshDataSync: string; setRefreshDataSync: (val: string) => void;};
const CustomerContext = createContext<CustomerContextType | undefined>(undefined);
export const CustomerProvider = ({ children }: { children: ReactNode }) => { const dbContext = useContext(DatabaseContext); const saasContext = useContext(SaaSContext); const { t } = useTranslation();
const [current, setCurrent] = useState<Customer | null>(null); const [loaderStatus, setLoaderStatus] = useState<DataLoadingStatus>(DataLoadingStatus.Idle); const [refreshDataSync, setRefreshDataSync] = useState("");
// Create a new instance of the API client const getApiClient = (): CustomerApiClient => { return new CustomerApiClient("", dbContext, saasContext, { useEncryption: false }); };
// Basic CRUD async function getAllCustomers(): Promise<Customer[]> { setLoaderStatus(DataLoadingStatus.Loading); try { const client = getApiClient(); const dtos = await client.getAll(); setLoaderStatus(DataLoadingStatus.Success); return dtos.map((dto) => Customer.fromDTO(dto)); } catch (err) { setLoaderStatus(DataLoadingStatus.Error); toast.error(t(getErrorMessage(err))); return []; } }
async function getCustomerById(id: number): Promise<Customer> { setLoaderStatus(DataLoadingStatus.Loading); try { const client = getApiClient(); const dtos = await client.getById(id); if (!dtos || !dtos.length) { throw new Error(t("Customer not found") as string); } setLoaderStatus(DataLoadingStatus.Success); return Customer.fromDTO(dtos[0]); } catch (err) { setLoaderStatus(DataLoadingStatus.Error); toast.error(t(getErrorMessage(err))); throw err; } }
async function updateCustomer(customer: Customer): Promise<Customer> { const client = getApiClient(); const dto = customer.toDTO(); const resp = await client.put(dto); if (resp.status !== 200) { toast.error(resp.message); throw new Error(resp.message); } const updated = Customer.fromDTO(resp.data); setRefreshDataSync(new Date().toISOString()); return updated; }
async function deleteCustomer(customer: Customer): Promise<void> { const client = getApiClient(); const resp = await client.delete(customer.id!); if (resp.status !== 200) { toast.error(resp.message); throw new Error(resp.message); } setRefreshDataSync(new Date().toISOString()); }
// If you need pagination: async function queryCustomers(params: PaginatedQuery): Promise<PaginatedResult<Customer[]>> { setLoaderStatus(DataLoadingStatus.Loading); try { const client = getApiClient(); const raw = await client.query(params); setLoaderStatus(DataLoadingStatus.Success); return { ...raw, rows: raw.rows.map((r: CustomerDTO) => Customer.fromDTO(r)), }; } catch (err) { setLoaderStatus(DataLoadingStatus.Error); toast.error(t(getErrorMessage(err))); throw err; } }
const value: CustomerContextType = { current, loaderStatus, setCurrent,
getAllCustomers, getCustomerById, updateCustomer, deleteCustomer,
queryCustomers,
refreshDataSync, setRefreshDataSync, };
return <CustomerContext.Provider value={value}>{children}</CustomerContext.Provider>;};
export const useCustomerContext = (): CustomerContextType => { const ctx = useContext(CustomerContext); if (!ctx) { throw new Error("useCustomerContext must be used within a CustomerProvider"); } return ctx;};Explanation
- We store the current customer for potential editing or easy reference.
queryCustomers(...)orgetAllCustomers()uses theCustomerApiClientunder the hood.updateCustomer(...)callsclient.put(...)and transforms the response back into a model instance.- We keep track of
refreshDataSyncin case we want to re-fetch data after changes.
5. Use the CustomerProvider in Your Admin Layout
In your layout or root admin component, wrap the child routes so they can access useCustomerContext():
"use client";
import { CustomerProvider } from "@/contexts/customer-context";// ...other providers...
export default function AdminLayout({ children, params }: { children: React.ReactNode; params: { locale: string; id: string };}) { // ... Possibly set up translations, other contexts ... return ( <CustomerProvider> {/* ... any other providers or layout markup ... */} {children} </CustomerProvider> );}Now anything under /admin/... can call useCustomerContext() to manage customers.
6. Listing Page (with Pagination)
Create a page like src/app/[locale]/admin/agent/[id]/customers/page.tsx:
"use client";
import React, { useState, useEffect } from "react";import { useCustomerContext } from "@/contexts/customer-context";import { PaginatedQuery, PaginatedResult } from "@/data/dto";import { Input } from "@/components/ui/input";import { Button } from "@/components/ui/button";import { NoRecordsAlert } from "@/components/shared/no-records-alert";import InfiniteScroll from "@/components/infinite-scroll";import { Loader2, FolderOpenIcon, PlusIcon } from "lucide-react";import { useRouter } from "next/navigation";import { useDebounce } from "use-debounce";import { toast } from "sonner";import { getErrorMessage } from "@/lib/utils";import { useTranslation } from "react-i18next";import Link from "next/link";
export default function CustomersPage() { const router = useRouter(); const { t } = useTranslation(); const customerContext = useCustomerContext();
// Searching / pagination const [query, setQuery] = useState<PaginatedQuery>({ limit: 5, offset: 0, orderBy: "createdAt", query: "", }); const [debouncedQuery] = useDebounce(query, 500);
// Data const [customersData, setCustomersData] = useState<PaginatedResult<any>>({ rows: [], total: 0, limit: 5, offset: 0, orderBy: "createdAt", query: "", }); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true);
// Initial load / search useEffect(() => { (async () => { setLoading(true); try { const result = await customerContext.queryCustomers({ ...debouncedQuery, offset: 0, }); setCustomersData(result); setHasMore(result.rows.length < result.total); } catch (err) { toast.error(t(getErrorMessage(err))); } setLoading(false); })(); }, [debouncedQuery, customerContext.refreshDataSync]);
// Check if more data is available useEffect(() => { setHasMore(customersData.offset + customersData.limit < customersData.total); }, [customersData]);
// Load next page const loadMore = async () => { if (loading) return; const newOffset = customersData.offset + customersData.limit; if (newOffset >= customersData.total) { setHasMore(false); return; } setLoading(true); try { const result = await customerContext.queryCustomers({ ...debouncedQuery, offset: newOffset, }); setCustomersData((prev) => ({ ...prev, rows: [...prev.rows, ...result.rows], offset: newOffset, })); setHasMore(newOffset + result.rows.length < result.total); } catch (err) { toast.error(t(getErrorMessage(err))); } setLoading(false); };
return ( <div className="space-y-6"> <div className="flex space-x-2"> <Link href={"./customers/new"} className="inline-flex"> <Button size="sm" variant="outline"> <PlusIcon className="w-4 h-4 mr-2" /> {t("Add new customer")} </Button> </Link> </div>
{/* Search */} <Input placeholder={t("Search customers...") || "Search..."} onChange={(e) => setQuery({ ...query, query: e.target.value })} value={query.query} />
{/* No Records */} {customersData.rows.length === 0 && !loading && ( <NoRecordsAlert title={t("No customers found") as string}> {t("Try adjusting your search or add a new customer.")} </NoRecordsAlert> )}
{/* List */} <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> {customersData.rows.map((customer: any) => ( <div key={customer.id} className="border rounded p-4"> <div className="font-bold"> {customer.name} </div> <div className="text-sm text-gray-500">{customer.email}</div> {customer.company && ( <div className="text-sm text-gray-500 mt-1"> {customer.company} </div> )} <div className="mt-2"> <Button variant="secondary" size="sm" onClick={() => router.push(`./customers/${customer.id}`)} > <FolderOpenIcon className="w-4 h-4 mr-1" /> {t("Open")} </Button> </div> </div> ))} </div>
<InfiniteScroll hasMore={hasMore} isLoading={loading} next={loadMore}> {hasMore && ( <div className="flex justify-center"> <Loader2 className="my-4 h-8 w-8 animate-spin" /> </div> )} </InfiniteScroll> </div> );}7. Details/Edit Page
For editing an existing customer (id) or creating a new one (id = "new"):
"use client";
import React, { useEffect } from "react";import { useForm } from "react-hook-form";import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { useCustomerContext } from "@/contexts/customer-context";import { useParams, useRouter } from "next/navigation";import { toast } from "sonner";import { getErrorMessage } from "@/lib/utils";import { useTranslation } from "react-i18next";import { Customer } from "@/data/client/models/customer";
interface CustomerFormData { name: string; email: string; company?: string;}
export default function CustomerFormPage() { const router = useRouter(); const params = useParams(); const { t } = useTranslation(); const { getCustomerById, updateCustomer, deleteCustomer } = useCustomerContext();
const { register, handleSubmit, reset, formState: { errors }, } = useForm<CustomerFormData>();
const isNew = params.customerId === "new";
// Load data if editing useEffect(() => { if (!isNew) { const idNum = parseInt(params.customerId, 10); (async () => { try { const existing = await getCustomerById(idNum); reset({ name: existing.name, email: existing.email, company: existing.company || "", }); } catch (err) { toast.error(t(getErrorMessage(err))); } })(); } }, [isNew, params.customerId]);
// Save const onSubmit = async (data: CustomerFormData) => { try { if (isNew) { // Create new const newCust = Customer.fromForm(data, undefined); const saved = await updateCustomer(newCust); toast.success(t("Customer created!") as string); } else { // Update existing const idNum = parseInt(params.customerId, 10); const existing = Customer.fromForm(data, new Customer({ id: idNum, // placeholders for existing fields name: data.name, email: data.email, company: data.company, createdAt: "", updatedAt: "", }.toDTO()));
await updateCustomer(existing); toast.success(t("Customer updated!") as string); } router.push("../"); } catch (err) { toast.error(t(getErrorMessage(err))); } };
// Delete const handleDelete = async () => { if (isNew) return; // nothing to delete const confirmed = confirm(t("Are you sure you want to delete?") as string); if (!confirmed) return;
try { await deleteCustomer({ id: parseInt(params.customerId, 10) } as Customer); toast.success(t("Customer deleted!") as string); router.push("../"); } catch (err) { toast.error(t(getErrorMessage(err))); } };
return ( <div className="max-w-xl"> <Button variant="outline" size="sm" onClick={() => router.back()}> {t("Back")} </Button>
<h2 className="text-2xl font-bold mt-4"> {isNew ? t("New Customer") : t("Edit Customer")} </h2>
<form onSubmit={handleSubmit(onSubmit)} className="mt-4 space-y-4"> <div> <label className="block font-medium">{t("Name")}</label> <Input {...register("name", { required: "Name is required" })} /> {errors.name && ( <p className="text-red-500 text-sm">{errors.name.message}</p> )} </div>
<div> <label className="block font-medium">{t("Email")}</label> <Input type="email" {...register("email")} /> {errors.email && ( <p className="text-red-500 text-sm">{errors.email.message}</p> )} </div>
<div> <label className="block font-medium">{t("Company")}</label> <Input {...register("company")} /> </div>
<div className="flex gap-2 pt-2"> <Button type="submit">{isNew ? t("Create") : t("Save")}</Button> {!isNew && ( <Button variant="destructive" onClick={handleDelete}> {t("Delete")} </Button> )} </div> </form> </div> );}Key Points
- If
customerId === "new", we create a blank record. - Otherwise, we fetch the existing customer from
getCustomerById. - On form submit, we build or update a
Customermodel viaCustomer.fromForm(data, existing). - We call
updateCustomer(...)and redirect or show a toast.
8. Summary
- Backend: Implement or confirm endpoints (
/api/customer) for listing, creating, updating, deleting customers (optionally with pagination). - Frontend:
Customermodel:fromDTO(...)/toDTO()for consistent transformations.CustomerApiClient: Wrappers around fetch/HTTP calls usingAdminApiClient.CustomerContext: Provides easy methods (likegetAllCustomers,updateCustomer,deleteCustomer) to your React components.- Admin Layout: Wrap everything in
<CustomerProvider>. - Listing Page: Display customers, implement search/pagination if needed.
- Edit/New Page: Basic React Hook Form for user input, calls
CustomerContextfor create/edit operations.
- Enhance with additional fields, validations, or logic as your application grows.
With these steps, you have a simple, maintainable pattern for any new entity—just replicate the approach for other objects (Orders, Products, etc.). Happy coding!