├── packages ├── .gitkeep ├── trpc-ui │ ├── .gitignore │ ├── src │ │ ├── react-app │ │ │ ├── index.css │ │ │ ├── components │ │ │ │ ├── form │ │ │ │ │ ├── fields │ │ │ │ │ │ ├── LiteralField.tsx │ │ │ │ │ │ ├── FieldError.tsx │ │ │ │ │ │ ├── EnumField.tsx │ │ │ │ │ │ ├── UnionField.tsx │ │ │ │ │ │ ├── TextField.tsx │ │ │ │ │ │ ├── BooleanField.tsx │ │ │ │ │ │ ├── NumberField.tsx │ │ │ │ │ │ ├── base │ │ │ │ │ │ │ ├── BaseSelectField.tsx │ │ │ │ │ │ │ ├── BaseCheckboxField.tsx │ │ │ │ │ │ │ └── BaseTextField.tsx │ │ │ │ │ │ ├── DiscriminatedUnionField.tsx │ │ │ │ │ │ ├── ObjectField.tsx │ │ │ │ │ │ └── ArrayField.tsx │ │ │ │ │ ├── FormLabel.tsx │ │ │ │ │ ├── ProcedureForm │ │ │ │ │ │ ├── ErrorRow.tsx │ │ │ │ │ │ ├── FormSectionHeader.tsx │ │ │ │ │ │ ├── ProcedureFormContext.tsx │ │ │ │ │ │ ├── Response.tsx │ │ │ │ │ │ ├── FormSection.tsx │ │ │ │ │ │ ├── LoadingSpinner.tsx │ │ │ │ │ │ ├── StackTrace.tsx │ │ │ │ │ │ ├── ProcedureFormButton.tsx │ │ │ │ │ │ ├── Error.tsx │ │ │ │ │ │ └── DescriptionSection.tsx │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── Field.tsx │ │ │ │ ├── hooks │ │ │ │ │ ├── useIsMac.tsx │ │ │ │ │ ├── useAsyncDuration.tsx │ │ │ │ │ ├── useLocalStorage.tsx │ │ │ │ │ └── useHotKey.tsx │ │ │ │ ├── ItemTypeIcon.tsx │ │ │ │ ├── AddItemButton.tsx │ │ │ │ ├── MetaHeader.tsx │ │ │ │ ├── InputGroupContainer.tsx │ │ │ │ ├── Button.tsx │ │ │ │ ├── Chevron.tsx │ │ │ │ ├── icons │ │ │ │ │ ├── AutoFillIcon.tsx │ │ │ │ │ ├── ToggleJsonIcon.tsx │ │ │ │ │ ├── CloseIcon.tsx │ │ │ │ │ ├── ChevronIcon.tsx │ │ │ │ │ └── SendIcon.tsx │ │ │ │ ├── contexts │ │ │ │ │ ├── SearchStore.tsx │ │ │ │ │ ├── OptionsContext.tsx │ │ │ │ │ ├── AllPathsContext.tsx │ │ │ │ │ ├── HotKeysContext.tsx │ │ │ │ │ ├── HeadersContext.tsx │ │ │ │ │ └── SiteNavigationContext.tsx │ │ │ │ ├── RouterContainer.tsx │ │ │ │ ├── style-utils.ts │ │ │ │ ├── TopBar.tsx │ │ │ │ ├── SideNav.tsx │ │ │ │ ├── CollapsableSection.tsx │ │ │ │ └── HeadersPopup.tsx │ │ │ ├── utils │ │ │ │ └── utils.ts │ │ │ ├── trpc.ts │ │ │ ├── index.html │ │ │ ├── index.tsx │ │ │ └── Root.tsx │ │ ├── index.ts │ │ ├── parse │ │ │ ├── input-mappers │ │ │ │ ├── zod │ │ │ │ │ ├── zod-types.ts │ │ │ │ │ ├── parsers │ │ │ │ │ │ ├── parseZodVoidDef.ts │ │ │ │ │ │ ├── parseZodBooleanFieldDef.ts │ │ │ │ │ │ ├── parseZodNumberDef.ts │ │ │ │ │ │ ├── parseZodStringDef.ts │ │ │ │ │ │ ├── parseZodEffectsDef.tsx │ │ │ │ │ │ ├── parseZodPromiseDef.tsx │ │ │ │ │ │ ├── parseZodDefaultDef.tsx │ │ │ │ │ │ ├── parseZodBigIntDef.tsx │ │ │ │ │ │ ├── parseZodNullableDef.tsx │ │ │ │ │ │ ├── parseZodEnumDef.ts │ │ │ │ │ │ ├── parseZodLiteralDef.tsx │ │ │ │ │ │ ├── parseZodBrandedDef.tsx │ │ │ │ │ │ ├── parseZodNullDef.tsx │ │ │ │ │ │ ├── parseZodNativeEnumDef.ts │ │ │ │ │ │ ├── parseZodOptionalDef.tsx │ │ │ │ │ │ ├── parseZodUndefinedDef.tsx │ │ │ │ │ │ ├── parseZodArrayDef.tsx │ │ │ │ │ │ ├── parseZodUnionDef.ts │ │ │ │ │ │ ├── parseZodObjectDef.ts │ │ │ │ │ │ └── parseZodDiscriminatedUnionDef.ts │ │ │ │ │ └── selector.ts │ │ │ │ ├── defaultReferences.ts │ │ │ │ └── __tests__ │ │ │ │ │ └── zod │ │ │ │ │ ├── bigint.test.ts │ │ │ │ │ ├── number.test.ts │ │ │ │ │ ├── boolean.test.ts │ │ │ │ │ ├── string.test.ts │ │ │ │ │ ├── nullable.test.ts │ │ │ │ │ ├── null.test.ts │ │ │ │ │ ├── promise.test.ts │ │ │ │ │ ├── default.test.ts │ │ │ │ │ ├── effects.test.ts │ │ │ │ │ ├── enum.test.ts │ │ │ │ │ ├── undefined.test.ts │ │ │ │ │ ├── nativeEnum.test.ts │ │ │ │ │ ├── union.test.ts │ │ │ │ │ ├── void.test.ts │ │ │ │ │ ├── branded.test.ts │ │ │ │ │ ├── optional.test.ts │ │ │ │ │ ├── array.test.ts │ │ │ │ │ ├── literal.test.ts │ │ │ │ │ ├── discriminatedUnion.test.ts │ │ │ │ │ └── object.test.ts │ │ │ ├── parseErrorLogs.ts │ │ │ ├── utils.ts │ │ │ ├── __tests__ │ │ │ │ ├── utils │ │ │ │ │ ├── schemas.ts │ │ │ │ │ └── router.ts │ │ │ │ ├── parseRouter.test.ts │ │ │ │ └── parseProcedure.test.ts │ │ │ ├── parseRouter.ts │ │ │ ├── parseNodeTypes.ts │ │ │ ├── routerType.ts │ │ │ └── parseProcedure.ts │ │ ├── meta.ts │ │ └── render.ts │ ├── postcss.config.cjs │ ├── tsconfig.build.json │ ├── tsconfig.buildPanel.json │ ├── tsconfig.buildReactApp.json │ ├── jest.config.cjs │ ├── tsconfig.json │ ├── tailwind.config.cjs │ ├── rollup.config.js │ └── package.json ├── test-app │ ├── .env.example │ ├── README.md │ ├── tsconfig.json │ ├── package.json │ └── src │ │ └── server.ts └── dev-app │ ├── public │ └── favicon.ico │ ├── postcss.config.js │ ├── .env.example │ ├── src │ ├── pages │ │ ├── index.module.css │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── api │ │ │ └── trpc │ │ │ │ └── [trpc].ts │ │ └── index.tsx │ ├── styles │ │ └── globals.css │ ├── utils │ │ └── api.ts │ ├── env.mjs │ └── server │ │ └── api │ │ └── trpc.ts │ ├── README.md │ ├── next.config.mjs │ ├── .gitignore │ ├── tsconfig.json │ ├── package.json │ └── tailwind.config.js ├── .node-version ├── README.md ├── pnpm-workspace.yaml ├── tsconfig.json ├── rm-modules.sh ├── .github ├── workflows │ ├── biome-check.yml │ ├── build-test.yml │ ├── pr-package.yml │ └── npm.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .gitignore ├── package.json ├── biome.jsonc └── CONTRIBUTING.md /packages/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.5.1 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/trpc-ui/README.md -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" -------------------------------------------------------------------------------- /packages/trpc-ui/.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | /node_modules 3 | /lib -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/test-app/.env.example: -------------------------------------------------------------------------------- 1 | PORT=4000 2 | SERVER_URL=http://localhost 3 | NODE_ENV=development -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /rm-modules.sh: -------------------------------------------------------------------------------- 1 | rm -rf node_modules packages/trpc-ui/node_modules packages/test-app/node_modules packages/dev-app/node_modules -------------------------------------------------------------------------------- /packages/dev-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aidansunbury/trpc-ui/HEAD/packages/dev-app/public/favicon.ico -------------------------------------------------------------------------------- /packages/dev-app/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/trpc-ui/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/trpc-ui/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["src/**/*.test.ts", "__tests__/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/dev-app/.env.example: -------------------------------------------------------------------------------- 1 | # Set this value to "false" to test trpc-ui without the superjson data transformer 2 | NEXT_PUBLIC_SUPERJSON="true" -------------------------------------------------------------------------------- /packages/trpc-ui/tsconfig.buildPanel.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "exclude": [ 4 | "src/react-app", 5 | ], 6 | } -------------------------------------------------------------------------------- /packages/trpc-ui/src/index.ts: -------------------------------------------------------------------------------- 1 | export { renderTrpcPanel } from "./render"; 2 | export type { TRPCPanelMeta } from "./meta"; 3 | export { parseRouterWithOptions } from "./parse/parseRouter"; 4 | -------------------------------------------------------------------------------- /packages/dev-app/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | min-height: 100vh; 7 | } 8 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/components/form/fields/LiteralField.tsx: -------------------------------------------------------------------------------- 1 | export function LiteralField() { 2 | // Not sure there's any real reason to render anything here 3 | return null; 4 | } 5 | -------------------------------------------------------------------------------- /packages/trpc-ui/tsconfig.buildReactApp.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "declaration": false 5 | }, 6 | "include": ["src/react-app"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/parse/input-mappers/zod/zod-types.ts: -------------------------------------------------------------------------------- 1 | import type { ZodFirstPartyTypeKind, ZodTypeDef } from "zod"; 2 | 3 | export type ZodDefWithType = ZodTypeDef & { typeName: ZodFirstPartyTypeKind }; 4 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/parse/parseErrorLogs.ts: -------------------------------------------------------------------------------- 1 | export function logParseError(procedurePath: string, error: string) { 2 | console.warn( 3 | `trpc-panel: Failed to parse procedure ${procedurePath}, ${error}`, 4 | ); 5 | } 6 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/meta.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const TRPCPanelMetaSchema = z.object({ 4 | description: z.string().optional(), 5 | }); 6 | 7 | export type TRPCPanelMeta = z.infer; 8 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/components/form/FormLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function FormLabel({ children }: { children: string }) { 4 | return {children}; 5 | } 6 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/components/form/fields/FieldError.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function FieldError({ errorMessage }: { errorMessage: string }) { 4 | return {errorMessage}; 5 | } 6 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from "@trpc/react-query"; 2 | import type { AnyTRPCRouter } from "@trpc/server"; 3 | // TODO add type safety here 4 | // don't try this at home 5 | export const trpc = createTRPCReact(); 6 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/components/hooks/useIsMac.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | export function useIsMac() { 4 | return useMemo(() => { 5 | if (typeof window === "undefined") return false; 6 | return navigator.userAgent.indexOf("Mac") !== -1; 7 | }, []); 8 | } 9 | -------------------------------------------------------------------------------- /packages/dev-app/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppType } from "next/app"; 2 | 3 | import { api } from "~/utils/api"; 4 | 5 | import "~/styles/globals.css"; 6 | 7 | const MyApp: AppType = ({ Component, pageProps }) => { 8 | return ; 9 | }; 10 | 11 | export default api.withTRPC(MyApp); 12 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/parse/input-mappers/defaultReferences.ts: -------------------------------------------------------------------------------- 1 | import type { ParseReferences } from "@src/parse/parseNodeTypes"; 2 | 3 | export function defaultReferences(): ParseReferences { 4 | return { 5 | path: [], 6 | options: {}, 7 | addDataFunctions: { 8 | addDescriptionIfExists: () => {}, 9 | }, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/parse/utils.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ParseReferences, 3 | SharedInputNodeProperties, 4 | } from "@src/parse/parseNodeTypes"; 5 | 6 | export function nodePropertiesFromRef( 7 | references: ParseReferences, 8 | ): SharedInputNodeProperties { 9 | return { 10 | path: references.path, 11 | ...(references.optional && { optional: true }), 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/components/form/ProcedureForm/ErrorRow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function ErrorRow({ 4 | title, 5 | text, 6 | padTitleTo, 7 | }: { 8 | title: string; 9 | text: string; 10 | padTitleTo: number; 11 | }) { 12 | return ( 13 | 14 | {title.padEnd(padTitleTo, " ")}: 15 | {text} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/components/form/ProcedureForm/FormSectionHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function FormSectionHeader({ 4 | children, 5 | className, 6 | }: { 7 | children: string; 8 | className?: string; 9 | }) { 10 | return ( 11 |

