From 3ba823a12fe6eca2550a5d669436d0bb7f7f4de8 Mon Sep 17 00:00:00 2001 From: Grishma Dhakal Date: Sat, 25 Jul 2026 20:58:55 +0545 Subject: [PATCH 1/4] feat: add component-templates module with codegen utilities --- src/lib/component-templates/codegen-utils.ts | 51 ++ src/lib/component-templates/comment-item.ts | 59 ++ src/lib/component-templates/dynamic-grid.ts | 232 +++++++ src/lib/component-templates/index.ts | 106 ++++ src/lib/component-templates/post-card.ts | 59 ++ src/lib/component-templates/product-card.ts | 98 +++ src/lib/component-templates/todo-list.ts | 81 +++ .../component-templates/transaction-row.ts | 89 +++ src/lib/component-templates/types.ts | 19 + src/lib/component-templates/user-card.ts | 75 +++ src/lib/endpoint-data-store.ts | 2 +- .../schema-utils.ts => lib/faker-options.ts} | 96 +-- src/lib/schema-resolver.test.ts | 7 + src/lib/schema-resolver.ts | 21 +- .../ui/components/dashboard/endpointBar.tsx | 1 + .../ui/components/dashboard/schemaBuilder.tsx | 587 ++++++++++++++---- .../ui/components/dashboard/schemaPreview.tsx | 63 +- tests/component-templates.test.ts | 182 ++++++ tsconfig.json | 2 +- 19 files changed, 1657 insertions(+), 173 deletions(-) create mode 100644 src/lib/component-templates/codegen-utils.ts create mode 100644 src/lib/component-templates/comment-item.ts create mode 100644 src/lib/component-templates/dynamic-grid.ts create mode 100644 src/lib/component-templates/index.ts create mode 100644 src/lib/component-templates/post-card.ts create mode 100644 src/lib/component-templates/product-card.ts create mode 100644 src/lib/component-templates/todo-list.ts create mode 100644 src/lib/component-templates/transaction-row.ts create mode 100644 src/lib/component-templates/types.ts create mode 100644 src/lib/component-templates/user-card.ts rename src/{modules/workspace/ui/components/dashboard/schemaBuilder/schema-utils.ts => lib/faker-options.ts} (76%) create mode 100644 tests/component-templates.test.ts diff --git a/src/lib/component-templates/codegen-utils.ts b/src/lib/component-templates/codegen-utils.ts new file mode 100644 index 0000000..838620d --- /dev/null +++ b/src/lib/component-templates/codegen-utils.ts @@ -0,0 +1,51 @@ +import type { SchemaField } from "~/lib/faker-options"; + +function normalize(s: string): string { + return s.toLowerCase().replace(/[_\s-]/g, ""); +} + +/** + * Finds the user's actual fieldName matching any of the given candidates + * (case + separator insensitive). Returns null if none found so the + * template can decide on a fallback. + */ +export function findField(fields: SchemaField[], candidates: string[]): string | null { + const normalizedCandidates = new Set(candidates.map(normalize)); + const match = fields.find((f) => normalizedCandidates.has(normalize(f.fieldName))); + return match ? match.fieldName : null; +} + +/** + * Builds a fetch hook that handles both response shapes from the mock + * endpoint: a single object (count === 1) or an array (count > 1). + */ +export function buildFetchHook( + hookName: string, + endpointUrl: string, + itemTypeName: string, +): string { + return `function use${hookName}Data() { + const [data, setData] = useState<${itemTypeName}[] | ${itemTypeName} | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("${endpointUrl}") + .then((res) => { + if (!res.ok) throw new Error(\`Request failed: \${res.status}\`); + return res.json(); + }) + .then((json) => setData(json)) + .catch((err) => setError(err instanceof Error ? err.message : "Unknown error")) + .finally(() => setLoading(false)); + }, []); + + return { data, loading, error }; +}`; +} + +export function buildInterface(name: string, fieldLines: string[]): string { + return `interface ${name} { +${fieldLines.map((l) => ` ${l}`).join("\n")} +}`; +} diff --git a/src/lib/component-templates/comment-item.ts b/src/lib/component-templates/comment-item.ts new file mode 100644 index 0000000..0809c82 --- /dev/null +++ b/src/lib/component-templates/comment-item.ts @@ -0,0 +1,59 @@ +import type { ComponentTemplate } from "./types"; +import { findField, buildFetchHook, buildInterface } from "./codegen-utils"; + +export const commentItemTemplate: ComponentTemplate = { + id: "comment-item", + name: "Comment Item", + description: "Avatar + author + comment text list. Handles single or list response.", + requiredFields: ["comment", "author"], + optionalFields: ["avatar", "createdAt", "id"], + code: (fields, endpointUrl) => { + const idField = findField(fields, ["id", "commentid", "uuid"]) ?? "id"; + const authorField = + findField(fields, ["author", "fullname", "username", "name"]) ?? "author"; + const commentField = + findField(fields, ["comment", "message", "content", "text"]) ?? "comment"; + const avatarField = findField(fields, ["avatar", "image", "photo"]); + const createdAtField = findField(fields, ["createdat", "date", "timestamp"]); + + const interfaceLines = [ + `${idField}: string;`, + `${authorField}: string;`, + `${commentField}: string;`, + ]; + if (avatarField) interfaceLines.push(`${avatarField}: string;`); + if (createdAtField) interfaceLines.push(`${createdAtField}: string;`); + + return `import { useEffect, useState } from "react"; + +${buildInterface("Comment", interfaceLines)} + +${buildFetchHook("CommentItem", endpointUrl, "Comment")} + +export function CommentList() { + const { data, loading, error } = useCommentItemData(); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + if (!data) return null; + + const comments = Array.isArray(data) ? data : [data]; + + return ( +
+ {comments.map((comment) => ( +
+${avatarField ? ` {comment.${authorField}}\n` : ""}
+
+ {comment.${authorField}} +${createdAtField ? ` {comment.${createdAtField}}\n` : ""}
+

{comment.${commentField}}

+
+
+ ))} +
+ ); +} +`; + }, +}; diff --git a/src/lib/component-templates/dynamic-grid.ts b/src/lib/component-templates/dynamic-grid.ts new file mode 100644 index 0000000..773fc2d --- /dev/null +++ b/src/lib/component-templates/dynamic-grid.ts @@ -0,0 +1,232 @@ +import type { ComponentTemplate } from "./types"; +import { buildFetchHook, buildInterface } from "./codegen-utils"; + +function mapDataTypeToTsType(dataType: string): string { + const dt = dataType.toLowerCase(); + if (dt.includes("boolean") || dt.includes("datatype.boolean")) { + return "boolean"; + } + if ( + dt.includes("number.int") || + dt.includes("number.float") || + dt.includes("number.bigint") || + dt.includes("number.hex") || + dt.includes("commerce.price") || + dt.includes("finance.amount") || + dt.includes("location.latitude") || + dt.includes("location.longitude") + ) { + return "number"; + } + return "string"; +} + +function normalize(s: string): string { + return s.toLowerCase().replace(/[_\s-]/g, ""); +} + +function renderPropAccess(path: string | undefined): string { + if (!path) return "undefined"; + if (path.includes(".")) { + return `item?.["${path}"]`; + } + return `item.${path}`; +} + +export const dynamicGridTemplate: ComponentTemplate = { + id: "dynamic-grid", + name: "Dynamic Card Grid", + description: "Adaptive UI card generated directly from schema fields and Faker types.", + requiredFields: [], + optionalFields: [], + code: (fields, endpointUrl) => { + const validFields = fields.filter((f) => f.fieldName.trim().length > 0); + + const idField = + validFields.find((f) => { + const norm = normalize(f.fieldName); + const dt = normalize(f.dataType); + return ( + norm === "id" || + norm.includes("uuid") || + norm.endsWith("id") || + dt.includes("uuid") || + dt.includes("nanoid") + ); + })?.fieldName ?? + validFields[0]?.fieldName ?? + "id"; + + const titleField = validFields.find((f) => { + const norm = normalize(f.fieldName); + const dt = normalize(f.dataType); + return ( + norm.includes("name") || + norm.includes("title") || + norm.includes("label") || + norm.includes("username") || + norm.includes("user") || + norm.includes("product") || + norm.includes("dish") || + norm.includes("vehicle") || + dt.includes("fullname") || + dt.includes("firstname") || + dt.includes("lastname") || + dt.includes("username") || + dt.includes("productname") || + dt.includes("company.name") || + dt.includes("book.title") || + dt.includes("food.dish") || + dt.includes("vehicle.vehicle") + ); + })?.fieldName; + + const subtitleField = validFields.find((f) => { + const norm = normalize(f.fieldName); + const dt = normalize(f.dataType); + return ( + f.fieldName !== titleField && + (norm.includes("username") || + norm.includes("user") || + norm.includes("role") || + norm.includes("job") || + norm.includes("category") || + norm.includes("department") || + norm.includes("status") || + norm.includes("author") || + dt.includes("username") || + dt.includes("jobtitle") || + dt.includes("email") || + dt.includes("domainname") || + dt.includes("department") || + dt.includes("book.author") || + dt.includes("vehicle.manufacturer")) + ); + })?.fieldName; + + const avatarField = validFields.find((f) => { + const norm = normalize(f.fieldName); + const dt = normalize(f.dataType); + return ( + norm.includes("avatar") || + norm.includes("image") || + norm.includes("photo") || + norm.includes("picture") || + norm.includes("cover") || + dt.includes("image.avatar") || + dt.includes("image.url") || + dt.includes("image.datauri") + ); + })?.fieldName; + + const descriptionField = validFields.find((f) => { + const norm = normalize(f.fieldName); + const dt = normalize(f.dataType); + return ( + f.fieldName !== titleField && + f.fieldName !== subtitleField && + (norm.includes("bio") || + norm.includes("description") || + norm.includes("comment") || + norm.includes("summary") || + norm.includes("catchphrase") || + dt.includes("person.bio") || + dt.includes("lorem.paragraph") || + dt.includes("lorem.paragraphs") || + dt.includes("lorem.sentence") || + dt.includes("commerce.productdescription") || + dt.includes("company.catchphrase")) + ); + })?.fieldName; + + const detailFields = validFields.filter( + (f) => + f.fieldName !== idField && + f.fieldName !== titleField && + f.fieldName !== subtitleField && + f.fieldName !== avatarField && + f.fieldName !== descriptionField, + ); + + const interfaceLines = validFields.map((f) => { + if (f.fieldName.includes(".")) { + return `"${f.fieldName}"?: ${mapDataTypeToTsType(f.dataType)};`; + } + return `${f.fieldName}: ${mapDataTypeToTsType(f.dataType)};`; + }); + + return `import { useEffect, useState } from "react"; + +${buildInterface("DataItem", interfaceLines)} + +${buildFetchHook("DataItem", endpointUrl, "DataItem")} + +export function DataCardList() { + const { data, loading, error } = useDataItemData(); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + if (!data) return null; + + const items = Array.isArray(data) ? data : [data]; + + return ( +
+ {items.map((item, idx) => ( +
+
+
+${ + avatarField + ? ` {${renderPropAccess(avatarField)} ? ( + Avatar + ) : ( +
+ {${titleField ? `String(${renderPropAccess(titleField)} ?? "").charAt(0).toUpperCase() || ` : ""} "#"} +
+ )}` + : `
+ {${titleField ? `String(${renderPropAccess(titleField)} ?? "").charAt(0).toUpperCase() || ` : ""} "D"} +
` +} +
+${ + titleField + ? `

{${renderPropAccess(titleField)}}

\n` + : `

Item #{idx + 1}

\n` +}${subtitleField ? `

{${renderPropAccess(subtitleField)}}

\n` : ""}
+
+ +${descriptionField ? `

{${renderPropAccess(descriptionField)}}

\n` : ""}
+ +${ + detailFields.length > 0 + ? `
+${detailFields + .map((f) => { + const propAccess = renderPropAccess(f.fieldName); + return ` {${propAccess} !== undefined && ( +
+ ${f.fieldName}: + {String(${propAccess})} +
+ )}`; + }) + .join("\n")} +
\n` + : "" +}
+ ))} +
+ ); +} +`; + }, +}; diff --git a/src/lib/component-templates/index.ts b/src/lib/component-templates/index.ts new file mode 100644 index 0000000..b54d98c --- /dev/null +++ b/src/lib/component-templates/index.ts @@ -0,0 +1,106 @@ +import type { SchemaField } from "~/lib/faker-options"; +import type { ComponentTemplate, TemplateMatch } from "./types"; +import { dynamicGridTemplate } from "./dynamic-grid"; + +export const COMPONENT_TEMPLATES: ComponentTemplate[] = [dynamicGridTemplate]; + +function normalize(name: string): string { + return name.toLowerCase().replace(/[_\s-]/g, ""); +} + +function fieldMatches(fieldNameNormalized: string, candidateNormalized: string): boolean { + return ( + fieldNameNormalized === candidateNormalized || + fieldNameNormalized.includes(candidateNormalized) || + candidateNormalized.includes(fieldNameNormalized) + ); +} + +/** + * Scores every template against the given schema fields and endpoint URL path. + * requiredFields matches are weighted heavily (10x); + * path segment matches add a strong boost (8x); + * optionalFields matches add a smaller boost. + */ +export function matchTemplates(fields: SchemaField[], endpointPath: string = ""): TemplateMatch[] { + const normalizedFieldNames = fields.map((f) => normalize(f.fieldName)); + const normalizedPath = normalize(endpointPath); + + const results: TemplateMatch[] = COMPONENT_TEMPLATES.map((template) => { + const matchedFields: string[] = []; + + // Endpoint path keyword bonus + let pathScoreBoost = 0; + const templateKeywords = [template.id, ...template.requiredFields]; + if (normalizedPath) { + for (const kw of templateKeywords) { + const normKw = normalize(kw); + if (normKw.length > 2 && normalizedPath.includes(normKw)) { + pathScoreBoost += 8; + break; + } + } + } + + for (const required of template.requiredFields) { + const normalizedRequired = normalize(required); + if (normalizedFieldNames.some((fn) => fieldMatches(fn, normalizedRequired))) { + matchedFields.push(required); + } + } + for (const optional of template.optionalFields) { + const normalizedOptional = normalize(optional); + if (normalizedFieldNames.some((fn) => fieldMatches(fn, normalizedOptional))) { + matchedFields.push(optional); + } + } + + const requiredMatchCount = template.requiredFields.filter((r) => + matchedFields.includes(r), + ).length; + const requiredRatio = + template.requiredFields.length > 0 + ? requiredMatchCount / template.requiredFields.length + : 0; + const optionalMatchCount = matchedFields.length - requiredMatchCount; + + const score = requiredRatio * 10 + optionalMatchCount + pathScoreBoost; + + return { template, score, matchedFields }; + }); + + // If template has 0 score or only path match without any fields, ensure dynamic-grid is fallback + const matched = results + .filter((r) => r.score > 0) + .toSorted((a: TemplateMatch, b: TemplateMatch) => b.score - a.score); + + // If dynamicGrid is not present or score is low, guarantee dynamicGrid is included at the end + const dynamicMatch = results.find((r) => r.template.id === "dynamic-grid"); + if (dynamicMatch && !matched.some((m: TemplateMatch) => m.template.id === "dynamic-grid")) { + matched.push(dynamicMatch); + } + + return matched; +} + +export function getTopMatches( + fields: SchemaField[], + endpointPath: string = "", + limit = 4, +): TemplateMatch[] { + return matchTemplates(fields, endpointPath).slice(0, limit); +} + +export function getTemplateById(id: string): ComponentTemplate | undefined { + return COMPONENT_TEMPLATES.find((t) => t.id === id); +} + +export function generateCode( + template: ComponentTemplate, + fields: SchemaField[], + endpointUrl: string, +): string { + return template.code(fields, endpointUrl); +} + +export * from "./types"; diff --git a/src/lib/component-templates/post-card.ts b/src/lib/component-templates/post-card.ts new file mode 100644 index 0000000..e609825 --- /dev/null +++ b/src/lib/component-templates/post-card.ts @@ -0,0 +1,59 @@ +import type { ComponentTemplate } from "./types"; +import { findField, buildFetchHook, buildInterface } from "./codegen-utils"; + +export const postCardTemplate: ComponentTemplate = { + id: "post-card", + name: "Post Card", + description: "Cover image + title + excerpt list. Handles single or list response.", + requiredFields: ["title", "content"], + optionalFields: ["author", "coverImage", "publishedAt", "tags", "id"], + code: (fields, endpointUrl) => { + const idField = findField(fields, ["id", "postid", "uuid"]) ?? "id"; + const titleField = findField(fields, ["title"]) ?? "title"; + const contentField = findField(fields, ["content", "body", "paragraph"]) ?? "content"; + const authorField = findField(fields, ["author", "fullname", "username"]); + const coverImageField = findField(fields, ["coverimage", "image", "thumbnail"]); + const publishedAtField = findField(fields, ["publishedat", "createdat", "date"]); + + const interfaceLines = [ + `${idField}: string;`, + `${titleField}: string;`, + `${contentField}: string;`, + ]; + if (authorField) interfaceLines.push(`${authorField}: string;`); + if (coverImageField) interfaceLines.push(`${coverImageField}: string;`); + if (publishedAtField) interfaceLines.push(`${publishedAtField}: string;`); + + return `import { useEffect, useState } from "react"; + +${buildInterface("Post", interfaceLines)} + +${buildFetchHook("PostCard", endpointUrl, "Post")} + +export function PostCard() { + const { data, loading, error } = usePostCardData(); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + if (!data) return null; + + const posts = Array.isArray(data) ? data : [data]; + + return ( +
+ {posts.map((post) => ( +
+${coverImageField ? ` {post.${titleField}}\n` : ""}
+

{post.${titleField}}

+

{post.${contentField}}

+
+${authorField ? ` {post.${authorField}}\n` : ""}${publishedAtField ? ` {post.${publishedAtField}}\n` : ""}
+
+
+ ))} +
+ ); +} +`; + }, +}; diff --git a/src/lib/component-templates/product-card.ts b/src/lib/component-templates/product-card.ts new file mode 100644 index 0000000..68052dc --- /dev/null +++ b/src/lib/component-templates/product-card.ts @@ -0,0 +1,98 @@ +import type { ComponentTemplate } from "./types"; +import { findField, buildFetchHook, buildInterface } from "./codegen-utils"; + +export const productCardTemplate: ComponentTemplate = { + id: "product-card", + name: "Product Card", + description: "Image + product info card. Gracefully handles present/absent images & tags.", + requiredFields: ["name", "price", "productname", "amount"], + optionalFields: ["description", "category", "image", "inStock", "rating", "brand", "id"], + code: (fields, endpointUrl) => { + const idField = findField(fields, ["id", "productid", "uuid"]) ?? "id"; + const nameField = findField(fields, ["name", "productname", "title"]) ?? "name"; + const priceField = findField(fields, ["price", "amount", "cost"]) ?? "price"; + const imageField = findField(fields, ["image", "photo", "picture", "thumbnail", "cover"]); + const descriptionField = findField(fields, [ + "description", + "productdescription", + "summary", + ]); + const categoryField = findField(fields, ["category", "department", "genre"]); + const brandField = findField(fields, ["brand", "company", "manufacturer"]); + const inStockField = findField(fields, ["instock", "available", "isinstock"]); + const ratingField = findField(fields, ["rating", "score", "stars"]); + + const interfaceLines = [ + `${idField}: string;`, + `${nameField}: string;`, + `${priceField}: string | number;`, + ]; + if (imageField) interfaceLines.push(`${imageField}: string;`); + if (descriptionField) interfaceLines.push(`${descriptionField}: string;`); + if (categoryField) interfaceLines.push(`${categoryField}: string;`); + if (brandField) interfaceLines.push(`${brandField}: string;`); + if (inStockField) interfaceLines.push(`${inStockField}: boolean;`); + if (ratingField) interfaceLines.push(`${ratingField}: string | number;`); + + return `import { useEffect, useState } from "react"; + +${buildInterface("Product", interfaceLines)} + +${buildFetchHook("ProductCard", endpointUrl, "Product")} + +export function ProductCard() { + const { data, loading, error } = useProductCardData(); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + if (!data) return null; + + const products = Array.isArray(data) ? data : [data]; + + return ( +
+ {products.map((product) => ( +
+
+${ + imageField + ? ` {product.${imageField} ? ( + {product.${nameField}} + ) : ( +
+ 📦 +
+ )}` + : `
+ 📦 +
` +} +
+
+${categoryField ? ` {product.${categoryField} && {product.${categoryField}}}\n` : ""}${brandField ? ` {product.${brandField} && {product.${brandField}}}\n` : ""}
+

{product.${nameField}}

+${descriptionField ? ` {product.${descriptionField} &&

{product.${descriptionField}}

}\n` : ""}
+
+ +
+
+ \${product.${priceField}} +${ratingField ? ` {product.${ratingField} && Rating: {product.${ratingField}}}\n` : ""}
+ +${ + inStockField + ? ` {product.${inStockField} !== undefined && ( + + {product.${inStockField} ? "In stock" : "Out of stock"} + + )}\n` + : "" +}
+
+ ))} +
+ ); +} +`; + }, +}; diff --git a/src/lib/component-templates/todo-list.ts b/src/lib/component-templates/todo-list.ts new file mode 100644 index 0000000..e6bdb3e --- /dev/null +++ b/src/lib/component-templates/todo-list.ts @@ -0,0 +1,81 @@ +import type { ComponentTemplate } from "./types"; +import { findField, buildFetchHook, buildInterface } from "./codegen-utils"; + +export const todoListTemplate: ComponentTemplate = { + id: "todo-list", + name: "Todo List", + description: "Checkbox list adaptively rendering title, status, priority, dates & tags.", + requiredFields: ["title", "completed", "task", "done"], + optionalFields: ["dueDate", "priority", "category", "description", "assignee", "id"], + code: (fields, endpointUrl) => { + const idField = findField(fields, ["id", "todoid", "uuid"]) ?? "id"; + const titleField = findField(fields, ["title", "task", "name", "summary"]) ?? "title"; + const completedField = + findField(fields, ["completed", "done", "isdone", "iscompleted"]) ?? "completed"; + const dueDateField = findField(fields, ["duedate", "deadline", "date", "due"]); + const priorityField = findField(fields, ["priority", "level", "urgency"]); + const categoryField = findField(fields, ["category", "tag", "project", "label"]); + const assigneeField = findField(fields, ["assignee", "assignedto", "user", "owner"]); + const descriptionField = findField(fields, ["description", "details", "notes"]); + + const interfaceLines = [ + `${idField}: string;`, + `${titleField}: string;`, + `${completedField}: boolean;`, + ]; + if (dueDateField) interfaceLines.push(`${dueDateField}: string;`); + if (priorityField) interfaceLines.push(`${priorityField}: string;`); + if (categoryField) interfaceLines.push(`${categoryField}: string;`); + if (assigneeField) interfaceLines.push(`${assigneeField}: string;`); + if (descriptionField) interfaceLines.push(`${descriptionField}: string;`); + + return `import { useEffect, useState } from "react"; + +${buildInterface("Todo", interfaceLines)} + +${buildFetchHook("TodoList", endpointUrl, "Todo")} + +export function TodoList() { + const { data, loading, error } = useTodoListData(); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + if (!data) return null; + + const todos = Array.isArray(data) ? data : [data]; + + return ( + + ); +} +`; + }, +}; diff --git a/src/lib/component-templates/transaction-row.ts b/src/lib/component-templates/transaction-row.ts new file mode 100644 index 0000000..ad30d8f --- /dev/null +++ b/src/lib/component-templates/transaction-row.ts @@ -0,0 +1,89 @@ +import type { ComponentTemplate } from "./types"; +import { findField, buildFetchHook, buildInterface } from "./codegen-utils"; + +export const transactionRowTemplate: ComponentTemplate = { + id: "transaction-row", + name: "Transaction Statement Table", + description: "Account statement / financial log table row list adapting to present columns.", + requiredFields: ["amount", "date", "price"], + optionalFields: [ + "transactionId", + "currency", + "status", + "description", + "category", + "merchant", + "id", + ], + code: (fields, endpointUrl) => { + const idField = findField(fields, ["id", "transactionid", "uuid"]) ?? "id"; + const amountField = findField(fields, ["amount", "price", "value", "cost"]) ?? "amount"; + const dateField = findField(fields, ["date", "createdat", "timestamp", "time"]) ?? "date"; + const currencyField = findField(fields, ["currency", "currencycode", "symbol"]); + const statusField = findField(fields, ["status", "state"]); + const descriptionField = findField(fields, ["description", "note", "title", "details"]); + const merchantField = findField(fields, ["merchant", "payee", "company", "vendor"]); + const categoryField = findField(fields, ["category", "type", "department"]); + + const interfaceLines = [ + `${idField}: string;`, + `${amountField}: string | number;`, + `${dateField}: string;`, + ]; + if (currencyField) interfaceLines.push(`${currencyField}: string;`); + if (statusField) interfaceLines.push(`${statusField}: string;`); + if (descriptionField) interfaceLines.push(`${descriptionField}: string;`); + if (merchantField) interfaceLines.push(`${merchantField}: string;`); + if (categoryField) interfaceLines.push(`${categoryField}: string;`); + + return `import { useEffect, useState } from "react"; + +${buildInterface("Transaction", interfaceLines)} + +${buildFetchHook("TransactionRow", endpointUrl, "Transaction")} + +export function TransactionTable() { + const { data, loading, error } = useTransactionRowData(); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + if (!data) return null; + + const transactions = Array.isArray(data) ? data : [data]; + + return ( +
+ + + + +${merchantField ? ` \n` : ""}${descriptionField ? ` \n` : ""}${categoryField ? ` \n` : ""}${statusField ? ` \n` : ""} + + + + {transactions.map((tx) => ( + + +${merchantField ? ` \n` : ""}${descriptionField ? ` \n` : ""}${categoryField ? ` \n` : ""}${ + statusField + ? ` \n` + : "" + } + + ))} + +
DateMerchant / PayeeDescriptionCategoryStatusAmount
{tx.${dateField}}{tx.${merchantField}}{tx.${descriptionField}}{tx.${categoryField}} + {tx.${statusField} && ( + + {tx.${statusField}} + + )} + + {tx.${amountField}}${currencyField ? ` {tx.${currencyField}}` : ""} +
+
+ ); +} +`; + }, +}; diff --git a/src/lib/component-templates/types.ts b/src/lib/component-templates/types.ts new file mode 100644 index 0000000..e91b3c6 --- /dev/null +++ b/src/lib/component-templates/types.ts @@ -0,0 +1,19 @@ +import type { SchemaField } from "~/lib/faker-options"; + +export interface ComponentTemplate { + id: string; + name: string; + description: string; + /** fieldName candidates that strongly indicate this template (case/underscore-insensitive) */ + requiredFields: string[]; + /** fieldName candidates that boost match confidence but aren't essential */ + optionalFields: string[]; + /** generates the full .tsx source as a string, using the user's actual field names */ + code: (fields: SchemaField[], endpointUrl: string) => string; +} + +export interface TemplateMatch { + template: ComponentTemplate; + score: number; + matchedFields: string[]; +} diff --git a/src/lib/component-templates/user-card.ts b/src/lib/component-templates/user-card.ts new file mode 100644 index 0000000..0e045f6 --- /dev/null +++ b/src/lib/component-templates/user-card.ts @@ -0,0 +1,75 @@ +import type { ComponentTemplate } from "./types"; +import { findField, buildFetchHook, buildInterface } from "./codegen-utils"; + +export const userCardTemplate: ComponentTemplate = { + id: "user-card", + name: "User Card", + description: "Avatar + profile details grid. Adapts to present fields.", + requiredFields: ["username", "fullName", "name"], + optionalFields: ["email", "jobTitle", "phone", "bio", "avatar", "id"], + code: (fields, endpointUrl) => { + const idField = findField(fields, ["id", "userid", "uuid"]) ?? "id"; + const usernameField = findField(fields, ["username", "handle"]); + const fullNameField = findField(fields, ["fullname", "name", "displayname"]) ?? "name"; + const avatarField = findField(fields, ["avatar", "image", "photo", "picture"]); + const emailField = findField(fields, ["email"]); + const jobTitleField = findField(fields, ["jobtitle", "role", "title"]); + const phoneField = findField(fields, ["phone", "phonenumber", "mobile", "cell"]); + const bioField = findField(fields, ["bio", "about", "description"]); + + const interfaceLines = [`${idField}: string;`, `${fullNameField}: string;`]; + if (usernameField) interfaceLines.push(`${usernameField}: string;`); + if (avatarField) interfaceLines.push(`${avatarField}: string;`); + if (emailField) interfaceLines.push(`${emailField}: string;`); + if (jobTitleField) interfaceLines.push(`${jobTitleField}: string;`); + if (phoneField) interfaceLines.push(`${phoneField}: string;`); + if (bioField) interfaceLines.push(`${bioField}: string;`); + + return `import { useEffect, useState } from "react"; + +${buildInterface("User", interfaceLines)} + +${buildFetchHook("UserCard", endpointUrl, "User")} + +export function UserCard() { + const { data, loading, error } = useUserCardData(); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + if (!data) return null; + + const users = Array.isArray(data) ? data : [data]; + + return ( +
+ {users.map((user) => ( +
+
+${ + avatarField + ? ` {user.${fullNameField}}` + : `
+ {user.${fullNameField}?.charAt(0) || "U"} +
` +} +
+

{user.${fullNameField}}

+${usernameField ? `

@{user.${usernameField}}

\n` : ""}${jobTitleField ? `

{user.${jobTitleField}}

\n` : ""}
+
+${bioField ? `

{user.${bioField}}

\n` : ""}${ + emailField || phoneField + ? `
+${emailField ? ` {user.${emailField} &&

Email: {user.${emailField}}

}\n` : ""}${phoneField ? ` {user.${phoneField} &&

Phone: {user.${phoneField}}

}\n` : ""}
\n` + : "" + }
+ ))} +
+ ); +} +`; + }, +}; diff --git a/src/lib/endpoint-data-store.ts b/src/lib/endpoint-data-store.ts index b0f6f9e..b8cd264 100644 --- a/src/lib/endpoint-data-store.ts +++ b/src/lib/endpoint-data-store.ts @@ -38,7 +38,7 @@ export function generateAndSaveData( count: number, ): unknown { const filePath = getEndpointFilePath(projectId, endpointId); - const data = resolveResponseData(schema, count); + const data = resolveResponseData(schema, count, projectId); fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); return data; } diff --git a/src/modules/workspace/ui/components/dashboard/schemaBuilder/schema-utils.ts b/src/lib/faker-options.ts similarity index 76% rename from src/modules/workspace/ui/components/dashboard/schemaBuilder/schema-utils.ts rename to src/lib/faker-options.ts index f3c4412..9417b93 100644 --- a/src/modules/workspace/ui/components/dashboard/schemaBuilder/schema-utils.ts +++ b/src/lib/faker-options.ts @@ -198,30 +198,70 @@ export interface SchemaField { } export function normalizeStoredFieldType(value: unknown): string { - if (typeof value !== "string") return "$faker.string.uuid"; - if (value.startsWith("$faker.")) return value; + if (typeof value === "string") { + if (value.startsWith("$faker.")) return value; - const legacyTypeMap: Record = { - uuid: "$faker.string.uuid", - fullName: "$faker.person.fullName", - username: "$faker.internet.username", - paragraph: "$faker.lorem.paragraph", - date: "$faker.date.anytime", - email: "$faker.internet.email", - phoneNumber: "$faker.phone.number", - city: "$faker.location.city", - companyName: "$faker.company.name", - productName: "$faker.commerce.productName", - price: "$faker.commerce.price", - number: "$faker.number.int", - boolean: "$faker.datatype.boolean", - }; + const legacyTypeMap: Record = { + uuid: "$faker.string.uuid", + fullName: "$faker.person.fullName", + username: "$faker.internet.username", + paragraph: "$faker.lorem.paragraph", + date: "$faker.date.anytime", + email: "$faker.internet.email", + phoneNumber: "$faker.phone.number", + city: "$faker.location.city", + companyName: "$faker.company.name", + productName: "$faker.commerce.productName", + price: "$faker.commerce.price", + number: "$faker.number.int", + boolean: "$faker.datatype.boolean", + }; - return legacyTypeMap[value] ?? "$faker.string.uuid"; + return legacyTypeMap[value] ?? value; + } + return JSON.stringify(value); } -export function fieldsFromSchema(schema: unknown): SchemaField[] { - if (!schema || typeof schema !== "object" || Array.isArray(schema)) { +export function fieldsFromSchema(schema: unknown, prefix = ""): SchemaField[] { + if (!schema || typeof schema !== "object") { + return []; + } + + if (Array.isArray(schema)) { + if (schema.length > 0) { + return fieldsFromSchema(schema[0], prefix); + } + return []; + } + + // Handle {$array: schema} wrapper format + if ("$array" in schema && schema.$array) { + return fieldsFromSchema((schema as { $array: unknown }).$array, prefix); + } + + const fields: SchemaField[] = []; + + for (const [key, val] of Object.entries(schema)) { + const fullPath = prefix ? `${prefix}.${key}` : key; + if (val && typeof val === "object" && !Array.isArray(val) && !("$array" in val)) { + // Nested object + fields.push(...fieldsFromSchema(val, fullPath)); + } else if (val && typeof val === "object" && "$array" in val) { + // Nested array object + fields.push(...fieldsFromSchema((val as { $array: unknown }).$array, fullPath)); + } else if (Array.isArray(val) && val.length > 0) { + // Primitive array + fields.push(...fieldsFromSchema(val[0], fullPath)); + } else { + fields.push({ + id: crypto.randomUUID(), + fieldName: fullPath, + dataType: normalizeStoredFieldType(val), + }); + } + } + + if (fields.length === 0 && !prefix) { return [ { id: crypto.randomUUID(), @@ -231,21 +271,7 @@ export function fieldsFromSchema(schema: unknown): SchemaField[] { ]; } - const fields = Object.entries(schema).map(([fieldName, dataType]) => ({ - id: crypto.randomUUID(), - fieldName, - dataType: normalizeStoredFieldType(dataType), - })); - - return fields.length > 0 - ? fields - : [ - { - id: crypto.randomUUID(), - fieldName: "", - dataType: "$faker.string.uuid", - }, - ]; + return fields; } export function buildSchema(schemaFields: SchemaField[]): Record { diff --git a/src/lib/schema-resolver.test.ts b/src/lib/schema-resolver.test.ts index f9c058a..2cff86b 100644 --- a/src/lib/schema-resolver.test.ts +++ b/src/lib/schema-resolver.test.ts @@ -178,4 +178,11 @@ describe("resolveResponseData", () => { const ids = result.map((r) => r.id); expect(new Set(ids).size).toBe(5); }); + + it("produces identical output when given the same string seed 'voidend'", () => { + const schema = { id: "$faker.string.uuid", email: "$faker.internet.email" }; + const res1 = resolveResponseData(schema, 3, "voidend"); + const res2 = resolveResponseData(schema, 3, "voidend"); + expect(res1).toEqual(res2); + }); }); diff --git a/src/lib/schema-resolver.ts b/src/lib/schema-resolver.ts index ff2b482..637ebad 100644 --- a/src/lib/schema-resolver.ts +++ b/src/lib/schema-resolver.ts @@ -1,5 +1,14 @@ import { faker } from "@faker-js/faker"; +export function stringToSeed(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + export function resolveSchema(schema: unknown): unknown { if (typeof schema === "string" && schema.startsWith("$faker.")) { const path = schema.slice(7).split("."); @@ -26,7 +35,17 @@ export function resolveSchema(schema: unknown): unknown { } /** Mirrors server-side response generation in endpoint-data-store. */ -export function resolveResponseData(schema: unknown, count: number): unknown { +export function resolveResponseData( + schema: unknown, + count: number, + seed: string | number = "voidend", +): unknown { + if (typeof seed === "string") { + faker.seed(stringToSeed(seed)); + } else { + faker.seed(seed); + } + return count > 1 ? Array.from({ length: count }, () => resolveSchema(schema)) : resolveSchema(schema); diff --git a/src/modules/workspace/ui/components/dashboard/endpointBar.tsx b/src/modules/workspace/ui/components/dashboard/endpointBar.tsx index 171ed74..194db29 100644 --- a/src/modules/workspace/ui/components/dashboard/endpointBar.tsx +++ b/src/modules/workspace/ui/components/dashboard/endpointBar.tsx @@ -208,6 +208,7 @@ export const EndpointBar = ({ projectId, mockOrigin, endpoint, project }: Endpoi {endpoint && ( setIsSchemaBuilderOpen(false)} /> diff --git a/src/modules/workspace/ui/components/dashboard/schemaBuilder.tsx b/src/modules/workspace/ui/components/dashboard/schemaBuilder.tsx index a55fcb7..3a8264b 100644 --- a/src/modules/workspace/ui/components/dashboard/schemaBuilder.tsx +++ b/src/modules/workspace/ui/components/dashboard/schemaBuilder.tsx @@ -24,18 +24,19 @@ import { SelectValue, } from "~/components/ui/select"; import { useUpdateEndpoint } from "~/hooks/use-endpoints"; -import { resolveSchema } from "~/lib/schema-resolver"; +import { resolveResponseData } from "~/lib/schema-resolver"; import type { HttpMethod } from "../sidebar/types"; import { FAKER_OPTIONS, type SchemaField, fieldsFromSchema, buildSchema, -} from "./schemaBuilder/schema-utils"; +} from "~/lib/faker-options"; interface SchemaBuilderProps { endpoint: { id: string; + projectId?: string; name: string; method: HttpMethod; path: string; @@ -45,6 +46,7 @@ interface SchemaBuilderProps { responseSchema: unknown; responseCount: number; }; + projectId?: string; initialTab?: "schema" | "auth"; onSuccess?: () => void; } @@ -71,7 +73,12 @@ function FieldLabel({ children, tooltip }: { children: React.ReactNode; tooltip: ); } -export function SchemaBuilder({ endpoint, initialTab = "schema", onSuccess }: SchemaBuilderProps) { +export function SchemaBuilder({ + endpoint, + projectId, + initialTab = "schema", + onSuccess, +}: SchemaBuilderProps) { const [activeTab, setActiveTab] = useState<"schema" | "auth">(initialTab); const [resourceName, setResourceName] = useState(endpoint.name); const [nameError, setNameError] = useState(null); @@ -81,6 +88,9 @@ export function SchemaBuilder({ endpoint, initialTab = "schema", onSuccess }: Sc const [fields, setFields] = useState([ { id: crypto.randomUUID(), fieldName: "", dataType: "$faker.string.uuid" }, ]); + const [editorMode, setEditorMode] = useState<"flat" | "raw">("flat"); + const [rawSchemaText, setRawSchemaText] = useState(""); + const [jsonError, setJsonError] = useState(null); const updateEndpoint = useUpdateEndpoint(); @@ -95,6 +105,12 @@ export function SchemaBuilder({ endpoint, initialTab = "schema", onSuccess }: Sc setFailureRate(endpoint.failureRate); setResponseCount(endpoint.responseCount); setFields(fieldsFromSchema(endpoint.responseSchema)); + setRawSchemaText( + endpoint.responseSchema + ? JSON.stringify(endpoint.responseSchema, null, 2) + : JSON.stringify({ id: "$faker.string.uuid" }, null, 2), + ); + setJsonError(null); }, [endpoint]); const addField = () => { @@ -120,12 +136,22 @@ export function SchemaBuilder({ endpoint, initialTab = "schema", onSuccess }: Sc setFields(nextFields); }; + const seed = projectId || endpoint.projectId || endpoint.id; + const previewData = useMemo(() => { - const formattedSchema = buildSchema(fields); - return Array.from({ length: Math.max(responseCount, 1) }, () => - resolveSchema(formattedSchema), - ); - }, [fields, responseCount]); + let formattedSchema: unknown; + if (editorMode === "raw") { + try { + formattedSchema = JSON.parse(rawSchemaText); + } catch { + formattedSchema = buildSchema(fields); + } + } else { + formattedSchema = buildSchema(fields); + } + + return resolveResponseData(formattedSchema, responseCount, seed); + }, [fields, editorMode, rawSchemaText, responseCount, seed]); const hasExistingSchema = useMemo(() => { if ( @@ -146,13 +172,24 @@ export function SchemaBuilder({ endpoint, initialTab = "schema", onSuccess }: Sc } setNameError(null); - const formattedSchema = buildSchema(fields); + let formattedSchema: unknown; + if (editorMode === "raw") { + try { + formattedSchema = JSON.parse(rawSchemaText); + } catch (err: any) { + setJsonError(err.message || "Invalid JSON syntax"); + toast.error("Please fix JSON syntax errors before saving."); + return; + } + } else { + formattedSchema = buildSchema(fields); + } updateEndpoint.mutate( { id: endpoint.id, name: resourceName, - responseSchema: formattedSchema, + responseSchema: formattedSchema as Record, delayMs: delayMs, failureRate: failureRate, responseCount: responseCount, @@ -293,122 +330,436 @@ export function SchemaBuilder({ endpoint, initialTab = "schema", onSuccess }: Sc - {/* Schema fields */} + {/* Schema fields editor with Flat vs Raw JSON tabs */}
Fields schema - - {fields.length} {fields.length === 1 ? "field" : "fields"} - -
- -
- {fields.length > 0 && ( -
-
Field name
-
Data type
-
+
+
+ +
- )} + {editorMode === "flat" && ( + + {fields.length}{" "} + {fields.length === 1 ? "field" : "fields"} + + )} +
+
+ {editorMode === "flat" ? (
- {fields.map((field) => ( -
- - updateField( - field.id, - "fieldName", - e.target.value, - ) - } - className="h-10 border-0 bg-muted font-mono text-sm shadow-none focus-visible:ring-2 focus-visible:ring-ring" - /> + {fields.length > 0 && ( +
+
Field name
+
Data type
+
+
+ )} - updateField( field.id, - "dataType", - value, - ); - }} - > - - - - - {FAKER_OPTIONS.map((group) => ( - - - {group.label} - - {group.options.map((option) => ( - - {option.label} - - ))} - - ))} - - - - - - removeField(field.id) - } - className="h-10 w-10 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:opacity-30" - > - - + "fieldName", + e.target.value, + ) } + className="h-10 border-0 bg-muted font-mono text-sm shadow-none focus-visible:ring-2 focus-visible:ring-ring" /> - - {fields.length === 1 - ? "At least one field is required" - : "Remove field"} - - -
- ))} + + + + + + removeField(field.id) + } + className="h-10 w-10 shrink-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive disabled:opacity-30" + > + + + } + /> + + {fields.length === 1 + ? "At least one field is required" + : "Remove field"} + + +
+ ))} +
+ +
+ + + +
+ ) : ( +
+
+ + JSON Editor (Tab / Enter auto-indent, " + auto-pairs) + + +
+