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
64 changes: 54 additions & 10 deletions src/components/json-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { validateBlockKit } from '@tightknitai/slack-block-kit-validator';
import { Check, Copy } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { BLOCKS_INPUT_SHAPE_ERROR, unwrapBlocksInput } from '../lib/parse-blocks-input';
import { Button } from '../lib/ui/button';
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '../lib/ui/sheet';
import type { SupportedBlock } from '../types';

Expand Down Expand Up @@ -49,10 +50,12 @@ export function JsonDrawer({
const [parseError, setParseError] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const [accepted, setAccepted] = useState(false);

const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const gutterRef = useRef<HTMLDivElement | null>(null);
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const acceptResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Hold the latest blocks in a ref so the open-seed effect can read them
// without listing them as a dependency. While the drawer is open the
Expand All @@ -64,19 +67,24 @@ export function JsonDrawer({

useEffect(() => {
if (open) {
setValue(JSON.stringify(blocksRef.current, null, 2));
const current = blocksRef.current;
setValue(current.length === 0 ? '' : JSON.stringify(current, null, 2));
setParseError(null);
setValidationErrors([]);
setCopied(false);
setAccepted(false);
}
}, [open]);

// Clear the "copied" reset timer on unmount.
// Clear the "copied" and "accepted" reset timers on unmount.
useEffect(
() => () => {
if (copyResetRef.current) {
clearTimeout(copyResetRef.current);
}
if (acceptResetRef.current) {
clearTimeout(acceptResetRef.current);
}
},
[]
);
Expand All @@ -103,6 +111,20 @@ export function JsonDrawer({
setValidationErrors([]);
return;
}
// A blank textarea (or one the user cleared entirely) is treated as an
// empty block list rather than a parse error, so clearing the box to
// paste something new doesn't flash red first.
if (next.trim() === '') {
setParseError(null);
setValidationErrors([]);
onApply([]);
setAccepted(true);
if (acceptResetRef.current) {
clearTimeout(acceptResetRef.current);
}
acceptResetRef.current = setTimeout(() => setAccepted(false), 1800);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(next);
Expand All @@ -123,6 +145,11 @@ export function JsonDrawer({
}
setParseError(null);
onApply(blocks);
setAccepted(true);
if (acceptResetRef.current) {
clearTimeout(acceptResetRef.current);
}
acceptResetRef.current = setTimeout(() => setAccepted(false), 1800);
const result = validateBlockKit(blocks, {
target: 'blocks',
surface: 'message'
Expand Down Expand Up @@ -151,14 +178,26 @@ export function JsonDrawer({
<SheetDescription>Edits update the preview as you type. Parse errors show below.</SheetDescription>
</div>
<div className="relative flex flex-1 overflow-hidden rounded-md border border-input bg-muted/30 shadow-sm focus-within:ring-1 focus-within:ring-ring">
<button
type="button"
onClick={handleCopy}
aria-label={copied ? 'Copied to clipboard' : 'Copy JSON'}
className="absolute right-2 top-2 z-10 inline-flex items-center justify-center rounded-md border border-input bg-background/80 p-1.5 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <Copy className="h-3.5 w-3.5" />}
</button>
<div className="absolute right-2 top-2 z-10 flex items-center gap-1.5">
<div
role="status"
aria-hidden={!accepted}
className={`pointer-events-none flex items-center justify-center rounded-md bg-background/80 p-1.5 shadow-sm backdrop-blur transition-opacity duration-300 ${
accepted ? 'opacity-100' : 'opacity-0'
}`}
>
<Check className="h-3.5 w-3.5 text-emerald-600" />
<span className="sr-only">Input accepted</span>
</div>
<button
type="button"
onClick={handleCopy}
aria-label={copied ? 'Copied to clipboard' : 'Copy JSON'}
className="inline-flex items-center justify-center rounded-md border border-input bg-background/80 p-1.5 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<div
ref={gutterRef}
aria-hidden="true"
Expand Down Expand Up @@ -195,6 +234,11 @@ export function JsonDrawer({
)}
</div>
)}
<div className="flex shrink-0 justify-end">
<Button type="button" onClick={() => onOpenChange(false)}>
Done
</Button>
</div>
</SheetContent>
</Sheet>
);
Expand Down
35 changes: 35 additions & 0 deletions test/json-drawer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { JsonDrawer } from '../src/components/json-drawer';
import type { SupportedBlock } from '../src/types';

const BLOCKS: SupportedBlock[] = [{ type: 'divider' }];

describe('JsonDrawer', () => {
it('renders a blank textarea when there are no blocks', () => {
render(<JsonDrawer open onOpenChange={() => {}} blocks={[]} onApply={() => {}} />);

expect((screen.getByRole('textbox', { name: 'Block Kit JSON' }) as HTMLTextAreaElement).value).toBe('');
});

it('clicking Done closes the panel', () => {
const onOpenChange = vi.fn();
render(<JsonDrawer open onOpenChange={onOpenChange} blocks={BLOCKS} onApply={() => {}} />);

fireEvent.click(screen.getByRole('button', { name: 'Done' }));

expect(onOpenChange).toHaveBeenCalledWith(false);
});

it('shows an accepted indicator after a valid edit is applied', async () => {
const onApply = vi.fn();
render(<JsonDrawer open onOpenChange={() => {}} blocks={BLOCKS} onApply={onApply} />);

fireEvent.change(screen.getByRole('textbox', { name: 'Block Kit JSON' }), {
target: { value: JSON.stringify([{ type: 'divider' }]) }
});

expect(onApply).toHaveBeenCalledWith([{ type: 'divider' }]);
await screen.findByText('Input accepted');
});
});
Loading