From b537e9bef4ea65a5e6cc43a2e3fbc1a9ce2dca85 Mon Sep 17 00:00:00 2001 From: Stephen Date: Tue, 4 Aug 2026 08:02:15 -0700 Subject: [PATCH 1/2] feat(editors): expose block_id and action_id behind an Advanced toggle Closes #152. Every block editor now ends with a collapsed "Advanced" disclosure holding the block's `block_id`, and each button form (actions block, section accessory, card actions) gets the same disclosure for its `action_id`. Both were previously reachable only by hand-editing the JSON drawer, even though the builder generates a random id for every button. Native `
`, so the open/closed state, the disclosure triangle, and keyboard behavior come from the browser rather than React state. Co-Authored-By: Claude Opus 5 --- src/components/editors/actions-editor.tsx | 10 ++++- src/components/editors/block-editor.tsx | 9 ++++ src/components/editors/card-editor.tsx | 10 ++++- src/components/editors/field.tsx | 53 +++++++++++++++++++++++ src/components/editors/section-editor.tsx | 10 ++++- test/block-editor-advanced.test.tsx | 47 ++++++++++++++++++++ 6 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 test/block-editor-advanced.test.tsx 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(); + }); +}); From 84623cbf564dc5a0c486c77e5ac64927a121a944 Mon Sep 17 00:00:00 2001 From: Stephen Date: Tue, 4 Aug 2026 10:31:42 -0700 Subject: [PATCH 2/2] fix(deps): bump slack-block-kit-validator to 0.1.13 for duplicate action_id 0.1.13 adds the within-a-block duplicate `action_id` check (TightknitAI/slack-block-kit-validator#66). The Advanced fields in this PR let people type ids by hand, so collisions the generated nanoid suffixes made impossible are now one keystroke away, and until this bump nothing flagged them. Test covers both directions: duplicates inside one block are reported and attributed to that block, the same id in two different blocks stays valid (`state.values` is keyed block_id then action_id). Co-Authored-By: Claude Opus 5 --- package.json | 2 +- pnpm-lock.yaml | 10 ++++----- test/duplicate-ids.test.ts | 46 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 test/duplicate-ids.test.ts diff --git a/package.json b/package.json index eb4012f..b0c4ebc 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.11", + "@tightknitai/slack-block-kit-validator": "^0.1.13", "@tiptap/extension-link": "^3.23.4", "@tiptap/react": "^3.23.4", "@tiptap/starter-kit": "^3.23.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 490b79b..0508c2c 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.11 - version: 0.1.11 + specifier: ^0.1.13 + version: 0.1.13 '@tiptap/extension-link': specifier: ^3.23.4 version: 3.28.0(@tiptap/core@3.28.0(@tiptap/pm@3.28.0))(@tiptap/pm@3.28.0) @@ -1751,8 +1751,8 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tightknitai/slack-block-kit-validator@0.1.11': - resolution: {integrity: sha512-E5RKDEJnARJUOZ9DhagG/i/GIpkX1k3D2GKDCwI4Jx4quJfSKE/cGF0j/g8hDlS1Cu0fTvEClVBtCngYQ9Pgkg==} + '@tightknitai/slack-block-kit-validator@0.1.13': + resolution: {integrity: sha512-Zxe+cgKnWSettBS5PCxGcNUR7GufZZlig9XXzf7FSxt6sYBc56eriEWE9Oub4MckSuCQTl8ds0NFsiMERTYkrg==} engines: {node: '>=20'} '@tiptap/core@3.28.0': @@ -4959,7 +4959,7 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tightknitai/slack-block-kit-validator@0.1.11': + '@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/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/); + }); +});