Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,29 @@ describe('FindReplaceOverlay', () => {
expect(onClose).not.toHaveBeenCalled();
});

it('replacingTheSelectedConfigMatch_changesOnlyThatField', () => {
const onApply = vi.fn();
render(
<FindReplaceOverlay canWrite
nodes={[node('a', 'Deploy', { script: 'Restart-Service prod-api', workingDirectory: '/srv/prod' })]}
edges={[]}
onApply={onApply}
onClose={vi.fn()}
/>
);

fireEvent.change(screen.getByPlaceholderText(/Find/i), { target: { value: 'prod' } });
fireEvent.change(screen.getByPlaceholderText(/Replace with/i), { target: { value: 'test' } });
fireEvent.click(screen.getByText('Deploy › workingDirectory'));
fireEvent.click(screen.getByTitle(/Replace current match/i));

const [appliedNodes] = onApply.mock.calls[0] as [Node[], Edge[]];
expect((appliedNodes[0].data as { config: Record<string, string> }).config).toEqual({
script: 'Restart-Service prod-api',
workingDirectory: '/srv/test',
});
});

it('replaceButtonsDisabledWhenNoMatches', () => {
render(
<FindReplaceOverlay canWrite nodes={[node('a', 'Foo')]} edges={[]} onApply={vi.fn()} onClose={vi.fn()} />
Expand Down
36 changes: 36 additions & 0 deletions src/nodepilot-ui/src/__tests__/lib/findReplace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,42 @@ describe('applyReplaceAll', () => {
expect(cfg.headers[0].value).toBe('still-New');
});

it('replacingOneConfigMatch_leavesOtherFieldsOfTheSameNodeUnchanged', () => {
const nodes = [node('n1', 'Deploy prod', { workingDirectory: '/srv/prod/app', script:'Restart-Service prod-api' })];
const selected = findMatches('prod', nodes, [], ALL_SCOPES)
.filter((m) => m.configKeyPath === 'workingDirectory');

const { nodes: out } = applyReplaceAll(selected, 'prod', 'test', nodes, []);

const data = out[0].data as { label: string; config: Record<string, string> };
expect(data.config.workingDirectory).toBe('/srv/test/app');
expect(data.config.script).toBe('Restart-Service prod-api');
expect(data.label).toBe('Deploy prod');
});

it('replacingALabelMatch_leavesConfigUnchanged', () => {
const nodes = [node('n1', 'Old step', { script: 'Old-Command' })];
const labelOnly = findMatches('Old', nodes, [], ALL_SCOPES).filter((m) => m.kind === 'node-label');

const { nodes: out } = applyReplaceAll(labelOnly, 'Old', 'New', nodes, []);

const data = out[0].data as { label: string; config: Record<string, string> };
expect(data.label).toBe('New step');
expect(data.config.script).toBe('Old-Command');
});

it('replacesAtAConfigKeyThatContainsADot', () => {
const nodes = [node('n1', 'Call', { headers: { 'X.Old': 'Old-value', X: { Old: 'Old-nested' } } })];
const selected = findMatches('Old', nodes, [], ALL_SCOPES)
.filter((m) => m.configPath?.join('/') === 'headers/X.Old');

const { nodes: out } = applyReplaceAll(selected, 'Old', 'New', nodes, []);

const cfg = (out[0].data as { config: { headers: Record<string, unknown> } }).config;
expect(cfg.headers['X.Old']).toBe('New-value');
expect(cfg.headers.X).toEqual({ Old: 'Old-nested' });
});

it('doesNotMutateOriginalArrays', () => {
// applyReplaceAll must produce new arrays instead of splicing the inputs in place.
const nodes = [node('n1', 'OldText')];
Expand Down
41 changes: 23 additions & 18 deletions src/nodepilot-ui/src/lib/findReplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ export interface MatchLocation {
kind: MatchKind;
nodeId?: string;
edgeId?: string;
/** Dot-path into config, e.g. "script" or "headers.0.value" */
/** Dot-path into config for display, e.g. "script" or "headers.0.value" */
configKeyPath?: string;
/** The same path as segments; replacement uses it, because a key may itself contain a dot. */
configPath?: string[];
/** The matched substring */
matchedText: string;
/** Short context around the match for display */
Expand Down Expand Up @@ -35,11 +37,11 @@ function traverseObject(
obj: unknown,
query: string,
pathParts: string[],
results: Array<{ path: string; text: string; context: string }>,
results: Array<{ path: string[]; text: string; context: string }>,
): void {
if (typeof obj === 'string') {
if (obj.toLowerCase().includes(query.toLowerCase())) {
results.push({ path: pathParts.join('.'), text: obj, context: snippet(obj, query) });
results.push({ path: pathParts, text: obj, context: snippet(obj, query) });
}
return;
}
Expand Down Expand Up @@ -98,17 +100,19 @@ export function findMatches(
for (const n of nodes) {
const config = (n.data as Record<string, unknown>).config;
if (!config) continue;
const hits: Array<{ path: string; text: string; context: string }> = [];
const hits: Array<{ path: string[]; text: string; context: string }> = [];
traverseObject(config, query, [], hits);
const nodeLabel = (n.data as Record<string, unknown>).label as string | undefined;
for (const h of hits) {
const keyPath = h.path.join('.');
results.push({
kind: 'config',
nodeId: n.id,
configKeyPath: h.path,
configKeyPath: keyPath,
configPath: h.path,
matchedText: h.text,
contextSnippet: h.context,
displayName: `${nodeLabel || n.id} › ${h.path || 'config'}`,
displayName: `${nodeLabel || n.id} › ${keyPath || 'config'}`,
});
}
}
Expand All @@ -117,17 +121,18 @@ export function findMatches(
return results.slice(0, 50);
}

function replaceInValue(value: unknown, search: string, replacement: string): unknown {
if (typeof value === 'string') {
return value.replaceAll(search, replacement);
}
/** Replaces inside the one string at `path`; every other config value is left untouched. */
function replaceAtPath(value: unknown, path: string[], search: string, replacement: string): unknown {
if (path.length === 0) return typeof value === 'string' ? value.replaceAll(search, replacement) : value;
const [head, ...rest] = path;
if (Array.isArray(value)) {
return value.map((v) => replaceInValue(v, search, replacement));
const index = Number(head);
if (!Number.isInteger(index) || index < 0 || index >= value.length) return value;
return value.map((item, i) => (i === index ? replaceAtPath(item, rest, search, replacement) : item));
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, replaceInValue(v, search, replacement)])
);
if (value && typeof value === 'object' && Object.hasOwn(value, head)) {
const record = value as Record<string, unknown>;
return { ...record, [head]: replaceAtPath(record[head], rest, search, replacement) };
}
return value;
}
Expand All @@ -145,13 +150,13 @@ export function applyReplaceAll(
const newNodes = nodes.map((n) => {
if (!nodeIds.has(n.id)) return n;
const hasLabelMatch = matches.some((m) => m.nodeId === n.id && m.kind === 'node-label');
const hasConfigMatch = matches.some((m) => m.nodeId === n.id && m.kind === 'config');
let data = n.data as Record<string, unknown>;
if (hasLabelMatch && typeof data.label === 'string') {
data = { ...data, label: data.label.replaceAll(search, replacement) };
}
if (hasConfigMatch && data.config) {
data = { ...data, config: replaceInValue(data.config, search, replacement) };
for (const m of matches) {
if (m.nodeId !== n.id || m.kind !== 'config' || !m.configPath) continue;
data = { ...data, config: replaceAtPath(data.config, m.configPath, search, replacement) };
}
return { ...n, data };
});
Expand Down
Loading