Skip to content

Commit

Permalink
feat(zui): New typescript transform (#332)
Browse files Browse the repository at this point in the history
  • Loading branch information
charlescatta authored Jun 17, 2024
1 parent 9be2ce1 commit 065818c
Show file tree
Hide file tree
Showing 15 changed files with 1,320 additions and 19 deletions.
7 changes: 4 additions & 3 deletions zui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bpinternal/zui",
"version": "0.8.4",
"version": "0.8.5",
"description": "An extension of Zod for working nicely with UIs and JSON Schemas",
"type": "module",
"source": "./src/index.ts",
Expand Down Expand Up @@ -49,14 +49,15 @@
"jsdom": "^24.0.0",
"local-ref-resolver": "^0.2.0",
"prettier": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"storybook": "^8.0.9",
"ts-morph": "^22.0.0",
"tsup": "^8.0.1",
"tsx": "^4.7.2",
"typescript": "5.4.5",
"vite": "^5.2.6",
"vitest": "1.5.2",
"react": "^18.2.0"
"vitest": "1.5.2"
},
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^11.5.5",
Expand Down
106 changes: 94 additions & 12 deletions zui/playground.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,122 @@
import { z } from './src'
import fs from 'node:fs'

const aschema = z.object({
payment: z.discriminatedUnion('type', [
const obj1 = z
.discriminatedUnion('type', [
z.object({
type: z.literal('Credit Card'),
cardNumber: z.string().title('Credit Card Number').placeholder('1234 5678 9012 3456'),
expirationDate: z.string().title('Expiration Date').placeholder('10/29'),
brand: z.enum(['Visa', 'Mastercard', 'American Express']).default('Visa'),
cardNumber: z
.string()
.title('Credit Card Number')
.placeholder('1234 5678 9012 3456')
.describe('This is the card number'),
expirationDate: z.string().title('Expiration Date').placeholder('10/29').describe('This is the expiration date'),
brand: z
.enum(['Visa', 'Mastercard', 'American Express'])
.nullable()
.optional()
.default('Visa')
.describe('This is the brand of the card'),
}),
z.object({
type: z.literal('PayPal'),
email: z.string().email().title('Paypal Email').placeholder('john.doe@gmail.com'),
email: z
.string()
.email()
.title('Paypal Email')
.placeholder('john@doe.com')
.describe("This is the paypal account's email address"),
}),
z.object({
type: z.literal('Bitcoin'),
address: z.string().title('Bitcoin Address').placeholder('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa'),
address: z
.string()
.title('Bitcoin Address')
.placeholder('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa')
.describe('This is the bitcoin address'),
}),
z.object({
type: z.literal('Bank Transfer'),
accountNumber: z.string().title('Account Number').placeholder('1234567890'),
accountNumber: z
.string()
.title('Account Number')
.placeholder('1234567890')
.describe('This is the bank account number'),
}),
z
.object({
type: z.literal('Cash'),
amount: z
.number()
.title('Amount')
.disabled((value) => (value || 0) > 100),
.disabled((value) => (value || 0) > 100)
.describe('This is the amount of cash'),
})
.disabled((obj) => {
return {
type: !!obj && obj.amount > 100,
}
})
.disabled((obj) => {
.disabled(() => {
return false
}),
]),
})
])
.title('payment')

const obj2 = z
.array(
z.object({
data: z.templateLiteral().literal('bro ').interpolated(z.string()).literal('!'),
name: z.string().optional().title('Name').placeholder('John Doe').describe('This is the name'),
age: z.number().nullable().title('Age').placeholder('18').describe('This is the age'),
email: z.string().email().title('Email').placeholder('The email').describe('This is the email'),
aUnion: z.union([z.string(), z.number()]).title('A Union').placeholder('A Union').describe('This is a union'),
aTuple: z.tuple([z.string(), z.number()]).title('A Tuple').placeholder('A Tuple').describe('This is a tuple'),
aRecord: z.record(z.number()).title('A Record').placeholder('A Record').describe('This is a record'),
anArray: z.array(z.number()).title('An Array').placeholder('An Array').describe('This is an array'),
aSet: z.set(z.number()).title('A Set').placeholder('A Set').describe('This is a set'),
aMap: z.map(z.string(), z.array(z.any())).title('A Map').placeholder('A Map').describe('This is a map'),
aFunction: z
.function()
.args(z.array(z.union([z.literal('bob'), z.literal('steve')], z.string())).title('names'))
.returns(z.literal('bro'))
.title('A Function')
.placeholder('A Function')
.describe('This is a function'),
aPromise: z.promise(z.number()).title('A Promise').placeholder('A Promise').describe('This is a promise'),
aLazy: z
.lazy(() => z.string())
.title('A Lazy')
.placeholder('A Lazy')
.describe('This is a lazy'),
aDate: z.date().title('A Date').placeholder('A Date').describe('This is a date'),
aOptional: z.optional(z.string()).title('An Optional').placeholder('An Optional').describe('This is an optional'),
aNullable: z.nullable(z.string()).title('A Nullable').placeholder('A Nullable').describe('This is a nullable'),
}),
)
.title('users')

const obj3 = z
.object({
address: z.lazy(() =>
z
.record(
z.number(),
z.object({
street: z.string(),
number: z.number(),
}),
)
.describe('This is a record'),
),
})
.title('MyObject')

const typings = [
obj1.toTypescript({ declaration: true }),
obj2.toTypescript({ declaration: true }),
obj3.toTypescript({ declaration: true }),
].join('\n\n')


fs.writeFileSync('./output.ts', typings)
38 changes: 38 additions & 0 deletions zui/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions zui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { jsonSchemaToZui } from './transforms/json-schema-to-zui'
import { zuiToJsonSchema } from './transforms/zui-to-json-schema'
import { objectToZui } from './transforms/object-to-zui'
import { toTypescriptTypings } from './transforms/zui-to-typescript'
import {
toTypescript,
UntitledDeclarationError,
TypescriptGenerationOptions,
} from './transforms/zui-to-typescript-next'

export type {
BaseType,
Expand All @@ -23,5 +28,11 @@ export const transforms = {
jsonSchemaToZui,
zuiToJsonSchema,
objectToZui,
toTypescript,
/**
* @deprecated use toTypescript instead
*/
zuiToTypescriptTypings: toTypescriptTypings,
}

export { UntitledDeclarationError, type TypescriptGenerationOptions }
49 changes: 49 additions & 0 deletions zui/src/setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect } from 'vitest'
import { Project, Diagnostic } from 'ts-morph'

export function isValidTypescript(
code: string,
): { isValid: true } | { isValid: false; diagnostics: Diagnostic[]; errorMessage: string } {
const project = new Project({})
try {
project.createSourceFile('test.ts', code)
const diags = project.getPreEmitDiagnostics()
if (diags.length) {
return { isValid: false, diagnostics: diags, errorMessage: project.formatDiagnosticsWithColorAndContext(diags) }
}
return { isValid: true }
} catch (e: any) {
return { isValid: false, diagnostics: [], errorMessage: e?.message || '' }
}
}

expect.extend({
toBeValidTypeScript(received: string) {
const { isNot } = this
const validation = isValidTypescript(received)

return {
pass: validation.isValid,
message: () => {
return `Expected code to ${isNot ? 'not ' : ''}be valid TypeScript:\n${received}\n\n${validation.isValid ? '' : validation.errorMessage}`
},
}
},
})

expect.extend({
toMatchWithoutFormatting(received: string, expected: string, _) {
const { isNot } = this
const transformedReceived = received.replace(/\s+/g, '')
const transformedExpected = expected.replace(/\s+/g, '')

return {
pass: transformedExpected === transformedReceived,
message: () => {
const message = isNot ? 'not ' : ''
const diffView = this.utils.diff(transformedExpected, transformedReceived, { expand: true })
return `Expected output to ${message}match without formatting:\n${diffView}`
},
}
},
})
Loading

0 comments on commit 065818c

Please sign in to comment.