refactor(spa): pipeline ref-select uses shared Select.vue component

The first design-ref pass used a styled native <select> for visual
fidelity to the source design. Cross-editor consistency wins here:
every other dropdown in the manager (Combine, Constraint, Derivation
editors and Categories) routes through src/manager/components/ui/
Select.vue, so pipeline rows do too — same trigger button chrome,
same teleported menu, same keyboard model.

Tests adapted to the Select component's teleported menu pattern:
options are queried via document.querySelectorAll('.wp-select__option')
after clicking the trigger, and selection happens via ArrowDown +
Enter rather than .setValue() on a native <select>.
This commit is contained in:
DumiFlex
2026-04-28 23:26:55 +03:00
parent c1b936140a
commit c9d893e969
2 changed files with 66 additions and 79 deletions
+48 -26
View File
@@ -1,5 +1,5 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import PipelineSteps from "../components/PipelineSteps.vue";
import type { ModuleRow, PipelineStep } from "../api/types";
@@ -107,55 +107,77 @@ describe("PipelineSteps.vue", () => {
expect(next[0].id).toBe("s2");
});
// The reference design's signature affordance is an inline transparent
// <select> rendered as the bold module name. Picking from the dropdown
// emits update:steps with the new module_id. Pin both shape and event.
// The shared `Select.vue` component teleports its dropdown menu to
// `document.body`, so option queries use `document.querySelectorAll`
// rather than the test-utils wrapper. `attachTo: document.body` +
// `wrap.unmount()` keeps teleported nodes from leaking between tests.
describe("inline ref-select", () => {
it("populates options from same-kind modules in allModules", () => {
function clearBody() {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
}
afterEach(clearBody);
it("populates options from same-kind modules in allModules", async () => {
const a = makeModule({ id: "m_a", name: "alpha", type: "wildcard" });
const b = makeModule({ id: "m_b", name: "bravo", type: "wildcard" });
const c = makeModule({ id: "m_c", name: "charlie", type: "wildcard" });
// A non-wildcard module — must NOT appear in the wildcard step's dropdown.
const fv = makeModule({ id: "m_fv", name: "fixed", type: "fixed_values" });
const wrap = mountSteps(
[makeStep({ id: "s1", module_id: "m_a" })],
[a, b, c, fv],
);
const select = wrap.find('[data-test="step-ref-0"]');
const optionLabels = select.findAll("option").map((o) => o.text());
const modulesById = new Map([a, b, c, fv].map((m) => [m.id, m] as const));
const wrap = mount(PipelineSteps, {
props: {
steps: [makeStep({ id: "s1", module_id: "m_a" })],
modulesById,
allModules: [a, b, c, fv],
},
attachTo: document.body,
});
// Open the dropdown by clicking the trigger inside the row's Select.
await wrap.find('[data-test="step-ref-0"] [data-test="select-trigger"]').trigger("click");
const optionLabels = Array.from(
document.querySelectorAll(".wp-select__option"),
).map((el) => (el.textContent ?? "").trim());
expect(optionLabels).toEqual(["alpha", "bravo", "charlie"]);
expect(optionLabels).not.toContain("fixed");
wrap.unmount();
});
it("emits update:steps with the chosen module_id when select changes", async () => {
const a = makeModule({ id: "m_a", name: "alpha", type: "wildcard" });
const b = makeModule({ id: "m_b", name: "bravo", type: "wildcard" });
const wrap = mountSteps(
[makeStep({ id: "s1", module_id: "m_a" })],
[a, b],
);
const select = wrap.find('[data-test="step-ref-0"]');
await select.setValue("m_b");
const modulesById = new Map([a, b].map((m) => [m.id, m] as const));
const wrap = mount(PipelineSteps, {
props: {
steps: [makeStep({ id: "s1", module_id: "m_a" })],
modulesById,
allModules: [a, b],
},
attachTo: document.body,
});
const trigger = wrap.find('[data-test="step-ref-0"] [data-test="select-trigger"]');
await trigger.trigger("click");
// ArrowDown moves active from index 0 (alpha, current) to index 1 (bravo);
// Enter commits it.
await trigger.trigger("keydown", { key: "ArrowDown" });
await trigger.trigger("keydown", { key: "Enter" });
const emitted = wrap.emitted("update:steps");
expect(emitted).toBeTruthy();
const next = (emitted ?? [])[0][0] as PipelineStep[];
expect(next[0].module_id).toBe("m_b");
wrap.unmount();
});
it("renders a `(missing reference)` option when the step's module_id is not in allModules", () => {
// step references "m_ghost" — not in allModules. Component must
// still render an option for the orphan id so the row doesn't
// silently switch to the first available wildcard on save.
it("renders a `(missing reference)` fallback when module_id is not in allModules", () => {
// step references "m_ghost" — not in allModules. modulesById can't
// resolve it → kind is null → component renders the missing-fallback
// span instead of a Select. User sees they have a broken ref.
const a = makeModule({ id: "m_a", name: "alpha", type: "wildcard" });
const wrap = mountSteps(
[makeStep({ id: "s1", module_id: "m_ghost" })],
[a],
);
// No wildcard with id `m_ghost` exists, so the select shows the
// missing reference fallback. The step's module_id isn't a
// resolvable wildcard either — kind is null — so we render
// `<span class="wp-pl-row__missing">` instead of a select. Either
// path satisfies the contract: the user sees they have a broken ref.
expect(wrap.text()).toContain("(missing reference)");
});
});
+18 -53
View File
@@ -22,6 +22,7 @@
* to where the list came from.
*/
import { computed } from "vue";
import Select from "./ui/Select.vue";
import type { ModuleRow, ModuleType, PipelineStep } from "../api/types";
interface Props {
@@ -295,31 +296,24 @@ function onDragEnd() {
</span>
</div>
<!-- Inline transparent <select> — the reference's signature move.
Reads as bold text with a tiny chevron; clicking opens the
native dropdown of same-kind modules. The select stays in
the document flow so its width matches the row main column. -->
<!-- Module-switcher dropdown — uses the shared Select component
so it matches the styling of every other dropdown in the
editors (Combine, Constraint, Derivation, etc.) instead of
rolling its own transparent native select. The original
design reference used a styled native <select>, but for
cross-editor visual consistency we route through `Select`. -->
<div class="wp-pl-row__name">
<select
<Select
v-if="row.kind"
class="wp-pl-row__refselect"
:value="row.step.module_id"
size="sm"
:model-value="row.step.module_id"
:options="row.sameKindOptions"
:placeholder="`Pick a ${row.meta?.label.toLowerCase()}`"
:aria-label="`Pick reference for step ${row.idx + 1}`"
class="wp-pl-row__refselect"
:data-test="`step-ref-${row.idx}`"
@change="(e) => changeModule(row.idx, (e.target as HTMLSelectElement).value)"
>
<option
v-if="!row.sameKindOptions.find((o) => o.value === row.step.module_id)"
:value="row.step.module_id"
>
(missing reference)
</option>
<option
v-for="opt in row.sameKindOptions"
:key="opt.value"
:value="opt.value"
>{{ opt.label }}</option>
</select>
@update:model-value="(v) => changeModule(row.idx, v as string)"
/>
<span v-else class="wp-pl-row__missing">(missing reference)</span>
<a
v-if="row.mod && row.meta"
@@ -550,41 +544,12 @@ function onDragEnd() {
font-family: var(--wp-font-mono, ui-monospace, monospace);
}
/* inline ref-select — transparent, looks like bold text */
/* Shared Select component sits inside .wp-pl-row__name; let it grow to
fill the main column with `flex: 1` so the picked option label has
horizontal room to read instead of being cropped. */
.wp-pl-row__refselect {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 22px 0 0;
border: none;
background-color: transparent;
color: var(--wp-text);
font-size: 13.5px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%23888' d='M0 0h10L5 6z'/></svg>");
background-repeat: no-repeat;
background-position: right 4px center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
border-radius: 4px;
transition: background-color 120ms ease;
}
.wp-pl-row__refselect:hover {
background-color: var(--wp-bg-3);
}
.wp-pl-row__refselect:focus-visible {
outline: none;
background-color: var(--wp-bg);
box-shadow: 0 0 0 2px color-mix(in oklab, var(--wp-accent-500) 35%, transparent);
}
.wp-pl-row__refselect option {
background: var(--wp-bg-2);
color: var(--wp-text);
}
/* row actions */