Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/date-only-field.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,18 @@ function FieldRenderer({
/>
);

case "date":
return (
<Input
label={label}
id={id}
type="date"
value={typeof value === "string" ? value.slice(0, 10) : ""}
onChange={(e) => handleChange(e.target.value)}
required={field.required}
/>
);

case "image": {
// value is either an ImageFieldValue object, a legacy string URL, or undefined
const imageValue =
Expand Down
7 changes: 7 additions & 0 deletions packages/admin/src/components/FieldEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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`,
Expand Down
10 changes: 10 additions & 0 deletions packages/admin/src/components/RepeaterField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,16 @@ function SubFieldInput({ subField, value, onChange }: SubFieldInputProps) {
required={subField.required}
/>
);
case "date":
return (
<Input
label={subField.label}
type="date"
value={typeof value === "string" ? value.slice(0, 10) : ""}
onChange={(e) => onChange(e.target.value)}
required={subField.required}
/>
);
case "select":
return (
<Select
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/lib/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type FieldType =
| "number"
| "integer"
| "boolean"
| "date"
| "datetime"
| "select"
| "multiSelect"
Expand Down
32 changes: 32 additions & 0 deletions packages/admin/tests/components/ContentEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,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 <input type="datetime-local"> only accepts "YYYY-MM-DDTHH:mm".
Expand Down Expand Up @@ -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" } },
Expand Down
3 changes: 2 additions & 1 deletion packages/admin/tests/components/FieldEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
Expand Down Expand Up @@ -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(<FieldEditor {...defaultProps} />);
// Each type renders as a button with label and description
for (const name of FIELD_TYPE_REGEXES) {
Expand Down
35 changes: 35 additions & 0 deletions packages/admin/tests/components/RepeaterField.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,39 @@ describe("RepeaterField", () => {
]);
});
});

describe("date sub-field", () => {
it("renders date-only values in a date input", async () => {
const screen = await render(
<RepeaterField
label="Events"
id="events"
value={[{ event_date: "2026-02-26" }]}
onChange={vi.fn()}
subFields={[{ slug: "event_date", type: "date", label: "Event date" }]}
/>,
);
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(
<RepeaterField
label="Events"
id="events"
value={[{ event_date: "" }]}
onChange={onChange}
subFields={[{ slug: "event_date", type: "date", label: "Event date" }]}
/>,
);
await screen.getByLabelText("Event date").fill("2026-02-26");

expect(onChange).toHaveBeenLastCalledWith([
expect.objectContaining({ event_date: "2026-02-26" }),
]);
});
});
});
3 changes: 3 additions & 0 deletions packages/core/src/api/handlers/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/api/schemas/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const fieldTypeValues = z.enum([
"number",
"integer",
"boolean",
"date",
"datetime",
"select",
"multiSelect",
Expand All @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/cli/commands/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ const FIELD_TYPE_TO_KIND: Record<FieldType, string> = {
number: "number",
integer: "number",
boolean: "boolean",
date: "date",
datetime: "datetime",
select: "select",
multiSelect: "multiSelect",
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/fields/date.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Comment thread
masonjames marked this conversation as resolved.
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<string> {
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,
};
}
2 changes: 2 additions & 0 deletions packages/core/src/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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.",
Expand All @@ -1338,6 +1338,7 @@ export function createMcpServer(): McpServer {
"number",
"integer",
"boolean",
"date",
"datetime",
"select",
"multiSelect",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/plugins/manifest-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const FIELD_TYPES = [
"number",
"integer",
"boolean",
"date",
"datetime",
"select",
"multiSelect",
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type FieldType =
| "number"
| "integer"
| "boolean"
| "date"
| "datetime"
| "select"
| "multiSelect"
Expand All @@ -36,6 +37,7 @@ export const FIELD_TYPES: readonly FieldType[] = [
"number",
"integer",
"boolean",
"date",
"datetime",
"select",
"multiSelect",
Expand All @@ -62,6 +64,7 @@ export const FIELD_TYPE_TO_COLUMN: Record<FieldType, ColumnType> = {
number: "REAL",
integer: "INTEGER",
boolean: "INTEGER",
date: "TEXT",
datetime: "TEXT",
select: "TEXT",
multiSelect: "JSON",
Expand Down Expand Up @@ -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
Expand All @@ -116,6 +128,7 @@ export const REPEATER_SUB_FIELD_TYPES = [
"number",
"integer",
"boolean",
"date",
"datetime",
"select",
] as const;
Expand Down
Loading
Loading