From 6c5fe24ccf9349bda96f5f7db985ecb01b1d3333 Mon Sep 17 00:00:00 2001 From: Natalia Date: Sun, 5 Jul 2026 01:29:24 -0700 Subject: [PATCH] Add response diff highlighting --- docs/CallHistoryDiff.md | 11 ++++ src/ApiUsage.tsx | 33 ++++++++-- src/components/CallHistoryRow.test.tsx | 56 ++++++++++++++++ src/components/CallHistoryRow.tsx | 50 ++++++++++++++- src/index.css | 85 +++++++++++++++++++++++++ src/utils/diff.test.ts | 32 ++++++++++ src/utils/diff.ts | 88 ++++++++++++++++++++++++++ 7 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 docs/CallHistoryDiff.md create mode 100644 src/utils/diff.test.ts create mode 100644 src/utils/diff.ts diff --git a/docs/CallHistoryDiff.md b/docs/CallHistoryDiff.md new file mode 100644 index 0000000..c9d0fb2 --- /dev/null +++ b/docs/CallHistoryDiff.md @@ -0,0 +1,11 @@ +# Call History Response Diff + +Expanded call-history rows can compare the selected call response with the next older call in the filtered history list. + +The comparison flattens JSON objects and arrays into stable paths, then marks each path as: + +- `added` when it only exists in the selected call response +- `removed` when it only exists in the previous call response +- `changed` when both calls include the path but the value changed + +The UI renders those entries below the response payload with accessible labels and kind-specific styling. When the previous and selected responses match, the row shows `No response changes detected.` instead of an empty list. diff --git a/src/ApiUsage.tsx b/src/ApiUsage.tsx index 08d835c..1a9b779 100644 --- a/src/ApiUsage.tsx +++ b/src/ApiUsage.tsx @@ -121,7 +121,16 @@ const MOCK_CALL_HISTORY: CallRecord[] = [ endpoint: '/api/v1/user/profile', status: 'success', responseTime: 120, - cost: 0.001 + cost: 0.001, + response: { + success: true, + data: { + id: 'user_123', + name: 'John Doe', + balance: 1250.5, + plan: 'pro', + }, + }, }, { id: '2', @@ -129,7 +138,15 @@ const MOCK_CALL_HISTORY: CallRecord[] = [ endpoint: '/api/v1/transactions', status: 'success', responseTime: 250, - cost: 0.003 + cost: 0.003, + response: { + success: true, + data: { + id: 'user_123', + name: 'John Doe', + balance: 1180, + }, + }, }, { id: '3', @@ -137,7 +154,14 @@ const MOCK_CALL_HISTORY: CallRecord[] = [ endpoint: '/api/v1/user/balance', status: 'error', responseTime: 5000, - cost: 0.001 + cost: 0.001, + response: { + success: false, + error: { + code: 'BALANCE_TIMEOUT', + message: 'Balance service timed out', + }, + }, } ]; @@ -772,10 +796,11 @@ export default function ApiUsage() { ) : filteredCallHistory.length === 0 ? ( ) : ( - filteredCallHistory.map(call => ( + filteredCallHistory.map((call, index) => ( setExpandedCall(expandedCall === id ? null : id)} /> diff --git a/src/components/CallHistoryRow.test.tsx b/src/components/CallHistoryRow.test.tsx index 246795f..b071935 100644 --- a/src/components/CallHistoryRow.test.tsx +++ b/src/components/CallHistoryRow.test.tsx @@ -16,6 +16,32 @@ const successCall: CallRecord = { response: { name: 'Alice' }, }; +const previousCall: CallRecord = { + ...successCall, + id: 'c0', + timestamp: new Date('2024-01-15T10:00:00'), + response: { + name: 'Alice', + balance: 100, + status: 'active', + metadata: { + tier: 'basic', + }, + }, +}; + +const changedCall: CallRecord = { + ...successCall, + response: { + name: 'Alice', + balance: 150, + metadata: { + tier: 'pro', + }, + plan: 'team', + }, +}; + const errorCall: CallRecord = { id: 'c2', timestamp: new Date('2024-01-15T11:00:00'), @@ -114,6 +140,36 @@ describe('CallHistoryRow', () => { expect(screen.queryByRole('region', { name: 'Call details' })).toBeNull(); }); + it('renders response diff entries against a previous call', () => { + renderRow({ + call: changedCall, + compareCall: previousCall, + expanded: true, + onToggleExpand: () => {}, + }); + + expect(screen.getByLabelText('Response diff against previous call')).toBeTruthy(); + expect(screen.getByText('Response diff')).toBeTruthy(); + expect(screen.getByText('balance')).toBeTruthy(); + expect(screen.getByText('metadata.tier')).toBeTruthy(); + expect(screen.getByText('plan')).toBeTruthy(); + expect(screen.getByText('status')).toBeTruthy(); + expect(screen.getAllByText('changed').length).toBe(2); + expect(screen.getByText('added')).toBeTruthy(); + expect(screen.getByText('removed')).toBeTruthy(); + }); + + it('renders an empty response diff message when responses match', () => { + renderRow({ + call: successCall, + compareCall: { ...previousCall, response: successCall.response }, + expanded: true, + onToggleExpand: () => {}, + }); + + expect(screen.getByText('No response changes detected.')).toBeTruthy(); + }); + // ── Data rendering ─────────────────────────────────────────────────────── it('displays the endpoint path', () => { diff --git a/src/components/CallHistoryRow.tsx b/src/components/CallHistoryRow.tsx index 523fb87..96d7c67 100644 --- a/src/components/CallHistoryRow.tsx +++ b/src/components/CallHistoryRow.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; import { CheckCircle, XCircle } from 'lucide-react'; import { formatPrice } from '../utils/format'; +import { diffValues } from '../utils/diff'; +import type { DiffEntry } from '../utils/diff'; export type CallRecord = { id: string; @@ -15,6 +16,7 @@ export type CallRecord = { type Props = { call: CallRecord; + compareCall?: CallRecord; expanded: boolean; onToggleExpand: (id: string) => void; }; @@ -33,6 +35,47 @@ function formatTimestamp(date: Date) { }).format(date); } +function formatDiffValue(value: unknown) { + if (typeof value === 'string') return value; + if (value === undefined) return 'undefined'; + const json = JSON.stringify(value); + return json ?? String(value); +} + +function DiffValue({ label, value }: { label: string; value: unknown }) { + return ( + + {label} + {formatDiffValue(value)} + + ); +} + +function ResponseDiff({ entries }: { entries: DiffEntry[] }) { + return ( +
+

