feat(spa): wildcards list — syntax indicator column + nested-ref filters
Add a Syntax column showing per-row pills for outgoing @ref count, inline
{a|b|c} alternation, and incoming references. Three new extra filters in
the filter panel: "Has nested refs", "Has inline choices", "Is referenced".
Row-expansion now renders option values via RichTextPreview so chips show.
Backed by new pure helpers in src/manager/utils/wildcardSyntax.ts
(getWildcardSyntax, buildWildcardGraph, wildcardVarName) with 9 unit tests
plus a Wildcards.vue render test covering all three pill variants.
This commit is contained in:
@@ -77,4 +77,43 @@ describe("Wildcards.vue", () => {
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("No wildcards yet");
|
||||
});
|
||||
|
||||
it("renders syntax indicator pills for refs / inline / referenced-by", async () => {
|
||||
// Two wildcards: `outfit` references `@hat` (outgoing) and contains an
|
||||
// inline `{a|b}` choice; `hat` is referenced by `outfit` (incoming).
|
||||
apiMod.list.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "wc_outfit", type: "wildcard", name: "outfit", category_id: null,
|
||||
tags: [], is_favorite: false,
|
||||
payload: {
|
||||
options: [{ id: "o1", value: "wear a @hat with a {red|blue} scarf", weight: 1 }],
|
||||
sub_categories: [],
|
||||
var_binding: "outfit",
|
||||
},
|
||||
version: 1, description: "", created_at: "", updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "wc_hat", type: "wildcard", name: "hat", category_id: null,
|
||||
tags: [], is_favorite: false,
|
||||
payload: {
|
||||
options: [{ id: "o1", value: "fedora", weight: 1 }],
|
||||
sub_categories: [],
|
||||
var_binding: "hat",
|
||||
},
|
||||
version: 1, description: "", created_at: "", updated_at: "",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
});
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
const pills = wrap.findAll(".wp-syntax-pill");
|
||||
// Outfit row: outgoing pill + inline pill. Hat row: incoming pill.
|
||||
expect(pills.length).toBeGreaterThanOrEqual(3);
|
||||
const html = wrap.html();
|
||||
expect(html).toContain("wp-syntax-pill--ref");
|
||||
expect(html).toContain("wp-syntax-pill--dp");
|
||||
expect(html).toContain("wp-syntax-pill--in");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWildcardGraph,
|
||||
getWildcardSyntax,
|
||||
wildcardVarName,
|
||||
} from "../utils/wildcardSyntax";
|
||||
import type { ModuleRow, WildcardOption } from "../api/types";
|
||||
|
||||
function wc(
|
||||
partial: Partial<ModuleRow> & {
|
||||
name: string;
|
||||
id?: string;
|
||||
options?: WildcardOption[];
|
||||
var_binding?: string | undefined;
|
||||
},
|
||||
): ModuleRow {
|
||||
const opts = partial.options ?? [];
|
||||
const payload: Record<string, unknown> = { options: opts, sub_categories: [] };
|
||||
if ("var_binding" in partial) {
|
||||
if (partial.var_binding !== undefined) payload.var_binding = partial.var_binding;
|
||||
} else {
|
||||
payload.var_binding = partial.name;
|
||||
}
|
||||
return {
|
||||
id: partial.id ?? `wc_${partial.name}`,
|
||||
type: "wildcard",
|
||||
name: partial.name,
|
||||
description: partial.description ?? "",
|
||||
category_id: partial.category_id ?? null,
|
||||
tags: partial.tags ?? [],
|
||||
is_favorite: partial.is_favorite ?? false,
|
||||
payload,
|
||||
version: partial.version ?? 1,
|
||||
created_at: partial.created_at ?? "",
|
||||
updated_at: partial.updated_at ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function opt(value: string, weight = 1, id = "o1"): WildcardOption {
|
||||
return { id, value, weight };
|
||||
}
|
||||
|
||||
describe("getWildcardSyntax", () => {
|
||||
it("flags no refs / no inline for a plain string", () => {
|
||||
const m = wc({ name: "color", options: [opt("red"), opt("blue")] });
|
||||
const sx = getWildcardSyntax(m);
|
||||
expect(sx.hasRefs).toBe(false);
|
||||
expect(sx.hasInline).toBe(false);
|
||||
expect(sx.refTargets).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects @ref tokens in option values", () => {
|
||||
const m = wc({
|
||||
name: "outfit",
|
||||
options: [opt("a @hat"), opt("a @shoes and a @hat"), opt("plain")],
|
||||
});
|
||||
const sx = getWildcardSyntax(m);
|
||||
expect(sx.hasRefs).toBe(true);
|
||||
// dedupes targets across options
|
||||
expect(sx.refTargets.sort()).toEqual(["hat", "shoes"]);
|
||||
});
|
||||
|
||||
it("ignores @@ literal escapes", () => {
|
||||
const m = wc({ name: "msg", options: [opt("email me at name@@host")] });
|
||||
const sx = getWildcardSyntax(m);
|
||||
expect(sx.hasRefs).toBe(false);
|
||||
});
|
||||
|
||||
it("detects inline {a|b|c} choice blocks", () => {
|
||||
const m = wc({ name: "tone", options: [opt("look {happy|sad|angry}")] });
|
||||
const sx = getWildcardSyntax(m);
|
||||
expect(sx.hasInline).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag inline for a brace block with no pipes", () => {
|
||||
const m = wc({ name: "blank", options: [opt("nothing here {literal}")] });
|
||||
expect(getWildcardSyntax(m).hasInline).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWildcardGraph", () => {
|
||||
it("creates bidirectional edges between two wildcards that reference each other", () => {
|
||||
const a = wc({ name: "alpha", var_binding: "alpha", options: [opt("see @beta")] });
|
||||
const b = wc({ name: "beta", var_binding: "beta", options: [opt("see @alpha")] });
|
||||
const g = buildWildcardGraph([a, b]);
|
||||
expect(g.outgoing.get("alpha")).toEqual(new Set(["beta"]));
|
||||
expect(g.outgoing.get("beta")).toEqual(new Set(["alpha"]));
|
||||
expect(g.incoming.get("alpha")).toEqual(new Set(["beta"]));
|
||||
expect(g.incoming.get("beta")).toEqual(new Set(["alpha"]));
|
||||
});
|
||||
|
||||
it("yields empty edge sets for a wildcard with no references", () => {
|
||||
const a = wc({ name: "lonely", var_binding: "lonely", options: [opt("just text")] });
|
||||
const g = buildWildcardGraph([a]);
|
||||
expect(g.outgoing.get("lonely")?.size ?? -1).toBe(0);
|
||||
expect(g.incoming.get("lonely")?.size ?? -1).toBe(0);
|
||||
});
|
||||
|
||||
it("drops dangling @ref targets that don't resolve to a known wildcard", () => {
|
||||
const a = wc({ name: "alpha", var_binding: "alpha", options: [opt("@ghost or @beta")] });
|
||||
const b = wc({ name: "beta", var_binding: "beta", options: [opt("plain")] });
|
||||
const g = buildWildcardGraph([a, b]);
|
||||
expect(g.outgoing.get("alpha")).toEqual(new Set(["beta"]));
|
||||
expect(g.incoming.get("ghost")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to slug(name) when var_binding is missing", () => {
|
||||
const m = wc({ name: "Hat Color", var_binding: undefined, options: [] });
|
||||
expect(wildcardVarName(m)).toBe("hat_color");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Wildcard syntax helpers.
|
||||
*
|
||||
* Pure utilities that inspect a wildcard module's option values and surface
|
||||
* syntactic features used by the manager UI (nested `@ref` targets, inline
|
||||
* `{a|b|c}` choice blocks). Mirrors the React reference at
|
||||
* `docs/design-handoff/wildcardpipeline/project/data.jsx` so list-screen
|
||||
* rendering matches the prototype exactly.
|
||||
*/
|
||||
import type { ModuleRow, WildcardOption, WildcardPayload } from "../api/types";
|
||||
import { tokenizeRich } from "./richTokenize";
|
||||
import { toIdentifier } from "./slug";
|
||||
|
||||
export interface SyntaxFlags {
|
||||
/** Any option value contains an `@name` reference token. */
|
||||
hasRefs: boolean;
|
||||
/** Any option value contains an inline `{a|b|c}` choice block. */
|
||||
hasInline: boolean;
|
||||
/** Distinct `@name` targets across every option value (deduped, ordered). */
|
||||
refTargets: string[];
|
||||
}
|
||||
|
||||
/** Pull `@name` ref-token names out of a single value string via `tokenizeRich`. */
|
||||
function refsInValue(value: string): string[] {
|
||||
if (!value) return [];
|
||||
const out: string[] = [];
|
||||
for (const tok of tokenizeRich(value)) {
|
||||
if (tok.kind === "ref" && tok.meta?.name) out.push(tok.meta.name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Detect a `{a|b|c}` block — at least one `|` between the braces. */
|
||||
function hasInlineChoice(value: string): boolean {
|
||||
if (!value) return false;
|
||||
let inline = false;
|
||||
for (const tok of tokenizeRich(value)) {
|
||||
// `dp-pipe` is only emitted at brace-depth zero by the tokenizer, so its
|
||||
// presence guarantees a real `{a|b|c}` alternation rather than a stray `|`.
|
||||
if (tok.kind === "dp-pipe") {
|
||||
inline = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return inline;
|
||||
}
|
||||
|
||||
/** Pull the option list out of a wildcard module's payload, defensively. */
|
||||
function optionsOf(mod: ModuleRow): WildcardOption[] {
|
||||
const payload = mod.payload as unknown as Partial<WildcardPayload> | undefined;
|
||||
return payload?.options ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `$varname` other modules use to reference this wildcard. Falls back to
|
||||
* `toIdentifier(name)` for legacy modules saved before `var_binding` existed.
|
||||
*/
|
||||
export function wildcardVarName(mod: ModuleRow): string {
|
||||
const payload = mod.payload as unknown as Partial<WildcardPayload> | undefined;
|
||||
const explicit = payload?.var_binding?.trim();
|
||||
return explicit && explicit.length > 0 ? explicit : toIdentifier(mod.name);
|
||||
}
|
||||
|
||||
/** Aggregate ref + inline-choice flags for a single wildcard module. */
|
||||
export function getWildcardSyntax(mod: ModuleRow): SyntaxFlags {
|
||||
const refs = new Set<string>();
|
||||
let hasInline = false;
|
||||
for (const opt of optionsOf(mod)) {
|
||||
for (const r of refsInValue(opt.value)) refs.add(r);
|
||||
if (!hasInline && hasInlineChoice(opt.value)) hasInline = true;
|
||||
}
|
||||
return {
|
||||
hasRefs: refs.size > 0,
|
||||
hasInline,
|
||||
refTargets: [...refs],
|
||||
};
|
||||
}
|
||||
|
||||
export interface WildcardGraph {
|
||||
/** outgoing[wcName] = Set of var-names it references via `@ref`. */
|
||||
outgoing: Map<string, Set<string>>;
|
||||
/** incoming[wcName] = Set of var-names that reference it. */
|
||||
incoming: Map<string, Set<string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bidirectional reference graph keyed by wildcard `var_binding`
|
||||
* (or slug-fallback). Edges are only added when the target var-name resolves
|
||||
* to an actual wildcard in the input list — dangling `@ref` tokens are
|
||||
* dropped so callers can rely on `incoming.get(name)` returning known peers.
|
||||
*/
|
||||
export function buildWildcardGraph(modules: ModuleRow[]): WildcardGraph {
|
||||
const outgoing = new Map<string, Set<string>>();
|
||||
const incoming = new Map<string, Set<string>>();
|
||||
|
||||
const wildcards = modules.filter((m) => m.type === "wildcard");
|
||||
const known = new Set<string>();
|
||||
const nameOf = new Map<string, string>();
|
||||
for (const w of wildcards) {
|
||||
const n = wildcardVarName(w);
|
||||
known.add(n);
|
||||
nameOf.set(w.id, n);
|
||||
}
|
||||
|
||||
for (const w of wildcards) {
|
||||
const src = nameOf.get(w.id)!;
|
||||
const out = new Set<string>();
|
||||
outgoing.set(src, out);
|
||||
for (const target of getWildcardSyntax(w).refTargets) {
|
||||
if (!known.has(target)) continue;
|
||||
out.add(target);
|
||||
let inSet = incoming.get(target);
|
||||
if (!inSet) {
|
||||
inSet = new Set<string>();
|
||||
incoming.set(target, inSet);
|
||||
}
|
||||
inSet.add(src);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure every wildcard appears as a key in both maps for easy lookup.
|
||||
for (const n of known) {
|
||||
if (!outgoing.has(n)) outgoing.set(n, new Set());
|
||||
if (!incoming.has(n)) incoming.set(n, new Set());
|
||||
}
|
||||
|
||||
return { outgoing, incoming };
|
||||
}
|
||||
@@ -10,9 +10,15 @@ import MultiSelect from "primevue/multiselect";
|
||||
import Select from "primevue/select";
|
||||
import EntityListView from "../components/EntityListView.vue";
|
||||
import RelativeDate from "../components/RelativeDate.vue";
|
||||
import RichTextPreview from "../components/RichTextPreview.vue";
|
||||
import { useModuleStore } from "../stores/moduleStore";
|
||||
import { useCategoryStore } from "../stores/categoryStore";
|
||||
import type { ModuleRow, CategoryRow } from "../api/types";
|
||||
import {
|
||||
buildWildcardGraph,
|
||||
getWildcardSyntax,
|
||||
wildcardVarName,
|
||||
} from "../utils/wildcardSyntax";
|
||||
|
||||
const router = useRouter();
|
||||
const store = useModuleStore();
|
||||
@@ -31,6 +37,49 @@ const allTags = computed(() => {
|
||||
return Array.from(set).sort();
|
||||
});
|
||||
|
||||
// Reactive ref-graph computed off the store. Rebuilds on every change since
|
||||
// `getWildcardSyntax` is cheap (linear over tokens) and graphs are small.
|
||||
const wildcardGraph = computed(() => buildWildcardGraph(store.items));
|
||||
|
||||
interface SyntaxView {
|
||||
hasInline: boolean;
|
||||
outgoing: string[];
|
||||
incoming: string[];
|
||||
}
|
||||
|
||||
function syntaxView(row: ModuleRow): SyntaxView {
|
||||
const sx = getWildcardSyntax(row);
|
||||
const name = wildcardVarName(row);
|
||||
const out = wildcardGraph.value.outgoing.get(name);
|
||||
const inc = wildcardGraph.value.incoming.get(name);
|
||||
return {
|
||||
hasInline: sx.hasInline,
|
||||
outgoing: out ? [...out] : [],
|
||||
incoming: inc ? [...inc] : [],
|
||||
};
|
||||
}
|
||||
|
||||
const extraFilters = computed(() => [
|
||||
{
|
||||
key: "has-refs",
|
||||
label: "Has nested refs",
|
||||
check: (m: ModuleRow) => getWildcardSyntax(m).hasRefs,
|
||||
},
|
||||
{
|
||||
key: "has-inline",
|
||||
label: "Has inline choices",
|
||||
check: (m: ModuleRow) => getWildcardSyntax(m).hasInline,
|
||||
},
|
||||
{
|
||||
key: "is-referenced",
|
||||
label: "Is referenced",
|
||||
check: (m: ModuleRow) => {
|
||||
const inc = wildcardGraph.value.incoming.get(wildcardVarName(m));
|
||||
return !!inc && inc.size > 0;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
onMounted(async () => {
|
||||
store.filter.type = "wildcard";
|
||||
await Promise.all([fetch(), categoryStore.fetchAll()]);
|
||||
@@ -111,6 +160,7 @@ function validIcon(row: ModuleRow): string {
|
||||
:items="store.items"
|
||||
:loading="store.loading"
|
||||
:filter="store.filter"
|
||||
:extra-filters="extraFilters"
|
||||
empty-message="No wildcards yet. Create your first to start building dynamic prompts."
|
||||
@fetch="fetch"
|
||||
@delete="del"
|
||||
@@ -172,6 +222,39 @@ function validIcon(row: ModuleRow): string {
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Syntax" header-style="width:8rem">
|
||||
<template #body="{ data }">
|
||||
<template v-for="v in [syntaxView(data)]" :key="data.id">
|
||||
<div
|
||||
v-if="v.outgoing.length || v.incoming.length || v.hasInline"
|
||||
class="wp-syntax-cell"
|
||||
>
|
||||
<span
|
||||
v-if="v.outgoing.length"
|
||||
class="wp-syntax-pill wp-syntax-pill--ref"
|
||||
:title="`References ${v.outgoing.length} wildcard${v.outgoing.length === 1 ? '' : 's'}: ${v.outgoing.join(', ')}`"
|
||||
>
|
||||
<i class="pi pi-arrow-right-arrow-left" />
|
||||
{{ v.outgoing.length }}
|
||||
</span>
|
||||
<span
|
||||
v-if="v.hasInline"
|
||||
class="wp-syntax-pill wp-syntax-pill--dp"
|
||||
title="Contains inline {a|b|c} alternatives"
|
||||
>{{ "{ }" }}</span>
|
||||
<span
|
||||
v-if="v.incoming.length"
|
||||
class="wp-syntax-pill wp-syntax-pill--in"
|
||||
:title="`Referenced by ${v.incoming.length} wildcard${v.incoming.length === 1 ? '' : 's'}: ${v.incoming.join(', ')}`"
|
||||
>
|
||||
<i class="pi pi-arrow-left" />
|
||||
{{ v.incoming.length }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-wp-text3 text-sm">—</span>
|
||||
</template>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="category_id" header="Category" header-style="width:9rem" sortable>
|
||||
<template #body="{ data }">
|
||||
<span
|
||||
@@ -230,7 +313,8 @@ function validIcon(row: ModuleRow): string {
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div v-for="(opt, i) in topOptions(data)" :key="i" class="flex items-center gap-3 text-sm">
|
||||
<Badge :value="String(opt.weight)" severity="secondary" class="min-w-8" />
|
||||
<span class="font-mono text-wp-text">{{ opt.value || "(empty)" }}</span>
|
||||
<RichTextPreview v-if="opt.value" :value="opt.value" />
|
||||
<span v-else class="font-mono text-wp-text3">(empty)</span>
|
||||
</div>
|
||||
<span v-if="optionCount(data) > 3" class="text-xs text-wp-text3 mt-1">
|
||||
… and {{ optionCount(data) - 3 }} more
|
||||
@@ -258,4 +342,45 @@ function validIcon(row: ModuleRow): string {
|
||||
color: var(--wp-text2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Syntax indicator pills (mirrors design-handoff styles.css). */
|
||||
.wp-syntax-cell {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.wp-syntax-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
font-family: var(--wp-font-mono, ui-monospace, monospace);
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.wp-syntax-pill .pi { font-size: 9.5px; }
|
||||
.wp-syntax-pill--ref {
|
||||
/* outgoing nested @refs — pink/magenta to match @ref token color */
|
||||
color: var(--wp-kind-wildcard, #f0abfc);
|
||||
background: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 14%, transparent);
|
||||
border-color: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 28%, transparent);
|
||||
}
|
||||
.wp-syntax-pill--in {
|
||||
/* incoming references — accent (purple) */
|
||||
color: var(--wp-accent-text, #c4b5fd);
|
||||
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 14%, transparent);
|
||||
border-color: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 32%, transparent);
|
||||
}
|
||||
.wp-syntax-pill--dp {
|
||||
/* inline {a|b|c} — amber/yellow to match dp-brace token color */
|
||||
color: var(--wp-warn, #fcd34d);
|
||||
background: color-mix(in oklab, var(--wp-warn, #facc15) 14%, transparent);
|
||||
border-color: color-mix(in oklab, var(--wp-warn, #facc15) 30%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/wp/images/site.webmanifest" />
|
||||
<script type="module" crossorigin src="/wp/assets/manager-Z-SdbdKR.js"></script>
|
||||
<script type="module" crossorigin src="/wp/assets/manager-xOK4I4Wx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/wp/assets/manager-CMgfxADo.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user