From 1222bb53a24a86e56bfad789265dc4bfe990545f Mon Sep 17 00:00:00 2001 From: Eric Luce <37158449+eluce2@users.noreply.github.com> Date: Sat, 13 Dec 2025 15:26:14 -0600 Subject: [PATCH 01/38] typegen web ui --- apps/demo/package.json | 8 +- apps/docs/package.json | 6 +- package.json | 4 +- packages/better-auth/package.json | 6 +- packages/cli/package.json | 12 +- packages/cli/src/utils/formatting.ts | 8 +- packages/fmdapi/package.json | 6 +- packages/fmodata/package.json | 2 +- packages/fmodata/src/client/error-parser.ts | 3 + packages/fmodata/tests/tsconfig.build.json | 3 + packages/registry/package.json | 8 +- packages/typegen/package.json | 28 +- .../typegen/proofkit-typegen.config.jsonc | 19 + packages/typegen/scripts/build-copy.js | 18 + packages/typegen/scripts/build.ts | 5 + packages/typegen/src/buildSchema.ts | 6 +- packages/typegen/src/cli.ts | 92 +- packages/typegen/src/formatting.ts | 8 +- packages/typegen/src/server/api.ts | 153 + packages/typegen/src/server/app.ts | 432 ++ packages/typegen/src/server/contract.ts | 75 + .../typegen/src/server/createDataApiClient.ts | 251 ++ packages/typegen/src/server/index.ts | 155 + packages/typegen/src/typegen.ts | 19 +- packages/typegen/src/types.ts | 126 +- packages/typegen/vite.config.ts | 7 +- packages/typegen/web/components.json | 24 + packages/typegen/web/index.html | 35 + packages/typegen/web/package.json | 47 + packages/typegen/web/src/App.css | 153 + packages/typegen/web/src/App.tsx | 209 + .../web/src/components/ConfigEditor.tsx | 246 ++ .../typegen/web/src/components/ConfigList.css | 81 + .../typegen/web/src/components/ConfigList.tsx | 66 + .../web/src/components/ConfigSummary.tsx | 32 + .../web/src/components/EnvVarDialog.tsx | 417 ++ .../web/src/components/EnvVarField.tsx | 139 + .../web/src/components/InfoTooltip.tsx | 15 + .../web/src/components/LayoutEditor.tsx | 85 + .../web/src/components/LayoutItemEditor.tsx | 206 + .../web/src/components/LayoutSelector.tsx | 243 ++ .../web/src/components/ServerEnvField.tsx | 70 + .../web/src/components/badge/circle.tsx | 59 + .../web/src/components/button/loading.tsx | 28 + .../web/src/components/combobox/default.tsx | 90 + .../web/src/components/dialog/default.tsx | 92 + .../web/src/components/form/default.tsx | 72 + .../web/src/components/input/addon.tsx | 29 + .../web/src/components/select/default.tsx | 18 + .../web/src/components/switch/button.tsx | 20 + .../web/src/components/switch/default.tsx | 11 + .../web/src/components/tree/default.tsx | 105 + .../web/src/components/ui/accordion.tsx | 159 + .../typegen/web/src/components/ui/alert.tsx | 258 ++ .../typegen/web/src/components/ui/badge.tsx | 242 + .../typegen/web/src/components/ui/button.tsx | 423 ++ .../typegen/web/src/components/ui/command.tsx | 139 + .../typegen/web/src/components/ui/dialog.tsx | 139 + .../typegen/web/src/components/ui/form.tsx | 138 + .../typegen/web/src/components/ui/input.tsx | 163 + .../typegen/web/src/components/ui/label.tsx | 31 + .../typegen/web/src/components/ui/popover.tsx | 41 + .../typegen/web/src/components/ui/select.tsx | 235 + .../web/src/components/ui/switch-field.tsx | 71 + .../typegen/web/src/components/ui/switch.tsx | 191 + .../web/src/components/ui/textarea.tsx | 37 + .../typegen/web/src/components/ui/tooltip.tsx | 49 + .../typegen/web/src/components/ui/tree.tsx | 160 + .../web/src/components/useEnvVarIndicator.ts | 83 + packages/typegen/web/src/hooks/useConfig.ts | 58 + .../web/src/hooks/useTestConnection.ts | 141 + packages/typegen/web/src/index.css | 123 + packages/typegen/web/src/lib/api.ts | 52 + packages/typegen/web/src/lib/config-utils.ts | 7 + packages/typegen/web/src/lib/envValues.ts | 20 + packages/typegen/web/src/lib/schema.ts | 3 + packages/typegen/web/src/lib/utils.ts | 12 + packages/typegen/web/src/main.tsx | 23 + packages/typegen/web/tsconfig.app.json | 12 + packages/typegen/web/tsconfig.json | 28 + packages/typegen/web/tsconfig.node.json | 10 + packages/typegen/web/vite.config.ts | 61 + packages/webviewer/package.json | 2 +- pnpm-lock.yaml | 3882 ++++++++++------- pnpm-workspace.yaml | 1 + 85 files changed, 9261 insertions(+), 1785 deletions(-) create mode 100644 packages/typegen/proofkit-typegen.config.jsonc create mode 100644 packages/typegen/scripts/build-copy.js create mode 100644 packages/typegen/scripts/build.ts create mode 100644 packages/typegen/src/server/api.ts create mode 100644 packages/typegen/src/server/app.ts create mode 100644 packages/typegen/src/server/contract.ts create mode 100644 packages/typegen/src/server/createDataApiClient.ts create mode 100644 packages/typegen/src/server/index.ts create mode 100644 packages/typegen/web/components.json create mode 100644 packages/typegen/web/index.html create mode 100644 packages/typegen/web/package.json create mode 100644 packages/typegen/web/src/App.css create mode 100644 packages/typegen/web/src/App.tsx create mode 100644 packages/typegen/web/src/components/ConfigEditor.tsx create mode 100644 packages/typegen/web/src/components/ConfigList.css create mode 100644 packages/typegen/web/src/components/ConfigList.tsx create mode 100644 packages/typegen/web/src/components/ConfigSummary.tsx create mode 100644 packages/typegen/web/src/components/EnvVarDialog.tsx create mode 100644 packages/typegen/web/src/components/EnvVarField.tsx create mode 100644 packages/typegen/web/src/components/InfoTooltip.tsx create mode 100644 packages/typegen/web/src/components/LayoutEditor.tsx create mode 100644 packages/typegen/web/src/components/LayoutItemEditor.tsx create mode 100644 packages/typegen/web/src/components/LayoutSelector.tsx create mode 100644 packages/typegen/web/src/components/ServerEnvField.tsx create mode 100644 packages/typegen/web/src/components/badge/circle.tsx create mode 100644 packages/typegen/web/src/components/button/loading.tsx create mode 100644 packages/typegen/web/src/components/combobox/default.tsx create mode 100644 packages/typegen/web/src/components/dialog/default.tsx create mode 100644 packages/typegen/web/src/components/form/default.tsx create mode 100644 packages/typegen/web/src/components/input/addon.tsx create mode 100644 packages/typegen/web/src/components/select/default.tsx create mode 100644 packages/typegen/web/src/components/switch/button.tsx create mode 100644 packages/typegen/web/src/components/switch/default.tsx create mode 100644 packages/typegen/web/src/components/tree/default.tsx create mode 100644 packages/typegen/web/src/components/ui/accordion.tsx create mode 100644 packages/typegen/web/src/components/ui/alert.tsx create mode 100644 packages/typegen/web/src/components/ui/badge.tsx create mode 100644 packages/typegen/web/src/components/ui/button.tsx create mode 100644 packages/typegen/web/src/components/ui/command.tsx create mode 100644 packages/typegen/web/src/components/ui/dialog.tsx create mode 100644 packages/typegen/web/src/components/ui/form.tsx create mode 100644 packages/typegen/web/src/components/ui/input.tsx create mode 100644 packages/typegen/web/src/components/ui/label.tsx create mode 100644 packages/typegen/web/src/components/ui/popover.tsx create mode 100644 packages/typegen/web/src/components/ui/select.tsx create mode 100644 packages/typegen/web/src/components/ui/switch-field.tsx create mode 100644 packages/typegen/web/src/components/ui/switch.tsx create mode 100644 packages/typegen/web/src/components/ui/textarea.tsx create mode 100644 packages/typegen/web/src/components/ui/tooltip.tsx create mode 100644 packages/typegen/web/src/components/ui/tree.tsx create mode 100644 packages/typegen/web/src/components/useEnvVarIndicator.ts create mode 100644 packages/typegen/web/src/hooks/useConfig.ts create mode 100644 packages/typegen/web/src/hooks/useTestConnection.ts create mode 100644 packages/typegen/web/src/index.css create mode 100644 packages/typegen/web/src/lib/api.ts create mode 100644 packages/typegen/web/src/lib/config-utils.ts create mode 100644 packages/typegen/web/src/lib/envValues.ts create mode 100644 packages/typegen/web/src/lib/schema.ts create mode 100644 packages/typegen/web/src/lib/utils.ts create mode 100644 packages/typegen/web/src/main.tsx create mode 100644 packages/typegen/web/tsconfig.app.json create mode 100644 packages/typegen/web/tsconfig.json create mode 100644 packages/typegen/web/tsconfig.node.json create mode 100644 packages/typegen/web/vite.config.ts diff --git a/apps/demo/package.json b/apps/demo/package.json index e3125aad..d955348f 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -19,10 +19,10 @@ "dotenv": "^16.5.0", "fm-odata-client": "^3.0.1", "fs-extra": "^11.3.0", - "next": "^15.4.9", + "next": "^15.5.8", "react": "^19.1.1", "react-dom": "^19.1.1", - "zod": "3.25.64" + "zod": "^4.1.13" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -35,7 +35,7 @@ "eslint": "^9.23.0", "eslint-config-next": "^15.3.3", "tailwindcss": "^4.1.11", - "typescript": "^5.9.2", - "vitest": "^3.2.4" + "typescript": "^5.9.3", + "vitest": "^4.0.7" } } diff --git a/apps/docs/package.json b/apps/docs/package.json index 9731a6e7..faaa886b 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -40,7 +40,7 @@ "tailwind-merge": "^3.3.1", "ts-morph": "^26.0.0", "twoslash": "^0.3.4", - "zod": "3.25.64" + "zod": "^4.1.13" }, "devDependencies": { "@proofkit/fmdapi": "workspace:*", @@ -55,7 +55,7 @@ "postcss": "^8.5.6", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.6", - "typescript": "^5.9.2", - "vitest": "^3.2.4" + "typescript": "^5.9.3", + "vitest": "^4.0.7" } } diff --git a/package.json b/package.json index 1f103314..95a8ca02 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "knip": "^5.56.0", "prettier": "^3.5.3", "turbo": "^2.5.4", - "typescript": "^5.9.2", - "vitest": "^3.2.4" + "typescript": "^5.9.3", + "vitest": "^4.0.7" }, "packageManager": "pnpm@10.14.0", "engines": { diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 4b85b8e5..e6985554 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -58,7 +58,7 @@ "odata-query": "^8.0.4", "prompts": "^2.4.2", "vite": "^6.3.4", - "zod": "3.25.64" + "zod": "^4.1.13" }, "devDependencies": { "@types/fs-extra": "^11.0.4", @@ -66,7 +66,7 @@ "@vitest/ui": "^3.2.4", "fm-odata-client": "^3.0.1", "publint": "^0.3.12", - "typescript": "^5.9.2", - "vitest": "^3.2.4" + "typescript": "^5.9.3", + "vitest": "^4.0.7" } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 14636c7d..aea6db7d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -67,7 +67,7 @@ "chalk": "5.4.1", "commander": "^14.0.0", "dotenv": "^16.5.0", - "es-toolkit": "^1.15.1", + "es-toolkit": "^1.38.0", "execa": "^9.5.1", "fast-glob": "^3.3.3", "fs-extra": "^11.3.0", @@ -96,7 +96,7 @@ "@proofkit/registry": "workspace:*", "@rollup/plugin-replace": "^6.0.3", "@t3-oss/env-nextjs": "^0.10.1", - "@tanstack/react-query": "^5.49.2", + "@tanstack/react-query": "^5.76.1", "@trpc/client": "11.0.0-rc.441", "@trpc/next": "11.0.0-rc.441", "@trpc/react-query": "11.0.0-rc.441", @@ -108,7 +108,7 @@ "@types/randomstring": "^1.3.0", "@types/react": "^19.1.10", "@types/semver": "^7.7.0", - "@vitest/coverage-v8": "^1.4.0", + "@vitest/coverage-v8": "^2.1.8", "drizzle-kit": "^0.21.4", "drizzle-orm": "^0.30.10", "mysql2": "^3.9.7", @@ -123,8 +123,8 @@ "tailwindcss": "^4.1.11", "tsdown": "^0.14.1", "type-fest": "^3.13.1", - "typescript": "^5.9.2", - "vitest": "^3.2.4", - "zod": "3.25.64" + "typescript": "^5.9.3", + "vitest": "^4.0.7", + "zod": "^4.1.13" } } diff --git a/packages/cli/src/utils/formatting.ts b/packages/cli/src/utils/formatting.ts index 25959dbb..4edbeaba 100644 --- a/packages/cli/src/utils/formatting.ts +++ b/packages/cli/src/utils/formatting.ts @@ -1,4 +1,4 @@ -import { format, getFileInfo } from "prettier"; +import * as prettier from "prettier"; import { Project } from "ts-morph"; import { state } from "~/state.js"; @@ -14,11 +14,13 @@ export async function formatAndSaveSourceFiles(project: Project) { // run each file through the prettier formatter for await (const file of files) { const filePath = file.getFilePath(); - const fileInfo = await getFileInfo(filePath); + const fileInfo = (await prettier.getFileInfo?.(filePath)) ?? { + ignored: false, + }; if (fileInfo.ignored) continue; - const formatted = await format(file.getFullText(), { + const formatted = await prettier.format(file.getFullText(), { filepath: filePath, }); file.replaceWithText(formatted); diff --git a/packages/fmdapi/package.json b/packages/fmdapi/package.json index c1a9d95e..97be322d 100644 --- a/packages/fmdapi/package.json +++ b/packages/fmdapi/package.json @@ -58,7 +58,7 @@ "fs-extra": "^11.3.0", "ts-morph": "^26.0.0", "vite": "^6.3.4", - "zod": "3.25.64" + "zod": "^4.1.13" }, "devDependencies": { "@types/fs-extra": "^11.0.4", @@ -72,8 +72,8 @@ "prettier": "^3.5.3", "publint": "^0.3.12", "ts-toolbelt": "^9.6.0", - "typescript": "^5.9.2", - "vitest": "^3.2.4" + "typescript": "^5.9.3", + "vitest": "^4.0.7" }, "engines": { "node": ">=18.0.0" diff --git a/packages/fmodata/package.json b/packages/fmodata/package.json index 72e875e0..18decf0d 100644 --- a/packages/fmodata/package.json +++ b/packages/fmodata/package.json @@ -63,7 +63,7 @@ "vite": "^6.3.4", "vite-plugin-dts": "^4.5.4", "vitest": "^4.0.7", - "zod": "4.1.12" + "zod": "^4.1.13" }, "engines": { "node": ">=18.0.0" diff --git a/packages/fmodata/src/client/error-parser.ts b/packages/fmodata/src/client/error-parser.ts index fd31d12e..bc4e124f 100644 --- a/packages/fmodata/src/client/error-parser.ts +++ b/packages/fmodata/src/client/error-parser.ts @@ -54,3 +54,6 @@ export async function parseErrorResponse( // Fall back to generic HTTPError return new HTTPError(url, response.status, response.statusText, errorBody); } + + + diff --git a/packages/fmodata/tests/tsconfig.build.json b/packages/fmodata/tests/tsconfig.build.json index b90b2dc4..5e005cd6 100644 --- a/packages/fmodata/tests/tsconfig.build.json +++ b/packages/fmodata/tests/tsconfig.build.json @@ -35,3 +35,6 @@ ], "exclude": ["../src/**/*", "../dist/**/*.js", "../dist/**/*.js.map"] } + + + diff --git a/packages/registry/package.json b/packages/registry/package.json index b42aadd0..a3839bdd 100644 --- a/packages/registry/package.json +++ b/packages/registry/package.json @@ -43,13 +43,13 @@ "chokidar-cli": "^3.0.0", "concurrently": "^8.2.2", "publint": "^0.3.12", - "tsdown": "^0.3.1", - "typescript": "^5.9.2", - "vitest": "^2.1.8" + "tsdown": "^0.14.1", + "typescript": "^5.9.3", + "vitest": "^4.0.7" }, "dependencies": { "jiti": "^1.21.7", "shadcn": "^2.10.0", - "zod": "3.25.64" + "zod": "^4.1.13" } } diff --git a/packages/typegen/package.json b/packages/typegen/package.json index 9bf537c8..cca6e79a 100644 --- a/packages/typegen/package.json +++ b/packages/typegen/package.json @@ -6,8 +6,10 @@ "main": "dist/esm/index.js", "scripts": { "dev": "pnpm build:watch", + "dev:ui": "concurrently -n \"web,api\" -c \"cyan,magenta\" \"pnpm -C web dev\" \"pnpm run dev:api\"", + "dev:api": "concurrently -n \"build,server\" -c \"cyan,magenta\" \"pnpm build:watch\" \"nodemon --watch dist/esm --delay 1 --exec 'node dist/esm/cli.js ui --port 3141 --no-open'\"", "test": "op inject -i op.env -o .env.local -f && vitest run", - "build": "vite build && publint --strict", + "build": "pnpm -C web build && pnpm vite build && node scripts/build-copy.js && publint --strict", "build:watch": "vite build --watch", "ci": "pnpm run build && pnpm run test", "prepublishOnly": "pnpm run ci" @@ -26,6 +28,18 @@ "default": "./dist/esm/types.js" } }, + "./api": { + "import": { + "types": "./dist/esm/server/contract.d.ts", + "default": "./dist/esm/server/contract.js" + } + }, + "./api-app": { + "import": { + "types": "./dist/esm/server/contract.d.ts", + "default": "./dist/esm/server/contract.js" + } + }, "./package.json": "./package.json" }, "keywords": [ @@ -51,26 +65,32 @@ "dependencies": { "@clack/prompts": "^0.11.0", "@commander-js/extra-typings": "^14.0.0", + "@hono/node-server": "^1.19.7", + "@hono/zod-validator": "^0.7.5", "@proofkit/fmdapi": "workspace:*", "@tanstack/vite-config": "^0.2.0", "chalk": "5.4.1", "commander": "^14.0.0", "dotenv": "^16.5.0", "fs-extra": "^11.3.0", + "hono": "^4.9.0", "jsonc-parser": "^3.3.1", + "open": "^10.1.0", "prettier": "^3.5.3", "semver": "^7.7.2", "ts-morph": "^26.0.0", "ts-toolbelt": "^9.6.0", "vite": "^6.3.4", - "zod": "3.25.64" + "zod": "^4.1.13" }, "devDependencies": { "@types/fs-extra": "^11.0.4", "@types/semver": "^7.7.0", + "concurrently": "^8.2.2", + "nodemon": "^3.1.11", "publint": "^0.3.12", "type-fest": "^3.13.1", - "typescript": "^5.9.2", - "vitest": "^3.2.4" + "typescript": "^5.9.3", + "vitest": "^4.0.7" } } diff --git a/packages/typegen/proofkit-typegen.config.jsonc b/packages/typegen/proofkit-typegen.config.jsonc new file mode 100644 index 00000000..ebb29643 --- /dev/null +++ b/packages/typegen/proofkit-typegen.config.jsonc @@ -0,0 +1,19 @@ +{ + "config": [ + { + "type": "fmdapi", + "layouts": [ + { + "layoutName": "customer", + "schemaName": "customer333", + "valueLists": "allowEmpty" + } + ], + "path": "schema", + "clearOldFiles": false, + "validator": "zod/v4", + "clientSuffix": "Layout", + "generateClient": true + } + ] +} diff --git a/packages/typegen/scripts/build-copy.js b/packages/typegen/scripts/build-copy.js new file mode 100644 index 00000000..1b068566 --- /dev/null +++ b/packages/typegen/scripts/build-copy.js @@ -0,0 +1,18 @@ +import { cpSync, existsSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const rootDir = join(__dirname, ".."); +const webDistDir = join(rootDir, "web", "dist"); +const distWebDir = join(rootDir, "dist", "web"); + +if (existsSync(webDistDir)) { + console.log("Copying web assets to dist/web..."); + cpSync(webDistDir, distWebDir, { recursive: true }); + console.log("Build complete!"); +} else { + console.warn("Web dist directory not found, skipping copy"); +} diff --git a/packages/typegen/scripts/build.ts b/packages/typegen/scripts/build.ts new file mode 100644 index 00000000..06779643 --- /dev/null +++ b/packages/typegen/scripts/build.ts @@ -0,0 +1,5 @@ +// This file is kept for git worktree compatibility +// The actual build logic has been moved to build-copy.js +// This file can be removed once the worktree is updated + +export {}; diff --git a/packages/typegen/src/buildSchema.ts b/packages/typegen/src/buildSchema.ts index 34e9bf85..8caab8b5 100644 --- a/packages/typegen/src/buildSchema.ts +++ b/packages/typegen/src/buildSchema.ts @@ -28,7 +28,7 @@ export function buildSchema( if (type === "zod" || type === "zod/v4" || type === "zod/v3") { schemaFile.addImportDeclaration({ - moduleSpecifier: type, + moduleSpecifier: "zod/v4", namedImports: ["z"], }); if (hasPortals) { @@ -312,8 +312,10 @@ export function buildOverrideFile( { type, ...args }: BuildSchemaArgs, ) { if (type === "zod" || type === "zod/v4" || type === "zod/v3") { + // Map zod/v4 to zod since we're using zod v4 + const moduleSpecifier = type === "zod/v4" ? "zod" : type; overrideFile.addImportDeclaration({ - moduleSpecifier: type, + moduleSpecifier, namedImports: ["z"], }); } diff --git a/packages/typegen/src/cli.ts b/packages/typegen/src/cli.ts index d2bb30eb..eed4ed37 100644 --- a/packages/typegen/src/cli.ts +++ b/packages/typegen/src/cli.ts @@ -9,6 +9,7 @@ import { config } from "dotenv"; import { fileURLToPath } from "url"; import { typegenConfig } from "./types"; import { generateTypedClients } from "./typegen"; +import { startServer } from "./server"; const defaultConfigPaths = [ "proofkit-typegen.config.jsonc", @@ -145,7 +146,7 @@ program ) .option( "--skip-env-check", - "Ignore loading environment variables from a file.", + "(deprecated) Ignore loading environment variables from a file.", false, ) .action(async (options) => { @@ -157,9 +158,12 @@ program path.resolve(configPath ?? defaultConfigPaths[0] ?? ""), ); - if (!options.skipEnvCheck) { - parseEnvs(options.envPath); + if (options.skipEnvCheck) { + console.log( + chalk.yellow("⚠️ You no longer need to use --skip-env-check"), + ); } + parseEnvs(options.envPath); // default command await runCodegen({ @@ -178,6 +182,71 @@ program console.log(configLocation); init({ configLocation }); }); + +program + .command("ui") + .description("Launch the configuration UI") + .option("--port ", "Port for the UI server") + .option("--config ", "optional config file name") + .option("--no-open", "Don't automatically open the browser") + .option("--env-path ", "optional path to your .env file") + .action(async (options) => { + const configPath = getConfigPath(options.config); + const configLocation = configPath ?? defaultConfigPaths[0] ?? ""; + + // Load environment variables before starting the server + parseEnvs(options.envPath); + + let port: number | null = null; + if (options.port) { + port = Number.parseInt(options.port, 10); + if (Number.isNaN(port) || port < 1 || port > 65535) { + console.error(chalk.red("Invalid port number")); + return process.exit(1); + } + } + + try { + const server = await startServer({ + port, + cwd: process.cwd(), + configPath: configLocation, + }); + + const url = `http://localhost:${server.port}`; + console.log(); + console.log(chalk.green(`🚀 Config UI ready at ${url}`)); + console.log(); + + // Auto-open browser + if (options.open !== false) { + try { + const { default: open } = await import("open"); + await open(url); + } catch (err) { + // Ignore errors opening browser + } + } + + // Handle graceful shutdown + process.on("SIGINT", () => { + console.log(); + console.log(chalk.yellow("Shutting down server...")); + server.close(); + process.exit(0); + }); + + process.on("SIGTERM", () => { + server.close(); + process.exit(0); + }); + } catch (err) { + console.error(chalk.red("Failed to start server:")); + console.error(err); + process.exit(1); + } + }); + program.parse(); function parseEnvs(envPath?: string | undefined) { @@ -192,15 +261,16 @@ function parseEnvs(envPath?: string | undefined) { } } + // this should fail silently. + // if we can't resolve the right env vars, they will be logged as errors later const envRes = config({ path: actualEnvPath }); - if (envRes.error) { - console.log( - chalk.red( - `Could not resolve your environment variables.\n${envRes.error.message}\n`, - ), - ); - throw new Error("Could not resolve your environment variables."); - } + // if (envRes.error) { + // console.log( + // chalk.red( + // `Could not resolve your environment variables.\n${envRes.error.message}\n`, + // ), + // ); + // } } function getConfigPath(configPath?: string): string | null { diff --git a/packages/typegen/src/formatting.ts b/packages/typegen/src/formatting.ts index b74f9860..febb94cd 100644 --- a/packages/typegen/src/formatting.ts +++ b/packages/typegen/src/formatting.ts @@ -1,5 +1,5 @@ import { Project } from "ts-morph"; -import { format, getFileInfo } from "prettier"; +import * as prettier from "prettier"; /** * Formats all source files in a ts-morph Project using prettier and saves the changes. @@ -12,11 +12,13 @@ export async function formatAndSaveSourceFiles(project: Project) { // run each file through the prettier formatter for await (const file of files) { const filePath = file.getFilePath(); - const fileInfo = await getFileInfo(filePath); + const fileInfo = (await prettier.getFileInfo?.(filePath)) ?? { + ignored: false, + }; if (fileInfo.ignored) continue; - const formatted = await format(file.getFullText(), { + const formatted = await prettier.format(file.getFullText(), { filepath: filePath, }); file.replaceWithText(formatted); diff --git a/packages/typegen/src/server/api.ts b/packages/typegen/src/server/api.ts new file mode 100644 index 00000000..9fa6ed1a --- /dev/null +++ b/packages/typegen/src/server/api.ts @@ -0,0 +1,153 @@ +import { IncomingMessage } from "http"; +import { URL } from "url"; +import fs from "fs-extra"; +import path from "path"; +import { parse } from "jsonc-parser"; +import { typegenConfig } from "../types"; + +export interface ApiContext { + cwd: string; + configPath: string; +} + +export interface ApiResponse { + status: number; + headers: Record; + body: string; +} + +export async function handleApiRequest( + req: IncomingMessage, + url: URL, + context: ApiContext, +): Promise { + const pathname = url.pathname.replace("/api", ""); + + // GET /api/config + if (pathname === "/config" && req.method === "GET") { + return handleGetConfig(context); + } + + // POST /api/config + if (pathname === "/config" && req.method === "POST") { + return handlePostConfig(req, context); + } + + return { + status: 404, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ error: "Not found" }), + }; +} + +async function handleGetConfig(context: ApiContext): Promise { + const { configPath } = context; + const fullPath = path.resolve(context.cwd, configPath); + + const exists = fs.existsSync(fullPath); + + if (!exists) { + return { + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + exists: false, + path: configPath, + config: null, + }), + }; + } + + try { + const raw = fs.readFileSync(fullPath, "utf8"); + const parsed = parse(raw); + + return { + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + exists: true, + path: configPath, + config: parsed, + }), + }; + } catch (err) { + return { + status: 500, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + error: err instanceof Error ? err.message : "Failed to read config", + }), + }; + } +} + +async function handlePostConfig( + req: IncomingMessage, + context: ApiContext, +): Promise { + try { + const body = await readRequestBody(req); + const data = JSON.parse(body); + + // Handle both { config: ... } and direct config object + const configToValidate = data.config ?? data; + + // Validate with Zod + const validation = typegenConfig.safeParse({ config: configToValidate }); + + if (!validation.success) { + const issues = validation.error.issues.map((err) => ({ + path: err.path, + message: err.message, + })); + + return { + status: 400, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + success: false, + error: "Validation failed", + issues, + }), + }; + } + + // Write to disk as pretty JSON (replacing JSONC) + const fullPath = path.resolve(context.cwd, context.configPath); + const jsonContent = JSON.stringify(validation.data, null, 2) + "\n"; + + await fs.ensureDir(path.dirname(fullPath)); + await fs.writeFile(fullPath, jsonContent, "utf8"); + + return { + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ success: true }), + }; + } catch (err) { + return { + status: 500, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }), + }; + } +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + req.on("end", () => { + resolve(body); + }); + req.on("error", (err) => { + reject(err); + }); + }); +} diff --git a/packages/typegen/src/server/app.ts b/packages/typegen/src/server/app.ts new file mode 100644 index 00000000..184728c3 --- /dev/null +++ b/packages/typegen/src/server/app.ts @@ -0,0 +1,432 @@ +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { zValidator } from "@hono/zod-validator"; +import fs from "fs-extra"; +import path from "path"; +import { parse } from "jsonc-parser"; +import { typegenConfig, typegenConfigSingle } from "../types"; +import z from "zod/v4"; +import { type clientTypes, FileMakerError } from "@proofkit/fmdapi"; +import { + createDataApiClient, + createClientFromConfig, +} from "./createDataApiClient"; +import { ContentfulStatusCode } from "hono/utils/http-status"; +import { generateTypedClients } from "../typegen"; + +export interface ApiContext { + cwd: string; + configPath: string; +} + +export const devOnlyLogger = (message: string, ...rest: string[]) => { + if (process.env.NODE_ENV === "development") { + console.log(message, ...rest); + } +}; + +/** + * Flattens a nested layout/folder structure into a flat list with full paths + */ +function flattenLayouts( + layouts: clientTypes.LayoutOrFolder[], + parentPath: string = "", +): Array<{ name: string; path: string; table?: string }> { + const result: Array<{ name: string; path: string; table?: string }> = []; + + for (const item of layouts) { + if ("isFolder" in item && item.isFolder) { + // It's a folder - recursively process its contents + const folderPath = parentPath ? `${parentPath}/${item.name}` : item.name; + if (item.folderLayoutNames) { + result.push(...flattenLayouts(item.folderLayoutNames, folderPath)); + } + } else { + // It's a layout + const layoutPath = parentPath ? `${parentPath}/${item.name}` : item.name; + result.push({ + name: item.name, + path: layoutPath, + table: "table" in item ? item.table : undefined, + }); + } + } + + return result; +} + +export function createApiApp(context: ApiContext) { + // Define all routes with proper chaining for type inference + const app = new Hono() + .use(logger(devOnlyLogger)) + .basePath("/api") + + // GET /api/config + .get("/config", async (c) => { + const { configPath } = context; + const fullPath = path.resolve(context.cwd, configPath); + + const exists = fs.existsSync(fullPath); + + if (!exists) { + return c.json({ + exists: false, + path: configPath, + config: null, + }); + } + + try { + const raw = fs.readFileSync(fullPath, "utf8"); + const rawJson = parse(raw); + const parsed = typegenConfig.parse(rawJson); + + return c.json({ + exists: true, + path: configPath, + config: parsed.config, + }); + } catch (err) { + console.log("error from get config", err); + return c.json( + { + error: err instanceof Error ? err.message : "Failed to read config", + }, + 500, + ); + } + }) + // POST /api/config + .post( + "/config", + zValidator( + "json", + z.object({ + config: z.array(typegenConfigSingle), + }), + ), + async (c) => { + try { + const data = c.req.valid("json"); + + // Validate with Zod (data is already { config: [...] }) + const validation = typegenConfig.safeParse(data); + + if (!validation.success) { + const issues = validation.error.issues.map((err) => ({ + path: err.path, + message: err.message, + })); + + const response = z + .object({ + success: z.boolean(), + error: z.string().optional(), + issues: z + .array( + z.object({ + path: z.array(z.union([z.string(), z.number()])), + message: z.string(), + }), + ) + .optional(), + }) + .parse({ + success: false, + error: "Validation failed", + issues, + }); + return c.json(response, 400); + } + + // Write to disk as pretty JSON (replacing JSONC) + const fullPath = path.resolve(context.cwd, context.configPath); + const jsonContent = JSON.stringify(validation.data, null, 2) + "\n"; + + await fs.ensureDir(path.dirname(fullPath)); + await fs.writeFile(fullPath, jsonContent, "utf8"); + + const response = z + .object({ + success: z.boolean(), + error: z.string().optional(), + issues: z + .array( + z.object({ + path: z.array(z.union([z.string(), z.number()])), + message: z.string(), + }), + ) + .optional(), + }) + .parse({ success: true }); + return c.json(response); + } catch (err) { + const response = z + .object({ + success: z.boolean(), + error: z.string().optional(), + issues: z + .array( + z.object({ + path: z.array(z.union([z.string(), z.number()])), + message: z.string(), + }), + ) + .optional(), + }) + .parse({ + success: false, + error: err instanceof Error ? err.message : "Unknown error", + }); + return c.json(response, 500); + } + }, + ) + // POST /api/run (stub) + .post( + "/run", + zValidator( + "json", + z.object({ + config: z.union([z.array(typegenConfigSingle), typegenConfigSingle]), + }), + ), + async (c, next) => { + const data = c.req.valid("json"); + const config = data.config; + + await generateTypedClients(config); + await next(); + }, + ) + // GET /api/layouts + .get( + "/layouts", + zValidator("query", z.object({ configIndex: z.coerce.number() })), + async (c) => { + const input = c.req.valid("query"); + const configIndex = input.configIndex; + + const result = createDataApiClient(context, configIndex); + + // Check if result is an error + if ("error" in result) { + const statusCode = result.statusCode; + if (statusCode === 400) { + return c.json( + { + error: result.error, + ...(result.details || {}), + }, + 400, + ); + } else if (statusCode === 404) { + return c.json( + { + error: result.error, + ...(result.details || {}), + }, + 404, + ); + } else { + return c.json( + { + error: result.error, + ...(result.details || {}), + }, + 500, + ); + } + } + + const { client } = result; + + // Call layouts method - using type assertion as TypeScript has inference issues with DataApi return type + // The layouts method exists but TypeScript can't infer it from the complex return type + try { + const layoutsResp = (await (client as any).layouts()) as { + layouts: clientTypes.LayoutOrFolder[]; + }; + const { layouts } = layoutsResp; + + // Flatten the nested layout/folder structure into a flat list with full paths + const flatLayouts = flattenLayouts(layouts); + + return c.json({ layouts: flatLayouts }); + } catch (err) { + // Handle connection errors from layouts() call + let errorMessage = "Failed to fetch layouts"; + let statusCode = 500; + let suspectedField: "server" | "db" | "auth" | undefined; + let fmErrorCode: string | undefined; + + if (err instanceof FileMakerError) { + errorMessage = err.message; + fmErrorCode = err.code; + + // Infer suspected field from error code + if (err.code === "105") { + suspectedField = "db"; + errorMessage = `Database not found: ${err.message}`; + } else if (err.code === "212" || err.code === "952") { + suspectedField = "auth"; + errorMessage = `Authentication failed: ${err.message}`; + } + statusCode = 400; + } else if (err instanceof TypeError) { + errorMessage = `Connection error: ${err.message}`; + suspectedField = "server"; + statusCode = 400; + } else if (err instanceof Error) { + errorMessage = err.message; + statusCode = 500; + } + + return c.json( + { + error: errorMessage, + message: errorMessage, + suspectedField, + fmErrorCode, + }, + statusCode as ContentfulStatusCode, + ); + } + }, + ) + // GET /api/env-names + .get( + "/env-names", + zValidator("query", z.object({ envName: z.string() })), + async (c) => { + const input = c.req.valid("query"); + + const value = process.env[input.envName]; + + return c.json({ value }); + }, + ) + // POST /api/test-connection + .post( + "/test-connection", + zValidator("json", z.object({ config: typegenConfigSingle })), + async (c) => { + try { + const data = c.req.valid("json"); + const config = data.config; + + // Validate config type + if (config.type !== "fmdapi") { + return c.json( + { + ok: false, + error: "Only fmdapi config type is supported", + statusCode: 400, + kind: "unknown", + message: "Only fmdapi config type is supported", + }, + 400, + ); + } + + // Create client from config + const clientResult = createClientFromConfig(config); + + // Check if client creation failed + if ("error" in clientResult) { + return c.json( + { + ok: false, + ...clientResult, + }, + clientResult.statusCode as ContentfulStatusCode, + ); + } + + const { client, server, db, authType } = clientResult; + + // Test connection by calling layouts() + try { + const layoutsResp = (await (client as any).layouts()) as { + layouts: clientTypes.LayoutOrFolder[]; + }; + + return c.json({ + ok: true, + server, + db, + authType, + }); + } catch (err) { + // Handle connection errors + let errorMessage = "Failed to connect to FileMaker Data API"; + let statusCode = 500; + let kind: "connection_error" | "unknown" = "unknown"; + let suspectedField: "server" | "db" | "auth" | undefined; + let fmErrorCode: string | undefined; + + if (err instanceof FileMakerError) { + errorMessage = err.message; + fmErrorCode = err.code; + kind = "connection_error"; + + // Infer suspected field from error code + // Common FileMaker error codes: + // 105 = Database not found + // 212 = Authentication failed + // 802 = Record not found (less relevant here) + if (err.code === "105") { + suspectedField = "db"; + errorMessage = `Database not found: ${err.message}`; + } else if (err.code === "212" || err.code === "952") { + suspectedField = "auth"; + errorMessage = `Authentication failed: ${err.message}`; + } + statusCode = 400; + } else if (err instanceof TypeError) { + // Network/URL errors + errorMessage = `Connection error: ${err.message}`; + suspectedField = "server"; + kind = "connection_error"; + statusCode = 400; + } else if (err instanceof Error) { + errorMessage = err.message; + kind = "connection_error"; + statusCode = 500; + } + + return c.json( + { + ok: false, + error: errorMessage, + statusCode, + kind, + suspectedField, + fmErrorCode, + message: errorMessage, + }, + statusCode as ContentfulStatusCode, + ); + } + } catch (err) { + return c.json( + { + ok: false, + error: err instanceof Error ? err.message : "Unknown error", + statusCode: 500, + kind: "unknown", + message: err instanceof Error ? err.message : "Unknown error", + }, + 500, + ); + } + }, + ); + + return app; +} + +// Export the app type for use in the typed client +// With proper chaining, TypeScript can now infer all route types +export type ApiApp = ReturnType; diff --git a/packages/typegen/src/server/contract.ts b/packages/typegen/src/server/contract.ts new file mode 100644 index 00000000..3a2eef04 --- /dev/null +++ b/packages/typegen/src/server/contract.ts @@ -0,0 +1,75 @@ +import { z } from "zod/v4"; +import { typegenConfigSingle, typegenConfig } from "../types"; +import type { ApiApp } from "./app"; + +// Re-export config types for convenience +export type SingleConfig = z.infer; +export type ConfigsArray = z.infer[]; + +// GET /api/config response +export const getConfigResponseSchema = z.object({ + exists: z.boolean(), + path: z.string(), + config: z + .union([z.array(typegenConfigSingle), typegenConfigSingle]) + .nullable(), +}); +export type GetConfigResponse = z.infer; + +// POST /api/config request +export const postConfigRequestSchema = z.union([ + z.array(typegenConfigSingle), + typegenConfigSingle, +]); +export type PostConfigRequest = z.infer; + +// POST /api/config response +export const postConfigResponseSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), + issues: z + .array( + z.object({ + path: z.array(z.union([z.string(), z.number()])), + message: z.string(), + }), + ) + .optional(), +}); +export type PostConfigResponse = z.infer; + +// POST /api/run request (stub) +export const runTypegenRequestSchema = z.object({ + config: z + .union([z.array(typegenConfigSingle), typegenConfigSingle]) + .optional(), +}); +export type RunTypegenRequest = z.infer; + +// POST /api/run response (stub) +export const runTypegenResponseSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), + message: z.string().optional(), +}); +export type RunTypegenResponse = z.infer; + +// GET /api/layouts response (stub) +export const getLayoutsResponseSchema = z.object({ + layouts: z.array( + z.object({ + layoutName: z.string(), + schemaName: z.string().optional(), + }), + ), +}); +export type GetLayoutsResponse = z.infer; + +// GET /api/env-names response +export const getEnvNamesResponseSchema = z.object({ + value: z.string().optional(), +}); +export type GetEnvNamesResponse = z.infer; + +// Re-export ApiApp type for client usage +export type { ApiApp }; diff --git a/packages/typegen/src/server/createDataApiClient.ts b/packages/typegen/src/server/createDataApiClient.ts new file mode 100644 index 00000000..14267bd4 --- /dev/null +++ b/packages/typegen/src/server/createDataApiClient.ts @@ -0,0 +1,251 @@ +import fs from "fs-extra"; +import path from "path"; +import { parse } from "jsonc-parser"; +import { typegenConfig, typegenConfigSingle } from "../types"; +import type { z } from "zod/v4"; +import { OttoAdapter, type OttoAPIKey } from "@proofkit/fmdapi/adapters/otto"; +import DataApi from "@proofkit/fmdapi"; +import { FetchAdapter } from "@proofkit/fmdapi/adapters/fetch"; +import { memoryStore } from "@proofkit/fmdapi/tokenStore/memory"; +import { defaultEnvNames } from "../constants"; +import type { ApiContext } from "./app"; + +export interface CreateClientResult { + client: ReturnType; + config: Extract, { type: "fmdapi" }>; + server: string; + db: string; + authType: "apiKey" | "username"; +} + +export interface CreateClientError { + error: string; + statusCode: number; + details?: Record; + kind?: "missing_env" | "adapter_error" | "connection_error" | "unknown"; + suspectedField?: "server" | "db" | "auth"; + fmErrorCode?: string; + message?: string; +} + +type FmdapiConfig = Extract< + z.infer, + { type: "fmdapi" } +>; + +/** + * Creates a DataApi client from an in-memory config object + * @param config The fmdapi config object + * @returns The client, server, and db, or an error object + */ +export function createClientFromConfig( + config: FmdapiConfig, +): Omit | CreateClientError { + const { envNames } = config; + + // Helper to get env name, treating empty strings as undefined + const getEnvName = (customName: string | undefined, defaultName: string) => + customName && customName.trim() !== "" ? customName : defaultName; + + // Resolve environment variables + const server = + process.env[getEnvName(envNames?.server, defaultEnvNames.server)]; + const db = process.env[getEnvName(envNames?.db, defaultEnvNames.db)]; + const apiKey = + (envNames?.auth && "apiKey" in envNames.auth + ? process.env[getEnvName(envNames.auth.apiKey, defaultEnvNames.apiKey)] + : undefined) ?? process.env[defaultEnvNames.apiKey]; + const username = + (envNames?.auth && "username" in envNames.auth + ? process.env[ + getEnvName(envNames.auth.username, defaultEnvNames.username) + ] + : undefined) ?? process.env[defaultEnvNames.username]; + const password = + (envNames?.auth && "password" in envNames.auth + ? process.env[ + getEnvName(envNames.auth.password, defaultEnvNames.password) + ] + : undefined) ?? process.env[defaultEnvNames.password]; + + // Validate required env vars + if (!server || !db || (!apiKey && !username)) { + console.error("Missing required environment variables", { + server, + db, + apiKey, + username, + }); + + // Build missing details object + const missingDetails: { + server?: boolean; + db?: boolean; + auth?: boolean; + password?: boolean; + } = { + server: !server, + db: !db, + auth: !apiKey && !username, + }; + + // Only report password as missing if server and db are both present, + // and username is set but password is missing. This ensures we don't + // incorrectly report password as missing when the actual error is about + // missing server or database. + if (server && db && username && !password) { + missingDetails.password = true; + } + + return { + error: "Missing required environment variables", + statusCode: 400, + kind: "missing_env", + details: { + missing: missingDetails, + }, + suspectedField: !server + ? "server" + : !db + ? "db" + : !apiKey && !username + ? "auth" + : undefined, + message: !server + ? "Server URL environment variable is missing" + : !db + ? "Database name environment variable is missing" + : "Authentication credentials environment variable is missing", + }; + } + + // Validate password if username is provided + if (username && !password) { + return { + error: "Password is required when using username authentication", + statusCode: 400, + kind: "missing_env", + details: { + missing: { + password: true, + }, + }, + suspectedField: "auth", + message: "Password environment variable is missing", + }; + } + + // Determine which auth method will be used (prefer API key if available) + const authType: "apiKey" | "username" = apiKey ? "apiKey" : "username"; + + // Create auth object + const auth: { apiKey: OttoAPIKey } | { username: string; password: string } = + apiKey + ? { apiKey: apiKey as OttoAPIKey } + : { username: username ?? "", password: password ?? "" }; + + // Create DataApi client with error handling for adapter construction + let client: ReturnType; + try { + client = + "apiKey" in auth + ? DataApi({ + adapter: new OttoAdapter({ auth, server, db }), + layout: "", + }) + : DataApi({ + adapter: new FetchAdapter({ + auth: auth as any, + server, + db, + tokenStore: memoryStore(), + }), + layout: "", + }); + } catch (err) { + // Handle adapter construction errors (e.g., invalid API key format, empty username/password) + const errorMessage = + err instanceof Error ? err.message : "Failed to create adapter"; + return { + error: errorMessage, + statusCode: 400, + kind: "adapter_error", + suspectedField: "auth", + message: errorMessage, + }; + } + + return { + client, + server, + db, + authType, + }; +} + +/** + * Creates a DataApi client from a config index + * @param context The API context with cwd and configPath + * @param configIndex The index of the config to use + * @returns The client, config, server, and db, or an error object + */ +export function createDataApiClient( + context: ApiContext, + configIndex: number, +): CreateClientResult | CreateClientError { + // Read and parse config file + const fullPath = path.resolve(context.cwd, context.configPath); + + if (!fs.existsSync(fullPath)) { + return { + error: "Config file not found", + statusCode: 404, + }; + } + + let parsed; + try { + const raw = fs.readFileSync(fullPath, "utf8"); + const rawJson = parse(raw); + parsed = typegenConfig.parse(rawJson); + } catch (err) { + return { + error: err instanceof Error ? err.message : "Failed to parse config", + statusCode: 500, + }; + } + + // Get config at index + const configArray = Array.isArray(parsed.config) + ? parsed.config + : [parsed.config]; + const config = configArray[configIndex]; + + if (!config) { + return { + error: "Config not found", + statusCode: 404, + }; + } + + // Validate config type + if (config.type !== "fmdapi") { + return { + error: "Only fmdapi config type is supported", + statusCode: 400, + }; + } + + // Use the extracted helper function + const result = createClientFromConfig(config); + + // Check if result is an error + if ("error" in result) { + return result; + } + + return { + ...result, + config, + }; +} diff --git a/packages/typegen/src/server/index.ts b/packages/typegen/src/server/index.ts new file mode 100644 index 00000000..f41fd414 --- /dev/null +++ b/packages/typegen/src/server/index.ts @@ -0,0 +1,155 @@ +import { serve } from "@hono/node-server"; +import { readFileSync, existsSync } from "fs"; +import { join, dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import { createServer } from "net"; +import { Hono } from "hono"; +import { createApiApp } from "./app"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Resolve path to embedded web assets +// When compiled, this will be relative to dist/esm/server/index.js +// So we go up to dist/esm, then into dist/web +const WEB_DIR = resolve(__dirname, "../../web"); + +export interface ServerOptions { + port: number | null; + cwd: string; + configPath: string; +} + +export async function startServer(options: ServerOptions) { + const { port, cwd, configPath } = options; + + const app = new Hono(); + + // Mount API routes + const apiApp = createApiApp({ cwd, configPath }); + app.route("/", apiApp); + + // Serve static files (only for non-API routes) + app.get("*", async (c) => { + const url = new URL(c.req.url); + // Skip API routes + if (url.pathname.startsWith("/api/")) { + return c.notFound(); + } + + // Handle root path + // Remove leading slash from pathname to avoid path.join() ignoring WEB_DIR + const pathname = + url.pathname === "/" ? "index.html" : url.pathname.slice(1); + const filePath = join(WEB_DIR, pathname); + + try { + if (existsSync(filePath)) { + const content = readFileSync(filePath); + const ext = filePath.split(".").pop()?.toLowerCase(); + const contentType = getContentType(ext || ""); + return c.body(content, 200, { + "Content-Type": contentType, + }); + } + } catch (err) { + // Fall through to SPA fallback + } + + // SPA fallback - serve index.html for client-side routing + try { + const indexPath = join(WEB_DIR, "index.html"); + if (existsSync(indexPath)) { + const content = readFileSync(indexPath); + return c.html(content.toString()); + } + } catch (err) { + // If we can't even serve index.html, return 404 + } + + return c.text("Not found", 404); + }); + + // If port is null, try to find an available port starting from 3141 + // Try 3141 first, then 3142-3151 (next 10 ports) if needed + let actualPort: number; + if (port === null) { + actualPort = await findAvailablePort(3141, 11); + } else { + // If port is explicitly specified, use it as-is + actualPort = port; + } + + const server = serve({ + fetch: app.fetch, + port: actualPort, + }); + + // The serve function from @hono/node-server already starts listening + // We just need to return the server with a close method + return Promise.resolve({ + port: actualPort, + close: () => { + server.close(); + }, + }); +} + +async function findAvailablePort( + startPort: number, + maxAttempts: number, +): Promise { + for (let i = 0; i < maxAttempts; i++) { + const portToTry = startPort + i; + const isAvailable = await checkPortAvailable(portToTry); + if (isAvailable) { + return portToTry; + } + } + throw new Error( + `Could not find an available port in range ${startPort}-${startPort + maxAttempts - 1}`, + ); +} + +function checkPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + + server.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + resolve(false); + } else { + // For other errors, assume port is not available + resolve(false); + } + }); + + server.once("listening", () => { + server.close(); + resolve(true); + }); + + server.listen(port); + }); +} + +function getContentType(ext: string): string { + const types: Record = { + html: "text/html", + js: "application/javascript", + mjs: "application/javascript", + css: "text/css", + json: "application/json", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + svg: "image/svg+xml", + ico: "image/x-icon", + woff: "font/woff", + woff2: "font/woff2", + ttf: "font/ttf", + eot: "application/vnd.ms-fontobject", + }; + return types[ext] || "application/octet-stream"; +} diff --git a/packages/typegen/src/typegen.ts b/packages/typegen/src/typegen.ts index c86cb852..93bfc5bb 100644 --- a/packages/typegen/src/typegen.ts +++ b/packages/typegen/src/typegen.ts @@ -41,17 +41,23 @@ export const generateTypedClients = async ( return; } - if (Array.isArray(parsedConfig.data.config)) { - for (const option of parsedConfig.data.config) { + const configArray = Array.isArray(parsedConfig.data.config) + ? parsedConfig.data.config + : [parsedConfig.data.config]; + + for (const option of configArray) { + if (option.type === "fmdapi") { await generateTypedClientsSingle(option, options); + } else { + console.log( + chalk.yellow("WARNING: Unsupported config type: " + option.type), + ); } - } else { - await generateTypedClientsSingle(parsedConfig.data.config, options); } }; const generateTypedClientsSingle = async ( - config: z.infer, + config: Extract, { type: "fmdapi" }>, options?: { resetOverrides?: boolean; cwd?: string }, ) => { const { @@ -192,7 +198,8 @@ const generateTypedClientsSingle = async ( ? validator : "ts", strictNumbers: item.strictNumbers, - webviewerScriptName: config.webviewerScriptName, + webviewerScriptName: + config?.type === "fmdapi" ? config.webviewerScriptName : undefined, envNames: { auth: "apiKey" in auth diff --git a/packages/typegen/src/types.ts b/packages/typegen/src/types.ts index b3483752..c8e52a91 100644 --- a/packages/typegen/src/types.ts +++ b/packages/typegen/src/types.ts @@ -26,64 +26,116 @@ const layoutConfig = z.object({ const envNames = z .object({ - server: z.string(), - db: z.string(), + server: z + .string() + .optional() + .transform((val) => (val === "" ? undefined : val)), + db: z + .string() + .optional() + .transform((val) => (val === "" ? undefined : val)), auth: z.union([ z .object({ - apiKey: z.string(), + apiKey: z + .string() + .optional() + .transform((val) => (val === "" ? undefined : val)), }) - .partial(), + .optional() + .transform((val) => { + if (val && Object.values(val).every((v) => v === undefined)) { + return undefined; + } + return val ?? undefined; + }), z .object({ - username: z.string(), - password: z.string(), + username: z + .string() + .optional() + .transform((val) => (val === "" ? undefined : val)), + password: z + .string() + .optional() + .transform((val) => (val === "" ? undefined : val)), }) - .partial(), + .optional() + .transform((val) => { + if (val && Object.values(val).every((v) => v === undefined)) { + return undefined; + } + return val ?? undefined; + }), ]), }) - .partial() .optional() + .transform((val) => { + if (val && Object.values(val).every((v) => v === undefined)) { + return undefined; + } + return val ?? undefined; + }) .meta({ description: "If you're using other environment variables than the default, custom the NAMES of them here for the typegen to lookup their values when it runs.", }); -export const typegenConfigSingle = z.object({ - envNames, - layouts: z.array(layoutConfig), - path: z - .string() - .default("schema") - .optional() - .meta({ description: "The folder path to output the generated files" }), - clearOldFiles: z.boolean().default(false).optional().meta({ - description: - "If false, the path will not be cleared before the new files are written. Only the `client` and `generated` directories are cleared to allow for potential overrides to be kept.", - }), - validator: z - .union([z.enum(["zod", "zod/v4", "zod/v3"]), z.literal(false)]) - .default("zod/v4") - .optional() - .meta({ +const path = z + .string() + .default("schema") + .optional() + .meta({ description: "The folder path to output the generated files" }); + +const typegenConfigSingleBase = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("fmdapi"), + configName: z.string().optional(), + envNames, + layouts: z.array(layoutConfig).default([]), + path, + clearOldFiles: z.boolean().default(false).optional().meta({ description: - "If set to 'zod', 'zod/v4', or 'zod/v3', the validator will be generated using zod, otherwise it will generated Typescript types only and no runtime validation will be performed", + "If false, the path will not be cleared before the new files are written. Only the `client` and `generated` directories are cleared to allow for potential overrides to be kept.", + }), + validator: z + .union([z.enum(["zod", "zod/v4", "zod/v3"]), z.literal(false)]) + .default("zod/v4") + .optional() + .meta({ + description: + "If set to 'zod', 'zod/v4', or 'zod/v3', the validator will be generated using zod, otherwise it will generated Typescript types only and no runtime validation will be performed", + }), + clientSuffix: z.string().default("Layout").optional().meta({ + description: "The suffix to be added to the schema name for each layout", + }), + generateClient: z.boolean().default(true).optional().meta({ + description: + "If true, a layout-specific client will be generated for each layout provided, otherwise it will only generate the types. This option can be overridden for each layout individually.", + }), + webviewerScriptName: z.string().optional().meta({ + description: + "The name of the webviewer script to be used. If this key is set, the generated client will use the @proofkit/webviewer adapter instead of the OttoFMS or Fetch adapter, which will only work when loaded inside of a FileMaker webviewer.", }), - clientSuffix: z.string().default("Layout").optional().meta({ - description: "The suffix to be added to the schema name for each layout", - }), - generateClient: z.boolean().default(true).optional().meta({ - description: - "If true, a layout-specific client will be generated for each layout provided, otherwise it will only generate the types. This option can be overridden for each layout individually.", }), - webviewerScriptName: z.string().optional().meta({ - description: - "The name of the webviewer script to be used. If this key is set, the generated client will use the @proofkit/webviewer adapter instead of the OttoFMS or Fetch adapter, which will only work when loaded inside of a FileMaker webviewer.", + z.object({ + type: z.literal("fmodata"), + configName: z.string().optional(), + envNames, + path, }), -}); +]); + +// Add default "type" field for backwards compatibility +export const typegenConfigSingle = z.preprocess((data) => { + if (data && typeof data === "object" && !("type" in data)) { + return { ...data, type: "fmdapi" }; + } + return data; +}, typegenConfigSingleBase); export const typegenConfig = z.object({ - config: z.union([typegenConfigSingle, z.array(typegenConfigSingle)]), + config: z.union([z.array(typegenConfigSingle), typegenConfigSingle]), }); export type TypegenConfig = z.infer; diff --git a/packages/typegen/vite.config.ts b/packages/typegen/vite.config.ts index 32a35a9f..1730806f 100644 --- a/packages/typegen/vite.config.ts +++ b/packages/typegen/vite.config.ts @@ -8,7 +8,12 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ["./src/index.ts", "./src/cli.ts", "./src/types.ts"], + entry: [ + "./src/index.ts", + "./src/cli.ts", + "./src/types.ts", + "./src/server/contract.ts", + ], srcDir: "./src", cjs: false, outDir: "./dist", diff --git a/packages/typegen/web/components.json b/packages/typegen/web/components.json new file mode 100644 index 00000000..2d677db9 --- /dev/null +++ b/packages/typegen/web/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@reui": "https://reui.io/r/{name}.json" + } +} diff --git a/packages/typegen/web/index.html b/packages/typegen/web/index.html new file mode 100644 index 00000000..1d0cdf73 --- /dev/null +++ b/packages/typegen/web/index.html @@ -0,0 +1,35 @@ + + + + + + + Typegen Config Editor + + + +
+ + + diff --git a/packages/typegen/web/package.json b/packages/typegen/web/package.json new file mode 100644 index 00000000..1310843b --- /dev/null +++ b/packages/typegen/web/package.json @@ -0,0 +1,47 @@ +{ + "name": "@proofkit/typegen-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@fetchkit/ffetch": "^4.2.0", + "@headless-tree/core": "^1.6.0", + "@headless-tree/react": "^1.6.0", + "@hookform/resolvers": "^5.2.2", + "@proofkit/typegen": "workspace:*", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-direction": "^1.1.1", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", + "@remixicon/react": "^4.7.0", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.76.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "hono": "^4.9.0", + "lucide-react": "^0.511.0", + "radix-ui": "^1.4.3", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hook-form": "^7.68.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.11", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/node": "^22.17.1", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^4.3.4", + "tw-animate-css": "^1.3.6", + "typescript": "^5.9.3", + "vite": "^6.3.4" + } +} diff --git a/packages/typegen/web/src/App.css b/packages/typegen/web/src/App.css new file mode 100644 index 00000000..3b854ef8 --- /dev/null +++ b/packages/typegen/web/src/App.css @@ -0,0 +1,153 @@ +.app { + min-height: 100vh; + padding: 2rem; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +header { + margin-bottom: 2rem; +} + +header h1 { + font-size: 2rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.config-info { + display: flex; + align-items: center; + gap: 1rem; + font-size: 0.875rem; + color: #a1a1aa; +} + +.config-path { + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; +} + +.badge { + padding: 0.25rem 0.75rem; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 500; +} + +.badge.exists { + background: #16a34a; + color: white; +} + +.badge.missing { + background: #dc2626; + color: white; +} + +.editor-section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.editor-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.editor-header label { + font-weight: 500; + font-size: 0.875rem; + color: #d4d4d8; +} + +.save-button { + padding: 0.5rem 1.5rem; + background: #3b82f6; + color: white; + border: none; + border-radius: 0.375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.save-button:hover:not(:disabled) { + background: #2563eb; +} + +.save-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.json-editor { + width: 100%; + min-height: 500px; + padding: 1rem; + background: #18181b; + border: 1px solid #3f3f46; + border-radius: 0.5rem; + color: #e4e4e7; + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; + font-size: 0.875rem; + line-height: 1.6; + resize: vertical; + outline: none; +} + +.json-editor:focus { + border-color: #3b82f6; +} + +.error-message, +.validation-errors { + padding: 1rem; + background: #7f1d1d; + border: 1px solid #991b1b; + border-radius: 0.375rem; + color: #fca5a5; + font-size: 0.875rem; +} + +.validation-errors ul { + margin-top: 0.5rem; + margin-left: 1.5rem; +} + +.validation-errors code { + background: rgba(0, 0, 0, 0.3); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.8125rem; +} + +.loading, +.error { + text-align: center; + padding: 3rem; +} + +.error h2 { + margin-bottom: 1rem; + color: #ef4444; +} + +.error button { + margin-top: 1rem; + padding: 0.5rem 1.5rem; + background: #3b82f6; + color: white; + border: none; + border-radius: 0.375rem; + font-weight: 500; + cursor: pointer; +} + +.error button:hover { + background: #2563eb; +} diff --git a/packages/typegen/web/src/App.tsx b/packages/typegen/web/src/App.tsx new file mode 100644 index 00000000..7b32b90a --- /dev/null +++ b/packages/typegen/web/src/App.tsx @@ -0,0 +1,209 @@ +import { useEffect } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { client } from "./lib/api"; +import { ConfigEditor } from "./components/ConfigEditor"; +import "./App.css"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { z } from "zod/v4"; +import { Button } from "./components/ui/button"; +import { Loader2, PlayIcon } from "lucide-react"; +import { ConfigSummary } from "./components/ConfigSummary"; +import { type SingleConfig, configsArraySchema } from "./lib/config-utils"; +import { Form } from "./components/ui/form"; +import { useConfig } from "./hooks/useConfig"; + +// Normalize config to always be an array +function normalizeConfig( + config: SingleConfig | SingleConfig[] | null, +): SingleConfig[] { + if (Array.isArray(config)) { + return config; + } + if (config && typeof config === "object") { + return [config]; + } + return []; +} + +function App() { + // Load and save config using custom hook + const { + configDataResponse, + isError, + error, + refetch, + saveMutation, + isLoading, + isRetrying, + } = useConfig(); + + // Use React Hook Form to manage the configs array + const formSchema = z.object({ config: configsArraySchema }); + type FormData = z.infer; + const form = useForm({ + resolver: zodResolver(formSchema) as any, // Type assertion needed due to discriminated union + }); + + useEffect(() => { + console.log("configData from useEffect", configDataResponse); + if (configDataResponse) { + const configData = configDataResponse?.config; + const serverConfigs = normalizeConfig(configData); + form.reset({ config: serverConfigs }); + } + }, [configDataResponse]); + + const { fields } = useFieldArray({ + control: form.control, + name: "config", + }); + + // Get configs from form values for data access + const configs = form.watch("config"); + + // Run typegen mutation + const runTypegenMutation = useMutation({ + mutationFn: async () => { + await client.api.run.$post({ + json: { config: configs }, + }); + }, + }); + + const handleSaveAll = form.handleSubmit(async (data) => { + try { + await saveMutation.mutateAsync(data.config); + // Reset the form with the current form state to clear dirty state + // Use getValues() to get the current state, preserving any changes made during the save request + const currentConfigs = form.getValues("config"); + form.reset({ config: currentConfigs }); + } catch (err) { + // Error is handled by the mutation + console.error("Failed to save configs:", err); + } + }); + + const handleRunTypegen = async () => { + try { + // First save the config if there are changes + if (form.formState.isDirty) { + await handleSaveAll(); + } + // Then run typegen + await runTypegenMutation.mutateAsync(); + } catch (err) { + // Error is handled by the mutation + console.error("Failed to run typegen:", err); + } + }; + + if (isLoading) { + return ( +
+
+
+ {isRetrying ? "Waiting for API server..." : "Loading config..."} +
+
+
+ ); + } + + if (isError && !isRetrying) { + return ( +
+
+
+

Error

+

+ {error instanceof Error ? error.message : "Failed to load config"} +

+ +
+
+
+ ); + } + + return ( +
+
+
+
+

Typegen Config Editor

+ +
+ + +
+
+
+ +
+ + + {fields.map((field, index) => { + const config = configs[index]; + return ( + + + + + + + + + ); + })} + +
+ +
+
+ ); +} + +export default App; diff --git a/packages/typegen/web/src/components/ConfigEditor.tsx b/packages/typegen/web/src/components/ConfigEditor.tsx new file mode 100644 index 00000000..b03e6310 --- /dev/null +++ b/packages/typegen/web/src/components/ConfigEditor.tsx @@ -0,0 +1,246 @@ +import { useFormContext, useWatch } from "react-hook-form"; +import { useState, useEffect, useId } from "react"; +import { Input } from "./ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { SwitchField } from "./ui/switch-field"; +import { Switch, SwitchIndicator, SwitchWrapper } from "./ui/switch"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "./ui/form"; +import { EnvVarDialog } from "./EnvVarDialog"; +import { SingleConfig } from "../lib/config-utils"; +import { InfoTooltip } from "./InfoTooltip"; +import { LayoutEditor } from "./LayoutEditor"; + +interface ConfigEditorProps { + index: number; +} + +export function ConfigEditor({ index }: ConfigEditorProps) { + const { + control, + formState: { errors }, + setValue, + } = useFormContext<{ config: SingleConfig[] }>(); + + const configErrors = errors.config?.[index]; + const webviewerScriptName = useWatch({ + control, + name: `config.${index}.webviewerScriptName` as const, + }); + const [usingWebviewer, setUsingWebviewer] = useState(!!webviewerScriptName); + + useEffect(() => { + setUsingWebviewer(!!webviewerScriptName); + }, [webviewerScriptName]); + + const handleWebviewerToggle = (checked: boolean) => { + setUsingWebviewer(checked); + if (!checked) { + setValue(`config.${index}.webviewerScriptName` as const, ""); + } + }; + + return ( +
+ {configErrors?.root && ( +
+ Error: {configErrors.root.message} +
+ )} + +
+ {/* General Settings */} +
+
+
+

