Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/add-url-field-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Adds the missing `url` field type for seed files, content type builder, and content editor with client-side URL validation.
110 changes: 109 additions & 1 deletion packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,19 @@ export function ContentEditor({
pendingAutosaveStateRef.current = null;
}, [lastAutosaveAt]);

const hasInvalidUrls = React.useCallback(
(data: Record<string, unknown>) => {
for (const [name, field] of Object.entries(fields)) {
if (field.kind === "url") {
const val = typeof data[name] === "string" ? (data[name] as string).trim() : "";
if (val && !isValidUrl(val)) return true;
}
}
return false;
},
[fields],
);

React.useEffect(() => {
// Don't autosave for new items (no ID yet) or if autosave isn't configured
if (isNew || !onAutosave || !item?.id) {
Expand All @@ -366,6 +379,7 @@ export function ContentEditor({

// Schedule autosave
autosaveTimeoutRef.current = setTimeout(() => {
if (hasInvalidUrls(formDataRef.current)) return;
const payload = {
data: formDataRef.current,
slug: slugRef.current || undefined,
Expand All @@ -384,11 +398,22 @@ export function ContentEditor({
clearTimeout(autosaveTimeoutRef.current);
}
};
}, [currentData, isNew, onAutosave, item?.id, isDirty, isSaving, isAutosaving, activeBylines]);
}, [
currentData,
isNew,
onAutosave,
item?.id,
isDirty,
isSaving,
isAutosaving,
activeBylines,
hasInvalidUrls,
]);

// Cancel pending autosave on manual save
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasInvalidUrls(formData)) return;
Comment on lines 414 to +416

Copilot AI Apr 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleSubmit (and autosave) silently aborts when hasInvalidUrls is true, but it doesn't surface which field is invalid (and a user can hit Save without triggering onBlur, so no inline error is shown). Consider tracking invalid URL fields centrally and showing an error/toast (or triggering validation for all URL inputs) before returning so the user understands why save/autosave didn’t run.

Copilot uses AI. Check for mistakes.
// Cancel pending autosave
if (autosaveTimeoutRef.current) {
clearTimeout(autosaveTimeoutRef.current);
Expand Down Expand Up @@ -1307,6 +1332,19 @@ function FieldRenderer({
);
}

case "url":
return (
<UrlFieldEditor
label={label}
labelClass={labelClass}
id={id}
value={typeof value === "string" ? value : ""}
onChange={handleChange}
required={field.required}
placeholder="https://"
/>
);

default:
// Default to text input
return (
Expand All @@ -1321,6 +1359,76 @@ function FieldRenderer({
}
}

const URL_PROTOCOL_PATTERN = /^https?:\/\//;

function isValidUrl(val: string): boolean {
if (!URL_PROTOCOL_PATTERN.test(val)) return false;
try {
const url = new URL(val);
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
if (url.hostname.includes("..")) return false;
return url.hostname.includes(".") || url.hostname === "localhost";
} catch {
return false;
}
}

/**
* URL field editor with validation on blur
*/
function UrlFieldEditor({
label,
labelClass,
id,
value,
onChange,
required,
placeholder,
}: {
label: string;
labelClass?: string;
id: string;
value: string;
onChange: (value: unknown) => void;
required?: boolean;
placeholder?: string;
}) {
const { t } = useLingui();
const [error, setError] = React.useState<string | null>(null);

const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const val = e.target.value.trim();
if (!val) {
setError(null);
return;
}
if (!isValidUrl(val)) {
setError(t`Enter a valid URL (e.g. https://example.com)`);
} else {
setError(null);
}
};

return (
<div>
<Input
label={<span className={labelClass}>{label}</span>}
id={id}
type="url"
value={value}
onChange={(e) => {
if (error) setError(null);
onChange(e.target.value);
}}
onBlur={handleBlur}
required={required}
placeholder={placeholder}
/>
{error && <p className="text-sm text-kumo-danger mt-1">{error}</p>}
</div>
);
}

/**
* JSON field editor with syntax validation
*/
Expand Down
14 changes: 12 additions & 2 deletions packages/admin/src/components/FieldEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
LinkSimple,
BracketsCurly,
Link,
GlobeSimple,
Rows,
Plus,
Trash,
Expand Down Expand Up @@ -224,6 +225,12 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
description: t`URL-friendly identifier`,
icon: Link,
},
{
type: "url",
label: t`URL`,
description: t`Web address`,
icon: GlobeSimple,
},
{
type: "repeater",
label: t`Repeater`,
Expand Down Expand Up @@ -298,7 +305,8 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
selectedType === "string" ||
selectedType === "text" ||
selectedType === "portableText" ||
selectedType === "slug";
selectedType === "slug" ||
selectedType === "url";

const input: CreateFieldInput = {
slug,
Expand Down Expand Up @@ -433,7 +441,8 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
{(selectedType === "string" ||
selectedType === "text" ||
selectedType === "portableText" ||
selectedType === "slug") && (
selectedType === "slug" ||
selectedType === "url") && (
<label className="flex items-center space-x-2">
<input
type="checkbox"
Expand Down Expand Up @@ -576,6 +585,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie
<option value="boolean">{t`Boolean`}</option>
<option value="datetime">{t`Date & Time`}</option>
<option value="select">{t`Select`}</option>
<option value="url">{t`URL`}</option>
</select>
</div>
</div>
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 @@ -7,6 +7,7 @@ import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./clie
export type FieldType =
| "string"
| "text"
| "url"
| "number"
| "integer"
| "boolean"
Expand Down
Loading
Loading