From 02f181483ff2cdcc2829f17e102a3c1166fab9d5 Mon Sep 17 00:00:00 2001 From: Bingbing Date: Thu, 29 Oct 2026 15:08:16 +0800 Subject: [PATCH] feat(examples): create request from an example --- .../insomnia-data/__tests__/example.test.ts | 103 +++++++++++++++++- .../node-src/services/request.ts | 45 +++++++- .../tests/smoke/examples.test.ts | 8 ++ .../dropdowns/example-actions-dropdown.tsx | 32 +++++- .../examples/example-request-pane.tsx | 17 +++ .../example-node.tsx | 3 + .../src/ui/hooks/use-example-mutations.ts | 45 ++++++++ 7 files changed, 250 insertions(+), 3 deletions(-) diff --git a/packages/insomnia-data/__tests__/example.test.ts b/packages/insomnia-data/__tests__/example.test.ts index a5e420521b..d8c0730a38 100644 --- a/packages/insomnia-data/__tests__/example.test.ts +++ b/packages/insomnia-data/__tests__/example.test.ts @@ -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, + })) 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'); + }); +}); diff --git a/packages/insomnia-data/node-src/services/request.ts b/packages/insomnia-data/node-src/services/request.ts index 5a423afaea..3c4cceda47 100644 --- a/packages/insomnia-data/node-src/services/request.ts +++ b/packages/insomnia-data/node-src/services/request.ts @@ -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(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 = {}) { if (!patch.parentId) { throw new Error(`New Requests missing \`parentId\`: ${JSON.stringify(patch)}`); diff --git a/packages/insomnia-smoke-test/tests/smoke/examples.test.ts b/packages/insomnia-smoke-test/tests/smoke/examples.test.ts index 02aeedca1c..dcb8ec506b 100644 --- a/packages/insomnia-smoke-test/tests/smoke/examples.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/examples.test.ts @@ -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 diff --git a/packages/insomnia/src/ui/components/dropdowns/example-actions-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/example-actions-dropdown.tsx index c6171b72f9..51d86eea11 100644 --- a/packages/insomnia/src/ui/components/dropdowns/example-actions-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/example-actions-dropdown.tsx @@ -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 }, diff --git a/packages/insomnia/src/ui/components/examples/example-request-pane.tsx b/packages/insomnia/src/ui/components/examples/example-request-pane.tsx index 57c11b4363..66c9966239 100644 --- a/packages/insomnia/src/ui/components/examples/example-request-pane.tsx +++ b/packages/insomnia/src/ui/components/examples/example-request-pane.tsx @@ -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 } )} + + Auth + {getAuthObjectOrNull(request.authentication)?.type && + getAuthObjectOrNull(request.authentication)?.type !== 'none' && ( + + + + )} + + + +
{ {!isEditable && ( diff --git a/packages/insomnia/src/ui/hooks/use-example-mutations.ts b/packages/insomnia/src/ui/hooks/use-example-mutations.ts index c84185987d..19929bc107 100644 --- a/packages/insomnia/src/ui/hooks/use-example-mutations.ts +++ b/packages/insomnia/src/ui/hooks/use-example-mutations.ts @@ -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; } +/** 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) => { @@ -308,3 +321,35 @@ export const useCreateExample = (): UseMutationResult => { + 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, + ); +};