mirror of
https://github.com/Kong/insomnia.git
synced 2026-06-02 13:19:21 -04:00
Feat: Make people's first request easy (#9950)
* feat: first request creation ux * first request example * feat: create project when first landing * welcome back * change text * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * light theme * fix: smoke test * store recent request in localstorage * create default collection automatically * fix --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -55,6 +55,6 @@ export class PreferencesDataTab extends BasePage {
|
||||
*/
|
||||
private async waitForExportCompleteAlert(): Promise<void> {
|
||||
await this.page.getByText('Export Complete').waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await this.page.getByRole('button', { name: 'Ok' }).click();
|
||||
await this.page.getByRole('button', { name: 'Ok', exact: true }).click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ test.describe('Vault key actions', () => {
|
||||
await expect.soft(modal).toBeVisible();
|
||||
const vaultKeyValueInModal = await modal.getByTestId('VaultKeyDisplayPanel').innerText();
|
||||
expect.soft(vaultKeyValueInModal.length).toBeGreaterThan(0);
|
||||
await page.getByText('OK').click();
|
||||
await page.getByText('OK', { exact: true }).click();
|
||||
const vaultKeyValue = page.getByTestId('VaultKeyDisplayPanel');
|
||||
await expect.soft(vaultKeyValue).toHaveText(vaultKeyValueInModal);
|
||||
});
|
||||
|
||||
151
packages/insomnia/src/basic-components/select-popover.tsx
Normal file
151
packages/insomnia/src/basic-components/select-popover.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { Key } from '@react-types/shared';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Placement } from 'react-aria';
|
||||
import { Dialog, DialogTrigger, Heading, ListBox, ListBoxItem, Popover } from 'react-aria-components';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { Button } from './button';
|
||||
|
||||
export interface SelectPopoverItem {
|
||||
id: Key;
|
||||
label: string;
|
||||
textValue?: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectPopoverProps<T extends SelectPopoverItem> {
|
||||
ariaLabel: string;
|
||||
items: T[];
|
||||
selectedKey?: Key | null;
|
||||
onSelectionChange: (key: Key) => void;
|
||||
renderTrigger: (selectedItem: T | null) => ReactNode;
|
||||
renderItem?: (item: T, isSelected: boolean) => ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
title?: ReactNode;
|
||||
isDisabled?: boolean;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
placement?: Placement;
|
||||
offset?: number;
|
||||
triggerClassName?: string;
|
||||
popoverClassName?: string;
|
||||
dialogClassName?: string;
|
||||
listClassName?: string;
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
export function SelectPopover<T extends SelectPopoverItem>({
|
||||
ariaLabel,
|
||||
items,
|
||||
selectedKey,
|
||||
onSelectionChange,
|
||||
renderTrigger,
|
||||
renderItem,
|
||||
emptyState,
|
||||
footer,
|
||||
title,
|
||||
isDisabled,
|
||||
isOpen: isOpenProp,
|
||||
onOpenChange,
|
||||
placement = 'bottom start',
|
||||
offset = 8,
|
||||
triggerClassName,
|
||||
popoverClassName,
|
||||
dialogClassName,
|
||||
listClassName,
|
||||
itemClassName,
|
||||
}: SelectPopoverProps<T>) {
|
||||
const [internalIsOpen, setInternalIsOpen] = useState(false);
|
||||
const isControlled = isOpenProp !== undefined;
|
||||
const isOpen = isControlled ? isOpenProp : internalIsOpen;
|
||||
|
||||
const setOpen = (nextIsOpen: boolean) => {
|
||||
if (!isControlled) {
|
||||
setInternalIsOpen(nextIsOpen);
|
||||
}
|
||||
|
||||
onOpenChange?.(nextIsOpen);
|
||||
};
|
||||
|
||||
const selectedItem = useMemo(() => {
|
||||
if (selectedKey === null || selectedKey === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return items.find(item => String(item.id) === String(selectedKey)) ?? null;
|
||||
}, [items, selectedKey]);
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setOpen}>
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
isDisabled={isDisabled}
|
||||
size="sm"
|
||||
variant="text"
|
||||
className={twMerge('', triggerClassName)}
|
||||
>
|
||||
{renderTrigger(selectedItem)}
|
||||
</Button>
|
||||
<Popover
|
||||
className={twMerge('z-10! flex min-w-55 flex-col', popoverClassName)}
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
>
|
||||
<Dialog
|
||||
className={twMerge(
|
||||
'flex max-h-[min(300px,60vh)] min-w-55 flex-col overflow-hidden rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) text-sm shadow-lg select-none focus:outline-hidden',
|
||||
dialogClassName,
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<Heading className="flex shrink-0 items-center px-3 py-2 text-sm font-semibold text-(--hl)">
|
||||
{title}
|
||||
</Heading>
|
||||
) : null}
|
||||
<ListBox
|
||||
aria-label={ariaLabel}
|
||||
items={[...items]}
|
||||
selectedKeys={selectedKey === null || selectedKey === undefined ? [] : [selectedKey]}
|
||||
selectionMode="single"
|
||||
onSelectionChange={keys => {
|
||||
if (keys === 'all' || !keys) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [nextKey] = keys.values();
|
||||
|
||||
if (nextKey === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectionChange(nextKey);
|
||||
setOpen(false);
|
||||
}}
|
||||
renderEmptyState={() => (emptyState ? <div className="p-3 text-sm text-(--hl)">{emptyState}</div> : null)}
|
||||
className={twMerge(
|
||||
'flex min-h-0 flex-1 flex-col overflow-y-auto p-2 text-sm focus:outline-hidden data-empty:py-0',
|
||||
listClassName,
|
||||
)}
|
||||
>
|
||||
{item => (
|
||||
<ListBoxItem
|
||||
id={item.id}
|
||||
textValue={item.textValue ?? item.label}
|
||||
isDisabled={item.isDisabled}
|
||||
className={twMerge(
|
||||
'flex min-h-8 w-full items-center gap-2 rounded-sm px-2 text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold',
|
||||
itemClassName,
|
||||
)}
|
||||
>
|
||||
{({ isSelected }) => renderItem?.(item, isSelected) ?? <span className="truncate">{item.label}</span>}
|
||||
</ListBoxItem>
|
||||
)}
|
||||
</ListBox>
|
||||
{footer ? <div className="shrink-0 border-t border-solid border-(--hl-sm) p-2">{footer}</div> : null}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,14 @@ import {
|
||||
type ApiSpec,
|
||||
database,
|
||||
type GitRepository,
|
||||
type GrpcRequest,
|
||||
type MockServer,
|
||||
models,
|
||||
type Project,
|
||||
type Request,
|
||||
services,
|
||||
type SocketIORequest,
|
||||
type WebSocketRequest,
|
||||
type Workspace,
|
||||
type WorkspaceMeta,
|
||||
type WorkspaceScope,
|
||||
@@ -87,6 +91,117 @@ const lockGenerator = () => {
|
||||
// TODO: move all project operations to this file to ensure they are properly wrapped with locks
|
||||
export const projectLock = lockGenerator();
|
||||
|
||||
type TrackableRecentRequest = Request | WebSocketRequest | GrpcRequest | SocketIORequest;
|
||||
|
||||
export interface RecentProjectRequest {
|
||||
workspaceId: string;
|
||||
request: TrackableRecentRequest;
|
||||
}
|
||||
|
||||
interface CachedProjectRecentRequest {
|
||||
requestId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
// Keep a small buffer beyond the 3 visible items so Jump back in stays populated after deletions.
|
||||
const MAX_RECENT_PROJECT_REQUESTS = 5;
|
||||
const RECENT_PROJECT_REQUESTS_STORAGE_KEY_PREFIX = 'recent-project-requests';
|
||||
|
||||
const getRecentProjectRequestsStorageKey = (projectId: string) =>
|
||||
`${RECENT_PROJECT_REQUESTS_STORAGE_KEY_PREFIX}:${projectId}`;
|
||||
|
||||
const writeCachedProjectRecentRequests = (projectId: string, recentRequests: CachedProjectRecentRequest[]) => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedRecentRequests = recentRequests.slice(0, MAX_RECENT_PROJECT_REQUESTS);
|
||||
|
||||
const storageKey = getRecentProjectRequestsStorageKey(projectId);
|
||||
|
||||
if (trimmedRecentRequests.length === 0) {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(trimmedRecentRequests));
|
||||
};
|
||||
|
||||
export const getCachedProjectRecentRequests = (projectId?: string): CachedProjectRecentRequest[] => {
|
||||
if (!projectId || typeof window === 'undefined' || !window.localStorage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const storedRequestIds = window.localStorage.getItem(getRecentProjectRequestsStorageKey(projectId));
|
||||
|
||||
if (!storedRequestIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedRequestIds = JSON.parse(storedRequestIds);
|
||||
|
||||
if (!Array.isArray(parsedRequestIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsedRequestIds as CachedProjectRecentRequest[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const recordProjectRecentRequest = ({
|
||||
projectId,
|
||||
requestId,
|
||||
workspaceId,
|
||||
}: {
|
||||
projectId: string;
|
||||
requestId: string;
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
if (!projectId || !requestId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingRecentRequests = getCachedProjectRecentRequests(projectId);
|
||||
writeCachedProjectRecentRequests(projectId, [
|
||||
{ requestId, workspaceId },
|
||||
...existingRecentRequests.filter(storedRequest => storedRequest.requestId !== requestId),
|
||||
]);
|
||||
};
|
||||
|
||||
export const getProjectRecentRequests = async (projectId?: string) => {
|
||||
const cachedRecentRequests = getCachedProjectRecentRequests(projectId);
|
||||
|
||||
if (!projectId || cachedRecentRequests.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const recentRequests = (
|
||||
await Promise.all(
|
||||
cachedRecentRequests.map(async ({ requestId, workspaceId }): Promise<RecentProjectRequest | null> => {
|
||||
try {
|
||||
const request = (await services.helpers.getRequestById(requestId)) as TrackableRecentRequest | null;
|
||||
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
request,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter(isNotNullOrUndefined);
|
||||
|
||||
return recentRequests;
|
||||
};
|
||||
|
||||
export const checkSingleProjectSyncStatus = async (projectId: string) => {
|
||||
const projectWorkspaces = await services.workspace.findByParentId(projectId);
|
||||
const workspaceMetas = await database.find<WorkspaceMeta>(models.workspaceMeta.type, {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { AnalyticsEvent, trackOnceDaily } from '~/ui/analytics';
|
||||
import { AvatarGroup } from '~/ui/components/avatar';
|
||||
import { WorkspaceCardDropdown } from '~/ui/components/dropdowns/workspace-card-dropdown';
|
||||
import { ErrorBoundary } from '~/ui/components/error-boundary';
|
||||
import { FirstRequestCreation } from '~/ui/components/first-request-creation';
|
||||
import { Icon } from '~/ui/components/icon';
|
||||
import { ImportModal } from '~/ui/components/modals/import-modal/import-modal';
|
||||
import { NewWorkspaceModal } from '~/ui/components/modals/new-workspace-modal';
|
||||
@@ -142,6 +143,36 @@ const Component = () => {
|
||||
userSession.accountId &&
|
||||
models.organization.isOwnerOfOrganization({ organization, accountId: userSession.accountId });
|
||||
const isPersonalOrg = organization && models.organization.isPersonalOrganization(organization);
|
||||
const greetingName = userSession.firstName || userSession.email.split('@')[0] || 'there';
|
||||
const collectionItems = useMemo(
|
||||
() =>
|
||||
localFiles
|
||||
.filter(file => file.scope === 'collection' && file.workspace)
|
||||
.map(file => ({
|
||||
id: file.workspace!._id,
|
||||
label: file.name,
|
||||
})),
|
||||
[localFiles],
|
||||
);
|
||||
const [selectedCollectionId, setSelectedCollectionId] = useState<string | null>(null);
|
||||
const [newWorkspaceModalState, setNewWorkspaceModalState] = useState<{
|
||||
scope: WorkspaceScope;
|
||||
isOpen: boolean;
|
||||
redirect?: boolean;
|
||||
} | null>({
|
||||
scope: 'collection',
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCollectionId(currentSelection => {
|
||||
if (currentSelection && collectionItems.some(collection => collection.id === currentSelection)) {
|
||||
return currentSelection;
|
||||
}
|
||||
|
||||
return collectionItems[0]?.id ?? null;
|
||||
});
|
||||
}, [collectionItems]);
|
||||
|
||||
const tabNavigate = useTabNavigate();
|
||||
|
||||
@@ -219,14 +250,6 @@ const Component = () => {
|
||||
},
|
||||
}));
|
||||
|
||||
const [newWorkspaceModalState, setNewWorkspaceModalState] = useState<{
|
||||
scope: WorkspaceScope;
|
||||
isOpen: boolean;
|
||||
} | null>({
|
||||
scope: 'collection',
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
const createNewCollection = () => setNewWorkspaceModalState({ scope: 'collection', isOpen: true });
|
||||
const createNewDocument = () => setNewWorkspaceModalState({ scope: 'design', isOpen: true });
|
||||
const createNewMockServer = () =>
|
||||
@@ -308,6 +331,17 @@ const Component = () => {
|
||||
<ErrorBoundary>
|
||||
<Fragment>
|
||||
<OrganizationTabList showActiveStatus={false} />
|
||||
<div className="px-4 pt-4">
|
||||
<FirstRequestCreation
|
||||
greetingName={greetingName}
|
||||
collectionItems={collectionItems}
|
||||
selectedCollectionId={selectedCollectionId}
|
||||
onSelectedCollectionChange={setSelectedCollectionId}
|
||||
onCreateCollection={() => {
|
||||
setNewWorkspaceModalState({ scope: 'collection', isOpen: true, redirect: false });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{activeProject ? (
|
||||
<div className="flex w-full flex-col overflow-hidden">
|
||||
{billing.isActive ? null : (
|
||||
@@ -668,10 +702,17 @@ const Component = () => {
|
||||
project={activeProject}
|
||||
storageRules={storageRules}
|
||||
scope={newWorkspaceModalState.scope}
|
||||
onCreateWorkspace={workspaceId => {
|
||||
if (newWorkspaceModalState.scope === 'collection' && newWorkspaceModalState.redirect === false) {
|
||||
setSelectedCollectionId(workspaceId);
|
||||
}
|
||||
}}
|
||||
redirectAfterCreate={newWorkspaceModalState.redirect}
|
||||
onOpenChange={isOpen => {
|
||||
setNewWorkspaceModalState({
|
||||
scope: newWorkspaceModalState.scope,
|
||||
isOpen,
|
||||
redirect: newWorkspaceModalState.redirect,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -61,13 +61,12 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
|
||||
invariant(projectId, 'Project ID is required');
|
||||
invariant(organizationId, 'Organization ID is required');
|
||||
|
||||
if (!models.project.isScratchpadProject({ _id: projectId })) {
|
||||
const { id: sessionId } = await services.userSession.get();
|
||||
const userSession = await services.userSession.get();
|
||||
const { id: sessionId, accountId } = userSession;
|
||||
|
||||
if (!sessionId) {
|
||||
await logout();
|
||||
throw redirect(href('/auth/login'));
|
||||
}
|
||||
if (!models.project.isScratchpadProject({ _id: projectId }) && !sessionId) {
|
||||
await logout();
|
||||
throw redirect(href('/auth/login'));
|
||||
}
|
||||
|
||||
const project = await services.project.get(projectId);
|
||||
@@ -76,6 +75,16 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
|
||||
return redirect(href('/organization/:organizationId', { organizationId }));
|
||||
}
|
||||
|
||||
const organization = await services.organization.get(organizationId);
|
||||
|
||||
if (accountId && organization && models.organization.isPersonalOrganization(organization)) {
|
||||
const firstPersonalOrgLandingKey = `firstPersonalOrgLandingHandled:${accountId}`;
|
||||
|
||||
if (!window.localStorage.getItem(firstPersonalOrgLandingKey)) {
|
||||
window.localStorage.setItem(firstPersonalOrgLandingKey, 'true');
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackLearningFeature = {
|
||||
active: false,
|
||||
title: '',
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
|
||||
const { requestType, parentId, req } = (await request.json()) as {
|
||||
requestType: CreateRequestType;
|
||||
parentId?: string;
|
||||
req?: Request;
|
||||
req?: Partial<Request>;
|
||||
};
|
||||
|
||||
const settings = await services.settings.getOrCreate();
|
||||
@@ -44,7 +44,8 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
|
||||
await services.request.create({
|
||||
parentId: parentId || workspaceId,
|
||||
method: METHOD_GET,
|
||||
name: 'New Request',
|
||||
name: req?.name || 'New Request',
|
||||
url: req?.url || '',
|
||||
headers: defaultHeaders,
|
||||
})
|
||||
)._id;
|
||||
@@ -65,9 +66,11 @@ export async function clientAction({ params, request }: Route.ClientActionArgs)
|
||||
headers: [...defaultHeaders, { name: 'Content-Type', value: CONTENT_TYPE_JSON }],
|
||||
body: {
|
||||
mimeType: CONTENT_TYPE_GRAPHQL,
|
||||
text: '',
|
||||
text: req?.body?.text || '',
|
||||
},
|
||||
name: 'New Request',
|
||||
name: req?.name || 'New Request',
|
||||
url: req?.url || '',
|
||||
authentication: req?.authentication,
|
||||
})
|
||||
)._id;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mockRouteToHar } from './organization.$organizationId.project.$projectI
|
||||
interface NewWorkspaceData {
|
||||
name: string;
|
||||
scope: WorkspaceScope;
|
||||
mcpServerUrl?: string;
|
||||
folderPath?: string;
|
||||
mockServerType?: 'self-hosted' | 'cloud';
|
||||
mockServerUrl?: string;
|
||||
@@ -36,6 +37,7 @@ interface NewWorkspaceData {
|
||||
export async function clientAction({ request, params }: Route.ClientActionArgs) {
|
||||
const { organizationId, projectId } = params;
|
||||
try {
|
||||
const redirectAfterCreate = new URL(request.url).searchParams.get('redirectAfterCreate') !== 'false';
|
||||
const workspaceData = (await request.json()) as NewWorkspaceData;
|
||||
const project = await services.project.get(projectId);
|
||||
|
||||
@@ -138,7 +140,7 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
|
||||
await services.mcpRequest.create({
|
||||
parentId: workspace._id,
|
||||
transportType: 'streamable-http',
|
||||
url: '',
|
||||
url: workspaceData.mcpServerUrl?.trim() || '',
|
||||
name: 'MCP Client',
|
||||
headers: defaultHeaders,
|
||||
description: '',
|
||||
@@ -214,6 +216,13 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
|
||||
|
||||
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.requestCreated, properties: { requestType: 'HTTP' } });
|
||||
|
||||
if (!redirectAfterCreate) {
|
||||
return {
|
||||
workspaceId: workspace._id,
|
||||
requestId: activeRequestId,
|
||||
};
|
||||
}
|
||||
|
||||
return redirect(
|
||||
href(`/organization/:organizationId/project/:projectId/workspace/:workspaceId/debug/request/:requestId`, {
|
||||
organizationId,
|
||||
@@ -224,6 +233,12 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
|
||||
);
|
||||
}
|
||||
|
||||
if (!redirectAfterCreate) {
|
||||
return {
|
||||
workspaceId: workspace._id,
|
||||
};
|
||||
}
|
||||
|
||||
return redirect(
|
||||
`${href('/organization/:organizationId/project/:projectId/workspace/:workspaceId', {
|
||||
organizationId,
|
||||
@@ -245,14 +260,22 @@ export const useWorkspaceNewActionFetcher = createFetcherSubmitHook(
|
||||
({
|
||||
organizationId,
|
||||
projectId,
|
||||
redirectAfterCreate,
|
||||
...workspaceData
|
||||
}: NewWorkspaceData & { organizationId: string; projectId: string }) => {
|
||||
}: NewWorkspaceData & { organizationId: string; projectId: string; redirectAfterCreate?: boolean }) => {
|
||||
const action = href('/organization/:organizationId/project/:projectId/workspace/new', {
|
||||
organizationId,
|
||||
projectId,
|
||||
});
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (redirectAfterCreate !== undefined) {
|
||||
query.set('redirectAfterCreate', String(redirectAfterCreate));
|
||||
}
|
||||
|
||||
return submit(JSON.stringify(workspaceData), {
|
||||
method: 'POST',
|
||||
action: href('/organization/:organizationId/project/:projectId/workspace/new', {
|
||||
organizationId,
|
||||
projectId,
|
||||
}),
|
||||
action: query.toString() ? `${action}?${query.toString()}` : action,
|
||||
encType: 'application/json',
|
||||
});
|
||||
},
|
||||
|
||||
@@ -22,11 +22,33 @@ export interface ProjectIndexLoaderData {
|
||||
projects: (Project & { gitRepository?: GitRepository })[];
|
||||
}
|
||||
|
||||
const shouldAutoCreateInitialProject = async ({
|
||||
organizationId,
|
||||
accountId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
accountId: string | null | undefined;
|
||||
}) => {
|
||||
if (!accountId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const organization = await services.organization.get(organizationId);
|
||||
|
||||
if (!organization || !models.organization.isPersonalOrganization(organization)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstPersonalOrgLandingKey = `firstPersonalOrgLandingHandled:${accountId}`;
|
||||
|
||||
return !window.localStorage.getItem(firstPersonalOrgLandingKey);
|
||||
};
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
const { organizationId } = params;
|
||||
invariant(organizationId, 'Organization ID is required');
|
||||
|
||||
const { id: sessionId } = await services.userSession.get();
|
||||
const { id: sessionId, accountId } = await services.userSession.get();
|
||||
|
||||
if (!sessionId) {
|
||||
await logout();
|
||||
@@ -40,6 +62,33 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
return redirect(`/organization/${organizationId}/project/${projects[0]._id}`);
|
||||
}
|
||||
|
||||
let isFirstPersonalOrgLanding = false;
|
||||
|
||||
try {
|
||||
isFirstPersonalOrgLanding = await shouldAutoCreateInitialProject({ organizationId, accountId });
|
||||
} catch (error) {
|
||||
console.warn('[project] Failed to evaluate first personal org landing state', error);
|
||||
}
|
||||
|
||||
if (isFirstPersonalOrgLanding) {
|
||||
try {
|
||||
const project = await services.project.create({
|
||||
name: 'Drafts',
|
||||
parentId: organizationId,
|
||||
});
|
||||
|
||||
await services.workspace.create({
|
||||
name: 'My first collection',
|
||||
scope: 'collection',
|
||||
parentId: project._id,
|
||||
});
|
||||
|
||||
return redirect(`/organization/${organizationId}/project/${project._id}`);
|
||||
} catch (error) {
|
||||
console.warn('[project] Failed to auto-create initial local project', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projects,
|
||||
projectsCount: organizationProjects.length,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { memo, type SVGProps } from 'react';
|
||||
export const SvgIcnGraphql = memo<SVGProps<SVGSVGElement>>(props => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M2.625 8.75H11.375V9.625H2.625V8.75Z" fill="#EC407A" />
|
||||
<path
|
||||
d="M3.0625 10.5C3.78737 10.5 4.375 9.91237 4.375 9.1875C4.375 8.46263 3.78737 7.875 3.0625 7.875C2.33763 7.875 1.75 8.46263 1.75 9.1875C1.75 9.91237 2.33763 10.5 3.0625 10.5Z"
|
||||
fill="#EC407A"
|
||||
/>
|
||||
<path
|
||||
d="M7 13.125C7.72487 13.125 8.3125 12.5374 8.3125 11.8125C8.3125 11.0876 7.72487 10.5 7 10.5C6.27513 10.5 5.6875 11.0876 5.6875 11.8125C5.6875 12.5374 6.27513 13.125 7 13.125Z"
|
||||
fill="#EC407A"
|
||||
/>
|
||||
<path
|
||||
d="M10.9375 10.5C11.6624 10.5 12.25 9.91237 12.25 9.1875C12.25 8.46263 11.6624 7.875 10.9375 7.875C10.2126 7.875 9.625 8.46263 9.625 9.1875C9.625 9.91237 10.2126 10.5 10.9375 10.5Z"
|
||||
fill="#EC407A"
|
||||
/>
|
||||
<path d="M2.625 4.375H11.375V5.25H2.625V4.375Z" fill="#EC407A" />
|
||||
<path
|
||||
d="M3.0625 6.125C3.78737 6.125 4.375 5.53737 4.375 4.8125C4.375 4.08763 3.78737 3.5 3.0625 3.5C2.33763 3.5 1.75 4.08763 1.75 4.8125C1.75 5.53737 2.33763 6.125 3.0625 6.125Z"
|
||||
fill="#EC407A"
|
||||
/>
|
||||
<path
|
||||
d="M7 3.5C7.72487 3.5 8.3125 2.91237 8.3125 2.1875C8.3125 1.46263 7.72487 0.875 7 0.875C6.27513 0.875 5.6875 1.46263 5.6875 2.1875C5.6875 2.91237 6.27513 3.5 7 3.5Z"
|
||||
fill="#EC407A"
|
||||
/>
|
||||
<path
|
||||
d="M10.9375 6.125C11.6624 6.125 12.25 5.53737 12.25 4.8125C12.25 4.08763 11.6624 3.5 10.9375 3.5C10.2126 3.5 9.625 4.08763 9.625 4.8125C9.625 5.53737 10.2126 6.125 10.9375 6.125Z"
|
||||
fill="#EC407A"
|
||||
/>
|
||||
<path d="M2.625 5.25H3.5V9.625H2.625V5.25ZM10.5 4.375H11.375V9.625H10.5V4.375Z" fill="#EC407A" />
|
||||
<path d="M2.19362 8.49188L7.301 11.4958L6.85737 12.25L1.75 9.24613L2.19362 8.49188Z" fill="#EC407A" />
|
||||
<path
|
||||
d="M11.6764 9.50422L6.56906 12.5081L6.12544 11.7538L11.2328 8.74997L11.6764 9.50422ZM2.24219 4.5421L7.23625 1.35272L7.70744 2.09035L2.71294 5.27972L2.24219 4.5421Z"
|
||||
fill="#EC407A"
|
||||
/>
|
||||
<path
|
||||
d="M11.2871 5.27928L6.293 2.08991L6.76419 1.35272L11.7582 4.5421L11.2871 5.27928ZM2.71294 8.72022L7.70744 11.9096L7.23625 12.6472L2.24219 9.45785L2.71294 8.72022Z"
|
||||
fill="#EC407A"
|
||||
/>
|
||||
<path
|
||||
d="M11.7579 9.4583L6.76385 12.6477L6.29266 11.9096L11.2867 8.72067L11.7579 9.4583ZM2.57129 9.88749L7.0176 1.61261L7.78848 2.02648L3.34173 10.3014L2.57129 9.88749Z"
|
||||
fill="#EC407A"
|
||||
/>
|
||||
<path d="M10.6577 10.3009L6.21094 2.02692L6.98138 1.61261L11.4286 9.88705L10.6577 10.3009Z" fill="#EC407A" />
|
||||
</svg>
|
||||
));
|
||||
473
packages/insomnia/src/ui/components/first-request-creation.tsx
Normal file
473
packages/insomnia/src/ui/components/first-request-creation.tsx
Normal file
@@ -0,0 +1,473 @@
|
||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { Button } from '~/basic-components/button';
|
||||
import { SelectPopover } from '~/basic-components/select-popover';
|
||||
import { getProjectRecentRequests, type RecentProjectRequest } from '~/common/project';
|
||||
import type { Request } from '~/insomnia-data';
|
||||
import { useRequestNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new';
|
||||
import { useWorkspaceNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.new';
|
||||
import { createKeybindingsHandler, useKeyboardShortcuts } from '~/ui/components/keydown-binder';
|
||||
import { ImportModal } from '~/ui/components/modals/import-modal/import-modal';
|
||||
import { SvgIcon } from '~/ui/components/svg-icon';
|
||||
import { showToast } from '~/ui/components/toast-notification';
|
||||
import { Tooltip } from '~/ui/components/tooltip';
|
||||
import { getBadgeClassName, ResourceIcon } from '~/ui/components/workspace/resource-icon';
|
||||
import { useIsLightTheme } from '~/ui/hooks/theme';
|
||||
import { setDefaultProtocol } from '~/utils/url/protocol';
|
||||
|
||||
import { Icon } from './icon';
|
||||
const CURL_COMMAND_PATTERN = /^\s*\$?\s*curl(?:\s|$)/i;
|
||||
const NOTION_MCP_SERVER_URL = 'https://mcp.notion.com/mcp';
|
||||
|
||||
const parseCurlImportError = (error: unknown) => {
|
||||
const rawMessage = error instanceof Error ? error.message : String(error);
|
||||
return rawMessage.includes('No importers found for file')
|
||||
? 'Invalid cURL request'
|
||||
: rawMessage.replace("Error invoking remote method 'parseImport': Error: ", '');
|
||||
};
|
||||
|
||||
const parseCurlRequest = async (value: string) => {
|
||||
try {
|
||||
const { data } = await window.main.parseImport({ contentStr: value }, { importerId: 'curl' });
|
||||
const importedRequest = data?.resources?.[0] as Partial<Request> | undefined;
|
||||
|
||||
if (!importedRequest?.url) {
|
||||
throw new Error('Invalid cURL request');
|
||||
}
|
||||
|
||||
return importedRequest;
|
||||
} catch (error) {
|
||||
throw new Error(parseCurlImportError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeRequestUrl = (value: string) => {
|
||||
const normalizedUrl = setDefaultProtocol(value.trim());
|
||||
|
||||
try {
|
||||
new URL(normalizedUrl);
|
||||
return normalizedUrl;
|
||||
} catch {
|
||||
throw new Error('Enter a valid endpoint URL');
|
||||
}
|
||||
};
|
||||
|
||||
interface CollectionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface QuickStartItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
badge?: string;
|
||||
onClick: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface FirstRequestCreationProps {
|
||||
greetingName: string;
|
||||
collectionItems: CollectionItem[];
|
||||
selectedCollectionId: string | null;
|
||||
onSelectedCollectionChange: (collectionId: string | null) => void;
|
||||
onCreateCollection: () => void;
|
||||
}
|
||||
|
||||
export const FirstRequestCreation = ({
|
||||
greetingName,
|
||||
collectionItems,
|
||||
selectedCollectionId,
|
||||
onSelectedCollectionChange,
|
||||
onCreateCollection,
|
||||
}: FirstRequestCreationProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { organizationId, projectId } = useParams() as {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const createRequestFetcher = useRequestNewActionFetcher();
|
||||
const createWorkspaceFetcher = useWorkspaceNewActionFetcher();
|
||||
const createWorkspaceFetcherRef = useRef(createWorkspaceFetcher);
|
||||
createWorkspaceFetcherRef.current = createWorkspaceFetcher;
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [requestInput, setRequestInput] = useState('');
|
||||
const [recentRequests, setRecentRequests] = useState<RecentProjectRequest[]>([]);
|
||||
const [curlParseError, setCurlParseError] = useState(false);
|
||||
const [selectOpen, setSelectOpen] = useState(false);
|
||||
const trimmedInput = requestInput.trim();
|
||||
const isCreatingRequest = createRequestFetcher.state !== 'idle';
|
||||
const selectedCollection = collectionItems.find(collection => collection.id === selectedCollectionId) ?? null;
|
||||
const shouldShowJumpBackIn = recentRequests.length >= 3;
|
||||
|
||||
const ensureWorkspaceId = async () => {
|
||||
if (selectedCollectionId) {
|
||||
return selectedCollectionId;
|
||||
}
|
||||
|
||||
await createWorkspaceFetcher.submit({
|
||||
organizationId,
|
||||
projectId,
|
||||
name: 'My first collection',
|
||||
scope: 'collection',
|
||||
redirectAfterCreate: false,
|
||||
});
|
||||
|
||||
const createdWorkspace = createWorkspaceFetcherRef.current.data;
|
||||
|
||||
if (
|
||||
!createdWorkspace ||
|
||||
createdWorkspace.error ||
|
||||
!('workspaceId' in createdWorkspace) ||
|
||||
!createdWorkspace.workspaceId
|
||||
) {
|
||||
showToast({
|
||||
icon: 'circle-exclamation',
|
||||
title: 'Unable to create collection, please create collection manually',
|
||||
status: 'error',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
console.log('Created workspace', createdWorkspace.workspaceId);
|
||||
return createdWorkspace.workspaceId;
|
||||
};
|
||||
|
||||
const handleInputEnter = (event: ReactKeyboardEvent<HTMLTextAreaElement> | KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
handleCreateRequest();
|
||||
};
|
||||
|
||||
const handleRequestCreateShortcut = (_event: KeyboardEvent) => {
|
||||
if (!selectedCollectionId) {
|
||||
createWorkspaceFetcher.submit({
|
||||
organizationId,
|
||||
projectId,
|
||||
name: 'My first collection',
|
||||
scope: 'collection',
|
||||
withRequest: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
createRequestFetcher.submit({
|
||||
organizationId,
|
||||
projectId,
|
||||
workspaceId: selectedCollectionId,
|
||||
parentId: selectedCollectionId,
|
||||
requestType: 'HTTP',
|
||||
});
|
||||
};
|
||||
|
||||
useKeyboardShortcuts(() => inputRef.current as HTMLTextAreaElement, {
|
||||
request_createHTTP: handleRequestCreateShortcut,
|
||||
});
|
||||
|
||||
const handleCreateRequest = async () => {
|
||||
if (!trimmedInput) {
|
||||
return;
|
||||
}
|
||||
const workspaceId = await ensureWorkspaceId();
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (CURL_COMMAND_PATTERN.test(trimmedInput)) {
|
||||
let req: Partial<Request>;
|
||||
try {
|
||||
req = await parseCurlRequest(trimmedInput);
|
||||
} catch {
|
||||
setCurlParseError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
createRequestFetcher.submit({
|
||||
organizationId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
parentId: workspaceId,
|
||||
requestType: 'From Curl',
|
||||
req,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
createRequestFetcher.submit({
|
||||
organizationId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
parentId: workspaceId,
|
||||
requestType: 'HTTP',
|
||||
req: {
|
||||
url: normalizeRequestUrl(trimmedInput),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
showToast({
|
||||
icon: 'circle-exclamation',
|
||||
title: error instanceof Error ? error.message : 'Unable to create request',
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectOpen(false);
|
||||
}, [selectedCollectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const loadRecentRequests = async () => {
|
||||
const nextRecentRequests = await getProjectRecentRequests(projectId);
|
||||
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRecentRequests(nextRecentRequests);
|
||||
};
|
||||
|
||||
loadRecentRequests();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const handleCreateNotionMcpWorkspace = () => {
|
||||
createWorkspaceFetcher.submit({
|
||||
organizationId,
|
||||
projectId,
|
||||
name: 'Notion MCP Server',
|
||||
scope: 'mcp',
|
||||
mcpServerUrl: NOTION_MCP_SERVER_URL,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreatePokemonRequest = async () => {
|
||||
const workspaceId = await ensureWorkspaceId();
|
||||
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
createRequestFetcher.submit({
|
||||
organizationId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
parentId: workspaceId,
|
||||
requestType: 'HTTP',
|
||||
req: {
|
||||
url: 'https://pokeapi.co/api/v2/pokemon/ditto',
|
||||
name: 'List a pokemon',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateGithubLookupRequest = async () => {
|
||||
const workspaceId = await ensureWorkspaceId();
|
||||
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const graphqlQuery =
|
||||
'query { viewer { repositories(first: 100, privacy: PUBLIC, affiliations: [OWNER]) { nodes { name description url stargazerCount } } } }';
|
||||
|
||||
const githubGraphqlLookupCurl = `curl --request POST \
|
||||
--url https://api.github.com/graphql \
|
||||
--header 'Authorization: Bearer replace with your own token' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header 'User-Agent: insomnia/12.5.1-alpha.0' \
|
||||
--data '${JSON.stringify({ query: graphqlQuery })}'`;
|
||||
try {
|
||||
const req = await parseCurlRequest(githubGraphqlLookupCurl);
|
||||
createRequestFetcher.submit({
|
||||
organizationId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
parentId: workspaceId,
|
||||
requestType: 'GraphQL',
|
||||
req: {
|
||||
...req,
|
||||
name: 'Lookup GitHub repository',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
showToast({
|
||||
icon: 'circle-exclamation',
|
||||
title: error instanceof Error ? error.message : 'Unable to create GitHub lookup request',
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const quickStartItems: QuickStartItem[] = [
|
||||
{
|
||||
id: 'mcp-server',
|
||||
label: 'Notion MCP Server',
|
||||
icon: <Icon icon={['fac', 'mcp'] as unknown as IconProp} />,
|
||||
onClick: handleCreateNotionMcpWorkspace,
|
||||
},
|
||||
{
|
||||
id: 'pokemon',
|
||||
label: 'List a pokemon',
|
||||
icon: <span className={getBadgeClassName('GET')}>GET</span>,
|
||||
badge: 'GET',
|
||||
onClick: handleCreatePokemonRequest,
|
||||
},
|
||||
{
|
||||
id: 'github-lookup',
|
||||
label: 'Lookup GitHub repository',
|
||||
icon: <SvgIcon icon="graphql" />,
|
||||
onClick: handleCreateGithubLookupRequest,
|
||||
},
|
||||
];
|
||||
|
||||
const isLightTheme = useIsLightTheme();
|
||||
const wrapperClassName = isLightTheme
|
||||
? 'w-full rounded-sm bg-[radial-gradient(95.72%_95.72%_at_-0.32%_2.6%,#999999_0%,#DDDDDD_100%),radial-gradient(100%_100.41%_at_100%_99.92%,#999999_0%,#DDDDDD_100%)] p-px'
|
||||
: 'w-full rounded-sm bg-[radial-gradient(100%_100.41%_at_100%_99.92%,#4C4C4C_0%,rgba(3,3,3,0)_100%),radial-gradient(95.72%_95.72%_at_-0.32%_2.6%,#4C4C4C_0%,rgba(3,3,3,0)_100%)] p-px';
|
||||
const wrapperSurfaceClassName = isLightTheme
|
||||
? 'flex w-full flex-col items-center rounded-[inherit] bg-[#FFFFFF] bg-linear-[360deg,rgba(27,27,27,0)_27.2%,rgba(96,48,191,0.2)_100%] px-6 pt-6 pb-5'
|
||||
: 'flex w-full flex-col items-center rounded-[inherit] bg-[#1B1B1B] bg-linear-[360deg,rgba(27,27,27,0)_27.2%,rgba(165,151,248,0.2)_100%] px-6 pt-6 pb-5';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={wrapperClassName}>
|
||||
<div className={wrapperSurfaceClassName}>
|
||||
<h2 className="text-center text-2xl leading-none font-semibold">
|
||||
{shouldShowJumpBackIn ? `Welcome back, ${greetingName}!` : `Welcome, ${greetingName}!`}
|
||||
</h2>
|
||||
<p className="mt-2.5 text-center text-sm">
|
||||
{shouldShowJumpBackIn
|
||||
? `Today is a new day, we’re rooting for you!`
|
||||
: `We have a sneaking suspicion that you came here to send a request, so let’s get started!`}
|
||||
</p>
|
||||
<div className="mt-8 w-[50%] min-w-100">
|
||||
<div className="flex aspect-540/127 flex-col overflow-hidden rounded-lg border border-[#3F3F46] bg-(--color-bg) shadow-[0_0_0_4px_#0044F433]">
|
||||
<div className="flex-1 px-4 pt-3 pb-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
aria-label="Request endpoint or cURL input"
|
||||
className="h-full w-full flex-1 resize-none text-xs"
|
||||
placeholder="Enter an endpoint URL or paste cURL, or ⌘N for a new blank request"
|
||||
value={requestInput}
|
||||
onChange={event => {
|
||||
setCurlParseError(false);
|
||||
setRequestInput(event.target.value);
|
||||
}}
|
||||
onKeyDown={createKeybindingsHandler({
|
||||
Enter: event => handleInputEnter(event),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 p-2">
|
||||
<Tooltip message="Upload Postman, OpenAPI, etc.">
|
||||
<Button
|
||||
aria-label="Attach content"
|
||||
className="w-10 rounded-full px-0"
|
||||
size="lg"
|
||||
variant="text"
|
||||
icon={<Icon className="text-lg" icon="paperclip" />}
|
||||
onPress={() => setIsImportModalOpen(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-3">
|
||||
<SelectPopover
|
||||
isOpen={selectOpen}
|
||||
onOpenChange={isOpen => setSelectOpen(isOpen)}
|
||||
ariaLabel="Select target collection"
|
||||
items={collectionItems}
|
||||
selectedKey={selectedCollectionId}
|
||||
onSelectionChange={key => onSelectedCollectionChange(key ? String(key) : null)}
|
||||
title="Where should we put your request?"
|
||||
emptyState="You have no collections, so a new one will be created for you by default."
|
||||
footer={
|
||||
<Button onPress={onCreateCollection} size="sm">
|
||||
New Collection
|
||||
</Button>
|
||||
}
|
||||
triggerClassName="h-8 rounded-xs px-3 text-sm"
|
||||
popoverClassName="w-[240px]"
|
||||
dialogClassName="w-[240px]"
|
||||
renderTrigger={selectedItem => (
|
||||
<>
|
||||
<span className="truncate">{selectedItem?.label ?? 'New collection'}</span>
|
||||
<Icon icon="chevron-down" className="w-3 shrink-0" />
|
||||
</>
|
||||
)}
|
||||
renderItem={(item, isSelected) => (
|
||||
<>
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{isSelected ? <Icon icon="check" className="text-(--color-success)" /> : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Create request"
|
||||
primary
|
||||
size="md"
|
||||
isDisabled={!trimmedInput || isCreatingRequest}
|
||||
onPress={() => handleCreateRequest()}
|
||||
>
|
||||
<span>Create</span>
|
||||
<span aria-hidden="true" className="text-sm leading-none">
|
||||
↵
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{curlParseError && (
|
||||
<div className="mt-2 text-xs text-[#FF5631]">Invalid cURL. Verify your input and try again.</div>
|
||||
)}
|
||||
<div className="my-6 px-4">
|
||||
<p className="text-xs font-semibold text-(--hl)">
|
||||
{shouldShowJumpBackIn ? 'Jump back in' : 'Not sure where to start?'}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{shouldShowJumpBackIn
|
||||
? recentRequests.map(recentRequest => (
|
||||
<Button
|
||||
key={recentRequest.request._id}
|
||||
variant="outlined"
|
||||
size="md"
|
||||
onPress={() => {
|
||||
navigate(
|
||||
`/organization/${organizationId}/project/${projectId}/workspace/${recentRequest.workspaceId}/debug/request/${recentRequest.request._id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ResourceIcon resource={recentRequest.request} />
|
||||
<span className="max-w-[18rem] truncate">{recentRequest.request.name}</span>
|
||||
</Button>
|
||||
))
|
||||
: quickStartItems.map(item => (
|
||||
<Button key={item.id} variant="outlined" size="md" onPress={item.onClick}>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isImportModalOpen && (
|
||||
<ImportModal
|
||||
onHide={() => setIsImportModalOpen(false)}
|
||||
from={{ type: 'file' }}
|
||||
workspaceName={selectedCollection?.label}
|
||||
organizationId={organizationId}
|
||||
defaultProjectId={projectId}
|
||||
defaultWorkspaceId={selectedCollectionId ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { StorageRules } from 'insomnia-api';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Collection,
|
||||
@@ -55,11 +55,15 @@ export const NewWorkspaceModal = ({
|
||||
scope,
|
||||
storageRules,
|
||||
sourceApiSpec,
|
||||
onCreateWorkspace,
|
||||
redirectAfterCreate = true,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
project: Project;
|
||||
storageRules: StorageRules;
|
||||
onCreateWorkspace?: (workspaceId: string) => void;
|
||||
redirectAfterCreate?: boolean;
|
||||
scope: WorkspaceScope;
|
||||
sourceApiSpec?: ApiSpec;
|
||||
}) => {
|
||||
@@ -99,6 +103,7 @@ export const NewWorkspaceModal = ({
|
||||
});
|
||||
|
||||
const createNewWorkspaceFetcher = useWorkspaceNewActionFetcher();
|
||||
const prevCreateNewWorkspaceFetcherState = useRef(createNewWorkspaceFetcher.state);
|
||||
|
||||
const [progressMessage, setProgressMessage] = useState(0);
|
||||
const progressMessages = ['Creating...', 'Working...', 'Building...', 'Still going...', 'Almost there...'];
|
||||
@@ -119,15 +124,25 @@ export const NewWorkspaceModal = ({
|
||||
}, [createNewWorkspaceFetcher.state, scope, progressMessages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
scope === models.workspace.WorkspaceScopeKeys.mockServer &&
|
||||
createNewWorkspaceFetcher.state === 'idle' &&
|
||||
createNewWorkspaceFetcher.data &&
|
||||
!createNewWorkspaceFetcher.data.error
|
||||
) {
|
||||
onOpenChange(false);
|
||||
const didCreateWorkspace =
|
||||
prevCreateNewWorkspaceFetcherState.current !== 'idle' && createNewWorkspaceFetcher.state === 'idle';
|
||||
|
||||
prevCreateNewWorkspaceFetcherState.current = createNewWorkspaceFetcher.state;
|
||||
|
||||
if (!didCreateWorkspace || createNewWorkspaceFetcher.data?.error) {
|
||||
return;
|
||||
}
|
||||
}, [createNewWorkspaceFetcher.state, createNewWorkspaceFetcher.data, scope, onOpenChange]);
|
||||
|
||||
if (
|
||||
createNewWorkspaceFetcher.data &&
|
||||
'workspaceId' in createNewWorkspaceFetcher.data &&
|
||||
createNewWorkspaceFetcher.data.workspaceId
|
||||
) {
|
||||
onCreateWorkspace?.(createNewWorkspaceFetcher.data.workspaceId);
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
}, [createNewWorkspaceFetcher.data, createNewWorkspaceFetcher.state, onCreateWorkspace, onOpenChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -156,6 +171,7 @@ export const NewWorkspaceModal = ({
|
||||
...(sourceApiSpec?.contents && {
|
||||
apiSpecContents: sourceApiSpec.contents,
|
||||
}),
|
||||
redirectAfterCreate,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
|
||||
import { useParams } from 'react-router';
|
||||
import * as reactUse from 'react-use';
|
||||
|
||||
import { recordProjectRecentRequest } from '~/common/project';
|
||||
import type { GrpcRequest, GrpcRequestHeader, RequestGroup } from '~/insomnia-data';
|
||||
import { models, services } from '~/insomnia-data';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
@@ -66,7 +67,11 @@ export const GrpcRequestPane: FunctionComponent<Props> = ({ grpcState, setGrpcSt
|
||||
const { requestMessages, running, methods } = grpcState;
|
||||
const editorRef = useRef<CodeEditorHandle>(null);
|
||||
const gitVersion = useGitVCSVersion();
|
||||
const { workspaceId, requestId } = useParams() as { workspaceId: string; requestId: string };
|
||||
const { projectId, workspaceId, requestId } = useParams() as {
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
};
|
||||
const patchRequest = useRequestPatcher();
|
||||
const { updateTabById } = useInsomniaTabContext();
|
||||
|
||||
@@ -160,6 +165,12 @@ export const GrpcRequestPane: FunctionComponent<Props> = ({ grpcState, setGrpcSt
|
||||
|
||||
updateTabById?.(requestId, { temporary: false });
|
||||
|
||||
recordProjectRecentRequest({
|
||||
projectId,
|
||||
requestId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
window.main.grpc.start({
|
||||
request,
|
||||
rejectUnauthorized: settings.validateSSL,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useParams, useSearchParams } from 'react-router';
|
||||
import * as reactUse from 'react-use';
|
||||
|
||||
import { SECURITY_SETTINGS_PATH_LABEL } from '~/common/misc';
|
||||
import { recordProjectRecentRequest } from '~/common/project';
|
||||
import type { Request, RequestGroup } from '~/insomnia-data';
|
||||
import { models, services } from '~/insomnia-data';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
@@ -217,12 +218,24 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(
|
||||
cookieJar: rendered.workspaceCookieJar,
|
||||
suppressUserAgent: rendered.suppressUserAgent,
|
||||
});
|
||||
rendered &&
|
||||
recordProjectRecentRequest({
|
||||
projectId,
|
||||
requestId,
|
||||
workspaceId: activeWorkspace._id,
|
||||
});
|
||||
};
|
||||
startListening();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
recordProjectRecentRequest({
|
||||
projectId,
|
||||
requestId,
|
||||
workspaceId: activeWorkspace._id,
|
||||
});
|
||||
|
||||
send({
|
||||
requestId,
|
||||
workspaceId: activeWorkspace._id,
|
||||
@@ -380,7 +393,9 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(
|
||||
icon="code"
|
||||
label="Generate Client Code"
|
||||
onClick={() => {
|
||||
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.requestSendMenuGenerateCodeClicked });
|
||||
window.main.trackAnalyticsEvent({
|
||||
event: AnalyticsEvent.requestSendMenuGenerateCodeClicked,
|
||||
});
|
||||
showModal(GenerateCodeModal, { request: activeRequest });
|
||||
}}
|
||||
/>
|
||||
@@ -392,7 +407,9 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(
|
||||
icon="clock-o"
|
||||
label="Send After Delay"
|
||||
onClick={() => {
|
||||
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.requestSendMenuSendAfterDelayClicked });
|
||||
window.main.trackAnalyticsEvent({
|
||||
event: AnalyticsEvent.requestSendMenuSendAfterDelayClicked,
|
||||
});
|
||||
showModal(PromptModal, {
|
||||
inputType: 'decimal',
|
||||
title: 'Send After Delay',
|
||||
|
||||
@@ -30,6 +30,7 @@ import { SvgIcnGitBranch } from './assets/svgr/IcnGitBranch';
|
||||
import { SvgIcnGithubLogo } from './assets/svgr/IcnGithubLogo';
|
||||
import { SvgIcnGitlabLogo } from './assets/svgr/IcnGitlabLogo';
|
||||
import { SvgIcnGlobe } from './assets/svgr/IcnGlobe';
|
||||
import { SvgIcnGraphql } from './assets/svgr/IcnGraphql';
|
||||
import { SvgIcnGui } from './assets/svgr/IcnGui';
|
||||
import { SvgIcnHashiCorp } from './assets/svgr/IcnHashiCorp';
|
||||
import { SvgIcnHeart } from './assets/svgr/IcnHeart';
|
||||
@@ -139,6 +140,7 @@ export const IconEnum = {
|
||||
gcpLogo: 'gcp-logo',
|
||||
azureLogo: 'azure-logo',
|
||||
hashiCorp: 'hashicorp',
|
||||
graphql: 'graphql',
|
||||
/** Blank icon */
|
||||
empty: 'empty',
|
||||
} as const;
|
||||
@@ -205,6 +207,7 @@ const icons: Record<IconId, [ThemeKeys, NamedExoticComponent<SVGProps<SVGSVGElem
|
||||
[IconEnum.gcpLogo]: [ThemeEnum.default, SvgIcnGCPLogo],
|
||||
[IconEnum.azureLogo]: [ThemeEnum.default, SvgIcnAzureLogo],
|
||||
[IconEnum.hashiCorp]: [ThemeEnum.default, SvgIcnHashiCorp],
|
||||
[IconEnum.graphql]: [ThemeEnum.default, SvgIcnGraphql],
|
||||
};
|
||||
|
||||
export type IconId = ValueOf<typeof IconEnum>;
|
||||
|
||||
@@ -12,10 +12,29 @@ interface Props {
|
||||
override?: string | null;
|
||||
fullNames?: boolean;
|
||||
}
|
||||
|
||||
function removeVowels(str: string) {
|
||||
return str.replace(/[aeiouyAEIOUY]/g, '');
|
||||
}
|
||||
|
||||
const requestBadgeClassNames: Record<string, string> = {
|
||||
GET: 'bg-[rgba(var(--color-surprise-rgb),0.5)] text-(--color-font-surprise)',
|
||||
POST: 'bg-[rgba(var(--color-success-rgb),0.5)] text-(--color-font-success)',
|
||||
HEAD: 'bg-[rgba(var(--color-info-rgb),0.5)] text-(--color-font-info)',
|
||||
OPTIONS: 'bg-[rgba(var(--color-info-rgb),0.5)] text-(--color-font-info)',
|
||||
DELETE: 'bg-[rgba(var(--color-danger-rgb),0.5)] text-(--color-font-danger)',
|
||||
PUT: 'bg-[rgba(var(--color-warning-rgb),0.5)] text-(--color-font-warning)',
|
||||
PATCH: 'bg-[rgba(var(--color-notice-rgb),0.5)] text-(--color-font-notice)',
|
||||
WS: 'bg-[rgba(var(--color-notice-rgb),0.5)] text-(--color-font-notice)',
|
||||
IO: 'bg-[rgba(var(--color-notice-rgb),0.5)] text-(--color-font-notice)',
|
||||
gRPC: 'bg-[rgba(var(--color-info-rgb),0.5)] text-(--color-font-info)',
|
||||
MCP: 'bg-[rgba(var(--color-info-rgb),0.5)] text-(--color-font-info)',
|
||||
};
|
||||
|
||||
export const getRequestBadgeClassName = (badge: string) => {
|
||||
return requestBadgeClassNames[badge] || 'bg-(--hl-md) text-(--color-font)';
|
||||
};
|
||||
|
||||
export const getMethodShortHand = (doc: Request) => {
|
||||
if (isEventStreamRequest(doc)) {
|
||||
return 'SSE';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { recordProjectRecentRequest } from '~/common/project';
|
||||
import type { SocketIORequest, WebSocketRequest } from '~/insomnia-data';
|
||||
import { services } from '~/insomnia-data';
|
||||
import {
|
||||
@@ -118,8 +119,15 @@ export const WebSocketActionBar = forwardRef<WebSocketActionBarHandle, ActionBar
|
||||
return;
|
||||
}
|
||||
const connectParams = await generateConnectParams();
|
||||
connectParams && connect(connectParams);
|
||||
}, [connect, generateConnectParams, isOpen, request._id, request.type, updateTabById]);
|
||||
if (connectParams) {
|
||||
recordProjectRecentRequest({
|
||||
projectId,
|
||||
requestId: request._id,
|
||||
workspaceId,
|
||||
});
|
||||
connect(connectParams);
|
||||
}
|
||||
}, [connect, generateConnectParams, isOpen, projectId, request._id, request.type, updateTabById, workspaceId]);
|
||||
|
||||
const setUrl = useCallback(
|
||||
(url: string) => {
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
import { Icon } from '~/basic-components/icon';
|
||||
import { models } from '~/insomnia-data';
|
||||
import { getMethodShortHand } from '~/ui/components/tags/method-tag';
|
||||
import { getMethodShortHand, getRequestBadgeClassName } from '~/ui/components/tags/method-tag';
|
||||
|
||||
export const getBadgeClassName = (colorKey: string) =>
|
||||
`flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) text-[0.65rem] ${getRequestBadgeClassName(colorKey)}`;
|
||||
|
||||
export function ResourceIcon({ resource }: { resource: any }) {
|
||||
const isProject = models.project.isProject(resource);
|
||||
@@ -36,37 +39,11 @@ export function ResourceIcon({ resource }: { resource: any }) {
|
||||
return (
|
||||
<>
|
||||
{models.request.isRequest(resource) && (
|
||||
<span
|
||||
className={`flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) text-[0.65rem] ${
|
||||
{
|
||||
GET: 'bg-[rgba(var(--color-surprise-rgb),0.5)] text-(--color-font-surprise)',
|
||||
POST: 'bg-[rgba(var(--color-success-rgb),0.5)] text-(--color-font-success)',
|
||||
HEAD: 'bg-[rgba(var(--color-info-rgb),0.5)] text-(--color-font-info)',
|
||||
OPTIONS: 'bg-[rgba(var(--color-info-rgb),0.5)] text-(--color-font-info)',
|
||||
DELETE: 'bg-[rgba(var(--color-danger-rgb),0.5)] text-(--color-font-danger)',
|
||||
PUT: 'bg-[rgba(var(--color-warning-rgb),0.5)] text-(--color-font-warning)',
|
||||
PATCH: 'bg-[rgba(var(--color-notice-rgb),0.5)] text-(--color-font-notice)',
|
||||
}[resource.method] || 'bg-(--hl-md) text-(--color-font)'
|
||||
}`}
|
||||
>
|
||||
{getMethodShortHand(resource)}
|
||||
</span>
|
||||
)}
|
||||
{models.webSocketRequest.isWebSocketRequest(resource) && (
|
||||
<span className="flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) bg-[rgba(var(--color-notice-rgb),0.5)] text-[0.65rem] text-(--color-font-notice)">
|
||||
WS
|
||||
</span>
|
||||
)}
|
||||
{models.socketIORequest.isSocketIORequest(resource) && (
|
||||
<span className="flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) bg-[rgba(var(--color-notice-rgb),0.5)] text-[0.65rem] text-(--color-font-notice)">
|
||||
IO
|
||||
</span>
|
||||
)}
|
||||
{models.grpcRequest.isGrpcRequest(resource) && (
|
||||
<span className="flex w-10 shrink-0 items-center justify-center rounded-xs border border-solid border-(--hl-sm) bg-[rgba(var(--color-info-rgb),0.5)] text-[0.65rem] text-(--color-font-info)">
|
||||
gRPC
|
||||
</span>
|
||||
<span className={getBadgeClassName(resource.method)}>{getMethodShortHand(resource)}</span>
|
||||
)}
|
||||
{models.webSocketRequest.isWebSocketRequest(resource) && <span className={getBadgeClassName('WS')}>WS</span>}
|
||||
{models.socketIORequest.isSocketIORequest(resource) && <span className={getBadgeClassName('IO')}>IO</span>}
|
||||
{models.grpcRequest.isGrpcRequest(resource) && <span className={getBadgeClassName('gRPC')}>gRPC</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user