From ecd1d989b2bafb3c5eb1289d24e7f8bd9a68742d Mon Sep 17 00:00:00 2001 From: Mason James Date: Fri, 5 Jun 2026 12:44:51 -0400 Subject: [PATCH 1/2] feat: add date-only field type --- .changeset/date-only-field.md | 6 ++ .../admin/src/components/ContentEditor.tsx | 12 ++++ packages/admin/src/components/FieldEditor.tsx | 7 +++ .../admin/src/components/RepeaterField.tsx | 10 +++ packages/admin/src/lib/api/schema.ts | 1 + .../tests/components/ContentEditor.test.tsx | 32 ++++++++++ .../tests/components/FieldEditor.test.tsx | 3 +- .../tests/components/RepeaterField.test.tsx | 35 +++++++++++ packages/core/src/api/handlers/manifest.ts | 3 + packages/core/src/api/schemas/schema.ts | 3 +- packages/core/src/cli/commands/schema.ts | 2 +- packages/core/src/emdash-runtime.ts | 1 + packages/core/src/fields/date.ts | 62 +++++++++++++++++++ packages/core/src/fields/index.ts | 2 + packages/core/src/mcp/server.ts | 5 +- packages/core/src/plugins/manifest-schema.ts | 1 + packages/core/src/schema/types.ts | 15 ++++- packages/core/src/schema/zod-generator.ts | 11 ++++ .../core/tests/integration/mcp/schema.test.ts | 1 + .../core/tests/unit/fields/all-fields.test.ts | 15 +++++ .../tests/unit/plugins/field-widgets.test.ts | 2 +- .../tests/unit/runtime/manifest-build.test.ts | 2 + .../core/tests/unit/schema/registry.test.ts | 1 + .../tests/unit/schema/zod-generator.test.ts | 20 ++++++ 24 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 .changeset/date-only-field.md create mode 100644 packages/core/src/fields/date.ts diff --git a/.changeset/date-only-field.md b/.changeset/date-only-field.md new file mode 100644 index 000000000..9adb53181 --- /dev/null +++ b/.changeset/date-only-field.md @@ -0,0 +1,6 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Add a date-only content field type that stores `YYYY-MM-DD` values and renders as a date picker in the admin. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 3f4834b2a..ca6db44e6 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1319,6 +1319,18 @@ function FieldRenderer({ /> ); + case "date": + return ( + handleChange(e.target.value)} + required={field.required} + /> + ); + case "image": { // value is either an ImageFieldValue object, a legacy string URL, or undefined const imageValue = diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index abd319fd3..b8772bcd7 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -181,6 +181,12 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie description: t`Date and time picker`, icon: Calendar, }, + { + type: "date", + label: t`Date Only`, + description: t`Date picker without time`, + icon: Calendar, + }, { type: "select", label: t`Select`, @@ -581,6 +587,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie number: t`Number`, integer: t`Integer`, boolean: t`Boolean`, + date: t`Date Only`, datetime: t`Date & Time`, select: t`Select`, url: t`URL`, diff --git a/packages/admin/src/components/RepeaterField.tsx b/packages/admin/src/components/RepeaterField.tsx index 4859dac6b..4443b6563 100644 --- a/packages/admin/src/components/RepeaterField.tsx +++ b/packages/admin/src/components/RepeaterField.tsx @@ -351,6 +351,16 @@ function SubFieldInput({ subField, value, onChange }: SubFieldInputProps) { required={subField.required} /> ); + case "date": + return ( + onChange(e.target.value)} + required={subField.required} + /> + ); case "select": return ( only accepts "YYYY-MM-DDTHH:mm". @@ -624,6 +632,30 @@ describe("ContentEditor", () => { ); }); + it("saves date fields back as YYYY-MM-DD without a time", async () => { + const onSave = vi.fn(); + const screen = await renderEditor({ + isNew: true, + onSave, + fields: { + title: { kind: "string", label: "Title", required: true }, + event_date: { kind: "date", label: "Event date" }, + }, + }); + + await screen.getByLabelText("Title").fill("Event"); + await screen.getByLabelText("Event date").fill("2026-02-26"); + await screen.getByRole("button", { name: "Save" }).first().click(); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + event_date: "2026-02-26", + }), + }), + ); + }); + it("renders json fields as a textarea", async () => { const screen = await renderEditor({ fields: { metadata: { kind: "json", label: "Metadata" } }, diff --git a/packages/admin/tests/components/FieldEditor.test.tsx b/packages/admin/tests/components/FieldEditor.test.tsx index 007e2e199..26f5123b2 100644 --- a/packages/admin/tests/components/FieldEditor.test.tsx +++ b/packages/admin/tests/components/FieldEditor.test.tsx @@ -16,6 +16,7 @@ const FIELD_TYPE_REGEXES = [ /Integer Whole number/, /Boolean True\/false toggle/, /Date & Time/, + /Date Only/, /^Select Single choice/, /Multi Select/, /Rich Text/, @@ -84,7 +85,7 @@ describe("FieldEditor", () => { .toBeInTheDocument(); }); - it("shows all 14 field types as buttons", async () => { + it("shows all 15 field types as buttons", async () => { const screen = await render(); // Each type renders as a button with label and description for (const name of FIELD_TYPE_REGEXES) { diff --git a/packages/admin/tests/components/RepeaterField.test.tsx b/packages/admin/tests/components/RepeaterField.test.tsx index 25a930984..edad09c89 100644 --- a/packages/admin/tests/components/RepeaterField.test.tsx +++ b/packages/admin/tests/components/RepeaterField.test.tsx @@ -42,4 +42,39 @@ describe("RepeaterField", () => { ]); }); }); + + describe("date sub-field", () => { + it("renders date-only values in a date input", async () => { + const screen = await render( + , + ); + const input = screen.getByLabelText("Event date"); + await expect.element(input).toHaveAttribute("type", "date"); + await expect.element(input).toHaveValue("2026-02-26"); + }); + + it("emits YYYY-MM-DD without a time on change", async () => { + const onChange = vi.fn(); + const screen = await render( + , + ); + await screen.getByLabelText("Event date").fill("2026-02-26"); + + expect(onChange).toHaveBeenLastCalledWith([ + expect.objectContaining({ event_date: "2026-02-26" }), + ]); + }); + }); }); diff --git a/packages/core/src/api/handlers/manifest.ts b/packages/core/src/api/handlers/manifest.ts index 1c88ef760..3d4b4a7b8 100644 --- a/packages/core/src/api/handlers/manifest.ts +++ b/packages/core/src/api/handlers/manifest.ts @@ -104,6 +104,9 @@ function extractFieldType(name: string, schema: unknown): FieldDescriptor { if (schema.isReference) { return { kind: "reference", label: formatLabel(name) }; } + if (schema.isDateOnly) { + return { kind: "date", label: formatLabel(name) }; + } // Handle standard Zod types const def = isObject(schema._def) ? schema._def : undefined; diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index f057091a2..200b96078 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -17,6 +17,7 @@ const fieldTypeValues = z.enum([ "number", "integer", "boolean", + "date", "datetime", "select", "multiSelect", @@ -31,7 +32,7 @@ const fieldTypeValues = z.enum([ const repeaterSubFieldSchema = z.object({ slug: z.string().min(1).max(63).regex(slugPattern, "Invalid slug format"), - type: z.enum(["string", "text", "number", "integer", "boolean", "datetime", "select"]), + type: z.enum(["string", "text", "number", "integer", "boolean", "date", "datetime", "select"]), label: z.string().min(1), required: z.boolean().optional(), options: z.array(z.string()).optional(), diff --git a/packages/core/src/cli/commands/schema.ts b/packages/core/src/cli/commands/schema.ts index c7db5e617..1c90c61ca 100644 --- a/packages/core/src/cli/commands/schema.ts +++ b/packages/core/src/cli/commands/schema.ts @@ -160,7 +160,7 @@ const addFieldCommand = defineCommand({ type: { type: "string", description: - "Field type (string, text, number, integer, boolean, datetime, image, reference, portableText, json)", + "Field type (string, text, number, integer, boolean, date, datetime, image, reference, portableText, json)", required: true, }, label: { diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index b32bb5253..f53bd9825 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -178,6 +178,7 @@ const FIELD_TYPE_TO_KIND: Record = { number: "number", integer: "number", boolean: "boolean", + date: "date", datetime: "datetime", select: "select", multiSelect: "multiSelect", diff --git a/packages/core/src/fields/date.ts b/packages/core/src/fields/date.ts new file mode 100644 index 000000000..96c9b59b0 --- /dev/null +++ b/packages/core/src/fields/date.ts @@ -0,0 +1,62 @@ +import { z } from "astro/zod"; + +import type { FieldDefinition, FieldUIHints } from "./types.js"; + +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export interface DateOptions { + required?: boolean; + min?: string | Date; + max?: string | Date; + helpText?: string; +} + +function toDateOnly(value: string | Date): string { + return value instanceof Date ? value.toISOString().slice(0, 10) : value; +} + +function isValidDateOnly(value: string): boolean { + if (!DATE_ONLY_PATTERN.test(value)) return false; + const parsed = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +/** + * Date field - date picker without a time component. + */ +export function date(options: DateOptions = {}): FieldDefinition { + const min = options.min ? toDateOnly(options.min) : undefined; + const max = options.max ? toDateOnly(options.max) : undefined; + let dateSchema = z + .string() + .regex(DATE_ONLY_PATTERN, "Must be a date in YYYY-MM-DD format") + .refine(isValidDateOnly, "Invalid date"); + + if (min !== undefined) { + dateSchema = dateSchema.refine((value) => value >= min, "Date is too early"); + } + + if (max !== undefined) { + dateSchema = dateSchema.refine((value) => value <= max, "Date is too late"); + } + + const markedSchema = dateSchema as z.ZodTypeAny & { isDateOnly?: true }; + markedSchema.isDateOnly = true; + + const schema: z.ZodTypeAny = options.required ? markedSchema : markedSchema.optional(); + + const ui: FieldUIHints = { + widget: "date", + helpText: options.helpText, + min, + max, + }; + + return { + type: "date", + columnType: "TEXT", + schema, + options, + ui, + }; +} diff --git a/packages/core/src/fields/index.ts b/packages/core/src/fields/index.ts index 068a4bcf2..d5de9e635 100644 --- a/packages/core/src/fields/index.ts +++ b/packages/core/src/fields/index.ts @@ -6,6 +6,7 @@ export { integer } from "./integer.js"; export { boolean } from "./boolean.js"; export { select } from "./select.js"; export { multiSelect } from "./multiselect.js"; +export { date } from "./date.js"; export { datetime } from "./datetime.js"; export { slug } from "./slug.js"; export { image } from "./image.js"; @@ -35,6 +36,7 @@ export type { IntegerOptions } from "./integer.js"; export type { BooleanOptions } from "./boolean.js"; export type { SelectOptions } from "./select.js"; export type { MultiSelectOptions } from "./multiselect.js"; +export type { DateOptions } from "./date.js"; export type { DatetimeOptions } from "./datetime.js"; export type { SlugOptions } from "./slug.js"; export type { FileOptions } from "./file.js"; diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 50a889c30..a555af93b 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -1201,7 +1201,7 @@ export function createMcpServer(): McpServer { description: "Get detailed info about a collection including all field definitions. " + "Fields describe the data model: name, type (string, text, number, " + - "boolean, datetime, portableText, image, reference, json, select, " + + "boolean, date, datetime, portableText, image, reference, json, select, " + "multiSelect, slug), constraints, and validation rules. Use this to " + "understand what data content_create and content_update expect.", inputSchema: z.object({ @@ -1320,7 +1320,7 @@ export function createMcpServer(): McpServer { description: "Add a new field to a collection's schema. This adds a column to the " + "database table. Field types: string (short text), text (long text), " + - "number (decimal), integer, boolean, datetime, select (single choice), " + + "number (decimal), integer, boolean, date, datetime, select (single choice), " + "multiSelect (multiple), portableText (rich text), image, file, " + "reference (link to another collection), json, slug (URL-safe id). " + "For select/multiSelect, provide choices in validation.options array.", @@ -1338,6 +1338,7 @@ export function createMcpServer(): McpServer { "number", "integer", "boolean", + "date", "datetime", "select", "multiSelect", diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 7640fd0c7..99efba09a 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -68,6 +68,7 @@ const FIELD_TYPES = [ "number", "integer", "boolean", + "date", "datetime", "select", "multiSelect", diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 285835593..0a6cee96b 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -15,6 +15,7 @@ export type FieldType = | "number" | "integer" | "boolean" + | "date" | "datetime" | "select" | "multiSelect" @@ -36,6 +37,7 @@ export const FIELD_TYPES: readonly FieldType[] = [ "number", "integer", "boolean", + "date", "datetime", "select", "multiSelect", @@ -62,6 +64,7 @@ export const FIELD_TYPE_TO_COLUMN: Record = { number: "REAL", integer: "INTEGER", boolean: "INTEGER", + date: "TEXT", datetime: "TEXT", select: "TEXT", multiSelect: "JSON", @@ -102,7 +105,16 @@ export type CollectionSource = /** Sub-field definition for repeater fields */ export interface RepeaterSubField { slug: string; - type: "string" | "text" | "url" | "number" | "integer" | "boolean" | "datetime" | "select"; + type: + | "string" + | "text" + | "url" + | "number" + | "integer" + | "boolean" + | "date" + | "datetime" + | "select"; label: string; required?: boolean; options?: string[]; // For select sub-fields @@ -116,6 +128,7 @@ export const REPEATER_SUB_FIELD_TYPES = [ "number", "integer", "boolean", + "date", "datetime", "select", ] as const; diff --git a/packages/core/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts index bf7842a67..283c2c70e 100644 --- a/packages/core/src/schema/zod-generator.ts +++ b/packages/core/src/schema/zod-generator.ts @@ -5,6 +5,7 @@ import type { Field, FieldType, CollectionWithFields } from "./types.js"; /** Pattern to split on underscores, hyphens, and spaces for PascalCase conversion */ const PASCAL_CASE_SPLIT_PATTERN = /[_\-\s]+/; +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; /** * Generate a Zod schema from a collection's field definitions @@ -85,6 +86,15 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny { // produce its standard rejection. return z.preprocess((v) => (v === 0 || v === 1 ? Boolean(v) : v), z.boolean()); + case "date": + return z + .string() + .regex(DATE_ONLY_PATTERN) + .refine((value) => { + const parsed = new Date(`${value}T00:00:00.000Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; + }, "Invalid date"); + case "datetime": return z.string().datetime().or(z.string().date()); @@ -366,6 +376,7 @@ function fieldTypeToTypeScript(field: Field): string { case "text": case "slug": case "url": + case "date": case "datetime": return "string"; diff --git a/packages/core/tests/integration/mcp/schema.test.ts b/packages/core/tests/integration/mcp/schema.test.ts index 4162fc541..2ee33cbd3 100644 --- a/packages/core/tests/integration/mcp/schema.test.ts +++ b/packages/core/tests/integration/mcp/schema.test.ts @@ -427,6 +427,7 @@ describe("schema_create_field", () => { ["number", "f_number"], ["integer", "f_integer"], ["boolean", "f_bool"], + ["date", "f_date"], ["datetime", "f_dt"], ["portableText", "f_portable_text"], ["json", "f_json"], diff --git a/packages/core/tests/unit/fields/all-fields.test.ts b/packages/core/tests/unit/fields/all-fields.test.ts index 2fd2dabfe..7b70b3567 100644 --- a/packages/core/tests/unit/fields/all-fields.test.ts +++ b/packages/core/tests/unit/fields/all-fields.test.ts @@ -8,6 +8,7 @@ import { boolean as booleanField, select, multiSelect, + date, datetime, slug, image, @@ -210,6 +211,20 @@ describe("Field Types", () => { }); }); + describe("date", () => { + it("should create date field", () => { + const field = date(); + expect(field.type).toBe("date"); + expect(field.ui?.widget).toBe("date"); + }); + + it("should validate date-only strings", () => { + const field = date({ required: true }); + expect(() => field.schema.parse("2026-02-26")).not.toThrow(); + expect(() => field.schema.parse("2026-02-26T09:30:00.000Z")).toThrow(); + }); + }); + describe("slug", () => { it("should create slug field", () => { const field = slug(); diff --git a/packages/core/tests/unit/plugins/field-widgets.test.ts b/packages/core/tests/unit/plugins/field-widgets.test.ts index c9929ca7d..2874ca06f 100644 --- a/packages/core/tests/unit/plugins/field-widgets.test.ts +++ b/packages/core/tests/unit/plugins/field-widgets.test.ts @@ -104,7 +104,7 @@ describe("pluginManifestSchema — fieldWidgets", () => { { name: "hex", label: "Hex Input", - fieldTypes: ["string", "json"], + fieldTypes: ["string", "json", "date"], }, ], }), diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts index ee9e37900..120bbda47 100644 --- a/packages/core/tests/unit/runtime/manifest-build.test.ts +++ b/packages/core/tests/unit/runtime/manifest-build.test.ts @@ -117,6 +117,7 @@ describe("EmDashRuntime.getManifest()", () => { source: "test", }); await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + await registry.createField("posts", { slug: "event_date", label: "Event Date", type: "date" }); await registry.createField("posts", { slug: "body", label: "Body", type: "json" }); const runtime = buildRuntime(db); @@ -125,6 +126,7 @@ describe("EmDashRuntime.getManifest()", () => { const posts = manifest.collections.posts; expect(posts).toBeDefined(); expect(posts?.fields.title?.kind).toBe("string"); + expect(posts?.fields.event_date?.kind).toBe("date"); expect(posts?.fields.body?.kind).toBe("json"); }); diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts index ee9d6eb55..9f787fae5 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -310,6 +310,7 @@ describe("SchemaRegistry", () => { { type: "number", slug: "f_number", expected: "REAL" }, { type: "integer", slug: "f_integer", expected: "INTEGER" }, { type: "boolean", slug: "f_boolean", expected: "INTEGER" }, + { type: "date", slug: "f_date", expected: "TEXT" }, { type: "datetime", slug: "f_datetime", expected: "TEXT" }, { type: "portableText", slug: "f_portable", expected: "JSON" }, { type: "json", slug: "f_json", expected: "JSON" }, diff --git a/packages/core/tests/unit/schema/zod-generator.test.ts b/packages/core/tests/unit/schema/zod-generator.test.ts index 4bc365739..508aefbef 100644 --- a/packages/core/tests/unit/schema/zod-generator.test.ts +++ b/packages/core/tests/unit/schema/zod-generator.test.ts @@ -202,6 +202,26 @@ describe("Zod Generator", () => { expect(() => schema.parse(123)).toThrow(); }); + it("should generate date schema", () => { + const field: Field = { + id: "f1", + collectionId: "c1", + slug: "event_date", + label: "Event Date", + type: "date", + columnType: "TEXT", + required: true, + unique: false, + sortOrder: 0, + createdAt: new Date().toISOString(), + }; + + const schema = generateFieldSchema(field); + expect(schema.parse("2026-02-26")).toBe("2026-02-26"); + expect(() => schema.parse("2026-02-26T09:30:00.000Z")).toThrow(); + expect(() => schema.parse("02/26/2026")).toThrow(); + }); + it("should generate select schema with options", () => { const field: Field = { id: "f1", From 1ceb8c8f9e1a22ac6dc08295f5d39e5492e3aeaa Mon Sep 17 00:00:00 2001 From: Mason James Date: Fri, 5 Jun 2026 15:29:49 -0400 Subject: [PATCH 2/2] fix: address date field review feedback --- packages/core/src/fields/date.ts | 8 ++++++- packages/core/src/schema/zod-generator.ts | 2 +- .../core/tests/unit/fields/all-fields.test.ts | 24 +++++++++++++++++++ .../tests/unit/schema/zod-generator.test.ts | 6 +++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/core/src/fields/date.ts b/packages/core/src/fields/date.ts index 96c9b59b0..26701e076 100644 --- a/packages/core/src/fields/date.ts +++ b/packages/core/src/fields/date.ts @@ -12,7 +12,13 @@ export interface DateOptions { } function toDateOnly(value: string | Date): string { - return value instanceof Date ? value.toISOString().slice(0, 10) : value; + if (value instanceof Date) { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + return value; } function isValidDateOnly(value: string): boolean { diff --git a/packages/core/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts index 283c2c70e..4e12efcce 100644 --- a/packages/core/src/schema/zod-generator.ts +++ b/packages/core/src/schema/zod-generator.ts @@ -89,7 +89,7 @@ function getBaseSchema(type: FieldType, field: Field): ZodTypeAny { case "date": return z .string() - .regex(DATE_ONLY_PATTERN) + .regex(DATE_ONLY_PATTERN, "Must be a date in YYYY-MM-DD format") .refine((value) => { const parsed = new Date(`${value}T00:00:00.000Z`); return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; diff --git a/packages/core/tests/unit/fields/all-fields.test.ts b/packages/core/tests/unit/fields/all-fields.test.ts index 7b70b3567..1f04edd1f 100644 --- a/packages/core/tests/unit/fields/all-fields.test.ts +++ b/packages/core/tests/unit/fields/all-fields.test.ts @@ -223,6 +223,30 @@ describe("Field Types", () => { expect(() => field.schema.parse("2026-02-26")).not.toThrow(); expect(() => field.schema.parse("2026-02-26T09:30:00.000Z")).toThrow(); }); + + it("should preserve local calendar dates for Date min/max options", () => { + const previousTimezone = process.env.TZ; + process.env.TZ = "Asia/Tokyo"; + + try { + const field = date({ + required: true, + min: new Date(2026, 1, 26), + max: new Date(2026, 1, 28), + }); + + expect(field.ui?.min).toBe("2026-02-26"); + expect(field.ui?.max).toBe("2026-02-28"); + expect(() => field.schema.parse("2026-02-26")).not.toThrow(); + expect(() => field.schema.parse("2026-02-25")).toThrow("Date is too early"); + } finally { + if (previousTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = previousTimezone; + } + } + }); }); describe("slug", () => { diff --git a/packages/core/tests/unit/schema/zod-generator.test.ts b/packages/core/tests/unit/schema/zod-generator.test.ts index 508aefbef..1c4f962c7 100644 --- a/packages/core/tests/unit/schema/zod-generator.test.ts +++ b/packages/core/tests/unit/schema/zod-generator.test.ts @@ -220,6 +220,12 @@ describe("Zod Generator", () => { expect(schema.parse("2026-02-26")).toBe("2026-02-26"); expect(() => schema.parse("2026-02-26T09:30:00.000Z")).toThrow(); expect(() => schema.parse("02/26/2026")).toThrow(); + + const result = schema.safeParse("02/26/2026"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("Must be a date in YYYY-MM-DD format"); + } }); it("should generate select schema with options", () => {