Improve import UX (#5869)

* add import routes and modal

* fix paths

* fix paths

* save

* remove import.ts

* ui

* save

* fix tests

* fix tests

* fix tests

* remove outdated test

* update import tests

* lint and ts fixes

* add section title and fix some style issues

* close modals on import success

* error handling for empty content

* fix api spec imports

* Simplify importResources function

Co-authored-by: Filipe Freire <filipe.r.freire@gmail.com>

* cleanup

* settings import button styles

* remove comment

* update tests

* comment for app-command imports

* fix test lint error

* update comments

* comment out app/import for now

---------

Co-authored-by: Filipe Freire <filipe.r.freire@gmail.com>
This commit is contained in:
James GatzandFilipe Freire authored and GitHub committed 2023-03-31 10:53:22 +00:00
1 parent d3b917a15f
commit 29acc92559
31 files changed
+3411 -3006

No files matched your search

@@ -8,8 +8,12 @@ test.describe('Cookie editor', async () => {
await page.getByRole('button', { name: 'Create' }).click();
const text = await loadFixture('simple.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('text=Collectionsimplejust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
});
test('create and send a cookie', async ({ page }) => {
@@ -10,8 +10,12 @@ test.describe('Debug-Sidebar', async () => {
await page.getByRole('button', { name: 'Create' }).click();
const text = await loadFixture('simple.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('text=Collectionsimplejust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
});
test.describe('Interact with sidebar', async () => {
@@ -59,9 +63,9 @@ test.describe('Debug-Sidebar', async () => {
});
test('Open properties of the collection', async ({ page }) => {
await page.getByRole('button', { name: 'simple' }).click();
await page.getByRole('menuitem', { name: 'Collection Settings' }).click();
await expect(page.locator('.app')).toContainText('Collection Settings wrk');
await page.getByRole('button', { name: 'Untitled' }).click();
await page.getByRole('menuitem', { name: 'Document Settings' }).click();
await page.getByText('Document Settings').click();
});
test('Filter by request name', async ({ page }) => {
@@ -17,9 +17,10 @@ test.describe('Design interactions', async () => {
await page.getByRole('button', { name: 'Create' }).click();
const text = await loadFixture('openapi3.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('div[role="dialog"] >> text=Design Document');
await page.click('text=DocumentSmoke Test API server 1.0.0v1.0.0OpenAPI 3.0.0just now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
// Renders the spec code
const codeEditor = page.locator('.pane-one');
@@ -8,8 +8,12 @@ test.describe('Environment Editor', async () => {
await page.getByRole('button', { name: 'Create' }).click();
const text = await loadFixture('environments.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('text=Collectionenvironmentsjust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
});
test('create a new environment', async ({ page }) => {
@@ -17,8 +17,12 @@ test.describe('gRPC interactions', () => {
const text = await loadFixture('grpc.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('text=CollectionPreRelease gRPCjust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
statusTag = page.locator('[data-testid="response-status-tag"]:visible');
responseBody = page.locator('[data-testid="response-pane"] >> [data-testid="CodeEditor"]:visible', {
has: page.locator('.CodeMirror-activeline'),
@@ -21,8 +21,12 @@ test('Check filter responses by environment preference', async ({ app, page }) =
await page.getByRole('button', { name: 'Create' }).click();
const text = await loadFixture('simple.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('text=Collectionsimplejust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
// Send a request
await page.getByRole('button', { name: 'example http' }).click();
@@ -16,8 +16,12 @@ test('can send requests', async ({ app, page }) => {
const text = await loadFixture('smoke-test-collection.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('text=CollectionSmoke testsjust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
await page.getByRole('button', { name: 'send JSON request' }).click();
await page.click('text=http://127.0.0.1:4010/pets/1Send >> button');
@@ -68,8 +72,12 @@ test('can cancel requests', async ({ app, page }) => {
const text = await loadFixture('smoke-test-collection.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('text=CollectionSmoke testsjust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
await page.getByRole('button', { name: 'delayed request' }).click();
await page.click('text=http://127.0.0.1:4010/delay/seconds/20Send >> button');
@@ -15,9 +15,12 @@ test('can render schema and send GraphQL requests', async ({ app, page }) => {
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
// Import from clipboard
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
// Open the new collection workspace
await page.click('text=CollectionSmoke GraphQLjust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
// Open the graphql request
await page.getByRole('button', { name: 'GraphQL request' }).click();
// Assert the schema is fetched after switching to GraphQL request
@@ -52,8 +55,12 @@ test('can send GraphQL requests after editing and prettifying query', async ({ a
const text = await loadFixture('graphql.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('text=CollectionSmoke GraphQLjust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
await page.getByRole('button', { name: 'GraphQL request' }).click();
// Edit and prettify query
@@ -16,8 +16,13 @@ test('can send gRPC requests with reflection', async ({ app, page }) => {
const text = await loadFixture('grpc.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('text=CollectionPreRelease gRPCjust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
await page.getByRole('button', { name: 'Route Guide Example' }).click();
await page.getByRole('button', { name: 'UnaryWithOutProtoFile' }).click();
@@ -23,8 +23,12 @@ test('can make oauth2 requests', async ({ app, page }) => {
const text = await loadFixture('oauth.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.locator('text=CollectionOauth Testingjust now').click();
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
// Authorization code
await projectView.getByRole('button', { name: 'Authorization Code' }).click();
@@ -16,8 +16,12 @@ test('can make websocket connection', async ({ app, page }) => {
const text = await loadFixture('websockets.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), text);
await page.getByRole('menuitem', { name: 'Clipboard' }).click();
await page.click('text=CollectionWebSocketsjust now');
await page.getByRole('menuitem', { name: 'Import' }).click();
await page.getByText('Clipboard').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('button', { name: 'Import' }).click();
await page.getByRole('link', { name: 'Debug' }).click();
await page.getByRole('button', { name: 'localhost:4010' }).click();
await expect(page.locator('.app')).toContainText('ws://localhost:4010');
+1736 -1841
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -186,13 +186,13 @@
"objectpath": "^2.0.0",
"openapi-types": "^7.0.1",
"react": "^18.2.0",
"react-aria": "^3.19.0",
"react-aria": "^3.23.1",
"react-dnd": "^7.4.5",
"react-dnd-html5-backend": "^7.4.4",
"react-dom": "^18.2.0",
"react-redux": "^7.2.6",
"react-router-dom": "^6.4.2",
"react-stately": "^3.17.0",
"react-stately": "^3.21.0",
"react-use": "^17.4.0",
"redux": "^4.1.2",
"redux-mock-store": "^1.5.4",
@@ -1,31 +0,0 @@
import { describe, expect, it, jest } from '@jest/globals';
import * as modals from '../../ui/components/modals';
import { askToImportIntoWorkspace, ForceToWorkspace } from '../import';
jest.mock('../../ui/components/modals');
describe('askToImportIntoWorkspace', () => {
it('should return null if no active workspace', () => {
const func = askToImportIntoWorkspace({ workspaceId: undefined, forceToWorkspace: ForceToWorkspace.new });
expect(func()).toBeNull();
});
it('should return null if forcing to a new workspace', () => {
const func = askToImportIntoWorkspace({ workspaceId: 'id', forceToWorkspace: ForceToWorkspace.new });
expect(func()).toBeNull();
});
it('should return id if forcing to a current workspace', () => {
const currentWorkspaceId = 'currentId';
const func = askToImportIntoWorkspace({ workspaceId: currentWorkspaceId, forceToWorkspace: ForceToWorkspace.current });
expect(func()).toBe(currentWorkspaceId);
});
it('should prompt the user if not forcing', () => {
const currentWorkspaceId = 'current';
const func = askToImportIntoWorkspace({ workspaceId: currentWorkspaceId });
func();
expect(modals.showModal).toHaveBeenCalledTimes(1);
});
});
@@ -3,7 +3,7 @@ import fs from 'fs';
import path from 'path';
import { globalBeforeEach } from '../../__jest__/before-each';
import { apiSpec, request, requestGroup, workspace } from '../../models';
import { request, requestGroup, workspace } from '../../models';
import { DEFAULT_PROJECT_ID } from '../../models/project';
import * as importUtil from '../import';
@@ -37,18 +37,18 @@ describe('importRaw()', () => {
it('should import a curl request to a new workspace', async () => {
const fixturePath = path.join(__dirname, '..', '__fixtures__', 'curl', 'complex-input.sh');
const rawFixture = fs.readFileSync(fixturePath, 'utf8').toString();
const content = fs.readFileSync(fixturePath, 'utf8').toString();
const importConfig: importUtil.ImportRawConfig = {
getWorkspaceId: () => null,
getProjectId: async () => DEFAULT_PROJECT_ID,
getWorkspaceScope: () => 'design',
};
const scanResult = await importUtil.scanResources({
content,
});
const { source, error } = await importUtil.importRaw(
rawFixture,
importConfig
);
expect(scanResult.type.id).toBe('curl');
expect(scanResult.errors.length).toBe(0);
await importUtil.importResources({
projectId: DEFAULT_PROJECT_ID,
});
const workspacesCount = await workspace.count();
const projectWorkspaces = await workspace.findByParentId(
@@ -57,9 +57,6 @@ describe('importRaw()', () => {
const curlRequests = await request.findByParentId(projectWorkspaces[0]._id);
expect(source).toBe('curl');
expect(error).toBe(null);
expect(workspacesCount).toBe(1);
expect(curlRequests[0]).toMatchObject({
@@ -71,30 +68,27 @@ describe('importRaw()', () => {
it('should import a curl request to an existing workspace', async () => {
const fixturePath = path.join(__dirname, '..', '__fixtures__', 'curl', 'complex-input.sh');
const rawFixture = fs.readFileSync(fixturePath, 'utf8').toString();
const content = fs.readFileSync(fixturePath, 'utf8').toString();
const existingWorkspace = await workspace.create();
const importConfig: importUtil.ImportRawConfig = {
getWorkspaceId: () => existingWorkspace._id,
getProjectId: async () => DEFAULT_PROJECT_ID,
getWorkspaceScope: () => 'design',
};
const scanResult = await importUtil.scanResources({
content,
});
const { source, error } = await importUtil.importRaw(
rawFixture,
importConfig
);
expect(scanResult.type?.id).toBe('curl');
expect(scanResult.errors.length).toBe(0);
await importUtil.importResources({
workspaceId: existingWorkspace._id,
projectId: existingWorkspace.parentId,
});
const workspacesCount = await workspace.count();
expect(workspacesCount).toBe(1);
const curlRequests = await request.findByParentId(existingWorkspace._id);
expect(source).toBe('curl');
expect(error).toBe(null);
expect(workspacesCount).toBe(1);
expect(curlRequests[0]).toMatchObject({
body: {
'text': '{\"email_id\": \"tem_123\"}',
@@ -104,18 +98,18 @@ describe('importRaw()', () => {
it('should import a postman collection to a new workspace', async () => {
const fixturePath = path.join(__dirname, '..', '__fixtures__', 'postman', 'aws-signature-auth-v2_0-input.json');
const rawFixture = fs.readFileSync(fixturePath, 'utf8').toString();
const content = fs.readFileSync(fixturePath, 'utf8').toString();
const importConfig: importUtil.ImportRawConfig = {
getWorkspaceId: () => null,
getProjectId: async () => DEFAULT_PROJECT_ID,
getWorkspaceScope: () => 'design',
};
const scanResult = await importUtil.scanResources({
content,
});
const { source, error } = await importUtil.importRaw(
rawFixture,
importConfig
);
expect(scanResult.type.id).toBe('postman');
expect(scanResult.errors.length).toBe(0);
await importUtil.importResources({
projectId: DEFAULT_PROJECT_ID,
});
const workspacesCount = await workspace.count();
const projectWorkspaces = await workspace.findByParentId(
@@ -125,9 +119,6 @@ describe('importRaw()', () => {
const requestGroups = await requestGroup.findByParentId(projectWorkspaces[0]._id);
const requests = await request.findByParentId(requestGroups[0]._id);
expect(source).toBe('postman');
expect(error).toBe(null);
expect(workspacesCount).toBe(1);
expect(requests[0]).toMatchObject({
@@ -137,29 +128,27 @@ describe('importRaw()', () => {
it('should import a postman collection to an existing workspace', async () => {
const fixturePath = path.join(__dirname, '..', '__fixtures__', 'postman', 'aws-signature-auth-v2_0-input.json');
const rawFixture = fs.readFileSync(fixturePath, 'utf8').toString();
const content = fs.readFileSync(fixturePath, 'utf8').toString();
const existingWorkspace = await workspace.create();
const importConfig: importUtil.ImportRawConfig = {
getWorkspaceId: () => existingWorkspace._id,
getProjectId: async () => DEFAULT_PROJECT_ID,
getWorkspaceScope: () => 'design',
};
const scanResult = await importUtil.scanResources({
content,
});
const { source, error } = await importUtil.importRaw(
rawFixture,
importConfig
);
expect(scanResult.type?.id).toBe('postman');
expect(scanResult.errors.length).toBe(0);
await importUtil.importResources({
workspaceId: existingWorkspace._id,
projectId: existingWorkspace.parentId,
});
const workspacesCount = await workspace.count();
const requestGroups = await requestGroup.findByParentId(existingWorkspace._id);
const requests = await request.findByParentId(requestGroups[0]._id);
expect(source).toBe('postman');
expect(error).toBe(null);
expect(workspacesCount).toBe(1);
expect(requests[0]).toMatchObject({
@@ -169,23 +158,21 @@ describe('importRaw()', () => {
it('should import an openapi collection to an existing workspace with scope design', async () => {
const fixturePath = path.join(__dirname, '..', '__fixtures__', 'openapi', 'endpoint-security-input.yaml');
const rawFixture = fs.readFileSync(fixturePath, 'utf8').toString();
const content = fs.readFileSync(fixturePath, 'utf8').toString();
const existingWorkspace = await workspace.create({ scope: 'design' });
const importConfig: importUtil.ImportRawConfig = {
getWorkspaceId: () => existingWorkspace._id,
getProjectId: async () => DEFAULT_PROJECT_ID,
getWorkspaceScope: () => 'design',
};
const scanResult = await importUtil.scanResources({
content,
});
const { source, error } = await importUtil.importRaw(
rawFixture,
importConfig
);
expect(scanResult.type?.id).toBe('openapi3');
expect(scanResult.errors.length).toBe(0);
expect(source).toBe('openapi3');
expect(error).toBe(null);
await importUtil.importResources({
workspaceId: existingWorkspace._id,
projectId: existingWorkspace.parentId,
});
const workspacesCount = await workspace.count();
@@ -193,10 +180,6 @@ describe('importRaw()', () => {
const requests = await request.findByParentId(existingWorkspace._id);
expect(requests.length).toBe(12);
const createdApiSpec = await apiSpec.getByParentId(existingWorkspace._id);
expect(createdApiSpec?.contents).toContain('openapi: \'3.0.2\'');
});
});
+228 -403
View File
@@ -1,54 +1,43 @@
import fs from 'fs';
import { readFile } from 'fs/promises';
import type { ApiSpec } from '../models/api-spec';
import type { BaseModel } from '../models/index';
import { ApiSpec, isApiSpec } from '../models/api-spec';
import { CookieJar, isCookieJar } from '../models/cookie-jar';
import { BaseEnvironment, isEnvironment } from '../models/environment';
import { GrpcRequest, isGrpcRequest } from '../models/grpc-request';
import { BaseModel, getModel } from '../models/index';
import * as models from '../models/index';
import { Project } from '../models/project';
import { isRequest } from '../models/request';
import { isWorkspace, Workspace, WorkspaceScope, WorkspaceScopeKeys } from '../models/workspace';
import { SegmentEvent, trackSegmentEvent } from '../ui/analytics';
import { AlertModal } from '../ui/components/modals/alert-modal';
import { AskModal } from '../ui/components/modals/ask-modal';
import { showError, showModal } from '../ui/components/modals/index';
import { showSelectModal } from '../ui/components/modals/select-modal';
import { convert, ConvertResultType } from '../utils/importers/convert';
import { isRequest, Request } from '../models/request';
import { isUnitTest, UnitTest } from '../models/unit-test';
import { isUnitTestSuite, UnitTestSuite } from '../models/unit-test-suite';
import {
BASE_ENVIRONMENT_ID_KEY,
CONTENT_TYPE_GRAPHQL,
EXPORT_TYPE_WORKSPACE,
WORKSPACE_ID_KEY,
} from './constants';
isWebSocketRequest,
WebSocketRequest,
} from '../models/websocket-request';
import { isWorkspace, Workspace } from '../models/workspace';
// import { SegmentEvent, trackSegmentEvent } from '../ui/analytics';
import { convert, ConvertResultType } from '../utils/importers/convert';
import { invariant } from '../utils/invariant';
import { database as db } from './database';
import { diffPatchObj, fnOrString, generateId } from './misc';
import { strings } from './strings';
import { generateId } from './misc';
export interface ImportResult {
source: string;
error: Error | null;
summary: Record<string, BaseModel[]>;
export interface ExportedModel extends BaseModel {
_type: string;
}
interface ConvertResult {
type: ConvertResultType;
data: {
resources: Record<string, any>[];
resources: ExportedModel[];
};
}
export interface ImportRawConfig {
getWorkspaceId: ImportToWorkspacePrompt;
getProjectId?: () => Promise<string>;
getWorkspaceScope?: SetWorkspaceScopePrompt;
enableDiffBasedPatching?: boolean;
enableDiffDeep?: boolean;
bypassDiffProps?: {
url: boolean;
};
}
export const isApiSpecImport = ({ id }: Pick<ConvertResultType, 'id'>) =>
id === 'openapi3' || id === 'swagger2';
export async function importUri(uri: string, importConfig: ImportRawConfig) {
let rawText;
// If GH preview, force raw
export const isInsomniaV4Import = ({ id }: Pick<ConvertResultType, 'id'>) =>
id === 'insomnia-4';
export async function fetchImportContentFromURI({ uri }: { uri: string }) {
const url = new URL(uri);
if (url.origin === 'https://github.com') {
@@ -58,406 +47,242 @@ export async function importUri(uri: string, importConfig: ImportRawConfig) {
}
if (uri.match(/^(http|https):\/\//)) {
const response = await window.fetch(uri);
rawText = await response.text();
const response = await fetch(uri);
const content = await response.text();
return content;
} else if (uri.match(/^(file):\/\//)) {
const path = uri.replace(/^(file):\/\//, '');
rawText = fs.readFileSync(path, 'utf8');
const content = await readFile(path, 'utf8');
return content;
} else {
// Treat everything else as raw text
rawText = decodeURIComponent(uri);
const content = decodeURIComponent(uri);
return content;
}
const result = await importRaw(rawText, importConfig);
const { summary, error } = result;
if (error) {
showError({
title: 'Failed to import',
error,
message: 'Import failed',
});
return result;
}
const statements = Object.keys(summary)
.map(type => {
const count = summary[type].length;
const name = models.getModelName(type, count);
return count === 0 ? null : `${count} ${name}`;
})
.filter(s => s !== null);
let message;
if (statements.length === 0) {
message = 'Nothing was found to import.';
} else {
message = `You imported ${statements.join(', ')}!`;
}
showModal(AlertModal, {
title: 'Import Succeeded',
message,
});
return result;
}
// If we come across an ID of this form, we will replace it with a new one
const REPLACE_ID_REGEX = /__\w+_\d+__/g;
export interface ScanResult {
requests?: (Request | WebSocketRequest | GrpcRequest)[];
workspace?: Workspace;
environments?: BaseEnvironment[];
apiSpec?: ApiSpec;
cookieJar?: CookieJar;
unitTests?: UnitTest[];
unitTestSuites?: UnitTestSuite[];
type?: ConvertResultType;
errors: string[];
}
export async function importRaw(
rawContent: string,
{
getWorkspaceId,
getWorkspaceScope,
getProjectId,
enableDiffBasedPatching,
enableDiffDeep,
bypassDiffProps,
}: ImportRawConfig,
) {
let results: ConvertResult;
let ResourceCache: {
content: string;
resources: BaseModel[];
type: ConvertResultType;
} | null = null;
export async function scanResources({
content,
}: {
content: string;
}): Promise<ScanResult> {
let results: ConvertResult | null = null;
try {
results = (await convert(rawContent)) as unknown as ConvertResult;
} catch (err) {
const importResult: ImportResult = {
source: 'not found',
error: err,
summary: {},
results = (await convert(content)) as unknown as ConvertResult;
} catch (err: unknown) {
if (err instanceof Error) {
return {
errors: [err.message],
};
}
}
if (!results) {
return {
errors: ['No resources found to import.'],
};
return importResult;
}
const { data, type: resultsType } = results;
// Generate all the ids we may need
const generatedIds: Record<string, string | ((...args: any[]) => any)> = {};
const { type, data } = results;
for (const r of data.resources) {
for (const key of r._id.match(REPLACE_ID_REGEX) || []) {
generatedIds[key] = generateId(models.MODELS_BY_EXPORT_TYPE[r._type].prefix);
}
}
// Contains the ID of the workspace to be used with the import
generatedIds[WORKSPACE_ID_KEY] = async () => {
const workspaceId = await getWorkspaceId();
// First try getting the workspace to overwrite
const workspace = await models.workspace.getById(workspaceId || 'n/a');
// Update this fn so it doesn't run again
const idToUse = workspace?._id || generateId(models.workspace.prefix);
generatedIds[WORKSPACE_ID_KEY] = idToUse;
return idToUse;
};
// Contains the ID of the base environment to be used with the import
generatedIds[BASE_ENVIRONMENT_ID_KEY] = async () => {
const parentId = await fnOrString(generatedIds[WORKSPACE_ID_KEY]);
const baseEnvironment = await models.environment.getOrCreateForParentId(parentId);
// Update this fn so it doesn't run again
generatedIds[BASE_ENVIRONMENT_ID_KEY] = baseEnvironment._id;
return baseEnvironment._id;
};
// NOTE: Although the order of the imported resources is not guaranteed,
// all current importers will produce resources in this order:
// Workspace > Environment > RequestGroup > Request
// Import everything backwards so they get inserted in the correct order
data.resources.reverse();
const importedDocs: Record<string, any[]> = {};
for (const model of models.all()) {
importedDocs[model.type] = [];
}
// Add a workspace to the resources if it doesn't exist
// NOTE: The workspace should be the last item of the resources
if (!data.resources.some(resource => resource._type === EXPORT_TYPE_WORKSPACE)) {
data.resources.push({
_id: WORKSPACE_ID_KEY,
_type: EXPORT_TYPE_WORKSPACE,
const resources = data.resources
.filter(r => r._type)
.map(r => {
const { _type, ...model } = r;
return { ...model, type: models.MODELS_BY_EXPORT_TYPE[_type].type };
});
}
for (const resource of data.resources) {
// Buffer DB changes
// NOTE: Doing it inside here so it's more "scalable"
await db.bufferChanges(100);
ResourceCache = {
resources,
type,
content,
};
// Replace null parentIds with current workspace
if (!resource.parentId && resource._type !== EXPORT_TYPE_WORKSPACE) {
resource.parentId = WORKSPACE_ID_KEY;
}
const requests = resources.filter(isRequest);
const websocketRequests = resources.filter(isWebSocketRequest);
const grpcRequests = resources.filter(isGrpcRequest);
const environments = resources.filter(isEnvironment);
const unitTests = resources.filter(isUnitTest);
const unitTestSuites = resources.filter(isUnitTestSuite);
const apiSpec = resources.find(isApiSpec);
const workspace = resources.find(isWorkspace);
const cookieJar = resources.find(isCookieJar);
// Replace ID placeholders (eg. __WORKSPACE_ID__) with generated values
for (const key of Object.keys(generatedIds)) {
const { parentId, _id } = resource;
return {
type,
unitTests,
unitTestSuites,
requests: [...requests, ...websocketRequests, ...grpcRequests],
workspace,
environments,
apiSpec,
cookieJar,
errors: [],
};
}
if (parentId?.includes(key)) {
resource.parentId = parentId.replace(key, await fnOrString(generatedIds[key]));
}
export async function importResources({
workspaceId,
projectId,
workspaceName,
}: {
workspaceId?: string;
workspaceName?: string;
projectId: string;
}) {
invariant(ResourceCache, 'No resources to import');
const bufferId = await db.bufferChanges();
if (_id && _id.includes(key)) {
resource._id = _id.replace(key, await fnOrString(generatedIds[key]));
const resources = ResourceCache.resources;
const workspace = resources.find(isWorkspace);
const ResourceIdMap = new Map();
// If we're importing into an existing workspace
if (workspaceId && workspaceId !== 'create-new-workspace-id') {
const existingWorkspace = await models.workspace.getById(workspaceId);
invariant(
existingWorkspace,
`Could not find workspace with id ${workspaceId}`
);
// If we're importing into a new workspace
// Map new IDs
ResourceIdMap.set(workspaceId, existingWorkspace._id);
ResourceIdMap.set('__WORKSPACE_ID__', existingWorkspace._id);
workspace && ResourceIdMap.set(workspace._id, existingWorkspace._id);
const filteredResources = resources.filter(
resource =>
!isWorkspace(resource) &&
!isApiSpec(resource) &&
!isCookieJar(resource) &&
!isEnvironment(resource)
);
for (const resource of filteredResources) {
const model = getModel(resource.type);
if (model) {
ResourceIdMap.set(resource._id, generateId(model.prefix));
} else {
console.log(
'[Import Scan] Could not find model for type',
resource.type
);
}
}
const model = models.MODELS_BY_EXPORT_TYPE[resource._type];
for (const resource of filteredResources) {
const model = getModel(resource.type);
if (!model) {
console.warn('Unknown doc type for import', resource._type);
continue;
}
// Hack to switch to GraphQL based on finding `graphql` in the URL path
// TODO: Support this in a better way
if (
isRequest(model) &&
resource.body &&
typeof resource.body.text === 'string' &&
typeof resource.url === 'string' &&
resource.body.text.includes('"query"') &&
resource.url.includes('graphql')
) {
resource.body.mimeType = CONTENT_TYPE_GRAPHQL;
}
// Try adding Content-Type JSON if no Content-Type exists
if (
isRequest(model) &&
resource.body &&
typeof resource.body.text === 'string' &&
Array.isArray(resource.headers) &&
!resource.headers.find(h => h.name.toLowerCase() === 'content-type')
) {
try {
JSON.parse(resource.body.text);
resource.headers.push({
name: 'Content-Type',
value: 'application/json',
});
} catch (err) {
// Not JSON
}
}
const existingDoc = await db.get(model.type, resource._id);
let newDoc: BaseModel;
if (existingDoc) {
let updateDoc = resource;
// Do differential patching when enabled
if (enableDiffBasedPatching) {
updateDoc = diffPatchObj(resource, existingDoc, enableDiffDeep);
}
// Bypass differential update for urls when enabled
if (bypassDiffProps?.url && updateDoc.url) {
updateDoc.url = resource.url;
}
// If workspace preserve the scope and parentId of the existing workspace while importing
if (isWorkspace(model)) {
(updateDoc as Workspace).scope = (existingDoc as Workspace).scope;
(updateDoc as Workspace).parentId = (existingDoc as Workspace).parentId;
}
newDoc = await db.docUpdate(existingDoc, updateDoc);
} else {
// If workspace, check and set the scope and parentId while importing a new workspace
if (isWorkspace(model)) {
// Set the workspace scope if creating a new workspace during import
// IF is creating a new workspace
// AND imported resource has no preset scope property OR scope is null
// AND we have a function to get scope
if ((!resource.hasOwnProperty('scope') || resource.scope === null) && getWorkspaceScope) {
const workspaceName = resource.name;
let specName;
// If is from insomnia v4 and the spec has contents, add to the name when prompting
if (isInsomniaV4Import(resultsType)) {
const spec: ApiSpec | null = await models.apiSpec.getByParentId(resource._id);
if (spec && spec.contents.trim()) {
specName = spec.fileName;
}
}
const nameToPrompt = specName ? `${specName} / ${workspaceName}` : workspaceName;
resource.scope = await getWorkspaceScope(nameToPrompt);
}
// If the workspace doesn't have a name, update the default name based on it's scope
if (!resource.name) {
const name =
(resource as Workspace).scope === 'collection'
? `My ${strings.collection.singular}`
: `My ${strings.document.singular}`;
resource.name = name;
}
if (getProjectId) {
// Set the workspace parent if creating a new workspace during import
resource.parentId = await getProjectId();
}
}
newDoc = await db.docCreate(model.type, resource);
// Mark as not seen if we created a new workspace from sync
if (isWorkspace(newDoc)) {
const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(newDoc._id);
await models.workspaceMeta.update(workspaceMeta, {
hasSeen: false,
if (model) {
await db.docCreate(model.type, {
...resource,
_id: ResourceIdMap.get(resource._id),
parentId: ResourceIdMap.get(resource.parentId),
});
}
}
importedDocs[newDoc.type].push(newDoc);
}
await db.flushChanges(bufferId);
// Store spec under workspace if it's OpenAPI
for (const workspace of importedDocs[models.workspace.type]) {
if (isApiSpecImport(resultsType)) {
const spec = await models.apiSpec.updateOrCreateForParentId(workspace._id, {
contents: rawContent,
return {
resources,
workspace: existingWorkspace,
};
} else {
const scope =
isApiSpecImport(ResourceCache?.type) ||
Boolean(resources.find(r => r.type === 'ApiSpec'))
? 'design'
: 'collection';
const newWorkspace = await models.workspace.create({
name: workspaceName || workspace?.name,
scope,
parentId: projectId,
});
const apiSpec = resources.find(isApiSpec);
if (apiSpec) {
await models.apiSpec.updateOrCreateForParentId(newWorkspace._id, {
...apiSpec,
fileName: workspaceName || workspace?.name,
});
}
if (
isApiSpecImport(ResourceCache?.type) &&
newWorkspace.scope === 'design'
) {
await models.apiSpec.updateOrCreateForParentId(newWorkspace._id, {
fileName: workspaceName || workspace?.name,
contents: ResourceCache.content,
contentType: 'yaml',
});
importedDocs[spec.type].push(spec);
}
// Set active environment when none is currently selected and one exists
const meta = await models.workspaceMeta.getOrCreateByParentId(workspace._id);
const envs = importedDocs[models.environment.type];
// If we're importing into a new workspace
// Map new IDs
ResourceIdMap.set(workspaceId, newWorkspace._id);
ResourceIdMap.set('__WORKSPACE_ID__', newWorkspace._id);
workspace && ResourceIdMap.set(workspace._id, newWorkspace._id);
if (!meta.activeEnvironmentId && envs.length > 0) {
meta.activeEnvironmentId = envs[0]._id;
await models.workspaceMeta.update(meta);
const resourcesWithoutWorkspaceAndApiSpec = resources.filter(
resource => !isWorkspace(resource) && !isApiSpec(resource)
);
for (const resource of resourcesWithoutWorkspaceAndApiSpec) {
const model = getModel(resource.type);
if (model) {
ResourceIdMap.set(resource._id, generateId(model.prefix));
} else {
console.log(
'[Import Scan] Could not find model for type',
resource.type
);
}
}
for (const resource of resourcesWithoutWorkspaceAndApiSpec) {
const model = getModel(resource.type);
if (model) {
await db.docCreate(model.type, {
...resource,
_id: ResourceIdMap.get(resource._id),
parentId: ResourceIdMap.get(resource.parentId),
});
}
}
await db.flushChanges(bufferId);
return {
resources,
workspace: newWorkspace,
};
}
await db.flushChanges();
trackSegmentEvent(SegmentEvent.dataImport, { type: resultsType.id });
const importRequest: ImportResult = {
source: resultsType && typeof resultsType.id === 'string' ? resultsType.id : 'unknown',
summary: importedDocs,
error: null,
};
return importRequest;
}
export const isApiSpecImport = ({ id }: Pick<ConvertResultType, 'id'>) => (
id === 'openapi3' || id === 'swagger2'
);
export const isInsomniaV4Import = ({ id }: Pick<ConvertResultType, 'id'>) => (
id === 'insomnia-4'
);
export enum ForceToWorkspace {
new = 'new',
current = 'current',
existing = 'existing'
}
export type SelectExistingWorkspacePrompt = Promise<string | null>;
// Returning null instead of a string will create a new workspace
export type ImportToWorkspacePrompt = () => null | string | Promise<null | string>;
export function askToImportIntoWorkspace({ workspaceId, forceToWorkspace, activeProjectWorkspaces }: { workspaceId?: string; forceToWorkspace?: ForceToWorkspace; activeProjectWorkspaces?: Workspace[] }): ImportToWorkspacePrompt {
return function() {
switch (forceToWorkspace) {
case ForceToWorkspace.new: {
return null;
}
case ForceToWorkspace.current: {
if (!workspaceId) {
return null;
}
return workspaceId;
}
case ForceToWorkspace.existing: {
// Return null if there are no available workspaces to chose from.
if (activeProjectWorkspaces?.length) {
return null;
}
}
default: {
if (!workspaceId) {
return null;
}
return new Promise(resolve => {
showModal(AskModal, {
title: 'Import',
message: 'Do you want to import into the current workspace or a new one?',
yesText: 'Current',
noText: 'New Workspace',
onDone: async (yes: boolean) => {
resolve(yes ? workspaceId : null);
},
});
});
}
}
};
}
export type SetWorkspaceScopePrompt = (name?: string) => WorkspaceScope | Promise<WorkspaceScope>;
export function askToSetWorkspaceScope(scope?: WorkspaceScope): SetWorkspaceScopePrompt {
return name => {
switch (scope) {
case WorkspaceScopeKeys.collection:
case WorkspaceScopeKeys.design:
return scope;
default:
return new Promise(resolve => {
const message = name
? `How would you like to import "${name}"?`
: 'Do you want to import as a Request Collection or a Design Document?';
showModal(AskModal, {
title: 'Import As',
message,
noText: 'Request Collection',
yesText: 'Design Document',
onDone: async (yes: boolean) => {
resolve(yes ? WorkspaceScopeKeys.design : WorkspaceScopeKeys.collection);
},
});
});
}
};
}
export type SetProjectIdPrompt = () => Promise<string>;
export function askToImportIntoProject({ projects, activeProject }: { projects?: Project[]; activeProject?: Project }): SetProjectIdPrompt {
return function() {
return new Promise(resolve => {
// If only one project exists, return that
if (projects?.length === 1) {
return resolve(projects[0]._id);
}
const options = projects?.map(project => ({ name: project.name, value: project._id })) || [];
const defaultValue = activeProject?._id || null;
showSelectModal({
title: 'Import',
message: `Select a ${strings.project.singular.toLowerCase()} to import into`,
options,
value: defaultValue,
onDone: selectedProjectId => {
// @ts-expect-error onDone can send null as an argument; why/how?
resolve(selectedProjectId);
},
});
});
};
}
@@ -45,12 +45,9 @@ describe('app.import.*', () => {
const filename = path.resolve(__dirname, '../__fixtures__/basic-import.json');
await data.import.uri(`file://${filename}`);
const allWorkspaces = await db.all(models.workspace.type);
expect(allWorkspaces).toEqual([
expect(allWorkspaces).toMatchObject([
workspace,
{
_id: 'wrk_imported_1',
created: 888,
modified: 999,
description: '',
name: 'New',
parentId: project._id,
@@ -58,21 +55,23 @@ describe('app.import.*', () => {
scope: WorkspaceScopeKeys.collection,
},
]);
expect(await db.all(models.request.type)).toEqual([
const importedWorkspace = allWorkspaces.find(w => w.name === 'New');
expect(importedWorkspace).toBeDefined();
expect(await db.all(models.request.type)).toMatchObject([
{
_id: 'req_imported_1',
isPrivate: false,
authentication: {},
body: {},
created: 111,
description: '',
headers: [],
metaSortKey: 0,
method: 'GET',
modified: 222,
name: 'Test',
parameters: [],
parentId: 'wrk_imported_1',
parentId: importedWorkspace._id,
settingDisableRenderRequestBody: false,
settingEncodeUrl: true,
settingSendCookies: true,
@@ -93,12 +92,9 @@ describe('app.import.*', () => {
const filename = path.resolve(__dirname, '../__fixtures__/basic-import.json');
await data.import.raw(fs.readFileSync(filename, 'utf8'));
const allWorkspaces = await db.all(models.workspace.type);
expect(allWorkspaces).toEqual([
expect(allWorkspaces).toMatchObject([
workspace,
{
_id: 'wrk_imported_1',
created: 888,
modified: 999,
description: '',
name: 'New',
parentId: project._id,
@@ -106,21 +102,21 @@ describe('app.import.*', () => {
scope: WorkspaceScopeKeys.collection,
},
]);
expect(await db.all(models.request.type)).toEqual([
const importedWorkspace = allWorkspaces.find(w => w.name === 'New');
expect(importedWorkspace).toBeDefined();
expect(await db.all(models.request.type)).toMatchObject([
{
_id: 'req_imported_1',
isPrivate: false,
authentication: {},
body: {},
created: 111,
description: '',
headers: [],
metaSortKey: 0,
method: 'GET',
modified: 222,
name: 'Test',
parameters: [],
parentId: 'wrk_imported_1',
parentId: importedWorkspace._id,
settingDisableRenderRequestBody: false,
settingEncodeUrl: true,
settingSendCookies: true,
+29 -14
View File
@@ -1,8 +1,6 @@
import { exportWorkspacesData, exportWorkspacesHAR } from '../../common/export';
import type { ImportRawConfig } from '../../common/import';
import { importRaw, importUri } from '../../common/import';
import { fetchImportContentFromURI, importResources, scanResources } from '../../common/import';
import * as models from '../../models';
import { DEFAULT_PROJECT_ID } from '../../models/project';
import type { Workspace, WorkspaceScope } from '../../models/workspace';
interface PluginImportOptions {
@@ -18,14 +16,6 @@ interface InsomniaExport {
type HarExport = Omit<InsomniaExport, 'format'>;
const buildImportRawConfig = (options: PluginImportOptions, activeProjectId: string): ImportRawConfig => ({
getWorkspaceId: () => Promise.resolve(options.workspaceId || null),
getWorkspaceScope: options.scope && (() => (
Promise.resolve<WorkspaceScope>(options.scope as WorkspaceScope))
),
getProjectId: () => Promise.resolve(activeProjectId),
});
const getWorkspaces = (activeProjectId?: string) => {
if (activeProjectId) {
return models.workspace.findByParentId(activeProjectId);
@@ -42,10 +32,35 @@ export const init = (activeProjectId?: string) => ({
data: {
import: {
uri: async (uri: string, options: PluginImportOptions = {}) => {
await importUri(uri, buildImportRawConfig(options, activeProjectId || DEFAULT_PROJECT_ID));
if (!activeProjectId) {
return;
}
const content = await fetchImportContentFromURI({
uri,
});
await scanResources({
content,
});
await importResources({
projectId: activeProjectId,
workspaceId: options.workspaceId,
});
},
raw: async (text: string, options: PluginImportOptions = {}) => {
await importRaw(text, buildImportRawConfig(options, activeProjectId || DEFAULT_PROJECT_ID));
raw: async (content: string, options: PluginImportOptions = {}) => {
if (!activeProjectId) {
return;
}
await scanResources({
content,
});
await importResources({
projectId: activeProjectId,
workspaceId: options.workspaceId,
});
},
},
export: {
@@ -1,10 +1,8 @@
import { AriaPopoverProps, DismissButton, Overlay, usePopover } from '@react-aria/overlays';
import { StyleProps } from '@react-types/shared';
import React, { FC, ReactNode, useRef } from 'react';
import { useOverlayTrigger } from 'react-aria';
import { AriaPopoverProps, DismissButton, Overlay, useOverlayTrigger, usePopover } from 'react-aria';
import { OverlayTriggerState } from 'react-stately';
interface Props extends Omit<AriaPopoverProps, 'popoverRef' | 'maxHeight'>, StyleProps {
interface Props extends Omit<AriaPopoverProps, 'popoverRef' | 'maxHeight'> {
children: ReactNode;
state: OverlayTriggerState;
}
@@ -0,0 +1,936 @@
import React, {
FC,
Fragment,
ReactNode,
useEffect,
useId,
useRef,
useState,
useTransition,
} from 'react';
import { OverlayContainer, useDrop } from 'react-aria';
import { useFetcher } from 'react-router-dom';
import styled from 'styled-components';
import { strings } from '../../../common/strings';
import { isDefaultProject, isLocalProject } from '../../../models/project';
import { Workspace } from '../../../models/workspace';
import {
ImportResourcesActionResult,
ScanForResourcesActionResult,
} from '../../routes/import';
import { ProjectLoaderData } from '../../routes/project';
import { Modal, ModalHandle, ModalProps } from '../base/modal';
import { ModalHeader } from '../base/modal-header';
import { Button } from '../themed-button';
import { hideAllModals } from '.';
const Pill = styled.div({
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-xs)',
padding: 'var(--padding-sm)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-xs)',
});
const RadioGroup = styled.div({
display: 'flex',
padding: 'var(--padding-xs)',
border: '1px solid var(--hl-md)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--hl-xs)',
'input[type="radio"]:checked ~ label': {
backgroundColor: 'var(--color-bg)',
boxShadow: '0 0 5px 1px var(--hl-xs)',
},
});
const RadioLabel = styled.label({
padding: 'var(--padding-sm)',
borderRadius: 'var(--radius-md)',
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-sm)',
});
const RadioInput = styled.input({
position: 'absolute',
width: '1px',
height: '1px',
padding: '0',
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
borderWidth: '0',
});
const Radio: FC<{
name: string;
value: string;
children: ReactNode;
checked?: boolean;
defaultChecked?: boolean;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
}> = ({ name, value, onChange, children, checked, defaultChecked }) => {
const id = useId();
return (
<div>
<RadioInput
id={id}
type="radio"
name={name}
checked={checked}
value={value}
defaultChecked={defaultChecked}
onChange={onChange}
/>
<RadioLabel htmlFor={id}>{children}</RadioLabel>
</div>
);
};
const Fieldset = styled.fieldset({
display: 'flex',
flexDirection: 'column',
gap: 'var(--padding-md)',
legend: {
marginBottom: 'var(--padding-xs)',
},
});
const FileInput = styled.input({
display: 'none',
});
const FileInputLabel = styled.label({
padding: 'var(--padding-sm)',
borderRadius: 'var(--radius-md)',
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-sm)',
border: '1px solid var(--hl-md)',
backgroundColor: 'var(--hl-xs)',
flexWrap: 'wrap',
});
const FileView = styled.div({
backgroundColor: 'var(--color-bg)',
borderRadius: 'var(--radius-md)',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 'var(--padding-md)',
gap: 'var(--padding-sm)',
width: '100%',
});
const FileField: FC = () => {
const id = useId();
const dropRef = useRef<HTMLLabelElement>(null);
const [selectedFile, setSelectedFile] = useState<File | undefined>();
const { isDropTarget, dropProps } = useDrop({
ref: dropRef,
onDrop: async event => {
const file =
event.items[0].kind === 'file'
? await event.items[0].getFile()
: undefined;
if (file) {
setSelectedFile(file);
}
},
});
return (
<div>
<FileInput
onChange={e => setSelectedFile(e.currentTarget.files?.[0])}
accept={[
'',
'sh',
'txt',
'json',
'har',
'curl',
'bash',
'shell',
'yaml',
'yml',
'wsdl',
].join(',')}
id={id}
type="file"
/>
<FileInputLabel
{...dropProps}
style={{
border: isDropTarget ? '1px solid var(--color-surprise)' : undefined,
}}
htmlFor={id}
>
{!selectedFile && (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 'var(--padding-md)',
gap: 'var(--padding-sm)',
width: '100%',
}}
>
<div>
<i className="fa fa-upload fa-xl" />
</div>
<div>
Drag and Drop or{' '}
<span
style={{
color: 'var(--color-surprise)',
}}
>
Choose a File
</span>{' '}
to import
</div>
</div>
)}
{selectedFile && (
<Fragment key={selectedFile.name}>
<FileView key={selectedFile.path}>
<div>
<i className="fa fa-file fa-xl" />
</div>
{selectedFile.name}
</FileView>
<input type="hidden" name="filePath" value={selectedFile.path} />
</Fragment>
)}
</FileInputLabel>
</div>
);
};
const InsomniaIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={18}
height={18}
viewBox="0 0 32 32"
fill="none"
{...props}
>
<path
d="M16 31.186c8.387 0 15.186-6.799 15.186-15.186S24.387.814 16 .814.814 7.613.814 16 7.613 31.186 16 31.186z"
fill="var(--color-bg)"
/>
<path
d="M16 0C7.163 0 0 7.163 0 16s7.163 16 16 16 16-7.163 16-16S24.837 0 16 0zm0 1.627c7.938 0 14.373 6.435 14.373 14.373S23.938 30.373 16 30.373 1.627 23.938 1.627 16 8.062 1.627 16 1.627z"
fill="var(--color-font)"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.18 4.61c6.291 0 11.39 5.1 11.39 11.39 0 6.29-5.099 11.39-11.39 11.39-6.29 0-11.39-5.1-11.39-11.39 0-1.537.305-3.004.858-4.342a4.43 4.43 0 106.192-6.192 11.357 11.357 0 014.34-.856z"
fill="var(--color-font)"
/>
<defs>
<linearGradient
id="paint0_linear"
x1={16.1807}
y1={27.3898}
x2={16.1807}
y2={4.61017}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#7400E1" />
<stop offset={1} stopColor="#4000BF" />
</linearGradient>
</defs>
</svg>
);
};
const PostmanIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width={18}
height={18}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18.038.13a16 16 0 10-4.076 31.74A16 16 0 0018.038.13z"
fill="var(--color-font)"
/>
<path
d="M11.567 17.011a.06.06 0 00.07.032l2.56-.552-1.076-1.091-1.534 1.534a.06.06 0 00-.02.077zM23.555 6.02a2.386 2.386 0 101.005 4.55l-1.623-1.623a.2.2 0 010-.283l2.12-2.118a2.386 2.386 0 00-1.502-.527z"
fill="var(--color-bg)"
/>
<path
d="M25.348 6.82L23.361 8.8l1.558 1.558a2.4 2.4 0 00.429-3.538zM21.372 10.474h-.035a.621.621 0 00-.123.01h-.015a.938.938 0 00-.13.04l-.034.015a.627.627 0 00-.093.048l-.035.023a.833.833 0 00-.11.09l-5.892 5.894.73.73 6.24-5.478a.727.727 0 00.096-.102l.027-.035a.87.87 0 00.11-.234c0-.019.011-.038.016-.057a.934.934 0 00.016-.12v-.053-.086c0-.03 0-.039-.008-.058a.778.778 0 00-.61-.613h-.03a.835.835 0 00-.12-.014zM13.396 15.117l1.21 1.203 5.909-5.909c.192-.188.442-.305.71-.331-1.045-.8-2.184-.59-7.829 5.037zM22.207 12.077l-.072.07-6.24 5.475 1.061 1.06c2.63-2.488 4.965-4.858 5.252-6.605zM6.643 24.904a.058.058 0 00.051.041l2.72.188-1.525-1.525-1.233 1.232a.061.061 0 00-.013.064zM8.174 23.325l1.608 1.608a.122.122 0 00.152.02.12.12 0 00.062-.139l-.27-1.155a.346.346 0 01.177-.386c2.82-1.412 5.093-2.867 6.762-4.32l-1.12-1.12-2.4.517-4.971 4.975zM15.201 17.494l-.601-.601-.832.83a.04.04 0 000 .051.038.038 0 00.046.021l1.387-.3z"
fill="var(--color-bg)"
/>
<path
d="M25.404 8.11a.185.185 0 10-.33.16.557.557 0 01-.07.602.185.185 0 00.285.237.926.926 0 00.115-.998z"
fill="var(--color-font)"
/>
</svg>
);
};
const SwaggerIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18px"
height="18px"
viewBox="0 0 256 256"
{...props}
>
<path
fill="var(--color-font)"
d="M127.999 249.895c-67.215 0-121.9-54.68-121.9-121.896C6.1 60.782 60.785 6.102 128 6.102c67.214 0 121.899 54.685 121.899 121.9 0 67.214-54.685 121.893-121.9 121.893z"
/>
<path
fill="var(--color-bg)"
d="M127.999 12.202c63.954 0 115.797 51.842 115.797 115.797 0 63.952-51.843 115.797-115.797 115.797-63.952 0-115.797-51.845-115.797-115.797S64.047 12.202 127.999 12.202m0-12.202C57.419 0 0 57.42 0 127.999s57.42 127.998 127.999 127.998S256 198.577 256 128C256 57.419 198.578 0 127.999 0z"
/>
<path
fill="var(--color-bg)"
d="M80.598 86.619c-.394 4.38.146 8.909-.146 13.338-.345 4.431-.887 8.811-1.773 13.192-1.23 6.25-5.12 10.976-10.482 14.914 10.436 6.793 11.616 17.324 12.304 28.006.345 5.76.197 11.567.788 17.276.443 4.429 2.165 5.562 6.745 5.708 1.87.048 3.786 0 5.956 0v13.683c-13.535 2.313-24.708-1.525-27.467-12.992-.887-4.184-1.478-8.467-1.673-12.798-.297-4.578.195-9.155-.148-13.732-.985-12.553-2.61-16.785-14.618-17.376v-15.602a23.714 23.714 0 012.608-.443c6.596-.345 9.4-2.364 10.828-8.86.69-3.641 1.084-7.333 1.23-11.074.494-7.136.297-14.42 1.525-21.507C67.997 68.163 74.3 63.24 84.785 62.65c2.952-.149 5.955 0 9.35 0v13.98c-1.427.1-2.658.294-3.937.294-8.515-.297-8.96 2.607-9.6 9.695zm16.39 32.386h-.196c-4.923-.245-9.155 3.593-9.403 8.515-.246 4.972 3.592 9.206 8.515 9.45h.59c4.875.296 9.056-3.447 9.352-8.319v-.491c.1-4.971-3.886-9.055-8.857-9.155zm30.862 0c-4.774-.148-8.763 3.593-8.909 8.318 0 .297 0 .543.051.837 0 5.365 3.641 8.812 9.155 8.812 5.414 0 8.812-3.544 8.812-9.106-.051-5.366-3.646-8.91-9.109-8.86zm31.602 0c-5.02-.1-9.206 3.89-9.352 8.91a9.03 9.03 0 009.055 9.054h.1c4.528.788 9.106-3.592 9.402-8.858.243-4.874-4.186-9.106-9.205-9.106zm43.363.737c-5.711-.245-8.567-2.164-9.992-7.581a54.874 54.874 0 01-1.624-10.582c-.395-6.596-.346-13.241-.789-19.837-1.033-15.651-12.352-21.114-28.794-18.41V76.92c2.607 0 4.626 0 6.645.049 3.495.048 6.153 1.379 6.496 5.268.345 3.543.345 7.136.69 10.73.692 7.139 1.083 14.372 2.314 21.41 1.085 5.809 5.07 10.14 10.04 13.684-8.71 5.857-11.27 14.223-11.714 23.626-.245 6.448-.394 12.944-.736 19.443-.297 5.905-2.362 7.824-8.318 7.972-1.674.05-3.298.198-5.169.297v13.93c3.495 0 6.694.196 9.892 0 9.942-.592 15.947-5.415 17.918-15.063a125.582 125.582 0 001.476-16.045c.343-4.923.297-9.894.788-14.766.737-7.63 4.232-10.78 11.862-11.27.739-.1 1.427-.246 2.118-.492v-15.604c-1.282-.149-2.17-.295-3.103-.346z"
/>
</svg>
);
};
const OpenAPIIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={17}
height={17}
viewBox="0 0 512 512"
{...props}
>
<path
fill="currentColor"
d="M.204 294.376l135.073-.002a91.735 91.735 0 008.452 30.17L27.94 394.298C12.75 368.103 3.166 334.783.204 294.376zm131.802 196.803l51.407-124.841a91.475 91.475 0 01-14.938-9.955l-95.47 95.472c19.49 17.557 39.562 30.71 59.001 39.324zm-65.604-45.647l95.284-95.286c-4.933-4.963-9.482-10.85-13.667-17.616L32.512 402.213c10.491 17.069 22.148 31.847 33.89 43.319zm313.997 6.327l-95.53-95.525a91.813 91.813 0 01-3.184 2.466l69.71 115.72c9.73-6.055 19.39-13.823 29.004-22.66zm-36.74 27.532l-69.565-115.479c-25.964 14.5-53.318 17.553-82.406 6.337l-51.301 124.583c70.2 28.467 142.961 20.313 203.272-15.441zM171.713 211.67L102.005 95.95c-10.164 6.523-19.819 14.106-29.006 22.66l95.527 95.527a135.39 135.39 0 013.187-2.467zM0 285.236l134.762-.002c.291-23.838 8.71-45.682 26.928-65.006l-95.287-95.287C22.416 171.553.512 225.056 0 285.236zm226.694-91.938L226.7 58.54c-44.728.274-84.005 12.326-116.96 32.539l69.563 115.48c13.077-7.677 28.664-12.98 47.39-13.26zm186.404-37.118l-99.449 99.452a93.73 93.73 0 014.453 20.46h135.09c-.883-40.98-14.404-80.939-40.094-119.912zm40.28 129.052H318.631c-.468 25.24-9.9 48.244-26.924 65.014l95.29 95.286c43.82-43.123 65.122-96.948 66.381-160.3zM235.84 58.74l-.006 135.087c7.082.802 13.883 2.342 20.466 4.455l99.415-99.415c-35.132-24.575-76.54-38.362-119.875-40.127zM430.95 2.597c-39.165 11.457-55.533 55.183-38.782 88.672L254.808 228.63c-32.407-16.157-74.902-1.57-87.795 36.034-15.716 45.84 24.243 91.802 71.754 82.533 42.01-8.196 62.31-54.188 44.466-90.01L420.722 119.7c34.66 17.226 79.533-1.065 89.396-41.654 11.43-47.037-32.658-89.054-79.168-75.449z"
/>
</svg>
);
};
const CurlIcon = (props: React.SVGProps<SVGSVGElement>) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={18}
height={18}
viewBox="0 0 123.184 102.926"
{...props}
>
<defs>
<symbol overflow="visible">
<path
d="M73.36 0v-84.172H13.233V0zm-9.626-78.156L43.297-47.5 22.844-78.156zm3.61 66.734L46.89-42.094 67.344-72.75zm-44.5 5.406l20.453-30.656L63.734-6.016zm-3.61-66.734l20.453 30.656-20.453 30.672zm0 0"
stroke="none"
/>
</symbol>
<symbol overflow="visible">
<path
d="M15.031-26.813c0 7.094 1.328 10.579 6.86 15.141l8.53 7.11C35.829-.125 38.595 0 45.329 0H63.61c3.844 0 7.938-.844 7.938-5.766 0-5.062-4.563-5.78-7.938-5.78H43.297c-2.297 0-4.578-1.204-6.625-2.892l-6.969-5.89c-1.812-1.438-3.125-2.64-3.125-4.922v-14.313c0-2.28 1.313-3.484 3.125-4.937l6.969-5.89c2.047-1.672 4.328-2.876 6.625-2.876h20.312c3.375 0 7.938-.734 7.938-5.78 0-4.923-4.094-5.767-7.938-5.767H45.33c-6.735 0-9.5.126-14.907 4.563l-8.531 7.094c-5.532 4.578-6.86 8.062-6.86 15.156zm0 0"
stroke="none"
/>
</symbol>
<symbol overflow="visible">
<path
d="M71.547-56.875c0-3.969-.844-7.938-5.766-7.938-4.937 0-5.781 3.97-5.781 7.938v33.547L43.047-12.625c-1.078.719-2.281 1.078-3.61 1.078h-3.843c-2.766 0-5.766-.719-6.735-2.406-.968-1.672-1.078-4.813-1.203-7.453l-1.203-35.469c-.125-3.734-.719-7.938-5.656-7.938-5.281 0-5.89 4.454-5.766 8.657l1.203 36.312c.235 6.25.125 11.188 5.297 15.64C25.375-.968 28.984 0 32.234 0h8.532c3.125 0 6.25-1.438 9.984-3.844l9.375-6.14v1.921c0 3.97.844 8.063 5.766 8.063 6.14 0 5.656-5.89 5.656-8.531zm0 0"
stroke="none"
/>
</symbol>
<symbol overflow="visible">
<path
d="M15.031-7.938c0 3.97.844 7.938 5.766 7.938 4.937 0 5.781-3.969 5.781-7.938v-29.218L42.688-50.5c2.046-1.688 3.609-2.766 5.765-2.766h3.375c2.766 0 5.531 0 6.969 2.047C60-49.547 60-48.094 60-46.172c0 3.719 1.688 7.094 5.89 7.094 4.938 0 5.657-4.094 5.657-8.063 0-5.28-1.328-9.25-5.172-13.109-3.125-3.125-6.844-4.563-11.297-4.563h-9.266c-3.609 0-6.968 2.282-10.218 4.922l-9.016 7.47v-4.704c0-3.844-1.203-7.688-5.781-7.688-5.281 0-5.766 4.329-5.766 8.532zm0 0"
stroke="none"
/>
</symbol>
<symbol overflow="visible">
<path
d="M49.297-80.563c0-6.859-1.922-8.062-8.531-8.062H28.625c-3.86 0-7.938.844-7.938 5.766 0 5.062 4.563 5.78 7.938 5.78h9.14v65.532h-9.14c-3.86 0-7.938.844-7.938 5.781C20.688-.719 25.25 0 28.625 0h29.328c3.375 0 7.938-.719 7.938-5.766 0-4.937-4.079-5.78-7.938-5.78h-8.656zm0 0"
stroke="none"
/>
</symbol>
</defs>
<path
d="M114.102 14.043a4.96 4.96 0 01-4.961-4.961 4.958 4.958 0 014.96-4.96 4.96 4.96 0 110 9.921M64.833 98.805a4.96 4.96 0 01-4.96-4.961 4.958 4.958 0 014.96-4.961 4.957 4.957 0 014.957 4.96 4.96 4.96 0 01-4.957 4.962M114.102 0a9.082 9.082 0 00-9.082 9.082c0 1.07.27 2.066.609 3.02L63.023 85.125c-4.117.863-7.273 4.344-7.273 8.719a9.082 9.082 0 009.082 9.082c5.012 0 9.078-4.067 9.078-9.082 0-1.008-.27-1.93-.57-2.836l42.82-73.262c3.992-.957 7.024-4.379 7.024-8.664A9.082 9.082 0 00114.102 0"
fill="currentColor"
fillOpacity={1}
fillRule="nonzero"
stroke="none"
/>
<path
d="M76.941 14.043a4.96 4.96 0 01-4.96-4.961 4.958 4.958 0 014.96-4.96 4.957 4.957 0 014.957 4.96 4.96 4.96 0 01-4.957 4.961M27.668 98.805a4.96 4.96 0 110-9.922 4.958 4.958 0 014.96 4.96 4.96 4.96 0 01-4.96 4.962M76.941 0a9.08 9.08 0 00-9.082 9.082c0 1.07.27 2.066.61 3.02L25.863 85.125c-4.12.863-7.277 4.344-7.277 8.719a9.082 9.082 0 0018.164 0c0-1.008-.27-1.93-.57-2.836L79 17.746c3.992-.957 7.023-4.379 7.023-8.664 0-5.016-4.07-9.082-9.082-9.082M9.082 29.227a4.963 4.963 0 014.961 4.96 4.96 4.96 0 11-4.96-4.96M9.081 43.27a9.082 9.082 0 009.082-9.082c0-1.004-.273-1.93-.574-2.836-1.203-3.606-4.5-6.247-8.508-6.247-.64 0-1.203.239-1.809.368C3.156 26.332 0 29.813 0 34.188a9.082 9.082 0 009.082 9.082M4.121 65.922a4.96 4.96 0 119.922 0 4.96 4.96 0 01-4.961 4.957 4.96 4.96 0 01-4.96-4.957m14.042 0c0-1.008-.273-1.93-.574-2.836-1.203-3.606-4.496-6.246-8.508-6.246-.64 0-1.203.238-1.809.363C3.156 58.066 0 61.547 0 65.922c0 5.012 4.066 9.082 9.082 9.082 5.016 0 9.082-4.07 9.082-9.082"
fill="currentColor"
fillOpacity={1}
fillRule="nonzero"
stroke="none"
/>
</svg>
);
};
interface ImportModalProps extends ModalProps {
organizationId: string;
defaultProjectId: string;
defaultWorkspaceId?: string;
from: 'file' | 'uri' | 'clipboard';
}
export const ImportModal: FC<ImportModalProps> = ({
defaultProjectId,
defaultWorkspaceId,
organizationId,
from,
...modalProps
}) => {
const modalRef = useRef<ModalHandle>(null);
const scanResourcesFetcher = useFetcher<ScanForResourcesActionResult>();
const importFetcher = useFetcher<ImportResourcesActionResult>();
useEffect(() => {
modalRef.current?.show();
}, []);
useEffect(() => {
if (importFetcher.state === 'loading') {
hideAllModals();
}
}, [importFetcher.state]);
return (
<OverlayContainer>
<Modal {...modalProps} ref={modalRef}>
<ModalHeader>Import to Insomnia</ModalHeader>
{scanResourcesFetcher.data && scanResourcesFetcher.data.errors.length === 0 ? (
<ImportResourcesForm
organizationId={organizationId}
defaultProjectId={defaultProjectId}
defaultWorkspaceId={defaultWorkspaceId}
scanResult={scanResourcesFetcher.data}
errors={importFetcher.data?.errors}
onSubmit={e => {
importFetcher.submit(e.currentTarget, {
method: 'post',
action: '/import/resources',
});
}}
/>
) : (
<ScanResourcesForm
from={from}
errors={scanResourcesFetcher.data?.errors}
onSubmit={e => {
e.preventDefault();
scanResourcesFetcher.submit(e.currentTarget, {
method: 'post',
action: '/import/scan',
});
}}
/>
)}
</Modal>
</OverlayContainer>
);
};
const ScanResourcesForm = ({
onSubmit,
from,
errors,
}: {
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void;
from?: 'file' | 'uri' | 'clipboard';
errors?: string[];
}) => {
const id = useId();
const [importFrom, setImportFrom] = useState(from || 'uri');
return (
<Fragment>
<form
id={id}
onSubmit={onSubmit}
method="post"
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--padding-sm)',
}}
>
<Fieldset>
<RadioGroup>
<Radio
onChange={() => setImportFrom('file')}
name="importFrom"
value="file"
checked={importFrom === 'file'}
>
<i className="fa fa-plus" />
File
</Radio>
<Radio
onChange={() => setImportFrom('uri')}
name="importFrom"
value="uri"
checked={importFrom === 'uri'}
>
<i className="fa fa-link" />
Url
</Radio>
<Radio
onChange={() => setImportFrom('clipboard')}
name="importFrom"
value="clipboard"
checked={importFrom === 'clipboard'}
>
<i className="fa fa-clipboard" />
Clipboard
</Radio>
</RadioGroup>
</Fieldset>
{importFrom === 'file' && <FileField />}
{importFrom === 'uri' && (
<div className="form-control form-control--outlined">
<label>
Url:
<input
type="text"
name="uri"
placeholder="https://website.com/insomnia-import.json"
/>
</label>
</div>
)}
</form>
<div>
{errors && errors.length > 0 && (
<div className="notice error margin-top-sm">
<p>
<strong>Error while scanning for resources to import:</strong>
{errors[0]}
</p>
</div>
)}
</div>
<div
style={{
display: 'flex',
gap: 'var(--padding-sm)',
justifyContent: 'space-between',
alignItems: 'flex-end',
}}
>
<div>
<div
style={{
paddingBottom: 'var(--padding-sm)',
}}
>
Supported Formats
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 'var(--padding-sm)',
}}
>
<Pill>
<InsomniaIcon />
Insomnia
</Pill>
<Pill>
<PostmanIcon />
Postman
</Pill>
<Pill>
<SwaggerIcon />
Swagger
</Pill>
<Pill>
<OpenAPIIcon />
OpenAPI
</Pill>
<Pill>
<CurlIcon />
cURL
</Pill>
<Pill>
<i className="fa-regular fa-file fa-lg" />
HAR
</Pill>
<Pill>
<i className="fa-regular fa-file fa-lg" />
WSDL
</Pill>
</div>
</div>
<Button
variant="contained"
bg="surprise"
type="submit"
style={{
height: '40px',
gap: 'var(--padding-sm)',
}}
form={id}
className="btn"
>
<i className="fa fa-file-import" /> Scan
</Button>
</div>
</Fragment>
);
};
const ImportTypeTitle = styled.div({
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-sm)',
});
const ImportResourcesForm = ({
scanResult,
defaultProjectId,
defaultWorkspaceId,
organizationId,
onSubmit,
errors,
}: {
scanResult: ScanForResourcesActionResult;
organizationId: string;
defaultProjectId?: string;
defaultWorkspaceId?: string;
errors?: string[];
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void;
}) => {
const [isPending, startTransition] = useTransition();
const projectFetcher = useFetcher<ProjectLoaderData>();
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(
defaultWorkspaceId || 'create-new-workspace-id'
);
const isCreatingNewWorkspace =
selectedWorkspaceId === 'create-new-workspace-id';
useEffect(() => {
if (projectFetcher.state === 'idle' && !projectFetcher.data) {
projectFetcher.load(
`/organization/${organizationId}/project/${defaultProjectId}`
);
}
}, [defaultProjectId, organizationId, selectedWorkspaceId, projectFetcher]);
const workspaces: Partial<Workspace>[] = [
...(projectFetcher?.data?.workspaces || []),
{
_id: 'create-new-workspace-id',
name: '+ Create New File',
},
];
const projects = projectFetcher?.data?.projects || [];
const id = useId();
return (
<Fragment>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--padding-md)',
}}
>
<form
onSubmit={e => {
e.preventDefault();
startTransition(() => {
onSubmit?.(e);
});
}}
method="post"
action="/import/resources"
id={id}
>
<div
style={{
fontSize: 'var(--font-size-sm)',
fontWeight: '500',
paddingBottom: 'var(--padding-sm)',
}}
>
Import to:
</div>
<div
style={{
border: '1px solid var(--hl-sm)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
display: 'flex',
}}
>
<div
style={{
margin: 0,
}}
className="form-control form-control--outlined"
>
<select
style={{
border: 'none',
borderRadius: 0,
margin: 0,
}}
onChange={e =>
projectFetcher.load(
`/organization/${organizationId}/project/${e.currentTarget.value}`
)
}
defaultValue={defaultProjectId}
name="projectId"
>
{projects.map(project => (
<option key={project._id} value={project._id}>
{project.name} (
{isDefaultProject(project)
? strings.defaultProject.singular
: isLocalProject(project)
? strings.localProject.singular
: strings.remoteProject.singular}
)
</option>
))}
</select>
</div>
<div
style={{
margin: 0,
}}
className="form-control form-control--outlined"
>
<select
style={{
border: 'none',
borderRadius: 0,
margin: 0,
}}
onChange={e => {
setSelectedWorkspaceId(e.target.value);
}}
value={selectedWorkspaceId}
name="workspaceId"
>
{workspaces.map(workspace => (
<option key={workspace._id} value={workspace._id}>
{workspace.name}
</option>
))}
</select>
</div>
{isCreatingNewWorkspace && (
<Fragment>
<div
style={{
margin: 0,
}}
className="form-control form-control--outlined"
>
<input
style={{
border: 'none',
margin: 0,
}}
autoFocus
type="text"
name="name"
defaultValue="Untitled"
/>
</div>
</Fragment>
)}
</div>
<input hidden name="organizationId" readOnly value={organizationId} />
</form>
<table className="table--fancy table--outlined margin-top-sm">
<thead>
<tr className="table--no-outline-row">
<th>
<ImportTypeTitle>
{scanResult.type?.id.includes('insomnia') && (
<Fragment>
<InsomniaIcon width={24} height={24} />
Insomnia
</Fragment>
)}
{scanResult.type?.id.includes('postman') && (
<Fragment>
<PostmanIcon width={24} height={24} />
Postman
</Fragment>
)}
{scanResult.type?.id.includes('swagger') && (
<Fragment>
<SwaggerIcon width={24} height={24} />
Swagger
</Fragment>
)}
{scanResult.type?.id.includes('openapi') && (
<Fragment>
<OpenAPIIcon width={24} height={24} />
OpenAPI
</Fragment>
)}
{scanResult.type?.id.includes('wsdl') && (
<Fragment>
<i className="fa-regular fa-file fa-lg" />
WSDL
</Fragment>
)}
{scanResult.type?.id.includes('har') && (
<Fragment>
<i className="fa-regular fa-file fa-lg" />
HAR
</Fragment>
)}
{scanResult.type?.id.includes('curl') && (
<Fragment>
<CurlIcon width={24} height={24} />
cURL
</Fragment>
)}{' '}
resources to be imported:
</ImportTypeTitle>
</th>
</tr>
</thead>
<tbody>
{scanResult.requests && scanResult.requests?.length > 0 && (
<tr
key={scanResult.requests[0]._id}
className="table--no-outline-row"
>
<td>
{scanResult.requests.length}{' '}
{scanResult.requests.length === 1 ? 'Request' : 'Requests'}
</td>
</tr>
)}
{isCreatingNewWorkspace && scanResult.apiSpec && (
<tr
key={scanResult.apiSpec._id}
className="table--no-outline-row"
>
<td>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-md)',
}}
>
OpenAPI Spec:{' '}
{scanResult.apiSpec.name || scanResult.apiSpec.fileName}
</div>
</td>
</tr>
)}
{isCreatingNewWorkspace &&
scanResult.environments &&
scanResult.environments.length > 0 && (
<tr className="table--no-outline-row">
<td>
{scanResult.environments.length}{' '}
{scanResult.environments.length === 1
? 'Environment'
: 'Environments'}
{scanResult.cookieJar ? ' and a Cookie Jar' : ''}
</td>
</tr>
)}
{scanResult.unitTestSuites &&
scanResult.unitTestSuites?.length > 0 && (
<tr className="table--no-outline-row">
<td>
{scanResult.unitTestSuites.length}{' '}
{scanResult.unitTestSuites.length === 1
? 'Test Suite'
: 'Test Suites'}
{scanResult.unitTests?.length}
{' with'}
{scanResult.unitTests?.length === 1 ? 'Test' : 'Tests'}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div>
{errors && errors.length > 0 && (
<div className="notice error margin-top-sm">
<p>
<strong>Error while importing to Insomnia:</strong>
{errors[0]}
</p>
</div>
)}
</div>
<div
style={{
display: 'flex',
gap: 'var(--padding-sm)',
width: '100%',
justifyContent: 'flex-end',
alignItems: 'flex-end',
}}
>
<Button
variant="contained"
bg="surprise"
type="submit"
disabled={isPending}
style={{
height: '40px',
gap: 'var(--padding-sm)',
}}
form={id}
className="btn"
>
{isPending ? (
<div>
<i className="fa fa-spinner fa-spin" /> Importing
</div>
) : (
<div>
<i className="fa fa-file-import" /> Import
</div>
)}
</Button>
</div>
</Fragment>
);
};
@@ -1,7 +1,6 @@
import React, { FC } from 'react';
import styled from 'styled-components';
import { Dropdown, DropdownButton, DropdownItem, ItemContent } from '../base/dropdown';
import { Button } from '../themed-button';
const Wrapper = styled.div({
@@ -48,67 +47,56 @@ const Title = styled.div({
interface Props {
createRequestCollection: () => void;
createDesignDocument: () => void;
importFromFile: () => void;
importFromURL: () => void;
importFromClipboard: () => void;
importFromGit: () => void;
importFrom: () => void;
cloneFromGit: () => void;
}
export const EmptyStatePane: FC<Props> = ({ createRequestCollection, createDesignDocument, importFromFile, importFromURL, importFromClipboard, importFromGit }) => {
export const EmptyStatePane: FC<Props> = ({ createRequestCollection, createDesignDocument, importFrom, cloneFromGit }) => {
return (
<Wrapper>
<Title>This is an empty project, to get started create your first resource:</Title>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', marginTop: 'var(--padding-md)' }}>
<div style={{ display: 'flex', flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 'var(--padding-md)', gap: 'var(--padding-md)' }}>
<div style={{ width: '100%', display: 'flex', flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 'var(--padding-md)', gap: 'var(--padding-md)' }}>
<Button
style={{
gap: 'var(--padding-xs)',
flex: 1,
}}
onClick={createRequestCollection}
>
New Collection
<i className='fa fa-bars' /> New Collection
</Button>
<Button
style={{
gap: 'var(--padding-xs)',
flex: 1,
}}
onClick={createDesignDocument}
>
New Document
<i className='fa fa-file-o' /> New Document
</Button>
</div>
<Divider>or</Divider>
<Dropdown
aria-label='Import From Dropdown'
triggerButton={
<DropdownButton style={{ width: '100%', alignSelf: 'stretch' }}>
Import From <i className="fa fa-caret-down pad-left-sm" />
</DropdownButton>
}
>
<DropdownItem aria-label='File'>
<ItemContent
icon="plus"
label="File"
onClick={importFromFile}
/>
</DropdownItem>
<DropdownItem aria-label='URL'>
<ItemContent
icon="link"
label="URL"
onClick={importFromURL}
/>
</DropdownItem>
<DropdownItem aria-label='Clipboard'>
<ItemContent
icon="clipboard"
label="Clipboard"
onClick={importFromClipboard}
/>
</DropdownItem>
<DropdownItem aria-label='Git Clone'>
<ItemContent
icon="code-fork"
label="Git Clone"
onClick={importFromGit}
/>
</DropdownItem>
</Dropdown>
<div style={{ width: '100%', display: 'flex', flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 'var(--padding-md)' }}>
<Button
style={{
gap: 'var(--padding-xs)',
flex: 1,
}}
onClick={importFrom}
>
<i className='fa fa-file-import' /> Import
</Button>
<Button
style={{
gap: 'var(--padding-xs)',
flex: 1,
}}
onClick={cloneFromGit}
>
<i className='fa fa-code-fork' /> Git Clone
</Button>
</div>
</div>
</Wrapper>
);
@@ -1,22 +1,21 @@
import React, { FC, useCallback } from 'react';
import React, { FC, Fragment, useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { useRevalidator } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { getProductName } from '../../../common/constants';
import { docsImportExport } from '../../../common/documentation';
import { exportAllToFile } from '../../../common/export';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
import { ForceToWorkspace } from '../../../common/import';
import { strings } from '../../../common/strings';
import { isRequestGroup } from '../../../models/request-group';
import { importers } from '../../../utils/importers/importers';
import { importClipBoard, importFile, importUri } from '../../import';
import { selectActiveProject, selectActiveProjectName, selectActiveWorkspace, selectActiveWorkspaceName, selectProjects, selectWorkspaceRequestsAndRequestGroups, selectWorkspacesForActiveProject, selectWorkspacesWithResolvedNameForActiveProject } from '../../redux/selectors';
import { selectActiveProjectName, selectActiveWorkspace, selectActiveWorkspaceName, selectWorkspaceRequestsAndRequestGroups, selectWorkspacesForActiveProject } from '../../redux/selectors';
import { Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown';
import { Link } from '../base/link';
import { AlertModal } from '../modals/alert-modal';
import { ExportRequestsModal } from '../modals/export-requests-modal';
import { showModal, showPrompt } from '../modals/index';
import { ImportModal } from '../modals/import-modal';
import { showModal } from '../modals/index';
import { Button } from '../themed-button';
interface Props {
@@ -24,37 +23,17 @@ interface Props {
}
export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
const {
organizationId,
projectId,
workspaceId,
} = useParams() as { organizationId: string; projectId: string; workspaceId: string };
const projectName = useSelector(selectActiveProjectName) ?? getProductName();
const activeWorkspace = useSelector(selectActiveWorkspace);
const activeProjectWorkspaces = useSelector(selectWorkspacesWithResolvedNameForActiveProject);
const activeProject = useSelector(selectActiveProject);
const projects = useSelector(selectProjects);
const forceToWorkspace = activeWorkspace?._id ? ForceToWorkspace.current : ForceToWorkspace.existing;
const activeWorkspaceName = useSelector(selectActiveWorkspaceName);
const workspacesForActiveProject = useSelector(selectWorkspacesForActiveProject);
const workspaceRequestsAndRequestGroups = useSelector(selectWorkspaceRequestsAndRequestGroups);
const { revalidate } = useRevalidator();
const handleImportUri = useCallback(() => {
const lastUsedImportUri = window.localStorage.getItem('insomnia.lastUsedImportUri');
const defaultValue = lastUsedImportUri ? { defaultValue: lastUsedImportUri } : {};
showPrompt({
title: 'Import Data from URL',
submitName: 'Fetch and Import',
label: 'URL',
placeholder: 'https://website.com/insomnia-import.json',
onComplete: (uri: string) => {
window.localStorage.setItem('insomnia.lastUsedImportUri', uri);
importUri(uri, {
activeProjectWorkspaces,
activeProject,
projects,
workspaceId: activeWorkspace?._id,
forceToWorkspace, onComplete: revalidate });
hideSettingsModal();
},
...defaultValue,
});
}, [activeProjectWorkspaces, activeProject, projects, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const showExportRequestsModal = useCallback(() => {
if (!workspaceRequestsAndRequestGroups.filter(r => !isRequestGroup(r)).length) {
@@ -73,109 +52,74 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
hideSettingsModal();
}, [hideSettingsModal, projectName, workspacesForActiveProject]);
const handleImportFile = useCallback(() => {
importFile({
activeProjectWorkspaces,
activeProject,
projects,
workspaceId: activeWorkspace?._id,
forceToWorkspace, onComplete: revalidate });
hideSettingsModal();
}, [activeProjectWorkspaces, activeProject, projects, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]);
const handleImportClipBoard = useCallback(() => {
importClipBoard({
activeProjectWorkspaces,
activeProject,
projects,
workspaceId: activeWorkspace?._id,
forceToWorkspace, onComplete: revalidate });
hideSettingsModal();
}, [activeProjectWorkspaces, activeProject, projects, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]);
const activeWorkspaceName = useSelector(selectActiveWorkspaceName);
return (
<div data-testid="import-export-tab">
<div className="no-margin-top">
Import format will be automatically detected. Supported formats include: {importers.map(importer => importer.name).join(', ')}
</div>
<p>
Your format isn't supported? <Link href={docsImportExport}>Add Your Own</Link>.
</p>
<div className="pad-top">
{activeWorkspace ?
(<Dropdown
aria-label='Export Data Dropdown'
triggerButton={
<DropdownButton className="btn btn--clicky">
Export Data <i className="fa fa-caret-down" />
</DropdownButton>
}
>
<DropdownSection
aria-label="Choose Export Type"
title="Choose Export Type"
<Fragment>
<div data-testid="import-export-tab">
<div className="no-margin-top">
Import format will be automatically detected. Supported formats include: {importers.map(importer => importer.name).join(', ')}
</div>
<p>
Your format isn't supported? <Link href={docsImportExport}>Add Your Own</Link>.
</p>
<div className="pad-top">
{activeWorkspace ?
(<Dropdown
aria-label='Export Data Dropdown'
triggerButton={
<DropdownButton className="btn btn--clicky">
Export Data <i className="fa fa-caret-down" />
</DropdownButton>
}
>
<DropdownItem aria-label={`Export the "${activeWorkspaceName}" ${getWorkspaceLabel(activeWorkspace).singular}`}>
<ItemContent
icon="home"
label={`Export the "${activeWorkspaceName}" ${getWorkspaceLabel(activeWorkspace).singular}`}
onClick={showExportRequestsModal}
/>
</DropdownItem>
<DropdownItem aria-label={`All ${strings.document.plural} and ${strings.collection.plural} from the "${projectName}" ${strings.project.singular}`}>
<ItemContent
icon="empty"
label={`All ${strings.document.plural} and ${strings.collection.plural} from the "${projectName}" ${strings.project.singular}`}
onClick={handleExportAllToFile}
/>
</DropdownItem>
</DropdownSection>
</Dropdown>) : (<Button onClick={handleExportAllToFile}>Export all</Button>)
}
&nbsp;&nbsp;
<Dropdown
aria-label='Import Data Dropdown'
triggerButton={
<DropdownButton className="btn btn--clicky">
Import Data <i className="fa fa-caret-down" />
</DropdownButton>
<DropdownSection
aria-label="Choose Export Type"
title="Choose Export Type"
>
<DropdownItem aria-label={`Export the "${activeWorkspaceName}" ${getWorkspaceLabel(activeWorkspace).singular}`}>
<ItemContent
icon="home"
label={`Export the "${activeWorkspaceName}" ${getWorkspaceLabel(activeWorkspace).singular}`}
onClick={showExportRequestsModal}
/>
</DropdownItem>
<DropdownItem aria-label={`All ${strings.document.plural} and ${strings.collection.plural} from the "${projectName}" ${strings.project.singular}`}>
<ItemContent
icon="empty"
label={`All ${strings.document.plural} and ${strings.collection.plural} from the "${projectName}" ${strings.project.singular}`}
onClick={handleExportAllToFile}
/>
</DropdownItem>
</DropdownSection>
</Dropdown>) : (<Button onClick={handleExportAllToFile}>Export all</Button>)
}
>
<DropdownSection
aria-label="Choose Import Type"
title="Choose Import Type"
>
<DropdownItem aria-label='From File'>
<ItemContent
icon="file-o"
label="From File"
onClick={handleImportFile}
/>
</DropdownItem>
<DropdownItem aria-label='From URL'>
<ItemContent
icon="link"
label="From URL"
onClick={handleImportUri}
/>
</DropdownItem>
<DropdownItem aria-label='From Clipboard'>
<ItemContent
icon="clipboard"
label="From Clipboard"
onClick={handleImportClipBoard}
/>
</DropdownItem>
</DropdownSection>
</Dropdown>
&nbsp;&nbsp;
<Link href="https://insomnia.rest/create-run-button" className="btn btn--compact" button>
Create Run Button
</Link>
<Button
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-sm)',
}}
onClick={() => setIsImportModalOpen(true)}
>
<i className="fa fa-file-import" />
Import
</Button>
&nbsp;&nbsp;
<Link href="https://insomnia.rest/create-run-button" className="btn btn--compact" button>
Create Run Button
</Link>
</div>
<p className="italic faint">* Tip: You can also paste Curl commands into the URL bar</p>
</div>
<p className="italic faint">* Tip: You can also paste Curl commands into the URL bar</p>
</div>
{isImportModalOpen && (
<ImportModal
onHide={() => setIsImportModalOpen(false)}
from={'file'}
organizationId={organizationId}
defaultProjectId={projectId}
defaultWorkspaceId={workspaceId}
/>
)}
</Fragment>
);
};
@@ -2,7 +2,6 @@ import { ipcRenderer } from 'electron';
import { FC, useEffect } from 'react';
import { useAppCommands } from '../hooks/use-app-commands';
import { useDragAndDropImportFile } from '../hooks/use-drag-and-drop-import-file';
import { useGlobalKeyboardShortcuts } from '../hooks/use-global-keyboard-shortcuts';
import { useSettingsSideEffects } from '../hooks/use-settings-side-effects';
import { useSyncMigration } from '../hooks/use-sync-migration';
@@ -12,7 +11,6 @@ export const AppHooks: FC = () => {
useSyncMigration();
useSettingsSideEffects();
useGlobalKeyboardShortcuts();
useDragAndDropImportFile();
useThemeChange();
useAppCommands();
// Give it a bit before letting the backend know it's ready
@@ -4,7 +4,6 @@ import React from 'react';
import { useSelector } from 'react-redux';
import { isDevelopment } from '../../common/constants';
import { askToImportIntoProject, askToImportIntoWorkspace, askToSetWorkspaceScope, importUri } from '../../common/import';
import * as models from '../../models';
import { reloadPlugins } from '../../plugins';
import { createPlugin } from '../../plugins/create';
@@ -53,26 +52,19 @@ export const useAppCommands = () => {
});
break;
case 'insomnia://app/import':
showModal(AlertModal, {
title: 'Confirm Data Import',
message: (
<span>
Do you really want to import {params.name && (<><code>{params.name}</code> from</>)} <code>{params.uri}</code>?
</span>
),
addCancel: true,
onConfirm: async () => {
const activeWorkspaceId = activeWorkspace?._id;
importUri(params.uri, {
getWorkspaceScope: askToSetWorkspaceScope(),
getWorkspaceId: askToImportIntoWorkspace({ workspaceId: params.workspaceId || activeWorkspaceId, activeProjectWorkspaces }),
// Currently, just return the active project instead of prompting for which project to import into
getProjectId: askToImportIntoProject({ projects, activeProject }),
});
},
});
break;
// case 'insomnia://app/import':
// @TODO @gatzjames - FixME - Move this to a separate hook in the root of the app that opens the import modal
// showModal(AlertModal, {
// title: 'Confirm Data Import',
// message: (
// <span>
// Do you really want to import {params.name && (<><code>{params.name}</code> from</>)} <code>{params.uri}</code>?
// </span>
// ),
// addCancel: true,
// onConfirm: async () => {},
// });
// break;
case 'insomnia://plugins/install':
showModal(AskModal, {
@@ -1,60 +0,0 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { showModal } from '../components/modals';
import { AlertModal } from '../components/modals/alert-modal';
import { importUri } from '../import';
import { selectActiveProject, selectActiveWorkspace, selectProjects, selectWorkspacesWithResolvedNameForActiveProject } from '../redux/selectors';
export const useDragAndDropImportFile = () => {
const activeWorkspace = useSelector(selectActiveWorkspace);
const activeProjectWorkspaces = useSelector(selectWorkspacesWithResolvedNameForActiveProject);
const activeProject = useSelector(selectActiveProject);
const projects = useSelector(selectProjects);
// Global Drag and Drop for importing files
useEffect(() => {
// NOTE: This is required for "drop" event to trigger.
document.addEventListener(
'dragover',
event => {
event.preventDefault();
},
false,
);
document.addEventListener(
'drop',
async event => {
event.preventDefault();
if (!activeWorkspace) {
return;
}
const files = event.dataTransfer?.files || [];
if (files.length === 0) {
console.log('[drag] Ignored drop event because no files present');
return;
}
const file = files[0];
if (!file?.path) {
return;
}
await showModal(AlertModal, {
title: 'Confirm Data Import',
message: (
<span>
Import <code>{file.path}</code>?
</span>
),
addCancel: true,
});
importUri(`file://${file.path}`, {
activeProjectWorkspaces,
activeProject,
projects,
workspaceId: activeWorkspace?._id,
});
},
false,
);
});
};
-192
View File
@@ -1,192 +0,0 @@
import electron, { OpenDialogOptions } from 'electron';
import {
askToImportIntoProject,
askToImportIntoWorkspace,
askToSetWorkspaceScope,
ForceToWorkspace,
importRaw,
importUri as _importUri,
} from '../common/import';
import * as models from '../models';
import { DEFAULT_PROJECT_ID, Project } from '../models/project';
import { Workspace, WorkspaceScope } from '../models/workspace';
import { showError, showModal } from './components/modals';
import { AlertModal } from './components/modals/alert-modal';
export interface ImportOptions {
workspaceId?: string;
forceToProject?: 'active' | 'prompt';
forceToWorkspace?: ForceToWorkspace;
forceToScope?: WorkspaceScope;
onComplete?: () => void;
activeProject?: Project;
activeProjectWorkspaces?: Workspace[];
projects?: Project[];
}
export const importFile = async (
{
forceToScope,
forceToWorkspace,
workspaceId,
forceToProject,
activeProject,
activeProjectWorkspaces,
projects,
onComplete,
}: ImportOptions = {},
) => {
const openDialogOptions: OpenDialogOptions = {
title: 'Import Insomnia Data',
buttonLabel: 'Import',
properties: ['openFile'],
filters: [
// @ts-expect-error https://github.com/electron/electron/pull/29322
{
extensions: [
'',
'sh',
'txt',
'json',
'har',
'curl',
'bash',
'shell',
'yaml',
'yml',
'wsdl',
],
},
],
};
const { canceled, filePaths } = await window.dialog.showOpenDialog(openDialogOptions);
if (canceled) {
// It was cancelled, so let's bail out
return;
}
// Let's import all the files!
for (const filePath of filePaths) {
try {
const uri = `file://${filePath}`;
const config = {
getWorkspaceScope: askToSetWorkspaceScope(forceToScope),
getWorkspaceId: askToImportIntoWorkspace({ workspaceId, forceToWorkspace, activeProjectWorkspaces }),
// Currently, just return the active project instead of prompting for which project to import into
getProjectId: forceToProject === 'prompt' ? askToImportIntoProject({ projects, activeProject }) : () => Promise.resolve(activeProject?._id || DEFAULT_PROJECT_ID),
};
const { error, summary } = await _importUri(uri, config);
if (!error) {
models.stats.incrementRequestStats({ createdRequests: summary[models.request.type].length + summary[models.grpcRequest.type].length });
}
if (error) {
showError({
title: 'Import Failed',
message: 'The file does not contain a valid specification.',
error,
});
}
} catch (err) {
showModal(AlertModal, {
title: 'Import Failed',
message: err + '',
});
}
}
onComplete?.();
};
export const importClipBoard = async ({
forceToScope,
forceToWorkspace,
workspaceId,
forceToProject,
activeProject,
activeProjectWorkspaces,
projects,
onComplete,
}: ImportOptions = {},
) => {
const schema = electron.clipboard.readText();
if (!schema) {
showModal(AlertModal, {
title: 'Import Failed',
message: 'Your clipboard appears to be empty.',
});
return;
}
// Let's import all the paths!
try {
const config = {
getWorkspaceScope: askToSetWorkspaceScope(forceToScope),
getWorkspaceId: askToImportIntoWorkspace({ workspaceId, forceToWorkspace, activeProjectWorkspaces }),
// Currently, just return the active project instead of prompting for which project to import into
getProjectId: forceToProject === 'prompt' ? askToImportIntoProject({ projects, activeProject }) : () => Promise.resolve(activeProject?._id || DEFAULT_PROJECT_ID),
};
const { error, summary } = await importRaw(schema, config);
if (!error) {
models.stats.incrementRequestStats({ createdRequests: summary[models.request.type].length + summary[models.grpcRequest.type].length });
}
if (error) {
showError({
title: 'Import Failed',
message: 'Your clipboard does not contain a valid specification.',
error,
});
}
} catch (err) {
showModal(AlertModal, {
title: 'Import Failed',
message: 'Your clipboard does not contain a valid specification.',
});
}
onComplete?.();
};
export const importUri = async (
uri: string,
{
forceToScope,
forceToWorkspace,
workspaceId,
forceToProject,
activeProject,
activeProjectWorkspaces,
projects,
onComplete,
}: ImportOptions = {},
) => {
try {
const config = {
getWorkspaceScope: askToSetWorkspaceScope(forceToScope),
getWorkspaceId: askToImportIntoWorkspace({ workspaceId, forceToWorkspace, activeProjectWorkspaces }),
// Currently, just return the active project instead of prompting for which project to import into
getProjectId: forceToProject === 'prompt' ? askToImportIntoProject({ projects, activeProject }) : () => Promise.resolve(activeProject?._id || DEFAULT_PROJECT_ID),
};
const { error, summary } = await _importUri(uri, config);
if (!error) {
models.stats.incrementRequestStats({ createdRequests: summary[models.request.type].length + summary[models.grpcRequest.type].length });
}
if (error) {
showError({
title: 'Import Failed',
message: 'The URI does not contain a valid specification.',
error,
});
}
} catch (err) {
showModal(AlertModal, {
title: 'Import Failed',
message: err + '',
});
}
onComplete?.();
};
+13
View File
@@ -69,6 +69,19 @@ const router = createMemoryRouter(
element: <Root />,
errorElement: <ErrorRoute />,
children: [
{
path: 'import',
children: [
{
path: 'scan',
action: async (...args) => (await import('./routes/import')).scanForResourcesAction(...args),
},
{
path: 'resources',
action: async (...args) => (await import('./routes/import')).importResourcesAction(...args),
},
],
},
{
path: 'organization',
children: [
+9 -8
View File
@@ -6,7 +6,7 @@ import { ActionFunction, redirect } from 'react-router-dom';
import * as session from '../../account/session';
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants';
import { database } from '../../common/database';
import { importRaw } from '../../common/import';
import { importResources, scanResources } from '../../common/import';
import * as models from '../../models';
import * as workspaceOperations from '../../models/helpers/workspace-operations';
import { DEFAULT_ORGANIZATION_ID } from '../../models/organization';
@@ -466,6 +466,7 @@ export const generateCollectionFromApiSpecAction: ActionFunction = async ({
}) => {
const { organizationId, projectId, workspaceId } = params;
invariant(typeof projectId === 'string', 'Project ID is required');
invariant(typeof workspaceId === 'string', 'Workspace ID is required');
const apiSpec = await models.apiSpec.getByParentId(workspaceId);
@@ -491,13 +492,13 @@ export const generateCollectionFromApiSpecAction: ActionFunction = async ({
throw new Error('Error Generating Configuration');
}
await importRaw(apiSpec.contents, {
getWorkspaceId: () => Promise.resolve(workspaceId),
enableDiffBasedPatching: true,
enableDiffDeep: true,
bypassDiffProps: {
url: true,
},
await scanResources({
content: apiSpec.contents,
});
await importResources({
projectId,
workspaceId,
});
return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/${ACTIVITY_DEBUG}`);
+103
View File
@@ -0,0 +1,103 @@
// Import
import { clipboard } from 'electron';
import { ActionFunction, redirect } from 'react-router-dom';
import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../../common/constants';
import { fetchImportContentFromURI, importResources, scanResources, ScanResult } from '../../common/import';
import * as models from '../../models';
import { WorkspaceScope } from '../../models/workspace';
import { invariant } from '../../utils/invariant';
function guard<T>(condition: any, value: any): asserts value is T {
if (!condition) {
throw new Error('Guard failed');
}
return value;
}
export interface ScanForResourcesActionResult extends ScanResult {}
export const scanForResourcesAction: ActionFunction = async ({ request }): Promise<ScanForResourcesActionResult> => {
const formData = await request.formData();
const source = formData.get('importFrom');
invariant(typeof source === 'string', 'Source is required.');
guard<'file' | 'uri' | 'clipboard'>(source, ['file', 'uri', 'clipboard'].includes(source));
let content = '';
if (source === 'uri') {
const uri = formData.get('uri');
if (typeof uri !== 'string' || uri === '') {
return {
errors: ['URI is required'],
};
}
content = await fetchImportContentFromURI({
uri,
});
} else if (source === 'file') {
const filePath = formData.get('filePath');
if (typeof filePath !== 'string' || filePath === '') {
return {
errors: ['File is required'],
};
}
const uri = `file://${filePath}`;
content = await fetchImportContentFromURI({
uri,
});
} else {
content = clipboard.readText();
}
if (!content) {
return {
errors: ['No content to import'],
};
}
const result = await scanResources({ content });
return result;
};
export interface ImportResourcesActionResult {
errors?: string[];
}
export const importResourcesAction: ActionFunction = async ({ request }): Promise<ImportResourcesActionResult | Response> => {
const formData = await request.formData();
const organizationId = formData.get('organizationId');
const projectId = formData.get('projectId');
const workspaceId = formData.get('workspaceId');
const scope = formData.get('scope') || 'design';
const name = formData.get('name');
const workspaceName = typeof name === 'string' ? name : 'Untitled';
invariant(typeof organizationId === 'string', 'OrganizationId is required.');
invariant(typeof projectId === 'string', 'ProjectId is required.');
invariant(typeof workspaceId === 'string', 'WorkspaceId is required.');
invariant(typeof scope === 'string', 'Scope is required.');
guard<WorkspaceScope>(scope === 'design' || scope === 'collection', scope);
const project = await models.project.getById(projectId);
invariant(project, 'Project not found.');
const result = await importResources({
projectId: project._id,
workspaceId,
workspaceName,
});
return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${result.workspace._id}/${
result.workspace.scope === 'design'
? ACTIVITY_SPEC
: ACTIVITY_DEBUG
}`);
};
+81 -127
View File
@@ -1,4 +1,4 @@
import React, { FC, Fragment, useCallback, useRef, useState } from 'react';
import React, { FC, Fragment, useRef, useState } from 'react';
import {
AriaButtonProps,
AriaGridListItemOptions,
@@ -16,17 +16,11 @@ import {
useLoaderData,
useNavigate,
useParams,
useRevalidator,
useRouteLoaderData,
useSearchParams,
useSubmit,
} from 'react-router-dom';
import {
Item,
ListProps,
ListState,
useListState,
} from 'react-stately';
import { Item, ListProps, ListState, useListState } from 'react-stately';
import styled from 'styled-components';
import { parseApiSpec, ParsedApiSpec } from '../../common/api-specs';
@@ -36,7 +30,6 @@ import {
DashboardSortOrder,
} from '../../common/constants';
import { clickLink } from '../../common/electron-helpers';
import { ForceToWorkspace } from '../../common/import';
import { fuzzyMatchAll, isNotNullOrUndefined } from '../../common/misc';
import { descendingNumberSort, sortMethodMap } from '../../common/sorting';
import { strings } from '../../common/strings';
@@ -68,11 +61,11 @@ import { RemoteWorkspacesDropdown } from '../components/dropdowns/remote-workspa
import { ErrorBoundary } from '../components/error-boundary';
import { showAlert, showPrompt } from '../components/modals';
import { GitRepositoryCloneModal } from '../components/modals/git-repository-settings-modal/git-repo-clone-modal';
import { ImportModal } from '../components/modals/import-modal';
import { EmptyStatePane } from '../components/panes/project-empty-state-pane';
import { SidebarLayout } from '../components/sidebar-layout';
import { Button } from '../components/themed-button/button';
import { WorkspaceCard } from '../components/workspace-card';
import { importClipBoard, importFile, importUri } from '../import';
import { RootLoaderData } from './root';
const StyledDropdownButton = styled(DropdownButton).attrs({
@@ -308,21 +301,23 @@ const OrganizationProjectsSidebar: FC<{
</SidebarTitle>
<SidebarSection>
<SidebarSectionTitle>Projects ({projects.length})</SidebarSectionTitle>
{shouldShowSearch && <Button
style={{
padding: 'var(--padding-sm)',
minWidth: 'auto',
width: 'unset',
flex: 0,
}}
variant="text"
size="small"
onClick={() => {
setSearchOpen(!isSearchOpen);
}}
>
<i data-testid="SearchProjectsButton" className="fa fa-search" />
</Button>}
{shouldShowSearch && (
<Button
style={{
padding: 'var(--padding-sm)',
minWidth: 'auto',
width: 'unset',
flex: 0,
}}
variant="text"
size="small"
onClick={() => {
setSearchOpen(!isSearchOpen);
}}
>
<i data-testid="SearchProjectsButton" className="fa fa-search" />
</Button>
)}
<Button
style={{
padding: 'var(--padding-sm)',
@@ -390,9 +385,7 @@ const OrganizationProjectsSidebar: FC<{
</SearchFormControl>
</SearchFormContainer>
)}
<ProjectListContainer
className="sidebar__list sidebar__list-root theme--sidebar__list"
>
<ProjectListContainer className="sidebar__list sidebar__list-root theme--sidebar__list">
{projects.map(proj => {
return (
<li key={proj._id} className="sidebar__row">
@@ -409,12 +402,10 @@ const OrganizationProjectsSidebar: FC<{
gap: 'var(--padding-sm)',
}}
onClick={() =>
navigate(
{
pathname: `/organization/${organizationId}/project/${proj._id}`,
search: searchParams.toString(),
},
)
navigate({
pathname: `/organization/${organizationId}/project/${proj._id}`,
search: searchParams.toString(),
})
}
className="wide"
>
@@ -510,7 +501,7 @@ const OrganizationProjectsSidebar: FC<{
<List
onAction={key => {
clickLink(key);
clickLink(key.toString());
}}
>
<Item
@@ -588,6 +579,7 @@ const SidebarListItemTitle = ({ icon, label }: SidebarListItemTitleProps) => {
};
interface WorkspaceWithMetadata {
_id: string;
hasUnsavedChanges: boolean;
lastModifiedTimestamp: number;
modifiedLocally: number;
@@ -623,7 +615,7 @@ export const indexLoader: LoaderFunction = async ({ params }) => {
return;
};
interface LoaderData {
export interface ProjectLoaderData {
workspaces: WorkspaceWithMetadata[];
allFilesCount: number;
documentsCount: number;
@@ -636,7 +628,7 @@ interface LoaderData {
export const loader: LoaderFunction = async ({
params,
request,
}): Promise<LoaderData> => {
}): Promise<ProjectLoaderData> => {
const search = new URL(request.url).searchParams;
const { projectId, organizationId } = params;
invariant(organizationId, 'Organization ID is required');
@@ -704,6 +696,7 @@ export const loader: LoaderFunction = async ({
);
return {
_id: workspace._id,
hasUnsavedChanges,
lastModifiedTimestamp,
modifiedLocally,
@@ -795,7 +788,9 @@ export const loader: LoaderFunction = async ({
? allProjects.filter(proj => !isRemoteProject(proj))
: [project];
const projects = sortProjects(organizationProjects).filter(p => p.name.toLowerCase().includes(projectName.toLowerCase()));
const projects = sortProjects(organizationProjects).filter(p =>
p.name.toLowerCase().includes(projectName.toLowerCase())
);
return {
organization:
@@ -827,20 +822,21 @@ const ProjectRoute: FC = () => {
allFilesCount,
collectionsCount,
documentsCount,
} = useLoaderData() as LoaderData;
} = useLoaderData() as ProjectLoaderData;
const { organizationId } = useParams() as { organizationId: string };
const [searchParams] = useSearchParams();
const [isGitRepositoryCloneModalOpen, setIsGitRepositoryCloneModalOpen] =
useState(false);
const fetcher = useFetcher();
const { revalidate } = useRevalidator();
const submit = useSubmit();
const navigate = useNavigate();
const filter = searchParams.get('filter') || '';
const sortOrder =
(searchParams.get('sortOrder') as DashboardSortOrder) || 'modified-desc';
const [isImportModalOpen, setIsImportModalOpen] = useState<
'uri' | 'file' | 'clipboard' | false
>(false);
const createNewCollection = () => {
showPrompt({
title: 'Create New Request Collection',
@@ -885,44 +881,6 @@ const ProjectRoute: FC = () => {
});
};
const importFromURL = useCallback(() => {
showPrompt({
title: 'Import document from URL',
submitName: 'Fetch and Import',
label: 'URL',
placeholder: 'https://website.com/insomnia-import.json',
onComplete: uri => {
importUri(uri, {
activeProjectWorkspaces: workspaces.map(w => w.workspace),
activeProject,
projects,
forceToWorkspace: ForceToWorkspace.existing,
onComplete: revalidate,
});
},
});
}, [activeProject, projects, revalidate, workspaces]);
const importFromClipboard = useCallback(() => {
importClipBoard({
activeProjectWorkspaces: workspaces.map(w => w.workspace),
activeProject,
projects,
forceToWorkspace: ForceToWorkspace.existing,
onComplete: revalidate,
});
}, [activeProject, projects, revalidate, workspaces]);
const importFromFile = useCallback(() => {
importFile({
activeProjectWorkspaces: workspaces.map(w => w.workspace),
activeProject,
projects,
forceToWorkspace: ForceToWorkspace.existing,
onComplete: revalidate,
});
}, [activeProject, projects, revalidate, workspaces]);
const importFromGit = () => {
setIsGitRepositoryCloneModalOpen(true);
};
@@ -949,10 +907,14 @@ const ProjectRoute: FC = () => {
}
renderPaneOne={
<Pane
style={!hasWorkspaces ? {
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr',
} : undefined}
style={
!hasWorkspaces
? {
gridTemplateRows: 'auto 1fr',
gridTemplateColumns: '1fr',
}
: undefined
}
>
<PaneHeaderToolbar>
<SearchFormControl className="form-control form-control--outlined no-margin">
@@ -1026,27 +988,13 @@ const ProjectRoute: FC = () => {
</DropdownSection>
<DropdownSection
aria-label="Import From"
title="Import From"
title="Import"
>
<DropdownItem aria-label="File">
<DropdownItem aria-label="Import">
<ItemContent
icon="plus"
label="File"
onClick={importFromFile}
/>
</DropdownItem>
<DropdownItem aria-label="URL">
<ItemContent
icon="link"
label="URL"
onClick={importFromURL}
/>
</DropdownItem>
<DropdownItem aria-label="Clipboard">
<ItemContent
icon="clipboard"
label="Clipboard"
onClick={importFromClipboard}
icon="file-import"
label="Import"
onClick={() => setIsImportModalOpen('file')}
/>
</DropdownItem>
<DropdownItem aria-label="Git Clone">
@@ -1059,28 +1007,28 @@ const ProjectRoute: FC = () => {
</DropdownSection>
</Dropdown>
</div>
</PaneHeaderToolbar>
{hasWorkspaces && workspaces.map(workspace => (
<WorkspaceCard
{...workspace}
projects={projects}
key={workspace.apiSpec._id}
activeProject={activeProject}
onSelect={() =>
navigate(
`/organization/${organizationId}/project/${
activeProject._id
}/workspace/${workspace.workspace._id}/${
workspace.workspace.scope === 'design'
? ACTIVITY_SPEC
: ACTIVITY_DEBUG
}`
)
}
filter={filter}
/>
))}
{hasWorkspaces &&
workspaces.map(workspace => (
<WorkspaceCard
{...workspace}
projects={projects}
key={workspace.apiSpec._id}
activeProject={activeProject}
onSelect={() =>
navigate(
`/organization/${organizationId}/project/${
activeProject._id
}/workspace/${workspace.workspace._id}/${
workspace.workspace.scope === 'design'
? ACTIVITY_SPEC
: ACTIVITY_DEBUG
}`
)
}
filter={filter}
/>
))}
{filter && !hasWorkspaces && (
<div>
<p className="notice subtle">
@@ -1092,10 +1040,8 @@ const ProjectRoute: FC = () => {
<EmptyStatePane
createRequestCollection={createNewCollection}
createDesignDocument={createNewDocument}
importFromFile={importFromFile}
importFromURL={importFromURL}
importFromClipboard={importFromClipboard}
importFromGit={importFromGit}
importFrom={() => setIsImportModalOpen('file')}
cloneFromGit={importFromGit}
/>
)}
</Pane>
@@ -1106,6 +1052,14 @@ const ProjectRoute: FC = () => {
onHide={() => setIsGitRepositoryCloneModalOpen(false)}
/>
)}
{isImportModalOpen && (
<ImportModal
onHide={() => setIsImportModalOpen(false)}
from={isImportModalOpen}
organizationId={organizationId}
defaultProjectId={activeProject._id}
/>
)}
</Fragment>
</ErrorBoundary>
);
+1 -3
View File
@@ -67,9 +67,7 @@ const Layout = styled.div({
const Root = () => {
const { revalidate } = useRevalidator();
const workspaceData = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData | null;
console.log({
workspaceData,
});
useEffect(() => {
onLoginLogout(() => {
revalidate();