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:
@@ -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
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user