feat(spa): real Constraint editor with matrix grid + factor tune popovers

Replaces the JSON-only ConstraintForm stub with a full editor that
matches the design-handoff prototype:

- Source/target wildcard Selects driven by moduleStore (rows are the
  source wildcard's option values; columns are the target wildcard's
  sub_categories)
- New ConstraintMatrix.vue component renders a sticky-first-column
  grid where each cell button cycles allow → exclude → boost → reduce
  → allow on click, with tinted backgrounds per mode and a tune cog
  on boost/reduce cells that opens a hand-positioned popover with an
  InputNumber and 4 mode-aware preset chips
- Exceptions table for per-pair overrides (source value × target
  value, mode Select, factor InputNumber)
- ConstraintPayload type updated to a sparse map shape
  ({ [sourceValue]: { [targetSub]: { mode, factor } } }) plus
  matching ConstraintException fields; Constraints.vue list view
  rebuilt against the new shape

Tests: +5 ConstraintForm specs, +3 ConstraintMatrix specs (cycling,
rendering, empty state, save flow). All 103 frontend tests pass.
This commit is contained in:
DumiFlex
2026-04-27 04:19:52 +03:00
parent 0dcb562801
commit 02a526a53c
6 changed files with 1139 additions and 69 deletions
@@ -0,0 +1,227 @@
import { mount, flushPromises } from "@vue/test-utils";
import { setActivePinia, createPinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMemoryHistory, createRouter } from "vue-router";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
vi.mock("../api/client", () => ({
api: {
modules: {
create: vi.fn(),
get: vi.fn(),
update: vi.fn(),
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
},
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
},
}));
import { api } from "../api/client";
import ConstraintForm from "../views/ConstraintForm.vue";
import ConstraintMatrix from "../components/ConstraintMatrix.vue";
import type {
ConstraintMatrix as Matrix,
ModuleRow,
} from "../api/types";
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
const apiCat = api.categories as unknown as Record<string, ReturnType<typeof vi.fn>>;
function makeWildcardRow(id: string, name: string, opts: {
values?: string[];
subs?: string[];
} = {}): ModuleRow {
return {
id,
name,
type: "wildcard",
description: "",
category_id: null,
tags: [],
is_favorite: false,
payload: {
options: (opts.values ?? []).map((v, i) => ({ id: `o${i}`, value: v, weight: 1 })),
sub_categories: opts.subs ?? [],
},
version: 1,
created_at: "",
updated_at: "",
};
}
beforeEach(() => {
setActivePinia(createPinia());
Object.values(apiMod).forEach((fn) => fn.mockReset());
apiMod.list.mockResolvedValue({ items: [], total: 0 });
apiCat.list.mockResolvedValue({ items: [] });
});
afterEach(() => {
vi.clearAllMocks();
});
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: "/", component: { template: "<div/>" } },
{ path: "/constraints", component: { template: "<div/>" } },
],
});
}
function findByText(wrap: ReturnType<typeof mount>, text: string) {
return wrap.findAll("button").find((b) => b.text().includes(text));
}
describe("ConstraintForm.vue", () => {
it("renders 'New constraint' heading when no id", async () => {
const wrap = mount(ConstraintForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("New constraint");
});
it("loads existing constraint payload when id is given", async () => {
apiMod.list.mockResolvedValue({
items: [
makeWildcardRow("wc_src", "Outfit", { values: ["jeans", "tux"] }),
makeWildcardRow("wc_tgt", "HairColor", { subs: ["warm", "cool"] }),
],
total: 2,
});
apiMod.get.mockResolvedValue({
id: "cn_a",
type: "constraint",
name: "Outfit × Hair",
description: "desc",
category_id: null,
tags: [],
is_favorite: false,
payload: {
source_wildcard_id: "wc_src",
target_wildcard_id: "wc_tgt",
matrix: { jeans: { warm: { mode: "boost", factor: 2 } } } as Matrix,
exceptions: [],
},
version: 1,
created_at: "",
updated_at: "",
});
const wrap = mount(ConstraintForm, {
props: { id: "cn_a" },
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
expect(wrap.text()).toContain("Edit constraint");
expect(apiMod.get).toHaveBeenCalledWith("cn_a");
// The grid should render the loaded matrix as a boost cell
const cell = wrap.find('button[data-test="cell-jeans-warm"]');
expect(cell.exists()).toBe(true);
expect(cell.attributes("data-mode")).toBe("boost");
});
it("save calls api.modules.create with type 'constraint'", async () => {
apiMod.list.mockResolvedValue({
items: [
makeWildcardRow("wc_src", "Outfit", { values: ["jeans"] }),
makeWildcardRow("wc_tgt", "HairColor", { subs: ["warm"] }),
],
total: 2,
});
apiMod.create.mockResolvedValue({
id: "cn_a", type: "constraint", name: "C1",
description: "", category_id: null, tags: [], is_favorite: false,
payload: {
source_wildcard_id: "wc_src",
target_wildcard_id: "wc_tgt",
matrix: {},
exceptions: [],
},
version: 1, created_at: "", updated_at: "",
});
const wrap = mount(ConstraintForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
// Set name
const nameInput = wrap.find("#cn-name");
await nameInput.setValue("C1");
await nameInput.trigger("input");
// Set source/target via component instance refs (Select dropdowns are JSDOM-flaky)
const vm = wrap.vm as unknown as {
sourceWildcardId: string | null;
targetWildcardId: string | null;
};
vm.sourceWildcardId = "wc_src";
vm.targetWildcardId = "wc_tgt";
await flushPromises();
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).toHaveBeenCalledWith(
expect.objectContaining({
type: "constraint",
name: "C1",
payload: expect.objectContaining({
source_wildcard_id: "wc_src",
target_wildcard_id: "wc_tgt",
}),
}),
);
});
it("save without source/target shows warn and does not call api", async () => {
const wrap = mount(ConstraintForm, {
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const nameInput = wrap.find("#cn-name");
await nameInput.setValue("C1");
await nameInput.trigger("input");
const saveBtn = findByText(wrap, "Save");
await saveBtn?.trigger("click");
await flushPromises();
expect(apiMod.create).not.toHaveBeenCalled();
});
it("cycling a cell emits the new mode (allow → exclude)", async () => {
apiMod.list.mockResolvedValue({
items: [
makeWildcardRow("wc_src", "Outfit", { values: ["jeans"] }),
makeWildcardRow("wc_tgt", "HairColor", { subs: ["warm"] }),
],
total: 2,
});
apiMod.get.mockResolvedValue({
id: "cn_a", type: "constraint", name: "C", description: "",
category_id: null, tags: [], is_favorite: false,
payload: {
source_wildcard_id: "wc_src",
target_wildcard_id: "wc_tgt",
matrix: {},
exceptions: [],
},
version: 1, created_at: "", updated_at: "",
});
const wrap = mount(ConstraintForm, {
props: { id: "cn_a" },
global: { plugins: [makeRouter(), PrimeVue, ToastService] },
});
await flushPromises();
const cell = wrap.find('button[data-test="cell-jeans-warm"]');
expect(cell.exists()).toBe(true);
expect(cell.attributes("data-mode")).toBe("allow");
await cell.trigger("click");
await flushPromises();
// After click + parent re-render, the cell should now be "exclude".
const cellAfter = wrap.find('button[data-test="cell-jeans-warm"]');
expect(cellAfter.attributes("data-mode")).toBe("exclude");
// And the matrix grid component emitted update:modelValue
const grid = wrap.findComponent(ConstraintMatrix);
const evs = grid.emitted("update:modelValue") ?? [];
const last = evs[evs.length - 1]?.[0] as Matrix;
expect(last.jeans.warm.mode).toBe("exclude");
});
});
@@ -0,0 +1,67 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import PrimeVue from "primevue/config";
import ConstraintMatrix from "../components/ConstraintMatrix.vue";
import type { ConstraintMatrix as Matrix } from "../api/types";
function lastEmitted(
wrap: ReturnType<typeof mount>,
event = "update:modelValue",
): Matrix | undefined {
const evs = wrap.emitted(event) ?? [];
return evs[evs.length - 1]?.[0] as Matrix | undefined;
}
function mountGrid(modelValue: Matrix = {}, rows = ["red", "blue"], cols = ["warm", "cool"]) {
return mount(ConstraintMatrix, {
props: { rows, cols, modelValue },
global: { plugins: [PrimeVue] },
});
}
describe("ConstraintMatrix.vue", () => {
it("renders rows × cols cells", () => {
const wrap = mountGrid({}, ["red", "blue", "green"], ["warm", "cool"]);
const cells = wrap.findAll('button[data-test^="cell-"]');
expect(cells).toHaveLength(3 * 2);
// Sanity: aria-labels mention row × col
expect(cells[0].attributes("aria-label")).toContain("red");
expect(cells[0].attributes("aria-label")).toContain("warm");
});
it("cycles cell mode allow → exclude → boost → reduce → allow", async () => {
const wrap = mountGrid({}, ["red"], ["warm"]);
const cell = wrap.find('button[data-test="cell-red-warm"]');
expect(cell.attributes("data-mode")).toBe("allow");
// allow → exclude
await cell.trigger("click");
let last = lastEmitted(wrap);
expect(last?.red.warm).toEqual({ mode: "exclude", factor: 0 });
await wrap.setProps({ modelValue: last });
expect(wrap.find('button[data-test="cell-red-warm"]').attributes("data-mode"))
.toBe("exclude");
// exclude → boost
await wrap.find('button[data-test="cell-red-warm"]').trigger("click");
last = lastEmitted(wrap);
expect(last?.red.warm).toEqual({ mode: "boost", factor: 2 });
await wrap.setProps({ modelValue: last });
// boost → reduce
await wrap.find('button[data-test="cell-red-warm"]').trigger("click");
last = lastEmitted(wrap);
expect(last?.red.warm).toEqual({ mode: "reduce", factor: 0.5 });
await wrap.setProps({ modelValue: last });
// reduce → allow (sparse: row entry should be absent)
await wrap.find('button[data-test="cell-red-warm"]').trigger("click");
last = lastEmitted(wrap);
expect(last?.red).toBeUndefined();
});
it("renders empty notice when no rows", () => {
const wrap = mountGrid({}, [], ["warm"]);
expect(wrap.text()).toContain("No source values yet");
});
});
+14 -11
View File
@@ -41,24 +41,27 @@ export interface DerivationPayload {
rules: DerivationRule[];
}
export type ConstraintMatrixCell =
| "allow"
| "exclude"
| "boost"
| "reduce"
| { mode: string; factor?: number };
export type ConstraintMode = "allow" | "exclude" | "boost" | "reduce";
export interface ConstraintCell {
mode: ConstraintMode;
factor: number;
}
/** Map shape: source_value → target_sub_category → cell. */
export type ConstraintMatrix = Record<string, Record<string, ConstraintCell>>;
export interface ConstraintException {
from: string;
to: string;
mode: string;
note?: string;
source: string;
target: string;
mode: ConstraintMode;
factor: number;
}
export interface ConstraintPayload {
source_wildcard_id: string | null;
target_wildcard_id: string | null;
matrix: ConstraintMatrixCell[][];
matrix: ConstraintMatrix;
exceptions: ConstraintException[];
}
+476
View File
@@ -0,0 +1,476 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import Button from "primevue/button";
import InputNumber from "primevue/inputnumber";
import type {
ConstraintCell,
ConstraintMatrix,
ConstraintMode,
} from "../api/types";
interface Props {
rows: string[]; // source values
cols: string[]; // target sub-categories
modelValue: ConstraintMatrix;
}
const props = defineProps<Props>();
const emit = defineEmits<{ "update:modelValue": [value: ConstraintMatrix] }>();
const MODE_ORDER: Record<ConstraintMode, ConstraintMode> = {
allow: "exclude",
exclude: "boost",
boost: "reduce",
reduce: "allow",
};
const MODE_DEFAULT_FACTOR: Record<ConstraintMode, number> = {
allow: 1,
exclude: 0,
boost: 2,
reduce: 0.5,
};
const MODE_ICON: Record<ConstraintMode, string> = {
allow: "·",
exclude: "×",
boost: "↑",
reduce: "↓",
};
const MODE_LABEL: Record<ConstraintMode, string> = {
allow: "Allow",
exclude: "Exclude",
boost: "Boost",
reduce: "Reduce",
};
function cellAt(row: string, col: string): ConstraintCell {
const raw = props.modelValue?.[row]?.[col];
if (!raw) return { mode: "allow", factor: 1 };
const mode = (raw.mode ?? "allow") as ConstraintMode;
const factor =
typeof raw.factor === "number" ? raw.factor : MODE_DEFAULT_FACTOR[mode] ?? 1;
return { mode, factor };
}
function cloneMatrix(src: ConstraintMatrix): ConstraintMatrix {
const out: ConstraintMatrix = {};
for (const [r, byCol] of Object.entries(src ?? {})) {
out[r] = {};
for (const [c, cell] of Object.entries(byCol ?? {})) {
out[r][c] = { mode: cell.mode, factor: cell.factor };
}
}
return out;
}
function writeCell(row: string, col: string, cell: ConstraintCell) {
const next = cloneMatrix(props.modelValue);
if (cell.mode === "allow") {
// Drop the row/col entry to keep payload sparse.
if (next[row]) {
delete next[row][col];
if (Object.keys(next[row]).length === 0) delete next[row];
}
} else {
if (!next[row]) next[row] = {};
next[row][col] = { mode: cell.mode, factor: cell.factor };
}
emit("update:modelValue", next);
}
function cycleCell(row: string, col: string) {
const cur = cellAt(row, col);
const nextMode = MODE_ORDER[cur.mode];
writeCell(row, col, {
mode: nextMode,
factor: MODE_DEFAULT_FACTOR[nextMode],
});
}
function setCellFactor(row: string, col: string, factor: number) {
const cur = cellAt(row, col);
writeCell(row, col, { mode: cur.mode, factor });
}
function fmtFactor(f: number): string {
if (!Number.isFinite(f)) return "1";
if (f >= 10) return f.toFixed(0);
return f.toFixed(2).replace(/\.?0+$/, "");
}
// ----- Tune popover state ------------------------------------------------
interface TuneState {
row: string;
col: string;
left: number;
top: number;
}
const tune = ref<TuneState | null>(null);
function isOpenAt(row: string, col: string) {
return tune.value?.row === row && tune.value?.col === col;
}
function openTune(row: string, col: string, ev: MouseEvent) {
ev.stopPropagation();
if (isOpenAt(row, col)) {
tune.value = null;
return;
}
const wrap = (ev.currentTarget as HTMLElement).closest(
".wp-matrix-cell-wrap",
) as HTMLElement | null;
const rect = wrap?.getBoundingClientRect();
const POP_W = 260;
const left = rect
? Math.min(
window.innerWidth - POP_W - 12,
Math.max(12, rect.left + rect.width / 2 - POP_W / 2),
)
: 12;
const top = rect ? rect.bottom + 8 : 12;
tune.value = { row, col, left, top };
}
function closeTune() {
tune.value = null;
}
function onDocumentMousedown(ev: MouseEvent) {
if (!tune.value) return;
const target = ev.target as HTMLElement | null;
if (!target) return;
if (target.closest(".wp-tune-pop")) return;
if (target.closest(".wp-matrix-cell-wrap")) return;
closeTune();
}
onMounted(() => {
window.addEventListener("mousedown", onDocumentMousedown);
window.addEventListener("scroll", closeTune, true);
window.addEventListener("resize", closeTune);
});
onBeforeUnmount(() => {
window.removeEventListener("mousedown", onDocumentMousedown);
window.removeEventListener("scroll", closeTune, true);
window.removeEventListener("resize", closeTune);
});
const tuneCell = computed<ConstraintCell | null>(() => {
if (!tune.value) return null;
return cellAt(tune.value.row, tune.value.col);
});
const presets = computed(() => {
if (!tuneCell.value) return [];
return tuneCell.value.mode === "boost"
? [1.5, 2, 3, 5]
: tuneCell.value.mode === "reduce"
? [0.75, 0.5, 0.25, 0.1]
: [];
});
function applyPreset(p: number) {
if (!tune.value) return;
setCellFactor(tune.value.row, tune.value.col, p);
}
function onTuneInput(value: number | null) {
if (!tune.value) return;
if (typeof value !== "number" || !Number.isFinite(value)) return;
setCellFactor(tune.value.row, tune.value.col, value);
}
defineExpose({ cellAt, cycleCell });
</script>
<template>
<div class="matrix-wrap">
<table class="wp-matrix" data-test="constraint-matrix">
<thead>
<tr>
<th class="wp-matrix__corner">
<span class="font-mono text-[10px] uppercase tracking-wide">source ↓</span>
<span class="font-mono text-[10px] uppercase tracking-wide">target →</span>
</th>
<th
v-for="col in cols"
:key="col"
class="wp-matrix__col-h"
>
{{ col }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row">
<td class="wp-matrix__row-h">{{ row }}</td>
<td v-for="col in cols" :key="col" class="wp-matrix__cell-td">
<div class="wp-matrix-cell-wrap">
<button
type="button"
class="wp-matrix-cell"
:data-mode="cellAt(row, col).mode"
:data-test="`cell-${row}-${col}`"
:aria-label="`Cell ${row} × ${col}: ${MODE_LABEL[cellAt(row, col).mode]}${cellAt(row, col).mode === 'boost' || cellAt(row, col).mode === 'reduce' ? ' ×' + fmtFactor(cellAt(row, col).factor) : ''}`"
@click="cycleCell(row, col)"
>
<span class="wp-matrix-cell__icon">{{ MODE_ICON[cellAt(row, col).mode] }}</span>
<span
v-if="cellAt(row, col).mode === 'boost' || cellAt(row, col).mode === 'reduce'"
class="wp-matrix-cell__factor"
>×{{ fmtFactor(cellAt(row, col).factor) }}</span>
</button>
<button
v-if="cellAt(row, col).mode === 'boost' || cellAt(row, col).mode === 'reduce'"
type="button"
class="wp-matrix-cell__tune"
:aria-label="`Tune factor for ${row} × ${col}`"
@click="openTune(row, col, $event)"
>
<i class="pi pi-cog" aria-hidden="true" />
</button>
</div>
</td>
</tr>
<tr v-if="rows.length === 0">
<td :colspan="cols.length + 1" class="wp-matrix__empty">
No source values yet — add options to the source wildcard first.
</td>
</tr>
</tbody>
</table>
<!-- Tune popover (absolute positioning so it escapes the table overflow ctx) -->
<div
v-if="tune && tuneCell && (tuneCell.mode === 'boost' || tuneCell.mode === 'reduce')"
class="wp-tune-pop"
data-test="tune-popover"
role="dialog"
:aria-label="`Tune ${tuneCell.mode} factor`"
:style="{ left: tune.left + 'px', top: tune.top + 'px' }"
@mousedown.stop
>
<div class="wp-tune-pop__head">
<span class="font-mono text-xs" :class="tuneCell.mode === 'boost' ? 'text-wp-success' : 'text-wp-warn'">
{{ MODE_LABEL[tuneCell.mode] }} factor
</span>
<span class="font-mono text-sm text-wp-text">×{{ fmtFactor(tuneCell.factor) }}</span>
</div>
<div class="wp-tune-pop__row">
<InputNumber
:model-value="tuneCell.factor"
:min="0.1"
:max="10"
:step="0.1"
:max-fraction-digits="2"
show-buttons
button-layout="horizontal"
aria-label="Factor value"
class="w-full"
@update:model-value="onTuneInput"
/>
<div class="wp-tune-pop__presets">
<button
v-for="p in presets"
:key="p"
type="button"
class="wp-chip-btn"
:data-active="Math.abs(tuneCell.factor - p) < 0.001"
:aria-label="`Preset factor ${p}`"
@click="applyPreset(p)"
>×{{ fmtFactor(p) }}</button>
</div>
</div>
<div class="flex justify-end">
<Button
label="Done"
size="small"
severity="secondary"
text
aria-label="Close tune popover"
@click="closeTune"
/>
</div>
</div>
</div>
</template>
<style scoped>
.matrix-wrap {
position: relative;
overflow-x: auto;
}
.wp-matrix {
border-collapse: separate;
border-spacing: 4px;
font-size: 12px;
}
.wp-matrix__corner {
padding: 0 8px;
text-align: left;
color: var(--wp-text-dim, var(--wp-text3));
font-weight: 500;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
position: sticky;
left: 0;
z-index: 2;
background: var(--wp-bg-2, var(--wp-bg2));
display: flex;
flex-direction: column;
gap: 2px;
}
.wp-matrix__col-h {
padding: 0 8px;
color: var(--wp-text-muted, var(--wp-text2));
font-weight: 500;
white-space: nowrap;
}
.wp-matrix__row-h {
padding: 0 8px;
color: var(--wp-text-muted, var(--wp-text2));
white-space: nowrap;
text-align: left;
height: 32px;
position: sticky;
left: 0;
z-index: 1;
background: var(--wp-bg-2, var(--wp-bg2));
}
.wp-matrix__cell-td { height: 32px; }
.wp-matrix__empty {
text-align: center;
color: var(--wp-text-dim, var(--wp-text3));
padding: 24px 12px;
font-size: 12px;
}
.wp-matrix-cell-wrap {
position: relative;
display: inline-block;
}
.wp-matrix-cell {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
min-width: 56px;
height: 30px;
padding: 0 8px;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--wp-border);
font-family: var(--wp-font-mono, monospace);
background: var(--wp-bg-3, var(--wp-bg3));
color: var(--wp-text-dim, var(--wp-text3));
transition: background 0.12s, border-color 0.12s, transform 0.04s;
}
.wp-matrix-cell:hover { transform: translateY(-1px); }
.wp-matrix-cell[data-mode="allow"] {
min-width: 36px;
padding: 0 6px;
}
.wp-matrix-cell[data-mode="exclude"] {
min-width: 36px;
padding: 0 6px;
background: color-mix(in oklab, var(--wp-danger) 16%, transparent);
color: var(--wp-danger);
border-color: color-mix(in oklab, var(--wp-danger) 35%, transparent);
}
.wp-matrix-cell[data-mode="boost"] {
background: color-mix(in oklab, var(--wp-success) 16%, transparent);
color: var(--wp-success);
border-color: color-mix(in oklab, var(--wp-success) 35%, transparent);
}
.wp-matrix-cell[data-mode="reduce"] {
background: color-mix(in oklab, var(--wp-warn) 16%, transparent);
color: var(--wp-warn);
border-color: color-mix(in oklab, var(--wp-warn) 35%, transparent);
}
.wp-matrix-cell__icon {
font-size: 14px;
font-weight: 600;
line-height: 1;
}
.wp-matrix-cell__factor {
font-family: var(--wp-font-mono, monospace);
font-size: 10.5px;
font-weight: 500;
opacity: 0.85;
}
.wp-matrix-cell__tune {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--wp-bg-1, var(--wp-bg));
border: 1px solid var(--wp-border-strong, var(--wp-border2));
color: var(--wp-text-muted, var(--wp-text2));
cursor: pointer;
padding: 0;
font-size: 9px;
box-shadow: var(--wp-shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.4));
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.wp-matrix-cell__tune:hover {
color: var(--wp-accent-text, var(--wp-accent));
border-color: var(--wp-accent-500, var(--wp-accent));
background: var(--wp-bg-2, var(--wp-bg2));
}
.wp-tune-pop {
position: fixed;
width: 260px;
z-index: 200;
background: var(--wp-bg-1, var(--wp-bg));
border: 1px solid var(--wp-border-strong, var(--wp-border2));
border-radius: 10px;
padding: 10px 12px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
gap: 8px;
}
.wp-tune-pop__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.wp-tune-pop__row {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
align-items: center;
}
.wp-tune-pop__presets {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-start;
}
.wp-chip-btn {
padding: 3px 7px;
border-radius: 999px;
border: 1px solid var(--wp-border);
background: var(--wp-bg-2, var(--wp-bg2));
color: var(--wp-text-muted, var(--wp-text2));
font-size: 10.5px;
font-family: var(--wp-font-mono, monospace);
cursor: pointer;
transition: color 0.12s, border-color 0.12s, background 0.12s;
}
.wp-chip-btn:hover {
color: var(--wp-text);
border-color: var(--wp-border-strong, var(--wp-border2));
}
.wp-chip-btn[data-active="true"] {
color: var(--wp-accent-text, var(--wp-accent));
border-color: var(--wp-accent-500, var(--wp-accent));
background: color-mix(in oklab, var(--wp-accent-500, var(--wp-accent)) 14%, transparent);
}
.text-wp-success { color: var(--wp-success); }
.text-wp-warn { color: var(--wp-warn); }
</style>
+331 -34
View File
@@ -4,13 +4,22 @@ import { useRouter } from "vue-router";
import Button from "primevue/button";
import Card from "primevue/card";
import InputText from "primevue/inputtext";
import InputNumber from "primevue/inputnumber";
import Textarea from "primevue/textarea";
import Select from "primevue/select";
import AutoComplete from "primevue/autocomplete";
import { useToast } from "primevue/usetoast";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type { ConstraintPayload } from "../api/types";
import ConstraintMatrixGrid from "../components/ConstraintMatrix.vue";
import type {
ConstraintCell,
ConstraintException,
ConstraintMatrix,
ConstraintMode,
ConstraintPayload,
ModuleRow,
} from "../api/types";
const props = defineProps<{ id?: string }>();
const router = useRouter();
@@ -18,19 +27,77 @@ const moduleStore = useModuleStore();
const categoryStore = useCategoryStore();
const toast = useToast();
interface WildcardOption { id: string; value: string; weight: number; sub_category?: string | null; }
interface WildcardPayloadShape {
options?: WildcardOption[];
sub_categories?: string[];
}
const name = ref("");
const description = ref("");
const categoryId = ref<string | null>(null);
const tags = ref<string[]>([]);
const payload = ref<ConstraintPayload>({
source_wildcard_id: null,
target_wildcard_id: null,
matrix: [],
exceptions: [],
});
const sourceWildcardId = ref<string | null>(null);
const targetWildcardId = ref<string | null>(null);
const matrix = ref<ConstraintMatrix>({});
const exceptions = ref<ConstraintException[]>([]);
const saving = ref(false);
const isEdit = computed(() => !!props.id);
const MODE_DEFAULT_FACTOR: Record<ConstraintMode, number> = {
allow: 1,
exclude: 0,
boost: 2,
reduce: 0.5,
};
const MODE_OPTIONS: Array<{ label: string; value: ConstraintMode }> = [
{ label: "Allow", value: "allow" },
{ label: "Exclude", value: "exclude" },
{ label: "Boost", value: "boost" },
{ label: "Reduce", value: "reduce" },
];
const wildcardOptions = computed(() =>
moduleStore.items
.filter((m) => m.type === "wildcard")
.map((m) => ({ label: m.name, value: m.id })),
);
function wildcardById(id: string | null): ModuleRow | undefined {
if (!id) return undefined;
return moduleStore.items.find((m) => m.id === id);
}
const sourceWildcard = computed(() => wildcardById(sourceWildcardId.value));
const targetWildcard = computed(() => wildcardById(targetWildcardId.value));
const sourceValues = computed<string[]>(() => {
const wc = sourceWildcard.value;
if (!wc) return [];
const payload = wc.payload as WildcardPayloadShape;
const values = (payload.options ?? [])
.map((o) => o.value)
.filter((v): v is string => typeof v === "string" && v.length > 0);
return Array.from(new Set(values));
});
const targetSubCategories = computed<string[]>(() => {
const wc = targetWildcard.value;
if (!wc) return [];
const payload = wc.payload as WildcardPayloadShape;
return payload.sub_categories ?? [];
});
const targetValues = computed<string[]>(() => {
const wc = targetWildcard.value;
if (!wc) return [];
const payload = wc.payload as WildcardPayloadShape;
const values = (payload.options ?? [])
.map((o) => o.value)
.filter((v): v is string => typeof v === "string" && v.length > 0);
return Array.from(new Set(values));
});
const tagSuggestions = ref<string[]>([]);
function searchTags(event: { query: string }) {
const q = event.query.toLowerCase();
@@ -43,6 +110,40 @@ function searchTags(event: { query: string }) {
.slice(0, 10);
}
function normalizeMatrix(raw: unknown): ConstraintMatrix {
// Tolerate the legacy array-of-arrays shape silently.
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: ConstraintMatrix = {};
for (const [r, byCol] of Object.entries(raw as Record<string, unknown>)) {
if (!byCol || typeof byCol !== "object") continue;
out[r] = {};
for (const [c, cellRaw] of Object.entries(byCol as Record<string, unknown>)) {
const cell = cellRaw as Partial<ConstraintCell>;
const mode = (cell?.mode ?? "allow") as ConstraintMode;
const factor =
typeof cell?.factor === "number" ? cell.factor : MODE_DEFAULT_FACTOR[mode];
out[r][c] = { mode, factor };
}
}
return out;
}
function normalizeExceptions(raw: unknown): ConstraintException[] {
if (!Array.isArray(raw)) return [];
return raw
.map((e) => {
if (!e || typeof e !== "object") return null;
const r = e as Record<string, unknown>;
const source = typeof r.source === "string" ? r.source : "";
const target = typeof r.target === "string" ? r.target : "";
const mode = (typeof r.mode === "string" ? r.mode : "allow") as ConstraintMode;
const factor =
typeof r.factor === "number" ? r.factor : MODE_DEFAULT_FACTOR[mode] ?? 1;
return { source, target, mode, factor } as ConstraintException;
})
.filter((x): x is ConstraintException => x !== null);
}
onMounted(async () => {
await Promise.all([categoryStore.fetchAll(), moduleStore.fetchAll()]);
if (props.id) {
@@ -53,12 +154,10 @@ onMounted(async () => {
categoryId.value = row.category_id;
tags.value = row.tags;
const p = row.payload as Partial<ConstraintPayload>;
payload.value = {
source_wildcard_id: p.source_wildcard_id ?? null,
target_wildcard_id: p.target_wildcard_id ?? null,
matrix: p.matrix ?? [],
exceptions: p.exceptions ?? [],
};
sourceWildcardId.value = p.source_wildcard_id ?? null;
targetWildcardId.value = p.target_wildcard_id ?? null;
matrix.value = normalizeMatrix(p.matrix);
exceptions.value = normalizeExceptions(p.exceptions);
} catch {
toast.add({ severity: "error", summary: "Constraint not found", life: 3000 });
router.replace("/constraints");
@@ -66,19 +165,50 @@ onMounted(async () => {
}
});
function onMatrixUpdate(next: ConstraintMatrix) {
matrix.value = next;
}
function addException() {
exceptions.value.push({ source: "", target: "", mode: "allow", factor: 1 });
}
function removeException(idx: number) {
exceptions.value.splice(idx, 1);
}
function setExceptionMode(idx: number, mode: ConstraintMode) {
const ex = exceptions.value[idx];
if (!ex) return;
ex.mode = mode;
ex.factor = MODE_DEFAULT_FACTOR[mode] ?? 1;
}
async function save() {
if (!name.value.trim()) {
toast.add({ severity: "warn", summary: "Name required", life: 2000 });
return;
}
if (!sourceWildcardId.value || !targetWildcardId.value) {
toast.add({
severity: "warn",
summary: "Source and target wildcards are required",
life: 2500,
});
return;
}
saving.value = true;
try {
const payload: ConstraintPayload = {
source_wildcard_id: sourceWildcardId.value,
target_wildcard_id: targetWildcardId.value,
matrix: matrix.value,
exceptions: exceptions.value,
};
const body = {
name: name.value,
description: description.value,
category_id: categoryId.value,
tags: tags.value,
payload: payload.value as unknown as Record<string, unknown>,
payload: payload as unknown as Record<string, unknown>,
};
if (isEdit.value && props.id) {
await moduleStore.update(props.id, body);
@@ -93,6 +223,8 @@ async function save() {
saving.value = false;
}
}
defineExpose({ sourceWildcardId, targetWildcardId, matrix, exceptions });
</script>
<template>
@@ -105,6 +237,7 @@ async function save() {
</div>
<div class="form-page__body">
<!-- A) Identity -->
<section class="form-section">
<h2 class="form-section__label">Identity</h2>
<div class="grid grid-cols-2 gap-4">
@@ -140,22 +273,199 @@ async function save() {
</div>
</section>
<!-- B) Source / Target wildcards -->
<section class="form-section">
<h2 class="form-section__label">Matrix</h2>
<h2 class="form-section__label">Source &amp; Target wildcards</h2>
<p class="text-xs text-wp-text2 mb-2">
Source wildcard's resolved value selects a row of the matrix; target wildcard's options are weighted by the matched cell.
</p>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="cn-source" class="block text-xs text-wp-text2 mb-1">Source wildcard</label>
<Select
id="cn-source"
v-model="sourceWildcardId"
:options="wildcardOptions"
option-label="label"
option-value="value"
placeholder="Pick source"
show-clear
class="w-full"
data-test="source-wildcard-select"
aria-label="Source wildcard"
/>
<p class="text-[11px] text-wp-text3 mt-1">
Drives matrix rows ({{ sourceValues.length }} value{{ sourceValues.length === 1 ? '' : 's' }}).
</p>
</div>
<div>
<label for="cn-target" class="block text-xs text-wp-text2 mb-1">Target wildcard</label>
<Select
id="cn-target"
v-model="targetWildcardId"
:options="wildcardOptions"
option-label="label"
option-value="value"
placeholder="Pick target"
show-clear
class="w-full"
data-test="target-wildcard-select"
aria-label="Target wildcard"
/>
<p class="text-[11px] text-wp-text3 mt-1">
Drives matrix columns ({{ targetSubCategories.length }} sub-categor{{ targetSubCategories.length === 1 ? 'y' : 'ies' }}).
</p>
</div>
</div>
</section>
<!-- C) Matrix grid -->
<section class="form-section">
<div class="flex items-center justify-between mb-2">
<h2 class="form-section__label m-0">Rule matrix</h2>
<span class="text-[11px] text-wp-text3">Click cycles · cog tunes factor</span>
</div>
<Card>
<template #content>
<p class="text-sm text-wp-text2 m-0 mb-2">
Editor coming soon — payload shown below.
</p>
<pre class="payload-preview" data-test="payload-preview">{{ JSON.stringify(payload, null, 2) }}</pre>
<div
v-if="!sourceWildcardId || !targetWildcardId"
class="text-sm text-wp-text2 py-6 text-center"
data-test="matrix-empty"
>
Pick a source and target wildcard to populate the matrix.
</div>
<div
v-else-if="sourceValues.length === 0 || targetSubCategories.length === 0"
class="text-sm text-wp-text2 py-6 text-center"
data-test="matrix-need-subs"
>
<i class="pi pi-info-circle mr-1" />
<span v-if="sourceValues.length === 0">Source wildcard needs at least one option value. </span>
<span v-if="targetSubCategories.length === 0">Target wildcard needs at least one sub-category. </span>
Add them on the wildcard editor to define rules.
</div>
<ConstraintMatrixGrid
v-else
:rows="sourceValues"
:cols="targetSubCategories"
:model-value="matrix"
data-test="matrix-grid"
@update:model-value="onMatrixUpdate"
/>
</template>
</Card>
</section>
<!-- D) Exceptions table -->
<section class="form-section">
<div class="flex items-center justify-between mb-2">
<h2 class="form-section__label m-0">Exceptions ({{ exceptions.length }})</h2>
<Button
label="Add exception"
icon="pi pi-plus"
size="small"
severity="primary"
data-test="add-exception"
@click="addException"
/>
</div>
<p class="text-xs text-wp-text2 mb-2">
Per-pair overrides for specific option values that the matrix doesn't cover.
</p>
<table class="w-full text-sm border border-wp-border rounded">
<thead>
<tr class="bg-wp-bg2">
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase">Source value</th>
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase">Target value</th>
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase w-32">Mode</th>
<th class="px-3 py-2 text-left text-wp-text2 text-xs uppercase w-28">Factor</th>
<th class="w-12"></th>
</tr>
</thead>
<tbody>
<tr v-for="(ex, idx) in exceptions" :key="idx" class="border-t border-wp-border">
<td class="px-3 py-2">
<Select
v-model="ex.source"
:options="sourceValues.map((v) => ({ label: v, value: v }))"
option-label="label"
option-value="value"
editable
placeholder="Pick value"
class="w-full"
aria-label="Exception source value"
/>
</td>
<td class="px-3 py-2">
<Select
v-model="ex.target"
:options="targetValues.map((v) => ({ label: v, value: v }))"
option-label="label"
option-value="value"
editable
placeholder="Pick value"
class="w-full"
aria-label="Exception target value"
/>
</td>
<td class="px-3 py-2 w-32">
<Select
:model-value="ex.mode"
:options="MODE_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
aria-label="Exception mode"
@update:model-value="(v) => setExceptionMode(idx, v as ConstraintMode)"
/>
</td>
<td class="px-3 py-2 w-28">
<InputNumber
v-if="ex.mode === 'boost' || ex.mode === 'reduce'"
v-model="ex.factor"
:min="0.1"
:max="10"
:step="0.1"
:max-fraction-digits="2"
show-buttons
button-layout="horizontal"
size="small"
class="w-full"
aria-label="Exception factor"
/>
<span v-else class="text-wp-text3 font-mono text-xs">—</span>
</td>
<td class="px-3 py-2 text-right">
<Button
icon="pi pi-trash"
text
rounded
size="small"
severity="danger"
aria-label="Remove exception"
@click="removeException(idx)"
/>
</td>
</tr>
<tr v-if="!exceptions.length">
<td colspan="5" class="text-center text-wp-text2 py-4">No exceptions yet.</td>
</tr>
</tbody>
</table>
</section>
</div>
<!-- E) Footer -->
<div class="form-page__footer">
<Button label="Cancel" severity="secondary" outlined @click="router.push('/constraints')" />
<Button label="Save" icon="pi pi-check" severity="primary" :loading="saving" data-test="save-btn" @click="save" />
<Button
label="Save"
icon="pi pi-check"
severity="primary"
:loading="saving"
data-test="save-btn"
@click="save"
/>
</div>
</div>
</template>
@@ -163,7 +473,7 @@ async function save() {
<style scoped>
.form-page { display: flex; flex-direction: column; min-height: calc(100vh - 56px); }
.form-page__header { padding: 24px 24px 0; }
.form-page__body { padding: 16px 24px 96px; max-width: 56rem; flex: 1; }
.form-page__body { padding: 16px 24px 96px; max-width: 64rem; flex: 1; }
.form-page__footer {
position: sticky; bottom: 0;
background: var(--wp-bg);
@@ -177,17 +487,4 @@ async function save() {
letter-spacing: 0.5px; color: var(--wp-text2);
margin: 0 0 8px 0;
}
.payload-preview {
background: var(--wp-bg2);
border: 1px solid var(--wp-border);
border-radius: var(--wp-radius);
padding: 8px 10px;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
color: var(--wp-text);
max-height: 320px;
overflow: auto;
}
</style>
+24 -24
View File
@@ -14,7 +14,8 @@ import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type {
CategoryRow,
ConstraintMatrixCell,
ConstraintCell,
ConstraintMatrix,
ConstraintPayload,
ModuleRow,
} from "../api/types";
@@ -101,11 +102,16 @@ async function bulkDel(items: ModuleRow[]) {
}
function payloadOf(row: ModuleRow): ConstraintPayload {
const p = row.payload as Partial<ConstraintPayload>;
const p = row.payload as Partial<ConstraintPayload> & {
// Backwards-compat: tolerate older array-of-arrays payloads silently.
matrix?: ConstraintMatrix | unknown[];
};
const matrix: ConstraintMatrix =
p.matrix && !Array.isArray(p.matrix) ? (p.matrix as ConstraintMatrix) : {};
return {
source_wildcard_id: p.source_wildcard_id ?? null,
target_wildcard_id: p.target_wildcard_id ?? null,
matrix: p.matrix ?? [],
matrix,
exceptions: p.exceptions ?? [],
};
}
@@ -121,31 +127,25 @@ interface MatrixSample { source: string; target: string; mode: string; factor: n
function matrixSamples(row: ModuleRow, max = 5): MatrixSample[] {
const p = payloadOf(row);
const sourceWc = p.source_wildcard_id ? moduleNameById.value.get(p.source_wildcard_id) : undefined;
const targetWc = p.target_wildcard_id ? moduleNameById.value.get(p.target_wildcard_id) : undefined;
const sourceSubs: string[] =
(sourceWc?.payload as { sub_categories?: string[] } | undefined)?.sub_categories ?? [];
const targetSubs: string[] =
(targetWc?.payload as { sub_categories?: string[] } | undefined)?.sub_categories ?? [];
const out: MatrixSample[] = [];
for (let ti = 0; ti < p.matrix.length && out.length < max; ti++) {
const row = p.matrix[ti] ?? [];
for (let si = 0; si < row.length && out.length < max; si++) {
const cell: ConstraintMatrixCell = row[si];
const mode = typeof cell === "string" ? cell : (cell?.mode ?? "allow");
for (const [sourceVal, byTarget] of Object.entries(p.matrix)) {
if (out.length >= max) break;
if (!byTarget || typeof byTarget !== "object") continue;
for (const [targetSub, cellRaw] of Object.entries(byTarget)) {
if (out.length >= max) break;
const cell = cellRaw as Partial<ConstraintCell>;
const mode = cell?.mode ?? "allow";
const factor =
typeof cell === "string"
? (cell === "boost" ? 2 : cell === "reduce" ? 0.5 : 1)
: (typeof cell?.factor === "number" ? cell.factor : (cell?.mode === "boost" ? 2 : cell?.mode === "reduce" ? 0.5 : 1));
typeof cell?.factor === "number"
? cell.factor
: mode === "boost"
? 2
: mode === "reduce"
? 0.5
: 1;
// Skip plain "allow" cells from the preview to surface meaningful entries.
if (mode === "allow") continue;
out.push({
source: sourceSubs[si] ?? `col ${si + 1}`,
target: targetSubs[ti] ?? `row ${ti + 1}`,
mode,
factor,
});
out.push({ source: sourceVal, target: targetSub, mode, factor });
}
}
return out;