From fc730b67e333617c0af38a3b623b3f259c2583d9 Mon Sep 17 00:00:00 2001 From: Jack Kavanagh Date: Wed, 16 Nov 2022 09:56:58 +0000 Subject: [PATCH] Remove/redux-loading (#5381) * removes 4 redux actions * fix lint * fix tests * fix tests (actually) * grumble * remove unused const * request timer still needs state from somewhere * remove redux from import logic * simplfied import files * simplfy imports again * move to ui root * remove unused * drag and drop import * remove console log * fix lint * key loading state on request id * fix loading first state --- .../src/__jest__/redux-state-for-test.ts | 2 - .../__tests__/import-helpers.test.ts} | 6 +- .../src/common/{export.ts => export.tsx} | 255 ++++++++- packages/insomnia/src/common/import.ts | 199 +++++-- .../src/sync/git/gitlab-oauth-provider.ts | 1 - .../insomnia/src/ui/components/breadcrumb.tsx | 6 +- .../dropdowns/workspace-dropdown.tsx | 3 - .../modals/export-requests-modal.tsx | 2 +- .../github-repository-settings-form-group.tsx | 19 +- .../gitlab-repository-settings-form-group.tsx | 19 +- .../panes/placeholder-request-pane.tsx | 4 +- .../src/ui/components/panes/request-pane.tsx | 3 + .../src/ui/components/panes/response-pane.tsx | 10 +- .../src/ui/components/request-url-bar.tsx | 22 +- .../ui/components/settings/import-export.tsx | 41 +- .../insomnia/src/ui/hooks/use-app-commands.ts | 24 - .../src/ui/hooks/use-app-commands.tsx | 164 ++++++ .../hooks/use-drag-and-drop-import-file.tsx | 18 +- packages/insomnia/src/ui/import.ts | 192 +++++++ .../ui/redux/modules/__tests__/git.test.tsx | 110 +--- .../redux/modules/__tests__/workspace.test.ts | 3 +- .../insomnia/src/ui/redux/modules/git.tsx | 61 ++- .../insomnia/src/ui/redux/modules/global.tsx | 495 +----------------- .../insomnia/src/ui/redux/modules/helpers.ts | 154 ------ .../insomnia/src/ui/redux/modules/import.ts | 179 ------- .../insomnia/src/ui/redux/modules/project.ts | 48 -- .../src/ui/redux/modules/workspace.ts | 69 +-- packages/insomnia/src/ui/redux/selectors.ts | 11 - packages/insomnia/src/ui/routes/debug.tsx | 14 +- packages/insomnia/src/ui/routes/project.tsx | 68 ++- 30 files changed, 959 insertions(+), 1243 deletions(-) rename packages/insomnia/src/{ui/redux/modules/__tests__/helpers.test.ts => common/__tests__/import-helpers.test.ts} (86%) rename packages/insomnia/src/common/{export.ts => export.tsx} (54%) delete mode 100644 packages/insomnia/src/ui/hooks/use-app-commands.ts create mode 100644 packages/insomnia/src/ui/hooks/use-app-commands.tsx create mode 100644 packages/insomnia/src/ui/import.ts delete mode 100644 packages/insomnia/src/ui/redux/modules/helpers.ts delete mode 100644 packages/insomnia/src/ui/redux/modules/import.ts delete mode 100644 packages/insomnia/src/ui/redux/modules/project.ts diff --git a/packages/insomnia/src/__jest__/redux-state-for-test.ts b/packages/insomnia/src/__jest__/redux-state-for-test.ts index aec837d89..9ba522349 100644 --- a/packages/insomnia/src/__jest__/redux-state-for-test.ts +++ b/packages/insomnia/src/__jest__/redux-state-for-test.ts @@ -39,9 +39,7 @@ export const reduxStateForTest = async (global: Partial = {}): Prom activeActivity: ACTIVITY_HOME, activeProjectId: DEFAULT_PROJECT_ID, dashboardSortOrder: 'modified-desc', - isLoading: false, isLoggedIn: false, - loadingRequestIds: {}, ...global, }, }; diff --git a/packages/insomnia/src/ui/redux/modules/__tests__/helpers.test.ts b/packages/insomnia/src/common/__tests__/import-helpers.test.ts similarity index 86% rename from packages/insomnia/src/ui/redux/modules/__tests__/helpers.test.ts rename to packages/insomnia/src/common/__tests__/import-helpers.test.ts index 46a61061c..70ee8e503 100644 --- a/packages/insomnia/src/ui/redux/modules/__tests__/helpers.test.ts +++ b/packages/insomnia/src/common/__tests__/import-helpers.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, jest } from '@jest/globals'; -import * as modals from '../../../components/modals'; -import { askToImportIntoWorkspace, ForceToWorkspace } from '../helpers'; +import * as modals from '../../ui/components/modals'; +import { askToImportIntoWorkspace, ForceToWorkspace } from '../import'; -jest.mock('../../../components/modals'); +jest.mock('../../ui/components/modals'); describe('askToImportIntoWorkspace', () => { it('should return null if no active workspace', () => { diff --git a/packages/insomnia/src/common/export.ts b/packages/insomnia/src/common/export.tsx similarity index 54% rename from packages/insomnia/src/common/export.ts rename to packages/insomnia/src/common/export.tsx index c320edce5..0c495106e 100644 --- a/packages/insomnia/src/common/export.ts +++ b/packages/insomnia/src/common/export.tsx @@ -1,11 +1,18 @@ import clone from 'clone'; +import { format } from 'date-fns'; +import fs from 'fs'; +import { NoParamCallback } from 'fs-extra'; import { Insomnia4Data } from 'insomnia-importers'; +import path from 'path'; +import React from 'react'; +import { unreachableCase } from 'ts-assert-unreachable'; import YAML from 'yaml'; import { isApiSpec } from '../models/api-spec'; import { isCookieJar } from '../models/cookie-jar'; -import { isEnvironment } from '../models/environment'; +import { Environment, isEnvironment } from '../models/environment'; import { isGrpcRequest } from '../models/grpc-request'; +import * as requestOperations from '../models/helpers/request-operations'; import type { BaseModel } from '../models/index'; import * as models from '../models/index'; import { isProtoDirectory } from '../models/proto-directory'; @@ -19,6 +26,9 @@ import { isWebSocketRequest } from '../models/websocket-request'; import { isWorkspace, Workspace } from '../models/workspace'; import { resetKeys } from '../sync/ignore-keys'; import { SegmentEvent, trackSegmentEvent } from '../ui/analytics'; +import { showAlert, showError, showModal } from '../ui/components/modals'; +import { AskModal } from '../ui/components/modals/ask-modal'; +import { SelectModal } from '../ui/components/modals/select-modal'; import { EXPORT_TYPE_API_SPEC, EXPORT_TYPE_COOKIE_JAR, @@ -35,8 +45,9 @@ import { EXPORT_TYPE_WORKSPACE, getAppVersion, } from './constants'; -import { database as db } from './database'; +import { database, database as db } from './database'; import * as har from './har'; +import { strings } from './strings'; const EXPORT_FORMAT = 4; @@ -270,3 +281,243 @@ export async function exportRequestsData( throw new Error(`Invalid export format ${format}. Must be "json" or "yaml"`); } } + +const VALUE_JSON = 'json'; +const VALUE_YAML = 'yaml'; +const VALUE_HAR = 'har'; + +export type SelectedFormat = + | typeof VALUE_HAR + | typeof VALUE_JSON + | typeof VALUE_YAML + ; + +const showSelectExportTypeModal = ({ onDone }: { + onDone: (selectedFormat: SelectedFormat) => Promise; +}) => { + const options = [ + { + name: 'Insomnia v4 (JSON)', + value: VALUE_JSON, + }, + { + name: 'Insomnia v4 (YAML)', + value: VALUE_YAML, + }, + { + name: 'HAR – HTTP Archive Format', + value: VALUE_HAR, + }, + ]; + + const lastFormat = window.localStorage.getItem('insomnia.lastExportFormat'); + const defaultValue = options.find(({ value }) => value === lastFormat) ? lastFormat : VALUE_JSON; + + showModal(SelectModal, { + title: 'Select Export Type', + value: defaultValue, + options, + message: 'Which format would you like to export as?', + onDone: async selectedFormat => { + if (selectedFormat) { + window.localStorage.setItem('insomnia.lastExportFormat', selectedFormat); + await onDone(selectedFormat as SelectedFormat); + } + }, + }); +}; + +const showExportPrivateEnvironmentsModal = async (privateEnvNames: string) => { + return new Promise(resolve => { + showModal(AskModal, { + title: 'Export Private Environments?', + message: `Do you want to include private environments (${privateEnvNames}) in your export?`, + onDone: async (isYes: boolean) => { + if (isYes) { + resolve(true); + } else { + resolve(false); + } + }, + }); + }); +}; + +const showSaveExportedFileDialog = async ({ + exportedFileNamePrefix, + selectedFormat, +}: { + exportedFileNamePrefix: string; + selectedFormat: SelectedFormat; +}) => { + const date = format(Date.now(), 'yyyy-MM-dd'); + const name = exportedFileNamePrefix.replace(/ /g, '-'); + const lastDir = window.localStorage.getItem('insomnia.lastExportPath'); + const dir = lastDir || window.app.getPath('desktop'); + const options = { + title: 'Export Insomnia Data', + buttonLabel: 'Export', + defaultPath: `${path.join(dir, `${name}_${date}`)}.${selectedFormat}`, + }; + const { filePath } = await window.dialog.showSaveDialog(options); + return filePath || null; +}; + +const writeExportedFileToFileSystem = (filename: string, jsonData: string, onDone: NoParamCallback) => { + // Remember last exported path + window.localStorage.setItem('insomnia.lastExportPath', path.dirname(filename)); + fs.writeFile(filename, jsonData, {}, onDone); +}; + +export const exportAllToFile = (activeProjectName: string, workspacesForActiveProject: Workspace[]) => { + if (!workspacesForActiveProject.length) { + showAlert({ + title: 'Cannot export', + message: <>There are no workspaces to export in the {activeProjectName} {strings.project.singular.toLowerCase()}., + }); + return; + } + + showSelectExportTypeModal({ + onDone: async selectedFormat => { + // Check if we want to export private environments. + const environments = await models.environment.all(); + + let exportPrivateEnvironments = false; + const privateEnvironments = environments.filter(environment => environment.isPrivate); + + if (privateEnvironments.length) { + const names = privateEnvironments.map(environment => environment.name).join(', '); + exportPrivateEnvironments = await showExportPrivateEnvironmentsModal(names); + } + + const fileName = await showSaveExportedFileDialog({ + exportedFileNamePrefix: 'Insomnia-All', + selectedFormat, + }); + + if (!fileName) { + return; + } + + let stringifiedExport; + + try { + switch (selectedFormat) { + case VALUE_HAR: + stringifiedExport = await exportWorkspacesHAR(workspacesForActiveProject, exportPrivateEnvironments); + break; + + case VALUE_YAML: + stringifiedExport = await exportWorkspacesData(workspacesForActiveProject, exportPrivateEnvironments, 'yaml'); + break; + + case VALUE_JSON: + stringifiedExport = await exportWorkspacesData(workspacesForActiveProject, exportPrivateEnvironments, 'json'); + break; + + default: + unreachableCase(selectedFormat, `selected export format "${selectedFormat}" is invalid`); + } + } catch (err) { + showError({ + title: 'Export Failed', + error: err, + message: 'Export failed due to an unexpected error', + }); + return; + } + + writeExportedFileToFileSystem(fileName, stringifiedExport, err => { + if (err) { + console.warn('Export failed', err); + } + }); + }, + }); +}; +export const exportRequestsToFile = (requestIds: string[]) => { + showSelectExportTypeModal({ + onDone: async selectedFormat => { + const requests: BaseModel[] = []; + const privateEnvironments: Environment[] = []; + const workspaceLookup: any = {}; + + for (const requestId of requestIds) { + const request = await requestOperations.getById(requestId); + + if (request == null) { + continue; + } + + requests.push(request); + const ancestors = await database.withAncestors(request, [ + models.workspace.type, + models.requestGroup.type, + ]); + const workspace = ancestors.find(isWorkspace); + + if (workspace == null || workspaceLookup.hasOwnProperty(workspace._id)) { + continue; + } + + workspaceLookup[workspace._id] = true; + const descendants = await database.withDescendants(workspace); + const privateEnvs = descendants.filter(isEnvironment).filter( + descendant => descendant.isPrivate, + ); + privateEnvironments.push(...privateEnvs); + } + + let exportPrivateEnvironments = false; + + if (privateEnvironments.length) { + const names = privateEnvironments.map(privateEnvironment => privateEnvironment.name).join(', '); + exportPrivateEnvironments = await showExportPrivateEnvironmentsModal(names); + } + + const fileName = await showSaveExportedFileDialog({ + exportedFileNamePrefix: 'Insomnia', + selectedFormat, + }); + + if (!fileName) { + return; + } + + let stringifiedExport; + + try { + switch (selectedFormat) { + case VALUE_HAR: + stringifiedExport = await exportRequestsHAR(requests, exportPrivateEnvironments); + break; + + case VALUE_YAML: + stringifiedExport = await exportRequestsData(requests, exportPrivateEnvironments, 'yaml'); + break; + + case VALUE_JSON: + stringifiedExport = await exportRequestsData(requests, exportPrivateEnvironments, 'json'); + break; + + default: + unreachableCase(selectedFormat, `selected export format "${selectedFormat}" is invalid`); + } + } catch (err) { + showError({ + title: 'Export Failed', + error: err, + message: 'Export failed due to an unexpected error', + }); + return; + } + + writeExportedFileToFileSystem(fileName, stringifiedExport, err => { + if (err) { + console.warn('Export failed', err); + } + }); + }, + }); +}; diff --git a/packages/insomnia/src/common/import.ts b/packages/insomnia/src/common/import.ts index ea19625e6..b174b6b72 100644 --- a/packages/insomnia/src/common/import.ts +++ b/packages/insomnia/src/common/import.ts @@ -4,12 +4,14 @@ import { convert, ConvertResultType } from 'insomnia-importers'; import type { ApiSpec } from '../models/api-spec'; import type { BaseModel } from '../models/index'; import * as models from '../models/index'; +import { Project } from '../models/project'; import { isRequest } from '../models/request'; -import { isWorkspace, Workspace } from '../models/workspace'; +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 { ImportToWorkspacePrompt, SetWorkspaceScopePrompt } from '../ui/redux/modules/helpers'; +import { showSelectModal } from '../ui/components/modals/select-modal'; import { BASE_ENVIRONMENT_ID_KEY, CONTENT_TYPE_GRAPHQL, @@ -265,8 +267,26 @@ export async function importRaw( } else { // If workspace, check and set the scope and parentId while importing a new workspace if (isWorkspace(model)) { - await updateWorkspaceScope(resource as Workspace, resultsType, getWorkspaceScope); + // 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 = @@ -277,7 +297,10 @@ export async function importRaw( resource.name = name; } - await createWorkspaceInProject(resource as Workspace, getProjectId); + if (getProjectId) { + // Set the workspace parent if creating a new workspace during import + resource.parentId = await getProjectId(); + } } newDoc = await db.docCreate(model.type, resource); @@ -324,43 +347,6 @@ export async function importRaw( return importRequest; } -async function updateWorkspaceScope( - resource: Workspace, - resultType: ConvertResultType, - getWorkspaceScope?: SetWorkspaceScopePrompt, -) { - // 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(resultType)) { - 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); - } -} - -async function createWorkspaceInProject( - resource: Workspace, - getProjectId?: () => Promise, -) { - if (getProjectId) { - // Set the workspace parent if creating a new workspace during import - resource.parentId = await getProjectId(); - } -} - export const isApiSpecImport = ({ id }: Pick) => ( id === 'openapi3' || id === 'swagger2' ); @@ -368,3 +354,134 @@ export const isApiSpecImport = ({ id }: Pick) => ( export const isInsomniaV4Import = ({ id }: Pick) => ( id === 'insomnia-4' ); + +export enum ForceToWorkspace { + new = 'new', + current = 'current', + existing = 'existing' +} + +export type SelectExistingWorkspacePrompt = Promise; + +// Returning null instead of a string will create a new workspace +export type ImportToWorkspacePrompt = () => null | string | Promise; +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 new Promise(async resolve => { + showModal(AskModal, { + title: 'Import', + message: `Do you want to import into an existing ${strings.workspace.singular.toLowerCase()} or a new one?`, + yesText: 'Existing', + noText: 'New', + onDone: async (yes: boolean) => { + if (!yes) { + return resolve(null); + } + const options = activeProjectWorkspaces.map(workspace => ({ name: workspace.name, value: workspace._id })); + showSelectModal({ + title: 'Import', + message: `Select a ${strings.workspace.singular.toLowerCase()} to import into`, + options, + value: options[0]?.value, + noEscape: true, + onDone: workspaceId => { + resolve(workspaceId); + }, + }); + }, + }); + }); + } + } + + 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; +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; +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, + noEscape: true, + onDone: selectedProjectId => { + // @ts-expect-error onDone can send null as an argument; why/how? + resolve(selectedProjectId); + }, + }); + }); + }; +} diff --git a/packages/insomnia/src/sync/git/gitlab-oauth-provider.ts b/packages/insomnia/src/sync/git/gitlab-oauth-provider.ts index 5a24ef355..71753dda6 100644 --- a/packages/insomnia/src/sync/git/gitlab-oauth-provider.ts +++ b/packages/insomnia/src/sync/git/gitlab-oauth-provider.ts @@ -96,7 +96,6 @@ export async function generateAuthorizationUrl() { export async function exchangeCodeForGitLabToken(input: { code: string; state: string; - scope: string; }) { const { code, state } = input; diff --git a/packages/insomnia/src/ui/components/breadcrumb.tsx b/packages/insomnia/src/ui/components/breadcrumb.tsx index 06b9076f3..f9baf775d 100644 --- a/packages/insomnia/src/ui/components/breadcrumb.tsx +++ b/packages/insomnia/src/ui/components/breadcrumb.tsx @@ -10,7 +10,6 @@ export interface CrumbProps { export interface BreadcrumbProps { crumbs: CrumbProps[]; className?: string; - isLoading?: boolean; } const StyledBreadcrumb = styled.ul` @@ -61,13 +60,10 @@ const Crumb: FC = ({ id, node, onClick }) => ( ); -export const Breadcrumb: FC = ({ crumbs, className, isLoading }) => ( +export const Breadcrumb: FC = ({ crumbs, className }) => ( {crumbs.map(Crumb)} - {isLoading ? ( - - ) : null} ); diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx index b0e6ef9a1..04616b8e5 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx @@ -11,7 +11,6 @@ import { isDesign, Workspace } from '../../../models/workspace'; import type { WorkspaceAction } from '../../../plugins'; import { ConfigGenerator, getConfigGenerators, getWorkspaceActions } from '../../../plugins'; import * as pluginContexts from '../../../plugins/context'; -import { selectIsLoading } from '../../redux/modules/global'; import { selectActiveApiSpec, selectActiveEnvironment, selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceName, selectSettings } from '../../redux/selectors'; import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; @@ -29,7 +28,6 @@ export const WorkspaceDropdown: FC = () => { const activeWorkspaceName = useSelector(selectActiveWorkspaceName); const activeApiSpec = useSelector(selectActiveApiSpec); const activeProject = useSelector(selectActiveProject); - const isLoading = useSelector(selectIsLoading); const settings = useSelector(selectSettings); const { hotKeyRegistry } = settings; const [actionPlugins, setActionPlugins] = useState([]); @@ -117,7 +115,6 @@ export const WorkspaceDropdown: FC = () => { {activeWorkspaceName} - {isLoading ? : null} {getWorkspaceLabel(activeWorkspace).singular} Settings diff --git a/packages/insomnia/src/ui/components/modals/export-requests-modal.tsx b/packages/insomnia/src/ui/components/modals/export-requests-modal.tsx index fd8f750dc..bc8b26df5 100644 --- a/packages/insomnia/src/ui/components/modals/export-requests-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/export-requests-modal.tsx @@ -1,12 +1,12 @@ import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; +import { exportRequestsToFile } from '../../../common/export'; import * as models from '../../../models'; import { GrpcRequest, isGrpcRequest } from '../../../models/grpc-request'; import { isRequest, Request } from '../../../models/request'; import { isRequestGroup, RequestGroup } from '../../../models/request-group'; import { isWebSocketRequest, WebSocketRequest } from '../../../models/websocket-request'; -import { exportRequestsToFile } from '../../redux/modules/global'; import { selectSidebarChildren } from '../../redux/sidebar-selectors'; import { type ModalHandle, Modal, ModalProps } from '../base/modal'; import { ModalBody } from '../base/modal-body'; diff --git a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-settings-form-group.tsx b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-settings-form-group.tsx index 27b6c60cb..e86d779e1 100644 --- a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-settings-form-group.tsx +++ b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/github-repository-settings-form-group.tsx @@ -1,23 +1,19 @@ import { AxiosResponse } from 'axios'; import type { GraphQLError } from 'graphql'; import React, { MouseEvent, useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; import { useInterval, useLocalStorage } from 'react-use'; import styled from 'styled-components'; import { GitRepository } from '../../../../models/git-repository'; import { axiosRequest } from '../../../../network/axios-request'; import { + exchangeCodeForToken, generateAuthorizationUrl, GITHUB_GRAPHQL_API_URL, signOut, } from '../../../../sync/git/github-oauth-provider'; -import { - COMMAND_GITHUB_OAUTH_AUTHENTICATE, - newCommand, -} from '../../../redux/modules/global'; import { Button } from '../../themed-button'; -import { showAlert } from '..'; +import { showAlert, showError } from '..'; interface Props { uri?: string; @@ -329,7 +325,6 @@ const GitHubSignInForm = ({ token }: GitHubSignInFormProps) => { const [error, setError] = useState(''); const [authUrl, setAuthUrl] = useState(() => generateAuthorizationUrl()); const [isAuthenticating, setIsAuthenticating] = useState(false); - const dispatch = useDispatch(); // When we get a new token we reset the authenticating flag and auth url. This happens because we can use the generated url for only one authorization flow. useEffect(() => { @@ -375,12 +370,16 @@ const GitHubSignInForm = ({ token }: GitHubSignInFormProps) => { return; } - const command = newCommand(COMMAND_GITHUB_OAUTH_AUTHENTICATE, { + exchangeCodeForToken({ code, state, + }).catch((error: Error) => { + showError({ + error, + title: 'Error authorizing GitHub', + message: error.message, + }); }); - - command(dispatch); } }} > diff --git a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/gitlab-repository-settings-form-group.tsx b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/gitlab-repository-settings-form-group.tsx index 8403ddd36..2a49e743e 100644 --- a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/gitlab-repository-settings-form-group.tsx +++ b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/gitlab-repository-settings-form-group.tsx @@ -1,23 +1,19 @@ import axios from 'axios'; import React, { useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; import { useInterval, useLocalStorage } from 'react-use'; import styled from 'styled-components'; import { GitRepository } from '../../../../models/git-repository'; import { axiosRequest } from '../../../../network/axios-request'; import { + exchangeCodeForGitLabToken, generateAuthorizationUrl, getGitLabOauthApiURL, refreshToken, signOut, } from '../../../../sync/git/gitlab-oauth-provider'; -import { - COMMAND_GITLAB_OAUTH_AUTHENTICATE, - newCommand, -} from '../../../redux/modules/global'; import { Button } from '../../themed-button'; -import { showAlert } from '..'; +import { showAlert, showError } from '..'; interface Props { uri?: string; @@ -281,7 +277,6 @@ interface GitLabSignInFormProps { const GitLabSignInForm = ({ token }: GitLabSignInFormProps) => { const [authUrl, setAuthUrl] = useState(''); const [isAuthenticating, setIsAuthenticating] = useState(false); - const dispatch = useDispatch(); // When we get a new token we reset the authenticating flag and auth url. This happens because we can use the generated url for only one authorization flow. useEffect(() => { @@ -323,12 +318,16 @@ const GitLabSignInForm = ({ token }: GitLabSignInFormProps) => { const state = parsedURL.searchParams.get('state'); if (typeof code === 'string' && typeof state === 'string') { - const command = newCommand(COMMAND_GITLAB_OAUTH_AUTHENTICATE, { + exchangeCodeForGitLabToken({ code, state, + }).catch((error: Error) => { + showError({ + error, + title: 'Error authorizing GitLab', + message: error.message, + }); }); - - command(dispatch); } } }} diff --git a/packages/insomnia/src/ui/components/panes/placeholder-request-pane.tsx b/packages/insomnia/src/ui/components/panes/placeholder-request-pane.tsx index 3b63e787b..58b977ae7 100644 --- a/packages/insomnia/src/ui/components/panes/placeholder-request-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/placeholder-request-pane.tsx @@ -1,9 +1,9 @@ import React, { FC, useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { ForceToWorkspace } from '../../../common/import'; import { createRequest } from '../../hooks/create-request'; -import { ForceToWorkspace } from '../../redux/modules/helpers'; -import { importFile } from '../../redux/modules/import'; +import { importFile } from '../../import'; import { selectActiveWorkspace, selectSettings } from '../../redux/selectors'; import { Hotkey } from '../hotkey'; import { Pane, PaneBody, PaneHeader } from './pane'; diff --git a/packages/insomnia/src/ui/components/panes/request-pane.tsx b/packages/insomnia/src/ui/components/panes/request-pane.tsx index 862d527a6..d45ce8b1f 100644 --- a/packages/insomnia/src/ui/components/panes/request-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/request-pane.tsx @@ -62,6 +62,7 @@ interface Props { request?: Request | null; settings: Settings; workspace: Workspace; + setLoading: (l: boolean) => void; } export const RequestPane: FC = ({ @@ -69,6 +70,7 @@ export const RequestPane: FC = ({ request, settings, workspace, + setLoading, }) => { const updateRequestUrl = (request: Request, url: string) => { @@ -179,6 +181,7 @@ export const RequestPane: FC = ({ handleAutocompleteUrls={autocompleteUrls} nunjucksPowerUserMode={settings.nunjucksPowerUserMode} request={request} + setLoading={setLoading} /> diff --git a/packages/insomnia/src/ui/components/panes/response-pane.tsx b/packages/insomnia/src/ui/components/panes/response-pane.tsx index 8cf952576..a78cf4fa7 100644 --- a/packages/insomnia/src/ui/components/panes/response-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/response-pane.tsx @@ -12,7 +12,7 @@ import type { Response } from '../../../models/response'; import { cancelRequestById } from '../../../network/network'; import { jsonPrettify } from '../../../utils/prettify/json'; import { updateRequestMetaByParentId } from '../../hooks/create-request'; -import { selectActiveResponse, selectLoadStartTime, selectResponseFilter, selectResponseFilterHistory, selectResponsePreviewMode, selectSettings } from '../../redux/selectors'; +import { selectActiveResponse, selectResponseFilter, selectResponseFilterHistory, selectResponsePreviewMode, selectSettings } from '../../redux/selectors'; import { PanelContainer, TabItem, Tabs } from '../base/tabs'; import { PreviewModeDropdown } from '../dropdowns/preview-mode-dropdown'; import { ResponseHistoryDropdown } from '../dropdowns/response-history-dropdown'; @@ -32,15 +32,16 @@ import { PlaceholderResponsePane } from './placeholder-response-pane'; interface Props { request?: Request | null; + runningRequests: Record; } export const ResponsePane: FC = ({ request, + runningRequests, }) => { const response = useSelector(selectActiveResponse) as Response | null; const filterHistory = useSelector(selectResponseFilterHistory); const filter = useSelector(selectResponseFilter); const settings = useSelector(selectSettings); - const loadStartTime = useSelector(selectLoadStartTime); const previewMode = useSelector(selectResponsePreviewMode); const handleSetFilter = async (responseFilter: string) => { if (!response) { @@ -123,12 +124,13 @@ export const ResponsePane: FC = ({ return ; } + // If there is no previous response, show placeholder for loading indicator if (!response) { return ( cancelRequestById(request._id)} - loadStartTime={loadStartTime} + loadStartTime={runningRequests[request._id]} /> ); @@ -229,7 +231,7 @@ export const ResponsePane: FC = ({ cancelRequestById(request._id)} - loadStartTime={loadStartTime} + loadStartTime={runningRequests[request._id]} /> diff --git a/packages/insomnia/src/ui/components/request-url-bar.tsx b/packages/insomnia/src/ui/components/request-url-bar.tsx index 7c94a238f..9d5d9728b 100644 --- a/packages/insomnia/src/ui/components/request-url-bar.tsx +++ b/packages/insomnia/src/ui/components/request-url-bar.tsx @@ -4,7 +4,7 @@ import * as importers from 'insomnia-importers'; import { extension as mimeExtension } from 'mime-types'; import path from 'path'; import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useInterval } from 'react-use'; import { database } from '../../common/database'; @@ -16,7 +16,6 @@ import * as network from '../../network/network'; import { SegmentEvent, trackSegmentEvent } from '../analytics'; import { updateRequestMetaByParentId } from '../hooks/create-request'; import { useTimeoutWhen } from '../hooks/useTimeoutWhen'; -import { loadRequestStart, loadRequestStop } from '../redux/modules/global'; import { selectActiveEnvironment, selectActiveRequest, selectHotKeyRegistry, selectResponseDownloadPath, selectSettings } from '../redux/selectors'; import { type DropdownHandle, Dropdown } from './base/dropdown/dropdown'; import { DropdownButton } from './base/dropdown/dropdown-button'; @@ -37,6 +36,7 @@ interface Props { onUrlChange: (r: Request, url: string) => Promise; request: Request; uniquenessKey: string; + setLoading: (l: boolean) => void; } export interface RequestUrlBarHandle { @@ -48,13 +48,13 @@ export const RequestUrlBar = forwardRef(({ onUrlChange, request, uniquenessKey, + setLoading, }, ref) => { const downloadPath = useSelector(selectResponseDownloadPath); const hotKeyRegistry = useSelector(selectHotKeyRegistry); const activeEnvironment = useSelector(selectActiveEnvironment); const activeRequest = useSelector(selectActiveRequest); const settings = useSelector(selectSettings); - const dispatch = useDispatch(); const methodDropdownRef = useRef(null); const dropdownRef = useRef(null); const inputRef = useRef(null); @@ -106,9 +106,7 @@ export const RequestUrlBar = forwardRef(({ authenticationType: request.authentication?.type, mimeType: request.body.mimeType, }); - // Start loading - dispatch(loadRequestStart(request._id)); - + setLoading(true); try { const responsePatch = await network.send(request._id, activeEnvironment?._id); const headers = responsePatch.headers || []; @@ -175,10 +173,9 @@ export const RequestUrlBar = forwardRef(({ } finally { // Unset active response because we just made a new one await updateRequestMetaByParentId(request._id, { activeResponseId: null }); - // Stop loading - dispatch(loadRequestStop(request._id)); + setLoading(false); } - }, [activeEnvironment, dispatch, request, settings.maxHistoryResponses, settings.preferredHttpVersion]); + }, [activeEnvironment?._id, request, setLoading, settings.maxHistoryResponses, settings.preferredHttpVersion]); const handleSend = useCallback(async () => { if (!request) { @@ -191,7 +188,7 @@ export const RequestUrlBar = forwardRef(({ authenticationType: request.authentication?.type, mimeType: request.body.mimeType, }); - dispatch(loadRequestStart(request._id)); + setLoading(true); try { const responsePatch = await network.send(request._id, activeEnvironment?._id); await models.response.create(responsePatch, settings.maxHistoryResponses); @@ -217,9 +214,8 @@ export const RequestUrlBar = forwardRef(({ } // Unset active response because we just made a new one await updateRequestMetaByParentId(request._id, { activeResponseId: null }); - // Stop loading - dispatch(loadRequestStop(request._id)); - }, [activeEnvironment, dispatch, request, settings.maxHistoryResponses, settings.preferredHttpVersion]); + setLoading(false); + }, [activeEnvironment?._id, request, setLoading, settings.maxHistoryResponses, settings.preferredHttpVersion]); const send = useCallback(() => { setCurrentTimeout(undefined); diff --git a/packages/insomnia/src/ui/components/settings/import-export.tsx b/packages/insomnia/src/ui/components/settings/import-export.tsx index bba83f59e..50b58b522 100644 --- a/packages/insomnia/src/ui/components/settings/import-export.tsx +++ b/packages/insomnia/src/ui/components/settings/import-export.tsx @@ -1,17 +1,17 @@ import { importers } from 'insomnia-importers'; import React, { FC, useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useRevalidator } 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 { exportAllToFile } from '../../redux/modules/global'; -import { ForceToWorkspace } from '../../redux/modules/helpers'; -import { importClipBoard, importFile, importUri } from '../../redux/modules/import'; -import { selectActiveProjectName, selectActiveWorkspace, selectActiveWorkspaceName, selectWorkspaceRequestsAndRequestGroups, selectWorkspacesForActiveProject } from '../../redux/selectors'; +import { importClipBoard, importFile, importUri } from '../../import'; +import { selectActiveProject, selectActiveProjectName, selectActiveWorkspace, selectActiveWorkspaceName, selectProjects, selectWorkspaceRequestsAndRequestGroups, selectWorkspacesForActiveProject, selectWorkspacesWithResolvedNameForActiveProject } from '../../redux/selectors'; import { Dropdown } from '../base/dropdown/dropdown'; import { DropdownButton } from '../base/dropdown/dropdown-button'; import { DropdownDivider } from '../base/dropdown/dropdown-divider'; @@ -26,9 +26,11 @@ interface Props { } export const ImportExport: FC = ({ hideSettingsModal }) => { - const dispatch = useDispatch(); 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 workspacesForActiveProject = useSelector(selectWorkspacesForActiveProject); const workspaceRequestsAndRequestGroups = useSelector(selectWorkspaceRequestsAndRequestGroups); @@ -44,12 +46,17 @@ export const ImportExport: FC = ({ hideSettingsModal }) => { placeholder: 'https://website.com/insomnia-import.json', onComplete: (uri: string) => { window.localStorage.setItem('insomnia.lastUsedImportUri', uri); - dispatch(importUri(uri, { workspaceId: activeWorkspace?._id, forceToWorkspace, onComplete: revalidate })); + importUri(uri, { + activeProjectWorkspaces, + activeProject, + projects, + workspaceId: activeWorkspace?._id, + forceToWorkspace, onComplete: revalidate }); hideSettingsModal(); }, ...defaultValue, }); - }, [dispatch, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]); + }, [activeProjectWorkspaces, activeProject, projects, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]); const showExportRequestsModal = useCallback(() => { if (!workspaceRequestsAndRequestGroups.filter(r => !isRequestGroup(r)).length) { @@ -69,14 +76,24 @@ export const ImportExport: FC = ({ hideSettingsModal }) => { }, [hideSettingsModal, projectName, workspacesForActiveProject]); const handleImportFile = useCallback(() => { - dispatch(importFile({ workspaceId: activeWorkspace?._id, forceToWorkspace, onComplete: revalidate })); + importFile({ + activeProjectWorkspaces, + activeProject, + projects, + workspaceId: activeWorkspace?._id, + forceToWorkspace, onComplete: revalidate }); hideSettingsModal(); - }, [dispatch, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]); + }, [activeProjectWorkspaces, activeProject, projects, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]); const handleImportClipBoard = useCallback(() => { - dispatch(importClipBoard({ workspaceId: activeWorkspace?._id, forceToWorkspace, onComplete: revalidate })); + importClipBoard({ + activeProjectWorkspaces, + activeProject, + projects, + workspaceId: activeWorkspace?._id, + forceToWorkspace, onComplete: revalidate }); hideSettingsModal(); - }, [dispatch, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]); + }, [activeProjectWorkspaces, activeProject, projects, activeWorkspace?._id, forceToWorkspace, revalidate, hideSettingsModal]); const activeWorkspaceName = useSelector(selectActiveWorkspaceName); diff --git a/packages/insomnia/src/ui/hooks/use-app-commands.ts b/packages/insomnia/src/ui/hooks/use-app-commands.ts deleted file mode 100644 index ff9b49a83..000000000 --- a/packages/insomnia/src/ui/hooks/use-app-commands.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IpcRendererEvent } from 'electron/renderer'; -import { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { parse } from 'url'; - -import { newCommand } from '../redux/modules/global'; -import { selectActiveWorkspace } from '../redux/selectors'; - -export const useAppCommands = () => { - const dispatch = useDispatch(); - const activeWorkspace = useSelector(selectActiveWorkspace); - - // Handle Application Commands - useEffect(() => { - return window.main.on('shell:open', (_: IpcRendererEvent, url: string) => { - console.log('[renderer] Received Deep Link URL', url); - const parsed = parse(url, true); - const command = `${parsed.hostname}${parsed.pathname}`; - const args = JSON.parse(JSON.stringify(parsed.query)); - args.workspaceId = args.workspaceId || activeWorkspace?._id; - newCommand(command, args)(dispatch); - }); - }, [activeWorkspace?._id, dispatch]); -}; diff --git a/packages/insomnia/src/ui/hooks/use-app-commands.tsx b/packages/insomnia/src/ui/hooks/use-app-commands.tsx new file mode 100644 index 000000000..fd2d45e33 --- /dev/null +++ b/packages/insomnia/src/ui/hooks/use-app-commands.tsx @@ -0,0 +1,164 @@ +import { IpcRendererEvent } from 'electron/renderer'; +import { useEffect } from 'react'; +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { askToImportIntoProject, askToImportIntoWorkspace, askToSetWorkspaceScope, importUri } from '../../common/import'; +import * as models from '../../models'; +import { reloadPlugins } from '../../plugins'; +import { createPlugin } from '../../plugins/create'; +import { setTheme } from '../../plugins/misc'; +import { exchangeCodeForToken } from '../../sync/git/github-oauth-provider'; +import { exchangeCodeForGitLabToken } from '../../sync/git/gitlab-oauth-provider'; +import { submitAuthCode } from '../auth-session-provider'; +import { showError, showModal } from '../components/modals'; +import { AlertModal } from '../components/modals/alert-modal'; +import { AskModal } from '../components/modals/ask-modal'; +import { LoginModal } from '../components/modals/login-modal'; +import { + SettingsModal, + TAB_INDEX_PLUGINS, + TAB_INDEX_THEMES, +} from '../components/modals/settings-modal'; +import { selectActiveProject, selectActiveWorkspace, selectProjects, selectWorkspacesWithResolvedNameForActiveProject } from '../redux/selectors'; + +export const useAppCommands = () => { + const activeWorkspace = useSelector(selectActiveWorkspace); + const activeProject = useSelector(selectActiveProject); + const activeProjectWorkspaces = useSelector(selectWorkspacesWithResolvedNameForActiveProject); + const projects = useSelector(selectProjects); + useEffect(() => { + return window.main.on('shell:open', async (_: IpcRendererEvent, url: string) => { + const urlWithoutParams = url.substring(0, url.indexOf('?')); + const params = Object.fromEntries(new URL(url).searchParams); + switch (urlWithoutParams) { + case 'insomnia://app/alert': + showModal(AlertModal, { + title: params.title, + message: params.message, + }); + break; + + case 'insomnia://app/auth/login': + showModal(LoginModal, { + title: params.title, + message: params.message, + reauth: true, + }); + break; + + case 'insomnia://app/import': + showModal(AlertModal, { + title: 'Confirm Data Import', + message: ( + + Do you really want to import {params.name && (<>{params.name} from)} {params.uri}? + + ), + 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://plugins/install': + showModal(AskModal, { + title: 'Plugin Install', + message: ( + <> + Do you want to install {params.name}? + + ), + yesText: 'Install', + noText: 'Cancel', + onDone: async (isYes: boolean) => { + if (isYes) { + try { + await window.main.installPlugin(params.name); + showModal(SettingsModal, { tab: TAB_INDEX_PLUGINS }); + } catch (err) { + showError({ + title: 'Plugin Install', + message: 'Failed to install plugin', + error: err.message, + }); + } + } + }, + }); + break; + + case 'insomnia://plugins/theme': + const parsedTheme = JSON.parse(decodeURIComponent(params.theme)); + showModal(AskModal, { + title: 'Install Theme', + message: ( + <> + Do you want to install {parsedTheme.displayName}? + + ), + yesText: 'Install', + noText: 'Cancel', + onDone: async (isYes: boolean) => { + if (isYes) { + const mainJsContent = `module.exports.themes = [${JSON.stringify( + parsedTheme, + null, + 2, + )}];`; + await createPlugin(`theme-${parsedTheme.name}`, '0.0.1', mainJsContent); + const settings = await models.settings.getOrCreate(); + await models.settings.update(settings, { + theme: parsedTheme.name, + }); + await reloadPlugins(); + await setTheme(parsedTheme.name); + showModal(SettingsModal, { tab: TAB_INDEX_THEMES }); + } + }, + }); + break; + + case 'insomnia://oauth/github/authenticate': { + const { code, state } = params; + await exchangeCodeForToken({ code, state }).catch((error: Error) => { + showError({ + error, + title: 'Error authorizing GitHub', + message: error.message, + }); + }); + break; + } + + case 'insomnia://oauth/gitlab/authenticate': { + const { code, state } = params; + await exchangeCodeForGitLabToken({ code, state }).catch((error: Error) => { + showError({ + error, + title: 'Error authorizing GitLab', + message: error.message, + }); + }); + break; + } + + case 'insomnia://app/auth/finish': { + submitAuthCode(params.box); + break; + } + + default: { + console.log(`Unknown deep link: ${url}`); + } + } + }); + }, [activeProject, activeProjectWorkspaces, activeWorkspace?._id, projects]); +}; diff --git a/packages/insomnia/src/ui/hooks/use-drag-and-drop-import-file.tsx b/packages/insomnia/src/ui/hooks/use-drag-and-drop-import-file.tsx index 571bb6b9d..31f9f2467 100644 --- a/packages/insomnia/src/ui/hooks/use-drag-and-drop-import-file.tsx +++ b/packages/insomnia/src/ui/hooks/use-drag-and-drop-import-file.tsx @@ -1,15 +1,16 @@ import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { showModal } from '../components/modals'; import { AlertModal } from '../components/modals/alert-modal'; -import { importUri } from '../redux/modules/import'; -import { selectActiveWorkspace } from '../redux/selectors'; +import { importUri } from '../import'; +import { selectActiveProject, selectActiveWorkspace, selectProjects, selectWorkspacesWithResolvedNameForActiveProject } from '../redux/selectors'; export const useDragAndDropImportFile = () => { const activeWorkspace = useSelector(selectActiveWorkspace); - const dispatch = useDispatch(); - const handleImportUri = dispatch(importUri); + const activeProjectWorkspaces = useSelector(selectWorkspacesWithResolvedNameForActiveProject); + const activeProject = useSelector(selectActiveProject); + const projects = useSelector(selectProjects); // Global Drag and Drop for importing files useEffect(() => { @@ -46,7 +47,12 @@ export const useDragAndDropImportFile = () => { ), addCancel: true, }); - handleImportUri(`file://${file.path}`, { workspaceId: activeWorkspace?._id }); + importUri(`file://${file.path}`, { + activeProjectWorkspaces, + activeProject, + projects, + workspaceId: activeWorkspace?._id, + }); }, false, ); diff --git a/packages/insomnia/src/ui/import.ts b/packages/insomnia/src/ui/import.ts new file mode 100644 index 000000000..041b03d55 --- /dev/null +++ b/packages/insomnia/src/ui/import.ts @@ -0,0 +1,192 @@ +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?.(); +}; diff --git a/packages/insomnia/src/ui/redux/modules/__tests__/git.test.tsx b/packages/insomnia/src/ui/redux/modules/__tests__/git.test.tsx index 4e62574fc..0b48ebef5 100644 --- a/packages/insomnia/src/ui/redux/modules/__tests__/git.test.tsx +++ b/packages/insomnia/src/ui/redux/modules/__tests__/git.test.tsx @@ -1,5 +1,5 @@ import { createBuilder } from '@develohpanda/fluent-builder'; -import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { PromiseFsClient } from 'isomorphic-git'; import { mocked } from 'jest-mock'; import path from 'path'; @@ -25,7 +25,7 @@ import { } from '../../../../test-utils'; import { SegmentEvent, trackSegmentEvent } from '../../../analytics'; import { cloneGitRepository, setupGitRepository } from '../git'; -import { LOAD_START, LOAD_STOP, SET_ACTIVE_ACTIVITY, SET_ACTIVE_PROJECT, SET_ACTIVE_WORKSPACE } from '../global'; +import { SET_ACTIVE_ACTIVITY, SET_ACTIVE_PROJECT, SET_ACTIVE_WORKSPACE } from '../global'; jest.mock('../../../components/modals'); jest.mock('../../../../sync/git/shallow-clone'); @@ -46,18 +46,6 @@ describe('git', () => { gitRepoBuilder.reset(); }); - // Check loading events - afterEach(() => { - const actions = store.getActions(); - // Should always contain one LOAD_START and one LOAD_END - expect(actions.filter(({ type }) => type === LOAD_START)).toHaveLength(1); - expect(actions.filter(({ type }) => type === LOAD_STOP)).toHaveLength(1); - // LOAD_START should never be before LOAD_STOP - const startActionIndex = actions.findIndex(({ type }) => type === LOAD_START); - const stopActionIndex = actions.findIndex(({ type }) => type === LOAD_STOP); - expect(stopActionIndex).toBeGreaterThan(startActionIndex); - }); - describe('cloneGitRepository', () => { const dispatchCloneAndSubmitSettings = async (memClient: PromiseFsClient, uri?: string) => { const createFsClientMock = jest.fn().mockReturnValue(memClient); @@ -113,12 +101,6 @@ describe('git', () => { expect(trackSegmentEvent).toHaveBeenCalledWith(SegmentEvent.documentCreate); // Ensure activity is activated expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, { type: SET_ACTIVE_PROJECT, projectId: DEFAULT_PROJECT_ID, @@ -164,15 +146,6 @@ describe('git', () => { const alertArgs = getAndClearShowAlertMockArgs(); expect(alertArgs.title).toBe('Clone Problem'); expect(alertArgs.message).toBe('Multiple workspaces found in repository; expected one.'); - // Ensure activity is activated - expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, - ]); }); it('should fail if workspace already exists', async () => { @@ -194,15 +167,6 @@ describe('git', () => { Workspace New Collection already exists. Please delete it before cloning. , ); - // Ensure activity is activated - expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, - ]); }); it('should fail if exception during clone and not try again if uri ends with .git', async () => { @@ -213,14 +177,6 @@ describe('git', () => { const alertArgs = getAndClearShowAlertMockArgs(); expect(alertArgs.title).toBe('Error Cloning Repository'); expect(alertArgs.message).toBe(err.message); - expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, - ]); expect(shallowClone).toHaveBeenCalledTimes(1); }); @@ -240,14 +196,6 @@ describe('git', () => { expect(alertArgs.message).toBe( `Failed to clone with original url (${uri}): ${firstError.message};\n\nAlso failed to clone with \`.git\` suffix added (${dotGitUri}): ${secondError.message}`, ); - expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, - ]); expect(shallowClone).toHaveBeenCalledTimes(2); }); @@ -355,15 +303,6 @@ describe('git', () => { expect(meta?.gitRepositoryId).toBe(repoSettings._id); const createdRepo = await models.gitRepository.getById(repoSettings._id); expect(createdRepo?.needsFullClone).toBe(true); - // Ensure workspace is enabled - expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, - ]); }); }); @@ -398,15 +337,6 @@ describe('git', () => { expect(errorArgs.title).toBe('Error Cloning Repository'); expect(errorArgs.message).toBe(err.message); expect(errorArgs.error).toBe(err); - // Ensure activity is activated - expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, - ]); }); it('should fail if workspace is found', async () => { @@ -426,15 +356,6 @@ describe('git', () => { expect(alertArgs.message).toBe( 'This repository is already connected to Insomnia; try creating a clone from the dashboard instead.', ); - // Ensure activity is activated - expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, - ]); }); it('should setup if no .insomnia directory is found', async () => { @@ -447,15 +368,6 @@ describe('git', () => { expect(wMeta?.gitRepositoryId).toBe(repo._id); const createdRepo = await models.gitRepository.getById(repo._id); expect(createdRepo?.needsFullClone).toBe(true); - // Ensure activity is activated - expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, - ]); }); it('should setup if empty .insomnia directory is found', async () => { @@ -469,15 +381,6 @@ describe('git', () => { expect(wMeta?.gitRepositoryId).toBe(repo._id); const createdRepo = await models.gitRepository.getById(repo._id); expect(createdRepo?.needsFullClone).toBe(true); - // Ensure activity is activated - expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, - ]); }); it('should setup if empty .insomnia/workspace directory is found', async () => { @@ -492,15 +395,6 @@ describe('git', () => { expect(wMeta?.gitRepositoryId).toBe(repo._id); const createdRepo = await models.gitRepository.getById(repo._id); expect(createdRepo?.needsFullClone).toBe(true); - // Ensure activity is activated - expect(store.getActions()).toEqual([ - { - type: LOAD_START, - }, - { - type: LOAD_STOP, - }, - ]); }); }); }); diff --git a/packages/insomnia/src/ui/redux/modules/__tests__/workspace.test.ts b/packages/insomnia/src/ui/redux/modules/__tests__/workspace.test.ts index a1120e5bf..5f544f169 100644 --- a/packages/insomnia/src/ui/redux/modules/__tests__/workspace.test.ts +++ b/packages/insomnia/src/ui/redux/modules/__tests__/workspace.test.ts @@ -15,8 +15,9 @@ import { Workspace, WorkspaceScope, WorkspaceScopeKeys } from '../../../../model import { WorkspaceMeta } from '../../../../models/workspace-meta'; import { getAndClearShowPromptMockArgs } from '../../../../test-utils'; import { SegmentEvent, trackSegmentEvent } from '../../../analytics'; +import { createWorkspace } from '../git'; import { SET_ACTIVE_ACTIVITY, SET_ACTIVE_PROJECT, SET_ACTIVE_WORKSPACE } from '../global'; -import { activateWorkspace, createWorkspace } from '../workspace'; +import { activateWorkspace } from '../workspace'; jest.mock('../../../components/modals'); jest.mock('../../../../ui/analytics'); diff --git a/packages/insomnia/src/ui/redux/modules/git.tsx b/packages/insomnia/src/ui/redux/modules/git.tsx index d4f187268..c86522d59 100644 --- a/packages/insomnia/src/ui/redux/modules/git.tsx +++ b/packages/insomnia/src/ui/redux/modules/git.tsx @@ -9,18 +9,17 @@ import * as models from '../../../models'; import { BaseModel } from '../../../models'; import type { GitRepository } from '../../../models/git-repository'; import { createGitRepository } from '../../../models/helpers/git-repository-operations'; -import { isWorkspace, Workspace, WorkspaceScopeKeys } from '../../../models/workspace'; +import { isDesign, isWorkspace, Workspace, WorkspaceScope, WorkspaceScopeKeys } from '../../../models/workspace'; import { forceWorkspaceScopeToDesign } from '../../../sync/git/force-workspace-scope-to-design'; import { GIT_CLONE_DIR, GIT_INSOMNIA_DIR, GIT_INSOMNIA_DIR_NAME } from '../../../sync/git/git-vcs'; import { shallowClone } from '../../../sync/git/shallow-clone'; import { addDotGit, getOauth2FormatName, translateSSHtoHTTP } from '../../../sync/git/utils'; import { SegmentEvent, trackSegmentEvent, vcsSegmentEventProperties } from '../../analytics'; -import { showAlert, showError, showModal } from '../../components/modals'; +import { showAlert, showError, showModal, showPrompt } from '../../components/modals'; import { GitRepositorySettingsModal } from '../../components/modals/git-repository-settings-modal'; import { selectActiveProject } from '../selectors'; import { RootState } from '.'; -import { loadStart, loadStop } from './global'; -import { createWorkspace } from './workspace'; +import { activateWorkspace } from './workspace'; export type UpdateGitRepositoryCallback = (arg0: { gitRepository: GitRepository }) => void; @@ -49,15 +48,13 @@ export type SetupGitRepositoryCallback = (arg0: { * Setup a git repository against a document * */ export const setupGitRepository: SetupGitRepositoryCallback = ({ createFsClient, workspace }) => { - return (dispatch: any) => { + return () => { trackSegmentEvent(SegmentEvent.vcsSyncStart, vcsSegmentEventProperties('git', 'setup')); showModal(GitRepositorySettingsModal, { gitRepository: null, onSubmitEdits: async (gitRepoPatch: Partial) => { const providerName = getOauth2FormatName(gitRepoPatch.credentials); - dispatch(loadStart()); - try { gitRepoPatch.needsFullClone = true; const fsClient = createFsClient(); @@ -97,8 +94,6 @@ export const setupGitRepository: SetupGitRepositoryCallback = ({ createFsClient, trackSegmentEvent(SegmentEvent.vcsSyncComplete, { ...vcsSegmentEventProperties('git', 'setup'), providerName }); } catch (err) { trackSegmentEvent(SegmentEvent.vcsSyncComplete, { ...vcsSegmentEventProperties('git', 'setup', err.message), providerName }); - } finally { - dispatch(loadStop()); } }, }); @@ -131,6 +126,46 @@ const createWorkspaceWithGitRepo = (gitRepo: GitRepository) => { ); }; +type OnWorkspaceCreateCallback = (arg0: Workspace) => Promise | void; + +export const createWorkspace = ({ scope, onCreate }: { + scope: WorkspaceScope; + onCreate?: OnWorkspaceCreateCallback; +}) => { + return (dispatch: any, getState: any) => { + const activeProject = selectActiveProject(getState()); + + const design = isDesign({ + scope, + }); + const title = design ? 'Design Document' : 'Request Collection'; + const defaultValue = design ? 'my-spec.yaml' : 'My Collection'; + const segmentEvent = design ? SegmentEvent.documentCreate : SegmentEvent.collectionCreate; + showPrompt({ + title: `Create New ${title}`, + submitName: 'Create', + placeholder: defaultValue, + defaultValue, + selectText: true, + onComplete: async name => { + const flushId = await db.bufferChanges(); + const workspace = await models.workspace.create({ + name, + scope, + parentId: activeProject._id, + }); + await models.workspace.ensureChildren(workspace); + await db.flushChanges(flushId); + if (onCreate) { + await onCreate(workspace); + } + await dispatch(activateWorkspace({ workspace })); + trackSegmentEvent(segmentEvent); + }, + }); + }; +}; + const cloneProblem = (message: ReactNode) => { showAlert({ title: 'Clone Problem', @@ -165,7 +200,6 @@ export const cloneGitRepository = ({ createFsClient, onComplete }: { showModal(GitRepositorySettingsModal, { gitRepository: null, onSubmitEdits: async (repoSettingsPatch: any) => { - dispatch(loadStart()); repoSettingsPatch.needsFullClone = true; repoSettingsPatch.uri = translateSSHtoHTTP(repoSettingsPatch.uri); let fsClient = createFsClient(); @@ -182,7 +216,6 @@ export const cloneGitRepository = ({ createFsClient, onComplete }: { title: 'Error Cloning Repository', message: originalUriError.message, }); - dispatch(loadStop()); trackSegmentEvent(SegmentEvent.vcsSyncComplete, { ...vcsSegmentEventProperties('git', 'clone', originalUriError.message), providerName }); return; } @@ -202,7 +235,6 @@ export const cloneGitRepository = ({ createFsClient, onComplete }: { title: 'Error Cloning Repository: failed to clone with and without `.git` suffix', message: `Failed to clone with original url (${repoSettingsPatch.uri}): ${originalUriError.message};\n\nAlso failed to clone with \`.git\` suffix added (${dotGitUri}): ${dotGitError.message}`, }); - dispatch(loadStop()); trackSegmentEvent(SegmentEvent.vcsSyncComplete, { ...vcsSegmentEventProperties('git', 'clone', dotGitError.message), providerName }); return; } @@ -211,7 +243,6 @@ export const cloneGitRepository = ({ createFsClient, onComplete }: { // If no workspace exists, user should be prompted to create a document if (!(await containsInsomniaWorkspaceDir(fsClient))) { dispatch(noDocumentFound(repoSettingsPatch)); - dispatch(loadStop()); trackSegmentEvent(SegmentEvent.vcsSyncComplete, { ...vcsSegmentEventProperties('git', 'clone', 'no directory found'), providerName }); return; } @@ -221,14 +252,12 @@ export const cloneGitRepository = ({ createFsClient, onComplete }: { if (workspaces.length === 0) { dispatch(noDocumentFound(repoSettingsPatch)); - dispatch(loadStop()); trackSegmentEvent(SegmentEvent.vcsSyncComplete, { ...vcsSegmentEventProperties('git', 'clone', 'no workspaces found'), providerName }); return; } if (workspaces.length > 1) { cloneProblem('Multiple workspaces found in repository; expected one.'); - dispatch(loadStop()); trackSegmentEvent(SegmentEvent.vcsSyncComplete, { ...vcsSegmentEventProperties('git', 'clone', 'multiple workspaces found'), providerName }); return; } @@ -247,7 +276,6 @@ export const cloneGitRepository = ({ createFsClient, onComplete }: { before cloning. , ); - dispatch(loadStop()); trackSegmentEvent(SegmentEvent.vcsSyncComplete, { ...vcsSegmentEventProperties('git', 'clone', 'workspace already exists'), providerName }); return; } @@ -290,7 +318,6 @@ export const cloneGitRepository = ({ createFsClient, onComplete }: { // Flush DB changes await db.flushChanges(bufferId); - dispatch(loadStop()); trackSegmentEvent(SegmentEvent.vcsSyncComplete, { ...vcsSegmentEventProperties('git', 'clone'), providerName }); onComplete?.(); }, diff --git a/packages/insomnia/src/ui/redux/modules/global.tsx b/packages/insomnia/src/ui/redux/modules/global.tsx index 431d9db72..0b7da0e37 100644 --- a/packages/insomnia/src/ui/redux/modules/global.tsx +++ b/packages/insomnia/src/ui/redux/modules/global.tsx @@ -1,70 +1,19 @@ -import { format } from 'date-fns'; -import fs, { NoParamCallback } from 'fs'; -import path from 'path'; -import React, { Fragment } from 'react'; -import { combineReducers, Dispatch } from 'redux'; -import { unreachableCase } from 'ts-assert-unreachable'; +import { combineReducers } from 'redux'; import type { DashboardSortOrder, GlobalActivity } from '../../../common/constants'; import { ACTIVITY_HOME, isValidActivity, } from '../../../common/constants'; -import { database } from '../../../common/database'; -import { - exportRequestsData, - exportRequestsHAR, - exportWorkspacesData, - exportWorkspacesHAR, -} from '../../../common/export'; -import { strings } from '../../../common/strings'; -import * as models from '../../../models'; -import { Environment, isEnvironment } from '../../../models/environment'; -import { GrpcRequest } from '../../../models/grpc-request'; -import * as requestOperations from '../../../models/helpers/request-operations'; import { DEFAULT_PROJECT_ID } from '../../../models/project'; -import { Request } from '../../../models/request'; -import { WebSocketRequest } from '../../../models/websocket-request'; -import { isWorkspace, Workspace } from '../../../models/workspace'; -import { reloadPlugins } from '../../../plugins'; -import { createPlugin } from '../../../plugins/create'; -import { setTheme } from '../../../plugins/misc'; -import { exchangeCodeForToken } from '../../../sync/git/github-oauth-provider'; -import { exchangeCodeForGitLabToken } from '../../../sync/git/gitlab-oauth-provider'; -import { AskModal } from '../../../ui/components/modals/ask-modal'; import { trackPageView } from '../../analytics'; -import { submitAuthCode } from '../../auth-session-provider'; -import { AlertModal } from '../../components/modals/alert-modal'; -import { showAlert, showError, showModal } from '../../components/modals/index'; -import { LoginModal } from '../../components/modals/login-modal'; -import { SelectModal } from '../../components/modals/select-modal'; -import { - SettingsModal, - TAB_INDEX_PLUGINS, - TAB_INDEX_THEMES, -} from '../../components/modals/settings-modal'; -import { RootState } from '.'; -import { importUri } from './import'; export const LOCALSTORAGE_PREFIX = 'insomnia::meta'; const LOGIN_STATE_CHANGE = 'global/login-state-change'; -export const LOAD_START = 'global/load-start'; -export const LOAD_STOP = 'global/load-stop'; -const LOAD_REQUEST_START = 'global/load-request-start'; -const LOAD_REQUEST_STOP = 'global/load-request-stop'; export const SET_ACTIVE_PROJECT = 'global/activate-project'; export const SET_DASHBOARD_SORT_ORDER = 'global/dashboard-sort-order'; export const SET_ACTIVE_WORKSPACE = 'global/activate-workspace'; export const SET_ACTIVE_ACTIVITY = 'global/activate-activity'; -export const SET_IS_FINISHED_BOOTING = 'global/is-finished-booting'; -const COMMAND_ALERT = 'app/alert'; -const COMMAND_LOGIN = 'app/auth/login'; -const COMMAND_IMPORT_URI = 'app/import'; -const COMMAND_PLUGIN_INSTALL = 'plugins/install'; -const COMMAND_PLUGIN_THEME = 'plugins/theme'; -export const COMMAND_GITHUB_OAUTH_AUTHENTICATE = 'oauth/github/authenticate'; -export const COMMAND_GITLAB_OAUTH_AUTHENTICATE = 'oauth/gitlab/authenticate'; -export const COMMAND_FINISH_AUTHENTICATION = 'app/auth/finish'; // ~~~~~~~~ // // REDUCERS // @@ -109,36 +58,6 @@ function activeWorkspaceReducer(state: string | null = null, action: any) { } } -function loadingReducer(state = false, action: any) { - switch (action.type) { - case LOAD_START: - return true; - - case LOAD_STOP: - return false; - - default: - return state; - } -} - -function loadingRequestsReducer(state: Record = {}, action: any) { - switch (action.type) { - case LOAD_REQUEST_START: - return Object.assign({}, state, { - [action.requestId]: action.time, - }); - - case LOAD_REQUEST_STOP: - return Object.assign({}, state, { - [action.requestId]: -1, - }); - - default: - return state; - } -} - function loginStateChangeReducer(state = false, action: any) { switch (action.type) { case LOGIN_STATE_CHANGE: @@ -150,191 +69,36 @@ function loginStateChangeReducer(state = false, action: any) { } export interface GlobalState { - isLoading: boolean; activeProjectId: string; dashboardSortOrder: DashboardSortOrder; activeWorkspaceId: string | null; activeActivity: GlobalActivity | null; isLoggedIn: boolean; - loadingRequestIds: Record; } export const reducer = combineReducers({ - isLoading: loadingReducer, dashboardSortOrder: dashboardSortOrderReducer, - loadingRequestIds: loadingRequestsReducer, activeProjectId: activeProjectReducer, activeWorkspaceId: activeWorkspaceReducer, activeActivity: activeActivityReducer, isLoggedIn: loginStateChangeReducer, }); -export const selectIsLoading = (state: RootState) => state.global.isLoading; - // ~~~~~~~ // // ACTIONS // // ~~~~~~~ // -export const newCommand = (command: string, args: any) => async (dispatch: Dispatch) => { - switch (command) { - case COMMAND_ALERT: - showModal(AlertModal, { - title: args.title, - message: args.message, - }); - break; - - case COMMAND_LOGIN: - showModal(LoginModal, { - title: args.title, - message: args.message, - reauth: true, - }); - break; - - case COMMAND_IMPORT_URI: - await showModal(AlertModal, { - title: 'Confirm Data Import', - message: ( - - Do you really want to import {args.name && (<>{args.name} from)} {args.uri}? - - ), - addCancel: true, - }); - dispatch(importUri(args.uri, { workspaceId: args.workspaceId, forceToProject: 'prompt' })); - break; - - case COMMAND_PLUGIN_INSTALL: - showModal(AskModal, { - title: 'Plugin Install', - message: ( - - Do you want to install {args.name}? - - ), - yesText: 'Install', - noText: 'Cancel', - onDone: async (isYes: boolean) => { - if (!isYes) { - return; - } - - try { - await window.main.installPlugin(args.name); - showModal(SettingsModal, { tab: TAB_INDEX_PLUGINS }); - } catch (err) { - showError({ - title: 'Plugin Install', - message: 'Failed to install plugin', - error: err.message, - }); - } - }, - }); - break; - - case COMMAND_PLUGIN_THEME: - const parsedTheme = JSON.parse(decodeURIComponent(args.theme)); - showModal(AskModal, { - title: 'Install Theme', - message: ( - - Do you want to install {parsedTheme.displayName}? - - ), - yesText: 'Install', - noText: 'Cancel', - onDone: async (isYes: boolean) => { - if (!isYes) { - return; - } - - const mainJsContent = `module.exports.themes = [${JSON.stringify( - parsedTheme, - null, - 2, - )}];`; - await createPlugin(`theme-${parsedTheme.name}`, '0.0.1', mainJsContent); - const settings = await models.settings.getOrCreate(); - await models.settings.update(settings, { - theme: parsedTheme.name, - }); - await reloadPlugins(); - await setTheme(parsedTheme.name); - showModal(SettingsModal, { tab: TAB_INDEX_THEMES }); - }, - }); - break; - - case COMMAND_GITHUB_OAUTH_AUTHENTICATE: { - await exchangeCodeForToken(args).catch((error: Error) => { - showError({ - error, - title: 'Error authorizing GitHub', - message: error.message, - }); - }); - break; - } - - case COMMAND_GITLAB_OAUTH_AUTHENTICATE: { - await exchangeCodeForGitLabToken(args).catch((error: Error) => { - showError({ - error, - title: 'Error authorizing GitLab', - message: error.message, - }); - }); - break; - } - - case COMMAND_FINISH_AUTHENTICATION: { - submitAuthCode(args.box); - break; - } - - case null: - break; - - default: { - console.log(`Unknown command: ${command}`); - } - } -}; - -export const loadStart = () => ({ - type: LOAD_START, -}); - -export const loadStop = () => ({ - type: LOAD_STOP, -}); - -export const loadRequestStart = (requestId: string) => ({ - type: LOAD_REQUEST_START, - requestId, - time: Date.now(), -}); - export const loginStateChange = (loggedIn: boolean) => ({ type: LOGIN_STATE_CHANGE, loggedIn, }); -export const loadRequestStop = (requestId: string) => ({ - type: LOAD_REQUEST_STOP, - requestId, -}); - /* Go to an explicit activity */ export const setActiveActivity = (activity: GlobalActivity) => { - activity = _normalizeActivity(activity); - if (window.localStorage.getItem(`${LOCALSTORAGE_PREFIX}::activity`) !== activity) { - window.localStorage.setItem(`${LOCALSTORAGE_PREFIX}::activity`, JSON.stringify(activity)); - trackPageView(activity); - } + activity = isValidActivity(activity) ? activity : ACTIVITY_HOME; + window.localStorage.setItem(`${LOCALSTORAGE_PREFIX}::activity`, JSON.stringify(activity)); + trackPageView(activity); return { type: SET_ACTIVE_ACTIVITY, activity, @@ -369,254 +133,3 @@ export const setActiveWorkspace = (workspaceId: string | null) => { workspaceId, }; }; - -const VALUE_JSON = 'json'; -const VALUE_YAML = 'yaml'; -const VALUE_HAR = 'har'; - -export type SelectedFormat = - | typeof VALUE_HAR - | typeof VALUE_JSON - | typeof VALUE_YAML - ; - -const showSelectExportTypeModal = ({ onDone }: { - onDone: (selectedFormat: SelectedFormat) => Promise; -}) => { - const options = [ - { - name: 'Insomnia v4 (JSON)', - value: VALUE_JSON, - }, - { - name: 'Insomnia v4 (YAML)', - value: VALUE_YAML, - }, - { - name: 'HAR – HTTP Archive Format', - value: VALUE_HAR, - }, - ]; - - const lastFormat = window.localStorage.getItem('insomnia.lastExportFormat'); - const defaultValue = options.find(({ value }) => value === lastFormat) ? lastFormat : VALUE_JSON; - - showModal(SelectModal, { - title: 'Select Export Type', - value: defaultValue, - options, - message: 'Which format would you like to export as?', - onDone: async selectedFormat => { - if (selectedFormat) { - window.localStorage.setItem('insomnia.lastExportFormat', selectedFormat); - await onDone(selectedFormat as SelectedFormat); - } - }, - }); -}; - -const showExportPrivateEnvironmentsModal = async (privateEnvNames: string) => { - return new Promise(resolve => { - showModal(AskModal, { - title: 'Export Private Environments?', - message: `Do you want to include private environments (${privateEnvNames}) in your export?`, - onDone: async (isYes: boolean) => { - if (isYes) { - resolve(true); - } else { - resolve(false); - } - }, - }); - }); -}; - -const showSaveExportedFileDialog = async ({ - exportedFileNamePrefix, - selectedFormat, -}: { - exportedFileNamePrefix: string; - selectedFormat: SelectedFormat; -}) => { - const date = format(Date.now(), 'yyyy-MM-dd'); - const name = exportedFileNamePrefix.replace(/ /g, '-'); - const lastDir = window.localStorage.getItem('insomnia.lastExportPath'); - const dir = lastDir || window.app.getPath('desktop'); - const options = { - title: 'Export Insomnia Data', - buttonLabel: 'Export', - defaultPath: `${path.join(dir, `${name}_${date}`)}.${selectedFormat}`, - }; - const { filePath } = await window.dialog.showSaveDialog(options); - return filePath || null; -}; - -const writeExportedFileToFileSystem = (filename: string, jsonData: string, onDone: NoParamCallback) => { - // Remember last exported path - window.localStorage.setItem('insomnia.lastExportPath', path.dirname(filename)); - fs.writeFile(filename, jsonData, {}, onDone); -}; - -export const exportAllToFile = (activeProjectName: string, workspacesForActiveProject: Workspace[]) => { - if (!workspacesForActiveProject.length) { - showAlert({ - title: 'Cannot export', - message: <>There are no workspaces to export in the {activeProjectName} {strings.project.singular.toLowerCase()}., - }); - return; - } - - showSelectExportTypeModal({ - onDone: async selectedFormat => { - // Check if we want to export private environments. - const environments = await models.environment.all(); - - let exportPrivateEnvironments = false; - const privateEnvironments = environments.filter(environment => environment.isPrivate); - - if (privateEnvironments.length) { - const names = privateEnvironments.map(environment => environment.name).join(', '); - exportPrivateEnvironments = await showExportPrivateEnvironmentsModal(names); - } - - const fileName = await showSaveExportedFileDialog({ - exportedFileNamePrefix: 'Insomnia-All', - selectedFormat, - }); - - if (!fileName) { - return; - } - - let stringifiedExport; - - try { - switch (selectedFormat) { - case VALUE_HAR: - stringifiedExport = await exportWorkspacesHAR(workspacesForActiveProject, exportPrivateEnvironments); - break; - - case VALUE_YAML: - stringifiedExport = await exportWorkspacesData(workspacesForActiveProject, exportPrivateEnvironments, 'yaml'); - break; - - case VALUE_JSON: - stringifiedExport = await exportWorkspacesData(workspacesForActiveProject, exportPrivateEnvironments, 'json'); - break; - - default: - unreachableCase(selectedFormat, `selected export format "${selectedFormat}" is invalid`); - } - } catch (err) { - showError({ - title: 'Export Failed', - error: err, - message: 'Export failed due to an unexpected error', - }); - return; - } - - writeExportedFileToFileSystem(fileName, stringifiedExport, err => { - if (err) { - console.warn('Export failed', err); - } - }); - }, - }); -}; - -export const exportRequestsToFile = (requestIds: string[]) => { - showSelectExportTypeModal({ - onDone: async selectedFormat => { - const requests: (GrpcRequest | Request | WebSocketRequest)[] = []; - const privateEnvironments: Environment[] = []; - const workspaceLookup: any = {}; - - for (const requestId of requestIds) { - const request = await requestOperations.getById(requestId); - - if (request == null) { - continue; - } - - requests.push(request); - const ancestors = await database.withAncestors(request, [ - models.workspace.type, - models.requestGroup.type, - ]); - const workspace = ancestors.find(isWorkspace); - - if (workspace == null || workspaceLookup.hasOwnProperty(workspace._id)) { - continue; - } - - workspaceLookup[workspace._id] = true; - const descendants = await database.withDescendants(workspace); - const privateEnvs = descendants.filter(isEnvironment).filter( - descendant => descendant.isPrivate, - ); - privateEnvironments.push(...privateEnvs); - } - - let exportPrivateEnvironments = false; - - if (privateEnvironments.length) { - const names = privateEnvironments.map(privateEnvironment => privateEnvironment.name).join(', '); - exportPrivateEnvironments = await showExportPrivateEnvironmentsModal(names); - } - - const fileName = await showSaveExportedFileDialog({ - exportedFileNamePrefix: 'Insomnia', - selectedFormat, - }); - - if (!fileName) { - return; - } - - let stringifiedExport; - - try { - switch (selectedFormat) { - case VALUE_HAR: - stringifiedExport = await exportRequestsHAR(requests, exportPrivateEnvironments); - break; - - case VALUE_YAML: - stringifiedExport = await exportRequestsData(requests, exportPrivateEnvironments, 'yaml'); - break; - - case VALUE_JSON: - stringifiedExport = await exportRequestsData(requests, exportPrivateEnvironments, 'json'); - break; - - default: - unreachableCase(selectedFormat, `selected export format "${selectedFormat}" is invalid`); - } - } catch (err) { - showError({ - title: 'Export Failed', - error: err, - message: 'Export failed due to an unexpected error', - }); - return; - } - - writeExportedFileToFileSystem(fileName, stringifiedExport, err => { - if (err) { - console.warn('Export failed', err); - } - }); - }, - }); -}; - -function _normalizeActivity(activity: GlobalActivity): GlobalActivity { - if (isValidActivity(activity)) { - return activity; - } - - const fallbackActivity = ACTIVITY_HOME; - console.log(`[app] invalid activity "${activity}"; navigating to ${fallbackActivity}`); - return fallbackActivity; -} diff --git a/packages/insomnia/src/ui/redux/modules/helpers.ts b/packages/insomnia/src/ui/redux/modules/helpers.ts deleted file mode 100644 index eda1a1dc5..000000000 --- a/packages/insomnia/src/ui/redux/modules/helpers.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { strings } from '../../../common/strings'; -import { Project } from '../../../models/project'; -import { Workspace, WorkspaceScope, WorkspaceScopeKeys } from '../../../models/workspace'; -import { showModal } from '../../components/modals'; -import { AskModal } from '../../components/modals/ask-modal'; -import { showSelectModal } from '../../components/modals/select-modal'; - -export enum ForceToWorkspace { - new = 'new', - current = 'current', - existing = 'existing' -} - -export type SelectExistingWorkspacePrompt = Promise; -export function askToSelectExistingWorkspace(workspaces: Workspace[]): SelectExistingWorkspacePrompt { - return new Promise(resolve => { - const options = workspaces.map(workspace => ({ name: workspace.name, value: workspace._id })); - - showSelectModal({ - title: 'Import', - message: `Select a ${strings.workspace.singular.toLowerCase()} to import into`, - options, - value: options[0]?.value, - noEscape: true, - onDone: workspaceId => { - resolve(workspaceId); - }, - }); - }); -} - -async function askToImportIntoNewWorkspace(): Promise { - return new Promise(resolve => { - showModal(AskModal, { - title: 'Import', - message: `Do you want to import into an existing ${strings.workspace.singular.toLowerCase()} or a new one?`, - yesText: 'Existing', - noText: 'New', - onDone: async (yes: boolean) => { - resolve(yes); - }, - }); - }); -} - -// Returning null instead of a string will create a new workspace -export type ImportToWorkspacePrompt = () => null | string | Promise; -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 === 0) { - return null; - } - - return new Promise(async resolve => { - const yes = await askToImportIntoNewWorkspace(); - if (yes) { - const workspaceId = await askToSelectExistingWorkspace(activeProjectWorkspaces); - resolve(workspaceId); - } else { - resolve(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; -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; -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; - - showSelectModal({ - title: 'Import', - message: `Select a ${strings.project.singular.toLowerCase()} to import into`, - options, - value: defaultValue, - noEscape: true, - onDone: selectedProjectId => { - // @ts-expect-error onDone can send null as an argument; why/how? - resolve(selectedProjectId); - }, - }); - }); - }; -} diff --git a/packages/insomnia/src/ui/redux/modules/import.ts b/packages/insomnia/src/ui/redux/modules/import.ts deleted file mode 100644 index e3fb753f5..000000000 --- a/packages/insomnia/src/ui/redux/modules/import.ts +++ /dev/null @@ -1,179 +0,0 @@ -import electron, { OpenDialogOptions } from 'electron'; -import { AnyAction } from 'redux'; -import { ThunkAction } from 'redux-thunk'; - -import { - importRaw, - ImportRawConfig, - ImportResult, - importUri as _importUri, -} from '../../../common/import'; -import * as models from '../../../models'; -import { Workspace, WorkspaceScope } from '../../../models/workspace'; -import { showError, showModal } from '../../components/modals'; -import { AlertModal } from '../../components/modals/alert-modal'; -import { selectActiveProject, selectProjects, selectWorkspacesWithResolvedNameForActiveProject } from '../selectors'; -import { RootState } from '.'; -import { loadStart, loadStop } from './global'; -import { askToImportIntoProject, askToImportIntoWorkspace, askToSetWorkspaceScope, ForceToWorkspace } from './helpers'; - -export interface ImportOptions { - workspaceId?: string; - forceToProject?: 'active' | 'prompt'; - forceToWorkspace?: ForceToWorkspace; - forceToScope?: WorkspaceScope; - onComplete?: () => void; -} - -const handleImportResult = (result: ImportResult, errorMessage: string) => { - const { error, summary } = result; - - if (error) { - showError({ - title: 'Import Failed', - message: errorMessage, - error, - }); - return []; - } - - models.stats.incrementRequestStats({ - createdRequests: summary[models.request.type].length + summary[models.grpcRequest.type].length, - }); - return (summary[models.workspace.type] as Workspace[]) || []; -}; - -const convertToRawConfig = ({ - forceToScope, - forceToWorkspace, - workspaceId, - forceToProject, -}: ImportOptions, -state: RootState): ImportRawConfig => { - const activeProject = selectActiveProject(state); - const activeProjectWorkspaces = selectWorkspacesWithResolvedNameForActiveProject(state); - const projects = selectProjects(state); - - return ({ - 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), - }); -}; - -export const importFile = ( - options: ImportOptions = {}, -): ThunkAction => async (dispatch, getState) => { - dispatch(loadStart()); - - 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 - dispatch(loadStop()); - return; - } - - // Let's import all the files! - for (const filePath of filePaths) { - try { - const uri = `file://${filePath}`; - const config = convertToRawConfig(options, getState()); - const result = await _importUri(uri, config); - handleImportResult(result, 'The file does not contain a valid specification.'); - } catch (err) { - showModal(AlertModal, { - title: 'Import Failed', - message: err + '', - }); - } finally { - dispatch(loadStop()); - } - } - - options.onComplete?.(); -}; - -export const readFromClipBoard = () => { - const schema = electron.clipboard.readText(); - - if (!schema) { - showModal(AlertModal, { - title: 'Import Failed', - message: 'Your clipboard appears to be empty.', - }); - } - - return schema; -}; - -export const importClipBoard = ( - options: ImportOptions = {}, -): ThunkAction => async (dispatch, getState) => { - dispatch(loadStart()); - const schema = readFromClipBoard(); - if (!schema) { - return; - } - - // Let's import all the paths! - try { - const config = convertToRawConfig(options, getState()); - const result = await importRaw(schema, config); - handleImportResult(result, 'Your clipboard does not contain a valid specification.'); - } catch (err) { - showModal(AlertModal, { - title: 'Import Failed', - message: 'Your clipboard does not contain a valid specification.', - }); - } finally { - dispatch(loadStop()); - } - - options.onComplete?.(); -}; - -export const importUri = ( - uri: string, - options: ImportOptions = {}, -): ThunkAction => async (dispatch, getState) => { - dispatch(loadStart()); - try { - const config = convertToRawConfig(options, getState()); - const result = await _importUri(uri, config); - handleImportResult(result, 'The URI does not contain a valid specification.'); - } catch (err) { - showModal(AlertModal, { - title: 'Import Failed', - message: err + '', - }); - } finally { - dispatch(loadStop()); - } - - options.onComplete?.(); -}; diff --git a/packages/insomnia/src/ui/redux/modules/project.ts b/packages/insomnia/src/ui/redux/modules/project.ts deleted file mode 100644 index 1ec9c4128..000000000 --- a/packages/insomnia/src/ui/redux/modules/project.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ACTIVITY_HOME } from '../../../common/constants'; -import { strings } from '../../../common/strings'; -import * as models from '../../../models'; -import { DEFAULT_PROJECT_ID, isRemoteProject, Project } from '../../../models/project'; -import { SegmentEvent, trackSegmentEvent } from '../../analytics'; -import { showAlert, showPrompt } from '../../components/modals'; -import { setActiveActivity, setActiveProject } from './global'; - -export const createProject = () => (dispatch: any) => { - const defaultValue = `My ${strings.project.singular}`; - - showPrompt({ - title: `Create New ${strings.project.singular}`, - submitName: 'Create', - cancelable: true, - placeholder: defaultValue, - defaultValue, - selectText: true, - onComplete: async name => { - const project = await models.project.create({ name }); - dispatch(setActiveProject(project._id)); - dispatch(setActiveActivity(ACTIVITY_HOME)); - trackSegmentEvent(SegmentEvent.projectLocalCreate); - }, - }); -}; - -export const removeProject = (project: Project) => (dispatch: any) => { - const message = isRemoteProject(project) - ? `Deleting a ${strings.remoteProject.singular.toLowerCase()} ${strings.project.singular.toLowerCase()} will delete all local copies and changes of ${strings.document.plural.toLowerCase()} and ${strings.collection.plural.toLowerCase()} within. All changes that are not synced will be lost. The ${strings.remoteProject.singular.toLowerCase()} ${strings.project.singular.toLowerCase()} will continue to exist remotely. Deleting this ${strings.project.singular.toLowerCase()} locally cannot be undone. Are you sure you want to delete ${project.name}?` - : `Deleting a ${strings.project.singular.toLowerCase()} will delete all ${strings.document.plural.toLowerCase()} and ${strings.collection.plural.toLowerCase()} within. This cannot be undone. Are you sure you want to delete ${project.name}?`; - - showAlert({ - title: `Delete ${strings.project.singular}`, - message, - addCancel: true, - okLabel: 'Delete', - onConfirm: async () => { - await models.stats.incrementDeletedRequestsForDescendents(project); - await models.project.remove(project); - // Show default project - dispatch(setActiveProject(DEFAULT_PROJECT_ID)); - // Show home in case not already on home - dispatch(setActiveActivity(ACTIVITY_HOME)); - trackSegmentEvent(SegmentEvent.projectLocalDelete); - }, - }); -}; diff --git a/packages/insomnia/src/ui/redux/modules/workspace.ts b/packages/insomnia/src/ui/redux/modules/workspace.ts index ac7b73719..03bc7f35c 100644 --- a/packages/insomnia/src/ui/redux/modules/workspace.ts +++ b/packages/insomnia/src/ui/redux/modules/workspace.ts @@ -2,77 +2,12 @@ import { Dispatch } from 'redux'; import { RequireExactlyOne } from 'type-fest'; import { ACTIVITY_DEBUG, ACTIVITY_SPEC, GlobalActivity, isCollectionActivity, isDesignActivity } from '../../../common/constants'; -import { database } from '../../../common/database'; import * as models from '../../../models'; -import { isCollection, isDesign, Workspace, WorkspaceScope } from '../../../models/workspace'; -import { SegmentEvent, trackSegmentEvent } from '../../analytics'; -import { showPrompt } from '../../components/modals'; -import { selectActiveActivity, selectActiveProject, selectWorkspaces } from '../selectors'; +import { isCollection, isDesign, Workspace } from '../../../models/workspace'; +import { selectActiveActivity, selectWorkspaces } from '../selectors'; import { RootState } from '.'; import { setActiveActivity, setActiveProject, setActiveWorkspace } from './global'; -type OnWorkspaceCreateCallback = (arg0: Workspace) => Promise | void; - -const createWorkspaceAndChildren = async (patch: Partial) => { - const flushId = await database.bufferChanges(); - - const workspace = await models.workspace.create(patch); - await models.workspace.ensureChildren(workspace); - - await database.flushChanges(flushId); - return workspace; -}; - -const actuallyCreate = (patch: Partial, onCreate?: OnWorkspaceCreateCallback) => { - return async (dispatch: any) => { - const workspace = await createWorkspaceAndChildren(patch); - - if (onCreate) { - await onCreate(workspace); - } - - trackSegmentEvent(SegmentEvent.collectionCreate); - - await dispatch(activateWorkspace({ workspace })); - }; -}; - -export const createWorkspace = ({ scope, onCreate }: { - scope: WorkspaceScope; - onCreate?: OnWorkspaceCreateCallback; -}) => { - return (dispatch: any, getState: any) => { - const activeProject = selectActiveProject(getState()); - - const design = isDesign({ - scope, - }); - const title = design ? 'Design Document' : 'Request Collection'; - const defaultValue = design ? 'my-spec.yaml' : 'My Collection'; - const segmentEvent = design ? SegmentEvent.documentCreate : SegmentEvent.collectionCreate; - showPrompt({ - title: `Create New ${title}`, - submitName: 'Create', - placeholder: defaultValue, - defaultValue, - selectText: true, - onComplete: async name => { - await dispatch( - actuallyCreate( - { - name, - scope, - parentId: activeProject._id, - }, - onCreate, - ), - ); - trackSegmentEvent(segmentEvent); - }, - }); - }; -}; - export const activateWorkspace = ({ workspace, workspaceId }: RequireExactlyOne<{workspace: Workspace; workspaceId: string}>) => { return async (dispatch: Dispatch, getState: () => RootState) => { // If we have no workspace but we do have an id, search for it diff --git a/packages/insomnia/src/ui/redux/selectors.ts b/packages/insomnia/src/ui/redux/selectors.ts index fab893181..97acaf0a9 100644 --- a/packages/insomnia/src/ui/redux/selectors.ts +++ b/packages/insomnia/src/ui/redux/selectors.ts @@ -379,17 +379,6 @@ export const selectActiveOAuth2Token = createSelector( }, ); -export const selectLoadingRequestIds = createSelector( - selectGlobal, - global => global.loadingRequestIds, -); - -export const selectLoadStartTime = createSelector( - selectLoadingRequestIds, - selectActiveRequest, - (loadingRequestIds, activeRequest) => loadingRequestIds[activeRequest ? activeRequest._id : 'n/a'] || -1 -); - export const selectUnseenWorkspaces = createSelector( selectEntitiesLists, entities => { diff --git a/packages/insomnia/src/ui/routes/debug.tsx b/packages/insomnia/src/ui/routes/debug.tsx index 152ecf853..68fed9d5f 100644 --- a/packages/insomnia/src/ui/routes/debug.tsx +++ b/packages/insomnia/src/ui/routes/debug.tsx @@ -1,4 +1,5 @@ -import React, { FC, Fragment, useEffect } from 'react'; +import { invariant } from '@remix-run/router'; +import React, { FC, Fragment, useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import * as models from '../../models'; @@ -48,6 +49,14 @@ export const Debug: FC = () => { const settings = useSelector(selectSettings); const sidebarFilter = useSelector(selectSidebarFilter); const activeWorkspaceMeta = useSelector(selectActiveWorkspaceMeta); + const [runningRequests, setRunningRequests] = useState({}); + const setLoading = (isLoading: boolean) => { + invariant(activeRequest, 'No active request'); + setRunningRequests({ + ...runningRequests, + [activeRequest._id]: isLoading ? Date.now() : 0, + }); + }; useDocBodyKeyboardShortcuts({ request_togglePin: @@ -205,6 +214,7 @@ export const Debug: FC = () => { request={activeRequest} settings={settings} workspace={activeWorkspace} + setLoading={setLoading} /> ) ) @@ -221,7 +231,7 @@ export const Debug: FC = () => { isWebSocketRequest(activeRequest) ? ( ) : ( - + ) ) )} diff --git a/packages/insomnia/src/ui/routes/project.tsx b/packages/insomnia/src/ui/routes/project.tsx index 6425f8e95..2508b7cb9 100644 --- a/packages/insomnia/src/ui/routes/project.tsx +++ b/packages/insomnia/src/ui/routes/project.tsx @@ -20,6 +20,7 @@ import { ACTIVITY_SPEC, DashboardSortOrder, } from '../../common/constants'; +import { ForceToWorkspace } from '../../common/import'; import { fuzzyMatchAll, isNotNullOrUndefined } from '../../common/misc'; import { descendingNumberSort, sortMethodMap } from '../../common/sorting'; import { strings } from '../../common/strings'; @@ -43,13 +44,12 @@ import { EmptyStatePane } from '../components/panes/project-empty-state-pane'; import { SidebarLayout } from '../components/sidebar-layout'; import { Button } from '../components/themed-button'; import { WorkspaceCard } from '../components/workspace-card'; -import { cloneGitRepository } from '../redux/modules/git'; -import { ForceToWorkspace } from '../redux/modules/helpers'; import { importClipBoard, importFile, importUri, -} from '../redux/modules/import'; +} from '../import'; +import { cloneGitRepository } from '../redux/modules/git'; const CreateButton = styled(Button).attrs({ variant: 'outlined', @@ -299,10 +299,9 @@ const OrganizationProjectsSidebar: FC<{ return (