diff --git a/.changeset/date-only-field.md b/.changeset/date-only-field.md new file mode 100644 index 0000000000..9adb531812 --- /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 7095747fcb..0178e41272 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -2041,6 +2041,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 d2740bc2a3..32284b0391 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -213,6 +213,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`, @@ -623,6 +629,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 4afc56811b..166c9e101a 100644 --- a/packages/admin/src/components/RepeaterField.tsx +++ b/packages/admin/src/components/RepeaterField.tsx @@ -369,6 +369,16 @@ function SubFieldInput({ subField, value, onChange, timezone }: SubFieldInputPro required={subField.required} /> ); + case "date": + return ( + onChange(e.target.value)} + required={subField.required} + /> + ); case "select": { // Searchable combobox so long option lists (e.g. taxonomy-derived // options) stay usable inside repeater rows, rather than a plain diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts index d8aa10a1f1..cb51baa21f 100644 --- a/packages/admin/src/lib/api/schema.ts +++ b/packages/admin/src/lib/api/schema.ts @@ -14,6 +14,7 @@ export type FieldType = | "number" | "integer" | "boolean" + | "date" | "datetime" | "select" | "multiSelect" diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index 0861252db7..f4bd68ec93 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -984,6 +984,14 @@ describe("ContentEditor", () => { await expect.element(input).toHaveAttribute("type", "datetime-local"); }); + it("renders date fields as date-only inputs", async () => { + const screen = await renderEditor({ + fields: { event_date: { kind: "date", label: "Event date" } }, + }); + const input = screen.getByLabelText("Event date"); + await expect.element(input).toHaveAttribute("type", "date"); + }); + it("displays a stored ISO datetime in the datetime-local input", async () => { // The validator stores datetimes as full ISO 8601 with "Z" + millis, // but only accepts "YYYY-MM-DDTHH:mm". @@ -1035,6 +1043,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 01d32584eb..6468718b3a 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/, @@ -85,7 +86,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 1b8a70f2ae..484541753b 100644 --- a/packages/admin/tests/components/RepeaterField.test.tsx +++ b/packages/admin/tests/components/RepeaterField.test.tsx @@ -42,6 +42,41 @@ 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 f431d120bb..7cc7eb2321 100644 --- a/packages/core/src/api/handlers/manifest.ts +++ b/packages/core/src/api/handlers/manifest.ts @@ -42,6 +42,7 @@ const FIELD_TYPE_TO_KIND: Record = { number: "number", integer: "number", boolean: "boolean", + date: "date", datetime: "datetime", select: "select", multiSelect: "multiSelect", @@ -225,6 +226,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 aad4a72ae8..d5450b3e82 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -43,6 +43,7 @@ const fieldTypeValues = z.enum([ "number", "integer", "boolean", + "date", "datetime", "select", "multiSelect", @@ -66,6 +67,7 @@ const repeaterSubFieldSchema = z.object({ "number", "integer", "boolean", + "date", "datetime", "select", "image", diff --git a/packages/core/src/cli/commands/schema.ts b/packages/core/src/cli/commands/schema.ts index c7db5e617e..1c90c61cad 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/fields/date.ts b/packages/core/src/fields/date.ts new file mode 100644 index 0000000000..26701e0769 --- /dev/null +++ b/packages/core/src/fields/date.ts @@ -0,0 +1,68 @@ +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 { + 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 { + 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 068a4bcf2f..d5de9e6357 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 ba76477213..f6ec892811 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -1924,7 +1924,7 @@ export function createMcpServer( 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({ @@ -2082,7 +2082,7 @@ export function createMcpServer( 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.", @@ -2100,6 +2100,7 @@ export function createMcpServer( "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 990a3489ba..d2e36b92de 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -94,6 +94,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 0d426df949..b4815f488e 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", @@ -79,6 +81,7 @@ export const FIELD_TYPE_TO_COLUMN: Record = { number: "REAL", integer: "INTEGER", boolean: "INTEGER", + date: "TEXT", datetime: "TEXT", select: "TEXT", multiSelect: "JSON", @@ -126,6 +129,7 @@ export interface RepeaterSubField { | "number" | "integer" | "boolean" + | "date" | "datetime" | "select" | "image"; @@ -142,6 +146,7 @@ export const REPEATER_SUB_FIELD_TYPES = [ "number", "integer", "boolean", + "date", "datetime", "select", "image", diff --git a/packages/core/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts index 91d3e94882..7e900ce479 100644 --- a/packages/core/src/schema/zod-generator.ts +++ b/packages/core/src/schema/zod-generator.ts @@ -5,6 +5,7 @@ import type { CollectionWithFields, Field, FieldType, RepeaterSubField } from ". /** 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: Pick): ZodTy // 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, "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; + }, "Invalid date"); + case "datetime": return z.iso.datetime({ offset: true }).or(z.iso.datetime({ offset: true, precision: -1 })); @@ -429,6 +439,7 @@ function fieldTypeToTypeScript(field: { 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 71d185e91b..a3f6fe5931 100644 --- a/packages/core/tests/integration/mcp/schema.test.ts +++ b/packages/core/tests/integration/mcp/schema.test.ts @@ -649,6 +649,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 5ba1588097..932f748c54 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,44 @@ 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(); + }); + + 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", () => { 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 c9929ca7d5..2874ca06f1 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 b7b52e74a4..e7cb9deb42 100644 --- a/packages/core/tests/unit/runtime/manifest-build.test.ts +++ b/packages/core/tests/unit/runtime/manifest-build.test.ts @@ -328,6 +328,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); @@ -336,6 +337,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 90d03174af..a4b4e149ee 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -743,6 +743,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 c2c410c4d2..42c32c2ddc 100644 --- a/packages/core/tests/unit/schema/zod-generator.test.ts +++ b/packages/core/tests/unit/schema/zod-generator.test.ts @@ -229,6 +229,32 @@ describe("Zod Generator", () => { expect(schema.safeParse("https://other.example/path").success).toBe(false); }); + 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(); + + 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", () => { const field: Field = { id: "f1", @@ -857,6 +883,7 @@ describe("Zod Generator", () => { number: "number", integer: "number", boolean: "boolean", + date: "string", datetime: "string", select: "string", image: MEDIA_LITERAL,