Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
62 changes: 62 additions & 0 deletions packages/core/src/fields/date.ts
Original file line number Diff line number Diff line change
@@ -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;
}

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