Config Settings

+

+ Settings apply to all layouts in this config. +

+
+ + +
+
+ {/* Path, Client Suffix, and Validator in one row */} +
+ ( + + + Path{" "} + + + + + + + + + )} + /> + + ( + + Client Suffix + + + + + + )} + /> + + ( + + Validator + + + + + + )} + /> +
+ + {/* Toggles in one row with fields expanding below */} +
+ {/* Generate Client */} +
+ { + const switchId = useId(); + return ( + + Generate + +
+ + + + Full Client + + + Types Only + + +
+
+ +
+ ); + }} + /> +
+ + {/* Clear Old Files */} +
+ ( + + + + + + + )} + /> +
+ + {/* Using a Webviewer */} +
+ + + {usingWebviewer && ( + ( + + Webviewer Script Name + + + + + + )} + /> + )} +
+
+
+
+ + {/* Layouts */} + +
+
+ ); +} diff --git a/packages/typegen/web/src/components/ConfigList.css b/packages/typegen/web/src/components/ConfigList.css new file mode 100644 index 00000000..0c23876a --- /dev/null +++ b/packages/typegen/web/src/components/ConfigList.css @@ -0,0 +1,81 @@ +.config-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.config-list-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.config-list-header h2 { + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +.add-button { + padding: 0.5rem 1.5rem; + background: #16a34a; + color: white; + border: none; + border-radius: 0.375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.add-button:hover { + background: #15803d; +} + +.config-list-items { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.config-list-item { + padding: 1rem; + background: #27272a; + border: 1px solid #3f3f46; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s; +} + +.config-list-item:hover { + background: #3f3f46; + border-color: #52525b; +} + +.config-list-item-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +.config-list-item-label { + font-weight: 500; + color: #e4e4e7; +} + +.config-list-item-arrow { + color: #a1a1aa; + font-size: 1.25rem; +} + +.config-list-empty { + padding: 2rem; + text-align: center; + color: #a1a1aa; +} + +.config-list-empty p { + margin: 0; +} + + + diff --git a/packages/typegen/web/src/components/ConfigList.tsx b/packages/typegen/web/src/components/ConfigList.tsx new file mode 100644 index 00000000..8602869c --- /dev/null +++ b/packages/typegen/web/src/components/ConfigList.tsx @@ -0,0 +1,66 @@ +import "./ConfigList.css"; + +interface ConfigListProps { + configs: unknown[]; + onSelectConfig: (index: number) => void; + onAddConfig: () => void; +} + +export function ConfigList({ + configs, + onSelectConfig, + onAddConfig, +}: ConfigListProps) { + const getConfigLabel = (config: unknown, index: number): string => { + if (typeof config === "object" && config !== null) { + const obj = config as Record; + // Try to find a meaningful label + if (obj.path && typeof obj.path === "string") { + return `Config ${index + 1} (${obj.path})`; + } + if (obj.layouts && Array.isArray(obj.layouts) && obj.layouts.length > 0) { + const firstLayout = obj.layouts[0] as Record; + if (firstLayout.layoutName && typeof firstLayout.layoutName === "string") { + return `Config ${index + 1} (${firstLayout.layoutName})`; + } + } + } + return `Config ${index + 1}`; + }; + + return ( +
+
+

Configurations

+ +
+
+ {configs.length === 0 ? ( +
+

No configurations found. Click "Add Config" to create one.

+
+ ) : ( + configs.map((config, index) => ( +
onSelectConfig(index)} + > +
+ + {getConfigLabel(config, index)} + + +
+
+ )) + )} +
+
+ ); +} + + + diff --git a/packages/typegen/web/src/components/ConfigSummary.tsx b/packages/typegen/web/src/components/ConfigSummary.tsx new file mode 100644 index 00000000..ace6da2c --- /dev/null +++ b/packages/typegen/web/src/components/ConfigSummary.tsx @@ -0,0 +1,32 @@ +import { type SingleConfig } from "../lib/config-utils"; +import { Badge } from "./ui/badge"; + +export function ConfigSummary({ config }: { config: SingleConfig }) { + return ( +
+ + {config?.type.toUpperCase()} + + {config.configName ? ( + <> +

{config.configName}

+

{config.path}

+ + ) : ( +

{config.path}

+ )} +
+ ); +} + +export default ConfigSummary; diff --git a/packages/typegen/web/src/components/EnvVarDialog.tsx b/packages/typegen/web/src/components/EnvVarDialog.tsx new file mode 100644 index 00000000..cacb32cd --- /dev/null +++ b/packages/typegen/web/src/components/EnvVarDialog.tsx @@ -0,0 +1,417 @@ +import { useEffect, useState } from "react"; +import { useWatch, useFormContext } from "react-hook-form"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "./ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { defaultEnvNames } from "../../../src/constants"; +import { EnvVarField } from "./EnvVarField"; +import { useEnvVarIndicator } from "./useEnvVarIndicator"; +import { useEnvValue } from "../lib/envValues"; +import { useTestConnection, setDialogOpen } from "../hooks/useTestConnection"; +import { Loader2, CheckCircle2, XCircle, AlertCircle } from "lucide-react"; + +interface EnvVarDialogProps { + index: number; +} + +export function EnvVarDialog({ index }: EnvVarDialogProps) { + const { control, setValue, getValues } = useFormContext<{ + config: any[]; + }>(); + const [authTypeSelector, setAuthTypeSelector] = useState< + "none" | "apiKey" | "username" + >("apiKey"); + const [dialogOpen, setDialogOpenState] = useState(false); + + // Track dialog open state to pause background tests + useEffect(() => { + setDialogOpen(index, dialogOpen); + return () => { + setDialogOpen(index, false); + }; + }, [index, dialogOpen]); + + // Watch the envNames.auth value for this config + const envNamesAuth = useWatch({ + control, + name: `config.${index}.envNames.auth` as const, + }); + + // Get indicator data + const { + hasCustomValues, + serverValue, + serverLoading, + dbValue, + dbLoading, + authEnvName: baseAuthEnvName, + } = useEnvVarIndicator(index); + + // Determine auth env name based on auth type selector + const authEnvName = + baseAuthEnvName || + (authTypeSelector === "apiKey" + ? defaultEnvNames.apiKey + : defaultEnvNames.username); + + const { data: authValue, isLoading: authLoading } = useEnvValue(authEnvName); + + // Test connection hook - disable automatic testing when dialog is open + // It will only run when the retry button is clicked + const { + status: testStatus, + data: testData, + error: testError, + errorDetails, + run: runTest, + } = useTestConnection(index, { enabled: false }); + + // Check if any values resolve to undefined/null/empty (only check after loading completes) + const hasUndefinedValues = + (!serverLoading && + (serverValue === undefined || + serverValue === null || + serverValue === "")) || + (!dbLoading && + (dbValue === undefined || dbValue === null || dbValue === "")) || + (!authLoading && + (authValue === undefined || authValue === null || authValue === "")); + + // Initialize auth type selector based on current form value + useEffect(() => { + let authSelector: "none" | "apiKey" | "username" = "apiKey"; + + if (envNamesAuth) { + if (typeof envNamesAuth === "object") { + // Check for username first (since it has two fields, it's more specific) + if ("username" in envNamesAuth || "password" in envNamesAuth) { + authSelector = "username"; + } else if ("apiKey" in envNamesAuth) { + authSelector = "apiKey"; + } + // If it's an empty object {}, don't change the selector or reset values + // This preserves the current state when the server returns {} + } + } else { + // Only initialize if auth is truly undefined/null + // Check current form value to avoid overwriting + const currentAuth = getValues(`config.${index}.envNames.auth` as any); + if (!currentAuth) { + setValue(`config.${index}.envNames.auth` as const, { + apiKey: "", + }); + } + } + + // Only update selector if it's different to avoid unnecessary re-renders + if (authSelector !== authTypeSelector) { + setAuthTypeSelector(authSelector); + } + }, [envNamesAuth, setValue, getValues, index, authTypeSelector]); + + return ( + +
+ + + + {(hasUndefinedValues || hasCustomValues) && ( + + {hasUndefinedValues ? "!" : "•"} + + )} +
+ + + Custom Environment Variable Names + +
+
+ + + + +
+
+ + +
+ +
+ {authTypeSelector === "apiKey" && ( +
+ +
+ )} + + {authTypeSelector === "username" && ( + <> +
+ +
+ +
+ +
+ + )} +
+
+
+ + {/* Test Connection Section */} +
+
+

