feat(editors): filter bars on constraint exceptions and derivation rules
The wildcard and fixed-values editors grew a filter; these two never did,
which had it backwards. A constraint's exception list is routinely longer
than the wildcard it filters, and a rule card is tall enough that a dozen
rules is already a scroll.
Extracted `ui/ListFilter.vue` rather than copy the fixed-values markup a
third time, and `utils/listFilter.ts` for the two haystack builders, since
"what does this row match on?" is the part worth testing and mounting an
editor to assert it would test the wiring instead.
Both surfaces match on what the row READS AS, not what it stores. An
exception matches its resolved source/target labels -- the stored values are
frequently `@{uuid}` tokens and nobody searches for a uuid they cannot see.
A rule matches every variable, operator, mode and literal across its branches
and its else, but never its generated id.
Rows carry their ORIGINAL index through the filter. Both editors address rows
by position (`removeRule(idx)`, `exceptions.value[idx]`), so a plain filtered
array would edit and delete the wrong row, and the rule cards would renumber
under a filter and claim Rule 5 is Rule 1.
Also fixes a pre-existing destructive bug in the fixed-values editor, found
while porting the pattern: `useBulkSelection` scoped select-all to every row,
not the visible ones. Filtering 40 values to 3 and hitting the header checkbox
selected all 40, and the delete that usually follows removed 37 rows the user
never saw. It takes an optional second getter for the visible ids; toggleAll
and the header state now read that, while count and selectedIds stay global so
a selection survives the user retyping the filter.
Verified against a running ComfyUI on temporary modules, since nothing in the
library was long enough to trigger either filter: filtering 8 rules to 1 keeps
its card labelled Rule 5, select-all under that filter reports 1 selected
rather than 8, and deleting a filtered exception removes that row -- confirmed
by the surviving rows' mode sequence, not by row text. The temporary modules
were deleted afterwards.
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
The extension starts faster, especially on slower machines.
|
||||
A faster start, filters on two more editors, and a bulk-delete fix worth reading.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **"Select all" now means the rows you can see.** With a filter active in the fixed-values editor, the header checkbox selected *every* value, not the filtered ones — so narrowing a list of 40 down to 3, selecting all and deleting removed all 40, with 37 of them never on screen and no warning. It now selects only what the filter is showing. If you have ever lost values this way, this was why.
|
||||
|
||||
- **Constraint exceptions and derivation rules have a filter bar.** The wildcard and fixed-values editors got one previously; these two did not, which was backwards — an exception list is routinely longer than the wildcard it filters. Type to narrow the list, with a count and a clear button.
|
||||
|
||||
Rules keep their real numbers while filtered, so "Rule 5" stays Rule 5 instead of renumbering to 1, and editing or deleting a filtered row acts on that row rather than the one that happens to share its position.
|
||||
|
||||
- **The webfonts are no longer bundled into JavaScript.** Both fonts were being base64-encoded into a code file that ComfyUI had to download and parse *before* it could finish registering this extension's nodes — about 90 KB of JavaScript that was never really JavaScript. They are now ordinary font files the browser fetches on its own, in the background, while everything else carries on. Node registration no longer waits for them.
|
||||
|
||||
Two things follow from that. Text appears sooner on a slow CPU, because 90 KB stopped going through the JavaScript parser. And the fonts now stay cached when you update — previously every release changed the code file they were hidden inside, so your browser re-downloaded them every time.
|
||||
|
||||
@@ -66,4 +66,66 @@ describe("useBulkSelection", () => {
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
expect(s.allSelected.value).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Select-all under an active filter.
|
||||
*
|
||||
* Before the second argument existed, `toggleAll` selected `getIds()` — every
|
||||
* row, filtered or not. Filtering 40 values down to 3 and clicking the header
|
||||
* checkbox therefore selected all 40, and the delete that usually follows
|
||||
* removed 37 rows that were never on screen. These are the tests that stop
|
||||
* that coming back.
|
||||
*/
|
||||
describe("with a filtered view", () => {
|
||||
const all = ["a", "b", "c", "d"];
|
||||
|
||||
it("selects only the visible rows", () => {
|
||||
const visible = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => all, () => visible.value);
|
||||
s.toggleAll();
|
||||
expect(s.selectedIds()).toEqual(["a", "b"]);
|
||||
expect(s.count.value).toBe(2);
|
||||
});
|
||||
|
||||
it("reports allSelected against the visible rows, not every row", () => {
|
||||
const visible = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => all, () => visible.value);
|
||||
s.toggleAll();
|
||||
// Every VISIBLE row is selected, so the header checkbox is checked —
|
||||
// even though c and d are not selected.
|
||||
expect(s.allSelected.value).toBe(true);
|
||||
});
|
||||
|
||||
it("deselects only the visible rows, leaving a hidden selection intact", () => {
|
||||
const visible = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => all, () => visible.value);
|
||||
s.toggle("d"); // selected while it was visible earlier
|
||||
s.toggleAll(); // + a, b
|
||||
expect(s.selectedIds().sort()).toEqual(["a", "b", "d"]);
|
||||
s.toggleAll(); // all visible selected → remove just those
|
||||
expect(s.selectedIds()).toEqual(["d"]);
|
||||
});
|
||||
|
||||
it("keeps a selection made before the filter changed", () => {
|
||||
const visible = ref(["a", "b"]);
|
||||
const s = useBulkSelection(() => all, () => visible.value);
|
||||
s.toggleAll();
|
||||
// User retypes the filter; c is now the only match.
|
||||
visible.value = ["c"];
|
||||
// a and b stay selected and stay deletable — the count is the honest
|
||||
// total, which is what the bulk toolbar shows.
|
||||
expect(s.count.value).toBe(2);
|
||||
expect(s.selectedIds().sort()).toEqual(["a", "b"]);
|
||||
expect(s.allSelected.value).toBe(false);
|
||||
});
|
||||
|
||||
it("behaves exactly as before when no visible-getter is passed", () => {
|
||||
const rows = ref(["a", "b", "c"]);
|
||||
const s = useBulkSelection(() => rows.value);
|
||||
s.toggleAll();
|
||||
expect(s.count.value).toBe(3);
|
||||
s.toggleAll();
|
||||
expect(s.count.value).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Text filter for a long list inside an editor card.
|
||||
*
|
||||
* Extracted from the fixed-values editor, which grew this pattern first
|
||||
* (`fv-filter` in FixedEditor.vue). The wildcard options list has its own,
|
||||
* richer variant with a tag menu attached; this is the plain-text case, which
|
||||
* is what the constraint exceptions and derivation rules need.
|
||||
*
|
||||
* FixedEditor has NOT been migrated onto this yet — doing so would mix a
|
||||
* refactor of a working surface into the commit that adds two new ones. It is
|
||||
* the obvious first follow-up.
|
||||
*
|
||||
* Deliberately dumb: it owns the query string and the count readout, and
|
||||
* nothing else. Each caller keeps its own filtering, because what counts as a
|
||||
* match differs per surface — an exception matches on resolved source/target
|
||||
* labels, a rule matches on every variable name and value across its branches.
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** Unfiltered row count. Drives the "n of N" readout and the show/hide gate. */
|
||||
total: number;
|
||||
/** Row count after filtering. */
|
||||
visible: number;
|
||||
/** Plural noun for the placeholder and idle readout, e.g. "exceptions". */
|
||||
noun: string;
|
||||
/** `data-test` prefix; yields `<prefix>-search`, `-count`, `-clear`. */
|
||||
testPrefix: string;
|
||||
/**
|
||||
* Hide the control until the list is at least this long. A filter over
|
||||
* four rows is noise — the rows are already all on screen.
|
||||
*
|
||||
* Gated on `total`, never on `visible`, so the box cannot disappear
|
||||
* underneath a query that filtered the list down past the threshold.
|
||||
*/
|
||||
minRows?: number;
|
||||
}>(),
|
||||
{ minRows: 8 },
|
||||
);
|
||||
|
||||
const query = defineModel<string>({ required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="total > minRows" class="wp-lfilter">
|
||||
<label class="wp-lfilter__search" :class="{ 'wp-lfilter__search--on': query.length > 0 }">
|
||||
<i class="pi pi-search" aria-hidden="true" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
:placeholder="`Filter ${total} ${noun}…`"
|
||||
:aria-label="`Filter ${noun}`"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
:data-test="`${testPrefix}-search`"
|
||||
/>
|
||||
<button
|
||||
v-if="query"
|
||||
type="button"
|
||||
class="wp-lfilter__clearx"
|
||||
aria-label="Clear filter"
|
||||
@click="query = ''"
|
||||
><i class="pi pi-times" aria-hidden="true" /></button>
|
||||
</label>
|
||||
<span class="wp-lfilter__count" :data-test="`${testPrefix}-count`">
|
||||
<template v-if="query.trim().length > 0">
|
||||
<!-- data-zero rather than a class so the "no matches" state is one
|
||||
attribute selector, and so a test can assert it without matching
|
||||
on colour. -->
|
||||
<span class="wp-lfilter__n" :data-zero="visible === 0 ? '' : null">
|
||||
{{ visible }} of {{ total }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-lfilter__clear"
|
||||
:data-test="`${testPrefix}-clear`"
|
||||
@click="query = ''"
|
||||
>Clear</button>
|
||||
</template>
|
||||
<span v-else class="wp-lfilter__idle">{{ total }} {{ noun }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-lfilter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wp-lfilter__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-space-2);
|
||||
padding: 0 var(--wp-space-2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
background: var(--wp-surface-2);
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
|
||||
.wp-lfilter__search--on { border-color: var(--wp-accent-500); }
|
||||
.wp-lfilter__search .pi { font-size: 11px; color: var(--wp-text-dim); }
|
||||
|
||||
.wp-lfilter__search input {
|
||||
width: 13ch;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--wp-text);
|
||||
font: inherit;
|
||||
padding: 4px 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wp-lfilter__clearx {
|
||||
display: flex;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--wp-text-dim);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wp-lfilter__clearx:hover { color: var(--wp-text); }
|
||||
|
||||
.wp-lfilter__count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wp-space-2);
|
||||
font-size: var(--wp-font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wp-lfilter__n { color: var(--wp-text-dim); font-family: var(--wp-font-mono); }
|
||||
.wp-lfilter__n[data-zero] { color: var(--wp-danger); }
|
||||
.wp-lfilter__idle { color: var(--wp-text-dim); font-family: var(--wp-font-mono); }
|
||||
|
||||
.wp-lfilter__clear {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--wp-accent-500);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.wp-lfilter__clear:hover { text-decoration: underline; }
|
||||
</style>
|
||||
@@ -34,7 +34,23 @@ export interface BulkSelection {
|
||||
selectedIds: () => string[];
|
||||
}
|
||||
|
||||
export function useBulkSelection(getIds: () => string[]): BulkSelection {
|
||||
/**
|
||||
* @param getIds every selectable row, filtered or not. Drives `count`
|
||||
* and `selectedIds`, so a selection survives the user
|
||||
* changing the filter and still deletes what they picked.
|
||||
* @param getVisibleIds the rows currently ON SCREEN. Drives `allSelected`,
|
||||
* `someSelected` and `toggleAll`.
|
||||
*
|
||||
* The split exists because "select all" under an active filter must mean the
|
||||
* rows you can see. Scoping it to `getIds` instead means filtering a list of
|
||||
* 40 down to 3, clicking the header checkbox, and deleting all 40 — with 37 of
|
||||
* them never rendered. Defaults to `getIds` so an unfiltered list behaves
|
||||
* exactly as before.
|
||||
*/
|
||||
export function useBulkSelection(
|
||||
getIds: () => string[],
|
||||
getVisibleIds: () => string[] = getIds,
|
||||
): BulkSelection {
|
||||
const active = ref(false);
|
||||
const selected = ref<Set<string>>(new Set());
|
||||
|
||||
@@ -42,11 +58,11 @@ export function useBulkSelection(getIds: () => string[]): BulkSelection {
|
||||
() => getIds().filter((id) => selected.value.has(id)).length,
|
||||
);
|
||||
const allSelected = computed(() => {
|
||||
const ids = getIds();
|
||||
const ids = getVisibleIds();
|
||||
return ids.length > 0 && ids.every((id) => selected.value.has(id));
|
||||
});
|
||||
const someSelected = computed(() =>
|
||||
getIds().some((id) => selected.value.has(id)),
|
||||
getVisibleIds().some((id) => selected.value.has(id)),
|
||||
);
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
@@ -58,9 +74,15 @@ export function useBulkSelection(getIds: () => string[]): BulkSelection {
|
||||
else next.add(id);
|
||||
selected.value = next;
|
||||
}
|
||||
/** Adds or removes ONLY the visible rows, so it never silently reaches a row
|
||||
* the filter is hiding — in either direction. Without a filter,
|
||||
* `getVisibleIds` is `getIds` and this is the original select-all/none. */
|
||||
function toggleAll(): void {
|
||||
if (allSelected.value) selected.value = new Set();
|
||||
else selected.value = new Set(getIds());
|
||||
const visible = getVisibleIds();
|
||||
const next = new Set(selected.value);
|
||||
if (allSelected.value) for (const id of visible) next.delete(id);
|
||||
else for (const id of visible) next.add(id);
|
||||
selected.value = next;
|
||||
}
|
||||
function clear(): void {
|
||||
selected.value = new Set();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { constraintExceptionHaystack, derivationRuleHaystack } from "./listFilter";
|
||||
import type { DerivationRule } from "../api/types";
|
||||
|
||||
function rule(partial: Partial<DerivationRule> = {}): DerivationRule {
|
||||
return {
|
||||
id: "rule_abc123",
|
||||
branches: [
|
||||
{
|
||||
condition: { var: "location", op: "equals", value: "outdoors" },
|
||||
action: { target_var: "outfit", mode: "replace", value: "raincoat" },
|
||||
},
|
||||
],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("derivationRuleHaystack", () => {
|
||||
it("matches on a condition variable, the operator, and the literal", () => {
|
||||
const h = derivationRuleHaystack(rule());
|
||||
expect(h).toContain("location");
|
||||
expect(h).toContain("equals");
|
||||
expect(h).toContain("outdoors");
|
||||
});
|
||||
|
||||
it("matches on the action's target variable and value", () => {
|
||||
const h = derivationRuleHaystack(rule());
|
||||
expect(h).toContain("outfit");
|
||||
expect(h).toContain("raincoat");
|
||||
});
|
||||
|
||||
it("covers every branch, not just the first", () => {
|
||||
const h = derivationRuleHaystack(rule({
|
||||
branches: [
|
||||
{
|
||||
condition: { var: "a", op: "equals", value: "1" },
|
||||
action: { target_var: "x", mode: "replace", value: "first" },
|
||||
},
|
||||
{
|
||||
condition: { var: "weather", op: "contains", value: "rain" },
|
||||
action: { target_var: "y", mode: "append", value: "wet" },
|
||||
},
|
||||
],
|
||||
}));
|
||||
expect(h).toContain("weather");
|
||||
expect(h).toContain("wet");
|
||||
});
|
||||
|
||||
it("covers the else action", () => {
|
||||
const h = derivationRuleHaystack(rule({
|
||||
else: { action: { target_var: "mood", mode: "replace", value: "cheerful" } },
|
||||
}));
|
||||
expect(h).toContain("mood");
|
||||
expect(h).toContain("cheerful");
|
||||
});
|
||||
|
||||
it("excludes the generated id", () => {
|
||||
// The id is never rendered, so a hit on it would be unexplainable.
|
||||
expect(derivationRuleHaystack(rule())).not.toContain("rule_abc123");
|
||||
});
|
||||
|
||||
it("lowercases, so the caller can compare a lowercased query", () => {
|
||||
const h = derivationRuleHaystack(rule({
|
||||
branches: [{
|
||||
condition: { var: "Location", op: "equals", value: "Outdoors" },
|
||||
action: { target_var: "Outfit", mode: "replace", value: "Raincoat" },
|
||||
}],
|
||||
}));
|
||||
expect(h).toContain("outdoors");
|
||||
expect(h).not.toContain("Outdoors");
|
||||
});
|
||||
|
||||
it("survives a half-built rule with no branches", () => {
|
||||
// `Add rule` creates a blank; filtering must not throw on it.
|
||||
expect(() => derivationRuleHaystack(rule({ branches: [] }))).not.toThrow();
|
||||
expect(derivationRuleHaystack(rule({ branches: [] }))).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("constraintExceptionHaystack", () => {
|
||||
const label = (v: string) => (v === "@{deadbeef}" ? "Rainy scenes" : v);
|
||||
const modeLabel = () => "Exclude";
|
||||
|
||||
it("matches the RESOLVED label, not the stored uuid token", () => {
|
||||
const h = constraintExceptionHaystack(
|
||||
{ source: "@{deadbeef}", target: "boots", mode: "exclude" },
|
||||
label,
|
||||
modeLabel,
|
||||
);
|
||||
expect(h).toContain("rainy scenes");
|
||||
// Searching for a uuid nobody can see would be a hit the user cannot explain.
|
||||
expect(h).not.toContain("deadbeef");
|
||||
});
|
||||
|
||||
it("matches on source, target and mode label", () => {
|
||||
const h = constraintExceptionHaystack(
|
||||
{ source: "sunny", target: "sandals", mode: "exclude" },
|
||||
label,
|
||||
modeLabel,
|
||||
);
|
||||
expect(h).toContain("sunny");
|
||||
expect(h).toContain("sandals");
|
||||
expect(h).toContain("exclude");
|
||||
});
|
||||
|
||||
it("drops empty halves rather than joining blanks", () => {
|
||||
// A null source renders as "⌀ null" in the table but resolves to "" here;
|
||||
// the join must not leave double spaces that break a multi-word query.
|
||||
const h = constraintExceptionHaystack(
|
||||
{ source: "", target: "boots", mode: "exclude" },
|
||||
label,
|
||||
modeLabel,
|
||||
);
|
||||
expect(h).toBe("boots exclude");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ConstraintException, ConstraintMode, DerivationRule } from "../api/types";
|
||||
|
||||
/**
|
||||
* Search text for the filter bars on the constraint and derivation editors.
|
||||
*
|
||||
* Kept out of the SFCs because "what does this row match on?" is the part worth
|
||||
* testing, and mounting an editor to assert it would test the wiring instead.
|
||||
*
|
||||
* Both builders return a lowercased haystack; callers do `.includes(query)`.
|
||||
* The rule is the same in both: **match what the row shows, not what it
|
||||
* stores.** A user searches for the text in front of them.
|
||||
*/
|
||||
|
||||
/** Lowercased, space-joined, empty parts dropped. */
|
||||
function haystack(parts: (string | undefined)[]): string {
|
||||
return parts.filter((p): p is string => !!p).join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every variable name, operator, mode and literal a rule mentions — across all
|
||||
* its IF/ELIF branches and its optional ELSE.
|
||||
*
|
||||
* The rule's `id` is deliberately excluded: it is generated, never rendered,
|
||||
* and matching it would produce hits the user cannot explain.
|
||||
*/
|
||||
export function derivationRuleHaystack(rule: DerivationRule): string {
|
||||
const parts: (string | undefined)[] = [];
|
||||
for (const b of rule.branches ?? []) {
|
||||
parts.push(b.condition?.var, b.condition?.op, b.condition?.value);
|
||||
parts.push(b.action?.target_var, b.action?.mode, b.action?.value);
|
||||
}
|
||||
const els = rule.else?.action;
|
||||
if (els) parts.push(els.target_var, els.mode, els.value);
|
||||
return haystack(parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* An exception's source and target as DISPLAYED, plus its mode label.
|
||||
*
|
||||
* `label` resolves an `@{uuid}` token to the module's name — the stored values
|
||||
* are frequently uuids, and nobody searches for a uuid they never see. Both
|
||||
* resolvers are injected because they live in the editor (they depend on the
|
||||
* loaded catalog and on the mode metadata table).
|
||||
*/
|
||||
export function constraintExceptionHaystack(
|
||||
ex: Pick<ConstraintException, "source" | "target" | "mode">,
|
||||
label: (value: string) => string,
|
||||
modeLabel: (mode: ConstraintMode) => string,
|
||||
): string {
|
||||
return haystack([label(ex.source), label(ex.target), modeLabel(ex.mode)]);
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import DraftBanner from "../components/DraftBanner.vue";
|
||||
import Input from "../components/ui/Input.vue";
|
||||
import Select from "../components/ui/Select.vue";
|
||||
import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import ListFilter from "../components/ui/ListFilter.vue";
|
||||
import { constraintExceptionHaystack } from "../utils/listFilter";
|
||||
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
|
||||
import { useBulkSelection } from "../composables/useBulkSelection";
|
||||
import RichTextPreview from "../components/RichTextPreview.vue";
|
||||
@@ -899,7 +901,31 @@ function exUid(ex: object): string {
|
||||
}
|
||||
return u;
|
||||
}
|
||||
const exBulk = useBulkSelection(() => exceptions.value.map((ex) => exUid(ex)));
|
||||
/* ── Filter ──────────────────────────────────────────────────────────────
|
||||
* An exception list outgrows the screen faster than the wildcard it filters:
|
||||
* one row per source/target value pair, so a modest wildcard yields dozens.
|
||||
*
|
||||
* Matching runs over `displayLabel(...)` and the mode's human label — what the
|
||||
* row actually READS AS. The stored `source`/`target` are frequently `@{uuid}`
|
||||
* tokens, and nobody searches for a uuid they cannot see. */
|
||||
const exQuery = ref("");
|
||||
const exFilterActive = computed(() => exQuery.value.trim().length > 0);
|
||||
|
||||
/** Rows to render, each carrying its ORIGINAL index: every row action —
|
||||
* `removeException(idx)`, `exceptions.value[idx]` in the update handlers —
|
||||
* addresses by position, so a filtered array alone would edit the wrong row. */
|
||||
const visibleExceptions = computed<{ ex: ConstraintException; idx: number }[]>(() => {
|
||||
const rows = exceptions.value.map((ex, idx) => ({ ex, idx }));
|
||||
const q = exQuery.value.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(({ ex }) =>
|
||||
constraintExceptionHaystack(ex, displayLabel, (m) => modeMeta(m).label).includes(q));
|
||||
});
|
||||
|
||||
const exBulk = useBulkSelection(
|
||||
() => exceptions.value.map((ex) => exUid(ex)),
|
||||
() => visibleExceptions.value.map(({ ex }) => exUid(ex)),
|
||||
);
|
||||
const {
|
||||
active: exBulkActive,
|
||||
count: exBulkCount,
|
||||
@@ -1410,6 +1436,13 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
<div id="editor-section-exceptions">
|
||||
<Card :title="`Exceptions (${exceptions.length})`" :padding="false" sticky-header>
|
||||
<template #actions>
|
||||
<ListFilter
|
||||
v-model="exQuery"
|
||||
:total="exceptions.length"
|
||||
:visible="visibleExceptions.length"
|
||||
noun="exceptions"
|
||||
test-prefix="cn-ex"
|
||||
/>
|
||||
<!-- Editing requires a live wildcard — the value pickers are empty
|
||||
when the ref is missing, so authoring a new exception is hidden
|
||||
in the read-only recovery view. -->
|
||||
@@ -1459,9 +1492,10 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(ex, idx) in exceptions"
|
||||
:key="idx"
|
||||
v-for="{ ex, idx } in visibleExceptions"
|
||||
:key="exUid(ex)"
|
||||
class="cn-ex-row"
|
||||
:data-row-index="idx"
|
||||
:style="{ '--cn-mode-var': `var(${modeMeta(ex.mode).cssVar})` }"
|
||||
>
|
||||
<td class="wp-mono" data-test="cn-ex-ro-src">{{ displayLabel(ex.source) || "⌀ null" }}</td>
|
||||
@@ -1504,10 +1538,11 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(ex, idx) in exceptions"
|
||||
:key="idx"
|
||||
v-for="{ ex, idx } in visibleExceptions"
|
||||
:key="exUid(ex)"
|
||||
class="cn-ex-row"
|
||||
:class="{ 'cn-ex-row--selected': exBulkActive && exBulkIsSelected(exUid(ex)) }"
|
||||
:data-row-index="idx"
|
||||
:style="{ '--cn-mode-var': `var(${modeMeta(ex.mode).cssVar})` }"
|
||||
>
|
||||
<td v-if="exBulkActive" class="cn-ex-col-check">
|
||||
@@ -1634,6 +1669,15 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Says the rows still exist. A filtered-empty table is otherwise
|
||||
indistinguishable from having deleted everything. -->
|
||||
<tr v-if="exFilterActive && visibleExceptions.length === 0">
|
||||
<td :colspan="exBulkActive ? 6 : 5" class="cn-ex-noresults" data-test="cn-ex-noresults">
|
||||
<b>No exception matches this filter</b>
|
||||
All {{ exceptions.length }} are still here — only the view is filtered.
|
||||
<button type="button" class="cn-ex-clearlink" @click="exQuery = ''">Clear filter</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
@@ -1804,6 +1848,17 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
|
||||
.cn-ex-col-check { width: 34px; text-align: center; }
|
||||
.cn-ex-row--selected > td { background: color-mix(in oklab, var(--wp-accent) 10%, transparent); }
|
||||
.cn-ex-bulkbar { padding: 12px 16px 14px; }
|
||||
.cn-ex-noresults {
|
||||
padding: var(--wp-space-6) var(--wp-space-5);
|
||||
text-align: center; color: var(--wp-text-dim); font-size: 12px;
|
||||
}
|
||||
.cn-ex-noresults b { display: block; color: var(--wp-text); margin-bottom: 4px; }
|
||||
.cn-ex-clearlink {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
color: var(--wp-text-muted); font: 11px var(--wp-font-sans);
|
||||
text-decoration: underline;
|
||||
margin-left: var(--wp-space-3);
|
||||
}
|
||||
|
||||
/* Mode chip — icon glyph + label, tinted by --cn-mode-var. Editable +
|
||||
* read-only share the base; the read-only modifier mutes it to the same
|
||||
|
||||
@@ -18,6 +18,8 @@ import CommunityRowActions from "../components/CommunityRowActions.vue";
|
||||
import DraftBanner from "../components/DraftBanner.vue";
|
||||
import DerivationRuleCard from "../components/DerivationRuleCard.vue";
|
||||
import Checkbox from "../components/ui/Checkbox.vue";
|
||||
import ListFilter from "../components/ui/ListFilter.vue";
|
||||
import { derivationRuleHaystack } from "../utils/listFilter";
|
||||
import BulkDeleteToolbar from "../components/BulkDeleteToolbar.vue";
|
||||
import { useBulkSelection } from "../composables/useBulkSelection";
|
||||
import ConfirmDialog from "../../components/shared/ConfirmDialog.vue";
|
||||
@@ -327,7 +329,32 @@ function collapseAllRules(collapsed: boolean): void {
|
||||
/* ── Bulk select + delete ────────────────────────────────────────────────
|
||||
* Rules are a card list (not a table), so the checkbox rides alongside each
|
||||
* card. Rules carry a stable `id`, so selection keys off that directly. */
|
||||
const ruleBulk = useBulkSelection(() => rules.value.map((r) => r.id));
|
||||
/* ── Filter ──────────────────────────────────────────────────────────────
|
||||
* A rule is a stack of IF/ELIF branches plus an optional ELSE, and each card
|
||||
* is tall — a dozen rules is already a lot of scrolling to answer "which rule
|
||||
* touches $outfit?".
|
||||
*
|
||||
* So the haystack is every variable name and literal the rule mentions:
|
||||
* each branch's condition (`var`, `value`) and action (`target_var`, `value`),
|
||||
* plus the else action. Searching a rule by its `id` would be useless — the id
|
||||
* is generated and never shown. */
|
||||
const ruleQuery = ref("");
|
||||
const ruleFilterActive = computed(() => ruleQuery.value.trim().length > 0);
|
||||
|
||||
/** Rules to render, each keeping its ORIGINAL index — `removeRule(idx)` and
|
||||
* `updateRule(idx, …)` address by position, and the cards show `index` as the
|
||||
* rule's human number. Renumbering under a filter would be a lie. */
|
||||
const visibleRules = computed<{ rule: DerivationRule; idx: number }[]>(() => {
|
||||
const rows = rules.value.map((rule, idx) => ({ rule, idx }));
|
||||
const q = ruleQuery.value.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter(({ rule }) => derivationRuleHaystack(rule).includes(q));
|
||||
});
|
||||
|
||||
const ruleBulk = useBulkSelection(
|
||||
() => rules.value.map((r) => r.id),
|
||||
() => visibleRules.value.map(({ rule }) => rule.id),
|
||||
);
|
||||
const {
|
||||
active: ruleBulkActive,
|
||||
count: ruleBulkCount,
|
||||
@@ -506,6 +533,14 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
|
||||
<Card :title="`Rules (${rules.length})`" sticky-header>
|
||||
<template #actions>
|
||||
<ListFilter
|
||||
v-model="ruleQuery"
|
||||
:total="rules.length"
|
||||
:visible="visibleRules.length"
|
||||
noun="rules"
|
||||
test-prefix="drv-rules"
|
||||
:min-rows="4"
|
||||
/>
|
||||
<Button
|
||||
v-if="rules.length > 1"
|
||||
size="sm"
|
||||
@@ -564,9 +599,19 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
No rules yet. Click <strong>Add rule</strong> to start defining IF / ELIF / ELSE behaviour.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="ruleFilterActive && visibleRules.length === 0"
|
||||
class="wp-empty-card"
|
||||
data-test="drv-rules-noresults"
|
||||
>
|
||||
<b>No rule matches this filter</b>
|
||||
All {{ rules.length }} are still here — only the view is filtered.
|
||||
<button type="button" class="drv-clearlink" @click="ruleQuery = ''">Clear filter</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="rules-stack" data-test="rules-stack">
|
||||
<div
|
||||
v-for="(rule, idx) in rules"
|
||||
v-for="{ rule, idx } in visibleRules"
|
||||
:key="rule.id"
|
||||
class="rule-row"
|
||||
:class="{ 'rule-row--selected': ruleBulkActive && ruleBulkIsSelected(rule.id) }"
|
||||
@@ -650,6 +695,12 @@ defineExpose({ rules, addRule, removeRule, applyRestore });
|
||||
.rule-row { display: flex; align-items: flex-start; gap: 10px; }
|
||||
.rule-row__card { flex: 1 1 auto; min-width: 0; }
|
||||
.rule-row__check { margin-top: 14px; flex: 0 0 auto; }
|
||||
.drv-clearlink {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
color: var(--wp-text-muted); font: 11px var(--wp-font-sans);
|
||||
text-decoration: underline;
|
||||
margin-left: var(--wp-space-3);
|
||||
}
|
||||
.rule-row--selected .rule-row__card {
|
||||
outline: 2px solid color-mix(in oklab, var(--wp-accent) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
|
||||
@@ -331,7 +331,13 @@ function commitBulkValues(parsed: ParsedFixedValue[]): void {
|
||||
/* ── Bulk select + delete ────────────────────────────────────────────────
|
||||
* Multi-select value rows to delete many at once (the bulk-ADD panel above
|
||||
* stays independent). Selection keys off each row's stable `id`. */
|
||||
const bulk = useBulkSelection(() => values.value.map((v) => v.id));
|
||||
const bulk = useBulkSelection(
|
||||
() => values.value.map((v) => v.id),
|
||||
// Select-all must mean the FILTERED rows. Before this second argument it
|
||||
// meant every row, so filtering 40 values to 3 and hitting the header
|
||||
// checkbox selected — and deleted — all 40, 37 of them never on screen.
|
||||
() => visibleValueRows.value.map(({ v }) => v.id),
|
||||
);
|
||||
const {
|
||||
active: bulkActive,
|
||||
count: bulkCount,
|
||||
|
||||
Reference in New Issue
Block a user