Response diff

+ {entries.length === 0 ? ( +

No response changes detected.

+ ) : ( +
    + {entries.map(entry => ( +
  • + {entry.kind} + {entry.path} + {entry.kind !== 'added' && } + {entry.kind !== 'removed' && } +
  • + ))} +
+ )} +
+ ); +} + /** Status icon using design tokens — no hardcoded hex values. */ function StatusIcon({ status }: { status: 'success' | 'error' }) { if (status === 'success') { @@ -55,7 +98,9 @@ function StatusIcon({ status }: { status: 'success' | 'error' }) { ); } -export default function CallHistoryRow({ call, expanded, onToggleExpand }: Props) { +export default function CallHistoryRow({ call, compareCall, expanded, onToggleExpand }: Props) { + const responseDiff = compareCall ? diffValues(compareCall.response, call.response) : null; + return ( <>
@@ -96,6 +141,7 @@ export default function CallHistoryRow({ call, expanded, onToggleExpand }: Props

Response

{JSON.stringify(call.response ?? {}, null, 2)}
+ {responseDiff && }
)} diff --git a/src/index.css b/src/index.css index 67d5bf4..372d4bd 100644 --- a/src/index.css +++ b/src/index.css @@ -2807,6 +2807,87 @@ code, overflow-x: auto; } +.response-diff { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface-soft); +} + +.response-diff h4 { + margin: 0 0 10px 0; +} + +.response-diff__empty { + margin: 0; + color: var(--muted); + font-size: 0.8125rem; +} + +.response-diff__list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.response-diff__row { + display: grid; + grid-template-columns: 72px minmax(120px, 1fr) minmax(0, 1.2fr) minmax(0, 1.2fr); + gap: 10px; + align-items: start; + padding: 10px; + border: 1px solid var(--line); + border-left-width: 4px; + border-radius: 6px; + background: var(--surface); + font-size: 0.8125rem; +} + +.response-diff__row--added { + border-left-color: var(--success); +} + +.response-diff__row--removed { + border-left-color: var(--danger); +} + +.response-diff__row--changed { + border-left-color: var(--warning); +} + +.response-diff__kind { + font-weight: 600; + text-transform: capitalize; +} + +.response-diff__path { + color: var(--accent); + overflow-wrap: anywhere; +} + +.response-diff__value { + display: grid; + gap: 4px; + min-width: 0; +} + +.response-diff__value-label { + color: var(--muted); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.response-diff__value code { + color: var(--text); + overflow-wrap: anywhere; + white-space: pre-wrap; +} + .integration-guide-section h2 { margin: 0 0 16px 0; } @@ -2997,6 +3078,10 @@ code, margin-right: 8px; } + .response-diff__row { + grid-template-columns: 1fr; + } + .section-header { flex-direction: column; align-items: flex-start; diff --git a/src/utils/diff.test.ts b/src/utils/diff.test.ts new file mode 100644 index 0000000..8a66ca6 --- /dev/null +++ b/src/utils/diff.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { diffValues } from './diff'; + +describe('diffValues', () => { + it('reports changed primitive values by path', () => { + expect(diffValues({ name: 'Alice' }, { name: 'Bob' })).toEqual([ + { path: 'name', kind: 'changed', before: 'Alice', after: 'Bob' }, + ]); + }); + + it('reports nested added and removed values', () => { + expect( + diffValues( + { user: { name: 'Alice', age: 30 } }, + { user: { name: 'Alice', plan: 'pro' } }, + ), + ).toEqual([ + { path: 'user.age', kind: 'removed', before: 30 }, + { path: 'user.plan', kind: 'added', after: 'pro' }, + ]); + }); + + it('formats changed array items with indexed paths', () => { + expect(diffValues({ items: ['starter', 'basic'] }, { items: ['starter', 'pro'] })).toEqual([ + { path: 'items[1]', kind: 'changed', before: 'basic', after: 'pro' }, + ]); + }); + + it('returns no entries for equivalent values', () => { + expect(diffValues({ ok: true, data: [1, 2] }, { ok: true, data: [1, 2] })).toEqual([]); + }); +}); diff --git a/src/utils/diff.ts b/src/utils/diff.ts new file mode 100644 index 0000000..fd909a1 --- /dev/null +++ b/src/utils/diff.ts @@ -0,0 +1,88 @@ +export type DiffKind = "added" | "removed" | "changed"; + +export type DiffEntry = { + path: string; + kind: DiffKind; + before?: unknown; + after?: unknown; +}; + +type FlatValueMap = Map; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function formatPath(base: string, key: string | number): string { + if (typeof key === "number") return `${base}[${key}]`; + return base ? `${base}.${key}` : key; +} + +function flatten(value: unknown, basePath = ""): FlatValueMap { + const result: FlatValueMap = new Map(); + + if (Array.isArray(value)) { + if (value.length === 0) { + result.set(basePath || "$", value); + return result; + } + + value.forEach((item, index) => { + flatten(item, formatPath(basePath, index)).forEach((nestedValue, path) => { + result.set(path, nestedValue); + }); + }); + return result; + } + + if (isRecord(value)) { + const entries = Object.entries(value); + if (entries.length === 0) { + result.set(basePath || "$", value); + return result; + } + + entries.forEach(([key, item]) => { + flatten(item, formatPath(basePath, key)).forEach((nestedValue, path) => { + result.set(path, nestedValue); + }); + }); + return result; + } + + result.set(basePath || "$", value); + return result; +} + +function valuesEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function diffValues(before: unknown, after: unknown): DiffEntry[] { + const beforeValues = flatten(before); + const afterValues = flatten(after); + const paths = Array.from(new Set([...beforeValues.keys(), ...afterValues.keys()])).sort(); + + return paths.reduce((entries, path) => { + const hasBefore = beforeValues.has(path); + const hasAfter = afterValues.has(path); + + if (!hasBefore && hasAfter) { + entries.push({ path, kind: "added", after: afterValues.get(path) }); + return entries; + } + + if (hasBefore && !hasAfter) { + entries.push({ path, kind: "removed", before: beforeValues.get(path) }); + return entries; + } + + const beforeValue = beforeValues.get(path); + const afterValue = afterValues.get(path); + if (!valuesEqual(beforeValue, afterValue)) { + entries.push({ path, kind: "changed", before: beforeValue, after: afterValue }); + } + + return entries; + }, []); +}