diff --git a/.changeset/late-kids-visit.md b/.changeset/late-kids-visit.md new file mode 100644 index 000000000..c199fe651 --- /dev/null +++ b/.changeset/late-kids-visit.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes plugin block defaults so initial values are seeded without overriding later edits. diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 7826fda0b..ac243fbf9 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -970,6 +970,33 @@ function SlashCommandMenu({ ); } +function getPluginBlockDefaultValues(fields?: Element[]): Record { + const defaults: Record = {}; + + for (const field of fields ?? []) { + const initialValue = "initial_value" in field ? field.initial_value : undefined; + if (initialValue !== undefined) { + defaults[field.action_id] = initialValue; + } + } + + return defaults; +} + +function buildPluginBlockFormValues( + block: PluginBlockDef | null, + initialValues?: Record, +): Record { + const defaults = getPluginBlockDefaultValues(block?.fields); + return initialValues ? { ...defaults, ...initialValues } : defaults; +} + +function hasPluginBlockFormData(values: Record): boolean { + return Object.values(values).some( + (value) => value !== undefined && value !== null && value !== "", + ); +} + /** * Plugin block insertion/editing modal. * When the block has `fields`, renders Block Kit elements. @@ -992,11 +1019,7 @@ function PluginBlockModal({ React.useEffect(() => { if (block) { - if (initialValues) { - setFormValues({ ...initialValues }); - } else { - setFormValues({}); - } + setFormValues(buildPluginBlockFormValues(block, initialValues)); if (!block.fields || block.fields.length === 0) { setTimeout(() => inputRef.current?.focus(), 0); } @@ -1025,7 +1048,7 @@ function PluginBlockModal({ // For simple URL mode, check if the URL is non-empty // For Block Kit fields, require at least one field to have a value const canSubmit = hasFields - ? Object.values(formValues).some((v) => v !== undefined && v !== null && v !== "") + ? hasPluginBlockFormData(formValues) : typeof formValues.id === "string" && formValues.id.trim().length > 0; return ( @@ -1255,6 +1278,10 @@ export type { PluginBlockDef } from "./editor/PluginBlockNode"; // Exported for unit testing (pure functions, no React dependencies) export { prosemirrorToPortableText as _prosemirrorToPortableText }; export { portableTextToProsemirror as _portableTextToProsemirror }; +export { + buildPluginBlockFormValues as _buildPluginBlockFormValues, + hasPluginBlockFormData as _hasPluginBlockFormData, +}; // ============================================================================= // Editor Footer with Writing Metrics diff --git a/packages/admin/tests/editor/PortableTextEditor.test.tsx b/packages/admin/tests/editor/PortableTextEditor.test.tsx index 7a2f54d7a..31d5d0517 100644 --- a/packages/admin/tests/editor/PortableTextEditor.test.tsx +++ b/packages/admin/tests/editor/PortableTextEditor.test.tsx @@ -11,7 +11,11 @@ import * as React from "react"; import { describe, it, expect, vi } from "vitest"; import type { PluginBlockDef } from "../../src/components/PortableTextEditor"; -import { PortableTextEditor } from "../../src/components/PortableTextEditor"; +import { + _buildPluginBlockFormValues, + _hasPluginBlockFormData, + PortableTextEditor, +} from "../../src/components/PortableTextEditor"; import { render } from "../utils/render"; // --------------------------------------------------------------------------- @@ -173,7 +177,123 @@ function textBlock( } // ============================================================================= -// 1. Portable Text ↔ ProseMirror Conversion (via component) +// 1. Plugin block helpers +// ============================================================================= + +describe("plugin block helpers", () => { + it("builds form state from field initial_value defaults", () => { + const block: PluginBlockDef = { + type: "readingTime", + pluginId: "reading-time", + label: "Reading Time", + fields: [ + { + type: "select", + action_id: "variant", + label: "Style", + options: [ + { label: "Inline", value: "inline" }, + { label: "Compact", value: "compact" }, + ], + initial_value: "inline", + }, + { + type: "toggle", + action_id: "includeHeadings", + label: "Include headings", + initial_value: true, + }, + ], + }; + + expect(_buildPluginBlockFormValues(block)).toEqual({ + variant: "inline", + includeHeadings: true, + }); + expect(_hasPluginBlockFormData(_buildPluginBlockFormValues(block))).toBe(true); + }); + + it("merges existing block data over defaults when editing", () => { + const block: PluginBlockDef = { + type: "readingTime", + pluginId: "reading-time", + label: "Reading Time", + fields: [ + { + type: "select", + action_id: "variant", + label: "Style", + options: [ + { label: "Inline", value: "inline" }, + { label: "Compact", value: "compact" }, + ], + initial_value: "inline", + }, + { + type: "toggle", + action_id: "includeHeadings", + label: "Include headings", + initial_value: true, + }, + ], + }; + + expect( + _buildPluginBlockFormValues(block, { + variant: "compact", + customLabel: "Custom label", + includeHeadings: false, + }), + ).toEqual({ + variant: "compact", + customLabel: "Custom label", + includeHeadings: false, + }); + }); + + it("keeps explicit existing values over defaults", () => { + const block: PluginBlockDef = { + type: "readingTime", + pluginId: "reading-time", + label: "Reading Time", + fields: [ + { + type: "number_input", + action_id: "minutes", + label: "Minutes", + initial_value: 5, + }, + { + type: "text_input", + action_id: "label", + label: "Label", + initial_value: "Default label", + }, + { + type: "toggle", + action_id: "includeHeadings", + label: "Include headings", + initial_value: true, + }, + ], + }; + + expect( + _buildPluginBlockFormValues(block, { + minutes: 0, + label: "", + includeHeadings: false, + }), + ).toEqual({ + minutes: 0, + label: "", + includeHeadings: false, + }); + }); +}); + +// ============================================================================= +// 2. Portable Text ↔ ProseMirror Conversion (via component) // ============================================================================= describe("Portable Text ↔ ProseMirror conversion", () => {