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:
yaoweiprc
2025-12-11 17:17:21 +08:00
committed by GitHub
parent f42923d469
commit 9a2d2bfb6c
23 changed files with 957 additions and 228 deletions

View File

@@ -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

View File

@@ -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>
);

View 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}`)} />;
};

View File

@@ -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>
);
};

View File

@@ -25,7 +25,7 @@ export function getBorderColorClasses(color: ButtonColor) {
return {
primary: '',
danger: '',
default: 'border border-[--hl]',
default: 'border border-(--hl-md)',
}[color];
}

View File

@@ -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,

View 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,
);

View File

@@ -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'

View File

@@ -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',

View File

@@ -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)" />

View File

@@ -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('')}>

View 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>
);
};

View 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>
);
};

View File

@@ -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';
}

View File

@@ -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>

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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 lets start something new!
</span>
There were no Insomnia files in the selected repo or branch, so youll 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"
/>
)}
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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, youll 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>

View File

@@ -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>
);
};

View File

@@ -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, youll 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>
}
/>
)}
</>
);
};

View 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';