diff --git a/.changeset/add-url-field-type.md b/.changeset/add-url-field-type.md new file mode 100644 index 000000000..6a957a26e --- /dev/null +++ b/.changeset/add-url-field-type.md @@ -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. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index cb2a7d217..8b03ee427 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -348,6 +348,19 @@ export function ContentEditor({ pendingAutosaveStateRef.current = null; }, [lastAutosaveAt]); + const hasInvalidUrls = React.useCallback( + (data: Record) => { + 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) { @@ -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, @@ -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; // Cancel pending autosave if (autosaveTimeoutRef.current) { clearTimeout(autosaveTimeoutRef.current); @@ -1307,6 +1332,19 @@ function FieldRenderer({ ); } + case "url": + return ( + + ); + default: // Default to text input return ( @@ -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(null); + + const handleBlur = (e: React.FocusEvent) => { + 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 ( +
+ {label}} + id={id} + type="url" + value={value} + onChange={(e) => { + if (error) setError(null); + onChange(e.target.value); + }} + onBlur={handleBlur} + required={required} + placeholder={placeholder} + /> + {error &&

{error}

} +
+ ); +} + /** * JSON field editor with syntax validation */ diff --git a/packages/admin/src/components/FieldEditor.tsx b/packages/admin/src/components/FieldEditor.tsx index 6cd75d8c7..62a9a78b7 100644 --- a/packages/admin/src/components/FieldEditor.tsx +++ b/packages/admin/src/components/FieldEditor.tsx @@ -14,6 +14,7 @@ import { LinkSimple, BracketsCurly, Link, + GlobeSimple, Rows, Plus, Trash, @@ -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`, @@ -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, @@ -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") && (