diff --git a/src/components/block-kitchen.tsx b/src/components/block-kitchen.tsx index 9111097..58e4dcb 100644 --- a/src/components/block-kitchen.tsx +++ b/src/components/block-kitchen.tsx @@ -14,7 +14,7 @@ import { } from '@dnd-kit/core'; import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; import { GripVertical } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { parseContainerBodyId } from '../lib/container-blocks'; import { makeEmojiHook } from '../lib/custom-emoji-hook'; import { buildVariantById, defaultPalette, type PaletteSection } from '../lib/default-blocks'; @@ -99,8 +99,22 @@ export function BlockKitchen(props: BlockKitchenProps) { const allowedSurfaces: readonly PreviewSurface[] = allowedSurfacesProp && allowedSurfacesProp.length > 0 ? allowedSurfacesProp : ['message']; + // A pre-loaded edit target (opt-in) carries its own blocks; they seed the + // draft and win over `initialBlocks`, which is the blank-canvas seed. + const seededBlocks = editing?.initialTarget?.blocks ?? initialBlocks; + // Mount-only: both props are read once at mount, so warn once. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only check + useEffect(() => { + if (editing?.initialTarget && initialBlocks) { + console.warn( + '[BlockKitchen] Both `initialBlocks` and `editing.initialTarget` were provided; ' + + 'using the target’s blocks and ignoring `initialBlocks`.' + ); + } + }, []); + const { blocks, addBlock, addChild, updateBlock, removeBlock, duplicateBlock, reorderBlock, moveBlock, replaceAll } = - useBlockKitchenState({ initialBlocks, onChange }); + useBlockKitchenState({ initialBlocks: seededBlocks, onChange }); // Lookups for resolving a drop target: which ids are container children, // and which container each child belongs to. Recomputed when the tree @@ -124,7 +138,7 @@ export function BlockKitchen(props: BlockKitchenProps) { // the current blocks as a new message (`sendOpen`). const [updateOpen, setUpdateOpen] = useState(false); const [loadOpen, setLoadOpen] = useState(false); - const [editTarget, setEditTarget] = useState(null); + const [editTarget, setEditTarget] = useState(() => editing?.initialTarget ?? null); // Edit mode only counts as active while `editing` is configured. If the host // toggles `editing` off mid-session, fall back to send-only without losing // the loaded target (it reactivates if `editing` returns). diff --git a/src/types.ts b/src/types.ts index f996e9e..99e476b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -635,6 +635,14 @@ export interface EditingConfig { * entry only. */ loadRecentMessages?: (channelId: string) => Promise; + /** + * Optional. Pre-load a message straight into edit mode at mount, skipping the + * load dialog. Same shape the host returns from {@link onLoadMessage} on + * success. Read once at mount (like {@link BlockKitchenProps.initialBlocks}); + * changing it later needs a remount. When set, its `blocks` seed the draft and + * {@link BlockKitchenProps.initialBlocks} is ignored. + */ + initialTarget?: Extract; } /** diff --git a/test/block-kitchen-initial-target.test.tsx b/test/block-kitchen-initial-target.test.tsx new file mode 100644 index 0000000..48248b6 --- /dev/null +++ b/test/block-kitchen-initial-target.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { BlockKitchen } from '../src/components/block-kitchen'; +import type { EditingConfig, LoadResult, SupportedBlock } from '../src/types'; + +const baseProps = { + loadChannels: async () => [{ id: 'C1', name: 'general' }], + loadSendAsUserStatus: async () => ({ canSendAsUser: false }) as never, + onSend: async () => ({ ok: true }) as never +}; + +const target: Extract = { + ok: true, + channelId: 'C1', + channelName: 'general', + ts: '1699999999.000100', + blocks: [{ type: 'header', text: { type: 'plain_text', text: 'from target' } }] as SupportedBlock[], + editableVia: 'bot' +}; + +const editing: EditingConfig = { + onLoadMessage: async () => ({ ok: false, reason: 'n/a' }), + onUpdate: async () => ({ ok: true }), + initialTarget: target +}; + +afterEach(() => vi.restoreAllMocks()); + +it('boots straight into edit mode when editing.initialTarget is provided', () => { + render(); + // The edit-mode banner only renders while a message is loaded. + expect(screen.getByText(/References an existing message in/i)).toBeTruthy(); + expect(screen.getByText('#general')).toBeTruthy(); +}); + +it('warns and prefers the target’s blocks when initialBlocks is also given', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0][0]).toMatch(/ignoring `initialBlocks`/); +});