mirror of
https://github.com/Kong/insomnia
synced 2024-11-07 22:30:15 +00:00
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:
parent
6933e92da2
commit
b3a53ed93c
@ -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;
|
||||
}
|
||||
};
|
@ -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;
|
||||
}
|
||||
};
|
@ -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
|
||||
|
@ -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 }),
|
||||
});
|
||||
|
@ -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) =>
|
||||
|
@ -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,
|
||||
|
@ -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');
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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}`);
|
||||
|
@ -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
|
||||
|
36
packages/insomnia/src/ui/routes/untracked-projects.tsx
Normal file
36
packages/insomnia/src/ui/routes/untracked-projects.tsx
Normal 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,
|
||||
};
|
||||
};
|
Loading…
Reference in New Issue
Block a user