diff --git a/package.json b/package.json index 432d577..5b319ed 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@tightknitai/slack-block-kit-validator": "^0.1.12", + "@tightknitai/slack-block-kit-validator": "^0.1.13", "@tiptap/extension-link": "^3.29.2", "@tiptap/react": "^3.29.2", "@tiptap/starter-kit": "^3.29.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 611be39..edf1c4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: ^3.2.2 version: 3.2.2(react@19.2.8) '@tightknitai/slack-block-kit-validator': - specifier: ^0.1.12 - version: 0.1.12 + specifier: ^0.1.13 + version: 0.1.13 '@tiptap/extension-link': specifier: ^3.29.2 version: 3.29.2(@tiptap/core@3.29.2(@tiptap/pm@3.29.2))(@tiptap/pm@3.29.2) @@ -1741,8 +1741,8 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tightknitai/slack-block-kit-validator@0.1.12': - resolution: {integrity: sha512-f+UNs6Mr/YoRrAHjm6cPidFLDwNkkq9dmdbMQNOktcqCVCl4cZy/uTVa+v1bNIk4++92RhxCTx3iPm2gG1jjPg==} + '@tightknitai/slack-block-kit-validator@0.1.13': + resolution: {integrity: sha512-Zxe+cgKnWSettBS5PCxGcNUR7GufZZlig9XXzf7FSxt6sYBc56eriEWE9Oub4MckSuCQTl8ds0NFsiMERTYkrg==} engines: {node: '>=20'} '@tiptap/core@3.29.2': @@ -4892,7 +4892,7 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tightknitai/slack-block-kit-validator@0.1.12': + '@tightknitai/slack-block-kit-validator@0.1.13': dependencies: ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) diff --git a/src/components/editors/actions-editor.tsx b/src/components/editors/actions-editor.tsx index efe6239..a671330 100644 --- a/src/components/editors/actions-editor.tsx +++ b/src/components/editors/actions-editor.tsx @@ -5,7 +5,7 @@ import { Button } from '../../lib/ui/button'; import { Input } from '../../lib/ui/input'; import { Label } from '../../lib/ui/label'; import { RadioGroup, RadioGroupItem } from '../../lib/ui/radio-group'; -import { EditorField } from './field'; +import { AdvancedIdField, EditorField } from './field'; import type { BlockEditorProps } from './types'; type ButtonStyle = 'default' | 'primary' | 'danger'; @@ -125,6 +125,14 @@ export function ActionsEditor({ block, onChange }: BlockEditorProps + updateButton(idx, { action_id: next })} + /> ); })} diff --git a/src/components/editors/block-editor.tsx b/src/components/editors/block-editor.tsx index 5a3359f..c7c6bfb 100644 --- a/src/components/editors/block-editor.tsx +++ b/src/components/editors/block-editor.tsx @@ -33,6 +33,7 @@ import { ContextActionsEditor } from './context-actions-editor'; import { ContextEditor } from './context-editor'; import { DataVisualizationEditor } from './data-visualization-editor'; import { DividerEditor } from './divider-editor'; +import { AdvancedIdField } from './field'; import { HeaderEditor } from './header-editor'; import { ImageEditor } from './image-editor'; import { InputEditor } from './input-editor'; @@ -85,6 +86,14 @@ export function BlockEditor({ ) : null} {dispatch(block, onChange)} + onChange({ ...block, block_id: next })} + /> {onDone ? (
); })} diff --git a/src/components/editors/field.tsx b/src/components/editors/field.tsx index 89fdd5e..3128911 100644 --- a/src/components/editors/field.tsx +++ b/src/components/editors/field.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { Input } from '../../lib/ui/input'; import { Label } from '../../lib/ui/label'; /** @@ -30,3 +31,55 @@ export function EditorField({ ); } + +/** + * Collapsed "Advanced" disclosure holding one identifier field + * (`block_id` on a block, `action_id` on an interactive element). + * These are plumbing the builder fills in automatically, so they stay + * out of the way until someone needs to pin one to a value their app + * matches on. + * + * Native `
`, so the open/closed state, the disclosure + * triangle, and keyboard behavior all come from the browser. + * @param props - field props + * @param props.label - the visible label (e.g. "Action ID") + * @param props.help - one-line helper text explaining the field + * @param props.htmlFor - id of the input, for a11y + * @param props.value - current identifier, if set + * @param props.placeholder - greyed-out example id + * @param props.onChange - called with the new id, or undefined when cleared + * @returns the rendered disclosure + */ +export function AdvancedIdField({ + label, + help, + htmlFor, + value, + placeholder, + onChange +}: { + label: string; + help: string; + htmlFor: string; + value: string | undefined; + placeholder: string; + onChange: (next: string | undefined) => void; +}) { + return ( +
+ + Advanced + +
+ + onChange(e.target.value || undefined)} + /> + +
+
+ ); +} diff --git a/src/components/editors/section-editor.tsx b/src/components/editors/section-editor.tsx index 7f36a8b..02ac666 100644 --- a/src/components/editors/section-editor.tsx +++ b/src/components/editors/section-editor.tsx @@ -5,7 +5,7 @@ import { Label } from '../../lib/ui/label'; import { RadioGroup, RadioGroupItem } from '../../lib/ui/radio-group'; import { Textarea } from '../../lib/ui/textarea'; import { EmojiTextInsertButton } from '../emoji/emoji-text-insert-button'; -import { EditorField } from './field'; +import { AdvancedIdField, EditorField } from './field'; import type { BlockEditorProps } from './types'; type AccessoryKind = 'none' | 'button' | 'image' | 'other'; @@ -230,6 +230,14 @@ function ButtonAccessoryFields({ button, onChange }: { button: SlackButton; onCh ))} + onChange({ ...button, action_id: next })} + /> ); } diff --git a/test/block-editor-advanced.test.tsx b/test/block-editor-advanced.test.tsx new file mode 100644 index 0000000..690ec98 --- /dev/null +++ b/test/block-editor-advanced.test.tsx @@ -0,0 +1,47 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { BlockEditor } from '../src/components/editors/block-editor'; +import type { SupportedBlock } from '../src/types'; + +const ACTIONS: SupportedBlock = { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Approve', emoji: true }, + action_id: 'button_abc123' + } + ] +}; + +describe('BlockEditor advanced fields', () => { + it('edits the block_id', () => { + const onChange = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Block ID'), { target: { value: 'approval_row' } }); + + expect(onChange).toHaveBeenCalledWith({ type: 'divider', block_id: 'approval_row' }); + }); + + it('edits a button action_id', () => { + const onChange = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Action ID'), { target: { value: 'approve_request' } }); + + expect(onChange).toHaveBeenCalledWith({ + type: 'actions', + elements: [{ ...ACTIONS.elements[0], action_id: 'approve_request' }] + }); + }); + + it('clearing an id drops the field so Slack generates one', () => { + const onChange = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('Action ID'), { target: { value: '' } }); + + expect(onChange.mock.calls[0][0].elements[0].action_id).toBeUndefined(); + }); +}); diff --git a/test/duplicate-ids.test.ts b/test/duplicate-ids.test.ts new file mode 100644 index 0000000..a656d45 --- /dev/null +++ b/test/duplicate-ids.test.ts @@ -0,0 +1,46 @@ +import { validateBlockKit } from '@tightknitai/slack-block-kit-validator'; +import { describe, expect, it } from 'vitest'; +import { groupValidatorErrors } from '../src/lib/error-grouping'; +import type { BuilderBlock, SupportedBlock } from '../src/types'; + +/** + * The Advanced fields let people type `block_id` / `action_id` by hand, so the + * collisions the builder's generated ids used to make impossible are now one + * keystroke away. These assert the validator reports them and that the error + * lands on the offending block rather than in the general bucket. + */ +function validateAndGroup(blocks: SupportedBlock[]) { + const result = validateBlockKit(blocks, { target: 'blocks', surface: 'message' }); + const builder: BuilderBlock[] = blocks.map((block, i) => ({ id: `blk-${i}`, block })); + return groupValidatorErrors(result.errors, builder); +} + +const button = (text: string, action_id: string) => + ({ type: 'button', text: { type: 'plain_text', text, emoji: true }, action_id }) as const; + +describe('duplicate id validation', () => { + it('flags two elements sharing an action_id inside one block', () => { + const grouped = validateAndGroup([{ type: 'actions', elements: [button('A', 'dup'), button('B', 'dup')] }]); + + expect(grouped.byBlockId.get('blk-0')?.[0]).toMatch(/action_id must be unique within the block/); + expect(grouped.general).toEqual([]); + }); + + it('allows the same action_id in two different blocks', () => { + const grouped = validateAndGroup([ + { type: 'actions', elements: [button('A', 'same')] }, + { type: 'actions', elements: [button('B', 'same')] } + ]); + + expect(grouped.total).toBe(0); + }); + + it('flags two blocks sharing a block_id', () => { + const grouped = validateAndGroup([ + { type: 'section', text: { type: 'mrkdwn', text: 'one' }, block_id: 'dup' }, + { type: 'section', text: { type: 'mrkdwn', text: 'two' }, block_id: 'dup' } + ]); + + expect(grouped.byBlockId.get('blk-1')?.[0]).toMatch(/block_id must be unique/); + }); +});