diff --git a/frontend/package.json b/frontend/package.json index d988ec29dc4..fd95f3721e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,7 +41,7 @@ "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@glideapps/glide-data-grid": "6.0.4-alpha16", - "@hookform/resolvers": "^3.10.0", + "@hookform/resolvers": "^5.2.2", "@img-comparison-slider/react": "^8.0.2", "@internationalized/date": "^3.9.0", "@lezer/common": "^1.2.3", @@ -170,7 +170,7 @@ "vscode-jsonrpc": "^8.2.1", "vscode-languageserver-protocol": "^3.17.5", "web-vitals": "^4.2.4", - "zod": "^3.25.76" + "zod": "^4.1.11" }, "scripts": { "preinstall": "npx only-allow pnpm", diff --git a/frontend/src/__tests__/main.test.tsx b/frontend/src/__tests__/main.test.tsx index 3abc4eca5c0..efcd78721e0 100644 --- a/frontend/src/__tests__/main.test.tsx +++ b/frontend/src/__tests__/main.test.tsx @@ -4,8 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { type AppConfig, + defaultUserConfig, parseAppConfig, - parseUserConfig, } from "@/core/config/config-schema"; import { appConfigAtom, @@ -41,7 +41,7 @@ describe("main", () => { store.set(showCodeInRunModeAtom, false); store.set(marimoVersionAtom, "unknown"); store.set(appConfigAtom, parseAppConfig({})); - store.set(userConfigAtom, parseUserConfig({})); + store.set(userConfigAtom, defaultUserConfig()); store.set(configOverridesAtom, {}); }); @@ -106,7 +106,7 @@ describe("main", () => { el, ); expect(error).toBeUndefined(); - expect(store.get(userConfigAtom)).toEqual(parseUserConfig({})); + expect(store.get(userConfigAtom)).toEqual(defaultUserConfig()); expect(store.get(configOverridesAtom)).toEqual({}); expect(store.get(appConfigAtom)).toEqual(parseAppConfig({})); expect(store.get(viewStateAtom).mode).toBe("edit"); diff --git a/frontend/src/components/app-config/app-config-form.tsx b/frontend/src/components/app-config/app-config-form.tsx index 058e81e7f0f..19edc4ce815 100644 --- a/frontend/src/components/app-config/app-config-form.tsx +++ b/frontend/src/components/app-config/app-config-form.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useId } from "react"; import { useForm } from "react-hook-form"; +import type { z } from "zod"; import { Form, FormControl, @@ -42,8 +43,10 @@ export const AppConfigForm: React.FC = () => { const ipynbCheckboxId = useId(); // Create form - const form = useForm({ - resolver: zodResolver(AppConfigSchema), + const form = useForm({ + resolver: zodResolver( + AppConfigSchema as unknown as z.ZodType, + ), defaultValues: config, }); diff --git a/frontend/src/components/app-config/user-config-form.tsx b/frontend/src/components/app-config/user-config-form.tsx index 6f3a33757f0..ff195a2a5f6 100644 --- a/frontend/src/components/app-config/user-config-form.tsx +++ b/frontend/src/components/app-config/user-config-form.tsx @@ -15,6 +15,7 @@ import { import React, { useId, useRef } from "react"; import { useLocale } from "react-aria"; import { useForm } from "react-hook-form"; +import type z from "zod"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -124,8 +125,10 @@ export const UserConfigForm: React.FC = () => { const { saveUserConfig } = useRequestClient(); // Create form - const form = useForm({ - resolver: zodResolver(UserConfigSchema), + const form = useForm({ + resolver: zodResolver( + UserConfigSchema as unknown as z.ZodType, + ), defaultValues: config, }); diff --git a/frontend/src/components/data-table/filters.ts b/frontend/src/components/data-table/filters.ts index b46ab7c7896..585d21275bf 100644 --- a/frontend/src/components/data-table/filters.ts +++ b/frontend/src/components/data-table/filters.ts @@ -92,6 +92,7 @@ export function filterToFilterCondition( return { column_id: columnId, operator: filter.operator, + value: undefined, }; } @@ -179,12 +180,14 @@ export function filterToFilterCondition( return { column_id: columnId, operator: "is_true", + value: undefined, }; } if (!filter.value) { return { column_id: columnId, operator: "is_false", + value: undefined, }; } diff --git a/frontend/src/components/editor/chrome/wrapper/storage.ts b/frontend/src/components/editor/chrome/wrapper/storage.ts index 4375aa83f2a..81cb6182a31 100644 --- a/frontend/src/components/editor/chrome/wrapper/storage.ts +++ b/frontend/src/components/editor/chrome/wrapper/storage.ts @@ -3,7 +3,7 @@ import type { PanelGroupStorage } from "react-resizable-panels"; import { z } from "zod"; import { Objects } from "@/utils/objects"; -const schema = z.record(z.tuple([z.number(), z.number()])); +const schema = z.record(z.string(), z.tuple([z.number(), z.number()])); let storedValue: string | null = null; diff --git a/frontend/src/components/editor/database/__tests__/as-code.test.ts b/frontend/src/components/editor/database/__tests__/as-code.test.ts index 4b5e640b806..90739b2fd1b 100644 --- a/frontend/src/components/editor/database/__tests__/as-code.test.ts +++ b/frontend/src/components/editor/database/__tests__/as-code.test.ts @@ -101,6 +101,7 @@ describe("generateDatabaseCode", () => { type: "REST", uri: "http://localhost:8181", warehouse: "/path/to/warehouse", + token: undefined, }, }; @@ -130,6 +131,7 @@ describe("generateDatabaseCode", () => { catalog: { type: "Glue", warehouse: "/path/to/warehouse", + uri: undefined, }, }; diff --git a/frontend/src/components/editor/database/add-database-form.tsx b/frontend/src/components/editor/database/add-database-form.tsx index 74d976ef733..bc0b5b62de3 100644 --- a/frontend/src/components/editor/database/add-database-form.tsx +++ b/frontend/src/components/editor/database/add-database-form.tsx @@ -60,7 +60,7 @@ interface Props { interface ConnectionSchema { name: string; - schema: z.ZodType; + schema: z.ZodType; color: string; logo: DBLogoName; connectionLibraries: { @@ -237,7 +237,7 @@ const DATA_CATALOGS = [ ] satisfies ConnectionSchema[]; const DatabaseSchemaSelector: React.FC<{ - onSelect: (schema: z.ZodType) => void; + onSelect: (schema: z.ZodType) => void; }> = ({ onSelect }) => { const renderItem = ({ name, schema, color, logo }: ConnectionSchema) => { return ( @@ -276,13 +276,15 @@ const DatabaseSchemaSelector: React.FC<{ const RENDERERS: FormRenderer[] = [ENV_RENDERER]; const DatabaseForm: React.FC<{ - schema: z.ZodType; + schema: z.ZodType; onSubmit: () => void; onBack: () => void; }> = ({ schema, onSubmit, onBack }) => { const form = useForm({ defaultValues: getDefaults(schema), - resolver: zodResolver(schema), + resolver: zodResolver( + schema as unknown as z.ZodType, + ), reValidateMode: "onChange", }); @@ -357,7 +359,8 @@ const DatabaseForm: React.FC<{ }; const AddDatabaseForm: React.FC = ({ onSubmit }) => { - const [selectedSchema, setSelectedSchema] = useState(null); + const [selectedSchema, setSelectedSchema] = + useState | null>(null); if (!selectedSchema) { return ; diff --git a/frontend/src/components/editor/database/as-code.ts b/frontend/src/components/editor/database/as-code.ts index 7b1f473aab0..f2c68d81d8e 100644 --- a/frontend/src/components/editor/database/as-code.ts +++ b/frontend/src/components/editor/database/as-code.ts @@ -514,7 +514,7 @@ class PyIcebergGenerator extends CodeGenerator<"iceberg"> { } generateConnectionCode(): string { - let options: Record = { + let options: Record = { ...this.connection.catalog, }; // Remove k='type' and v=nullish values diff --git a/frontend/src/components/editor/database/form-renderers.tsx b/frontend/src/components/editor/database/form-renderers.tsx index 3873fa38440..c4bd5b5fc75 100644 --- a/frontend/src/components/editor/database/form-renderers.tsx +++ b/frontend/src/components/editor/database/form-renderers.tsx @@ -96,7 +96,7 @@ export const ENV_RENDERER: FormRenderer = { isMatch: (schema: z.ZodType): schema is z.ZodString | z.ZodNumber => { // string or number with optionsRegex if (schema instanceof z.ZodString || schema instanceof z.ZodNumber) { - const { optionRegex } = FieldOptions.parse(schema._def.description || ""); + const { optionRegex } = FieldOptions.parse(schema.description || ""); return Boolean(optionRegex); } @@ -110,7 +110,7 @@ export const ENV_RENDERER: FormRenderer = { label, description, optionRegex = "", - } = FieldOptions.parse(schema._def.description || ""); + } = FieldOptions.parse(schema.description || ""); const [recommendedKeys, otherKeys] = partition(secretKeys, (key) => new RegExp(optionRegex, "i").test(key), diff --git a/frontend/src/components/editor/database/schemas.ts b/frontend/src/components/editor/database/schemas.ts index 19a6f2df1f3..f54b20d73e9 100644 --- a/frontend/src/components/editor/database/schemas.ts +++ b/frontend/src/components/editor/database/schemas.ts @@ -17,7 +17,14 @@ function passwordField() { } function tokenField(label?: string, required?: boolean) { - const field = z.string().describe( + let field: z.ZodString | z.ZodOptional = z.string(); + if (required) { + field = field.nonempty(); + } else { + field = field.optional(); + } + + field = field.describe( FieldOptions.of({ label: label || "Token", inputType: "password", @@ -25,7 +32,7 @@ function tokenField(label?: string, required?: boolean) { optionRegex: ".*token.*", }), ); - return required ? field.nonempty() : field.optional(); + return field; } function warehouseNameField() { @@ -42,12 +49,16 @@ function warehouseNameField() { } function uriField(label?: string, required?: boolean) { - const field = z - .string() - .describe( - FieldOptions.of({ label: label || "URI", optionRegex: ".*uri.*" }), - ); - return required ? field.nonempty() : field.optional(); + let field: z.ZodString | z.ZodOptional = z.string(); + if (required) { + field = field.nonempty(); + } else { + field = field.optional(); + } + + return field.describe( + FieldOptions.of({ label: label || "URI", optionRegex: ".*uri.*" }), + ); } function hostField(label?: string) { @@ -112,7 +123,7 @@ function portField(defaultPort?: number) { }); if (defaultPort !== undefined) { - return field.default(defaultPort.toString()); + return field.default(defaultPort); } return field; @@ -383,6 +394,7 @@ export const IcebergConnectionSchema = z.object({ ]) .default({ type: "REST", + token: undefined, }) .describe(FieldOptions.of({ special: "tabs" })), }); diff --git a/frontend/src/components/editor/package-alert.tsx b/frontend/src/components/editor/package-alert.tsx index 776b2d9c42e..bf681563dc7 100644 --- a/frontend/src/components/editor/package-alert.tsx +++ b/frontend/src/components/editor/package-alert.tsx @@ -15,6 +15,7 @@ import { import type React from "react"; import { useState } from "react"; import { useForm } from "react-hook-form"; +import type { z } from "zod"; import { Form, FormControl, @@ -359,8 +360,10 @@ const PackageManagerForm: React.FC = () => { const { saveUserConfig } = useRequestClient(); // Create form - const form = useForm({ - resolver: zodResolver(UserConfigSchema), + const form = useForm({ + resolver: zodResolver( + UserConfigSchema as unknown as z.ZodType, + ), defaultValues: config, }); diff --git a/frontend/src/components/editor/renderers/types.ts b/frontend/src/components/editor/renderers/types.ts index cda4359b1ad..b025007cbb0 100644 --- a/frontend/src/components/editor/renderers/types.ts +++ b/frontend/src/components/editor/renderers/types.ts @@ -1,6 +1,6 @@ /* Copyright 2024 Marimo. All rights reserved. */ -import type { ZodType, ZodTypeDef } from "zod"; +import type { ZodType } from "zod"; import type { CellData, CellRuntimeState } from "@/core/cells/types"; import type { AppConfig } from "@/core/config/config-schema"; import type { AppMode } from "@/core/mode"; @@ -64,7 +64,7 @@ export interface ICellRendererPlugin { /** * Validate the layout data. Use [zod](https://zod.dev/) to validate the data. */ - validator: ZodType; + validator: ZodType; deserializeLayout: (layout: S, cells: CellData[]) => L; serializeLayout: (layout: L, cells: CellData[]) => S; diff --git a/frontend/src/components/forms/__tests__/form-utils.test.ts b/frontend/src/components/forms/__tests__/form-utils.test.ts index fecdb7fc26f..2d4f2efc796 100644 --- a/frontend/src/components/forms/__tests__/form-utils.test.ts +++ b/frontend/src/components/forms/__tests__/form-utils.test.ts @@ -101,4 +101,181 @@ describe("getDefaults", () => { }, ]); }); + + it("should return default for ZodOptional with default", () => { + const schema = z.object({ + foo: z.string().optional().default("bar"), + }); + const result = getDefaults(schema); + expect(result).toEqual({ foo: "bar" }); + }); + + it("should return undefined for ZodOptional without default", () => { + const schema = z.object({ + foo: z.string().optional(), + }); + const result = getDefaults(schema); + expect(result).toEqual({ foo: undefined }); + }); + + it("should return null for ZodNullable with nullish default", () => { + const schema = z.object({ + foo: z.string().nullable().default(null), + }); + const result = getDefaults(schema); + expect(result).toEqual({ foo: null }); + }); + + it("should handle nested objects with defaults", () => { + const schema = z.object({ + outer: z.object({ + inner: z.string().default("baz"), + }), + }); + + const result1 = getDefaults(schema); + expect(result1).toEqual({ outer: undefined }); + + const schema2 = schema.default({ + outer: { + inner: "boo", + }, + }); + const result2 = getDefaults(schema2); + expect(result2).toEqual({ outer: { inner: "boo" } }); + }); + + it("should handle ZodEnum with default", () => { + const schema = z.object({ + color: z.enum(["red", "green", "blue"]).default("green"), + }); + const result = getDefaults(schema); + expect(result).toEqual({ color: "green" }); + }); + + it("should handle ZodUnion with default", () => { + const schema = z.object({ + value: z.union([z.string(), z.number()]).default("foo"), + }); + const result = getDefaults(schema); + expect(result).toEqual({ value: "foo" }); + }); + + it("should handle ZodLiteral with default", () => { + const schema = z.object({ + lit: z.literal("abc").default("abc"), + }); + const result = getDefaults(schema); + expect(result).toEqual({ lit: "abc" }); + }); + + it("should handle ZodDefault on ZodArray", () => { + const schema = z.object({ + arr: z.array(z.number()).default([1, 2, 3]), + }); + const result = getDefaults(schema); + expect(result).toEqual({ arr: [1, 2, 3] }); + }); + + it("should handle ZodRecord with default", () => { + const schema = z.object({ + rec: z.record(z.string(), z.string()).default({ foo: "bar" }), + }); + const result = getDefaults(schema); + expect(result).toEqual({ rec: { foo: "bar" } }); + }); + + it("should handle ZodMap with default", () => { + const schema = z.object({ + map: z.map(z.string(), z.number()).default(new Map([["a", 1]])), + }); + const result = getDefaults(schema) as { map: Map }; + expect(result.map instanceof Map).toBe(true); + expect(Array.from(result.map.entries())).toEqual([["a", 1]]); + }); + + it("should handle ZodSet with default", () => { + const schema = z.object({ + set: z.set(z.string()).default(new Set(["a", "b"])), + }); + const result = getDefaults(schema) as { set: Set }; + expect(result.set instanceof Set).toBe(true); + expect(Array.from(result.set)).toEqual(["a", "b"]); + }); + + it("should handle deeply nested defaults", () => { + const schema = z.object({ + a: z.object({ + b: z.object({ + c: z.string().default("deep"), + }), + }), + }); + const result = getDefaults(schema); + expect(result).toEqual({ a: undefined }); + }); + + it("should handle ZodObject with no properties", () => { + const schema = z.object({}); + const result = getDefaults(schema); + expect(result).toEqual({}); + }); + + it("should handle ZodTuple with defaults", () => { + const schema = z.object({ + tup: z.tuple([z.string().default("a"), z.number().default(1)]), + }); + const result = getDefaults(schema); + expect(result).toEqual({ tup: ["a", 1] }); + }); + + it("should handle ZodDefault on ZodObject", () => { + const schema = z + .object({ + foo: z.string(), + }) + .default({ foo: "bar" }); + const result = getDefaults(schema); + expect(result).toEqual({ foo: "bar" }); + }); + + it("should handle ZodDefault on ZodString", () => { + const schema = z.object({ + foo: z.string().default("bar"), + }); + const result = getDefaults(schema); + expect(result).toEqual({ foo: "bar" }); + }); + + it("should handle ZodDefault on ZodNumber", () => { + const schema = z.object({ + num: z.number().default(42), + }); + const result = getDefaults(schema); + expect(result).toEqual({ num: 42 }); + }); + + it("should handle ZodDefault on ZodBoolean", () => { + const schema = z.object({ + flag: z.boolean().default(true), + }); + const result = getDefaults(schema); + expect(result).toEqual({ flag: true }); + }); + + it("should handle ZodNullable with default", () => { + const schema = z.object({ + maybe: z.string().nullable().default("x"), + }); + const result = getDefaults(schema); + expect(result).toEqual({ maybe: "x" }); + }); + + it("should handle ZodOptional and ZodNullable with no default", () => { + const schema = z.object({ + maybe: z.string().optional().nullable(), + }); + const result = getDefaults(schema); + expect(result).toEqual({ maybe: undefined }); + }); }); diff --git a/frontend/src/components/forms/form-utils.ts b/frontend/src/components/forms/form-utils.ts index 6a4fe1890d9..f5b0cdfdc7f 100644 --- a/frontend/src/components/forms/form-utils.ts +++ b/frontend/src/components/forms/form-utils.ts @@ -2,6 +2,14 @@ import { z } from "zod"; import { Logger } from "@/utils/Logger"; +import { isZodArray, isZodPipe, isZodTuple } from "@/utils/zod-utils"; + +export function maybeUnwrap(schema: T): z.ZodType { + if ("unwrap" in schema) { + return (schema as unknown as z.ZodOptional).unwrap() as z.ZodType; + } + return schema; +} /** * Get default values for a zod schema @@ -9,20 +17,28 @@ import { Logger } from "@/utils/Logger"; export function getDefaults, T>( schema: TSchema, ): T { - const getDefaultValue = (schema: z.ZodTypeAny): unknown => { + const getDefaultValue = (schema: z.ZodType): unknown => { if (schema instanceof z.ZodLiteral) { - return schema._def.value; + const values = [...schema.values]; + if (schema.values.size === 1) { + return values[0]; + } + return values; } if (schema instanceof z.ZodDefault) { - return schema._def.defaultValue(); + const defValue = schema.def.defaultValue; + return typeof defValue === "function" ? defValue() : defValue; + } + if (isZodPipe(schema)) { + return getDefaultValue(schema.in); } - if (schema instanceof z.ZodEffects) { - return getDefaults(schema._def.schema); + if (isZodTuple(schema)) { + return schema.def.items.map((item) => getDefaultValue(item)); } - if (!("innerType" in schema._def)) { - return undefined; + if ("unwrap" in schema) { + return getDefaultValue(maybeUnwrap(schema)); } - return getDefaultValue(schema._def.innerType); + return undefined; }; // If union, take the first one @@ -30,15 +46,15 @@ export function getDefaults, T>( schema instanceof z.ZodUnion || schema instanceof z.ZodDiscriminatedUnion ) { - return getDefaultValue(schema._def.options[0]) as T; + return getDefaultValue(schema.options[0] as z.ZodType) as T; } // If array, return an array of 1 item - if (schema instanceof z.ZodArray) { - if (schema._def.minLength && schema._def.minLength.value > 0) { - return [getDefaults(schema._def.type)] as unknown as T; + if (isZodArray(schema)) { + if (doesArrayRequireMinLength(schema)) { + return [getDefaults(schema.element)] as T; } - return [] as unknown as T; + return [] as T; } // If string, return the default value @@ -48,7 +64,8 @@ export function getDefaults, T>( // If enum, return the first value if (schema instanceof z.ZodEnum) { - return schema._def.values[0] as T; + const values = schema.options; + return values[0] as T; } // If not an object, return the default value @@ -57,8 +74,8 @@ export function getDefaults, T>( } return Object.fromEntries( - Object.entries(schema.shape).map(([key, value]) => { - return [key, getDefaultValue(value as z.AnyZodObject)]; + Object.entries(schema.shape).map(([key, value]: [string, z.ZodType]) => { + return [key, getDefaultValue(value)]; }), ) as T; } @@ -66,25 +83,33 @@ export function getDefaults, T>( /** * Get the literal value of a union */ -export function getUnionLiteral>( +export function getUnionLiteral( schema: T, ): z.ZodLiteral { if (schema instanceof z.ZodLiteral) { - return schema; + return schema as z.ZodLiteral; } if (schema instanceof z.ZodObject) { - const type = schema._def.shape().type; - if (type instanceof z.ZodLiteral) { - return type; + const typeField = schema.shape.type; + if (typeField instanceof z.ZodLiteral) { + return typeField as z.ZodLiteral; } - throw new Error(`Invalid schema: ${schema._type}`); + throw new Error("Invalid schema"); } if ( schema instanceof z.ZodUnion || schema instanceof z.ZodDiscriminatedUnion ) { - return getUnionLiteral(schema._def.options[0]); + return getUnionLiteral(schema.options[0] as z.ZodType); } Logger.warn(schema); - throw new Error(`Invalid schema: ${schema._type}`); + throw new Error("Invalid schema"); +} + +function doesArrayRequireMinLength(schema: T): boolean { + const result = schema.safeParse([]); + if (!result.success) { + return true; + } + return false; } diff --git a/frontend/src/components/forms/form.tsx b/frontend/src/components/forms/form.tsx index 079bcfcb17e..864b3b1edd9 100644 --- a/frontend/src/components/forms/form.tsx +++ b/frontend/src/components/forms/form.tsx @@ -27,6 +27,7 @@ import { import { cn } from "@/utils/cn"; import { Events } from "@/utils/events"; import { Strings } from "@/utils/strings"; +import { isZodPipe } from "@/utils/zod-utils"; import { Objects } from "../../utils/objects"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; @@ -40,16 +41,16 @@ import { } from "../ui/form"; import { DebouncedInput, DebouncedNumberInput } from "../ui/input"; import { Textarea } from "../ui/textarea"; -import { getDefaults, getUnionLiteral } from "./form-utils"; +import { getDefaults, getUnionLiteral, maybeUnwrap } from "./form-utils"; import { ensureStringArray, SwitchableMultiSelect, TextAreaMultiSelect, } from "./switchable-multi-select"; export interface FormRenderer { - isMatch: (schema: z.ZodType) => schema is z.ZodType; + isMatch: (schema: z.ZodType) => schema is z.ZodType; Component: React.ComponentType<{ - schema: z.ZodType; + schema: z.ZodType; form: UseFormReturn; path: Path; }>; @@ -97,10 +98,11 @@ export function renderZodSchema( description, special, direction = "column", - } = FieldOptions.parse(schema._def.description || ""); + minLength, + } = FieldOptions.parse(schema.description || ""); if (schema instanceof z.ZodDefault) { - let inner = schema._def.innerType as z.ZodType; + let inner = schema.unwrap() as z.ZodType; inner = !inner.description && schema.description ? inner.describe(schema.description) @@ -109,7 +111,7 @@ export function renderZodSchema( } if (schema instanceof z.ZodOptional) { - let inner = schema._def.innerType as z.ZodType; + let inner = schema.unwrap() as z.ZodType; inner = !inner.description && schema.description ? inner.describe(schema.description) @@ -130,10 +132,10 @@ export function renderZodSchema( )} > {label} - {Objects.entries(schema._def.shape()).map(([key, value]) => { + {Objects.entries(schema.shape).map(([key, value]) => { const isLiteral = value instanceof z.ZodLiteral; const childForm = renderZodSchema( - value as z.ZodType, + value as z.ZodType, form, joinPath(path, key), renderers, @@ -282,12 +284,13 @@ export function renderZodSchema( return ; } if (schema instanceof z.ZodEnum) { + const values = schema.options.map((option) => option.toString()); return ( ); } @@ -297,11 +300,13 @@ export function renderZodSchema( } // Inspect child type for a better input - const childType = schema._def.type; + const childType = schema.element; // Show multi-select for enum array if (childType instanceof z.ZodEnum) { - const childOptions: string[] = childType._def.values; + const childOptions: string[] = childType.options.map((option) => + option.toString(), + ); return ( (
@@ -330,9 +335,15 @@ export function renderZodSchema( } if (schema instanceof z.ZodDiscriminatedUnion) { - const options = schema._def.options as z.ZodType[]; - const discriminator = schema._def.discriminator; - const optionsMap = schema._def.optionsMap; + const def = schema.def; + const options = def.options as z.ZodType[]; + const discriminator = def.discriminator; + const getSchemaValue = (value: string) => { + return options.find((option) => { + return getUnionLiteral(option).value === value; + }); + }; + return ( ( render={({ field }) => { const value = field.value; const types = options.map((option) => { - return getUnionLiteral(option)._def.value; + return getUnionLiteral(option).value; }); const unionTypeValue: string = @@ -348,7 +359,7 @@ export function renderZodSchema( ? value[discriminator] : types[0]; - const selectedOption = optionsMap.get(unionTypeValue) || options[0]; + const selectedOption = getSchemaValue(unionTypeValue); return (
@@ -364,7 +375,7 @@ export function renderZodSchema( : "text-muted-foreground" }`} onClick={() => { - const nextSchema = optionsMap.get(type); + const nextSchema = getSchemaValue(type); if (nextSchema) { field.onChange(getDefaults(nextSchema)); } else { @@ -392,10 +403,10 @@ export function renderZodSchema( control={form.control} name={path} render={({ field }) => { - const options = schema._def.options as z.ZodType[]; + const options = schema.options as z.ZodType[]; let value: string = field.value; const types = options.map((option) => { - return getUnionLiteral(option)._def.value; + return getUnionLiteral(option).value; }); if (!value) { @@ -404,7 +415,7 @@ export function renderZodSchema( } const selectedOption = options.find((option) => { - return getUnionLiteral(option)._def.value === value; + return getUnionLiteral(option).value === value; }); return ( @@ -437,22 +448,27 @@ export function renderZodSchema( control={form.control} name={path} render={({ field }) => ( - + )} /> ); } - if ( - schema instanceof z.ZodEffects && - ["refinement", "transform"].includes(schema._def.effect.type) - ) { - return renderZodSchema(schema._def.schema, form, path, renderers); + if ("unwrap" in schema) { + // Handle ZodEffects (transforms/refinements) + return renderZodSchema(maybeUnwrap(schema), form, path, renderers); + } + if (isZodPipe(schema)) { + return renderZodSchema(schema.in, form, path, renderers); } return (
Unknown schema type{" "} - {schema == null ? path : JSON.stringify(schema._type ?? schema)} + {schema == null ? path : JSON.stringify(schema.type ?? schema)}
); } @@ -467,15 +483,13 @@ const FormArray = ({ minLength, renderers, }: { - schema: z.ZodType; + schema: z.ZodType; form: UseFormReturn; path: Path; renderers: FormRenderer[]; minLength?: number; }) => { - const { label, description } = FieldOptions.parse( - schema._def.description || "", - ); + const { label, description } = FieldOptions.parse(schema.description || ""); const control = form.control; // prepend, remove, swap, move, insert, replace @@ -546,7 +560,7 @@ const StringFormField = ({ path: Path; }) => { const { label, description, placeholder, disabled, inputType } = - FieldOptions.parse(schema._def.description); + FieldOptions.parse(schema.description); if (inputType === "textarea") { return ( @@ -613,7 +627,7 @@ const MultiStringFormField = ({ path: Path; }) => { const { label, description, placeholder } = FieldOptions.parse( - schema._def.description, + schema.description, ); return ( @@ -652,7 +666,7 @@ const SelectFormField = ({ textTransform?: (value: string) => string; }) => { const { label, description, disabled, special } = FieldOptions.parse( - schema._def.description, + schema.description, ); if (special === "radio_group") { @@ -760,7 +774,7 @@ const MultiSelectFormField = ({ showSwitchable?: boolean; }) => { const { label, description, placeholder } = FieldOptions.parse( - schema._def.description, + schema.description, ); const resolvePlaceholder = diff --git a/frontend/src/components/forms/options.ts b/frontend/src/components/forms/options.ts index 05d19cf87e3..97001ef2e53 100644 --- a/frontend/src/components/forms/options.ts +++ b/frontend/src/components/forms/options.ts @@ -6,6 +6,7 @@ export interface FieldOptions { disabled?: boolean; hidden?: boolean; direction?: "row" | "column" | "two-columns"; + minLength?: number; /** * Only valid for string fields */ diff --git a/frontend/src/core/config/__tests__/config-schema.test.ts b/frontend/src/core/config/__tests__/config-schema.test.ts index 43e33e727e5..7cfbfcbf27e 100644 --- a/frontend/src/core/config/__tests__/config-schema.test.ts +++ b/frontend/src/core/config/__tests__/config-schema.test.ts @@ -9,6 +9,7 @@ import { } from "../config"; import { AppConfigSchema, + defaultUserConfig, type UserConfig, UserConfigSchema, } from "../config-schema"; @@ -40,7 +41,7 @@ test("another AppConfig", () => { }); test("default UserConfig - empty", () => { - const defaultConfig = UserConfigSchema.parse({}); + const defaultConfig = defaultUserConfig(); expect(defaultConfig).toMatchInlineSnapshot(` { "ai": { diff --git a/frontend/src/core/config/config-schema.ts b/frontend/src/core/config/config-schema.ts index c7ea7d57e89..b630a4a6e6e 100644 --- a/frontend/src/core/config/config-schema.ts +++ b/frontend/src/core/config/config-schema.ts @@ -49,7 +49,7 @@ const AiConfigSchema = z api_key: z.string().optional(), base_url: z.string().optional(), }) - .passthrough(); + .loose(); const AiModelsSchema = z.object({ chat_model: z.string().nullish(), @@ -66,13 +66,13 @@ export type AIModelKey = keyof Pick< >; export const UserConfigSchema = z - .object({ + .looseObject({ completion: z .object({ - activate_on_typing: z.boolean().default(true), + activate_on_typing: z.boolean().prefault(true), copilot: z .union([z.boolean(), z.enum(["github", "codeium", "custom"])]) - .default(false) + .prefault(false) .transform((copilot) => { if (copilot === true) { return "github"; @@ -81,63 +81,58 @@ export const UserConfigSchema = z }), codeium_api_key: z.string().nullish(), }) - .passthrough() - .default({}), + .prefault({}), save: z - .object({ - autosave: z.enum(["off", "after_delay"]).default("after_delay"), + .looseObject({ + autosave: z.enum(["off", "after_delay"]).prefault("after_delay"), autosave_delay: z .number() .nonnegative() // Ensure that the delay is at least 1 second .transform((millis) => Math.max(millis, 1000)) - .default(1000), - format_on_save: z.boolean().default(false), + .prefault(1000), + format_on_save: z.boolean().prefault(false), }) - .passthrough() - .default({}), + .prefault({}), formatting: z - .object({ + .looseObject({ line_length: z .number() .nonnegative() - .default(79) + .prefault(79) .transform((n) => Math.min(n, 1000)), }) - .passthrough() - .default({}), + .prefault({}), keymap: z - .object({ - preset: z.enum(["default", "vim"]).default("default"), - overrides: z.record(z.string()).default({}), - destructive_delete: z.boolean().default(true), + .looseObject({ + preset: z.enum(["default", "vim"]).prefault("default"), + overrides: z.record(z.string(), z.string()).prefault({}), + destructive_delete: z.boolean().prefault(true), }) - .passthrough() - .default({}), + .prefault({}), runtime: z - .object({ - auto_instantiate: z.boolean().default(true), - on_cell_change: z.enum(["lazy", "autorun"]).default("autorun"), - auto_reload: z.enum(["off", "lazy", "autorun"]).default("off"), - watcher_on_save: z.enum(["lazy", "autorun"]).default("lazy"), - default_sql_output: z.enum(VALID_SQL_OUTPUT_FORMATS).default("auto"), + .looseObject({ + auto_instantiate: z.boolean().prefault(true), + on_cell_change: z.enum(["lazy", "autorun"]).prefault("autorun"), + auto_reload: z.enum(["off", "lazy", "autorun"]).prefault("off"), + watcher_on_save: z.enum(["lazy", "autorun"]).prefault("lazy"), + default_sql_output: z.enum(VALID_SQL_OUTPUT_FORMATS).prefault("auto"), default_auto_download: z .array(z.enum(AUTO_DOWNLOAD_FORMATS)) - .default([]), + .prefault([]), }) - .passthrough() - .default({}), + .prefault({}), display: z - .object({ - theme: z.enum(["light", "dark", "system"]).default("light"), - code_editor_font_size: z.number().nonnegative().default(14), - cell_output: z.enum(["above", "below"]).default("above"), - dataframes: z.enum(["rich", "plain"]).default("rich"), - default_table_page_size: z.number().default(10), - default_table_max_columns: z.number().default(50), + .looseObject({ + theme: z.enum(["light", "dark", "system"]).prefault("light"), + code_editor_font_size: z.number().nonnegative().prefault(14), + cell_output: z.enum(["above", "below"]).prefault("above"), + dataframes: z.enum(["rich", "plain"]).prefault("rich"), + default_table_page_size: z.number().prefault(10), + default_table_max_columns: z.number().prefault(50), default_width: z .enum(VALID_APP_WIDTHS) - .default("medium") + .prefault("medium") .transform((width) => { if (width === "normal") { return "compact"; @@ -145,20 +140,18 @@ export const UserConfigSchema = z return width; }), locale: z.string().nullable().optional(), - reference_highlighting: z.boolean().default(false), + reference_highlighting: z.boolean().prefault(false), }) - .passthrough() - .default({}), + .prefault({}), package_management: z - .object({ - manager: z.enum(PackageManagerNames).default("pip"), + .looseObject({ + manager: z.enum(PackageManagerNames).prefault("pip"), }) - .passthrough() - .default({ manager: "pip" }), + .prefault({}), ai: z - .object({ - rules: z.string().default(""), - mode: z.enum(["manual", "ask"]).default("manual"), + .looseObject({ + rules: z.string().prefault(""), + mode: z.enum(["manual", "ask"]).prefault("manual"), open_ai: AiConfigSchema.optional(), anthropic: AiConfigSchema.optional(), google: AiConfigSchema.optional(), @@ -167,41 +160,37 @@ export const UserConfigSchema = z open_ai_compatible: AiConfigSchema.optional(), azure: AiConfigSchema.optional(), bedrock: z - .object({ + .looseObject({ region_name: z.string().optional(), profile_name: z.string().optional(), aws_access_key_id: z.string().optional(), aws_secret_access_key: z.string().optional(), }) .optional(), - models: AiModelsSchema.default({ + models: AiModelsSchema.prefault({ displayed_models: [], custom_models: [], }), }) - .passthrough() - .default({}), + .prefault({}), experimental: z - .object({ + .looseObject({ markdown: z.boolean().optional(), rtc: z.boolean().optional(), // Add new experimental features here }) // Pass through so that we don't remove any extra keys that the user has added. - .passthrough() - .default({}), - server: z.object({}).passthrough().default({}), + .prefault(() => ({})), + server: z.looseObject({}).prefault(() => ({})), sharing: z - .object({ + .looseObject({ html: z.boolean().optional(), wasm: z.boolean().optional(), }) - .passthrough() .optional(), }) - // Pass through so that we don't remove any extra keys that the user has added - .passthrough() - .default({ + .partial() + .prefault(() => ({ completion: {}, save: {}, formatting: {}, @@ -210,13 +199,9 @@ export const UserConfigSchema = z display: {}, experimental: {}, server: {}, - ai: { - rules: "", - mode: "manual", - open_ai: {}, - models: {}, - }, - }); + ai: {}, + package_management: {}, + })); export type UserConfig = MarimoConfig; export type SaveConfig = UserConfig["save"]; export type CompletionConfig = UserConfig["completion"]; @@ -226,13 +211,15 @@ export type DiagnosticsConfig = UserConfig["diagnostics"]; export type DisplayConfig = UserConfig["display"]; export const AppTitleSchema = z.string(); -export const SqlOutputSchema = z.enum(VALID_SQL_OUTPUT_FORMATS).default("auto"); +export const SqlOutputSchema = z + .enum(VALID_SQL_OUTPUT_FORMATS) + .prefault("auto"); export const AppConfigSchema = z .object({ width: z .enum(VALID_APP_WIDTHS) - .default("medium") + .prefault("medium") .transform((width) => { if (width === "normal") { return "compact"; @@ -242,10 +229,14 @@ export const AppConfigSchema = z app_title: AppTitleSchema.nullish(), css_file: z.string().nullish(), html_head_file: z.string().nullish(), - auto_download: z.array(z.enum(AUTO_DOWNLOAD_FORMATS)).default([]), + auto_download: z.array(z.enum(AUTO_DOWNLOAD_FORMATS)).prefault([]), sql_output: SqlOutputSchema, }) - .default({ width: "medium", auto_download: [] }); + .prefault(() => ({ + width: "medium" as const, + auto_download: [], + sql_output: "auto" as const, + })); export type AppConfig = z.infer; export function parseAppConfig(config: unknown) { @@ -262,16 +253,22 @@ export function parseAppConfig(config: unknown) { export function parseUserConfig(config: unknown): UserConfig { try { const parsed = UserConfigSchema.parse(config); - for (const [key, value] of Object.entries(parsed.experimental)) { + for (const [key, value] of Object.entries(parsed.experimental ?? {})) { if (value === true) { Logger.log(`🧪 Experimental feature "${key}" is enabled.`); } } return parsed as unknown as UserConfig; } catch (error) { - Logger.error( - `Marimo got an unexpected value in the configuration file: ${error}`, - ); + if (error instanceof z.ZodError) { + Logger.error( + `Marimo got an unexpected value in the configuration file: ${z.prettifyError(error)}`, + ); + } else { + Logger.error( + `Marimo got an unexpected value in the configuration file: ${error}`, + ); + } return defaultUserConfig(); } } @@ -294,5 +291,17 @@ export function parseConfigOverrides(config: unknown): {} { } export function defaultUserConfig(): UserConfig { - return UserConfigSchema.parse({}) as unknown as UserConfig; + const defaultConfig: Partial> = { + completion: {}, + save: {}, + formatting: {}, + keymap: {}, + runtime: {}, + display: {}, + experimental: {}, + server: {}, + ai: {}, + package_management: {}, + }; + return UserConfigSchema.parse(defaultConfig) as UserConfig; } diff --git a/frontend/src/core/config/config.ts b/frontend/src/core/config/config.ts index e2b128259b4..c786acdeb9c 100644 --- a/frontend/src/core/config/config.ts +++ b/frontend/src/core/config/config.ts @@ -6,15 +6,15 @@ import { type Platform, resolvePlatform } from "../hotkeys/shortcuts"; import { store } from "../state/jotai"; import { type AppConfig, + defaultUserConfig, parseAppConfig, - parseUserConfig, type UserConfig, } from "./config-schema"; /** * Atom for storing the user config. */ -export const userConfigAtom = atom(parseUserConfig({})); +export const userConfigAtom = atom(defaultUserConfig()); export const configOverridesAtom = atom<{}>({}); diff --git a/frontend/src/core/i18n/__tests__/locale-provider.test.tsx b/frontend/src/core/i18n/__tests__/locale-provider.test.tsx index 4255cd0cdcd..25ab6afb3a5 100644 --- a/frontend/src/core/i18n/__tests__/locale-provider.test.tsx +++ b/frontend/src/core/i18n/__tests__/locale-provider.test.tsx @@ -4,7 +4,10 @@ import { cleanup, render } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { userConfigAtom } from "@/core/config/config"; -import { parseUserConfig } from "@/core/config/config-schema"; +import { + defaultUserConfig, + parseUserConfig, +} from "@/core/config/config-schema"; import { LocaleProvider } from "../locale-provider"; // Mock navigator.language with a getter @@ -156,7 +159,7 @@ describe("LocaleProvider", () => { mockNavigatorLanguage = "de-DE"; const store = createStore(); - const config = parseUserConfig({}); + const config = defaultUserConfig(); store.set(userConfigAtom, config); const { getByTestId } = render( diff --git a/frontend/src/plugins/core/builder.ts b/frontend/src/plugins/core/builder.ts index 739b79fc080..73a930dfe7e 100644 --- a/frontend/src/plugins/core/builder.ts +++ b/frontend/src/plugins/core/builder.ts @@ -1,7 +1,7 @@ /* Copyright 2024 Marimo. All rights reserved. */ import type { JSX } from "react"; -import type { ZodType, ZodTypeDef } from "zod"; +import type { ZodType } from "zod"; import type { IPlugin, IPluginProps } from "../types"; import type { FunctionSchemas, PluginFunctions } from "./rpc"; @@ -19,7 +19,7 @@ export function createPlugin( /** * Data schema for the plugin. */ - withData(validator: ZodType) { + withData(validator: ZodType) { return { /** * Functions that the plugin can call. diff --git a/frontend/src/plugins/core/registerReactComponent.tsx b/frontend/src/plugins/core/registerReactComponent.tsx index 45e178204a3..6ac48800d22 100644 --- a/frontend/src/plugins/core/registerReactComponent.tsx +++ b/frontend/src/plugins/core/registerReactComponent.tsx @@ -22,7 +22,7 @@ import React, { } from "react"; import ReactDOM, { type Root } from "react-dom/client"; import useEvent from "react-use-event-hook"; -import type { ZodSchema } from "zod"; +import { type ZodSchema, z } from "zod"; import { notebookAtom } from "@/core/cells/cells.ts"; import { HTMLCellId } from "@/core/cells/ids.ts"; import { isUninstantiated } from "@/core/cells/utils"; @@ -561,13 +561,11 @@ export function isCustomMarimoElement( return "__type__" in element && element.__type__ === customElementLocator; } -function prettyParse(schema: ZodSchema, data: unknown): T { +function prettyParse(schema: z.ZodType, data: unknown): T { const result = schema.safeParse(data); if (!result.success) { Logger.log("Failed to parse data", data, result.error); - throw new Error( - result.error.errors.map((e) => `${e.path}: ${e.message}`).join("\n"), - ); + throw new Error(z.prettifyError(result.error)); } return result.data; } diff --git a/frontend/src/plugins/core/rpc.ts b/frontend/src/plugins/core/rpc.ts index 8310bebfe9a..0a6b462e933 100644 --- a/frontend/src/plugins/core/rpc.ts +++ b/frontend/src/plugins/core/rpc.ts @@ -1,7 +1,7 @@ /* Copyright 2024 Marimo. All rights reserved. */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { ZodType, ZodTypeDef } from "zod"; +import type { ZodType } from "zod"; export type PluginFunction = (args: REQ) => Promise; @@ -14,12 +14,12 @@ export type PluginFunctions = Record; export type ExtractInputSchema = F extends ( args: infer REQ, ) => Promise - ? ZodType + ? ZodType : never; export type ExtractOutputSchema = F extends ( args: any, ) => Promise - ? ZodType + ? ZodType : never; /** @@ -42,9 +42,9 @@ export type FunctionSchemas = { * RPC builder for plugin functions. */ export const rpc = { - input(inputSchema: ZodType) { + input(inputSchema: ZodType) { return { - output(outputSchema: ZodType) { + output(outputSchema: ZodType) { return { input: inputSchema, output: outputSchema, diff --git a/frontend/src/plugins/impl/DataTablePlugin.tsx b/frontend/src/plugins/impl/DataTablePlugin.tsx index 694e7c9f11a..da728345745 100644 --- a/frontend/src/plugins/impl/DataTablePlugin.tsx +++ b/frontend/src/plugins/impl/DataTablePlugin.tsx @@ -252,16 +252,18 @@ export const DataTablePlugin = createPlugin("marimo-table") freezeColumnsLeft: z.array(z.string()).optional(), freezeColumnsRight: z.array(z.string()).optional(), textJustifyColumns: z - .record(z.enum(["left", "center", "right"])) + .record(z.string(), z.enum(["left", "center", "right"])) .optional(), wrappedColumns: z.array(z.string()).optional(), - headerTooltip: z.record(z.string()).optional(), + headerTooltip: z.record(z.string(), z.string()).optional(), fieldTypes: columnToFieldTypesSchema.nullish(), totalColumns: z.number(), maxColumns: z.union([z.number(), z.literal("all")]).default("all"), hasStableRowId: z.boolean().default(false), maxHeight: z.number().optional(), - cellStyles: z.record(z.record(z.object({}).passthrough())).optional(), + cellStyles: z + .record(z.string(), z.record(z.string(), z.object({}).passthrough())) + .optional(), hoverTemplate: z.string().optional(), // Whether to load the data lazily. lazy: z.boolean().default(false), @@ -303,7 +305,10 @@ export const DataTablePlugin = createPlugin("marimo-table") data: z.union([z.string(), z.array(z.object({}).passthrough())]), total_rows: z.union([z.number(), z.literal(TOO_MANY_ROWS)]), cell_styles: z - .record(z.record(z.object({}).passthrough())) + .record( + z.string(), + z.record(z.string(), z.object({}).passthrough()), + ) .nullable(), }), ), diff --git a/frontend/src/plugins/impl/__tests__/DropdownPlugin.test.tsx b/frontend/src/plugins/impl/__tests__/DropdownPlugin.test.tsx index dc102b54972..b3ec9a04028 100644 --- a/frontend/src/plugins/impl/__tests__/DropdownPlugin.test.tsx +++ b/frontend/src/plugins/impl/__tests__/DropdownPlugin.test.tsx @@ -1,6 +1,8 @@ /* Copyright 2024 Marimo. All rights reserved. */ + import { fireEvent, render, screen } from "@testing-library/react"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { z } from "zod"; import type { IPluginProps } from "../../types"; import { DropdownPlugin } from "../DropdownPlugin"; @@ -28,7 +30,7 @@ describe("DropdownPlugin", () => { const host = document.createElement("div"); const props: IPluginProps< string[], - (typeof plugin)["validator"]["_type"] + z.infer<(typeof plugin)["validator"]> > = { data: { label: "Test Label", @@ -54,7 +56,7 @@ describe("DropdownPlugin", () => { const host = document.createElement("div"); const props: IPluginProps< string[], - (typeof plugin)["validator"]["_type"] + z.infer<(typeof plugin)["validator"]> > = { data: { label: "Test Label", @@ -79,7 +81,7 @@ describe("DropdownPlugin", () => { const setValue = vi.fn(); const props: IPluginProps< string[], - (typeof plugin)["validator"]["_type"] + z.infer<(typeof plugin)["validator"]> > = { data: { label: "Test Label", @@ -121,7 +123,7 @@ describe("DropdownPlugin", () => { const setValue = vi.fn(); const props: IPluginProps< string[], - (typeof plugin)["validator"]["_type"] + z.infer<(typeof plugin)["validator"]> > = { data: { label: "Test Label", diff --git a/frontend/src/plugins/impl/__tests__/NumberPlugin.test.tsx b/frontend/src/plugins/impl/__tests__/NumberPlugin.test.tsx index c7f89d07def..e0842480766 100644 --- a/frontend/src/plugins/impl/__tests__/NumberPlugin.test.tsx +++ b/frontend/src/plugins/impl/__tests__/NumberPlugin.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { z } from "zod"; import type { IPluginProps } from "../../types"; import { NumberPlugin } from "../NumberPlugin"; @@ -22,7 +23,7 @@ describe("NumberPlugin", () => { // Initial render with value 5 const props: IPluginProps< number | null, - (typeof plugin)["validator"]["_type"] + z.infer<(typeof plugin)["validator"]> > = { host, value: 5, @@ -69,7 +70,7 @@ describe("NumberPlugin", () => { const props: IPluginProps< number | null, - (typeof plugin)["validator"]["_type"] + z.infer<(typeof plugin)["validator"]> > = { host, value: 5, diff --git a/frontend/src/plugins/impl/anywidget/model.ts b/frontend/src/plugins/impl/anywidget/model.ts index 2371d0251ba..ba0903b6f31 100644 --- a/frontend/src/plugins/impl/anywidget/model.ts +++ b/frontend/src/plugins/impl/anywidget/model.ts @@ -214,7 +214,7 @@ export class Model> implements AnyModel { } const BufferPathSchema = z.array(z.array(z.union([z.string(), z.number()]))); -const StateSchema = z.record(z.any()); +const StateSchema = z.record(z.string(), z.any()); const AnyWidgetMessageSchema = z.discriminatedUnion("method", [ z.object({ diff --git a/frontend/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap b/frontend/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap index 5164aebc808..9706f17d248 100644 --- a/frontend/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +++ b/frontend/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap @@ -521,7 +521,9 @@ exports[`renderZodSchema > should render a form filter_rows 1`] = ` >
diff --git a/frontend/src/plugins/impl/data-frames/forms/__tests__/form.test.tsx b/frontend/src/plugins/impl/data-frames/forms/__tests__/form.test.tsx index 6d675c099d2..bae77e9800e 100644 --- a/frontend/src/plugins/impl/data-frames/forms/__tests__/form.test.tsx +++ b/frontend/src/plugins/impl/data-frames/forms/__tests__/form.test.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { render } from "@testing-library/react"; -import { useForm } from "react-hook-form"; +import { type FieldValues, useForm } from "react-hook-form"; import { describe, expect, it } from "vitest"; import type { z } from "zod"; import { getUnionLiteral } from "@/components/forms/form-utils"; @@ -20,7 +20,7 @@ const ColumnTypes = new Map([ ["B" as ColumnId, "int"], ]); -const Subject = (props: { schema: z.ZodType }) => { +const Subject = (props: { schema: z.ZodType }) => { const form = useForm({ resolver: zodResolver(props.schema), defaultValues: {}, @@ -41,12 +41,12 @@ const Subject = (props: { schema: z.ZodType }) => { describe("renderZodSchema", () => { // Snapshot each form to make sure they don't change unexpectedly const options = Objects.keyBy( - TransformTypeSchema._def.options, + [...TransformTypeSchema.options], (z) => getUnionLiteral(z).value, ); it.each(Object.entries(options))( "should render a form %s", - (name, schema) => { + (name, schema: z.ZodType) => { const expected = render(); expect(expected.asFragment()).toMatchSnapshot(); @@ -62,6 +62,8 @@ const options = [ ] as const; it.each(options)("renders custom forms %s", (key, schema) => { - const expected = render(); + const expected = render( + } />, + ); expect(expected.asFragment()).toMatchSnapshot(); }); diff --git a/frontend/src/plugins/impl/data-frames/forms/renderers.tsx b/frontend/src/plugins/impl/data-frames/forms/renderers.tsx index f0e3eaa0976..8a1e2a1bed2 100644 --- a/frontend/src/plugins/impl/data-frames/forms/renderers.tsx +++ b/frontend/src/plugins/impl/data-frames/forms/renderers.tsx @@ -53,12 +53,12 @@ export const columnIdRenderer = (): FormRenderer< string > => ({ isMatch: (schema: z.ZodType): schema is z.ZodString => { - const { special } = FieldOptions.parse(schema._def.description || ""); + const { special } = FieldOptions.parse(schema.description || ""); return special === "column_id"; }, Component: ({ schema, form, path }) => { const columns = React.use(ColumnInfoContext); - const { label, description } = FieldOptions.parse(schema._def.description); + const { label, description } = FieldOptions.parse(schema.description); return ( (): FormRenderer< > => ({ isMatch: (schema: z.ZodType): schema is z.ZodArray => { if (schema instanceof z.ZodArray) { - const childType = schema._def.type; - const { special } = FieldOptions.parse(childType._def.description || ""); + const childType = schema.element; + const description = + childType instanceof z.ZodType ? childType.description : ""; + const { special } = FieldOptions.parse(description || ""); return special === "column_id"; } return false; }, Component: ({ schema, form, path }) => { - const { label } = FieldOptions.parse(schema._def.description); + const { label } = FieldOptions.parse(schema.description); return ( { const columns = React.use(ColumnInfoContext); - const { description } = FieldOptions.parse(schema._def.description); + const { description } = FieldOptions.parse(schema.description); const placeholder = itemLabel ? `Select ${itemLabel.toLowerCase()}` : undefined; @@ -214,14 +216,14 @@ export const columnValuesRenderer = (): FormRenderer< > => ({ isMatch: (schema: z.ZodType): schema is z.ZodArray => { if (schema instanceof z.ZodArray) { - const { special } = FieldOptions.parse(schema._def.description || ""); + const { special } = FieldOptions.parse(schema.description || ""); return special === "column_values"; } return false; }, Component: ({ schema, form, path }) => { const { label, description, placeholder } = FieldOptions.parse( - schema._def.description, + schema.description, ); const column = React.use(ColumnNameContext); const fetchValues = React.use(ColumnFetchValuesContext); @@ -296,7 +298,7 @@ export const multiColumnValuesRenderer = < T extends FieldValues, >(): FormRenderer => ({ isMatch: (schema: z.ZodType): schema is z.ZodArray => { - const { special } = FieldOptions.parse(schema._def.description || ""); + const { special } = FieldOptions.parse(schema.description || ""); return special === "column_values" && schema instanceof z.ZodArray; }, Component: ({ schema, form, path }) => { @@ -367,7 +369,7 @@ export const filterFormRenderer = (): FormRenderer< > => ({ isMatch: (schema: z.ZodType): schema is z.ZodObject<{}> => { if (schema instanceof z.ZodObject) { - const { special } = FieldOptions.parse(schema._def.description || ""); + const { special } = FieldOptions.parse(schema.description || ""); return special === "column_filter"; } return false; @@ -392,10 +394,10 @@ const ColumnFilterForm = ({ form: UseFormReturn; path: Path; }) => { - const { description } = FieldOptions.parse(schema._def.description); + const { description } = FieldOptions.parse(schema.description); const columns = React.use(ColumnInfoContext); - const columnIdSchema = Objects.entries(schema._def.shape()).find( + const columnIdSchema = Objects.entries(schema.shape).find( ([key]) => key === "column_id", )?.[1] as unknown as z.ZodString; diff --git a/frontend/src/plugins/impl/data-frames/panel.tsx b/frontend/src/plugins/impl/data-frames/panel.tsx index d61c3c280e9..cae50644b3d 100644 --- a/frontend/src/plugins/impl/data-frames/panel.tsx +++ b/frontend/src/plugins/impl/data-frames/panel.tsx @@ -113,11 +113,9 @@ export const TransformPanel: React.FC = ({ selectedTransform === undefined ? undefined : transforms[selectedTransform]?.type; - const selectedTransformSchema = TransformTypeSchema._def.options.find( - (option) => { - return getUnionLiteral(option)._def.value === selectedTransformType; - }, - ); + const selectedTransformSchema = TransformTypeSchema.options.find((option) => { + return getUnionLiteral(option).value === selectedTransformType; + }); const effectiveColumns = useMemo(() => { const transformsBeforeSelected = transforms.slice(0, selectedTransform); @@ -125,7 +123,9 @@ export const TransformPanel: React.FC = ({ }, [columns, transforms, selectedTransform]); const handleAddTransform = (transform: z.ZodType) => { - const next: TransformType = getDefaults(transform); + const next: TransformType = getDefaults( + transform as z.ZodType, + ); const nextIdx = transformsField.fields.length; transformsField.append(next); setSelectedTransform(nextIdx); @@ -256,19 +256,19 @@ const AddTransformDropdown: React.FC< Add Transform - {Object.values(TransformTypeSchema._def.options).map((type) => { + {Object.values(TransformTypeSchema.options).map((type) => { const literal = getUnionLiteral(type); - const Icon = ICONS[literal._def.value as TransformType["type"]]; + const Icon = ICONS[literal.value as TransformType["type"]]; return ( { evt.stopPropagation(); onAdd(type); }} > - {Strings.startCase(literal._def.value)} + {Strings.startCase(literal.value)} ); })} diff --git a/frontend/src/plugins/impl/data-frames/schema.ts b/frontend/src/plugins/impl/data-frames/schema.ts index 6e0fc89fdfa..3bec14c4a00 100644 --- a/frontend/src/plugins/impl/data-frames/schema.ts +++ b/frontend/src/plugins/impl/data-frames/schema.ts @@ -29,7 +29,7 @@ export const column_id_array = z .array(column_id.describe(FieldOptions.of({ special: "column_id" }))) .min(1, "At least one column is required") .default([]) - .describe(FieldOptions.of({ label: "Columns" })); + .describe(FieldOptions.of({ label: "Columns", minLength: 1 })); const ColumnConversionTransformSchema = z .object({ @@ -55,7 +55,7 @@ const RenameColumnTransformSchema = z.object({ .string() .min(1, "Required") .transform((v) => v as ColumnId) - .describe(FieldOptions.of({ label: "New column name" })), + .describe(FieldOptions.of({ label: "New column name", minLength: 1 })), }); const SortColumnTransformSchema = z.object({ @@ -93,13 +93,15 @@ const FilterRowsTransformSchema = z.object({ where: z .array(ConditionSchema) .min(1) + .describe(FieldOptions.of({ label: "Value", minLength: 1 })) .transform((value) => { return value.filter((condition) => { return isConditionValueValid(condition.operator, condition.value); }); }) - .describe(FieldOptions.of({ label: "Value" })) - .default([{ column_id: "", operator: "==", value: "" }]), + .default(() => [ + { column_id: "" as ColumnId, operator: "==" as const, value: "" }, + ]), }); const GroupByTransformSchema = z @@ -125,7 +127,7 @@ const AggregateTransformSchema = z .array(z.enum(AGGREGATION_FNS)) .min(1, "At least one aggregation is required") .default(["count"]) - .describe(FieldOptions.of({ label: "Aggregations" })), + .describe(FieldOptions.of({ label: "Aggregations", minLength: 1 })), }) .describe(FieldOptions.of({ direction: "row" })); diff --git a/frontend/src/plugins/impl/panel/PanelPlugin.tsx b/frontend/src/plugins/impl/panel/PanelPlugin.tsx index 7270c9c047b..8228729b800 100644 --- a/frontend/src/plugins/impl/panel/PanelPlugin.tsx +++ b/frontend/src/plugins/impl/panel/PanelPlugin.tsx @@ -86,10 +86,10 @@ export const PanelPlugin = createPlugin("marimo-panel") .withData( z.object({ extension: z.string().nullable(), - docs_json: z.record(z.unknown()), + docs_json: z.record(z.string(), z.unknown()), render_json: z .object({ - roots: z.record(z.string()), + roots: z.record(z.string(), z.string()), }) .catchall(z.unknown()), }), diff --git a/frontend/src/plugins/layout/MimeRenderPlugin.tsx b/frontend/src/plugins/layout/MimeRenderPlugin.tsx index b3704d179ca..c4f1b3d581a 100644 --- a/frontend/src/plugins/layout/MimeRenderPlugin.tsx +++ b/frontend/src/plugins/layout/MimeRenderPlugin.tsx @@ -20,7 +20,12 @@ export class MimeRendererPlugin implements IStatelessPlugin { validator = z.object({ mime: z.string().transform((val) => val as OutputMessage["mimetype"]), data: z - .union([z.string(), z.null(), z.record(z.unknown()), z.array(z.any())]) + .union([ + z.string(), + z.null(), + z.record(z.string(), z.unknown()), + z.array(z.any()), + ]) .transform((val) => val as OutputMessage["data"]), }); diff --git a/frontend/src/plugins/types.ts b/frontend/src/plugins/types.ts index 398e9420192..af59ca80baa 100644 --- a/frontend/src/plugins/types.ts +++ b/frontend/src/plugins/types.ts @@ -1,7 +1,7 @@ /* Copyright 2024 Marimo. All rights reserved. */ import type { JSX } from "react"; -import type { ZodType, ZodTypeDef } from "zod"; +import type { ZodType } from "zod"; import type { FunctionSchemas, PluginFunctions } from "./core/rpc"; /** @@ -68,7 +68,7 @@ export interface IPlugin< /** * Validate the plugin data. Use [zod](https://zod.dev/) to validate the data. */ - validator: ZodType; + validator: ZodType; /** * Functions definitions and validation. diff --git a/frontend/src/utils/localStorage.ts b/frontend/src/utils/localStorage.ts index 1e8dcfa2ced..d0a7a102fb8 100644 --- a/frontend/src/utils/localStorage.ts +++ b/frontend/src/utils/localStorage.ts @@ -1,6 +1,6 @@ /* Copyright 2024 Marimo. All rights reserved. */ -import type { ZodType, ZodTypeDef } from "zod"; +import type { ZodType } from "zod"; import { filenameAtom } from "@/core/saving/file-state"; import { store } from "@/core/state/jotai"; import { Logger } from "./Logger"; @@ -36,13 +36,10 @@ export class TypedLocalStorage implements Storage { } export class ZodLocalStorage implements Storage { - private schema: ZodType; + private schema: ZodType; private getDefaultValue: () => T; - constructor( - schema: ZodType, - getDefaultValue: () => T, - ) { + constructor(schema: ZodType, getDefaultValue: () => T) { this.schema = schema; this.getDefaultValue = getDefaultValue; } @@ -82,11 +79,7 @@ export class NotebookScopedLocalStorage extends ZodLocalStorage { private filename: string | null; private unsubscribeFromFilename: (() => void) | null; - constructor( - key: string, - schema: ZodType, - getDefaultValue: () => T, - ) { + constructor(key: string, schema: ZodType, getDefaultValue: () => T) { const filename = store.get(filenameAtom); super(schema, getDefaultValue); this.filename = filename; diff --git a/frontend/src/utils/zod-utils.ts b/frontend/src/utils/zod-utils.ts new file mode 100644 index 00000000000..7b847c4ce0e --- /dev/null +++ b/frontend/src/utils/zod-utils.ts @@ -0,0 +1,19 @@ +/* Copyright 2024 Marimo. All rights reserved. */ + +import z from "zod"; + +export function isZodArray( + schema: z.ZodType, +): schema is z.ZodArray { + return schema instanceof z.ZodArray; +} + +export function isZodPipe(schema: z.ZodType): schema is z.ZodPipe { + return schema instanceof z.ZodPipe; +} + +export function isZodTuple( + schema: z.ZodType, +): schema is z.ZodTuple { + return schema instanceof z.ZodTuple; +} diff --git a/packages/llm-info/package.json b/packages/llm-info/package.json index 3a36514374a..87ca6d39711 100644 --- a/packages/llm-info/package.json +++ b/packages/llm-info/package.json @@ -22,6 +22,6 @@ "@marimo-team/tsconfig": "workspace:*", "yaml": "^2.8.1", "vitest": "^3.2.4", - "zod": "^3.25.76" + "zod": "^4.1.11" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccdc015fe61..912a53dd595 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,7 +38,7 @@ importers: dependencies: '@ai-sdk/react': specifier: ^2.0.30 - version: 2.0.30(react@19.1.1)(zod@3.25.76) + version: 2.0.30(react@19.1.1)(zod@4.1.11) '@anywidget/types': specifier: ^0.2.0 version: 0.2.0 @@ -109,8 +109,8 @@ importers: specifier: 6.0.4-alpha16 version: 6.0.4-alpha16(lodash@4.17.21)(marked@15.0.12)(react-dom@19.1.1(react@19.1.1))(react-responsive-carousel@3.2.23)(react@19.1.1) '@hookform/resolvers': - specifier: ^3.10.0 - version: 3.10.0(react-hook-form@7.54.2(react@19.1.1)) + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.54.2(react@19.1.1)) '@img-comparison-slider/react': specifier: ^8.0.2 version: 8.0.2 @@ -308,7 +308,7 @@ importers: version: 0.3.1 ai: specifier: ^5.0.30 - version: 5.0.30(zod@3.25.76) + version: 5.0.30(zod@4.1.11) ansi_up: specifier: ^6.0.6 version: 6.0.6 @@ -496,8 +496,8 @@ importers: specifier: ^4.2.4 version: 4.2.4 zod: - specifier: ^3.25.76 - version: 3.25.76 + specifier: ^4.1.11 + version: 4.1.11 devDependencies: '@babel/plugin-proposal-decorators': specifier: ^7.27.1 @@ -662,8 +662,8 @@ importers: specifier: ^2.8.1 version: 2.8.1 zod: - specifier: ^3.25.76 - version: 3.25.76 + specifier: ^4.1.11 + version: 4.1.11 packages/lsp: dependencies: @@ -1556,10 +1556,10 @@ packages: react-dom: ^19.1.0 react-responsive-carousel: ^3.2.7 - '@hookform/resolvers@3.10.0': - resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: - react-hook-form: ^7.0.0 + react-hook-form: ^7.55.0 '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} @@ -3499,6 +3499,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@storybook/addon-docs@9.1.8': resolution: {integrity: sha512-GVrNVEdNRRo6r1hawfgyy6x+HJqPx1oOHm0U0wz0SGAxgS/Xh6SQVZL+RDoh7NpXkNi1GbezVlT931UsHQTyvQ==} peerDependencies: @@ -10097,6 +10100,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -10163,32 +10169,32 @@ snapshots: '@adobe/css-tools@4.4.3': {} - '@ai-sdk/gateway@1.0.15(zod@3.25.76)': + '@ai-sdk/gateway@1.0.15(zod@4.1.11)': dependencies: '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.7(zod@3.25.76) - zod: 3.25.76 + '@ai-sdk/provider-utils': 3.0.7(zod@4.1.11) + zod: 4.1.11 - '@ai-sdk/provider-utils@3.0.7(zod@3.25.76)': + '@ai-sdk/provider-utils@3.0.7(zod@4.1.11)': dependencies: '@ai-sdk/provider': 2.0.0 '@standard-schema/spec': 1.0.0 eventsource-parser: 3.0.6 - zod: 3.25.76 + zod: 4.1.11 '@ai-sdk/provider@2.0.0': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@2.0.30(react@19.1.1)(zod@3.25.76)': + '@ai-sdk/react@2.0.30(react@19.1.1)(zod@4.1.11)': dependencies: - '@ai-sdk/provider-utils': 3.0.7(zod@3.25.76) - ai: 5.0.30(zod@3.25.76) + '@ai-sdk/provider-utils': 3.0.7(zod@4.1.11) + ai: 5.0.30(zod@4.1.11) react: 19.1.1 swr: 2.3.4(react@19.1.1) throttleit: 2.1.0 optionalDependencies: - zod: 3.25.76 + zod: 4.1.11 '@alloc/quick-lru@5.2.0': {} @@ -11160,8 +11166,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@hookform/resolvers@3.10.0(react-hook-form@7.54.2(react@19.1.1))': + '@hookform/resolvers@5.2.2(react-hook-form@7.54.2(react@19.1.1))': dependencies: + '@standard-schema/utils': 0.3.0 react-hook-form: 7.54.2(react@19.1.1) '@humanwhocodes/config-array@0.13.0': @@ -13715,6 +13722,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/utils@0.3.0': {} + '@storybook/addon-docs@9.1.8(@types/react@19.1.12)(storybook@9.1.8(@testing-library/dom@10.4.0)(msw@2.11.1(@types/node@24.3.1)(typescript@5.9.2))(rolldown-vite@7.1.13(@types/node@24.3.1)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.44.0)(yaml@2.8.1)))': dependencies: '@mdx-js/react': 3.1.0(@types/react@19.1.12)(react@19.1.1) @@ -14766,13 +14775,13 @@ snapshots: agent-base@7.1.4: {} - ai@5.0.30(zod@3.25.76): + ai@5.0.30(zod@4.1.11): dependencies: - '@ai-sdk/gateway': 1.0.15(zod@3.25.76) + '@ai-sdk/gateway': 1.0.15(zod@4.1.11) '@ai-sdk/provider': 2.0.0 - '@ai-sdk/provider-utils': 3.0.7(zod@3.25.76) + '@ai-sdk/provider-utils': 3.0.7(zod@4.1.11) '@opentelemetry/api': 1.9.0 - zod: 3.25.76 + zod: 4.1.11 ajv-formats@2.1.1(ajv@8.17.1): optionalDependencies: @@ -21751,6 +21760,8 @@ snapshots: zod@3.25.76: {} + zod@4.1.11: {} + zustand@4.5.7(@types/react@19.1.12)(react@19.1.1): dependencies: use-sync-external-store: 1.5.0(react@19.1.1)