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:
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user