mirror of
https://github.com/Kong/insomnia.git
synced 2026-11-01 18:51:44 -05:00
feat(examples): create request from an example
This commit is contained in:
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
|
||||
|
||||
+3
@@ -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,
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user