only migrate local data under org (#6660)

* first pass

* renaming and notes

* notes

* import-export ui improvements

* show unknown parentIds

* remove delete project

* can restore project

* local project logic

* can restore to local and create local

* check for selection

* some todos

* create project modal

* default value in project name

* only delete remote projects through the api

* only rename remote projects through the api

* move untracked projects and project move action to loader/action

* rename migrate

---------

Co-authored-by: gatzjames <jamesgatzos@gmail.com>
This commit is contained in:
Jack Kavanagh 2023-10-09 15:41:59 +02:00 committed by GitHub
parent 6933e92da2
commit b3a53ed93c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 573 additions and 321 deletions

View File

@ -0,0 +1,124 @@
import { getAccountId, getCurrentSessionId } from '../../account/session';
import { database } from '../../common/database';
import * as models from '../../models';
import { isOwnerOfOrganization, isPersonalOrganization } from '../../models/organization';
import { Project, RemoteProject } from '../../models/project';
import { OrganizationsResponse } from '../../ui/routes/organization';
import { invariant } from '../../utils/invariant';
let status: 'idle' | 'pending' | 'error' | 'completed' = 'idle';
// TODO:
// Error handling and return type for errors
// Migration:
// Team ~= Project > Workspaces
// In the previous API: { _id: 'proj_team_123', remoteId: 'team_123', parentId: null }
// Organization > TeamProject > Workspaces
// In the new API: { _id: 'proj_team_123', remoteId: 'proj_team_123', parentId: 'team_123' }
export const shouldMigrateProjectUnderOrganization = async () => {
const localProjectCount = await database.count<Project>(models.project.type, {
remoteId: null,
parentId: null,
_id: { $ne: models.project.SCRATCHPAD_PROJECT_ID },
});
const legacyRemoteProjectCount = await database.count<RemoteProject>(models.project.type, {
remoteId: { $ne: null },
parentId: null,
});
return localProjectCount > 0 || legacyRemoteProjectCount > 0;
};
export const migrateProjectsIntoOrganization = async () => {
if (status !== 'idle' && status !== 'error') {
return;
}
status = 'pending';
try {
const sessionId = getCurrentSessionId();
invariant(sessionId, 'User must be logged in to migrate projects');
// local projects what if they dont have a parentId?
// after migration: all projects have a parentId
// no more hostage projects
// when will we know what org is you have?
// already migrated: all things are globes
// already migrated with a blank account what happens to previous account data?
// go to whatever
// about to migrate: have one or more projects without parentIds/null
// if the project doesn't have remoteId we can throw in the home org
// if the project has a remoteId, and is in the org, we know it should have org as a parentId
// if the project has a remoteId, and is not in my logged in org
// go to the whatever
// whatever
// export all
// 1. show a alert describing the state of the orphaned projects and instructing export all and reimport
// 2. show a recovery ux to move old workspaces into existing projects
// 3. show orphaned projects in the home organization
// 4. show disabled orgs in the sidebar from previous account where you can see the data
// todo
// 1. [x] only assign parentIds and migrate old remote logic
// 2. count orphaned projects
// 3. decide which approach take for orphaned projects
// 4. decide if theres no reason to keep migrateCollectionsIntoRemoteProject
// assign remote project parentIds to new organization structure
// the remote id field used to track team_id (remote concept for matching 1:1 with this project) which is now org_id
// the _id field used to track the proj_team_id which was a wrapper for the team_id prefixing proj_to the above id,
// which is now the remoteId for tracking the projects within an org
const legacyRemoteProjects = await database.find<RemoteProject>(models.project.type, {
remoteId: { $ne: null },
parentId: null,
});
for (const remoteProject of legacyRemoteProjects) {
await models.project.update(remoteProject, {
parentId: remoteProject.remoteId,
remoteId: remoteProject._id,
});
}
// Local projects without organizations except scratchpad
const localProjects = await database.find<Project>(models.project.type, {
remoteId: null,
parentId: null,
_id: { $ne: models.project.SCRATCHPAD_PROJECT_ID },
});
const organizationsResult = await window.main.insomniaFetch<OrganizationsResponse | void>({
method: 'GET',
path: '/v1/organizations',
sessionId,
});
const accountId = getAccountId();
invariant(organizationsResult, 'Failed to fetch organizations');
invariant(accountId, 'Failed to get account id');
const { organizations } = organizationsResult;
const personalOrganization = organizations.filter(isPersonalOrganization)
.find(organization =>
isOwnerOfOrganization({
organization,
accountId,
}));
invariant(personalOrganization, 'Failed to find personal organization');
for (const localProject of localProjects) {
await models.project.update(localProject, {
parentId: personalOrganization.id,
});
}
status = 'completed';
} catch (err) {
console.warn('Failed to migrate projects to personal workspace', err);
throw err;
}
};

View File

@ -1,117 +0,0 @@
import { getCurrentSessionId } from '../../account/session';
import { database } from '../../common/database';
import * as models from '../../models';
import { Project, RemoteProject } from '../../models/project';
import { Workspace } from '../../models/workspace';
import { invariant } from '../../utils/invariant';
import { initializeLocalBackendProjectAndMarkForSync, pushSnapshotOnInitialize } from './initialize-backend-project';
import { VCS } from './vcs';
let status: 'idle' | 'pending' | 'error' | 'completed' = 'idle';
// TODO:
// Error handling and return type for errors
// Migration:
// Team ~= Project > Workspaces
// In the previous API: { _id: 'proj_team_123', remoteId: 'team_123', parentId: null }
// Organization > TeamProject > Workspaces
// In the new API: { _id: 'proj_team_123', remoteId: 'proj_team_123', parentId: 'team_123' }
export const shouldRunMigration = async () => {
const localProjects = await database.find<Project>(models.project.type, {
remoteId: null,
_id: { $ne: models.project.SCRATCHPAD_PROJECT_ID },
});
const legacyRemoteProjects = await database.find<RemoteProject>(models.project.type, {
remoteId: { $ne: null },
parentId: null,
});
return localProjects.length > 0 || legacyRemoteProjects.length > 0;
};
// Second time we run this, whats gonna happen?
// we will get duplicate projects in the cloud and local with the same but no workspaces in the duplicate
export const migrateLocalToCloudProjects = async (vcs: VCS) => {
if (status !== 'idle' && status !== 'error') {
return;
}
status = 'pending';
try {
const sessionId = getCurrentSessionId();
invariant(sessionId, 'User must be logged in to migrate projects');
// Migrate legacy remote projects to new organization structure
const legacyRemoteProjects = await database.find<RemoteProject>(models.project.type, {
remoteId: { $ne: null },
parentId: null,
});
for (const remoteProject of legacyRemoteProjects) {
await models.project.update(remoteProject, {
// Remote Id was previously the teamId
parentId: remoteProject.remoteId,
// _id was previously the remoteId
remoteId: remoteProject._id,
});
}
// Local projects except scratchpad
const localProjects = await database.find<Project>(models.project.type, {
remoteId: null,
_id: { $ne: models.project.SCRATCHPAD_PROJECT_ID },
});
for (const localProject of localProjects) {
// -- Unsafe to run twice, will cause duplicates unless we would need to match ids
const newCloudProject = await window.main.insomniaFetch<{ id: string; name: string; organizationId: string } | void>({
path: '/v1/organizations/personal/team-projects',
method: 'POST',
data: {
name: localProject.name,
},
sessionId,
});
invariant(typeof newCloudProject?.id === 'string', 'Failed to create remote project');
const project = await models.project.update(localProject, {
name: newCloudProject.name,
remoteId: newCloudProject.id,
parentId: newCloudProject.organizationId,
});
// For each workspace in the local project
const projectWorkspaces = (await database.find<Workspace>(models.workspace.type, {
parentId: localProject._id,
}));
for (const workspace of projectWorkspaces) {
const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(workspace._id);
// Initialize Sync on the workspace if it's not using Git sync
try {
if (!workspaceMeta.gitRepositoryId) {
invariant(vcs, 'VCS must be initialized');
await initializeLocalBackendProjectAndMarkForSync({ vcs, workspace });
await pushSnapshotOnInitialize({ vcs, workspace, project });
}
} catch (e) {
console.warn('Failed to initialize sync on workspace. This will be retried when the workspace is opened on the app.', e);
// TODO: here we should show the try again dialog
}
}
}
status = 'completed';
} catch (err) {
console.warn('Failed to migrate projects to cloud', err);
throw err;
}
};

View File

@ -1,27 +1,27 @@
import React, { FC, Fragment, useEffect, useState } from 'react';
import { Button, Heading, Item, ListBox, Popover, Select, SelectValue } from 'react-aria-components';
import { useFetcher, useParams } from 'react-router-dom';
import { useRouteLoaderData } from 'react-router-dom';
import { isLoggedIn } from '../../../account/session';
import { getProductName } from '../../../common/constants';
import { docsImportExport } from '../../../common/documentation';
import { exportAllToFile } from '../../../common/export';
import { exportAllData } from '../../../common/export-all-data';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
import { strings } from '../../../common/strings';
import { isScratchpadOrganizationId } from '../../../models/organization';
import { isScratchpadOrganizationId, Organization } from '../../../models/organization';
import { isScratchpad } from '../../../models/workspace';
import { SegmentEvent } from '../../analytics';
import { useOrganizationLoaderData } from '../../routes/organization';
import { ProjectLoaderData } from '../../routes/project';
import { useRootLoaderData } from '../../routes/root';
import { LoaderData } from '../../routes/untracked-projects';
import { WorkspaceLoaderData } from '../../routes/workspace';
import { Dropdown, DropdownButton, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown';
import { Link } from '../base/link';
import { Dropdown, DropdownItem, DropdownSection, ItemContent } from '../base/dropdown';
import { Icon } from '../icon';
import { showAlert } from '../modals';
import { ExportRequestsModal } from '../modals/export-requests-modal';
import { ImportModal } from '../modals/import-modal';
import { Button } from '../themed-button';
interface Props {
hideSettingsModal: () => void;
}
@ -32,6 +32,19 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
projectId,
workspaceId,
} = useParams() as { organizationId: string; projectId: string; workspaceId?: string };
const { organizations } = useOrganizationLoaderData();
const untrackedProjectsFetcher = useFetcher<LoaderData>();
const moveProjectFetcher = useFetcher();
useEffect(() => {
const isIdleAndUninitialized = untrackedProjectsFetcher.state === 'idle' && !untrackedProjectsFetcher.data;
if (isIdleAndUninitialized) {
untrackedProjectsFetcher.load('/untracked-projects');
}
}, [untrackedProjectsFetcher, organizationId]);
const untrackedProjects = untrackedProjectsFetcher.data?.untrackedProjects || [];
const workspaceData = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData | undefined;
const activeWorkspaceName = workspaceData?.activeWorkspace.name;
@ -57,123 +70,214 @@ export const ImportExport: FC<Props> = ({ hideSettingsModal }) => {
if (!organizationId) {
return null;
}
// here we should list all the folders which contain insomnia.*.db files
// and have some big red button to overwrite the current data with the backup
// and once complete trigger an app restart?
return (
<Fragment>
<div data-testid="import-export-tab">
<div className="no-margin-top">
Import format will be automatically detected.
</div>
<p>
Your format isn't supported? <Link href={docsImportExport}>Add Your Own</Link>.
</p>
<div className="flex flex-col pt-4 gap-4">
{workspaceData?.activeWorkspace ?
isScratchpad(workspaceData.activeWorkspace) ?
<Button onClick={() => setIsExportModalOpen(true)}>Export the "{activeWorkspaceName}" {getWorkspaceLabel(workspaceData.activeWorkspace).singular}</Button>
:
(<Dropdown
aria-label='Export Data Dropdown'
triggerButton={
<DropdownButton className="btn btn--clicky">
Export Data <i className="fa fa-caret-down" />
</DropdownButton>
}
>
<DropdownSection
aria-label="Choose Export Type"
title="Choose Export Type"
>
<DropdownItem aria-label={`Export the "${activeWorkspaceName}" ${getWorkspaceLabel(workspaceData.activeWorkspace).singular}`}>
<ItemContent
icon="home"
label={`Export the "${activeWorkspaceName}" ${getWorkspaceLabel(workspaceData.activeWorkspace).singular}`}
onClick={() => setIsExportModalOpen(true)}
/>
</DropdownItem>
<DropdownItem aria-label={`Export files from the "${projectName}" ${strings.project.singular}`}>
<ItemContent
icon="empty"
label={`Export files from the "${projectName}" ${strings.project.singular}`}
onClick={handleExportAllToFile}
/>
</DropdownItem>
</DropdownSection>
</Dropdown>) : (<Button onClick={handleExportAllToFile}>{`Export files from the "${projectName}" ${strings.project.singular}`}</Button>)
}
<Button
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-sm)',
}}
onClick={async () => {
const { filePaths, canceled } = await window.dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory', 'promptToCreate'],
buttonLabel: 'Select',
title: 'Export All Insomnia Data',
});
if (canceled) {
return;
}
const [dirPath] = filePaths;
try {
dirPath && await exportAllData({
dirPath,
<div data-testid="import-export-tab" className='flex flex-col gap-4'>
<div className='rounded-md border border-solid border-[--hl-md] p-4 flex flex-col gap-2'>
<Heading className='text-lg font-bold flex items-center gap-2'><Icon icon="file-export" /> Export:</Heading>
<div className="flex gap-2 flex-wrap">
{workspaceData?.activeWorkspace ?
isScratchpad(workspaceData.activeWorkspace) ?
<Button className="px-4 py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm" onPress={() => setIsExportModalOpen(true)}>Export the "{activeWorkspaceName}" {getWorkspaceLabel(workspaceData.activeWorkspace).singular}</Button>
:
(
<Dropdown
aria-label='Export Data Dropdown'
triggerButton={
<Button className="px-4 py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm">
Export Data <i className="fa fa-caret-down" />
</Button>
}
>
<DropdownSection
aria-label="Choose Export Type"
title="Choose Export Type"
>
<DropdownItem aria-label={`Export the "${activeWorkspaceName}" ${getWorkspaceLabel(workspaceData.activeWorkspace).singular}`}>
<ItemContent
icon="home"
label={`Export the "${activeWorkspaceName}" ${getWorkspaceLabel(workspaceData.activeWorkspace).singular}`}
onClick={() => setIsExportModalOpen(true)}
/>
</DropdownItem>
<DropdownItem aria-label={`Export files from the "${projectName}" ${strings.project.singular}`}>
<ItemContent
icon="empty"
label={`Export files from the "${projectName}" ${strings.project.singular}`}
onClick={handleExportAllToFile}
/>
</DropdownItem>
</DropdownSection>
</Dropdown>
) : (
<Button
className="px-4 py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={handleExportAllToFile}
>
{`Export files from the "${projectName}" ${strings.project.singular}`}
</Button>
)
}
<Button
className="px-4 py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={async () => {
const { filePaths, canceled } = await window.dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory', 'promptToCreate'],
buttonLabel: 'Select',
title: 'Export All Insomnia Data',
});
} catch (e) {
if (canceled) {
return;
}
const [dirPath] = filePaths;
try {
dirPath && await exportAllData({
dirPath,
});
} catch (e) {
showAlert({
title: 'Export Failed',
message: 'An error occurred while exporting data. Please try again.',
});
console.error(e);
}
showAlert({
title: 'Export Failed',
message: 'An error occurred while exporting data. Please try again.',
title: 'Export Complete',
message: 'All your data have been successfully exported',
});
console.error(e);
}
window.main.trackSegmentEvent({
event: SegmentEvent.exportAllCollections,
});
}}
aria-label='Export all data'
>
<Icon icon="file-export" />
<span>Export all data {`(${workspaceCount} files)`}</span>
</Button>
showAlert({
title: 'Export Complete',
message: 'All your data have been successfully exported',
});
window.main.trackSegmentEvent({
event: SegmentEvent.exportAllCollections,
});
}}
aria-label='Export all data'
className="px-4 py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-base"
>
<Icon icon="file-export" />
<span>Export all data {`(${workspaceCount} files)`}</span>
</Button>
<Button
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-sm)',
}}
disabled={workspaceData?.activeWorkspace && isScratchpad(workspaceData?.activeWorkspace)}
onClick={() => setIsImportModalOpen(true)}
>
<i className="fa fa-file-import" />
{`Import to the "${projectName}" ${strings.project.singular}`}
</Button>
<Button
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--padding-sm)',
}}
disabled={!isLoggedIn()}
onClick={() => window.main.openInBrowser('https://insomnia.rest/create-run-button')}
>
<i className="fa fa-file-import" />
Create Run Button
</Button>
<Button
className="px-4 py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
isDisabled={!isLoggedIn()}
onPress={() => window.main.openInBrowser('https://insomnia.rest/create-run-button')}
>
<i className="fa fa-file-import" />
Create Run Button
</Button>
</div>
</div>
<div className='rounded-md border border-solid border-[--hl-md] p-4 flex flex-col gap-2'>
<Heading className='text-lg font-bold flex items-center gap-2'><Icon icon="file-import" /> Import:</Heading>
<div className="flex gap-2 flex-wrap">
<Button
className="px-4 py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
isDisabled={workspaceData?.activeWorkspace && isScratchpad(workspaceData?.activeWorkspace)}
onPress={() => setIsImportModalOpen(true)}
>
<Icon icon="file-import" />
{`Import to the "${projectName}" ${strings.project.singular}`}
</Button>
</div>
</div>
{untrackedProjects.length > 0 && <div className='rounded-md border border-solid border-[--hl-md] p-4 flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<Heading className='text-lg font-bold flex items-center gap-2'><Icon icon="cancel" /> Untracked projects ({untrackedProjects.length})</Heading>
<p className='text-[--hl] text-sm'>
<Icon icon="info-circle" /> These projects are not associated with any organization in your account. You can move them to an organization below.
</p>
</div>
<div className='flex flex-col gap-1 overflow-y-auto divide-y divide-solid divide-[--hl-md]'>
{untrackedProjects.map(project => (
<div key={project._id} className="flex items-center gap-2 justify-between py-2">
<div className='flex flex-col gap-1'>
<Heading className='text-base font-semibold flex items-center gap-2'>
{project.name}
<span className='text-xs text-[--hl]'>
Id: {project._id}
</span>
</Heading>
<p className='text-sm'>
This project contains {project.workspacesCount} {project.workspacesCount === 1 ? 'file' : 'files'}.
</p>
</div>
<form
className='flex items-center gap-2'
onSubmit={e => {
e.preventDefault();
moveProjectFetcher.submit(e.currentTarget, {
action: `/organization/${organizationId}/project/${project._id}/move`,
method: 'POST',
});
}}
>
<Select
aria-label="Select an organization"
name="organizationId"
items={organizations}
>
<Button className="px-4 py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm">
<SelectValue<Organization> className="flex truncate items-center justify-center gap-2">
{({ selectedItem }) => {
if (!selectedItem) {
return (
<Fragment>
<span>
Select an organization
</span>
</Fragment>
);
}
return (
<Fragment>
{selectedItem.display_name}
</Fragment>
);
}}
</SelectValue>
<Icon icon="caret-down" />
</Button>
<Popover className="min-w-max">
<ListBox<Organization>
className="border select-none text-sm min-w-max border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] py-2 rounded-md overflow-y-auto max-h-[85vh] focus:outline-none"
>
{item => (
<Item
id={item.id}
key={item.id}
className="flex gap-2 px-[--padding-md] aria-selected:font-bold items-center text-[--color-font] h-[--line-height-xs] w-full text-md whitespace-nowrap bg-transparent hover:bg-[--hl-sm] disabled:cursor-not-allowed focus:bg-[--hl-xs] focus:outline-none transition-colors"
aria-label={item.name}
textValue={item.name}
value={item}
>
{({ isSelected }) => (
<Fragment>
{item.display_name}
{isSelected && (
<Icon
icon="check"
className="text-[--color-success] justify-self-end"
/>
)}
</Fragment>
)}
</Item>
)}
</ListBox>
</Popover>
</Select>
<Button type="submit" className="px-4 py-1 font-semibold border border-solid border-[--hl-md] flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm">
Move
</Button>
</form>
</div>
))}
</div>
</div>}
</div>
{isImportModalOpen && (
<ImportModal

View File

@ -19,12 +19,15 @@ export const useGlobalKeyboardShortcuts = () => {
useDocBodyKeyboardShortcuts({
plugin_reload:
() => plugins.reloadPlugins(),
// TODO: move this to workspace route
environment_showVariableSourceAndValue:
() => patchSettings({ showVariableSourceAndValue: !settings.showVariableSourceAndValue }),
// TODO: move this to organization route
preferences_showGeneral:
() => showModal(SettingsModal),
preferences_showKeyboardShortcuts:
() => showModal(SettingsModal, { tab: TAB_INDEX_SHORTCUTS }),
// TODO: move this to workspace route
sidebar_toggle:
() => activeWorkspaceMeta && patchWorkspaceMeta(activeWorkspaceMeta.parentId, { sidebarHidden: !activeWorkspaceMeta.sidebarHidden }),
});

View File

@ -151,6 +151,10 @@ const router = createMemoryRouter(
action: async (...args) =>
(await import('./routes/actions')).updateSettingsAction(...args),
},
{
path: 'untracked-projects',
loader: async (...args) => (await import('./routes/untracked-projects')).loader(...args),
},
{
path: 'organization',
id: '/organization',
@ -209,6 +213,13 @@ const router = createMemoryRouter(
await import('./routes/actions')
).deleteProjectAction(...args),
},
{
path: 'move',
action: async (...args) =>
(
await import('./routes/actions')
).moveProjectAction(...args),
},
{
path: 'rename',
action: async (...args) =>

View File

@ -28,12 +28,23 @@ export const createNewProjectAction: ActionFunction = async ({ request, params }
const { organizationId } = params;
invariant(organizationId, 'Organization ID is required');
const formData = await request.formData();
const name = formData.get('name');
const name = formData.get('name') || 'My project';
invariant(typeof name === 'string', 'Name is required');
const projectType = formData.get('type');
invariant(projectType === 'local' || projectType === 'remote', 'Project type is required');
const sessionId = session.getCurrentSessionId();
invariant(sessionId, 'User must be logged in to create a project');
if (projectType === 'local') {
const project = await models.project.create({
name,
parentId: organizationId,
});
return redirect(`/organization/${organizationId}/project/${project._id}`);
}
try {
const newCloudProject = await window.main.insomniaFetch<{
id: string;
@ -97,22 +108,24 @@ export const renameProjectAction: ActionFunction = async ({
invariant(sessionId, 'User must be logged in to rename a project');
try {
const response = await window.main.insomniaFetch<void | {
error: string;
message?: string;
}>({
path: `/v1/organizations/${project.parentId}/team-projects/${project.remoteId}`,
method: 'PATCH',
sessionId,
data: {
name,
},
});
if (project.remoteId) {
const response = await window.main.insomniaFetch<void | {
error: string;
message?: string;
}>({
path: `/v1/organizations/${project.parentId}/team-projects/${project.remoteId}`,
method: 'PATCH',
sessionId,
data: {
name,
},
});
if (response && 'error' in response) {
return {
error: response.error === 'FORBIDDEN' ? 'You do not have permission to rename this project.' : 'An unexpected error occurred while renaming the project. Please try again.',
};
if (response && 'error' in response) {
return {
error: response.error === 'FORBIDDEN' ? 'You do not have permission to rename this project.' : 'An unexpected error occurred while renaming the project. Please try again.',
};
}
}
await models.project.update(project, { name });
@ -136,19 +149,21 @@ export const deleteProjectAction: ActionFunction = async ({ params }) => {
invariant(sessionId, 'User must be logged in to delete a project');
try {
const response = await window.main.insomniaFetch<void | {
error: string;
message?: string;
}>({
path: `/v1/organizations/${organizationId}/team-projects/${project.remoteId}`,
method: 'DELETE',
sessionId,
});
if (project.remoteId) {
const response = await window.main.insomniaFetch<void | {
error: string;
message?: string;
}>({
path: `/v1/organizations/${organizationId}/team-projects/${project.remoteId}`,
method: 'DELETE',
sessionId,
});
if (response && 'error' in response) {
return {
error: response.error === 'FORBIDDEN' ? 'You do not have permission to delete this project.' : 'An unexpected error occurred while deleting the project. Please try again.',
};
if (response && 'error' in response) {
return {
error: response.error === 'FORBIDDEN' ? 'You do not have permission to delete this project.' : 'An unexpected error occurred while deleting the project. Please try again.',
};
}
}
await models.stats.incrementDeletedRequestsForDescendents(project);
@ -163,6 +178,27 @@ export const deleteProjectAction: ActionFunction = async ({ params }) => {
}
};
export const moveProjectAction: ActionFunction = async ({ request, params }) => {
const { projectId } = params as { projectId: string };
const formData = await request.formData();
const organizationId = formData.get('organizationId');
invariant(typeof organizationId === 'string', 'Organization ID is required');
invariant(typeof projectId === 'string', 'Project ID is required');
const project = await models.project.getById(projectId);
invariant(project, 'Project not found');
await models.project.update(project, {
parentId: organizationId,
// We move a project to another organization as local no matter what it was before
remoteId: null,
});
return null;
};
// Workspace
export const createNewWorkspaceAction: ActionFunction = async ({
params,

View File

@ -3,7 +3,7 @@ import { Heading } from 'react-aria-components';
import { ActionFunction, redirect, useFetcher, useFetchers, useNavigate } from 'react-router-dom';
import { isLoggedIn } from '../../account/session';
import { shouldRunMigration } from '../../sync/vcs/migrate-to-cloud-projects';
import { shouldMigrateProjectUnderOrganization } from '../../sync/vcs/migrate-projects-into-organization';
import { invariant } from '../../utils/invariant';
import { getLoginUrl, submitAuthCode } from '../auth-session-provider';
import { Icon } from '../components/icon';
@ -26,7 +26,7 @@ export const action: ActionFunction = async ({
console.log('Login successful');
window.localStorage.setItem('hasUserLoggedInBefore', 'true');
if (isLoggedIn() && await shouldRunMigration()) {
if (isLoggedIn() && await shouldMigrateProjectUnderOrganization()) {
throw redirect('/auth/migrate');
}

View File

@ -4,7 +4,7 @@ import { ActionFunction, Link, LoaderFunction, redirect, useFetcher, useLoaderDa
import { getAppWebsiteBaseURL } from '../../common/constants';
import { exportAllData } from '../../common/export-all-data';
import { shouldRunMigration } from '../../sync/vcs/migrate-to-cloud-projects';
import { shouldMigrateProjectUnderOrganization } from '../../sync/vcs/migrate-projects-into-organization';
import { SegmentEvent } from '../analytics';
import { getLoginUrl } from '../auth-session-provider';
import { Icon } from '../components/icon';
@ -39,7 +39,7 @@ interface LoaderData {
}
export const loader: LoaderFunction = async () => {
const hasProjectsToMigrate = await shouldRunMigration();
const hasProjectsToMigrate = await shouldMigrateProjectUnderOrganization();
return {
hasProjectsToMigrate,

View File

@ -5,12 +5,12 @@ import { ActionFunction, LoaderFunction, redirect, useFetcher } from 'react-rout
import { getCurrentSessionId, logout } from '../../account/session';
import FileSystemDriver from '../../sync/store/drivers/file-system-driver';
import { migrateCollectionsIntoRemoteProject } from '../../sync/vcs/migrate-collections';
import { migrateLocalToCloudProjects, shouldRunMigration } from '../../sync/vcs/migrate-to-cloud-projects';
import { migrateProjectsIntoOrganization, shouldMigrateProjectUnderOrganization } from '../../sync/vcs/migrate-projects-into-organization';
import { VCS } from '../../sync/vcs/vcs';
import { Icon } from '../components/icon';
export const loader: LoaderFunction = async () => {
if (!shouldRunMigration()) {
if (!shouldMigrateProjectUnderOrganization()) {
return redirect('/organization');
}
@ -32,7 +32,7 @@ export const action: ActionFunction = async () => {
const driver = FileSystemDriver.create(process.env['INSOMNIA_DATA_PATH'] || window.app.getPath('userData'));
const vcs = new VCS(driver);
await migrateCollectionsIntoRemoteProject(vcs);
await migrateLocalToCloudProjects(vcs);
await migrateProjectsIntoOrganization();
return redirect('/organization');
} catch (err) {
@ -61,13 +61,13 @@ export const Migrate = () => {
return (
<div className="flex flex-col gap-[--padding-md] text-[--color-font]">
<Heading className="text-2xl font-bold text-center px-3">
Migrating data to Insomnia Cloud
Initializing Organizations
</Heading>
{isMigrating && (
<div className="flex flex-col gap-3 rounded-md bg-[--hl-sm] p-[--padding-md]">
<Heading className="text-lg flex items-center p-8 gap-8">
<Icon icon="spinner" className="fa-spin" />
<span>Running migration...</span>
<span>Fetching your organizations...</span>
</Heading>
</div>
)}

View File

@ -4,7 +4,7 @@ import { ActionFunction, LoaderFunction, redirect, useFetcher } from 'react-rout
import { logout } from '../../account/session';
import { exportAllData } from '../../common/export-all-data';
import { shouldRunMigration } from '../../sync/vcs/migrate-to-cloud-projects';
import { shouldMigrateProjectUnderOrganization } from '../../sync/vcs/migrate-projects-into-organization';
import { SegmentEvent } from '../analytics';
import { InsomniaLogo } from '../components/insomnia-icon';
import { showAlert } from '../components/modals';
@ -17,7 +17,7 @@ export const action: ActionFunction = async () => {
};
export const loader: LoaderFunction = async () => {
if (await shouldRunMigration()) {
if (await shouldMigrateProjectUnderOrganization()) {
return null;
}

View File

@ -35,7 +35,7 @@ import { isOwnerOfOrganization, isPersonalOrganization, isScratchpadOrganization
import { isDesign, isScratchpad } from '../../models/workspace';
import FileSystemDriver from '../../sync/store/drivers/file-system-driver';
import { MergeConflict } from '../../sync/types';
import { shouldRunMigration } from '../../sync/vcs/migrate-to-cloud-projects';
import { shouldMigrateProjectUnderOrganization } from '../../sync/vcs/migrate-projects-into-organization';
import { getVCS, initVCS } from '../../sync/vcs/vcs';
import { invariant } from '../../utils/invariant';
import { SegmentEvent } from '../analytics';
@ -55,7 +55,7 @@ import { PresenceProvider } from '../context/app/presence-context';
import { useRootLoaderData } from './root';
import { WorkspaceLoaderData } from './workspace';
interface OrganizationsResponse {
export interface OrganizationsResponse {
start: number;
limit: number;
length: number;
@ -90,7 +90,7 @@ interface CurrentPlan {
type: PersonalPlanType;
};
const organizationsData: OrganizationLoaderData = {
export const organizationsData: OrganizationLoaderData = {
organizations: [],
user: undefined,
currentPlan: undefined,
@ -121,7 +121,7 @@ export const indexLoader: LoaderFunction = async () => {
if (sessionId) {
// Check if there are any migrations to run before loading organizations.
// If there are migrations, we need to log the user out and redirect them to the login page
if (await shouldRunMigration()) {
if (await shouldMigrateProjectUnderOrganization()) {
await session.logout();
return redirect('/auth/login');
}
@ -175,13 +175,12 @@ export const indexLoader: LoaderFunction = async () => {
organizationsData.user = user;
organizationsData.currentPlan = currentPlan;
const personalOrganization = organizations.filter(isPersonalOrganization).find(organization => {
const accountId = getAccountId();
return accountId && isOwnerOfOrganization({
organization,
accountId,
});
});
const personalOrganization = organizations.filter(isPersonalOrganization)
.find(organization =>
isOwnerOfOrganization({
organization,
accountId,
}));
if (personalOrganization) {
return redirect(`/organization/${personalOrganization.id}`);

View File

@ -2,17 +2,25 @@ import { IconName } from '@fortawesome/fontawesome-svg-core';
import React, { FC, Fragment, useEffect, useState } from 'react';
import {
Button,
Dialog,
DialogTrigger,
GridList,
Heading,
Input,
Item,
Label,
ListBox,
Menu,
MenuTrigger,
Modal,
ModalOverlay,
Popover,
Radio,
RadioGroup,
SearchField,
Select,
SelectValue,
TextField,
} from 'react-aria-components';
import {
LoaderFunction,
@ -37,7 +45,6 @@ import {
import { database } from '../../common/database';
import { fuzzyMatchAll, isNotNullOrUndefined } from '../../common/misc';
import { descendingNumberSort, sortMethodMap } from '../../common/sorting';
import { strings } from '../../common/strings';
import * as models from '../../models';
import { ApiSpec } from '../../models/api-spec';
import { CaCertificate } from '../../models/ca-certificate';
@ -161,18 +168,6 @@ async function syncTeamProjects({
});
}
}));
// Remove any remote projects from the current organization that are not in the list of remote projects
const removedRemoteProjects = await database.find<Project>(models.project.type, {
// filter by this organization so no legacy data can be accidentally removed, because legacy had null parentId
parentId: organizationId,
// Remote ID is not in the list of remote projects
remoteId: { $nin: teamProjects.map(p => p.id) },
});
await Promise.all(removedRemoteProjects.map(async prj => {
await models.project.remove(prj);
}));
}
export const indexLoader: LoaderFunction = async ({ params }) => {
@ -750,33 +745,94 @@ const ProjectRoute: FC = () => {
</Button>
</div>
</SearchField>
<DialogTrigger>
<Button
aria-label="Create new Project"
className="flex items-center justify-center h-full aspect-square aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
<Icon icon="plus-circle" />
</Button>
<ModalOverlay isDismissable className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex items-center justify-center bg-black/30">
<Modal className="max-w-2xl w-full rounded-md border border-solid border-[--hl-sm] p-[--padding-lg] max-h-full bg-[--color-bg] text-[--color-font]">
<Dialog className="outline-none">
{({ close }) => (
<div className='flex flex-col gap-4'>
<div className='flex gap-2 items-center justify-between'>
<Heading className='text-2xl'>Create a new project</Heading>
<Button
className="flex flex-shrink-0 items-center justify-center aspect-square h-6 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={close}
>
<Icon icon="x" />
</Button>
</div>
<form
className='flex flex-col gap-4'
onSubmit={e => {
createNewProjectFetcher.submit(e.currentTarget, {
action: `/organization/${organizationId}/project/new`,
method: 'post',
});
<Button
onPress={() => {
const defaultValue = `My ${strings.project.singular}`;
showPrompt({
title: `Create New ${strings.project.singular}`,
submitName: 'Create',
placeholder: defaultValue,
defaultValue,
selectText: true,
onComplete: async name =>
createNewProjectFetcher.submit(
{
name,
},
{
action: `/organization/${organizationId}/project/new`,
method: 'post',
}
),
});
}}
aria-label="Create new Project"
className="flex items-center justify-center h-full aspect-square aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
<Icon icon="plus-circle" />
</Button>
close();
}}
>
<TextField
autoFocus
name="name"
defaultValue="My project"
className="group relative flex-1 flex flex-col gap-2"
>
<Label className='text-sm text-[--hl]'>
Project name
</Label>
<Input
placeholder="My project"
className="py-1 placeholder:italic w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors"
/>
</TextField>
<RadioGroup name="type" defaultValue="remote" className="flex flex-col gap-2">
<Label className="text-sm text-[--hl]">
Project type
</Label>
<div className="flex gap-2">
<Radio
value="remote"
className="data-[selected]:border-[--color-surprise] data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
<Icon icon="globe" />
<Heading className="text-lg font-bold">Secure Cloud</Heading>
<p className='pt-2'>
End-to-end encrypted (E2EE) and synced securely to the cloud, ideal for collaboration.
</p>
</Radio>
<Radio
value="local"
className="data-[selected]:border-[--color-surprise] data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
<Icon icon="laptop" />
<Heading className="text-lg font-bold">Local Vault</Heading>
<p className="pt-2">
Stored locally only with no cloud. Ideal when collaboration is not needed.
</p>
</Radio>
</div>
</RadioGroup>
<div className="flex justify-end">
<Button
type="submit"
className="hover:no-underline bg-[#4000BF] hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font] transition-colors rounded-sm"
>
Create
</Button>
</div>
</form>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
</div>
<GridList

View File

@ -0,0 +1,36 @@
import { LoaderFunction } from 'react-router-dom';
import { database } from '../../common/database';
import { SCRATCHPAD_ORGANIZATION_ID } from '../../models/organization';
import { Project } from '../../models/project';
import { organizationsData } from './organization';
export interface LoaderData {
untrackedProjects: (Project & { workspacesCount: number })[];
}
export const loader: LoaderFunction = async () => {
const { organizations } = organizationsData;
const listOfOrganizationIds = [...organizations.map(o => o.id), SCRATCHPAD_ORGANIZATION_ID];
const projects = await database.find<Project>('Project', {
parentId: { $nin: listOfOrganizationIds },
});
const untrackedProjects = [];
for (const project of projects) {
const workspacesCount = await database.count('Workspace', {
parentId: project._id,
});
untrackedProjects.push({
...project,
workspacesCount,
});
}
return {
untrackedProjects,
};
};