mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-03 19:45:33 -05:00
feat(examples): support visual diff for examples
## Background Examples are nested in their request's YAML file, so the Git visual diff treated an example change as a raw `examples` field on the request and could not stage it independently. ## Changes - Collect examples as nested entities matched by their `meta.id`. - Add an Example diff card with request, response and docs tabs. - Stage, unstage and discard a single example without touching its siblings. - Keep examples out of request staging and preserve the export key order. ## Testing - `vitest run` on the diff engine, entity splice and card tabs tests: 69 passed. - `eslint` and `tsc --noEmit` on the changed files.
This commit is contained in:
8 files changed
+422
-15
No files matched your search
@@ -7,6 +7,7 @@ import { buildCookieJarTabs } from './cookie-jar-diff-card';
|
||||
import { computeFieldChanges, type EntityDiff, type VisualDiffEntityType } from './diff-engine';
|
||||
import type { DiffTabDef } from './diff-tabs';
|
||||
import { buildEnvironmentTabs } from './environment-diff-card';
|
||||
import { buildExampleTabs, ExampleDiffCard } from './example-diff-card';
|
||||
import { buildGenericTabs } from './generic-entity-diff-card';
|
||||
import { buildGrpcTabs } from './grpc-diff-card';
|
||||
import { buildMcpTabs } from './mcp-diff-card';
|
||||
@@ -115,6 +116,82 @@ describe('buildRequestTabs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExampleTabs', () => {
|
||||
const before = {
|
||||
name: '200 OK',
|
||||
meta: { id: 'exp_1', description: '' },
|
||||
request: { method: 'GET', url: 'https://a', headers: [], parameters: [], body: {} },
|
||||
response: { statusCode: 200, statusMessage: 'OK', headers: [], body: '{}' },
|
||||
};
|
||||
|
||||
it('maps the request snapshot and the response to their own tabs, with no raw Request/Response in Settings', () => {
|
||||
const after = {
|
||||
...before,
|
||||
meta: { id: 'exp_1', description: '' },
|
||||
request: {
|
||||
...before.request,
|
||||
url: 'https://b',
|
||||
headers: [{ name: 'X-New', value: '1' }],
|
||||
body: { mimeType: 'application/json', text: '{}' },
|
||||
},
|
||||
response: { ...before.response, statusCode: 201, body: '{"id":1}' },
|
||||
};
|
||||
|
||||
expect(summary(buildExampleTabs(modified('example', before, after)))).toEqual([
|
||||
{ id: 'body', status: 'added', count: undefined },
|
||||
{ id: 'headers', status: 'added', count: 1 },
|
||||
{ id: 'response', status: 'modified', count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows the example description as Docs, not in the Settings catch-all', () => {
|
||||
const after = { ...before, meta: { id: 'exp_1', description: 'Saves the created user' } };
|
||||
|
||||
expect(summary(buildExampleTabs(modified('example', before, after)))).toEqual([
|
||||
{ id: 'docs', status: 'added', count: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a method and URL change in the header rather than the Settings catch-all', () => {
|
||||
const after = { ...before, request: { ...before.request, method: 'POST', url: 'https://b' } };
|
||||
|
||||
expect(summary(buildExampleTabs(modified('example', before, after)))).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows request fields the request tabs cannot render in their own catch-all', () => {
|
||||
const headers = [
|
||||
{ name: 'A', value: '1' },
|
||||
{ name: 'B', value: '2' },
|
||||
];
|
||||
const withHeaders = { ...before, request: { ...before.request, headers } };
|
||||
const after = { ...withHeaders, request: { ...withHeaders.request, headers: [...headers].reverse() } };
|
||||
|
||||
expect(summary(buildExampleTabs(modified('example', withHeaders, after)))).toEqual([
|
||||
{ id: 'request-settings', status: 'modified', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders the example header with its method and URL', () => {
|
||||
const after = {
|
||||
...before,
|
||||
request: { ...before.request, url: 'https://b' },
|
||||
response: { ...before.response, body: '{"id":1}' },
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(ExampleDiffCard, {
|
||||
diff: modified('example', before, after),
|
||||
staged: false,
|
||||
pendingAction: null,
|
||||
isDisabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('200 OK');
|
||||
expect(html).toContain('https://b');
|
||||
expect(html).toContain('Response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings catch-all for changes a tab finds nothing to show for', () => {
|
||||
const request = { name: 'r', url: 'https://a', method: 'GET', meta: { id: 'req_1' } };
|
||||
const settingsPaths = (tabs: DiffTabDef[]) => {
|
||||
|
||||
@@ -31,9 +31,11 @@ describe('ownFields', () => {
|
||||
it('drops nested entities, which are shown as their own cards', () => {
|
||||
const folder = { name: 'F', meta: { id: 'fld_1' }, children: [{ name: 'r', meta: { id: 'req_1' } }] };
|
||||
const env = { name: 'Base', data: { a: 1 }, subEnvironments: [{ name: 'Sub' }] };
|
||||
const request = { name: 'R', meta: { id: 'req_1' }, examples: [{ name: 'E', meta: { id: 'exp_1' } }] };
|
||||
|
||||
expect(ownFields(folder)).toEqual({ name: 'F', meta: { id: 'fld_1' } });
|
||||
expect(ownFields(env)).toEqual({ name: 'Base', data: { a: 1 } });
|
||||
expect(ownFields(request)).toEqual({ name: 'R', meta: { id: 'req_1' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +81,46 @@ describe('computeVisualDiff - file-level sections', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeVisualDiff - examples', () => {
|
||||
const example = (id: string, body = '{}') => ({
|
||||
name: id,
|
||||
meta: { id },
|
||||
request: { method: 'GET', url: 'https://a', headers: [], body: {} },
|
||||
response: { statusCode: 200, statusMessage: '', headers: [], body },
|
||||
});
|
||||
const request = (examples: unknown[]) => ({
|
||||
name: 'r',
|
||||
url: 'https://a',
|
||||
method: 'GET',
|
||||
meta: { id: 'req_1' },
|
||||
examples,
|
||||
});
|
||||
|
||||
it('reports an example change as its own entity, not a request change', () => {
|
||||
const entities = computeVisualDiff(
|
||||
file([request([example('exp_1', 'old')])]),
|
||||
file([request([example('exp_1', 'new')])]),
|
||||
).entities;
|
||||
|
||||
expect(entities.map(e => [e.id, e.type, e.status])).toEqual([['exp_1', 'example', 'modified']]);
|
||||
expect(entities[0].fieldChanges.map(change => change.path)).toEqual(['response']);
|
||||
});
|
||||
|
||||
it('reports added and removed examples inside their request', () => {
|
||||
const added = computeVisualDiff(
|
||||
file([request([example('exp_1')])]),
|
||||
file([request([example('exp_1'), example('exp_2')])]),
|
||||
).entities;
|
||||
expect(added.map(e => [e.id, e.type, e.status])).toEqual([['exp_2', 'example', 'added']]);
|
||||
|
||||
const removed = computeVisualDiff(
|
||||
file([request([example('exp_1'), example('exp_2')])]),
|
||||
file([request([example('exp_1')])]),
|
||||
).entities;
|
||||
expect(removed.map(e => [e.id, e.type, e.status])).toEqual([['exp_2', 'example', 'removed']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffByKey', () => {
|
||||
const userAgent = { name: 'User-Agent', value: 'insomnia/12.1.0', disabled: false };
|
||||
const customUserAgent = { name: 'User-Agent', value: '123', disabled: true };
|
||||
|
||||
@@ -13,6 +13,7 @@ import { parse as parseYaml } from 'yaml';
|
||||
* (bullet list of raw field changes). Status of dedicated visual cards:
|
||||
*
|
||||
* - [x] Request (HTTP) -> RequestDiffCard
|
||||
* - [x] Example -> ExampleDiffCard
|
||||
* - [x] Environment -> EnvironmentDiffCard
|
||||
* - [x] Request Group (folder) -> RequestGroupDiffCard
|
||||
* - [x] WebSocket Request -> WebSocketDiffCard
|
||||
@@ -36,6 +37,7 @@ export type VisualDiffEntityType =
|
||||
| 'websocket_request'
|
||||
| 'socketio_request'
|
||||
| 'request_group'
|
||||
| 'example'
|
||||
| 'environment'
|
||||
| 'mock_route'
|
||||
| 'mcp_request'
|
||||
@@ -132,7 +134,7 @@ function cleanMeta(meta: any) {
|
||||
}
|
||||
|
||||
// Keys that are structural (handled by entity matching itself) rather than displayable fields
|
||||
const STRUCTURAL_KEYS = new Set(['children', 'subEnvironments']);
|
||||
const STRUCTURAL_KEYS = new Set(['children', 'subEnvironments', 'examples']);
|
||||
|
||||
// An entity node minus its nested entities (eg. a folder's `children`), which get their own cards.
|
||||
export function ownFields(node: any): any {
|
||||
@@ -365,6 +367,14 @@ function collectEntities(file: any): Map<string, CollectedEntity> {
|
||||
if (id) {
|
||||
map.set(id, { type: classifyCollectionNode(node), name: node.name || 'Untitled', node });
|
||||
}
|
||||
// Examples live inside their request's file, not in a file of their own, so they are
|
||||
// diffed as nested entities matched by id.
|
||||
(node.examples ?? []).forEach((example: { meta?: { id?: string }; name?: string }) => {
|
||||
const exampleId = example?.meta?.id;
|
||||
if (exampleId) {
|
||||
map.set(exampleId, { type: 'example', name: example.name || 'Example', node: example });
|
||||
}
|
||||
});
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children.forEach(addCollectionNode);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CookieJarDiffCard } from './cookie-jar-diff-card';
|
||||
import { computeVisualDiff, getBaseEnvironmentId } from './diff-engine';
|
||||
import { applyEntityChange, hasOnlyWorkspaceIdentity } from './entity-splice';
|
||||
import { EnvironmentDiffCard } from './environment-diff-card';
|
||||
import { ExampleDiffCard } from './example-diff-card';
|
||||
import { GenericEntityDiffCard } from './generic-entity-diff-card';
|
||||
import { GrpcDiffCard } from './grpc-diff-card';
|
||||
import { McpDiffCard } from './mcp-diff-card';
|
||||
@@ -262,6 +263,9 @@ export const EntityDiffList: FC<Props> = ({
|
||||
case 'request': {
|
||||
return <RequestDiffCard key={diff.id} diff={diff} {...actionProps} />;
|
||||
}
|
||||
case 'example': {
|
||||
return <ExampleDiffCard key={diff.id} diff={diff} {...actionProps} />;
|
||||
}
|
||||
case 'request_group': {
|
||||
return <RequestGroupDiffCard key={diff.id} diff={diff} {...actionProps} />;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,114 @@ describe('applyEntityChange - collection tree', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyEntityChange - examples', () => {
|
||||
const example = (id: string, body = '{}') => ({
|
||||
name: id,
|
||||
meta: { id },
|
||||
request: { method: 'GET', url: 'https://a' },
|
||||
response: { statusCode: 200, statusMessage: '', headers: [], body },
|
||||
});
|
||||
const requestWithExamples = (examples: unknown[]) => ({ ...req('req_1'), examples });
|
||||
const examplesOf = (file: any) =>
|
||||
(file.collection[0].examples ?? []).map((item: any) => [item.meta.id, item.response.body]);
|
||||
|
||||
it('grafts one example without touching its siblings', () => {
|
||||
const head = collectionFile(BASE_ENV, [
|
||||
requestWithExamples([example('exp_1', 'old'), example('exp_2', 'two')]),
|
||||
]);
|
||||
const workdir = collectionFile(BASE_ENV, [
|
||||
requestWithExamples([example('exp_1', 'new'), example('exp_2', 'two'), example('exp_3', 'three')]),
|
||||
]);
|
||||
|
||||
const result = parse(applyEntityChange(head, workdir, 'exp_1'));
|
||||
|
||||
expect(examplesOf(result)).toEqual([
|
||||
['exp_1', 'new'],
|
||||
['exp_2', 'two'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds and removes a single example', () => {
|
||||
const one = collectionFile(BASE_ENV, [requestWithExamples([example('exp_1')])]);
|
||||
const two = collectionFile(BASE_ENV, [requestWithExamples([example('exp_1'), example('exp_2')])]);
|
||||
|
||||
expect(examplesOf(parse(applyEntityChange(one, two, 'exp_2')))).toEqual([
|
||||
['exp_1', '{}'],
|
||||
['exp_2', '{}'],
|
||||
]);
|
||||
|
||||
const result = parse(applyEntityChange(two, one, 'exp_2'));
|
||||
expect(examplesOf(result)).toEqual([['exp_1', '{}']]);
|
||||
});
|
||||
|
||||
it('drops the examples key when the last example is removed, as the export does', () => {
|
||||
const withExample = collectionFile(BASE_ENV, [requestWithExamples([example('exp_1')])]);
|
||||
const without = collectionFile(BASE_ENV, [requestWithExamples([])]);
|
||||
|
||||
const result = parse(applyEntityChange(withExample, without, 'exp_1'));
|
||||
|
||||
expect(result.collection[0]).not.toHaveProperty('examples');
|
||||
});
|
||||
|
||||
it("doesn't stage example changes along with the request's own fields", () => {
|
||||
const head = collectionFile(BASE_ENV, [
|
||||
{ ...requestWithExamples([example('exp_1', 'old')]), url: 'https://old' },
|
||||
]);
|
||||
const workdir = collectionFile(BASE_ENV, [
|
||||
{ ...requestWithExamples([example('exp_1', 'new')]), url: 'https://new' },
|
||||
]);
|
||||
|
||||
const result = parse(applyEntityChange(head, workdir, 'req_1'));
|
||||
|
||||
expect(result.collection[0].url).toBe('https://new');
|
||||
expect(examplesOf(result)).toEqual([['exp_1', 'old']]);
|
||||
});
|
||||
|
||||
it('keeps examples where the export writes them when staging the request', () => {
|
||||
const exportedRequest = (url: string) => ({
|
||||
url,
|
||||
name: 'r',
|
||||
meta: { id: 'req_1' },
|
||||
method: 'GET',
|
||||
body: {},
|
||||
parameters: [],
|
||||
headers: [],
|
||||
authentication: {},
|
||||
pathParameters: [],
|
||||
examples: [example('exp_1')],
|
||||
});
|
||||
const head = collectionFile(BASE_ENV, [exportedRequest('https://old')]);
|
||||
const workdir = collectionFile(BASE_ENV, [exportedRequest('https://new')]);
|
||||
|
||||
const result = parse(applyEntityChange(head, workdir, 'req_1'));
|
||||
|
||||
expect(Object.keys(result.collection[0])).toEqual([
|
||||
'url',
|
||||
'name',
|
||||
'meta',
|
||||
'method',
|
||||
'body',
|
||||
'parameters',
|
||||
'headers',
|
||||
'authentication',
|
||||
'pathParameters',
|
||||
'examples',
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries a newly added request along when staging one of its examples', () => {
|
||||
const base = collectionFile(BASE_ENV, []);
|
||||
const source = collectionFile(BASE_ENV, [
|
||||
requestWithExamples([example('exp_1', 'one'), example('exp_2', 'two')]),
|
||||
]);
|
||||
|
||||
const result = parse(applyEntityChange(base, source, 'exp_1'));
|
||||
|
||||
expect(result.collection[0].meta.id).toBe('req_1');
|
||||
expect(examplesOf(result)).toEqual([['exp_1', 'one']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyEntityChange - base environment', () => {
|
||||
it('drops a field that is absent from the source (eg. unstaging an added `data` block)', () => {
|
||||
const head = collectionFile({ name: 'Base Environment', meta: { id: ENV_ID } });
|
||||
|
||||
@@ -89,6 +89,7 @@ function safeParse(text: string): any {
|
||||
interface EntityLocation {
|
||||
section:
|
||||
| 'collection'
|
||||
| 'example'
|
||||
| 'environments-base'
|
||||
| 'sub-environment'
|
||||
| 'routes'
|
||||
@@ -97,6 +98,8 @@ interface EntityLocation {
|
||||
| 'file-section';
|
||||
// ids of ancestor folders, root to the entity's direct parent (collection section only)
|
||||
folderPath: string[];
|
||||
// id of the request owning the example (example section only)
|
||||
parentRequestId?: string;
|
||||
// top-level keys making up the entity (file-section only)
|
||||
keys?: readonly string[];
|
||||
}
|
||||
@@ -108,6 +111,9 @@ function findInCollection(nodes: any[], entityId: string, path: string[]): Entit
|
||||
if (node?.meta?.id === entityId) {
|
||||
return { section: 'collection', folderPath: path };
|
||||
}
|
||||
if ((node?.examples ?? []).some((example: { meta?: { id?: string } }) => example?.meta?.id === entityId)) {
|
||||
return { section: 'example', folderPath: path, parentRequestId: node.meta?.id };
|
||||
}
|
||||
if (Array.isArray(node?.children)) {
|
||||
const found = findInCollection(node.children, entityId, [...path, node.meta?.id]);
|
||||
if (found) {
|
||||
@@ -255,30 +261,50 @@ function descendToContainer(baseFile: any, sourceFile: any, folderPath: string[]
|
||||
return { container, sourceArray };
|
||||
}
|
||||
|
||||
// A folder's own fields with `children` swapped for the given ones — placed after `meta`, where the
|
||||
// app writes them, and left out when empty, as the app's export does.
|
||||
function withChildren(folder: any, children: any[] | undefined): any {
|
||||
const hasChildren = Array.isArray(children) && children.length > 0;
|
||||
// An entity's own fields with one nested collection (`children` for folders, `examples` for
|
||||
// requests) swapped for the given one. An existing key keeps its position; a missing one goes
|
||||
// where the app writes it (after `meta` for folders, last for requests) and is left out when
|
||||
// empty, as the app's export does.
|
||||
function withNested(
|
||||
node: any,
|
||||
key: 'children' | 'examples',
|
||||
nested: any[] | undefined,
|
||||
insertAfterMeta: boolean,
|
||||
): any {
|
||||
const hasNested = Array.isArray(nested) && nested.length > 0;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(folder)) {
|
||||
if (key === 'children') {
|
||||
for (const [field, value] of Object.entries(node)) {
|
||||
if (field === key) {
|
||||
if (hasNested) {
|
||||
result[key] = nested;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result[key] = value;
|
||||
if (key === 'meta' && hasChildren) {
|
||||
result.children = children;
|
||||
result[field] = value;
|
||||
if (insertAfterMeta && field === 'meta' && hasNested && !(key in result)) {
|
||||
result[key] = nested;
|
||||
}
|
||||
}
|
||||
if (hasChildren && !('children' in result)) {
|
||||
result.children = children;
|
||||
if (hasNested && !(key in result)) {
|
||||
result[key] = nested;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Sets a request's examples, dropping the key when empty as the app's export does.
|
||||
function setExamples(request: any, examples: any[]) {
|
||||
if (examples.length > 0) {
|
||||
request.examples = examples;
|
||||
} else {
|
||||
delete request.examples;
|
||||
}
|
||||
}
|
||||
|
||||
// Grafts a collection-tree entity (request or folder) from source into base,
|
||||
// wherever source has it — moving it if it sits elsewhere in base, and removing
|
||||
// it if source no longer has it. A folder only carries its own fields: its
|
||||
// `children` are separate entities, so base's children are kept as-is.
|
||||
// it if source no longer has it. A folder only carries its own fields, and a
|
||||
// request only its own: their `children`/`examples` are separate entities, so
|
||||
// base's are kept as-is.
|
||||
function applyCollectionChange(baseFile: any, sourceFile: any, entityId: string) {
|
||||
if (!Array.isArray(baseFile.collection)) {
|
||||
baseFile.collection = [];
|
||||
@@ -301,7 +327,12 @@ function applyCollectionChange(baseFile: any, sourceFile: any, entityId: string)
|
||||
const sourceNode = sourceArray[sourceIndex];
|
||||
// Recognised by id, not by a `children` array: the app omits `children` for an empty folder,
|
||||
// and treating such a folder as a leaf would replace base's folder along with its contents.
|
||||
const node = entityId.startsWith('fld_') ? withChildren(sourceNode, existing?.node?.children) : sourceNode;
|
||||
// A request keeps base's examples for the same reason: examples are their own cards.
|
||||
const node = entityId.startsWith('fld_')
|
||||
? withNested(sourceNode, 'children', existing?.node?.children, true)
|
||||
: entityId.startsWith('req')
|
||||
? withNested(sourceNode, 'examples', existing?.node?.examples, false)
|
||||
: sourceNode;
|
||||
|
||||
// Staying in the same container keeps its original position to avoid reorder noise.
|
||||
const insertAt = existing?.container === container ? existingIndex : Math.min(sourceIndex, container.length);
|
||||
@@ -317,6 +348,41 @@ function applyChange(baseFile: any, sourceFile: any, entityId: string, location:
|
||||
applyCollectionChange(baseFile, sourceFile, entityId);
|
||||
return;
|
||||
}
|
||||
case 'example': {
|
||||
const parentRequestId = location.parentRequestId;
|
||||
if (!parentRequestId) {
|
||||
return;
|
||||
}
|
||||
const sourceRequest = findNodeWithContainer(sourceFile.collection, parentRequestId)?.node;
|
||||
const baseRequest = findNodeWithContainer(baseFile.collection, parentRequestId)?.node;
|
||||
|
||||
if (!sourceRequest) {
|
||||
if (baseRequest) {
|
||||
setExamples(baseRequest, spliceById(baseRequest.examples ?? [], [], entityId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!baseRequest) {
|
||||
// The request is new too: carry it along, with only the example being staged, so the
|
||||
// example has somewhere to live. Its other examples keep their own cards.
|
||||
const sourceExample = (sourceRequest.examples ?? []).find((example: any) => example?.meta?.id === entityId);
|
||||
const sourceLocation = findInCollection(sourceFile.collection, parentRequestId, []);
|
||||
if (!sourceExample || !sourceLocation) {
|
||||
return;
|
||||
}
|
||||
const { container, sourceArray } = descendToContainer(baseFile, sourceFile, sourceLocation.folderPath);
|
||||
const sourceIndex = sourceArray.findIndex((node: any) => node?.meta?.id === parentRequestId);
|
||||
container.splice(sourceIndex === -1 ? container.length : Math.min(sourceIndex, container.length), 0, {
|
||||
...sourceRequest,
|
||||
examples: [sourceExample],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setExamples(baseRequest, spliceById(baseRequest.examples ?? [], sourceRequest.examples ?? [], entityId));
|
||||
return;
|
||||
}
|
||||
case 'environments-base': {
|
||||
// The base environment is the container of the sub-environments, so
|
||||
// removing it removes them too.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { formatMethodName, getRequestBadgeClassName } from '../../tags/method-tag';
|
||||
import { computeFieldChanges, diffByKey, type EntityDiff, sideStatus, valuesEqual } from './diff-engine';
|
||||
import { type DiffTabDef, docsTab, KeyValueDiffRows, withSettingsTab } from './diff-tabs';
|
||||
import { buildRequestTabs } from './request-diff-card';
|
||||
import { type EntityCardActionProps, FieldDiffRow } from './shared';
|
||||
import { buildHeaderLines, EntityHeader, TabbedDiffCard } from './tabbed-diff-card';
|
||||
|
||||
// Fields this card shows outside its Settings catch-all. `request` is covered by the header
|
||||
// (method and URL) and the request tabs; without this it would fall through to the raw Settings
|
||||
// catch-all when only method or URL changed.
|
||||
const CARD_FIELD_PATHS = new Set(['name', 'request']);
|
||||
|
||||
// The example editor reuses the request editor for its request snapshot, so its tabs
|
||||
// (params, body, auth, headers, scripts) are reused here too. They all belong to the
|
||||
// example's `request` snapshot as far as the example-level catch-all is concerned.
|
||||
function requestTabs(diff: EntityDiff): DiffTabDef[] {
|
||||
const before = diff.before?.request;
|
||||
const after = diff.after?.request;
|
||||
return buildRequestTabs({
|
||||
...diff,
|
||||
before,
|
||||
after,
|
||||
fieldChanges: computeFieldChanges(before, after),
|
||||
}).map(tab => ({
|
||||
...tab,
|
||||
// The request card's own catch-all (eg. headers that were only reordered) stays a tab of
|
||||
// its own, so an example-level Settings tab still means "outside the request snapshot".
|
||||
id: tab.id === 'settings' ? 'request-settings' : tab.id,
|
||||
label: tab.id === 'settings' ? 'Request settings' : tab.label,
|
||||
paths: ['request'],
|
||||
}));
|
||||
}
|
||||
|
||||
// The response part of an example is a saved snapshot, not a live response, so it has no
|
||||
// timeline: status, headers and body are all there is to show.
|
||||
function responseTab(diff: EntityDiff): DiffTabDef | null {
|
||||
const before = diff.before?.response;
|
||||
const after = diff.after?.response;
|
||||
const statusChanges = computeFieldChanges(
|
||||
{ statusCode: before?.statusCode, statusMessage: before?.statusMessage },
|
||||
{ statusCode: after?.statusCode, statusMessage: after?.statusMessage },
|
||||
);
|
||||
const headerRows = diffByKey(before?.headers, after?.headers, 'name');
|
||||
const bodyChanged = !valuesEqual(before?.body, after?.body);
|
||||
|
||||
if (statusChanges.length === 0 && headerRows.length === 0 && !bodyChanged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'response',
|
||||
label: 'Response',
|
||||
status: sideStatus(Boolean(before), Boolean(after)),
|
||||
count: statusChanges.length + headerRows.length + (bodyChanged ? 1 : 0),
|
||||
paths: ['response'],
|
||||
content: () => (
|
||||
<div className="flex flex-col gap-3">
|
||||
{statusChanges.map(change => (
|
||||
<FieldDiffRow key={change.path} label={change.label} before={change.before} after={change.after} />
|
||||
))}
|
||||
<KeyValueDiffRows title="Headers" rows={headerRows} />
|
||||
{bodyChanged && <FieldDiffRow label="Content" before={before?.body} after={after?.body} />}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExampleTabs(diff: EntityDiff): DiffTabDef[] {
|
||||
return withSettingsTab(diff, CARD_FIELD_PATHS, [...requestTabs(diff), responseTab(diff), docsTab(diff)]);
|
||||
}
|
||||
|
||||
export const ExampleDiffCard: FC<{ diff: EntityDiff } & EntityCardActionProps> = ({ diff, ...actionProps }) => {
|
||||
const request = (diff.after ?? diff.before)?.request;
|
||||
const currentMethod = request?.method || 'GET';
|
||||
// Header changes inside the request snapshot don't surface as top-level field changes,
|
||||
// so add them for the old-line preview of the method and URL.
|
||||
const header = buildHeaderLines(
|
||||
{ ...diff, fieldChanges: [...diff.fieldChanges, ...computeFieldChanges(diff.before?.request, diff.after?.request)] },
|
||||
{
|
||||
badgeOf: node => {
|
||||
const label = formatMethodName(node?.request?.method || currentMethod);
|
||||
return { label, className: getRequestBadgeClassName(label) };
|
||||
},
|
||||
detailOf: node => node?.request?.url || '(no url)',
|
||||
watchedPaths: ['method', 'url'],
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<TabbedDiffCard
|
||||
diff={diff}
|
||||
header={<EntityHeader {...header} />}
|
||||
tabs={buildExampleTabs(diff)}
|
||||
{...actionProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { InlineDiffEditor } from './inline-diff-editor';
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<VisualDiffEntityType, string> = {
|
||||
request: 'Request',
|
||||
example: 'Example',
|
||||
grpc_request: 'gRPC Request',
|
||||
websocket_request: 'WebSocket Request',
|
||||
socketio_request: 'Socket.IO Request',
|
||||
|
||||
Reference in new issue
Block a user