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
20 changes: 17 additions & 3 deletions src/components/block-kitchen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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<EditTarget | null>(null);
const [editTarget, setEditTarget] = useState<EditTarget | null>(() => 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).
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,14 @@ export interface EditingConfig {
* entry only.
*/
loadRecentMessages?: (channelId: string) => Promise<RecentMessage[]>;
/**
* 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<LoadResult, { ok: true }>;
}

/**
Expand Down
41 changes: 41 additions & 0 deletions test/block-kitchen-initial-target.test.tsx
Original file line number Diff line number Diff line change
@@ -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<LoadResult, { ok: true }> = {
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(<BlockKitchen {...baseProps} editing={editing} />);
// 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(<BlockKitchen {...baseProps} editing={editing} initialBlocks={[{ type: 'divider' }] as SupportedBlock[]} />);
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toMatch(/ignoring `initialBlocks`/);
});
Loading