feat(examples): create request from an example

This commit is contained in:
Bingbing committed 2026-10-29 16:39:39 +08:00
1 parent 4243a83541
commit 02f181483f
7 files changed
+250 -3

No files matched your search

@@ -1,4 +1,4 @@
import type { Example, ExamplePatch, Request } from 'insomnia-data';
import type { Example, ExamplePatch, ExampleRequest, Request } from 'insomnia-data';
import { services } from 'insomnia-data';
import { v4 as uuidv4 } from 'uuid';
import { beforeEach, describe, expect, it } from 'vitest';
@@ -469,3 +469,104 @@ describe('Example Service validated write paths', () => {
});
});
});
describe('Request Service createFromExample', () => {
let workspaceId: string;
let requestId: string;
beforeEach(async () => {
await services.settings.getOrCreate();
const project = await services.project.create({
_id: `proj_test_${uuidv4()}`,
name: 'Test Project',
});
const workspace = await services.workspace.create({
_id: `wrk_test_${uuidv4()}`,
name: 'Test Workspace',
parentId: project._id,
scope: 'collection',
});
const request = (await services.request.create({
_id: `req_test_${uuidv4()}`,
name: 'Test Request',
parentId: workspace._id,
url: 'https://api.example.com/test',
method: 'GET',
metaSortKey: -1 * Date.now(),
})) as Request;
workspaceId = workspace._id;
requestId = request._id;
});
it('creates a request from the example right after its parent', async () => {
const example = (await services.example.create({
parentId: requestId,
name: '200 OK',
description: 'promoted',
metaSortKey: -100,
request: {
method: 'POST',
url: 'https://example.com/promoted',
parameters: [],
headers: [],
body: {},
} satisfies Partial<ExampleRequest>,
})) as Example;
// A sibling request after the parent gives the placement something to
// fall between: parent < created < next sibling.
const parentRequest = (await services.request.getById(requestId))!;
const nextSibling = (await services.request.create({
name: 'Next Request',
parentId: workspaceId,
url: 'r2.url',
metaSortKey: parentRequest.metaSortKey + 100,
})) as Request;
const created = await services.request.createFromExample(example._id, requestId);
expect(created).toMatchObject({
parentId: workspaceId,
name: '200 OK',
description: 'promoted',
method: 'POST',
url: 'https://example.com/promoted',
});
expect(created.metaSortKey).toBeGreaterThan(parentRequest.metaSortKey);
expect(created.metaSortKey).toBeLessThan(nextSibling.metaSortKey);
});
it('places the created request after the parent when there is no later sibling', async () => {
const example = (await services.example.create({
parentId: requestId,
name: '200 OK',
metaSortKey: -100,
})) as Example;
const parentRequest = (await services.request.getById(requestId))!;
const created = await services.request.createFromExample(example._id, requestId);
// No sibling after the parent: the fallback boundary is
// parent.metaSortKey + 100, halved into a midpoint (+50).
expect(created.metaSortKey).toBe(parentRequest.metaSortKey + 50);
});
it('falls back to the parent request name when the example is unnamed', async () => {
const example = (await services.example.create({ parentId: requestId, name: '', metaSortKey: -100 })) as Example;
const created = await services.request.createFromExample(example._id, requestId);
expect(created.name).toBe('Test Request');
});
it('throws when the example does not belong to the expected parent', async () => {
const example = (await services.example.create({ parentId: requestId, name: 'X', metaSortKey: -100 })) as Example;
await expect(services.request.createFromExample(example._id, 'req_other')).rejects.toThrow('does not belong');
});
it('throws when the example does not exist', async () => {
await expect(services.request.createFromExample('exp_missing', requestId)).rejects.toThrow('not found');
});
});
@@ -1,8 +1,51 @@
import type { Request } from 'insomnia-data';
import type { Example, Request } from 'insomnia-data';
import { database as db, models } from 'insomnia-data';
const { type, name } = models.request;
/**
* Promote an Example back into a first-class Request, created right after the
* example's parent request among its HTTP request siblings (midpoint of the
* parent's and the next sibling's metaSortKey). Refuses unknown examples and
* examples that no longer live under `expectedParentId`.
*/
export async function createFromExample(exampleId: string, expectedParentId: string) {
const example = await db.findOne<Example>(models.example.type, { _id: exampleId });
if (!example) {
throw new Error('Example not found');
}
if (example.parentId !== expectedParentId) {
throw new Error(`Example ${exampleId} does not belong to request ${expectedParentId}`);
}
const parentRequest = await getById(example.parentId);
if (!parentRequest) {
throw new Error('Request not found');
}
// Land right after the parent request among its HTTP request siblings
// (midpoint of the parent's and the next sibling's metaSortKey).
const siblings = await findByParentId(parentRequest.parentId);
const [nextRequest] = siblings
.filter(sibling => sibling.metaSortKey > parentRequest.metaSortKey)
.sort((a, b) => a.metaSortKey - b.metaSortKey);
const nextSortKey = nextRequest ? nextRequest.metaSortKey : parentRequest.metaSortKey + 100;
const metaSortKey = parentRequest.metaSortKey + (nextSortKey - parentRequest.metaSortKey) / 2;
return create({
parentId: parentRequest.parentId,
name: example.name || parentRequest.name,
description: example.description,
method: example.request.method,
url: example.request.url,
parameters: example.request.parameters,
pathParameters: example.request.pathParameters ?? parentRequest.pathParameters,
headers: example.request.headers,
body: example.request.body,
metaSortKey,
});
}
export function create(patch: Partial<Request> = {}) {
if (!patch.parentId) {
throw new Error(`New Requests missing \`parentId\`: ${JSON.stringify(patch)}`);
@@ -64,6 +64,14 @@ test.describe('Examples', () => {
await expect.soft(page).toHaveURL(/\/debug\/request\/req_/);
await expect.soft(page.getByText('Example not found')).toHaveCount(0);
// Promote an example into a standalone request via its context menu
await page.getByTestId('example-node-200 OK').first().click({ button: 'right' });
await page.getByRole('menuitemradio', { name: 'Create Request from Example' }).click();
// The created request is a normal request: the example editor is left and
// the request pane with its Send button is shown.
await expect.soft(page).toHaveURL(/\/debug\/request\/req_/);
await expect.soft(page.getByRole('button', { name: 'Send' })).toBeVisible();
// Drag-to-reorder between sibling examples is not covered here: react-aria
// uses native HTML5 drag events, which Playwright cannot synthesize with
// mouse primitives. Verify manually: drag one example row onto another in
@@ -8,6 +8,7 @@ import { showModal } from '~/ui/components/modals';
import { AskModal } from '~/ui/components/modals/ask-modal';
import { PromptModal } from '~/ui/components/modals/prompt-modal';
import {
useCreateRequestFromExample,
useDeleteExample,
useDuplicateExample,
useUpdateExample,
@@ -15,17 +16,40 @@ import {
interface Props {
example: ExampleSummary;
// The example's own location: the sidebar lists examples from every
// workspace, so navigation must not read the active route's params.
organizationId: string;
projectId: string;
workspaceId: string;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
export const ExampleActionsDropdown = ({ example, isOpen, onOpenChange }: Props) => {
export const ExampleActionsDropdown = ({
example,
organizationId,
projectId,
workspaceId,
isOpen,
onOpenChange,
}: Props) => {
const updateExample = useUpdateExample();
const duplicateExampleMutation = useDuplicateExample();
const deleteExampleMutation = useDeleteExample();
const createRequestFromExampleMutation = useCreateRequestFromExample();
const exampleName = example.name || `Example ${example.response.statusCode}`;
const createRequestFromExample = () => {
createRequestFromExampleMutation.mutate({
exampleId: example._id,
parentId: example.parentId,
organizationId,
projectId,
workspaceId,
});
};
const duplicateExample = () => {
showModal(PromptModal, {
title: 'Duplicate Example',
@@ -71,6 +95,12 @@ export const ExampleActionsDropdown = ({ example, isOpen, onOpenChange }: Props)
id: 'actions',
icon: 'cog' as const,
items: [
{
id: 'CreateRequest',
name: 'Create Request from Example',
icon: 'plus' as const,
action: createRequestFromExample,
},
{ id: 'Duplicate', name: 'Duplicate', icon: 'copy' as const, action: duplicateExample },
{ id: 'Rename', name: 'Rename', icon: 'edit' as const, action: renameExample },
{ id: 'Delete', name: 'Delete', icon: 'trash' as const, action: deleteExample },
@@ -6,8 +6,10 @@ import { Fragment } from 'react';
import { Button, Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import { extractQueryStringFromUrl } from '~/common/utils/url/querystring';
import { getAuthObjectOrNull } from '~/network/authentication';
import { OneLineEditor } from '~/ui/components/.client/codemirror/one-line-editor';
import { MethodSelector } from '~/ui/components/dropdowns/method-selector';
import { AuthWrapper } from '~/ui/components/editors/auth/auth-wrapper';
import { BodyEditor } from '~/ui/components/editors/body/body-editor';
import { readOnlyHttpPairs, RequestHeadersEditor } from '~/ui/components/editors/request-headers-editor';
import { RequestParametersEditor } from '~/ui/components/editors/request-parameters-editor';
@@ -115,6 +117,18 @@ export const ExampleRequestPane = ({ example, request, environmentId, settings }
</span>
)}
</Tab>
<Tab
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
id="auth"
>
<span>Auth</span>
{getAuthObjectOrNull(request.authentication)?.type &&
getAuthObjectOrNull(request.authentication)?.type !== 'none' && (
<span className="flex h-6 min-w-6 items-center justify-center rounded-lg border border-solid border-(--hl) p-1 text-xs">
<span className="h-2 w-2 rounded bg-green-500" />
</span>
)}
</Tab>
<Tab
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
id="headers"
@@ -194,6 +208,9 @@ export const ExampleRequestPane = ({ example, request, environmentId, settings }
<TabPanel className="flex w-full flex-1 flex-col" id="content-type">
<BodyEditor key={requestId} request={request} environmentId={environmentId} />
</TabPanel>
<TabPanel className="flex w-full flex-1 flex-col overflow-hidden" id="auth">
<AuthWrapper authentication={request.authentication} />
</TabPanel>
<TabPanel className="relative flex w-full flex-1 flex-col overflow-hidden" id="headers">
<div className="flex-1 shrink-0 overflow-y-auto">
<RequestHeadersEditor
@@ -78,6 +78,9 @@ export const ExampleNode = ({ item, depthOffset = 0 }: ExampleNodeProps) => {
{!isEditable && (
<ExampleActionsDropdown
example={doc}
organizationId={item.organizationId}
projectId={project._id}
workspaceId={workspace._id}
isOpen={isContextMenuOpen}
onOpenChange={setIsContextMenuOpen}
/>
@@ -7,6 +7,7 @@ import type {
ExampleRequest,
ExampleResponse,
ExampleSummary,
Request,
WorkspaceChildren,
} from 'insomnia-data';
import { models, services } from 'insomnia-data';
@@ -60,6 +61,18 @@ export interface CreateExampleVariables {
response?: Partial<ExampleResponse>;
}
/** Variables accepted by `useCreateRequestFromExample().mutate`. */
export interface CreateRequestFromExampleVariables {
exampleId: string;
parentId: string;
// Target request's location, used for post-create navigation — never infer
// these from the calling component's route params (the sidebar can trigger a
// create for a request in a different workspace).
workspaceId: string;
projectId: string;
organizationId: string;
}
// The default name mirrors the HTTP reason phrases used across the app
// ("404 Not Found"); creation payloads may override it.
const buildExampleName = (response: Pick<ExampleResponse, 'statusCode' | 'statusMessage'>) => {
@@ -308,3 +321,35 @@ export const useCreateExample = (): UseMutationResult<Example, Error, CreateExam
queryClient,
);
};
/**
* Promote an example back into a first-class request, then navigate to the
* new request. The service owns the sibling sort-key placement.
*/
export const useCreateRequestFromExample = (): UseMutationResult<Request, Error, CreateRequestFromExampleVariables> => {
const queryClient = useDBQueryClient();
const navigate = useNavigate();
return useMutation(
{
mutationKey: ['example', 'create-request'],
scope: { id: 'example' },
mutationFn: ({ exampleId, parentId }: CreateRequestFromExampleVariables) =>
services.request.createFromExample(exampleId, parentId),
onSuccess: (created, variables) => {
// The created request enters the sidebar via the DB change listener
// (insert events invalidate the workspace children); navigate to it.
navigate(
buildResourceUrl({
organizationId: variables.organizationId,
projectId: variables.projectId,
workspaceId: variables.workspaceId,
resource: created,
}),
);
},
onError: error => showError(error),
},
queryClient,
);
};