Connection Status

+ +
+ + {/* Test Results - Show automatically when available */} + {testStatus !== "idle" && ( +
+ {testStatus === "pending" && ( +
+ + Testing connection... +
+ )} + + {testStatus === "success" && testData && ( +
+
+ + Connection OK +
+
+
+ Server:{" "} + {testData.server} +
+
+ Database:{" "} + {testData.db} +
+
+ Auth Type:{" "} + {testData.authType === "apiKey" + ? "API Key" + : "Username/Password"} +
+
+
+ )} + + {testStatus === "error" && ( +
+
+ + Connection Failed +
+ {errorDetails && ( +
+
+ {errorDetails.message || + errorDetails.error || + "Unknown error"} +
+ {errorDetails.details?.missing && ( +
+
+ Missing environment variables: +
+
    + {errorDetails.details.missing.server && ( +
  • + Server ( + {errorDetails.suspectedField === "server" && + "⚠️"} + ) +
  • + )} + {errorDetails.details.missing.db && ( +
  • + Database ( + {errorDetails.suspectedField === "db" && "⚠️"} + ) +
  • + )} + {errorDetails.details.missing.auth && ( +
  • + Authentication ( + {errorDetails.suspectedField === "auth" && + "⚠️"} + ) +
  • + )} + {errorDetails.details.missing.password && ( +
  • + Password ( + {errorDetails.suspectedField === "auth" && + "⚠️"} + ) +
  • + )} +
+
+ )} + {errorDetails.fmErrorCode && ( +
+ + FileMaker Error Code: + {" "} + {errorDetails.fmErrorCode} +
+ )} + {errorDetails.suspectedField && + !errorDetails.details?.missing && ( +
+ + + Suspected issue with:{" "} + {errorDetails.suspectedField === "server" + ? "Server URL" + : errorDetails.suspectedField === "db" + ? "Database name" + : "Credentials"} + +
+ )} +
+ )} + {testError && !errorDetails && ( +
{testError.message}
+ )} +
+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/packages/typegen/web/src/components/EnvVarField.tsx b/packages/typegen/web/src/components/EnvVarField.tsx new file mode 100644 index 00000000..881ca0b8 --- /dev/null +++ b/packages/typegen/web/src/components/EnvVarField.tsx @@ -0,0 +1,139 @@ +import { useState, useEffect, useRef, memo } from "react"; +import { useFormContext, useWatch, Path, PathValue } from "react-hook-form"; +import { z } from "zod"; +import { Eye, EyeOff } from "lucide-react"; +import { configSchema } from "../lib/schema"; +import { Input } from "./ui/input"; +import { Button } from "./ui/button"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "./ui/form"; +import { useEnvValue } from "../lib/envValues"; + +type FormData = z.infer; +type FormConfig = { config: FormData[] }; + +// Separate component for value display to prevent Input re-renders +const EnvValueDisplay = memo(function EnvValueDisplay({ + fieldName, + defaultValue, +}: { + fieldName: Path; + defaultValue: string; +}) { + const { control } = useFormContext(); + const [isVisible, setIsVisible] = useState(false); + const [debouncedEnvName, setDebouncedEnvName] = useState( + undefined, + ); + const timerRef = useRef(null); + + // Watch the env name value - but debounce updates to prevent re-renders + const envNameRaw = useWatch({ + control, + name: fieldName, + defaultValue: "", + }) as string | undefined; + + // Treat empty string as undefined to use default + const envName = + envNameRaw && envNameRaw.trim() !== "" ? envNameRaw : undefined; + + // Debounce the env name to prevent excessive re-renders and API calls + useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + setDebouncedEnvName(envName); + }, 300); // 300ms debounce + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [envName]); + + // Get the resolved value from the server (using debounced value) + const { data: envValue, isLoading } = useEnvValue( + debouncedEnvName ?? defaultValue, + ); + + if (!envName && !defaultValue) return null; + + return ( +
+ {isLoading ? ( + Loading... + ) : envValue ? ( + <> + + + Value:{" "} + + {isVisible ? envValue : "****"} + + + + ) : ( + Not set + )} +
+ ); +}); + +interface EnvVarFieldProps> { + fieldName: TFieldName extends Path + ? PathValue extends string | undefined + ? TFieldName + : never + : never; + label: string; + placeholder: string; + defaultValue: string; +} + +export function EnvVarField>({ + fieldName, + label, + placeholder, + defaultValue, +}: EnvVarFieldProps) { + const { control } = useFormContext(); + + return ( + ( + + {label} + + + + + + + )} + /> + ); +} diff --git a/packages/typegen/web/src/components/InfoTooltip.tsx b/packages/typegen/web/src/components/InfoTooltip.tsx new file mode 100644 index 00000000..89d96e5e --- /dev/null +++ b/packages/typegen/web/src/components/InfoTooltip.tsx @@ -0,0 +1,15 @@ +import { InfoIcon } from "lucide-react"; +import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; + +export function InfoTooltip({ label }: { label: string }) { + return ( + + + + + + {label} + + + ); +} diff --git a/packages/typegen/web/src/components/LayoutEditor.tsx b/packages/typegen/web/src/components/LayoutEditor.tsx new file mode 100644 index 00000000..041cf082 --- /dev/null +++ b/packages/typegen/web/src/components/LayoutEditor.tsx @@ -0,0 +1,85 @@ +import { useFieldArray, useFormContext } from "react-hook-form"; +import { Button } from "./ui/button"; +import { SingleConfig } from "../lib/config-utils"; +import { LayoutItemEditor } from "./LayoutItemEditor"; +import { Plus, AlertTriangle } from "lucide-react"; +import { useTestConnection } from "../hooks/useTestConnection"; + +interface LayoutEditorProps { + configIndex: number; +} + +export function LayoutEditor({ configIndex }: LayoutEditorProps) { + const { control } = useFormContext<{ config: SingleConfig[] }>(); + const { fields, append, remove } = useFieldArray({ + control, + name: `config.${configIndex}.layouts` as const, + }); + + // Check connection test status + const { status: testStatus, errorDetails } = useTestConnection(configIndex); + // Only show warning if connection test failed + const showWarning = testStatus === "error"; + + return ( +
+

Layouts

+ + {fields.length === 0 && ( +

+ No layouts configured. Click "Add Layout" to add one. +

+ )} + + {fields.map((field, fieldIndex) => ( + remove(fieldIndex)} + /> + ))} + +
+ {showWarning && ( +
+
+ +
+
+
Connection test failed
+ {errorDetails?.message && ( +
+ {errorDetails.message} +
+ )} +
+ Fix the connection issue in the "Configure Environment + Variables" dialog before adding layouts. +
+
+
+
+
+ )} + +
+ +
+
+
+ ); +} diff --git a/packages/typegen/web/src/components/LayoutItemEditor.tsx b/packages/typegen/web/src/components/LayoutItemEditor.tsx new file mode 100644 index 00000000..48be2f17 --- /dev/null +++ b/packages/typegen/web/src/components/LayoutItemEditor.tsx @@ -0,0 +1,206 @@ +import { useFormContext } from "react-hook-form"; +import { Button } from "./ui/button"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "./ui/form"; +import { Input } from "./ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; +import { SwitchField } from "./ui/switch-field"; +import { SingleConfig } from "../lib/config-utils"; +import { LayoutSelector } from "./LayoutSelector"; +import { InfoTooltip } from "./InfoTooltip"; +import { CircleMinus } from "lucide-react"; + +interface LayoutItemEditorProps { + configIndex: number; + layoutIndex: number; + onRemove: () => void; +} + +export function LayoutItemEditor({ + configIndex, + layoutIndex, + onRemove, +}: LayoutItemEditorProps) { + const { control, watch } = useFormContext<{ config: SingleConfig[] }>(); + const schemaName = watch( + `config.${configIndex}.layouts.${layoutIndex}.schemaName`, + ); + const layoutName = watch( + `config.${configIndex}.layouts.${layoutIndex}.layoutName`, + ); + + return ( +
+
+
+

