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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion src/components/editors/actions-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -125,6 +125,14 @@ export function ActionsEditor({ block, onChange }: BlockEditorProps<ActionsBlock
))}
</RadioGroup>
</EditorField>
<AdvancedIdField
label="Action ID"
help="Optional. Sent in the interaction payload so your app knows which button was clicked."
htmlFor={`btn-action-${idx}`}
value={el.action_id}
placeholder="e.g. approve_request"
onChange={(next) => updateButton(idx, { action_id: next })}
/>
</div>
);
})}
Expand Down
9 changes: 9 additions & 0 deletions src/components/editors/block-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -85,6 +86,14 @@ export function BlockEditor({
</ul>
) : null}
{dispatch(block, onChange)}
<AdvancedIdField
label="Block ID"
help="Optional. Unique within the message or view. Slack returns it in interaction payloads."
htmlFor="block-id"
value={block.block_id}
placeholder="e.g. approval_row"
onChange={(next) => onChange({ ...block, block_id: next })}
/>
{onDone ? (
<div className="flex justify-end border-t pt-3">
<Button type="button" size="sm" onClick={onDone}>
Expand Down
10 changes: 9 additions & 1 deletion src/components/editors/card-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Label } from '../../lib/ui/label';
import { RadioGroup, RadioGroupItem } from '../../lib/ui/radio-group';
import { Textarea } from '../../lib/ui/textarea';
import type { CardBlock } from '../../types';
import { EditorField } from './field';
import { AdvancedIdField, EditorField } from './field';
import type { BlockEditorProps } from './types';

type ButtonStyle = 'default' | 'primary' | 'danger';
Expand Down Expand Up @@ -289,6 +289,14 @@ function ButtonsField({
))}
</RadioGroup>
</EditorField>
<AdvancedIdField
label="Action ID"
help="Optional. Sent in the interaction payload so your app knows which button was clicked."
htmlFor={`${idPrefix}-btn-action-${idx}`}
value={btn.action_id}
placeholder="e.g. open_details"
onChange={(next) => update(idx, { action_id: next })}
/>
</div>
);
})}
Expand Down
53 changes: 53 additions & 0 deletions src/components/editors/field.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { Input } from '../../lib/ui/input';
import { Label } from '../../lib/ui/label';

/**
Expand Down Expand Up @@ -30,3 +31,55 @@ export function EditorField({
</div>
);
}

/**
* 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 `<details>`, 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 (
<details className="rounded-md border bg-muted/20 px-2.5 py-1.5">
<summary className="cursor-pointer text-[11px] font-medium text-muted-foreground hover:text-foreground">
Advanced
</summary>
<div className="pt-2 pb-1">
<EditorField label={label} help={help} htmlFor={htmlFor}>
<Input
id={htmlFor}
value={value ?? ''}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value || undefined)}
/>
</EditorField>
</div>
</details>
);
}
10 changes: 9 additions & 1 deletion src/components/editors/section-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -230,6 +230,14 @@ function ButtonAccessoryFields({ button, onChange }: { button: SlackButton; onCh
))}
</RadioGroup>
</EditorField>
<AdvancedIdField
label="Action ID"
help="Optional. Sent in the interaction payload so your app knows which button was clicked."
htmlFor="section-acc-btn-action"
value={button.action_id}
placeholder="e.g. learn_more"
onChange={(next) => onChange({ ...button, action_id: next })}
/>
</div>
);
}
Expand Down
47 changes: 47 additions & 0 deletions test/block-editor-advanced.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BlockEditor block={{ type: 'divider' }} onChange={onChange} />);

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(<BlockEditor block={ACTIONS} onChange={onChange} />);

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(<BlockEditor block={ACTIONS} onChange={onChange} />);

fireEvent.change(screen.getByLabelText('Action ID'), { target: { value: '' } });

expect(onChange.mock.calls[0][0].elements[0].action_id).toBeUndefined();
});
});
46 changes: 46 additions & 0 deletions test/duplicate-ids.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading