-
Notifications
You must be signed in to change notification settings - Fork 925
feat: per-field allowed MIME types for file and image fields #942
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
09445e8
5ef22bd
5e1ab0c
4b10634
1d591c7
5734d97
7903116
0172543
4891453
200b4e9
61b9e96
fe148fc
86c46a6
035491c
59bd04b
992ac2d
9148a49
d2313de
df139ee
879c3db
437827b
081ddde
ed4b03c
dd718a7
433a78a
b2a53e2
9604e40
e1c14a1
132e325
08d8047
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| --- | ||
| "emdash": minor | ||
| "@emdash-cms/admin": minor | ||
| --- | ||
|
|
||
| Adds per-field allowed MIME types for `file` and `image` fields. Field-level `allowedTypes` is now honored end-to-end: it filters the media picker, widens upload acceptance for that field (so e.g. a zip-only field can accept zip uploads even though the global allowlist excludes them), and validates referenced media against the destination field on content save. The schema editor in admin gains an "Allowed types" control with curated presets and freeform entry. | ||
|
|
||
| Behavior change: the `image` builder's `allowedTypes` option was previously accepted but read by nothing. It is now load-bearing — a code-first schema that already passed `allowedTypes` (e.g. `["image/png"]`) will now actually narrow the picker and gate uploads. Most users will see no change; if you set this option intending the old (silent) behavior, drop it. | ||
|
|
||
| Behavior change: updating a field via the admin schema editor now explicitly clears its validation when the form contains no validation settings, instead of leaving an existing `validation` value intact. This only affects fields with pre-existing validation that is not expressible in the editor UI. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| import { Button, Input, Label } from "@cloudflare/kumo"; | ||
| import { useLingui } from "@lingui/react/macro"; | ||
| import { Plus, X } from "@phosphor-icons/react"; | ||
| import * as React from "react"; | ||
|
|
||
| import { EXTENSION_TO_MIME, VALID_MIME_RE } from "../lib/mime-utils.js"; | ||
| import { cn } from "../lib/utils"; | ||
|
|
||
| interface Preset { | ||
| key: string; | ||
| mimeTypes: string[]; | ||
| } | ||
|
|
||
| const PRESETS: ReadonlyArray<Preset> = [ | ||
| { key: "images", mimeTypes: ["image/"] }, | ||
| { key: "pdf", mimeTypes: ["application/pdf"] }, | ||
| { | ||
| key: "documents", | ||
| mimeTypes: [ | ||
| "application/pdf", | ||
| "application/msword", | ||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document", | ||
| "text/plain", | ||
| "application/rtf", | ||
| ], | ||
| }, | ||
| { | ||
| key: "spreadsheets", | ||
| mimeTypes: [ | ||
| "text/csv", | ||
| "application/vnd.ms-excel", | ||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | ||
| ], | ||
| }, | ||
| { key: "archives", mimeTypes: ["application/zip", "application/x-tar", "application/gzip"] }, | ||
| { key: "audio", mimeTypes: ["audio/"] }, | ||
| { key: "video", mimeTypes: ["video/"] }, | ||
| { key: "captions", mimeTypes: ["text/vtt", "application/x-subrip"] }, | ||
| { key: "fonts", mimeTypes: ["font/"] }, | ||
| ]; | ||
|
|
||
| function expandShorthand(entry: string): string | null { | ||
| const trimmed = entry.trim(); | ||
| if (!trimmed) return null; | ||
| if (trimmed.includes("/")) return VALID_MIME_RE.test(trimmed) ? trimmed : null; | ||
| if (trimmed.startsWith(".")) return EXTENSION_TO_MIME[trimmed.toLowerCase()] ?? null; | ||
| return null; | ||
| } | ||
|
|
||
| export interface AllowedTypesEditorProps { | ||
| value: string[]; | ||
| onChange: (next: string[]) => void; | ||
| } | ||
|
|
||
| export function AllowedTypesEditor({ value, onChange }: AllowedTypesEditorProps) { | ||
| const { t } = useLingui(); | ||
| const [draft, setDraft] = React.useState(""); | ||
| const [warning, setWarning] = React.useState<string | null>(null); | ||
|
|
||
| const presetLabels: Record<string, string> = { | ||
| images: t`Images`, | ||
| pdf: t`PDF`, | ||
| documents: t`Documents`, | ||
| spreadsheets: t`Spreadsheets`, | ||
| archives: t`Archives`, | ||
| audio: t`Audio`, | ||
| video: t`Video`, | ||
| captions: t`Captions / Subtitles`, | ||
| fonts: t`Fonts`, | ||
| }; | ||
|
|
||
| const set = React.useMemo(() => new Set(value), [value]); | ||
|
|
||
| const togglePreset = (preset: Preset) => { | ||
| const allIncluded = preset.mimeTypes.every((m) => set.has(m)); | ||
| const next = new Set(value); | ||
| if (allIncluded) { | ||
| for (const m of preset.mimeTypes) next.delete(m); | ||
| } else { | ||
| for (const m of preset.mimeTypes) next.add(m); | ||
| } | ||
| onChange([...next]); | ||
| }; | ||
|
|
||
| const addDraft = () => { | ||
| const expanded = expandShorthand(draft); | ||
| if (!expanded) { | ||
| setWarning(t`Couldn't map "${draft}" to a MIME type. Type the MIME directly.`); | ||
| return; | ||
| } | ||
| setWarning(null); | ||
| if (!set.has(expanded)) onChange([...value, expanded]); | ||
| setDraft(""); | ||
| }; | ||
|
|
||
| const removeEntry = (entry: string) => { | ||
| onChange(value.filter((v) => v !== entry)); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="space-y-3"> | ||
| <Label>{t`Allowed types`}</Label> | ||
| <p className="text-xs text-kumo-subtle"> | ||
| {value.length === 0 | ||
| ? t`Any media type allowed (subject to global limits).` | ||
| : t`Only the listed MIME types will be accepted for this field.`} | ||
| </p> | ||
|
|
||
| <div className="flex flex-wrap gap-1.5"> | ||
| {PRESETS.map((preset) => { | ||
| const allIncluded = preset.mimeTypes.every((m) => set.has(m)); | ||
| return ( | ||
| <button | ||
| key={preset.key} | ||
| type="button" | ||
| onClick={() => togglePreset(preset)} | ||
| aria-pressed={allIncluded} | ||
| className={cn( | ||
| "px-3 py-1 rounded-full text-xs font-medium transition-colors", | ||
| allIncluded | ||
| ? "bg-kumo-brand text-white" | ||
| : "bg-kumo-tint text-kumo-subtle hover:bg-kumo-tint/80", | ||
| )} | ||
| > | ||
| {presetLabels[preset.key]} | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> | ||
|
|
||
| {value.length > 0 && ( | ||
| <ul className="flex flex-wrap gap-1.5"> | ||
| {value.map((entry) => ( | ||
| <li | ||
| key={entry} | ||
| className="flex items-center gap-1 bg-kumo-tint rounded px-2 py-1 text-xs" | ||
| > | ||
| <code>{entry}</code> | ||
| <Button | ||
| type="button" | ||
| shape="square" | ||
| variant="ghost" | ||
| className="h-5 w-5" | ||
| onClick={() => removeEntry(entry)} | ||
| aria-label={t`Remove ${entry}`} | ||
| > | ||
| <X className="h-3 w-3" /> | ||
| </Button> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| )} | ||
|
|
||
| <div className="flex gap-2"> | ||
| <Input | ||
| value={draft} | ||
| onChange={(e) => { | ||
| setDraft(e.target.value); | ||
| setWarning(null); | ||
| }} | ||
| placeholder={t`e.g. application/zip or .pdf`} | ||
| aria-label={t`Add MIME type or extension`} | ||
| onKeyDown={(e) => { | ||
| if (e.key === "Enter") { | ||
| e.preventDefault(); | ||
| addDraft(); | ||
| } | ||
| }} | ||
| /> | ||
| <Button type="button" icon={Plus} onClick={addDraft} disabled={!draft.trim()}> | ||
| {t`Add`} | ||
| </Button> | ||
| </div> | ||
| {warning && <p className="text-xs text-kumo-danger">{warning}</p>} | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,12 +18,13 @@ import { | |
| Rows, | ||
| Plus, | ||
| Trash, | ||
| X, | ||
| } from "@phosphor-icons/react"; | ||
| import { X } from "@phosphor-icons/react"; | ||
| import * as React from "react"; | ||
|
|
||
| import type { FieldType, CreateFieldInput, SchemaField } from "../lib/api"; | ||
| import { cn } from "../lib/utils"; | ||
| import { AllowedTypesEditor } from "./AllowedTypesEditor"; | ||
|
|
||
| // ============================================================================ | ||
| // Constants | ||
|
|
@@ -75,6 +76,7 @@ interface FieldFormState { | |
| subFields: RepeaterSubFieldState[]; | ||
| minItems: string; | ||
| maxItems: string; | ||
| allowedMimeTypes: string[]; | ||
| } | ||
|
|
||
| function getInitialFormState(field?: SchemaField): FieldFormState { | ||
|
|
@@ -98,6 +100,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState { | |
| : [], | ||
| minItems: (field.validation as Record<string, unknown>)?.minItems?.toString() ?? "", | ||
| maxItems: (field.validation as Record<string, unknown>)?.maxItems?.toString() ?? "", | ||
| allowedMimeTypes: field.validation?.allowedMimeTypes ?? [], | ||
| }; | ||
| } | ||
| return { | ||
|
|
@@ -117,6 +120,7 @@ function getInitialFormState(field?: SchemaField): FieldFormState { | |
| subFields: [], | ||
| minItems: "", | ||
| maxItems: "", | ||
| allowedMimeTypes: [], | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -300,6 +304,13 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie | |
| (validation as Record<string, unknown>).maxItems = parseInt(formState.maxItems, 10); | ||
| } | ||
|
|
||
| if ( | ||
| (selectedType === "file" || selectedType === "image") && | ||
| formState.allowedMimeTypes.length > 0 | ||
| ) { | ||
| validation.allowedMimeTypes = formState.allowedMimeTypes; | ||
| } | ||
|
|
||
| // Only include searchable for text-based fields | ||
| const isSearchableType = | ||
| selectedType === "string" || | ||
|
|
@@ -315,7 +326,7 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie | |
| required, | ||
| unique, | ||
| searchable: isSearchableType ? searchable : undefined, | ||
| validation: Object.keys(validation).length > 0 ? validation : undefined, | ||
| validation: Object.keys(validation).length > 0 ? validation : null, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Switching the empty-validation case from
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a note to the changeset. |
||
| }; | ||
|
|
||
| onSave(input); | ||
|
|
@@ -636,6 +647,13 @@ export function FieldEditor({ open, onOpenChange, field, onSave, isSaving }: Fie | |
| </div> | ||
| </div> | ||
| )} | ||
|
|
||
| {(selectedType === "file" || selectedType === "image") && ( | ||
| <AllowedTypesEditor | ||
| value={formState.allowedMimeTypes} | ||
| onChange={(next) => setField("allowedMimeTypes", next)} | ||
| /> | ||
| )} | ||
| </div> | ||
| )} | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
togglePresettoggles by exact MIME-string membership invalue. If a preset's MIME (e.g."application/pdf"from the Documents preset) is also already present from the PDF preset toggle, deselecting Documents will silently strip PDF too. Less of a bug, more of a UX quirk — toggling a multi-MIME preset has hidden interactions with adjacent presets that share entries. Worth at least a comment, or treating presets as additive-only with a single "clear" affordance.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair UX observation. The toggle-by-membership behavior is intentional — it lets users compose their own sets from multiple presets. The hidden interaction (Documents includes PDF, so toggling both means deselecting Documents also removes PDF) is a consequence of that design. Will leave as-is for now; adding a comment in the code.