generated from coulomb/repo-seed
Add graph explorer filter rules
This commit is contained in:
@@ -245,6 +245,50 @@ def graph_explorer_page() -> str:
|
||||
}
|
||||
.detail-list { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
|
||||
.detail-list li { overflow-wrap: anywhere; }
|
||||
.rule-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
.rule-panel summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: 10px;
|
||||
}
|
||||
.rule-panel summary::-webkit-details-marker { display: none; }
|
||||
.rule-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
.rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.rule-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.rule-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 8px;
|
||||
}
|
||||
.rule-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.rule-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.rule-actions button { min-height: 28px; padding: 4px 7px; }
|
||||
.popup {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
@@ -274,6 +318,7 @@ def graph_explorer_page() -> str:
|
||||
.main { grid-template-columns: 1fr; grid-template-rows: minmax(420px, 1fr) 330px; }
|
||||
.side { border-left: 0; border-top: 1px solid var(--line); }
|
||||
.map-controls { top: 8px; right: 8px; max-width: calc(100% - 16px); }
|
||||
.rule-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -319,7 +364,7 @@ def graph_explorer_page() -> str:
|
||||
<select id="unresolved-filter" aria-label="Unresolved filter"><option value="">Any</option><option value="true">Only</option></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Profile <button type="button" class="help-tip" aria-label="Saved views help" data-help-title="Saved Views" data-help="Saved views store the current search, mode, layout, label density, filters, and manual visibility overrides in this browser. Copy State creates a shareable URL for the current view.">?</button></span>
|
||||
<span class="field-label">Profile <button type="button" class="help-tip" aria-label="Saved views help" data-help-title="Saved Views" data-help="Saved views store the current search, mode, layout, label density, filters, rules, and manual visibility overrides in this browser. Copy State creates a shareable URL for the current view.">?</button></span>
|
||||
<select id="profile-select" aria-label="Saved view profile" disabled><option>Unsaved exploration</option></select>
|
||||
</div>
|
||||
<span id="counts" class="meta"></span>
|
||||
@@ -365,6 +410,46 @@ def graph_explorer_page() -> str:
|
||||
<button type="button" data-action="reset-overrides" data-help-title="Clear Overrides" data-help="Clear Overrides removes manual Show, Blur, and Hide choices. Filters, mode, layout, and label density stay as they are.">Clear Overrides</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<details id="rule-panel" class="rule-panel">
|
||||
<summary><span class="field-label">Rules <button type="button" class="help-tip" aria-label="Rule builder help" data-help-title="Rule Builder" data-help="Rules select nodes or edges by type and available attributes, then apply a modifier. Hide preserves the current layout; Remove redraws the map without matching entities.">?</button></span></summary>
|
||||
<div class="rule-editor">
|
||||
<div class="rule-grid">
|
||||
<label>Target
|
||||
<select id="rule-target">
|
||||
<option value="node">Nodes</option>
|
||||
<option value="edge">Edges</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Type
|
||||
<select id="rule-type"></select>
|
||||
</label>
|
||||
<label>Attribute
|
||||
<select id="rule-attribute"></select>
|
||||
</label>
|
||||
<label>Value
|
||||
<select id="rule-value"></select>
|
||||
</label>
|
||||
<label>Modifier
|
||||
<select id="rule-action">
|
||||
<option value="highlight">Highlight</option>
|
||||
<option value="show">Show</option>
|
||||
<option value="blur">Blur</option>
|
||||
<option value="hide">Hide</option>
|
||||
<option value="remove">Remove and redraw</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p id="rule-context" class="meta">Rules are applied top to bottom; later matching rules refine earlier ones.</p>
|
||||
<div class="button-row">
|
||||
<button type="button" data-rule-action="save" class="primary">Add Rule</button>
|
||||
<button type="button" data-rule-action="cancel">Cancel Edit</button>
|
||||
<button type="button" data-rule-action="reset">Reset Rules</button>
|
||||
</div>
|
||||
<ul id="rule-list" class="rule-list"></ul>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
<section class="section">
|
||||
<p id="profile-summary" class="meta">Profile persistence unavailable for this host.</p>
|
||||
<div class="field">
|
||||
@@ -372,10 +457,10 @@ def graph_explorer_page() -> str:
|
||||
<input id="profile-name" autocomplete="off" aria-label="Profile name" disabled>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button type="button" data-profile-action="save" data-help-title="Save" data-help="Save stores the current browser-local view, including filters, layout, label density, and manual overrides." disabled>Save</button>
|
||||
<button type="button" data-profile-action="save" data-help-title="Save" data-help="Save stores the current browser-local view, including filters, rules, layout, label density, and manual overrides." disabled>Save</button>
|
||||
<button type="button" data-profile-action="duplicate" data-help-title="Duplicate" data-help="Duplicate creates a new saved view from the currently loaded profile and current map state." disabled>Duplicate</button>
|
||||
<button type="button" data-profile-action="delete" data-help-title="Delete" data-help="Delete removes the selected browser-local saved view. It does not delete graph data." disabled>Delete</button>
|
||||
<button type="button" data-profile-action="copy" data-help-title="Copy State" data-help="Copy State copies a URL that can restore this map state, including filters and manual overrides." disabled>Copy State</button>
|
||||
<button type="button" data-profile-action="copy" data-help-title="Copy State" data-help="Copy State copies a URL that can restore this map state, including filters, rules, and manual overrides." disabled>Copy State</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
@@ -405,6 +490,13 @@ def graph_explorer_page() -> str:
|
||||
const edgeTypeSummary = document.getElementById("edge-type-summary");
|
||||
const reviewFilter = document.getElementById("review-filter");
|
||||
const unresolvedFilter = document.getElementById("unresolved-filter");
|
||||
const ruleTarget = document.getElementById("rule-target");
|
||||
const ruleType = document.getElementById("rule-type");
|
||||
const ruleAttribute = document.getElementById("rule-attribute");
|
||||
const ruleValue = document.getElementById("rule-value");
|
||||
const ruleAction = document.getElementById("rule-action");
|
||||
const ruleContext = document.getElementById("rule-context");
|
||||
const ruleList = document.getElementById("rule-list");
|
||||
const profileSelect = document.getElementById("profile-select");
|
||||
const profileNameInput = document.getElementById("profile-name");
|
||||
const profileSummary = document.getElementById("profile-summary");
|
||||
@@ -432,6 +524,8 @@ def graph_explorer_page() -> str:
|
||||
let profilePersistence = "none";
|
||||
let profiles = [];
|
||||
let currentProfileId = "";
|
||||
let filterRules = [];
|
||||
let editingRuleId = "";
|
||||
|
||||
const escapeHtml = (value) => String(value ?? "")
|
||||
.replaceAll("&", "&").replaceAll("<", "<")
|
||||
@@ -524,6 +618,159 @@ def graph_explorer_page() -> str:
|
||||
}).join("");
|
||||
};
|
||||
|
||||
const ruleActionLabels = {
|
||||
show: "Show",
|
||||
hide: "Hide",
|
||||
blur: "Blur",
|
||||
highlight: "Highlight",
|
||||
remove: "Remove and redraw",
|
||||
};
|
||||
|
||||
const ruleAttributeLabels = {
|
||||
any: "Any attribute",
|
||||
repo: "Repo",
|
||||
kind: "Kind",
|
||||
reviewState: "Review state",
|
||||
unresolved: "Unresolved",
|
||||
lifecycle: "Lifecycle",
|
||||
strength: "Strength",
|
||||
sameRepo: "Same repo",
|
||||
layoutAffinity: "Layout affinity",
|
||||
};
|
||||
|
||||
const ruleAttributeCandidates = {
|
||||
node: ["any", "repo", "kind", "reviewState", "unresolved", "lifecycle"],
|
||||
edge: ["any", "repo", "kind", "reviewState", "unresolved", "strength", "sameRepo", "layoutAffinity"],
|
||||
};
|
||||
|
||||
const renderOptions = (select, options, current = "") => {
|
||||
select.innerHTML = options.map((option) =>
|
||||
`<option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>`
|
||||
).join("");
|
||||
if (options.some((option) => option.value === current)) select.value = current;
|
||||
};
|
||||
|
||||
const ruleElementType = (element, target) =>
|
||||
target === "edge" ? element.data("edgeType") : element.data("layer");
|
||||
|
||||
const currentRuleElements = () => {
|
||||
if (!cy) return [];
|
||||
const target = ruleTarget.value || "node";
|
||||
const type = ruleType.value || "";
|
||||
const elements = target === "edge" ? cy.edges() : cy.nodes();
|
||||
return elements.filter((element) => !type || ruleElementType(element, target) === type).toArray();
|
||||
};
|
||||
|
||||
const valuesForRuleAttribute = (attribute) => {
|
||||
if (attribute === "any") return [{value: "", label: "Any"}];
|
||||
const values = Array.from(new Set(currentRuleElements()
|
||||
.map((element) => element.data(attribute))
|
||||
.filter((value) => value !== undefined && value !== null && value !== "")
|
||||
.map((value) => String(value))
|
||||
)).sort((left, right) => left.localeCompare(right));
|
||||
return values.length
|
||||
? values.map((value) => ({value, label: humanize(value)}))
|
||||
: [{value: "", label: "No values"}];
|
||||
};
|
||||
|
||||
const availableRuleAttributes = () => {
|
||||
const elements = currentRuleElements();
|
||||
return (ruleAttributeCandidates[ruleTarget.value || "node"] || ["any"])
|
||||
.filter((attribute) => attribute === "any" || elements.some((element) => {
|
||||
const value = element.data(attribute);
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
}))
|
||||
.map((attribute) => ({value: attribute, label: ruleAttributeLabels[attribute] || humanize(attribute)}));
|
||||
};
|
||||
|
||||
const refreshRuleBuilder = (state = {}) => {
|
||||
const target = state.target || ruleTarget.value || "node";
|
||||
ruleTarget.value = target;
|
||||
const types = target === "edge" ? allEdgeTypes : allNodeTypes;
|
||||
const labels = target === "edge" ? {} : nodeTypeLabels;
|
||||
renderOptions(ruleType, [
|
||||
{value: "", label: target === "edge" ? "All edge types" : "All node types"},
|
||||
...types.map((type) => ({value: type, label: labels[type] || humanize(type)})),
|
||||
], state.type ?? ruleType.value);
|
||||
renderOptions(ruleAttribute, availableRuleAttributes(), state.attribute ?? ruleAttribute.value);
|
||||
renderOptions(ruleValue, valuesForRuleAttribute(ruleAttribute.value || "any"), state.value ?? ruleValue.value);
|
||||
if (state.action && ruleActionLabels[state.action]) ruleAction.value = state.action;
|
||||
ruleValue.disabled = (ruleAttribute.value || "any") === "any" || ruleValue.options.length === 0;
|
||||
const attributeNames = Array.from(ruleAttribute.options).map((option) => option.textContent).join(", ");
|
||||
ruleContext.textContent = attributeNames
|
||||
? `Available here: ${attributeNames}. Rules are applied top to bottom; later matches refine earlier ones.`
|
||||
: "No attributes are available for this type yet.";
|
||||
};
|
||||
|
||||
const normalizeRules = (rules) => Array.isArray(rules)
|
||||
? rules
|
||||
.filter((rule) => rule && (rule.target === "node" || rule.target === "edge") && ruleActionLabels[rule.action])
|
||||
.map((rule, index) => ({
|
||||
id: String(rule.id || `rule-${Date.now().toString(36)}-${index}`),
|
||||
target: rule.target,
|
||||
type: String(rule.type || ""),
|
||||
attribute: String(rule.attribute || "any"),
|
||||
value: String(rule.value || ""),
|
||||
action: rule.action,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const ruleDescription = (rule) => {
|
||||
const target = rule.target === "edge" ? "edges" : "nodes";
|
||||
const typeLabel = rule.type
|
||||
? (rule.target === "edge" ? humanize(rule.type) : nodeTypeLabels[rule.type] || humanize(rule.type))
|
||||
: `all ${target}`;
|
||||
const attribute = rule.attribute && rule.attribute !== "any"
|
||||
? `${ruleAttributeLabels[rule.attribute] || humanize(rule.attribute)} = ${humanize(rule.value)}`
|
||||
: "any attribute";
|
||||
return `${ruleActionLabels[rule.action]} ${typeLabel} where ${attribute}`;
|
||||
};
|
||||
|
||||
const renderRules = () => {
|
||||
const saveButton = document.querySelector("[data-rule-action='save']");
|
||||
const cancelButton = document.querySelector("[data-rule-action='cancel']");
|
||||
saveButton.textContent = editingRuleId ? "Update Rule" : "Add Rule";
|
||||
cancelButton.disabled = !editingRuleId;
|
||||
ruleList.innerHTML = filterRules.length
|
||||
? filterRules.map((rule, index) => `
|
||||
<li class="rule-item" data-rule-id="${escapeHtml(rule.id)}">
|
||||
<div class="rule-title"><strong>${escapeHtml(ruleDescription(rule))}</strong><span class="meta">${index + 1}</span></div>
|
||||
<div class="rule-actions">
|
||||
<button type="button" data-rule-list-action="up" ${index === 0 ? "disabled" : ""}>Up</button>
|
||||
<button type="button" data-rule-list-action="down" ${index === filterRules.length - 1 ? "disabled" : ""}>Down</button>
|
||||
<button type="button" data-rule-list-action="edit">Edit</button>
|
||||
<button type="button" data-rule-list-action="delete">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
`).join("")
|
||||
: '<li class="meta">No rules yet.</li>';
|
||||
};
|
||||
|
||||
const matchesRule = (element, rule) => {
|
||||
if (rule.target === "node" && !element.isNode()) return false;
|
||||
if (rule.target === "edge" && !element.isEdge()) return false;
|
||||
if (rule.type && ruleElementType(element, rule.target) !== rule.type) return false;
|
||||
if (!rule.attribute || rule.attribute === "any") return true;
|
||||
const value = element.data(rule.attribute);
|
||||
return value !== undefined && value !== null && String(value) === String(rule.value);
|
||||
};
|
||||
|
||||
const ruleActionFor = (element) => {
|
||||
let action = "";
|
||||
filterRules.forEach((rule) => {
|
||||
if (matchesRule(element, rule)) action = rule.action;
|
||||
});
|
||||
return action;
|
||||
};
|
||||
|
||||
const ruleRemovalSignature = () => cy
|
||||
? cy.elements()
|
||||
.filter((element) => element.data("displayState") === "remove")
|
||||
.map((element) => element.id())
|
||||
.sort()
|
||||
.join("|")
|
||||
: "";
|
||||
|
||||
const isFinitePoint = (position) =>
|
||||
position && Number.isFinite(position.x) && Number.isFinite(position.y);
|
||||
|
||||
@@ -608,6 +855,7 @@ def graph_explorer_page() -> str:
|
||||
edgeTypes: Array.from(selectedEdgeTypes()),
|
||||
review: reviewFilter.value,
|
||||
unresolved: unresolvedFilter.value,
|
||||
rules: filterRules.map((rule) => ({...rule})),
|
||||
manualOverrides: {...manualOverrides},
|
||||
});
|
||||
|
||||
@@ -658,7 +906,9 @@ def graph_explorer_page() -> str:
|
||||
if (state.review) params.set("review", state.review);
|
||||
if (state.unresolved) params.set("unresolved", state.unresolved);
|
||||
if (currentProfileId) params.set("profile", currentProfileId);
|
||||
if (includeStateBlob || hasManualOverrides()) params.set("state", encodeStateBlob(state));
|
||||
if (includeStateBlob || hasManualOverrides() || filterRules.length > 0) {
|
||||
params.set("state", encodeStateBlob(state));
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`;
|
||||
};
|
||||
@@ -684,6 +934,12 @@ def graph_explorer_page() -> str:
|
||||
if ("manualOverrides" in state && state.manualOverrides && typeof state.manualOverrides === "object") {
|
||||
manualOverrides = {...state.manualOverrides};
|
||||
}
|
||||
if ("rules" in state) {
|
||||
filterRules = normalizeRules(state.rules);
|
||||
editingRuleId = "";
|
||||
refreshRuleBuilder();
|
||||
renderRules();
|
||||
}
|
||||
activeMode = modeSelect.value || "full";
|
||||
activeLabelMode = labelSelect.value || "auto";
|
||||
focusSet = null;
|
||||
@@ -826,35 +1082,54 @@ def graph_explorer_page() -> str:
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
const applyFilters = (options = {}) => {
|
||||
if (!cy) return;
|
||||
const previousRemoved = options.redrawOnRemove ? ruleRemovalSignature() : "";
|
||||
syncFilterSummaries();
|
||||
const hiddenNodes = new Set();
|
||||
const removedNodes = new Set();
|
||||
cy.nodes().forEach((node) => {
|
||||
let state = matchesFilters(node) ? "show" : "hide";
|
||||
const override = manualOverrideFor(node);
|
||||
if (override) state = override;
|
||||
const ruleAction = ruleActionFor(node);
|
||||
if (ruleAction) state = ruleAction;
|
||||
if (state !== "remove") {
|
||||
const override = manualOverrideFor(node);
|
||||
if (override) state = override;
|
||||
}
|
||||
node.data("displayState", state);
|
||||
node.toggleClass("display-blur", state === "blur");
|
||||
node.style("display", state === "hide" ? "none" : "element");
|
||||
node.toggleClass("rule-highlight", state === "highlight");
|
||||
node.style("display", state === "hide" || state === "remove" ? "none" : "element");
|
||||
if (state === "remove") removedNodes.add(node.id());
|
||||
if (state === "hide") hiddenNodes.add(node.id());
|
||||
});
|
||||
cy.edges().forEach((edge) => {
|
||||
let state = matchesFilters(edge) ? "show" : "hide";
|
||||
if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) state = "hide";
|
||||
const override = manualOverrideFor(edge);
|
||||
if (override) state = override;
|
||||
const ruleAction = ruleActionFor(edge);
|
||||
if (ruleAction) state = ruleAction;
|
||||
if (removedNodes.has(edge.data("source")) || removedNodes.has(edge.data("target"))) {
|
||||
state = "remove";
|
||||
} else if (hiddenNodes.has(edge.data("source")) || hiddenNodes.has(edge.data("target"))) {
|
||||
state = "hide";
|
||||
}
|
||||
if (state !== "remove") {
|
||||
const override = manualOverrideFor(edge);
|
||||
if (override) state = override;
|
||||
}
|
||||
edge.data("displayState", state);
|
||||
edge.toggleClass("display-blur", state === "blur");
|
||||
edge.style("display", state === "hide" ? "none" : "element");
|
||||
edge.toggleClass("rule-highlight", state === "highlight");
|
||||
edge.style("display", state === "hide" || state === "remove" ? "none" : "element");
|
||||
});
|
||||
const visibleNodes = cy.nodes().filter((node) => node.style("display") !== "none").length;
|
||||
const visibleEdges = cy.edges().filter((edge) => edge.style("display") !== "none").length;
|
||||
const hidden = cy.elements().length - visibleNodes - visibleEdges;
|
||||
const removed = cy.elements().filter((element) => element.data("displayState") === "remove").length;
|
||||
const hidden = cy.elements().length - visibleNodes - visibleEdges - removed;
|
||||
counts.textContent = `${visibleNodes} nodes / ${visibleEdges} edges`;
|
||||
hiddenSummary.textContent = `Hidden ${hidden}`;
|
||||
hiddenSummary.textContent = removed ? `Hidden ${hidden} / Removed ${removed}` : `Hidden ${hidden}`;
|
||||
updateLabelVisibility();
|
||||
updateSelectionAnchor();
|
||||
if (options.redrawOnRemove && previousRemoved !== ruleRemovalSignature()) runLayout();
|
||||
};
|
||||
|
||||
const showDetails = (element) => {
|
||||
@@ -989,6 +1264,7 @@ def graph_explorer_page() -> str:
|
||||
const runLayout = () => {
|
||||
if (!cy) return;
|
||||
cy.elements().stop();
|
||||
const layoutElements = cy.elements().filter((element) => element.data("displayState") !== "remove");
|
||||
const name = layoutSelect.value || "cose";
|
||||
const options = name === "breadthfirst"
|
||||
? {name, directed: true, padding: 48, animate: false}
|
||||
@@ -1006,7 +1282,7 @@ def graph_explorer_page() -> str:
|
||||
numIter: 1400,
|
||||
}
|
||||
: {name, padding: 48, animate: false};
|
||||
cy.layout(options).run();
|
||||
layoutElements.layout(options).run();
|
||||
updateSelectionAnchor();
|
||||
};
|
||||
|
||||
@@ -1098,6 +1374,17 @@ def graph_explorer_page() -> str:
|
||||
}},
|
||||
{selector: "edge[strength = 'strong']", style: {"line-color": "#344054", "target-arrow-color": "#344054"}},
|
||||
{selector: "edge[strength = 'weak']", style: {"line-style": "dotted"}},
|
||||
{selector: "node.rule-highlight", style: {
|
||||
"border-color": "#2563eb",
|
||||
"border-width": 3,
|
||||
"z-index": 3
|
||||
}},
|
||||
{selector: "edge.rule-highlight", style: {
|
||||
"line-color": "#2563eb",
|
||||
"target-arrow-color": "#2563eb",
|
||||
"width": 5,
|
||||
"z-index": 3
|
||||
}},
|
||||
{selector: "edge.hover", style: {
|
||||
"label": "data(edgeType)",
|
||||
"font-size": 10,
|
||||
@@ -1112,6 +1399,8 @@ def graph_explorer_page() -> str:
|
||||
{selector: ":selected", style: {"overlay-opacity": 0}}
|
||||
]
|
||||
});
|
||||
refreshRuleBuilder();
|
||||
renderRules();
|
||||
cy.on("tap", "node, edge", (event) => showDetails(event.target));
|
||||
cy.on("tap", (event) => { if (event.target === cy) showDetails(null); });
|
||||
cy.on("pan zoom resize render layoutstop", updateSelectionAnchor);
|
||||
@@ -1183,6 +1472,81 @@ def graph_explorer_page() -> str:
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
});
|
||||
ruleTarget.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value}));
|
||||
ruleType.addEventListener("input", () => refreshRuleBuilder({target: ruleTarget.value, type: ruleType.value}));
|
||||
ruleAttribute.addEventListener("input", () => refreshRuleBuilder({
|
||||
target: ruleTarget.value,
|
||||
type: ruleType.value,
|
||||
attribute: ruleAttribute.value,
|
||||
}));
|
||||
document.querySelector("[data-rule-action='save']").addEventListener("click", () => {
|
||||
const rule = {
|
||||
id: editingRuleId || `rule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
target: ruleTarget.value || "node",
|
||||
type: ruleType.value || "",
|
||||
attribute: ruleAttribute.value || "any",
|
||||
value: ruleAttribute.value === "any" ? "" : ruleValue.value || "",
|
||||
action: ruleAction.value || "highlight",
|
||||
};
|
||||
filterRules = editingRuleId
|
||||
? filterRules.map((existing) => existing.id === editingRuleId ? rule : existing)
|
||||
: [...filterRules, rule];
|
||||
editingRuleId = "";
|
||||
renderRules();
|
||||
applyFilters({redrawOnRemove: true});
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
});
|
||||
document.querySelector("[data-rule-action='cancel']").addEventListener("click", () => {
|
||||
editingRuleId = "";
|
||||
refreshRuleBuilder();
|
||||
renderRules();
|
||||
});
|
||||
document.querySelector("[data-rule-action='reset']").addEventListener("click", () => {
|
||||
filterRules = [];
|
||||
editingRuleId = "";
|
||||
refreshRuleBuilder();
|
||||
renderRules();
|
||||
applyFilters({redrawOnRemove: true});
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
});
|
||||
ruleList.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-rule-list-action]");
|
||||
if (!button) return;
|
||||
const item = button.closest("[data-rule-id]");
|
||||
const index = filterRules.findIndex((rule) => rule.id === item?.dataset?.ruleId);
|
||||
if (index < 0) return;
|
||||
const action = button.dataset.ruleListAction;
|
||||
if (action === "edit") {
|
||||
const rule = filterRules[index];
|
||||
editingRuleId = rule.id;
|
||||
refreshRuleBuilder(rule);
|
||||
renderRules();
|
||||
return;
|
||||
}
|
||||
if (action === "delete") {
|
||||
filterRules = filterRules.filter((_, ruleIndex) => ruleIndex !== index);
|
||||
} else if (action === "up" && index > 0) {
|
||||
[filterRules[index - 1], filterRules[index]] = [filterRules[index], filterRules[index - 1]];
|
||||
} else if (action === "down" && index < filterRules.length - 1) {
|
||||
[filterRules[index + 1], filterRules[index]] = [filterRules[index], filterRules[index + 1]];
|
||||
}
|
||||
editingRuleId = "";
|
||||
renderRules();
|
||||
applyFilters({redrawOnRemove: true});
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
});
|
||||
document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
|
||||
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
|
||||
document.querySelector("[data-action='clear']").addEventListener("click", () => {
|
||||
@@ -1197,9 +1561,13 @@ def graph_explorer_page() -> str:
|
||||
unresolvedFilter.value = "";
|
||||
focusSet = null;
|
||||
manualOverrides = {};
|
||||
filterRules = [];
|
||||
editingRuleId = "";
|
||||
refreshRuleBuilder();
|
||||
renderRules();
|
||||
currentProfileId = "";
|
||||
profileSelect.value = "";
|
||||
applyFilters();
|
||||
applyFilters({redrawOnRemove: true});
|
||||
updateProfileControls();
|
||||
updateProfileSummary();
|
||||
updateUrlState();
|
||||
|
||||
@@ -209,10 +209,17 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
||||
assert 'id="label-select"' in page
|
||||
assert 'id="node-type-filter"' in page
|
||||
assert 'id="edge-type-filter"' in page
|
||||
assert 'id="rule-panel"' in page
|
||||
assert 'id="rule-target"' in page
|
||||
assert 'id="rule-list"' in page
|
||||
assert 'id="selection-anchor"' in page
|
||||
assert 'id="help-popup"' in page
|
||||
assert "updateSelectionAnchor" in page
|
||||
assert "updateLabelVisibility" in page
|
||||
assert "ruleActionFor" in page
|
||||
assert "ruleRemovalSignature" in page
|
||||
assert "Remove and redraw" in page
|
||||
assert "Rules are applied top to bottom" in page
|
||||
assert "showHelp" in page
|
||||
assert "label-hidden" in page
|
||||
assert "Loading graph..." in page
|
||||
|
||||
@@ -151,7 +151,7 @@ Acceptance notes:
|
||||
|
||||
```task
|
||||
id: RAIL-FAB-WP-0009-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1b446ac8-eeba-4de6-9a32-d7f980d70b93"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user