Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-block-kit-select-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/blocks": patch
---

Fixes Block Kit `select` fields displaying the raw option value — or nothing at all — instead of the selected option's label. Adds an optional `placeholder` for the unselected state, defaulting to the label of an option whose `value` is `""` and otherwise to `Select...`. Submitted values are unchanged.
6 changes: 5 additions & 1 deletion docs/src/content/docs/plugins/creating-plugins/block-kit.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,14 @@ The route handler takes two arguments: `routeCtx` (with `input`, `request`, `req
| `button` | Action button with optional confirmation dialog |
| `text_input` | Single-line or multiline text input |
| `number_input` | Numeric input with min/max |
| `select` | Dropdown select |
| `select` | Dropdown select with optional `placeholder` |
| `toggle` | On/off switch |
| `secret_input` | Masked input for API keys and tokens |

A `select` renders the label of the selected option. When nothing is selected it renders its
`placeholder`, which defaults to the label of an option whose `value` is `""` (the usual "All"
entry) and otherwise to `Select...`.

## Builder helpers

The `@emdash-cms/blocks` package exports builder helpers for cleaner code:
Expand Down
3 changes: 2 additions & 1 deletion packages/blocks/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ function select(
actionId: string,
label: string,
options: Array<{ label: string; value: string }>,
opts?: { initialValue?: string },
opts?: { initialValue?: string; placeholder?: string },
): SelectElement {
return {
type: "select",
Expand All @@ -220,6 +220,7 @@ function select(
...(opts?.initialValue !== undefined && {
initial_value: opts.initialValue,
}),
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
};
}

Expand Down
14 changes: 14 additions & 0 deletions packages/blocks/src/elements/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useCallback } from "react";

import type { BlockInteraction, SelectElement } from "../types.js";

const DEFAULT_PLACEHOLDER = "Select...";

export function SelectElementComponent({
element,
onAction,
Expand All @@ -27,9 +29,21 @@ export function SelectElementComponent({
[onChange, onAction, element.action_id],
);

// An empty-string value counts as "no value" to the underlying Select, which
// then shows the placeholder. An option declaring `value: ""` *is* that empty
// state, so its label is the placeholder text unless the element sets one.
const placeholder =
element.placeholder ??
element.options.find((opt) => opt.value === "")?.label ??
DEFAULT_PLACEHOLDER;

// `items` is what the trigger resolves its label from; without it the trigger
// renders the raw selected value. The children still render the popup.
return (
<Select
label={element.label}
items={element.options}
placeholder={placeholder}
defaultValue={element.initial_value}
onValueChange={handleValueChange}
>
Expand Down
5 changes: 5 additions & 0 deletions packages/blocks/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export interface SelectElement {
label: string;
options: Array<{ label: string; value: string }>;
initial_value?: string;
/**
* Text shown in the trigger when nothing is selected. Defaults to the label of
* an option whose `value` is `""`, or `"Select..."`.
*/
placeholder?: string;
/** Plugin route that returns `{ items: Array<{ id, name }> }` to populate options dynamically */
optionsRoute?: string;
}
Expand Down
6 changes: 6 additions & 0 deletions packages/blocks/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,12 @@ function validateElement(value: unknown, path: string, errors: ValidationError[]
if (value.initial_value !== undefined) {
validateInitialValueInOptions(value.initial_value, selectValidValues, path, errors);
}
if (value.placeholder !== undefined && typeof value.placeholder !== "string") {
errors.push({
path: `${path}.placeholder`,
message: "Field 'placeholder' must be a string",
});
}
break;
}
case "toggle": {
Expand Down
137 changes: 137 additions & 0 deletions packages/blocks/tests/select-element.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { SelectElementComponent } from "../src/elements/select.js";
import type { SelectElement } from "../src/types.js";

// Deliberately not mocking @cloudflare/kumo: the trigger label and the empty
// state come out of the real Select, so a mock would assert nothing.

afterEach(cleanup);

const OPTIONS = [
{ label: "Published", value: "pub_1" },
{ label: "Draft", value: "dft_2" },
];

function renderSelect(element: Partial<SelectElement>) {
const onAction = vi.fn();
const onChange = vi.fn();
render(
<SelectElementComponent
element={{
type: "select",
action_id: "status",
label: "Status",
options: OPTIONS,
...element,
}}
onAction={onAction}
onChange={onChange}
/>,
);
return { onAction, onChange };
}

function trigger() {
return screen.getByRole("combobox");
}

/** Base UI only commits a click that follows a pointer press on the option. */
function pick(name: string) {
fireEvent.click(trigger());
const option = screen.getByRole("option", { name });
fireEvent.pointerDown(option);
fireEvent.click(option);
}

describe("select element", () => {
it("renders the label of the selected option, not its value", () => {
renderSelect({ initial_value: "pub_1" });

expect(trigger().textContent).toContain("Published");
expect(trigger().textContent).not.toContain("pub_1");
});

it("renders the label of the option picked by the user", () => {
renderSelect({ initial_value: "pub_1" });

pick("Draft");

expect(trigger().textContent).toContain("Draft");
expect(trigger().textContent).not.toContain("dft_2");
});

it("renders a placeholder when nothing is selected", () => {
renderSelect({ placeholder: "Any status" });

expect(trigger().textContent).toContain("Any status");
});

it("renders a default placeholder when the element has none", () => {
renderSelect({});

expect(trigger().textContent).toContain("Select...");
});

it("renders the label of an empty-value option as the empty state", () => {
renderSelect({
options: [{ label: "All statuses", value: "" }, ...OPTIONS],
initial_value: "",
});

expect(trigger().textContent).toContain("All statuses");
});

it("prefers an explicit placeholder over an empty-value option's label", () => {
renderSelect({
options: [{ label: "All statuses", value: "" }, ...OPTIONS],
initial_value: "",
placeholder: "Filter by status",
});

expect(trigger().textContent).toContain("Filter by status");
});

it("submits the option value unchanged", () => {
const { onChange } = renderSelect({ initial_value: "pub_1" });

pick("Draft");

expect(onChange).toHaveBeenCalledWith("status", "dft_2");
});

it("submits an empty-string option value unchanged", () => {
const { onChange } = renderSelect({
options: [{ label: "All statuses", value: "" }, ...OPTIONS],
initial_value: "pub_1",
});

pick("All statuses");

expect(onChange).toHaveBeenCalledWith("status", "");
});

it("reports the value through onAction when no onChange is given", () => {
const onAction = vi.fn();
render(
<SelectElementComponent
element={{
type: "select",
action_id: "status",
label: "Status",
options: OPTIONS,
}}
onAction={onAction}
/>,
);

pick("Draft");

expect(onAction).toHaveBeenCalledWith({
type: "block_action",
action_id: "status",
value: "dft_2",
});
});
});
19 changes: 19 additions & 0 deletions packages/blocks/tests/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,25 @@ describe("validateBlocks", () => {
expect(result.errors[0]!.message).toContain("must not be empty");
});

it("select with non-string placeholder", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "select",
action_id: "sel",
label: "Pick",
options: [{ label: "One", value: "1" }],
placeholder: 42,
},
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].placeholder");
});

it("select option missing label/value", () => {
const result = validateBlocks([
{
Expand Down
6 changes: 5 additions & 1 deletion skills/creating-plugins/references/block-kit.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,18 @@ routes: {
| `button` | Action button with optional confirmation dialog |
| `text_input` | Single-line or multiline text input |
| `number_input` | Numeric input with min/max |
| `select` | Dropdown select |
| `select` | Dropdown select with optional `placeholder` |
| `toggle` | On/off switch |
| `secret_input` | Masked input for API keys and tokens |
| `checkbox` | Multi-select checkboxes |
| `radio` | Single-select radio buttons |
| `date_input` | Date picker |
| `combobox` | Searchable dropdown select |

A `select` renders the label of the selected option. When nothing is selected it renders its
`placeholder`, which defaults to the label of an option whose `value` is `""` (the usual "All"
entry) and otherwise to `Select...`.

## Block Syntax

### Header
Expand Down
Loading