12 | {children} 13 |

14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/parse/input-mappers/zod/parsers/parseZodVoidDef.ts: -------------------------------------------------------------------------------- 1 | import type { LiteralNode, ParseReferences } from "@src/parse/parseNodeTypes"; 2 | import type { ZodVoidDef } from "zod"; 3 | 4 | export function parseZodVoidDef( 5 | _: ZodVoidDef, 6 | refs: ParseReferences, 7 | ): LiteralNode { 8 | return { 9 | type: "literal", 10 | value: undefined, 11 | path: refs.path, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /packages/test-app/README.md: -------------------------------------------------------------------------------- 1 | # tRPC (panel) test app 2 | 3 | This app is used as the example app for tRPC (panel) as well as is convenient for use while testing in development. Live at [trpc.aidansunbury.dev](https://trpc.aidansunbury.dev/) 4 | 5 | ## Run 6 | 7 | ```sh 8 | pnpm dev 9 | ``` 10 | 11 | If you want to change any of the environment variables for local development 12 | 13 | ```sh 14 | cp .env.example .env 15 | ``` 16 | -------------------------------------------------------------------------------- /packages/dev-app/README.md: -------------------------------------------------------------------------------- 1 | # Dev App 2 | 3 | This app is for development of `trpc-panel`. See our [contributing](../../CONTRIBUTING.md) guide for more information. 4 | 5 | ## Toggling Superjson 6 | There are several features of `trpc-ui` which only work with superjson enabled, meaning it is important be able to quickly test the package with and without superjson. Superjson is enabled by default in the dev app, but can be disabled by setting an environment variable `SUPERJSON=false` -------------------------------------------------------------------------------- /packages/dev-app/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | // pages/_document 2 | import { Head, Html, Main, NextScript } from "next/document"; 3 | 4 | export default function Document() { 5 | return ( 6 | 7 | 8 | {/* React scan */} 9 | `; 92 | const css = ``; 93 | const htmlReplaceParams: InjectionParam[] = [ 94 | { 95 | searchFor: javascriptReplaceSymbol, 96 | injectString: script, 97 | }, 98 | { 99 | searchFor: cssReplaceSymbol, 100 | injectString: css, 101 | }, 102 | ]; 103 | cache.val = injectParams(indexHtml, htmlReplaceParams); 104 | return cache.val; 105 | } 106 | -------------------------------------------------------------------------------- /packages/dev-app/src/server/api/trpc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: 3 | * 1. You want to modify request context (see Part 1). 4 | * 2. You want to create a new middleware or type of procedure (see Part 3). 5 | * 6 | * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will 7 | * need to use are documented accordingly near the end. 8 | */ 9 | 10 | /** 11 | * 1. CONTEXT 12 | * 13 | * This section defines the "contexts" that are available in the backend API. 14 | * 15 | * These allow you to access things when processing a request, like the database, the session, etc. 16 | */ 17 | import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; 18 | 19 | /** Replace this with an object if you want to pass things to `createContextInner`. */ 20 | type CreateContextOptions = Record; 21 | 22 | /** 23 | * This helper generates the "internals" for a tRPC context. If you need to use it, you can export 24 | * it from here. 25 | * 26 | * Examples of things you may need it for: 27 | * - testing, so we don't have to mock Next.js' req/res 28 | * - tRPC's `createSSGHelpers`, where we don't have req/res 29 | * 30 | * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts 31 | */ 32 | const createInnerTRPCContext = (_opts: CreateContextOptions) => { 33 | return {}; 34 | }; 35 | 36 | /** 37 | * This is the actual context you will use in your router. It will be used to process every request 38 | * that goes through your tRPC endpoint. 39 | * 40 | * @see https://trpc.io/docs/context 41 | */ 42 | export const createTRPCContext = (_opts: CreateNextContextOptions) => { 43 | return createInnerTRPCContext({}); 44 | }; 45 | 46 | /** 47 | * 2. INITIALIZATION 48 | * 49 | * This is where the tRPC API is initialized, connecting the context and transformer. We also parse 50 | * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation 51 | * errors on the backend. 52 | */ 53 | import { initTRPC } from "@trpc/server"; 54 | import superjson from "superjson"; 55 | import type { TRPCPanelMeta } from "trpc-ui"; 56 | import { ZodError } from "zod"; 57 | import { env } from "~/env.mjs"; 58 | 59 | const t = initTRPC 60 | .context() 61 | .meta() 62 | .create({ 63 | transformer: env.NEXT_PUBLIC_SUPERJSON === "false" ? undefined : superjson, 64 | errorFormatter({ shape, error }) { 65 | return { 66 | ...shape, 67 | data: { 68 | ...shape.data, 69 | zodError: 70 | error.cause instanceof ZodError ? error.cause.flatten() : null, 71 | }, 72 | }; 73 | }, 74 | allowOutsideOfServer: true, 75 | }); 76 | 77 | /** 78 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) 79 | * 80 | * These are the pieces you use to build your tRPC API. You should import these a lot in the 81 | * "/src/server/api/routers" directory. 82 | */ 83 | 84 | /** 85 | * This is how you create new routers and sub-routers in your tRPC API. 86 | * 87 | * @see https://trpc.io/docs/router 88 | */ 89 | export const createTRPCRouter = t.router; 90 | 91 | /** 92 | * Public (unauthenticated) procedure 93 | * 94 | * This is the base piece you use to build new queries and mutations on your tRPC API. It does not 95 | * guarantee that a user querying is authorized, but you can still access user session data if they 96 | * are logged in. 97 | */ 98 | export const procedure = t.procedure; 99 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/parse/__tests__/utils/router.ts: -------------------------------------------------------------------------------- 1 | import type { TRPCPanelMeta } from "@src/meta"; 2 | import type { ObjectNode } from "@src/parse/parseNodeTypes"; 3 | import type { ParsedProcedure } from "@src/parse/parseProcedure"; 4 | import { initTRPC } from "@trpc/server"; 5 | import { z } from "zod"; 6 | import { zodToJsonSchema } from "zod-to-json-schema"; 7 | 8 | export const testTrpcInstance = initTRPC.meta().create({}); 9 | 10 | export const parseTestRouterInputSchema = z.object({ 11 | id: z.string(), 12 | age: z.number(), 13 | expectedAgeOfDeath: z.number().optional(), 14 | object: z.object({ 15 | nestedId: z.string(), 16 | }), 17 | du: z.discriminatedUnion("d", [ 18 | z.object({ 19 | d: z.literal("one"), 20 | oneProps: z.string(), 21 | }), 22 | z.object({ 23 | d: z.literal("two"), 24 | }), 25 | ]), 26 | }); 27 | 28 | export const expectedTestRouterInputParsedNode: ObjectNode = { 29 | type: "object", 30 | path: [], 31 | children: { 32 | id: { 33 | type: "string", 34 | path: ["id"], 35 | }, 36 | age: { 37 | type: "number", 38 | path: ["age"], 39 | }, 40 | expectedAgeOfDeath: { 41 | type: "number", 42 | optional: true, 43 | path: ["expectedAgeOfDeath"], 44 | }, 45 | object: { 46 | type: "object", 47 | path: ["object"], 48 | children: { 49 | nestedId: { 50 | type: "string", 51 | path: ["object", "nestedId"], 52 | }, 53 | }, 54 | }, 55 | du: { 56 | type: "discriminated-union", 57 | path: ["du"], 58 | discriminatorName: "d", 59 | discriminatedUnionValues: ["one", "two"], 60 | discriminatedUnionChildrenMap: { 61 | one: { 62 | type: "object", 63 | path: ["du"], 64 | children: { 65 | d: { 66 | type: "literal", 67 | value: "one", 68 | path: ["du", "d"], 69 | }, 70 | oneProps: { 71 | type: "string", 72 | path: ["du", "oneProps"], 73 | }, 74 | }, 75 | }, 76 | two: { 77 | type: "object", 78 | path: ["du"], 79 | children: { 80 | d: { 81 | type: "literal", 82 | value: "two", 83 | path: ["du", "d"], 84 | }, 85 | }, 86 | }, 87 | }, 88 | }, 89 | }, 90 | }; 91 | 92 | export const testQueryExpectedParseResult: ParsedProcedure = { 93 | nodeType: "procedure", 94 | node: expectedTestRouterInputParsedNode, 95 | inputSchema: zodToJsonSchema(parseTestRouterInputSchema), 96 | procedureType: "query", 97 | pathFromRootRouter: ["testQuery"], 98 | extraData: { 99 | parameterDescriptions: {}, 100 | }, 101 | }; 102 | 103 | export const testMutationExpectedParseResult: ParsedProcedure = { 104 | nodeType: "procedure", 105 | node: expectedTestRouterInputParsedNode, 106 | inputSchema: zodToJsonSchema(parseTestRouterInputSchema), 107 | procedureType: "mutation", 108 | pathFromRootRouter: ["testMutation"], 109 | extraData: { 110 | parameterDescriptions: {}, 111 | }, 112 | }; 113 | 114 | export const testQuery = testTrpcInstance.procedure 115 | .input(parseTestRouterInputSchema) 116 | .query(() => "Nada"); 117 | 118 | export const testMutation = testTrpcInstance.procedure 119 | .input(parseTestRouterInputSchema) 120 | .mutation(() => "Nope"); 121 | 122 | export const parseTestRouter = testTrpcInstance.router({ 123 | testQuery, 124 | testMutation, 125 | }); 126 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/parse/__tests__/parseProcedure.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseTestRouter, 3 | parseTestRouterInputSchema, 4 | testMutationExpectedParseResult, 5 | testQueryExpectedParseResult, 6 | testTrpcInstance, 7 | } from "@src/parse/__tests__/utils/router"; 8 | import { testSchemas } from "@src/parse/__tests__/utils/schemas"; 9 | import { 10 | type ParsedProcedure, 11 | parseProcedure, 12 | } from "@src/parse/parseProcedure"; 13 | import type { Procedure } from "@src/parse/routerType"; 14 | import { z } from "zod"; 15 | import zodToJsonSchema from "zod-to-json-schema"; 16 | 17 | describe("Parse TRPC Procedure", () => { 18 | it("should parse the test query", () => { 19 | // IDK how to type this 20 | const testQuery = parseTestRouter.testQuery as unknown as Procedure; 21 | const parsedProcedure = parseProcedure(testQuery, ["testQuery"], {}); 22 | expect(testQueryExpectedParseResult).toStrictEqual(parsedProcedure); 23 | }); 24 | it("should parse the test mutation", () => { 25 | // IDK how to type this 26 | const testQuery = parseTestRouter.testMutation as unknown as Procedure; 27 | const parsedProcedure = parseProcedure(testQuery, ["testMutation"], {}); 28 | expect(testMutationExpectedParseResult).toStrictEqual(parsedProcedure); 29 | }); 30 | it("should parse the meta description if it exists", () => { 31 | const description = "It's a good description"; 32 | const { testQuery } = testTrpcInstance.router({ 33 | testQuery: testTrpcInstance.procedure 34 | .meta({ description }) 35 | .input(parseTestRouterInputSchema) 36 | .query(() => "nope"), 37 | }); 38 | const expected = { 39 | ...testQueryExpectedParseResult, 40 | extraData: { 41 | ...testQueryExpectedParseResult.extraData, 42 | description, 43 | }, 44 | }; 45 | 46 | const parsed = parseProcedure( 47 | testQuery as unknown as Procedure, 48 | ["testQuery"], 49 | {}, 50 | ); 51 | expect(parsed).toStrictEqual(expected); 52 | }); 53 | it("should parse input descriptions if they exist for common types", () => { 54 | // good luck understanding this 55 | const description = "A description"; 56 | const testSchemasWithDescriptions = testSchemas.map((e, i) => ({ 57 | ...e, 58 | schema: e.schema.describe(description + i), 59 | })); 60 | const inputSchema = z.object({ 61 | ...Object.fromEntries( 62 | testSchemasWithDescriptions.map((e, i) => [i, e.schema]), 63 | ), 64 | }); 65 | const expected: ParsedProcedure = { 66 | nodeType: "procedure", 67 | inputSchema: zodToJsonSchema(inputSchema), 68 | pathFromRootRouter: ["testQuery"], 69 | procedureType: "query", 70 | extraData: { 71 | parameterDescriptions: { 72 | ...Object.fromEntries( 73 | testSchemasWithDescriptions.map((_, i) => [i, description + i]), 74 | ), 75 | }, 76 | }, 77 | node: { 78 | type: "object", 79 | path: [], 80 | children: { 81 | ...Object.fromEntries( 82 | testSchemasWithDescriptions.map((e, i) => [i, e.parsed]), 83 | ), 84 | }, 85 | }, 86 | }; 87 | const testRouter = testTrpcInstance.router({ 88 | testQuery: testTrpcInstance.procedure 89 | .input(inputSchema) 90 | .query(() => "nothing"), 91 | }); 92 | const parsed = parseProcedure( 93 | testRouter.testQuery as unknown as Procedure, 94 | ["testQuery"], 95 | {}, 96 | ); 97 | expect(parsed).toStrictEqual(expected); 98 | }); 99 | it("should parse descriptions from nested objects with the appropriate path", () => {}); 100 | }); 101 | -------------------------------------------------------------------------------- /packages/trpc-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trpc-ui", 3 | "version": "1.0.15", 4 | "description": "UI for testing tRPC backends", 5 | "main": "lib/index.js", 6 | "module": "lib/index.mjs", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/aidansunbury/trpc-ui/" 10 | }, 11 | "typings": "lib/src/index.d.ts", 12 | "scripts": { 13 | "test": "jest", 14 | "test:watch": "jest --watchAll", 15 | "build": "pnpx rollup --bundleConfigAsCjs --config rollup.config.js", 16 | "dev": "rollup --config rollup.config.js --watch --bundleConfigAsCjs" 17 | }, 18 | "author": "", 19 | "license": "ISC", 20 | "exports": { 21 | ".": { 22 | "types": "./lib/src/index.d.ts", 23 | "import": "./lib/index.mjs", 24 | "require": "./lib/index.js", 25 | "default": "./lib/index.js" 26 | } 27 | }, 28 | "files": [ 29 | "lib/" 30 | ], 31 | "directories": { 32 | "lib": "lib" 33 | }, 34 | "peerDependencies": { 35 | "@trpc/server": "^11.0.0-next-beta.264", 36 | "zod": "^3.19.1" 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.20.2", 40 | "@babel/preset-react": "^7.18.6", 41 | "@emotion/react": "^11.10.5", 42 | "@emotion/styled": "^11.10.5", 43 | "@hookform/resolvers": "^2.9.10", 44 | "@mui/icons-material": "^5.10.16", 45 | "@mui/material": "^5.10.16", 46 | "@rollup/plugin-babel": "^6.0.3", 47 | "@rollup/plugin-commonjs": "^23.0.2", 48 | "@rollup/plugin-json": "^5.0.1", 49 | "@rollup/plugin-node-resolve": "^15.0.1", 50 | "@rollup/plugin-replace": "^5.0.1", 51 | "@rollup/plugin-terser": "^0.2.0", 52 | "@rollup/plugin-typescript": "^10.0.1", 53 | "@tailwindcss/typography": "^0.5.15", 54 | "@tanstack/react-query": "^5.12.2", 55 | "@testing-library/jest-dom": "^5.16.5", 56 | "@testing-library/react": "^13.4.0", 57 | "@testing-library/user-event": "^14.4.3", 58 | "@trpc/client": "^11.0.0-next-beta.264", 59 | "@trpc/react-query": "^11.0.0-next-beta.264", 60 | "@trpc/server": "^11.0.0-next-beta.264", 61 | "@types/jest": "^29.2.4", 62 | "@types/json-bigint": "^1.0.1", 63 | "@types/react": "^18.0.21", 64 | "@types/react-dom": "^18.0.6", 65 | "ajv": "^8.11.2", 66 | "ajv-formats": "^2.1.1", 67 | "autoprefixer": "^10.4.13", 68 | "devalue": "^4.2.0", 69 | "gulp": "^4.0.2", 70 | "gulp-inline-source": "^4.0.0", 71 | "gulp-replace": "^1.1.3", 72 | "jest": "^29.3.1", 73 | "json-bigint": "^1.0.0", 74 | "pkg-pr-new": "^0.0.35", 75 | "postcss": "^8.4.19", 76 | "react": "18.2.0", 77 | "react-dom": "18.2.0", 78 | "react-hook-form": "^7.39.5", 79 | "react-hot-toast": "^2.4.0", 80 | "react-hotkeys-hook": "^4.0.6", 81 | "rollup": "^3.7.4", 82 | "rollup-plugin-copy": "^3.4.0", 83 | "rollup-plugin-livereload": "^2.0.5", 84 | "rollup-plugin-postcss": "^4.0.2", 85 | "rollup-plugin-serve": "^2.0.1", 86 | "rollup-plugin-visualizer": "^5.8.3", 87 | "superjson": "^1.12.0", 88 | "tailwindcss": "^3.2.4", 89 | "ts-jest": "^29.0.3", 90 | "tslib": "^2.4.1", 91 | "typescript": "^5.4.5", 92 | "url": "^0.11.0", 93 | "zustand": "^4.1.5" 94 | }, 95 | "dependencies": { 96 | "@monaco-editor/react": "^4.7.0", 97 | "@stoplight/json-schema-sampler": "^0.3.0", 98 | "@textea/json-viewer": "^3.0.0", 99 | "clsx": "^2.1.1", 100 | "fuzzysort": "^2.0.4", 101 | "nuqs": "^2.2.1", 102 | "path": "^0.12.7", 103 | "pretty-bytes": "^6.1.0", 104 | "pretty-ms": "^8.0.0", 105 | "react-markdown": "^9.0.1", 106 | "string-byte-length": "^1.6.0", 107 | "tailwind-merge": "^2.5.5", 108 | "url": "^0.11.0", 109 | "zod-to-json-schema": "^3.20.0" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/components/form/fields/ArrayField.tsx: -------------------------------------------------------------------------------- 1 | import XIcon from "@mui/icons-material/CloseOutlined"; 2 | import DataArray from "@mui/icons-material/DataArray"; 3 | import type { ParsedInputNode } from "@src/parse/parseNodeTypes"; 4 | import { AddItemButton } from "@src/react-app/components/AddItemButton"; 5 | import { InputGroupContainer } from "@src/react-app/components/InputGroupContainer"; 6 | import { ROOT_VALS_PROPERTY_NAME } from "@src/react-app/components/form/ProcedureForm"; 7 | import { FieldError } from "@src/react-app/components/form/fields/FieldError"; 8 | import { defaultFormValuesForNode } from "@src/react-app/components/form/utils"; 9 | import React, { useState } from "react"; 10 | import { type Control, useController, useWatch } from "react-hook-form"; 11 | import { Field } from "../Field"; 12 | 13 | let currentKeyCount = 0; 14 | 15 | export function ArrayField({ 16 | name, 17 | label, 18 | control, 19 | node, 20 | }: { 21 | name: string; 22 | label: string; 23 | control: Control; 24 | node: ParsedInputNode & { type: "array" }; 25 | }) { 26 | const { field, fieldState } = useController({ 27 | name, 28 | control, 29 | }); 30 | // To make sure text field state dies when they're deleted 31 | const [textFieldKeys, setTextFieldKeys] = useState([]); 32 | 33 | // For some ungodly reason RHF doesn't update field.value when the child fields update the value in the form 34 | // state. Each of the changes end up being reflected in the form state so they end up overwriting eachother and stuff. 35 | // Anyways, useWatch always has the real form state, so we just use it. So ArrayField will always rerender any time 36 | // the form state changes. 37 | const watch = useWatch({ control }); 38 | 39 | function getValueFromWatch() { 40 | let r = watch; 41 | for (const p of [ROOT_VALS_PROPERTY_NAME].concat( 42 | node.path.map((e) => `${e}`), 43 | )) { 44 | r = r[p]; 45 | } 46 | return r; 47 | } 48 | 49 | function onAddClick() { 50 | setTextFieldKeys((old) => old.concat([`${currentKeyCount++}`])); 51 | field.onChange( 52 | getValueFromWatch().concat([defaultFormValuesForNode(node.childType)]), 53 | ); 54 | } 55 | 56 | function onDeleteClick(index: number) { 57 | const newArr = [...getValueFromWatch()]; 58 | const newKeysArr = [...textFieldKeys]; 59 | newArr.splice(index, 1); 60 | newKeysArr.splice(index, 1); 61 | field.onChange(newArr); 62 | setTextFieldKeys(newKeysArr); 63 | } 64 | return ( 65 | } 67 | title={label} 68 | > 69 | {field.value.map((parsedNode: ParsedInputNode, i: number) => ( 70 | 74 | 75 | 86 | 87 | 94 | 95 | ))} 96 | 97 | {fieldState.error?.message && ( 98 | 99 | )} 100 | 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | `trpc-ui` is open to contributions! Before getting started, check the open issues, or create a new one for bug fixes or feature improvements. @aidansunbury to be assigned to the issue. 4 | 5 | It is also extremely helpful to upvote (👍) or downvote (👎) existing bugs and features, to ensure that the most in demand features or problematic bugs get addressed first. 6 | 7 | ## Repo Overview 8 | 9 | There are three main packages in this repo: 10 | 1. `trpc-ui`: The main package that is [published to npm](https://www.npmjs.com/package/trpc-ui) 11 | 2. `dev-app`: A development app that makes it easy to work on `trpc-ui` locally. It uses the `trpc-ui` package. 12 | 3. `test-app`: A [showcase application](https://trpc.aidansunbury.dev/) to demonstrate the capabilities of `trpc-ui` 13 | 14 | The repo is configured to work with [pnpm workspaces](https://pnpm.io/workspaces). To install dependencies for all three packages, run: 15 | 16 | ```sh 17 | pnpm install 18 | ``` 19 | 20 | When adding new dependencies, be sure to add them in the correct package, and not the top level `package.json`. Running `pnpm install` at the root will give a warning. 21 | 22 | ### Development App 23 | 24 | Included in this repo there is a development app that makes it easy to work on `trpc-ui` locally. It is a `next.js` app that will render the router included in the dev app. To run it, do: 25 | 26 | ```sh 27 | pnpm dev:dev-app # Run at base of monorepo 28 | ``` 29 | 30 | ```sh 31 | cd packages/dev-app && pnpm dev # To run in the dev-app directory 32 | ``` 33 | 34 | This will run the app in your browser. 35 | 36 | To add / remove procedures from the dev app's panel, modify its router in [router.ts](./packages/dev-app/src/router.ts). Please do not commit changes to this file. 37 | 38 | ### Test App 39 | 40 | The test app is just a simple express server. The procedures can be modified in the test-app [router.ts](./packages/test-app/src/router.ts) router. After adding new functionality to the trpc-ui package, be sure to showcase it in the test app! 41 | 42 | ```sh 43 | pnpm dev:test-app # Run at base of monorepo 44 | ``` 45 | 46 | ```sh 47 | cd packages/test-app && pnpm dev # To run in the test-app directory 48 | ``` 49 | 50 | ## Preview deployments 51 | Most of the time, the Dev App will be sufficient for testing changes to the trpc-ui package. However, the Dev App does not actually use the bundled `trpc-ui` dependency the same way you would install it into your project. Additionally, the dev app is configured using the NextJS pages router and superjson, so it is not particularly for debugging issues specific to other [trpc adapters](https://trpc.io/docs/server/adapters) or issues that only arise when not using superjson. 52 | 53 | As a result, this project is configured to use [preview releases](https://pkg.pr.new/), for each pull request. These preview releases can then be installed anywhere to test the changes made to the `trpc-ui`. 54 | 55 | ``` 56 | pnpm add https://pkg.pr.new/aidansunbury/trpc-ui@40 57 | ``` 58 | 59 | Comments like this will be automatically generated on each pull request. 60 | 61 | 62 | ## Front end contributions 63 | 64 | The `trpc-ui` front end is just a bunch of react components. Any updates made to the react components should immediately be visible while running the `dev-app`. 65 | 66 | The React components are located in `packages/trpc-ui/src/react-app`. 67 | 68 | ## Updating the parser 69 | 70 | For more advanced features, it may be required to update the parsing logic, which can be found in `packages/trpc-ui/parse`. 71 | 72 | ### Running the parser tests 73 | 74 | Jest is used to test the functionality of the parser. To run them, use 75 | 76 | ```sh 77 | pnpm test:panel 78 | ``` 79 | 80 | at the root of the monorepo. If you add additional functionality to the parser, please add tests for the new functionality. 81 | 82 | ## Linting and Formatting 83 | 84 | trpc-ui uses [biome](https://biomejs.dev/) as a linter and formatter. To just check for errors, run 85 | 86 | ```bash 87 | pnpm biome:check 88 | ``` 89 | 90 | To fix them, run 91 | ```bash 92 | pnpm biome:check:fix 93 | ``` 94 | 95 | both at the root of the monorepo. Not all errors can be fixed automatically. 96 | 97 | There are a good number of errors in much of the older code, but please try not to introduce new ones. -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/components/CollapsableSection.tsx: -------------------------------------------------------------------------------- 1 | import { Chevron } from "@src/react-app/components/Chevron"; 2 | import { 3 | collapsables, 4 | useCollapsableIsShowing, 5 | useSiteNavigationContext, 6 | } from "@src/react-app/components/contexts/SiteNavigationContext"; 7 | import { 8 | backgroundColor, 9 | solidColorBg, 10 | solidColorBorder, 11 | } from "@src/react-app/components/style-utils"; 12 | import { useQueryState } from "nuqs"; 13 | import React, { 14 | type MutableRefObject, 15 | type ReactNode, 16 | useEffect, 17 | useRef, 18 | } from "react"; 19 | 20 | export type ColorSchemeType = 21 | | "query" 22 | | "mutation" 23 | | "router" 24 | | "neutral" 25 | | "subscription"; 26 | export function CollapsableSection({ 27 | titleElement, 28 | fullPath, 29 | children, 30 | sectionType, 31 | isRoot, 32 | focusOnScrollRef, 33 | }: { 34 | titleElement: ReactNode; 35 | fullPath: string[]; 36 | children: ReactNode; 37 | sectionType: ColorSchemeType; 38 | isRoot?: boolean; 39 | focusOnScrollRef?: MutableRefObject; 40 | }) { 41 | const { scrollToPathIfMatches } = useSiteNavigationContext(); 42 | const shown = useCollapsableIsShowing(fullPath); 43 | const [_path, setPath] = useQueryState("path"); 44 | 45 | const containerRef = useRef(null); 46 | useEffect(() => { 47 | if (shown && containerRef.current) { 48 | if (scrollToPathIfMatches(fullPath, containerRef.current)) { 49 | // timeout or it'll immediately submit the form (which shows error messages) 50 | const firstChild = 51 | focusOnScrollRef?.current && 52 | findFirstFormChildInput(focusOnScrollRef.current); 53 | if (firstChild) { 54 | setTimeout(() => { 55 | firstChild.focus({ preventScroll: true }); 56 | }, 0); 57 | } 58 | } 59 | } 60 | }, [shown]); 61 | 62 | // deals with root router. If it's not collapsable we **simply** render the title element and children 63 | const collapsable = fullPath.length > 0; 64 | return ( 65 |
73 | {collapsable ? ( 74 | 96 | ) : ( 97 | titleElement 98 | )} 99 | 100 |
103 | {children} 104 |
105 |
106 | ); 107 | } 108 | 109 | export function SectionTypeLabel({ 110 | sectionType, 111 | className, 112 | }: { 113 | sectionType: ColorSchemeType; 114 | className?: string; 115 | }) { 116 | return ( 117 | 120 | {sectionType.toUpperCase()} 121 | 122 | ); 123 | } 124 | 125 | function findFirstFormChildInput(formElement: HTMLFormElement) { 126 | for (let i = 0; i < formElement.elements.length; i++) { 127 | const child = formElement.elements[i]; 128 | if (child?.tagName === "input" || child?.tagName === "INPUT") { 129 | return child as HTMLInputElement; 130 | } 131 | } 132 | return; 133 | } 134 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/components/contexts/SiteNavigationContext.tsx: -------------------------------------------------------------------------------- 1 | import { useAllPaths } from "@src/react-app/components/contexts/AllPathsContext"; 2 | import React, { 3 | createContext, 4 | type ReactNode, 5 | useContext, 6 | useMemo, 7 | useRef, 8 | } from "react"; 9 | import { create } from "zustand"; 10 | 11 | const Context = createContext<{ 12 | scrollToPathIfMatches: (path: string[], element: Element) => boolean; 13 | markForScrollTo: (path: string[]) => void; 14 | openAndNavigateTo: (path: string[], closeOthers?: boolean) => void; 15 | } | null>(null); 16 | 17 | function forAllPaths(path: string[], callback: (current: string) => void) { 18 | const cur: string[] = []; 19 | for (const next of path) { 20 | cur.push(next); 21 | const joined = cur.join("."); 22 | callback(joined); 23 | } 24 | } 25 | 26 | const collapsablesStore = { 27 | current: null as null | ReturnType, 28 | }; 29 | 30 | function initialCollapsableStoreValues(allPaths: string[]) { 31 | const vals: Record = {}; 32 | 33 | for (const path of allPaths) { 34 | vals[path] = false; 35 | } 36 | return vals; 37 | } 38 | 39 | function initCollapsablesStore(allPaths: string[]) { 40 | collapsablesStore.current = create(() => ({ 41 | ...initialCollapsableStoreValues(allPaths), 42 | })); 43 | } 44 | 45 | function useInitCollapsablesStore(allPaths: string[]) { 46 | const hasInitted = useRef(false); 47 | 48 | if (!hasInitted.current) { 49 | initCollapsablesStore(allPaths); 50 | hasInitted.current = true; 51 | } 52 | } 53 | 54 | export const collapsables = (() => { 55 | const hide = (path: string[]) => { 56 | const pathJoined = path.join("."); 57 | forAllPaths(path, (current) => { 58 | if (pathJoined.length <= current.length) { 59 | collapsablesStore.current?.setState({ 60 | [current]: false, 61 | }); 62 | } 63 | }); 64 | }; 65 | const show = (path: string[]) => { 66 | forAllPaths(path, (current) => { 67 | collapsablesStore.current?.setState({ 68 | [current]: true, 69 | }); 70 | }); 71 | }; 72 | return { 73 | hide, 74 | show, 75 | toggle(path: string[]) { 76 | const state = collapsablesStore.current?.getState() as any; 77 | if (state[path.join(".")]) { 78 | hide(path); 79 | } else { 80 | show(path); 81 | } 82 | }, 83 | hideAll() { 84 | const state = collapsablesStore.current! as any; 85 | const newValue: Record = {}; 86 | for (const path in state) { 87 | newValue[path] = false; 88 | } 89 | collapsablesStore.current?.setState(newValue); 90 | }, 91 | }; 92 | })(); 93 | 94 | export function useCollapsableIsShowing(path: string[]) { 95 | const p = useMemo(() => { 96 | return path.join("."); 97 | }, []); 98 | return collapsablesStore.current?.((s) => (s as any)[p]); 99 | } 100 | 101 | export function SiteNavigationContextProvider({ 102 | children, 103 | }: { 104 | children: ReactNode; 105 | }) { 106 | const allPaths = useAllPaths(); 107 | useInitCollapsablesStore(allPaths.pathsArray); 108 | 109 | const scrollToPathRef = useRef(null); 110 | 111 | function scrollToPathIfMatches(path: string[], element: Element) { 112 | if ( 113 | !scrollToPathRef.current || 114 | path.join(".") !== scrollToPathRef.current.join(".") 115 | ) { 116 | return false; 117 | } 118 | 119 | scrollToPathRef.current = null; 120 | element.scrollIntoView({ 121 | behavior: "smooth", 122 | block: "start", 123 | inline: "start", 124 | }); 125 | return true; 126 | } 127 | 128 | function markForScrollTo(path: string[]) { 129 | scrollToPathRef.current = path; 130 | } 131 | 132 | function openAndNavigateTo(path: string[], hideOthers?: boolean) { 133 | if (hideOthers) { 134 | collapsables.hideAll(); 135 | } 136 | collapsables.show(path); 137 | markForScrollTo(path); 138 | } 139 | 140 | return ( 141 | 148 | {children} 149 | 150 | ); 151 | } 152 | 153 | export function useSiteNavigationContext() { 154 | const context = useContext(Context); 155 | if (context === null) 156 | throw new Error( 157 | "useCollapsableContext must be called from within a CollapsableContext", 158 | ); 159 | return context; 160 | } 161 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/parse/parseProcedure.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | JSON7SchemaType, 3 | ProcedureType, 4 | TrpcPanelExtraOptions, 5 | } from "./parseRouter"; 6 | import { 7 | type Procedure, 8 | isMutationDef, 9 | isQueryDef, 10 | isSubscriptionDef, 11 | } from "./routerType"; 12 | 13 | import type { 14 | AddDataFunctions, 15 | ParseReferences, 16 | ParsedInputNode, 17 | } from "@src/parse/parseNodeTypes"; 18 | import { type AnyZodObject, z } from "zod"; 19 | import { zodToJsonSchema } from "zod-to-json-schema"; 20 | import { zodSelectorFunction } from "./input-mappers/zod/selector"; 21 | 22 | export type ProcedureExtraData = { 23 | parameterDescriptions: { [path: string]: string }; 24 | description?: string; 25 | }; 26 | 27 | export type ParsedProcedure = { 28 | inputSchema: JSON7SchemaType; 29 | node: ParsedInputNode; 30 | nodeType: "procedure"; 31 | procedureType: ProcedureType; 32 | pathFromRootRouter: string[]; 33 | extraData: ProcedureExtraData; 34 | }; 35 | 36 | type SupportedInputType = "zod"; 37 | 38 | const inputParserMap = { 39 | zod: (zodObject: AnyZodObject, refs: ParseReferences) => { 40 | return zodSelectorFunction(zodObject._def, refs); 41 | }, 42 | }; 43 | 44 | function inputType(_: unknown): SupportedInputType | "unsupported" { 45 | return "zod"; 46 | } 47 | 48 | type NodeAndInputSchemaFromInputs = 49 | | { 50 | node: ParsedInputNode; 51 | schema: ReturnType; 52 | parseInputResult: "success"; 53 | } 54 | | { 55 | parseInputResult: "failure"; 56 | }; 57 | 58 | const emptyZodObject = z.object({}); 59 | function nodeAndInputSchemaFromInputs( 60 | inputs: unknown[], 61 | _routerPath: string[], 62 | options: TrpcPanelExtraOptions, 63 | addDataFunctions: AddDataFunctions, 64 | ): NodeAndInputSchemaFromInputs { 65 | if (!inputs.length) { 66 | return { 67 | parseInputResult: "success", 68 | schema: zodToJsonSchema(emptyZodObject, { 69 | errorMessages: true, 70 | $refStrategy: "none", 71 | }), 72 | node: inputParserMap.zod(emptyZodObject, { 73 | path: [], 74 | options, 75 | addDataFunctions, 76 | }), 77 | }; 78 | } 79 | 80 | let input = inputs[0]; 81 | if (inputs.length < 1) { 82 | return { parseInputResult: "failure" }; 83 | } 84 | 85 | if (inputs.length > 1) { 86 | const allInputsAreZodObjects = inputs.every( 87 | (input) => input instanceof z.ZodObject, 88 | ); 89 | if (!allInputsAreZodObjects) { 90 | return { parseInputResult: "failure" }; 91 | } 92 | 93 | input = inputs.reduce( 94 | (acc, input: z.AnyZodObject) => (acc as z.AnyZodObject).merge(input), 95 | emptyZodObject, 96 | ); 97 | } 98 | 99 | const iType = inputType(input); 100 | if (iType === "unsupported") { 101 | return { parseInputResult: "failure" }; 102 | } 103 | 104 | return { 105 | parseInputResult: "success", 106 | schema: zodToJsonSchema(input as any, { 107 | errorMessages: true, 108 | $refStrategy: "none", 109 | }), // 110 | node: zodSelectorFunction((input as any)._def, { 111 | path: [], 112 | options, 113 | addDataFunctions, 114 | }), 115 | }; 116 | } 117 | 118 | export function parseProcedure( 119 | procedure: Procedure, 120 | path: string[], 121 | options: TrpcPanelExtraOptions, 122 | ): ParsedProcedure | null { 123 | const { _def } = procedure; 124 | const { inputs } = _def; 125 | const parseExtraData: ProcedureExtraData = { 126 | parameterDescriptions: {}, 127 | }; 128 | const nodeAndInput = nodeAndInputSchemaFromInputs(inputs, path, options, { 129 | addDescriptionIfExists: (def, refs) => { 130 | if (def.description) { 131 | parseExtraData.parameterDescriptions[refs.path.join(".")] = 132 | def.description; 133 | } 134 | }, 135 | }); 136 | if (nodeAndInput.parseInputResult === "failure") { 137 | return null; 138 | } 139 | 140 | const t = (() => { 141 | if (isQueryDef(_def)) return "query"; 142 | if (isMutationDef(_def)) return "mutation"; 143 | if (isSubscriptionDef(_def)) return "subscription"; 144 | return null; 145 | })(); 146 | 147 | if (!t) { 148 | return null; 149 | } 150 | 151 | return { 152 | inputSchema: nodeAndInput.schema, 153 | node: nodeAndInput.node, 154 | nodeType: "procedure", 155 | procedureType: t, 156 | pathFromRootRouter: path, 157 | extraData: { 158 | ...parseExtraData, 159 | ...(procedure._def.meta?.description && { 160 | description: procedure._def.meta.description, 161 | }), 162 | }, 163 | }; 164 | } 165 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/Root.tsx: -------------------------------------------------------------------------------- 1 | import { HeadersPopup } from "@src/react-app/components/HeadersPopup"; 2 | import { SearchOverlay } from "@src/react-app/components/SearchInputOverlay"; 3 | import { AllPathsContextProvider } from "@src/react-app/components/contexts/AllPathsContext"; 4 | import { 5 | HeadersContextProvider, 6 | useHeaders, 7 | } from "@src/react-app/components/contexts/HeadersContext"; 8 | import { HotKeysContextProvider } from "@src/react-app/components/contexts/HotKeysContext"; 9 | import { SiteNavigationContextProvider } from "@src/react-app/components/contexts/SiteNavigationContext"; 10 | import { useSiteNavigationContext } from "@src/react-app/components/contexts/SiteNavigationContext"; 11 | import { useLocalStorage } from "@src/react-app/components/hooks/useLocalStorage"; 12 | import type { RenderOptions } from "@src/render"; 13 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 14 | // import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 15 | import { type createTRPCReact, httpBatchLink } from "@trpc/react-query"; 16 | import { useQueryState } from "nuqs"; 17 | import { parseAsArrayOf, parseAsString } from "nuqs"; 18 | import { NuqsAdapter } from "nuqs/adapters/react"; 19 | import React, { type ReactNode, useEffect, useState } from "react"; 20 | import { Toaster } from "react-hot-toast"; 21 | import superjson from "superjson"; 22 | import type { ParsedRouter } from "../parse/parseRouter"; 23 | import { MetaHeader } from "./components/MetaHeader"; 24 | import { RouterContainer } from "./components/RouterContainer"; 25 | import { SideNav } from "./components/SideNav"; 26 | import { TopBar } from "./components/TopBar"; 27 | import { RenderOptionsProvider } from "./components/contexts/OptionsContext"; 28 | 29 | export function RootComponent({ 30 | rootRouter, 31 | options, 32 | trpc, 33 | }: { 34 | rootRouter: ParsedRouter; 35 | options: RenderOptions; 36 | trpc: ReturnType; 37 | }) { 38 | return ( 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | 49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | ); 59 | } 60 | 61 | function ClientProviders({ 62 | trpc, 63 | children, 64 | options, 65 | }: { 66 | trpc: ReturnType; 67 | children: ReactNode; 68 | options: RenderOptions; 69 | }) { 70 | const headers = useHeaders(); 71 | const [trpcClient] = useState(() => 72 | trpc.createClient({ 73 | links: [ 74 | httpBatchLink({ 75 | url: options.url, 76 | headers: headers.getHeaders, 77 | }), 78 | ], 79 | transformer: (() => { 80 | if (options.transformer === "superjson") return superjson; 81 | return undefined; 82 | })(), 83 | }), 84 | ); 85 | const [queryClient] = useState(() => new QueryClient()); 86 | 87 | return ( 88 | 89 | 90 | {children} 91 | {/* */} 92 | 93 | 94 | ); 95 | } 96 | 97 | function AppInnards({ 98 | rootRouter, 99 | options, 100 | }: { 101 | rootRouter: ParsedRouter; 102 | options: RenderOptions; 103 | }) { 104 | const [sidebarOpen, setSidebarOpen] = useLocalStorage( 105 | "trpc-panel.show-minimap", 106 | true, 107 | ); 108 | const { openAndNavigateTo } = useSiteNavigationContext(); 109 | 110 | const [path] = useQueryState("path", parseAsArrayOf(parseAsString, ".")); 111 | 112 | useEffect(() => { 113 | openAndNavigateTo(path ?? [], true); 114 | }, []); 115 | 116 | return ( 117 |
118 | 119 |
120 | 125 |
131 |
132 | 133 | 134 |
135 |
136 |
137 | 138 | 139 |
140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/parse/input-mappers/zod/selector.ts: -------------------------------------------------------------------------------- 1 | import { parseZodBigIntDef } from "@src/parse/input-mappers/zod/parsers/parseZodBigIntDef"; 2 | import { parseZodBrandedDef } from "@src/parse/input-mappers/zod/parsers/parseZodBrandedDef"; 3 | import { parseZodDefaultDef } from "@src/parse/input-mappers/zod/parsers/parseZodDefaultDef"; 4 | import { parseZodEffectsDef } from "@src/parse/input-mappers/zod/parsers/parseZodEffectsDef"; 5 | import { parseZodNullDef } from "@src/parse/input-mappers/zod/parsers/parseZodNullDef"; 6 | import { parseZodNullableDef } from "@src/parse/input-mappers/zod/parsers/parseZodNullableDef"; 7 | import { parseZodOptionalDef } from "@src/parse/input-mappers/zod/parsers/parseZodOptionalDef"; 8 | import { parseZodPromiseDef } from "@src/parse/input-mappers/zod/parsers/parseZodPromiseDef"; 9 | import { parseZodUndefinedDef } from "@src/parse/input-mappers/zod/parsers/parseZodUndefinedDef"; 10 | import { parseZodUnionDef } from "@src/parse/input-mappers/zod/parsers/parseZodUnionDef"; 11 | import { 12 | type ZodArrayDef, 13 | type ZodBigIntDef, 14 | type ZodBooleanDef, 15 | type ZodBrandedDef, 16 | type ZodDefaultDef, 17 | type ZodEffectsDef, 18 | type ZodEnumDef, 19 | ZodFirstPartyTypeKind, 20 | type ZodLiteralDef, 21 | type ZodNativeEnumDef, 22 | type ZodNullDef, 23 | type ZodNullableDef, 24 | type ZodNumberDef, 25 | type ZodObjectDef, 26 | type ZodOptionalDef, 27 | type ZodPromiseDef, 28 | type ZodStringDef, 29 | type ZodUndefinedDef, 30 | type ZodUnionDef, 31 | type ZodVoidDef, 32 | } from "zod"; 33 | import type { ParserSelectorFunction } from "../../parseNodeTypes"; 34 | import { parseZodArrayDef } from "./parsers/parseZodArrayDef"; 35 | import { parseZodBooleanFieldDef } from "./parsers/parseZodBooleanFieldDef"; 36 | import { 37 | type ZodDiscriminatedUnionDefUnversioned, 38 | parseZodDiscriminatedUnionDef, 39 | } from "./parsers/parseZodDiscriminatedUnionDef"; 40 | import { parseZodEnumDef } from "./parsers/parseZodEnumDef"; 41 | import { parseZodLiteralDef } from "./parsers/parseZodLiteralDef"; 42 | import { parseZodNativeEnumDef } from "./parsers/parseZodNativeEnumDef"; 43 | import { parseZodNumberDef } from "./parsers/parseZodNumberDef"; 44 | import { parseZodObjectDef } from "./parsers/parseZodObjectDef"; 45 | import { parseZodStringDef } from "./parsers/parseZodStringDef"; 46 | import { parseZodVoidDef } from "./parsers/parseZodVoidDef"; 47 | import type { ZodDefWithType } from "./zod-types"; 48 | 49 | export const zodSelectorFunction: ParserSelectorFunction = ( 50 | def, 51 | references, 52 | ) => { 53 | // const optional = isZodOptional(zodAny); 54 | // const unwrappedOptional = optional ? zodAny._def.innerType : zodAny; 55 | // Please keep these in alphabetical order 56 | switch (def.typeName) { 57 | case ZodFirstPartyTypeKind.ZodArray: 58 | return parseZodArrayDef(def as ZodArrayDef, references); 59 | case ZodFirstPartyTypeKind.ZodBoolean: 60 | return parseZodBooleanFieldDef(def as ZodBooleanDef, references); 61 | case ZodFirstPartyTypeKind.ZodDiscriminatedUnion: 62 | return parseZodDiscriminatedUnionDef( 63 | // Zod had some type changes between 3.19 -> 3.20 and we want to support both, not sure there's a way 64 | // to avoid this. 65 | def as unknown as ZodDiscriminatedUnionDefUnversioned, 66 | references, 67 | ); 68 | case ZodFirstPartyTypeKind.ZodEnum: 69 | return parseZodEnumDef(def as ZodEnumDef, references); 70 | case ZodFirstPartyTypeKind.ZodNativeEnum: 71 | return parseZodNativeEnumDef(def as ZodNativeEnumDef, references); 72 | case ZodFirstPartyTypeKind.ZodLiteral: 73 | return parseZodLiteralDef(def as ZodLiteralDef, references); 74 | case ZodFirstPartyTypeKind.ZodNumber: 75 | return parseZodNumberDef(def as ZodNumberDef, references); 76 | case ZodFirstPartyTypeKind.ZodObject: 77 | return parseZodObjectDef(def as ZodObjectDef, references); 78 | case ZodFirstPartyTypeKind.ZodOptional: 79 | return parseZodOptionalDef(def as ZodOptionalDef, references); 80 | case ZodFirstPartyTypeKind.ZodString: 81 | return parseZodStringDef(def as ZodStringDef, references); 82 | case ZodFirstPartyTypeKind.ZodNullable: 83 | return parseZodNullableDef(def as ZodNullableDef, references); 84 | case ZodFirstPartyTypeKind.ZodBigInt: 85 | return parseZodBigIntDef(def as ZodBigIntDef, references); 86 | case ZodFirstPartyTypeKind.ZodBranded: 87 | return parseZodBrandedDef(def as ZodBrandedDef, references); 88 | case ZodFirstPartyTypeKind.ZodDefault: 89 | return parseZodDefaultDef(def as ZodDefaultDef, references); 90 | case ZodFirstPartyTypeKind.ZodEffects: 91 | return parseZodEffectsDef(def as ZodEffectsDef, references); 92 | case ZodFirstPartyTypeKind.ZodNull: 93 | return parseZodNullDef(def as ZodNullDef, references); 94 | case ZodFirstPartyTypeKind.ZodPromise: 95 | return parseZodPromiseDef(def as ZodPromiseDef, references); 96 | case ZodFirstPartyTypeKind.ZodUndefined: 97 | return parseZodUndefinedDef(def as ZodUndefinedDef, references); 98 | case ZodFirstPartyTypeKind.ZodUnion: 99 | return parseZodUnionDef(def as ZodUnionDef, references); 100 | case ZodFirstPartyTypeKind.ZodVoid: 101 | return parseZodVoidDef(def as ZodVoidDef, references); 102 | } 103 | return { type: "unsupported", path: references.path }; 104 | }; 105 | -------------------------------------------------------------------------------- /packages/trpc-ui/src/react-app/components/HeadersPopup.tsx: -------------------------------------------------------------------------------- 1 | import XIcon from "@mui/icons-material/Close"; 2 | import SaveIcon from "@mui/icons-material/Lock"; 3 | import { AddItemButton } from "@src/react-app/components/AddItemButton"; 4 | import { Button } from "@src/react-app/components/Button"; 5 | import { useHeadersContext } from "@src/react-app/components/contexts/HeadersContext"; 6 | import { FieldError } from "@src/react-app/components/form/fields/FieldError"; 7 | import { BaseTextField } from "@src/react-app/components/form/fields/base/BaseTextField"; 8 | import React, { useEffect, useState } from "react"; 9 | import toast from "react-hot-toast"; 10 | 11 | export function HeadersPopup() { 12 | const { 13 | headersPopupShown, 14 | setHeadersPopupShown, 15 | getHeaders, 16 | setHeaders: setContextHeaders, 17 | saveHeadersToLocalStorage, 18 | setSaveHeadersToLocalStorage, 19 | } = useHeadersContext(); 20 | const [headers, setHeaders] = useState<[string, string][]>([]); 21 | const [errors, setErrors] = useState([]); 22 | 23 | function addHeader() { 24 | setHeaders((old) => [...old, ["", ""]]); 25 | } 26 | 27 | function clearErrorIfNecessary(index: number) { 28 | if (!errors[index]) return; 29 | const newErrors = [...errors]; 30 | newErrors[index] = false; 31 | setErrors(newErrors); 32 | } 33 | 34 | function update(index: number, value: string, type: "key" | "value") { 35 | const newHeaders = [...headers]; 36 | const newValue = newHeaders[index]!; 37 | newValue[type === "key" ? 0 : 1] = value; 38 | newHeaders[index] = newValue; 39 | setHeaders(newHeaders); 40 | clearErrorIfNecessary(index); 41 | } 42 | 43 | function deleteHeader(index: number) { 44 | const newHeaders = [...headers]; 45 | const newErrors = [...errors]; 46 | newHeaders.splice(index, 1); 47 | newErrors.splice(index, 1); 48 | setHeaders(newHeaders); 49 | setErrors(newErrors); 50 | } 51 | 52 | function onExitPress() { 53 | setHeadersPopupShown(false); 54 | } 55 | 56 | function onConfirmClick() { 57 | const newErrors: boolean[] = [...errors]; 58 | let i = 0; 59 | for (const [headerKey, headerValue] of headers) { 60 | if (!headerKey || !headerValue) { 61 | newErrors[i] = true; 62 | } 63 | i++; 64 | } 65 | if (newErrors.some((e) => e)) { 66 | setErrors(newErrors); 67 | return; 68 | } 69 | setContextHeaders(Object.fromEntries(headers)); 70 | setHeadersPopupShown(false); 71 | toast("Headers updated."); 72 | } 73 | 74 | useEffect(() => { 75 | if (headersPopupShown) { 76 | setHeaders(Object.entries(getHeaders())); 77 | } 78 | }, [headersPopupShown]); 79 | if (!headersPopupShown) return null; 80 | return ( 81 |
82 |
{ 84 | e.preventDefault(); 85 | onConfirmClick(); 86 | }} 87 | className="flex w-full max-w-2xl flex-col space-y-4 rounded-md bg-white" 88 | > 89 |
90 |

Headers

91 | 94 |
95 |
96 | {headers.map(([headerKey, headerValue], i) => ( 97 | // biome-ignore lint/suspicious/noArrayIndexKey: their order doesn't change 98 |
99 |
100 | update(i, value, "key")} 105 | /> 106 | 107 | update(i, value, "value")} 112 | /> 113 | 120 |
121 | {errors[i] && ( 122 | 123 | )} 124 |
125 | ))} 126 | 127 |
128 |
129 | 130 | Save Headers 131 | setSaveHeadersToLocalStorage(e.target.checked)} 136 | /> 137 | 138 | 141 |
142 |
143 |
144 | ); 145 | } 146 | --------------------------------------------------------------------------------