mirror of
https://github.com/Kong/insomnia.git
synced 2025-12-23 22:28:58 -05:00
Refine the modal for creating and editing project (#9423)
* tmp * split creat and edit project form * Update style * tmp * Create button wording * feat: project type select (#9436) * feat: project type select * update * Use ProjectTypeSelect component * fix: edit form ts * Extract git-repo-form * feat: git connection info (#9441) * feat: git connection info * update * fix: lint * fix: ts * fix: delete load git repo * comment out branch * tmp * git repo scan result * feat: update git repo name directly * feat: update git repo name directly (#9456) * fix lint issue * remove unused props * fix: smoke test * Disable connected git repo options when creating new git project. * refactor: improve layout and styling in project modal and create form * refactor: adjust spacing and layout in project forms and type select component --------- Co-authored-by: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Co-authored-by: Curry Yang <1019yanglu@gmail.com> Co-authored-by: James Gatz <jamesgatzos@gmail.com>
This commit is contained in:
@@ -12,6 +12,7 @@ test.describe('Dashboard', () => {
|
||||
|
||||
// Create new project
|
||||
await page.getByRole('button', { name: 'Create new Project' }).click();
|
||||
await page.getByLabel('Project Type: local').click();
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
// Check empty project
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Icon } from './icon';
|
||||
interface BannerProps {
|
||||
type: 'info' | 'warning';
|
||||
message: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
@@ -15,20 +16,16 @@ const bannerTypeToIconName: Record<BannerProps['type'], IconProp> = {
|
||||
};
|
||||
const bannerTypeToBgColor: Record<BannerProps['type'], string> = {
|
||||
info: 'bg-(--color-surprise)',
|
||||
warning: 'bg-(--color-warning)',
|
||||
warning: 'bg-(--color-warning)/50',
|
||||
};
|
||||
export const Banner = ({ type, title, message, className }: BannerProps) => {
|
||||
export const Banner = ({ type, title, message, footer, className }: BannerProps) => {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
`flex gap-4 rounded-sm p-4 ${bannerTypeToBgColor[type]} ${!title && 'items-center'}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon icon={bannerTypeToIconName[type]} />
|
||||
<div className="leading-none">
|
||||
{title && <div className="mb-3 text-[16px] font-semibold">{title}</div>}
|
||||
<div>{message}</div>
|
||||
<div className={twMerge(`flex gap-4 rounded-sm p-4 leading-5 ${bannerTypeToBgColor[type]}`, className)}>
|
||||
<Icon icon={bannerTypeToIconName[type]} className="mt-1" />
|
||||
<div className="flex flex-col gap-3">
|
||||
{title && <div className="text-base font-semibold">{title}</div>}
|
||||
<div className="text-sm">{message}</div>
|
||||
{footer && <div>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
9
packages/insomnia/src/basic-components/divider.tsx
Normal file
9
packages/insomnia/src/basic-components/divider.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface DividerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Divider = ({ className }: DividerProps) => {
|
||||
return <div className={twMerge(`border-t border-(--hl-md) ${className}`)} />;
|
||||
};
|
||||
@@ -1,25 +1,18 @@
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { Icon } from './icon';
|
||||
|
||||
interface LearnMoreLinkProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export const LearnMoreLink = ({ href, children, className }: LearnMoreLinkProps) => {
|
||||
export const LearnMoreLink = ({ href, children = 'Learn more ↗', className }: LearnMoreLinkProps) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
className={twMerge(
|
||||
'inline-flex items-center gap-1 border-b border-solid border-(--color-font) text-(--color-font) hover:no-underline!',
|
||||
className,
|
||||
)}
|
||||
className={twMerge('inline-flex items-center gap-1 text-(--color-font) underline', className)}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{children}
|
||||
<Icon className="-rotate-45" icon="arrow-right" />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export function getBorderColorClasses(color: ButtonColor) {
|
||||
return {
|
||||
primary: '',
|
||||
danger: '',
|
||||
default: 'border border-[--hl]',
|
||||
default: 'border border-(--hl-md)',
|
||||
}[color];
|
||||
}
|
||||
|
||||
|
||||
@@ -155,22 +155,6 @@ export function getDefaultProjectStorageType(
|
||||
return 'local';
|
||||
}
|
||||
|
||||
export function isSwitchingStorageType(project: Project, storageType: 'local' | 'remote' | 'git') {
|
||||
if (storageType === 'git' && !isGitProject(project)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (storageType === 'local' && (isRemoteProject(project) || isGitProject(project))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (storageType === 'remote' && !isRemoteProject(project)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getProjectStorageTypeLabel(storageRules: StorageRules): string {
|
||||
const storageTypes = {
|
||||
'Cloud Sync': storageRules.enableCloudSync,
|
||||
|
||||
31
packages/insomnia/src/routes/git.all-connected-repos.tsx
Normal file
31
packages/insomnia/src/routes/git.all-connected-repos.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { href } from 'react-router';
|
||||
|
||||
import * as models from '~/models';
|
||||
import { isEmptyGitProject } from '~/models/project';
|
||||
import { createFetcherLoadHook } from '~/utils/router';
|
||||
|
||||
export async function clientLoader() {
|
||||
const allProjects = await models.project.all();
|
||||
const allConnectedGitProjects = allProjects.filter(
|
||||
project => models.project.isGitProject(project) && !isEmptyGitProject(project),
|
||||
);
|
||||
const gitRepoURIProjectNameMap: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
allConnectedGitProjects.map(async ({ gitRepositoryId, name }) => {
|
||||
if (gitRepositoryId) {
|
||||
const gitRepository = await models.gitRepository.getById(gitRepositoryId);
|
||||
if (gitRepository) {
|
||||
gitRepoURIProjectNameMap[gitRepository.uri] = name;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
return gitRepoURIProjectNameMap;
|
||||
}
|
||||
|
||||
export const useAllConnectedReposLoaderFetcher = createFetcherLoadHook(
|
||||
load => () => {
|
||||
return load(`${href('/git/all-connected-repos')}`);
|
||||
},
|
||||
clientLoader,
|
||||
);
|
||||
@@ -84,7 +84,7 @@ import { insomniaFetch } from '~/ui/insomnia-fetch';
|
||||
import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils';
|
||||
import { invariant } from '~/utils/invariant';
|
||||
|
||||
type ProjectScopeKeys = WorkspaceScope | 'unsynced';
|
||||
export type ProjectScopeKeys = WorkspaceScope | 'unsynced';
|
||||
export const scopeToLabelMap: Record<
|
||||
ProjectScopeKeys,
|
||||
'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' | 'MCP Client'
|
||||
|
||||
@@ -422,13 +422,6 @@ export const GitProjectSyncDropdown: FC<Props> = ({ gitRepository }) => {
|
||||
isDisabled?: boolean;
|
||||
action: () => void;
|
||||
}[] = [
|
||||
{
|
||||
id: 'repository-settings',
|
||||
label: 'Repository Settings',
|
||||
isDisabled: false,
|
||||
icon: 'wrench',
|
||||
action: () => setIsGitRepoSettingsModalOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'branches',
|
||||
label: 'Branches',
|
||||
|
||||
@@ -8,7 +8,15 @@ import { GitRemoteBranchSelect } from './git-remote-branch-select';
|
||||
|
||||
type GitHubRepository = Awaited<ReturnType<typeof window.main.git.getGitHubRepositories>>['repos'][number];
|
||||
|
||||
export const GitHubRepositorySelect = ({ uri, token }: { uri?: string; token: string }) => {
|
||||
export const GitHubRepositorySelect = ({
|
||||
uri,
|
||||
token,
|
||||
allConnectedRepoURIProjectNameMap,
|
||||
}: {
|
||||
uri?: string;
|
||||
token: string;
|
||||
allConnectedRepoURIProjectNameMap?: Record<string, string> | undefined;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [repositories, setRepositories] = useState<GitHubRepository[]>([]);
|
||||
const [selectedRepository, setSelectedRepository] = useState<GitHubRepository | null>(null);
|
||||
@@ -118,14 +126,26 @@ export const GitHubRepositorySelect = ({ uri, token }: { uri?: string; token: st
|
||||
id: string;
|
||||
name: string;
|
||||
}> className="flex min-w-max flex-col p-2 text-sm select-none focus:outline-hidden">
|
||||
{item => (
|
||||
<ListBoxItem
|
||||
textValue={item.name}
|
||||
className="flex h-(--line-height-xs) w-full items-center gap-2 rounded-sm bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-disabled:opacity-30 aria-selected:bg-(--hl-sm) aria-selected:font-bold data-focused:bg-(--hl-xs)"
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</ListBoxItem>
|
||||
)}
|
||||
{item => {
|
||||
const isDisabled =
|
||||
allConnectedRepoURIProjectNameMap &&
|
||||
Object.prototype.hasOwnProperty.call(allConnectedRepoURIProjectNameMap, item.id);
|
||||
return (
|
||||
<ListBoxItem
|
||||
isDisabled={isDisabled}
|
||||
textValue={item.name}
|
||||
className="group flex h-(--line-height-xs) w-full items-center gap-2 rounded-sm bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden aria-disabled:cursor-not-allowed aria-selected:bg-(--hl-sm) aria-selected:font-bold data-focused:bg-(--hl-xs)"
|
||||
>
|
||||
{isDisabled && <Icon icon="lock" className="group-aria-disabled:opacity-30" />}
|
||||
<span className="truncate group-aria-disabled:opacity-30">{item.name}</span>
|
||||
{isDisabled && (
|
||||
<span className="hidden rounded border border-solid border-(--hl-xl) px-2 py-1 text-(--color-font-info) group-hover:inline-block">
|
||||
Already connected to: {allConnectedRepoURIProjectNameMap[item.id]}
|
||||
</span>
|
||||
)}
|
||||
</ListBoxItem>
|
||||
);
|
||||
}}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
<FieldError className="text-xs text-(--color-danger)" />
|
||||
|
||||
@@ -15,10 +15,11 @@ import { GitHubRepositorySelect } from './github-repository-select';
|
||||
interface Props {
|
||||
uri?: string;
|
||||
onSubmit: (args: Partial<GitRepository>) => void;
|
||||
allConnectedRepoURIProjectNameMap?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
export const GitHubRepositorySetupFormGroup = (props: Props) => {
|
||||
const { onSubmit, uri } = props;
|
||||
const { onSubmit, uri, allConnectedRepoURIProjectNameMap } = props;
|
||||
const githubTokenLoader = useGitHubCredentialsFetcher();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,7 +34,14 @@ export const GitHubRepositorySetupFormGroup = (props: Props) => {
|
||||
return <GitHubSignInForm />;
|
||||
}
|
||||
|
||||
return <GitHubRepositoryForm uri={uri} onSubmit={onSubmit} credentials={credentials} />;
|
||||
return (
|
||||
<GitHubRepositoryForm
|
||||
uri={uri}
|
||||
onSubmit={onSubmit}
|
||||
credentials={credentials}
|
||||
allConnectedRepoURIProjectNameMap={allConnectedRepoURIProjectNameMap}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Avatar = ({ src }: { src: string }) => {
|
||||
@@ -68,9 +76,15 @@ interface GitHubRepositoryFormProps {
|
||||
uri?: string;
|
||||
onSubmit: (args: Partial<GitRepository & { ref?: string }>) => void;
|
||||
credentials: GitCredentials;
|
||||
allConnectedRepoURIProjectNameMap?: Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
const GitHubRepositoryForm = ({ uri, credentials, onSubmit }: GitHubRepositoryFormProps) => {
|
||||
const GitHubRepositoryForm = ({
|
||||
uri,
|
||||
credentials,
|
||||
onSubmit,
|
||||
allConnectedRepoURIProjectNameMap,
|
||||
}: GitHubRepositoryFormProps) => {
|
||||
const [error, setError] = useState('');
|
||||
const signOutFetcher = useGithubSignOutFetcher();
|
||||
|
||||
@@ -116,7 +130,11 @@ const GitHubRepositoryForm = ({ uri, credentials, onSubmit }: GitHubRepositoryFo
|
||||
Disconnect
|
||||
</PromptButton>
|
||||
</div>
|
||||
<GitHubRepositorySelect uri={uri} token={credentials.token} />
|
||||
<GitHubRepositorySelect
|
||||
uri={uri}
|
||||
token={credentials.token}
|
||||
allConnectedRepoURIProjectNameMap={allConnectedRepoURIProjectNameMap}
|
||||
/>
|
||||
{error && (
|
||||
<p className="notice error margin-bottom-sm">
|
||||
<button className="pull-right icon" onClick={() => setError('')}>
|
||||
|
||||
32
packages/insomnia/src/ui/components/git/connection-info.tsx
Normal file
32
packages/insomnia/src/ui/components/git/connection-info.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { GitRepository } from '~/models/git-repository';
|
||||
import { getDefaultOAuthProvider } from '~/ui/components/modals/git-repository-settings-modal/git-project-repository-settings-modal';
|
||||
|
||||
import { GitProviderTag } from './git-provider-tag';
|
||||
|
||||
export const GitConnectionInfo = ({ gitRepository }: { gitRepository?: GitRepository }) => {
|
||||
if (!gitRepository) {
|
||||
return null;
|
||||
}
|
||||
const provider = getDefaultOAuthProvider(gitRepository.credentials);
|
||||
const repoUrl = gitRepository.uri;
|
||||
return (
|
||||
<div className="text-[12px]">
|
||||
<div className="mb-6 font-semibold text-(--hl)">Connection Info</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex">
|
||||
<div className="w-[110px] font-semibold">Provider</div>
|
||||
<GitProviderTag provider={provider} />
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-[110px] font-semibold">Repo URL</div>
|
||||
<div>{repoUrl}</div>
|
||||
</div>
|
||||
{/* TODO: get repo branch */}
|
||||
{/* <div className="flex">
|
||||
<div className="w-[110px] font-semibold">Base Branch</div>
|
||||
<div>{branch}</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
packages/insomnia/src/ui/components/git/git-provider-tag.tsx
Normal file
18
packages/insomnia/src/ui/components/git/git-provider-tag.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
import { Icon } from '~/basic-components/icon';
|
||||
import type { OauthProviderName } from '~/models/git-repository';
|
||||
|
||||
export const GitProviderTag = ({ provider }: { provider: OauthProviderName }) => {
|
||||
const icon: Record<OauthProviderName, IconProp> = {
|
||||
github: ['fab', 'github'],
|
||||
gitlab: ['fab', 'gitlab'],
|
||||
custom: 'code-branch',
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Icon className="mr-1" icon={icon[provider]} />
|
||||
{provider}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ import { HelpTooltip } from '../../help-tooltip';
|
||||
import { showModal } from '..';
|
||||
import { AlertModal } from '../alert-modal';
|
||||
|
||||
function getDefaultOAuthProvider(credentials?: GitCredentials | null): OauthProviderName {
|
||||
export function getDefaultOAuthProvider(credentials?: GitCredentials | null): OauthProviderName {
|
||||
if (!credentials) {
|
||||
return 'github';
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-compone
|
||||
import { useNavigation } from 'react-router';
|
||||
|
||||
import type { StorageRules } from '~/models/organization';
|
||||
import { useActiveView } from '~/ui/components/project/utils';
|
||||
|
||||
import type { GitRepository } from '../../../models/git-repository';
|
||||
import type { Project } from '../../../models/project';
|
||||
import { Icon } from '../icon';
|
||||
import { ProjectCreateForm } from '../project/project-create-form';
|
||||
import { ProjectSettingsForm } from '../project/project-settings-form';
|
||||
|
||||
export const ProjectModal = ({
|
||||
@@ -33,19 +35,26 @@ export const ProjectModal = ({
|
||||
}
|
||||
}, [activeNavigation, isOpen, onOpenChange]);
|
||||
|
||||
const title = project ? 'Update project' : 'Create a new project';
|
||||
const activeViewObj = useActiveView();
|
||||
|
||||
let title = '';
|
||||
if (project) {
|
||||
title = 'Project settings';
|
||||
} else {
|
||||
title = activeViewObj.activeView === 'git-results' ? 'Create Git Sync project' : 'Create project';
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
isDismissable
|
||||
className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30"
|
||||
className="fixed top-0 right-0 bottom-0 left-0 z-10 flex items-start justify-center bg-black/30 pt-[70px]"
|
||||
>
|
||||
<Modal className="flex max-h-[90dvh] min-h-[420px] w-full max-w-3xl flex-col overflow-hidden rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) text-(--color-font)">
|
||||
<Modal className="flex max-h-[calc(var(--visual-viewport-height)-140px)] w-full max-w-3xl flex-col overflow-hidden rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) text-(--color-font)">
|
||||
<Dialog
|
||||
aria-label="Create or update dialog"
|
||||
className="grid flex-1 grid-rows-[min-content_1fr_min-content] gap-4 px-10 pt-10 outline-hidden"
|
||||
className="grid flex-1 grid-rows-[min-content_1fr] gap-4 overflow-hidden p-10 outline-hidden"
|
||||
>
|
||||
{({ close }) => (
|
||||
<>
|
||||
@@ -60,14 +69,23 @@ export const ProjectModal = ({
|
||||
<Icon icon="x" />
|
||||
</Button>
|
||||
</div>
|
||||
<ProjectSettingsForm
|
||||
storageRules={storageRules}
|
||||
isGitSyncEnabled={isGitSyncEnabled}
|
||||
project={project}
|
||||
gitRepository={gitRepository}
|
||||
onCancel={close}
|
||||
onSuccessUpdate={() => onOpenChange(false)}
|
||||
/>
|
||||
{project ? (
|
||||
<ProjectSettingsForm
|
||||
storageRules={storageRules}
|
||||
isGitSyncEnabled={isGitSyncEnabled}
|
||||
project={project}
|
||||
gitRepository={gitRepository}
|
||||
onCancel={close}
|
||||
onSuccessUpdate={close}
|
||||
/>
|
||||
) : (
|
||||
<ProjectCreateForm
|
||||
storageRules={storageRules}
|
||||
isGitSyncEnabled={isGitSyncEnabled}
|
||||
onCancel={close}
|
||||
activeViewObj={activeViewObj}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { type FC } from 'react';
|
||||
|
||||
import type { StorageRules } from '~/models/organization';
|
||||
|
||||
import { ProjectSettingsForm } from '../project/project-settings-form';
|
||||
import { ProjectCreateForm } from '../project/project-create-form';
|
||||
|
||||
interface Props {
|
||||
storageRules: StorageRules;
|
||||
@@ -11,14 +11,18 @@ interface Props {
|
||||
|
||||
export const NoProjectView: FC<Props> = ({ storageRules, isGitSyncEnabled }) => {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center gap-3 pt-[15%] text-center">
|
||||
<span className="text-xl font-semibold">Welcome to your organization!</span>
|
||||
<span>Create a new project to get started</span>
|
||||
<ProjectSettingsForm
|
||||
storageRules={storageRules}
|
||||
isGitSyncEnabled={isGitSyncEnabled}
|
||||
defaultProjectName="My first project"
|
||||
/>
|
||||
<div className="h-full overflow-y-auto pt-16">
|
||||
<div className="max-h-full text-center">
|
||||
<p className="mb-3 text-xl font-semibold">Welcome to your organization!</p>
|
||||
<p className="mb-3">Create a new project to get started</p>
|
||||
<div className="flex justify-center">
|
||||
<ProjectCreateForm
|
||||
storageRules={storageRules}
|
||||
isGitSyncEnabled={isGitSyncEnabled}
|
||||
defaultProjectName="My first project"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
136
packages/insomnia/src/ui/components/project/git-repo-form.tsx
Normal file
136
packages/insomnia/src/ui/components/project/git-repo-form.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { type FC, useEffect } from 'react';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
|
||||
|
||||
import { isGitCredentialsOAuth } from '~/models/git-repository';
|
||||
import { useAllConnectedReposLoaderFetcher } from '~/routes/git.all-connected-repos';
|
||||
import type { useGitProjectInitCloneActionFetcher } from '~/routes/git.init-clone';
|
||||
|
||||
import type { OauthProviderName } from '../../../models/git-credentials';
|
||||
import type { GitRepository } from '../../../models/git-repository';
|
||||
import { ErrorBoundary } from '../error-boundary';
|
||||
import { CustomRepositorySettingsFormGroup } from '../git-credentials/custom-repository-settings-form';
|
||||
import { GitHubRepositorySetupFormGroup } from '../git-credentials/github-repository-settings-form';
|
||||
import { GitLabRepositorySetupFormGroup } from '../git-credentials/gitlab-repository-settings-form';
|
||||
import type { ActiveView, ProjectData } from './utils';
|
||||
|
||||
interface Props {
|
||||
setProjectData: React.Dispatch<React.SetStateAction<ProjectData>>;
|
||||
projectData: ProjectData;
|
||||
initCloneGitRepositoryFetcher: ReturnType<typeof useGitProjectInitCloneActionFetcher>;
|
||||
organizationId: string;
|
||||
setActiveView: React.Dispatch<React.SetStateAction<ActiveView>>;
|
||||
selectedTab: OauthProviderName;
|
||||
setTab: React.Dispatch<React.SetStateAction<OauthProviderName>>;
|
||||
}
|
||||
|
||||
export const GitRepoForm: FC<Props> = ({
|
||||
setProjectData,
|
||||
projectData,
|
||||
initCloneGitRepositoryFetcher,
|
||||
organizationId,
|
||||
setActiveView,
|
||||
selectedTab,
|
||||
setTab,
|
||||
}) => {
|
||||
const onGitRepoFormSubmit = (gitRepositoryPatch: Partial<GitRepository & { ref?: string }>) => {
|
||||
const { author, credentials, created, modified, isPrivate, needsFullClone, uriNeedsMigration, ...repoPatch } =
|
||||
gitRepositoryPatch;
|
||||
|
||||
setProjectData({
|
||||
...projectData,
|
||||
...credentials,
|
||||
authorName: author?.name || '',
|
||||
authorEmail: author?.email || '',
|
||||
uri: repoPatch.uri,
|
||||
ref: repoPatch.ref,
|
||||
});
|
||||
|
||||
initCloneGitRepositoryFetcher.submit({
|
||||
...repoPatch,
|
||||
authorName: author?.name || '',
|
||||
authorEmail: author?.email || '',
|
||||
...(credentials
|
||||
? isGitCredentialsOAuth(credentials)
|
||||
? {
|
||||
credentials: {
|
||||
token: credentials.token || '',
|
||||
oauth2format: credentials.oauth2format || 'github',
|
||||
username: credentials.username || '',
|
||||
},
|
||||
}
|
||||
: {
|
||||
credentials,
|
||||
}
|
||||
: {
|
||||
credentials: {
|
||||
password: '',
|
||||
username: '',
|
||||
},
|
||||
}),
|
||||
uri: repoPatch.uri || '',
|
||||
organizationId,
|
||||
});
|
||||
|
||||
setActiveView('git-results');
|
||||
};
|
||||
const allConnectedReposLoaderFetcher = useAllConnectedReposLoaderFetcher();
|
||||
const allConnectedReposLoaderFetcherLoad = allConnectedReposLoaderFetcher.load;
|
||||
useEffect(() => {
|
||||
allConnectedReposLoaderFetcherLoad();
|
||||
}, [allConnectedReposLoaderFetcherLoad]);
|
||||
const allConnectedRepoURIProjectNameMap = allConnectedReposLoaderFetcher.data;
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Tabs
|
||||
selectedKey={selectedTab}
|
||||
onSelectionChange={key => {
|
||||
setTab(key as OauthProviderName);
|
||||
}}
|
||||
aria-label="Git repository settings tabs"
|
||||
className="flex h-full w-full flex-col"
|
||||
>
|
||||
<TabList
|
||||
className="flex h-(--line-height-sm) w-full shrink-0 items-center overflow-x-auto border-b border-solid border-b-(--hl-md) bg-(--color-bg)"
|
||||
aria-label="Request pane tabs"
|
||||
>
|
||||
<Tab
|
||||
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
|
||||
id="github"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="fa fa-github" /> GitHub
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab
|
||||
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
|
||||
id="gitlab"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="fa fa-gitlab" /> GitLab
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab
|
||||
className="flex h-full shrink-0 cursor-pointer items-center justify-between gap-2 px-3 py-1 text-(--hl) outline-hidden transition-colors duration-300 select-none hover:bg-(--hl-sm) hover:text-(--color-font) focus:bg-(--hl-sm) aria-selected:bg-(--hl-xs) aria-selected:text-(--color-font) aria-selected:hover:bg-(--hl-sm) aria-selected:focus:bg-(--hl-sm)"
|
||||
id="custom"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<i className="fa fa-code-fork" /> Git
|
||||
</div>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel className="h-full w-full overflow-y-auto py-2" id="github">
|
||||
<GitHubRepositorySetupFormGroup
|
||||
onSubmit={onGitRepoFormSubmit}
|
||||
allConnectedRepoURIProjectNameMap={allConnectedRepoURIProjectNameMap}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel className="h-full w-full overflow-y-auto py-2" id="gitlab">
|
||||
<GitLabRepositorySetupFormGroup onSubmit={onGitRepoFormSubmit} />
|
||||
</TabPanel>
|
||||
<TabPanel className="h-full w-full overflow-y-auto py-2" id="custom">
|
||||
<CustomRepositorySettingsFormGroup onSubmit={onGitRepoFormSubmit} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import classNames from 'classnames';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { Banner } from '~/basic-components/banner';
|
||||
import { Icon } from '~/basic-components/icon';
|
||||
import { LearnMoreLink } from '~/basic-components/link';
|
||||
import type { useGitProjectInitCloneActionFetcher } from '~/routes/git.init-clone';
|
||||
import {
|
||||
type ProjectScopeKeys,
|
||||
scopeToIconMap,
|
||||
scopeToLabelMap,
|
||||
} from '~/routes/organization.$organizationId.project.$projectId._index';
|
||||
|
||||
interface Props {
|
||||
initCloneGitRepositoryFetcher: ReturnType<typeof useGitProjectInitCloneActionFetcher>;
|
||||
insomniaFiles:
|
||||
| Extract<ReturnType<typeof useGitProjectInitCloneActionFetcher>['data'], { files: any }>['files']
|
||||
| undefined;
|
||||
repoURI?: string;
|
||||
}
|
||||
|
||||
export const GitRepoScanResult: FC<Props> = ({ initCloneGitRepositoryFetcher, insomniaFiles, repoURI }) => {
|
||||
const fileTypeCountMap: Partial<Record<ProjectScopeKeys, number>> = {};
|
||||
insomniaFiles?.forEach(({ scope }) => {
|
||||
if (!fileTypeCountMap[scope]) {
|
||||
fileTypeCountMap[scope] = 0;
|
||||
}
|
||||
fileTypeCountMap[scope]++;
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className="rounded border border-solid border-(--hl-sm) px-4 pt-4 text-left">
|
||||
<h3 className="mb-2 text-lg font-bold text-(--color-font-info)">Insomnia files in repo</h3>
|
||||
<p className="mb-4 text-(--hl)">{repoURI}</p>
|
||||
{initCloneGitRepositoryFetcher.state !== 'idle' ? (
|
||||
<div className="flex min-h-[134px] flex-col justify-center">
|
||||
<p className="text-center text-base text-(--hl)">
|
||||
<Icon icon="circle-notch" className="mr-2 animate-spin" />
|
||||
Scanning remote repo for Insomnia files...
|
||||
</p>
|
||||
</div>
|
||||
) : insomniaFiles?.length === 0 ? (
|
||||
<div className="flex min-h-[134px] flex-col justify-center">
|
||||
<p className="text-center text-base text-(--hl)">
|
||||
<span className="mb-2 block font-bold text-(--color-font-info)">
|
||||
No Insomnia files found − let’s start something new!
|
||||
</span>
|
||||
There were no Insomnia files in the selected repo or branch, so you’ll begin with a blank project locally.
|
||||
When you commit and push changes, they will be available on the remote repo selected.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col justify-center py-2">
|
||||
<table className="text-base">
|
||||
<thead>
|
||||
<tr className="border-b border-solid border-(--hl-sm)">
|
||||
<th className="w-[86px] pb-2 text-base normal-case">Count</th>
|
||||
<th className="pb-2 text-base normal-case">File type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.keys(fileTypeCountMap)
|
||||
.sort()
|
||||
.map((scope, idx) => (
|
||||
<tr key={scope}>
|
||||
<td
|
||||
className={classNames('pl-3 text-base leading-10 text-(--color-font-info)', {
|
||||
'pt-2': idx === 0,
|
||||
})}
|
||||
>
|
||||
{fileTypeCountMap[scope as ProjectScopeKeys]}
|
||||
</td>
|
||||
<td
|
||||
className={classNames('text-base leading-10 text-(--color-font-info)', { 'pt-2': idx === 0 })}
|
||||
>
|
||||
<Icon icon={scopeToIconMap[scope as ProjectScopeKeys]} className="mr-2 w-4" />
|
||||
{scopeToLabelMap[scope as ProjectScopeKeys]}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* TODO: update learn more link */}
|
||||
{insomniaFiles && insomniaFiles?.some(file => file.path === '.insomnia') && (
|
||||
<Banner
|
||||
type="warning"
|
||||
message={
|
||||
<>
|
||||
There are out of date Insomnia project files in the selected repo. By cloning this project, outdated files
|
||||
will automatically be migrated to the latest version. <LearnMoreLink href="https://insomnia.rest" />
|
||||
</>
|
||||
}
|
||||
title="Migrate legacy files?"
|
||||
className="text-left"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import classNames from 'classnames';
|
||||
import type { FC } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Checkbox, Input, Label, TextField } from 'react-aria-components';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { type StorageRules } from '~/models/organization';
|
||||
import { useGitProjectInitCloneActionFetcher } from '~/routes/git.init-clone';
|
||||
import {
|
||||
fallbackFeatures,
|
||||
useOrganizationPermissionsLoaderFetcher,
|
||||
} from '~/routes/organization.$organizationId.permissions';
|
||||
import { useProjectNewActionFetcher } from '~/routes/organization.$organizationId.project.new';
|
||||
import { GitRepoForm } from '~/ui/components/project/git-repo-form';
|
||||
import { GitRepoScanResult } from '~/ui/components/project/git-repo-scan-result';
|
||||
import { ProjectTypeSelect } from '~/ui/components/project/project-type-select';
|
||||
import { ProjectTypeWarning } from '~/ui/components/project/project-type-warning';
|
||||
import { type ProjectData, type ProjectType, useActiveView } from '~/ui/components/project/utils';
|
||||
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
|
||||
|
||||
import type { OauthProviderName } from '../../../models/git-credentials';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
interface Props {
|
||||
storageRules: StorageRules;
|
||||
isGitSyncEnabled: boolean;
|
||||
defaultProjectName?: string;
|
||||
onCancel?(): void;
|
||||
activeViewObj?: ReturnType<typeof useActiveView>;
|
||||
}
|
||||
|
||||
export const ProjectCreateForm: FC<Props> = ({
|
||||
storageRules,
|
||||
isGitSyncEnabled,
|
||||
defaultProjectName = 'My Project',
|
||||
onCancel,
|
||||
activeViewObj,
|
||||
}) => {
|
||||
const { organizationId } = useParams() as { organizationId: string };
|
||||
|
||||
// Reload isGitSyncEnabled everytime this component is mounted
|
||||
const permissionsFetcher = useOrganizationPermissionsLoaderFetcher({ key: `permissions:${organizationId}` });
|
||||
const permissionsFetcherLoad = permissionsFetcher.load;
|
||||
useEffect(() => {
|
||||
permissionsFetcherLoad({
|
||||
organizationId,
|
||||
});
|
||||
}, [organizationId, permissionsFetcherLoad]);
|
||||
const { featuresPromise } = permissionsFetcher.data || {};
|
||||
const [features = fallbackFeatures] = useLoaderDeferData(featuresPromise, organizationId);
|
||||
isGitSyncEnabled = features.gitSync.enabled;
|
||||
|
||||
const [storageType, setStorageType] = useState<ProjectType>();
|
||||
|
||||
let { activeView, setActiveView } = useActiveView();
|
||||
if (activeViewObj) {
|
||||
activeView = activeViewObj.activeView;
|
||||
setActiveView = activeViewObj.setActiveView;
|
||||
}
|
||||
|
||||
const [selectedTab, setTab] = useState<OauthProviderName>('github');
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [projectData, setProjectData] = useState<ProjectData>({
|
||||
name: defaultProjectName,
|
||||
authorName: '',
|
||||
authorEmail: '',
|
||||
uri: '',
|
||||
username: '',
|
||||
password: '',
|
||||
token: '',
|
||||
oauth2format: undefined,
|
||||
connectRepositoryLater: false,
|
||||
});
|
||||
|
||||
const initCloneGitRepositoryFetcher = useGitProjectInitCloneActionFetcher();
|
||||
const newProjectFetcher = useProjectNewActionFetcher();
|
||||
|
||||
const insomniaFiles =
|
||||
initCloneGitRepositoryFetcher.data && 'files' in initCloneGitRepositoryFetcher.data
|
||||
? initCloneGitRepositoryFetcher.data.files
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (newProjectFetcher.state === 'idle' && newProjectFetcher.data && newProjectFetcher.data?.error) {
|
||||
setError(newProjectFetcher.data.error);
|
||||
}
|
||||
}, [newProjectFetcher.data, newProjectFetcher.state]);
|
||||
|
||||
const onUpsertProject = () => {
|
||||
if (!storageType) {
|
||||
return;
|
||||
}
|
||||
newProjectFetcher.submit({
|
||||
organizationId,
|
||||
projectData: {
|
||||
...projectData,
|
||||
storageType,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4 overflow-y-auto">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-xs bg-[rgba(var(--color-danger-rgb),0.5)] px-2 py-1 text-sm text-(--color-font-danger)">
|
||||
<Icon icon="triangle-exclamation" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classNames({ hidden: activeView !== 'project' })}>
|
||||
<div className="mt-4 flex w-full flex-col justify-start gap-4 pb-2 text-left">
|
||||
<TextField
|
||||
autoFocus
|
||||
name="name"
|
||||
value={projectData.name}
|
||||
onChange={name => setProjectData({ ...projectData, name })}
|
||||
className="group relative flex flex-col gap-2 px-0.5"
|
||||
>
|
||||
<Label className="pt-0 text-sm text-(--color-font)">Project name</Label>
|
||||
<Input
|
||||
placeholder={defaultProjectName}
|
||||
className="w-full rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) py-1 pr-7 pl-2 text-(--color-font) transition-colors placeholder:italic focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden"
|
||||
/>
|
||||
</TextField>
|
||||
<ProjectTypeSelect
|
||||
storageRules={storageRules}
|
||||
value={storageType}
|
||||
onChange={v => setStorageType(v as ProjectType)}
|
||||
/>
|
||||
<ProjectTypeWarning
|
||||
isGitSyncEnabled={isGitSyncEnabled}
|
||||
storageType={storageType}
|
||||
storageRules={storageRules}
|
||||
/>
|
||||
{storageType === 'git' && (
|
||||
<>
|
||||
<Checkbox
|
||||
slot={null}
|
||||
isSelected={projectData.connectRepositoryLater}
|
||||
onChange={isSelected => setProjectData(prev => ({ ...prev, connectRepositoryLater: isSelected }))}
|
||||
className="group mt-4 flex h-full items-center gap-2 p-0 pl-[1px]"
|
||||
>
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded-sm ring-1 ring-(--hl-sm) transition-colors group-focus:ring-2 group-data-selected:bg-(--hl-xs)">
|
||||
<Icon
|
||||
icon="check"
|
||||
className="h-3 w-3 opacity-0 group-data-indeterminate:opacity-100 group-data-selected:text-(--color-success) group-data-selected:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-(--hl)">Connect repository later</span>
|
||||
</Checkbox>
|
||||
{!projectData.connectRepositoryLater && (
|
||||
<GitRepoForm
|
||||
{...{
|
||||
setProjectData,
|
||||
projectData,
|
||||
initCloneGitRepositoryFetcher,
|
||||
organizationId,
|
||||
setActiveView,
|
||||
selectedTab,
|
||||
setTab,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex w-full items-center justify-end gap-2 px-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
onPress={onCancel}
|
||||
className="flex h-full items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) px-4 py-2 text-sm text-(--color-font) transition-colors hover:bg-(--hl-xs) aria-pressed:bg-(--hl-xs)"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{storageType !== 'git' || projectData.connectRepositoryLater ? (
|
||||
<Button
|
||||
onPress={onUpsertProject}
|
||||
isDisabled={!storageType || newProjectFetcher.state !== 'idle'}
|
||||
className="flex h-full w-[10ch] items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) bg-(--color-surprise) px-4 py-2 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all hover:bg-(--color-surprise)/80 focus:ring-(--hl-md) focus:ring-inset aria-pressed:opacity-80"
|
||||
>
|
||||
{newProjectFetcher.state !== 'idle' && <Icon icon="spinner" className="animate-spin" />}
|
||||
<span>Create</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
form={selectedTab}
|
||||
className="flex h-full items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) bg-(--color-surprise) px-4 py-2 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all hover:bg-(--color-surprise)/80 focus:ring-(--hl-md) focus:ring-inset aria-pressed:opacity-80"
|
||||
>
|
||||
Scan for files
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classNames({ hidden: activeView !== 'git-results' })}>
|
||||
<GitRepoScanResult
|
||||
initCloneGitRepositoryFetcher={initCloneGitRepositoryFetcher}
|
||||
insomniaFiles={insomniaFiles}
|
||||
repoURI={projectData.uri}
|
||||
/>
|
||||
<div className="mt-8 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
isDisabled={newProjectFetcher.state !== 'idle' || initCloneGitRepositoryFetcher.state !== 'idle'}
|
||||
onPress={() => {
|
||||
setActiveView('project');
|
||||
setError(null);
|
||||
}}
|
||||
className="flex h-full items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) px-4 py-2 text-sm text-(--color-font) transition-colors hover:bg-(--hl-xs) aria-pressed:bg-(--hl-xs)"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{initCloneGitRepositoryFetcher.state !== 'idle' ? (
|
||||
<Button
|
||||
isDisabled={true}
|
||||
type="button"
|
||||
className="flex h-full w-[10ch] items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) bg-(--color-surprise) px-4 py-2 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all hover:bg-(--color-surprise)/80 focus:ring-(--hl-md) focus:ring-inset aria-pressed:opacity-80"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
isDisabled={newProjectFetcher.state !== 'idle'}
|
||||
onPress={onUpsertProject}
|
||||
className="flex h-full items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) bg-(--color-surprise) px-4 py-2 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all hover:bg-(--color-surprise)/80 focus:ring-(--hl-md) focus:ring-inset aria-pressed:opacity-80"
|
||||
>
|
||||
{newProjectFetcher.state !== 'idle' && <Icon icon="spinner" className="animate-spin" />}
|
||||
<span>
|
||||
{(() => {
|
||||
if (insomniaFiles) {
|
||||
if (insomniaFiles.length > 0) {
|
||||
if (insomniaFiles.some(file => file.path === '.insomnia')) {
|
||||
return 'Clone and Migrate';
|
||||
}
|
||||
return 'Clone Project';
|
||||
}
|
||||
return 'Create Blank Project';
|
||||
}
|
||||
return 'Create';
|
||||
})()}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import classNames from 'classnames';
|
||||
import type { FC } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
@@ -9,8 +8,6 @@ import {
|
||||
Heading,
|
||||
Input,
|
||||
Label,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Row,
|
||||
Tab,
|
||||
Table,
|
||||
@@ -23,30 +20,24 @@ import {
|
||||
} from 'react-aria-components';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { getAppWebsiteBaseURL } from '~/common/constants';
|
||||
import { docsPricingLearnMoreLink } from '~/common/documentation';
|
||||
import { Divider } from '~/basic-components/divider';
|
||||
import { isGitCredentialsOAuth } from '~/models/git-repository';
|
||||
import { isOwnerOfOrganization, type StorageRules } from '~/models/organization';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
import type { StorageRules } from '~/models/organization';
|
||||
import { useGitProjectInitCloneActionFetcher } from '~/routes/git.init-clone';
|
||||
import { useOrganizationLoaderData } from '~/routes/organization';
|
||||
import {
|
||||
fallbackFeatures,
|
||||
useOrganizationPermissionsLoaderFetcher,
|
||||
} from '~/routes/organization.$organizationId.permissions';
|
||||
import { useProjectNewActionFetcher } from '~/routes/organization.$organizationId.project.new';
|
||||
import { useIsLightTheme } from '~/ui/hooks/theme';
|
||||
import { GitConnectionInfo } from '~/ui/components/git/connection-info';
|
||||
import { ProjectTypeSelect } from '~/ui/components/project/project-type-select';
|
||||
import { ProjectTypeWarning } from '~/ui/components/project/project-type-warning';
|
||||
import { useActiveView } from '~/ui/components/project/utils';
|
||||
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
|
||||
|
||||
import type { OauthProviderName } from '../../../models/git-credentials';
|
||||
import type { GitRepository } from '../../../models/git-repository';
|
||||
import {
|
||||
getDefaultProjectStorageType,
|
||||
getProjectStorageTypeLabel,
|
||||
isGitProject,
|
||||
isRemoteProject,
|
||||
type Project,
|
||||
} from '../../../models/project';
|
||||
import { getDefaultProjectStorageType, isGitProject, isRemoteProject, type Project } from '../../../models/project';
|
||||
import {
|
||||
scopeToBgColorMap,
|
||||
scopeToIconMap,
|
||||
@@ -112,9 +103,9 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
const [storageType, setStorageType] = useState<'local' | 'remote' | 'git'>(
|
||||
getDefaultProjectStorageType(storageRules, project),
|
||||
);
|
||||
const [activeView, setActiveView] = useState<'project' | 'git-clone' | 'git-results' | 'switch-storage-type'>(
|
||||
'project',
|
||||
);
|
||||
|
||||
const { activeView, setActiveView } = useActiveView();
|
||||
|
||||
const [selectedTab, setTab] = useState<OauthProviderName>('github');
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -150,8 +141,6 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
const updateProjectFetcher = useProjectUpdateActionFetcher();
|
||||
const newProjectFetcher = useProjectNewActionFetcher();
|
||||
|
||||
const showStorageRestrictionMessage =
|
||||
!storageRules.enableCloudSync || !storageRules.enableLocalVault || !storageRules.enableGitSync;
|
||||
const insomniaFiles =
|
||||
initCloneGitRepositoryFetcher.data && 'files' in initCloneGitRepositoryFetcher.data
|
||||
? initCloneGitRepositoryFetcher.data.files
|
||||
@@ -232,27 +221,11 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
storageType,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
newProjectFetcher.submit({
|
||||
organizationId,
|
||||
projectData: {
|
||||
...projectData,
|
||||
storageType,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const organizationData = useOrganizationLoaderData();
|
||||
const { userSession } = useRootLoaderData()!;
|
||||
const organization = organizationData?.organizations.find(o => o.id === organizationId);
|
||||
const isUserOwner =
|
||||
organization && userSession.accountId && isOwnerOfOrganization({ organization, accountId: userSession.accountId });
|
||||
|
||||
const isLightTheme = useIsLightTheme();
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-4">
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-8">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-xs bg-[rgba(var(--color-danger-rgb),0.5)] px-2 py-1 text-sm text-(--color-font-danger)">
|
||||
<Icon icon="triangle-exclamation" />
|
||||
@@ -262,7 +235,7 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
|
||||
{activeView === 'project' && (
|
||||
<>
|
||||
<div className="mt-4 flex w-full flex-col justify-start gap-8 overflow-y-auto pb-2 text-left">
|
||||
<div className="mt-4 flex w-full flex-col justify-start gap-8 text-left">
|
||||
<TextField
|
||||
autoFocus
|
||||
name="name"
|
||||
@@ -270,118 +243,29 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
onChange={name => setProjectData({ ...projectData, name })}
|
||||
className="group relative flex flex-col gap-2 px-0.5"
|
||||
>
|
||||
<Label className="text-sm text-(--hl)">Project name</Label>
|
||||
<Label className="pt-0 text-sm text-(--color-font)">Project name</Label>
|
||||
<Input
|
||||
placeholder="My project"
|
||||
className="w-full rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) py-1 pr-7 pl-2 text-(--color-font) transition-colors placeholder:italic focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden"
|
||||
/>
|
||||
</TextField>
|
||||
<RadioGroup
|
||||
name="type"
|
||||
className="flex flex-col gap-2 px-0.5"
|
||||
onChange={value => {
|
||||
error && setError(null);
|
||||
setStorageType(value as 'local' | 'remote' | 'git');
|
||||
}}
|
||||
<ProjectTypeSelect
|
||||
storageRules={storageRules}
|
||||
value={storageType}
|
||||
>
|
||||
<Label className="text-sm text-(--hl)">Project type</Label>
|
||||
<div className="flex gap-2">
|
||||
<Radio
|
||||
isDisabled={!storageRules.enableLocalVault}
|
||||
value="local"
|
||||
className="flex-1 rounded-sm border border-solid border-(--hl-md) p-4 transition-colors hover:bg-(--hl-xs) focus:bg-(--hl-sm) focus:outline-hidden data-disabled:opacity-25 data-selected:border-(--color-surprise) data-selected:ring-2 data-selected:ring-(--color-surprise)"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="laptop" />
|
||||
<Heading className="text-lg font-bold">Local Vault</Heading>
|
||||
</div>
|
||||
<p className="pt-2">Stored locally only, with no cloud. Ideal when collaboration is not needed.</p>
|
||||
</Radio>
|
||||
|
||||
<Radio
|
||||
isDisabled={!storageRules.enableCloudSync}
|
||||
value="remote"
|
||||
className="flex-1 rounded-sm border border-solid border-(--hl-md) p-4 transition-colors hover:bg-(--hl-xs) focus:bg-(--hl-sm) focus:outline-hidden data-disabled:opacity-25 data-selected:border-(--color-surprise) data-selected:ring-2 data-selected:ring-(--color-surprise)"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon="globe" />
|
||||
<Heading className="text-lg font-bold">Cloud Sync</Heading>
|
||||
</div>
|
||||
<p className="pt-2">
|
||||
Encrypted and synced securely to the cloud, ideal for out of the box collaboration.
|
||||
</p>
|
||||
</Radio>
|
||||
<Radio
|
||||
isDisabled={!storageRules.enableGitSync}
|
||||
value="git"
|
||||
className="flex-1 rounded-sm border border-solid border-(--hl-md) p-4 transition-colors hover:bg-(--hl-xs) focus:bg-(--hl-sm) focus:outline-hidden data-disabled:opacity-25 data-selected:border-(--color-surprise) data-selected:ring-2 data-selected:ring-(--color-surprise)"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon icon={['fab', 'git-alt']} />
|
||||
<Heading className="text-lg font-bold">Git Sync</Heading>
|
||||
</div>
|
||||
<p className="pt-2">
|
||||
Stored locally and synced to a Git repository. Ideal for version control and collaboration.
|
||||
</p>
|
||||
</Radio>
|
||||
</div>
|
||||
{storageType === 'git' && !isGitSyncEnabled && (
|
||||
<div
|
||||
className={classNames('mt-3 flex items-start justify-start gap-5 rounded-md px-6 py-5', {
|
||||
'bg-[#292535]': !isLightTheme,
|
||||
'bg-[#EEEBFF]': isLightTheme,
|
||||
})}
|
||||
>
|
||||
<Icon icon="circle-info" className="pt-1.5" />
|
||||
<div className="flex flex-col items-start justify-start gap-3.5">
|
||||
<Heading className="text-lg font-bold">
|
||||
Git Sync limited to organizations of 3 or fewer users
|
||||
</Heading>
|
||||
{isUserOwner ? (
|
||||
<>
|
||||
<p>
|
||||
Git Sync is included on your plan for up to 3 users. Since your team is larger, you’ll need to
|
||||
upgrade your plan to use it.{' '}
|
||||
<a href={docsPricingLearnMoreLink} className="underline">
|
||||
Learn more ↗
|
||||
</a>
|
||||
</p>
|
||||
<a
|
||||
href={getAppWebsiteBaseURL() + '/app/pricing?source=app_create_git_project'}
|
||||
className="rounded-xs border border-solid border-(--hl-md) px-3 py-2 text-(--color-font) transition-colors hover:no-underline"
|
||||
>
|
||||
Upgrade
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
Git Sync is included on your plan for up to 3 users. Because your team is larger, your admin
|
||||
will need to upgrade the plan for you to access it.
|
||||
</p>
|
||||
<a
|
||||
href={docsPricingLearnMoreLink}
|
||||
className="rounded-xs border border-solid border-(--hl-md) px-3 py-2 text-(--color-font) transition-colors hover:no-underline"
|
||||
>
|
||||
Learn More ↗
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RadioGroup>
|
||||
{showStorageRestrictionMessage && (
|
||||
<div className="flex items-center gap-2 rounded-xs bg-[rgba(var(--color-warning-rgb),0.5)] px-2 py-1 text-sm text-(--color-font-warning)">
|
||||
<Icon icon="triangle-exclamation" />
|
||||
<span>
|
||||
The organization owner mandates that projects must be created and stored using{' '}
|
||||
{getProjectStorageTypeLabel(storageRules)}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
onChange={v => setStorageType(v as 'local' | 'remote' | 'git')}
|
||||
/>
|
||||
<ProjectTypeWarning
|
||||
isGitSyncEnabled={isGitSyncEnabled}
|
||||
storageType={storageType}
|
||||
storageRules={storageRules}
|
||||
/>
|
||||
</div>
|
||||
{storageType === 'git' && (
|
||||
<>
|
||||
<Divider />
|
||||
<GitConnectionInfo gitRepository={gitRepository} />
|
||||
</>
|
||||
)}
|
||||
<div className="mt-4 flex w-full items-center justify-end gap-2 px-0.5 pb-10">
|
||||
<div className="flex items-center gap-2">
|
||||
{onCancel && (
|
||||
@@ -392,7 +276,7 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{storageType === 'git' && (
|
||||
{storageType === 'git' && isSwitchingStorageType(project!, storageType) && (
|
||||
<Button
|
||||
isDisabled={!isGitSyncEnabled}
|
||||
onPress={() => setActiveView('git-clone')}
|
||||
@@ -401,6 +285,18 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{storageType === 'git' && !isSwitchingStorageType(project!, storageType) && (
|
||||
<Button
|
||||
onPress={onUpsertProject}
|
||||
isDisabled={updateProjectFetcher.state !== 'idle' || newProjectFetcher.state !== 'idle'}
|
||||
className="flex h-full w-[10ch] items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) bg-(--color-surprise) px-4 py-2 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all hover:bg-(--color-surprise)/80 focus:ring-(--hl-md) focus:ring-inset aria-pressed:opacity-80"
|
||||
>
|
||||
{(updateProjectFetcher.state !== 'idle' || newProjectFetcher.state !== 'idle') && (
|
||||
<Icon icon="spinner" className="animate-spin" />
|
||||
)}
|
||||
<span>Update</span>
|
||||
</Button>
|
||||
)}
|
||||
{storageType !== 'git' && (
|
||||
<Button
|
||||
onPress={onUpsertProject}
|
||||
@@ -410,7 +306,7 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
{(updateProjectFetcher.state !== 'idle' || newProjectFetcher.state !== 'idle') && (
|
||||
<Icon icon="spinner" className="animate-spin" />
|
||||
)}
|
||||
<span>{project ? 'Update' : 'Create'}</span>
|
||||
<span>Update</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
import { useState } from 'react';
|
||||
import { Label, Radio, RadioGroup } from 'react-aria-components';
|
||||
|
||||
import { Icon } from '~/basic-components/icon';
|
||||
import type { StorageRules } from '~/models/organization';
|
||||
import type { ProjectType } from '~/ui/components/project/utils';
|
||||
|
||||
interface ProjectTypeItem {
|
||||
type: ProjectType;
|
||||
icon: IconProp;
|
||||
name: string;
|
||||
description: string;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
const TypeItem = ({ icon, name, description }: Omit<ProjectTypeItem, 'type' | 'isDisabled'>) => {
|
||||
return (
|
||||
<div className="flex gap-2 p-2">
|
||||
<Icon icon={icon} className="mt-1" />
|
||||
<div>
|
||||
<div>{name}</div>
|
||||
<div className="text-sm text-(--hl)">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
value?: ProjectTypeItem['type'];
|
||||
onChange: (value: string) => void;
|
||||
storageRules: StorageRules;
|
||||
}
|
||||
export const ProjectTypeSelect = ({ value, onChange, storageRules }: Props) => {
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
const typeList: ProjectTypeItem[] = [
|
||||
{
|
||||
type: 'local',
|
||||
icon: 'laptop',
|
||||
name: 'Local Vault',
|
||||
description: 'For working alone with data stored on your machine.',
|
||||
isDisabled: !storageRules.enableLocalVault,
|
||||
},
|
||||
{
|
||||
type: 'remote',
|
||||
icon: 'globe',
|
||||
name: 'Cloud Sync',
|
||||
description: 'Out of the box collaboration with data stored securely to the cloud.',
|
||||
isDisabled: !storageRules.enableCloudSync,
|
||||
},
|
||||
{
|
||||
type: 'git',
|
||||
icon: ['fab', 'git-alt'],
|
||||
name: 'Git Sync',
|
||||
description: 'Collaborate with others securely using your existing git provider.',
|
||||
isDisabled: !storageRules.enableGitSync,
|
||||
},
|
||||
];
|
||||
|
||||
const currentType = typeList.find(item => item.type === value);
|
||||
|
||||
const handleChange = (v: string) => {
|
||||
setListOpen(false);
|
||||
onChange(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label aria-label="Project Type" className="p-0 text-sm text-(--color-font)">
|
||||
Type
|
||||
</Label>
|
||||
{listOpen || !currentType ? (
|
||||
<RadioGroup
|
||||
aria-label="Project Type Radio"
|
||||
className="flex flex-col px-0.5"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<div className="rounded-sm border border-(--hl-md) p-1">
|
||||
{typeList.map(item => (
|
||||
<Radio
|
||||
key={item.name}
|
||||
value={item.type}
|
||||
isDisabled={item.isDisabled}
|
||||
className="w-full pt-0 data-disabled:cursor-not-allowed data-disabled:opacity-50"
|
||||
>
|
||||
{({ isHovered, isSelected }) => (
|
||||
<div
|
||||
aria-label={`Project Type: ${item.type}`}
|
||||
className={`rounded-sm border ${isSelected ? 'border-(--color-surprise)' : 'border-transparent'} ${isHovered ? 'border-transparent bg-(--hl-xs)' : ''}`}
|
||||
>
|
||||
<TypeItem icon={item.icon} name={item.name} description={item.description} />
|
||||
</div>
|
||||
)}
|
||||
</Radio>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-[30px] cursor-default items-center justify-between rounded-sm border border-(--hl-sm) px-2"
|
||||
onClick={() => setListOpen(true)}
|
||||
>
|
||||
<div>
|
||||
<Icon className="mr-2" icon={currentType?.icon} />
|
||||
{currentType?.name}
|
||||
</div>
|
||||
<div>Change</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Banner } from '~/basic-components/banner';
|
||||
import { Button } from '~/basic-components/button';
|
||||
import { LearnMoreLink } from '~/basic-components/link';
|
||||
import { getAppWebsiteBaseURL } from '~/common/constants';
|
||||
import { docsPricingLearnMoreLink } from '~/common/documentation';
|
||||
import { isOwnerOfOrganization, type StorageRules } from '~/models/organization';
|
||||
import { getProjectStorageTypeLabel } from '~/models/project';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
import { useOrganizationLoaderData } from '~/routes/organization';
|
||||
import type { ProjectType } from '~/ui/components/project/utils';
|
||||
import { useIsLightTheme } from '~/ui/hooks/theme';
|
||||
|
||||
interface Props {
|
||||
isGitSyncEnabled: boolean;
|
||||
storageType?: ProjectType;
|
||||
storageRules: StorageRules;
|
||||
}
|
||||
export const ProjectTypeWarning = ({ isGitSyncEnabled, storageType, storageRules }: Props) => {
|
||||
const isLightTheme = useIsLightTheme();
|
||||
const showStorageRestrictionMessage =
|
||||
!storageRules.enableCloudSync || !storageRules.enableLocalVault || !storageRules.enableGitSync;
|
||||
const organizationData = useOrganizationLoaderData();
|
||||
const { userSession } = useRootLoaderData()!;
|
||||
const { organizationId } = useParams() as { organizationId: string };
|
||||
const organization = organizationData?.organizations.find(o => o.id === organizationId);
|
||||
// TODO: extract to a hook later
|
||||
const isUserOwner =
|
||||
organization && userSession.accountId && isOwnerOfOrganization({ organization, accountId: userSession.accountId });
|
||||
return (
|
||||
<>
|
||||
{storageType === 'git' &&
|
||||
!isGitSyncEnabled &&
|
||||
(isUserOwner ? (
|
||||
<Banner
|
||||
type="info"
|
||||
title="Git Sync limited to organizations of 3 or fewer users"
|
||||
className={`${isLightTheme ? 'bg-[#EEEBFF]' : 'bg-[#292535]'}`}
|
||||
message={
|
||||
<div>
|
||||
Git Sync is included on your plan for up to 3 users. Since your team is larger, you’ll need to upgrade
|
||||
your plan to use it. <LearnMoreLink href={docsPricingLearnMoreLink} />
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
onPress={() => {
|
||||
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/pricing?source=app_create_git_project`);
|
||||
}}
|
||||
>
|
||||
Upgrade
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Banner
|
||||
type="info"
|
||||
title="Git Sync limited to organizations of 3 or fewer users"
|
||||
className={`${isLightTheme ? 'bg-[#EEEBFF]' : 'bg-[#292535]'}`}
|
||||
message={
|
||||
<div>
|
||||
Git Sync is included on your plan for up to 3 users. Because your team is larger, your admin will need
|
||||
to upgrade the plan for you to access it.
|
||||
</div>
|
||||
}
|
||||
footer={<LearnMoreLink href={docsPricingLearnMoreLink} />}
|
||||
/>
|
||||
))}
|
||||
{showStorageRestrictionMessage && (
|
||||
<Banner
|
||||
type="warning"
|
||||
message={
|
||||
<span>
|
||||
The organization owner mandates that projects must be created and stored using{' '}
|
||||
{getProjectStorageTypeLabel(storageRules)}.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
26
packages/insomnia/src/ui/components/project/utils.tsx
Normal file
26
packages/insomnia/src/ui/components/project/utils.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { OauthProviderName } from '../../../models/git-credentials';
|
||||
|
||||
// TODO: remove unused view value
|
||||
export type ActiveView = 'project' | 'git-results' | 'git-clone' | 'switch-storage-type';
|
||||
|
||||
export function useActiveView() {
|
||||
const [activeView, setActiveView] = useState<ActiveView>('project');
|
||||
return { activeView, setActiveView };
|
||||
}
|
||||
|
||||
export interface ProjectData {
|
||||
name: string;
|
||||
authorName?: string;
|
||||
authorEmail?: string;
|
||||
uri?: string;
|
||||
ref?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
oauth2format?: OauthProviderName;
|
||||
connectRepositoryLater?: boolean;
|
||||
}
|
||||
|
||||
export type ProjectType = 'local' | 'remote' | 'git';
|
||||
Reference in New Issue
Block a user