+ {schemaName || `Layout ${layoutIndex + 1}`} +

+
+ {layoutName ? ( + layoutName + ) : ( + No layout selected + )} +
+
+ +
+ +
+
+ + + ( + + + Schema Name{" "} + + + + + + + + )} + /> +
+ +
+ ( + + Value Lists + + + + + + )} + /> + + ( + + + + + + + )} + /> + + { + const isDefault = field.value === undefined; + return ( + + Generate + + + + + + ); + }} + /> +
+
+
+ ); +} diff --git a/packages/typegen/web/src/components/LayoutSelector.tsx b/packages/typegen/web/src/components/LayoutSelector.tsx new file mode 100644 index 00000000..947a3d01 --- /dev/null +++ b/packages/typegen/web/src/components/LayoutSelector.tsx @@ -0,0 +1,243 @@ +import * as React from "react"; +import { client } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import { Path, useFormContext } from "react-hook-form"; +import { cn } from "@/lib/utils"; +import { Button, ButtonArrow } from "@/components/ui/button"; +import { + Command, + CommandCheck, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "./ui/form"; +import { SingleConfig } from "@/lib/config-utils"; +import { InfoTooltip } from "./InfoTooltip"; + +type FormData = { config: SingleConfig[] }; + +export function LayoutSelector({ + configIndex, + path, +}: { + configIndex: number; + path: Path; +}) { + const { control, setValue, getValues } = useFormContext(); + const [open, setOpen] = React.useState(false); + + const { + data: layouts, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["layouts", configIndex], + queryFn: async () => { + const res = await client.api.layouts.$get({ + query: { configIndex: configIndex.toString() }, + }); + + const data = await res.json(); + if (!res.ok || "error" in data) { + // Parse error JSON to get detailed error information + const errorMessage = + "error" in data ? data.error : "Failed to fetch layouts"; + throw new Error(errorMessage); + } + return data.layouts; + }, + }); + + // Extract error details from the error object + const errorDetails = error && (error as any).details; + + // Transform layouts array into combobox format + const layoutOptions = React.useMemo(() => { + if (!layouts) return []; + return layouts.map((layout) => ({ + value: layout.name, + label: layout.name, + })); + }, [layouts]); + + return ( + ( + + + Layout Name{" "} + + + + + + + + + + + + {isLoading ? ( +
+ Loading layouts... +
+ ) : isError ? ( +
+
+ {error instanceof Error + ? error.message + : "Failed to load layouts"} +
+ {errorDetails && ( +
+ {errorDetails.missing && ( +
+
+ Missing environment variables: +
+
    + {errorDetails.missing.server && ( +
  • + Server + {errorDetails.suspectedField === + "server" && " ⚠️"} +
  • + )} + {errorDetails.missing.db && ( +
  • + Database + {errorDetails.suspectedField === "db" && + " ⚠️"} +
  • + )} + {errorDetails.missing.auth && ( +
  • + Authentication + {errorDetails.suspectedField === "auth" && + " ⚠️"} +
  • + )} + {errorDetails.missing.password && ( +
  • + Password + {errorDetails.suspectedField === "auth" && + " ⚠️"} +
  • + )} +
+
+ )} + {errorDetails.fmErrorCode && ( +
+ + FileMaker Error Code: + {" "} + {errorDetails.fmErrorCode} +
+ )} + {errorDetails.suspectedField && + !errorDetails.missing && ( +
+ Suspected issue with:{" "} + {errorDetails.suspectedField === "server" + ? "Server URL" + : errorDetails.suspectedField === "db" + ? "Database name" + : "Credentials"} +
+ )} +
+ )} +
+ Check your connection settings in "Configure + Environment Variables" +
+
+ ) : ( + <> + No layout found. + + {layoutOptions.map((layout) => ( + { + const newValue = + currentValue === field.value + ? "" + : currentValue; + field.onChange(newValue); + + // If schema name is undefined or empty, set it to the layout name + if (newValue) { + const schemaNamePath = path.replace( + ".layoutName", + ".schemaName", + ) as Path; + const currentSchemaName = + getValues(schemaNamePath); + if ( + currentSchemaName === undefined || + currentSchemaName === "" + ) { + setValue(schemaNamePath, newValue); + } + } + + setOpen(false); + }} + > + {layout.label} + {field.value === layout.value && } + + ))} + + + )} +
+
+
+
+
+ +
+ )} + /> + ); +} + +export default LayoutSelector; diff --git a/packages/typegen/web/src/components/ServerEnvField.tsx b/packages/typegen/web/src/components/ServerEnvField.tsx new file mode 100644 index 00000000..cf3cf39e --- /dev/null +++ b/packages/typegen/web/src/components/ServerEnvField.tsx @@ -0,0 +1,70 @@ +import { useFormContext, useWatch, Path } from "react-hook-form"; +import { z } from "zod"; +import { configSchema } from "../lib/schema"; +import { Input } from "./ui/input"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "./ui/form"; +import { useEnvValue } from "../lib/envValues"; + +type FormData = z.infer; + +interface EnvVarFieldProps { + index: number; + fieldName: Path<{ config: FormData[] }>; + label: string; + placeholder: string; + defaultValue: string; + type?: "text" | "password"; +} + +export function EnvVarField({ + index, + fieldName, + label, + placeholder, + defaultValue, + type = "text", +}: EnvVarFieldProps) { + const { control } = useFormContext<{ config: FormData[] }>(); + + // Watch the env name value to get the resolved env var + const envName = useWatch({ + control, + name: fieldName, + }); + + // Get the resolved value from the server + const { data: envValue, isLoading } = useEnvValue(envName || defaultValue); + + return ( + ( + + {label} + + + + {(envName || defaultValue) && ( +
+ {isLoading ? ( + Loading... + ) : envValue ? ( + Resolved: {envValue} + ) : ( + Not set + )} +
+ )} + +
+ )} + /> + ); +} diff --git a/packages/typegen/web/src/components/badge/circle.tsx b/packages/typegen/web/src/components/badge/circle.tsx new file mode 100644 index 00000000..3da418fa --- /dev/null +++ b/packages/typegen/web/src/components/badge/circle.tsx @@ -0,0 +1,59 @@ +import { Badge } from '@/components/ui/badge'; + +export default function Component() { + return ( +
+
+ + Primary + + + Success + + + Warning + + + Info + + + Destructive + +
+
+ + Primary + + + Success + + + Warning + + + Info + + + Destructive + +
+
+ + Primary + + + Success + + + Warning + + + Info + + + Destructive + +
+
+ ); +} diff --git a/packages/typegen/web/src/components/button/loading.tsx b/packages/typegen/web/src/components/button/loading.tsx new file mode 100644 index 00000000..685eb47c --- /dev/null +++ b/packages/typegen/web/src/components/button/loading.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { LoaderCircleIcon } from 'lucide-react'; + +export default function ButtonDemo() { + const [isDisabled, setIsDisabled] = useState(false); + + useEffect(() => { + // Automatically toggle button state every 4 seconds + const interval = setInterval(() => { + setIsDisabled((prev) => !prev); + }, 1000); + + // Cleanup interval on component unmount + return () => clearInterval(interval); + }, []); + + return ( +
+ +
+ ); +} diff --git a/packages/typegen/web/src/components/combobox/default.tsx b/packages/typegen/web/src/components/combobox/default.tsx new file mode 100644 index 00000000..d909f881 --- /dev/null +++ b/packages/typegen/web/src/components/combobox/default.tsx @@ -0,0 +1,90 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { Button, ButtonArrow } from '@/components/ui/button'; +import { + Command, + CommandCheck, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +const topCities = [ + { + value: 'amsterdam', + label: 'Amsterdam, Netherlands', + }, + { + value: 'london', + label: 'London, UK', + }, + { + value: 'paris', + label: 'Paris, France', + }, + { + value: 'tokyo', + label: 'Tokyo, Japan', + }, + { + value: 'new_york', + label: 'New York, USA', + }, + { + value: 'dubai', + label: 'Dubai, UAE', + }, +]; + +export default function ComboboxDemo() { + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(''); + + return ( + + + + + + + + + No city found. + + {topCities.map((city) => ( + { + setValue(currentValue === value ? '' : currentValue); + setOpen(false); + }} + > + {city.label} + {value === city.value && } + + ))} + + + + + + ); +} diff --git a/packages/typegen/web/src/components/dialog/default.tsx b/packages/typegen/web/src/components/dialog/default.tsx new file mode 100644 index 00000000..a96f5240 --- /dev/null +++ b/packages/typegen/web/src/components/dialog/default.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import { Alert, AlertIcon, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/components/ui/form'; +import { Textarea } from '@/components/ui/textarea'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useDirection } from '@radix-ui/react-direction'; +import { RiCheckboxCircleFill } from '@remixicon/react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +export default function DialogDemo() { + const [open, setOpen] = useState(false); + const direction = useDirection(); + + const FormSchema = z.object({ + feedback: z.string().min(1, 'Feedback is required').max(200, 'Feedback cannot exceed 200 characters'), + }); + + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { feedback: '' }, + mode: 'onSubmit', + }); + + function onSubmit() { + toast.custom((t) => ( + toast.dismiss(t)}> + + + + Your feedback successfully submitted + + )); + + form.reset(); + setOpen(false); + } + + return ( + + + + + +
+ + + Suggest Idea + Describe your suggestion. + + + ( + + +