Implement functionality to create project using a template (#60)

* Create repository from selected template

* Create project based on created repository

* Replace dropdown component with component from material tailwind

* Remove repository name from query parameters
This commit is contained in:
Nabarun Gogoi 2024-02-08 09:20:49 +05:30 committed by GitHub
parent 413ed03eb8
commit e0001466e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 209 additions and 117 deletions

View File

@ -1,9 +1,11 @@
[ [
{ {
"id": "2379cf1f-a232-4ad2-ae14-4d881131cc26",
"name": "Snowball Tools", "name": "Snowball Tools",
"slug": "snowball-tools" "slug": "snowball-tools"
}, },
{ {
"id": "7eb9b3eb-eb74-4b53-b59a-69884c82a7fb",
"name": "AirFoil", "name": "AirFoil",
"slug": "airfoil" "slug": "airfoil"
} }

View File

@ -1,22 +1,27 @@
[ [
{ {
"framework": "React", "id": "1",
"name": "Progressive Web App (PWA)",
"icon": "^" "icon": "^"
}, },
{ {
"framework": "Reactnative", "id": "2",
"name": "Kotlin",
"icon": "^" "icon": "^"
}, },
{ {
"framework": "Kotlin", "id": "3",
"name": "React Native",
"icon": "^" "icon": "^"
}, },
{ {
"framework": "Swift", "id": "4",
"name": "Swift",
"icon": "^" "icon": "^"
}, },
{ {
"framework": "Webapp", "id": "5",
"name": "Web app",
"icon": "^" "icon": "^"
} }
] ]

View File

@ -5,7 +5,7 @@ import {
} from 'react-dropdown'; } from 'react-dropdown';
import 'react-dropdown/style.css'; import 'react-dropdown/style.css';
interface Option { export interface Option {
value: string; value: string;
label: string; label: string;
} }

View File

@ -1,5 +1,5 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Button, Typography } from '@material-tailwind/react'; import { Button, Typography } from '@material-tailwind/react';
@ -8,8 +8,12 @@ import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
import ConfirmDialog from '../../shared/ConfirmDialog'; import ConfirmDialog from '../../shared/ConfirmDialog';
const Deploy = () => { const Deploy = () => {
const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId');
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const handleOpen = () => setOpen(!open); const handleOpen = () => setOpen(!open);
const navigate = useNavigate(); const navigate = useNavigate();
const { orgSlug } = useParams(); const { orgSlug } = useParams();
@ -70,6 +74,14 @@ const Deploy = () => {
status={DeployStatus.NOT_STARTED} status={DeployStatus.NOT_STARTED}
step="4" step="4"
/> />
<Button
onClick={() => {
navigate(`/${orgSlug}/projects/create/success/${projectId}`);
}}
>
VIEW DEMO
</Button>
</div> </div>
); );
}; };

View File

@ -1,43 +1,61 @@
import React from 'react'; import React, { useCallback } from 'react';
import { Link } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Chip, IconButton } from '@material-tailwind/react'; import { Chip, IconButton } from '@material-tailwind/react';
import { relativeTime } from '../../../utils/time'; import { relativeTime } from '../../../utils/time';
import { GitRepositoryDetails } from '../../../types/project'; import { GitRepositoryDetails } from '../../../types/project';
import { useGQLClient } from '../../../context/GQLClientContext';
interface ProjectRepoCardProps { interface ProjectRepoCardProps {
repository: GitRepositoryDetails; repository: GitRepositoryDetails;
} }
const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => { const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
const client = useGQLClient();
const navigate = useNavigate();
const { orgSlug } = useParams();
const createProject = useCallback(async () => {
if (!repository) {
return;
}
const { addProject } = await client.addProject(orgSlug!, {
name: `${repository.owner!.login}-${repository.name}`,
// TODO: Get organization id from context or URL
prodBranch: repository.default_branch!,
repository: repository.full_name,
});
navigate(`import?projectId=${addProject.id}`);
}, [client, repository]);
return ( return (
<Link <div
to={`import?owner=${repository.owner?.login}&repo=${repository.name}`} className="group flex items-center gap-4 text-gray-500 text-xs hover:bg-gray-100 p-2 cursor-pointer"
onClick={createProject}
> >
<div className="group flex items-center gap-4 text-gray-500 text-xs hover:bg-gray-100 p-2 cursor-pointer"> <div>^</div>
<div>^</div> <div className="grow">
<div className="grow"> <div>
<div> <span className="text-black">{repository.full_name}</span>
<span className="text-black">{repository.full_name}</span> {repository.visibility === 'private' && (
{repository.visibility === 'private' ? ( <Chip
<Chip className="normal-case inline ml-6 font-normal"
className="normal-case inline ml-6 bg-[#FED7AA] text-[#EA580C] font-normal" size="sm"
size="sm" value="Private"
value="Private" icon={'^'}
icon={'^'} />
/> )}
) : (
''
)}
</div>
<p>{repository.updated_at && relativeTime(repository.updated_at)}</p>
</div>
<div className="hidden group-hover:block">
<IconButton size="sm">{'>'}</IconButton>
</div> </div>
<p>{repository.updated_at && relativeTime(repository.updated_at)}</p>
</div> </div>
</Link> <div className="hidden group-hover:block">
<IconButton size="sm">{'>'}</IconButton>
</div>
</div>
); );
}; };

View File

@ -6,19 +6,20 @@ import { IconButton, Typography } from '@material-tailwind/react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
interface TemplateDetails { interface TemplateDetails {
framework: string; id: string;
name: string;
icon: string; icon: string;
} }
interface TemplateCardProps { interface TemplateCardProps {
framework: TemplateDetails; template: TemplateDetails;
isGitAuth: boolean; isGitAuth: boolean;
} }
const CardDetails = ({ framework }: { framework: TemplateDetails }) => { const CardDetails = ({ template }: { template: TemplateDetails }) => {
return ( return (
<div className="h-14 group bg-gray-200 border-gray-200 rounded-lg shadow p-4 flex items-center justify-between"> <div className="h-14 group bg-gray-200 border-gray-200 rounded-lg shadow p-4 flex items-center justify-between">
<Typography className="grow"> <Typography className="grow">
{framework.icon} {framework.framework} {template.icon} {template.name}
</Typography> </Typography>
<div> <div>
<IconButton size="sm" className="rounded-full hidden group-hover:block"> <IconButton size="sm" className="rounded-full hidden group-hover:block">
@ -29,13 +30,10 @@ const CardDetails = ({ framework }: { framework: TemplateDetails }) => {
); );
}; };
const TemplateCard: React.FC<TemplateCardProps> = ({ const TemplateCard: React.FC<TemplateCardProps> = ({ template, isGitAuth }) => {
framework,
isGitAuth,
}) => {
return isGitAuth ? ( return isGitAuth ? (
<Link to="template"> <Link to={`template?templateId=${template.id}`}>
<CardDetails framework={framework} /> <CardDetails template={template} />
</Link> </Link>
) : ( ) : (
<a <a
@ -43,7 +41,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
toast.error('Connect Git account to start with a template') toast.error('Connect Git account to start with a template')
} }
> >
<CardDetails framework={framework} /> <CardDetails template={template} />
</a> </a>
); );
}; };

View File

@ -3,3 +3,5 @@ export const COMMIT_DETAILS = {
createdAt: '2023-12-11T04:20:00', createdAt: '2023-12-11T04:20:00',
branch: 'main', branch: 'main',
}; };
export const ORGANIZATION_ID = '2379cf1f-a232-4ad2-ae14-4d881131cc26';

View File

@ -1,65 +1,37 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import assert from 'assert';
import { Button } from '@material-tailwind/react';
import { useOctokit } from '../../../../context/OctokitContext';
import { GitRepositoryDetails } from '../../../../types/project';
import Deploy from '../../../../components/projects/create/Deploy'; import Deploy from '../../../../components/projects/create/Deploy';
import { useGQLClient } from '../../../../context/GQLClientContext'; import { useGQLClient } from '../../../../context/GQLClientContext';
const Import = () => { const Import = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { orgSlug } = useParams(); const projectId = searchParams.get('projectId');
const navigate = useNavigate(); assert(projectId);
const { octokit } = useOctokit();
const [repoName, setRepoName] = useState<string>('');
const client = useGQLClient(); const client = useGQLClient();
const [gitRepo, setGitRepo] = useState<GitRepositoryDetails>();
useEffect(() => { useEffect(() => {
const fetchRepo = async () => { const fetchRepo = async () => {
if (!octokit) { const { project } = await client.getProject(projectId);
return; assert(project);
}
const result = await octokit.rest.repos.get({ setRepoName(project.repository);
owner: searchParams.get('owner') ?? '',
repo: searchParams.get('repo') ?? '',
});
setGitRepo(result.data);
}; };
fetchRepo(); fetchRepo();
}, [searchParams, octokit]); }, [projectId]);
const createProjectAndCreate = useCallback(async () => {
if (!gitRepo) {
return;
}
const { addProject } = await client.addProject(orgSlug!, {
// TODO: Implement form for setting project name
name: `${gitRepo.owner!.login}-${gitRepo.name}`,
prodBranch: gitRepo.default_branch ?? 'main',
repository: gitRepo.full_name,
});
navigate(`/${orgSlug}/projects/create/success/${addProject.id}`);
}, [client, gitRepo]);
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="flex w-5/6 my-4 bg-gray-200 rounded-xl p-6"> <div className="flex w-5/6 my-4 bg-gray-200 rounded-xl p-6">
<div>^</div> <div>^</div>
<div className="grow">{gitRepo?.full_name}</div> <div className="grow">{repoName}</div>
</div> </div>
<Deploy /> <Deploy />
<Button onClick={createProjectAndCreate}>
CREATE PROJECT (FOR DEMO)
</Button>
</div> </div>
); );
}; };

View File

@ -1,7 +1,8 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation, useSearchParams } from 'react-router-dom';
import Stepper from '../../../../components/Stepper'; import Stepper from '../../../../components/Stepper';
import templateDetails from '../../../../assets/templates.json';
const STEPPER_VALUES = [ const STEPPER_VALUES = [
{ step: 1, route: '/projects/create/template', label: 'Create repository' }, { step: 1, route: '/projects/create/template', label: 'Create repository' },
@ -12,6 +13,12 @@ const STEPPER_VALUES = [
const CreateWithTemplate = () => { const CreateWithTemplate = () => {
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams();
const template = templateDetails.find(
(template) => template.id === searchParams.get('templateId'),
);
const activeStep = useMemo( const activeStep = useMemo(
() => () =>
STEPPER_VALUES.find((data) => data.route === location.pathname)?.step ?? STEPPER_VALUES.find((data) => data.route === location.pathname)?.step ??
@ -23,7 +30,7 @@ const CreateWithTemplate = () => {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="flex justify-between w-5/6 my-4 bg-gray-200 rounded-xl p-6"> <div className="flex justify-between w-5/6 my-4 bg-gray-200 rounded-xl p-6">
<div>^</div> <div>^</div>
<div className="grow">React native</div> <div className="grow">{template?.name}</div>
{/* TODO: Get template Git link from DB */} {/* TODO: Get template Git link from DB */}
<div>^snowball-tools/react-native-starter</div> <div>^snowball-tools/react-native-starter</div>
</div> </div>

View File

@ -13,12 +13,12 @@ const NewProject = () => {
<> <>
<h5 className="mt-4 ml-4">Start with template</h5> <h5 className="mt-4 ml-4">Start with template</h5>
<div className="grid grid-cols-3 p-4 gap-4"> <div className="grid grid-cols-3 p-4 gap-4">
{templateDetails.map((framework, key) => { {templateDetails.map((template) => {
return ( return (
<TemplateCard <TemplateCard
isGitAuth={Boolean(octokit)} isGitAuth={Boolean(octokit)}
framework={framework} template={template}
key={key} key={template.id}
/> />
); );
})} })}

View File

@ -1,31 +1,104 @@
import React from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller, SubmitHandler } from 'react-hook-form';
import { Link } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import toast from 'react-hot-toast';
import assert from 'assert';
import { Typography } from '@material-tailwind/react'; import { Option, Typography } from '@material-tailwind/react';
import Dropdown from '../../../../../components/Dropdown'; import { useOctokit } from '../../../../../context/OctokitContext';
import { useGQLClient } from '../../../../../context/GQLClientContext';
import AsyncSelect from '../../../../../components/shared/AsyncSelect';
const USER_OPTIONS = [ type SubmitRepoValues = {
{ value: 'saugatyadav1', label: 'saugatyadav1' }, framework: string;
{ value: 'brad102', label: 'brad102' }, repoName: string;
{ value: 'erin20', label: 'erin20' }, isPrivate: boolean;
]; account: string;
};
const CreateRepo = () => { const CreateRepo = () => {
const { register, handleSubmit, control } = useForm({ const { octokit } = useOctokit();
const client = useGQLClient();
const { orgSlug } = useParams();
const navigate = useNavigate();
const [gitAccounts, setGitAccounts] = useState<string[]>([]);
const submitRepoHandler: SubmitHandler<SubmitRepoValues> = useCallback(
async (data) => {
assert(data.account);
try {
// TODO: Handle this functionality in backend
const gitRepo = await octokit?.rest.repos.createUsingTemplate({
template_owner: 'github-rest',
template_repo: 'test-progressive-web-app',
owner: data.account,
name: data.repoName,
description: 'This is your first repository',
include_all_branches: false,
private: data.isPrivate,
});
if (!gitRepo) {
return;
}
const { addProject } = await client.addProject(orgSlug!, {
name: `${gitRepo.data.owner!.login}-${gitRepo.data.name}`,
// TODO: Get organization id from context or URL
prodBranch: gitRepo.data.default_branch ?? 'main',
repository: gitRepo.data.full_name,
});
navigate(
`/${orgSlug}/projects/create/template/deploy?projectId=${addProject.id}`,
);
} catch (err) {
toast.error('Error deploying project');
}
},
[octokit],
);
useEffect(() => {
const fetchUserAndOrgs = async () => {
const user = await octokit?.rest.users.getAuthenticated();
const orgs = await octokit?.rest.orgs.listForAuthenticatedUser();
if (user && orgs) {
const orgsLoginArr = orgs.data.map((org) => org.login);
setGitAccounts([user.data.login, ...orgsLoginArr]);
}
};
fetchUserAndOrgs();
}, [octokit]);
const { register, handleSubmit, control, reset } = useForm<SubmitRepoValues>({
defaultValues: { defaultValues: {
framework: 'reactNative', framework: 'React',
repoName: '', repoName: '',
isPrivate: false, isPrivate: false,
account: { value: 'saugatyadav1', label: 'saugatyadav1' }, account: gitAccounts[0],
}, },
}); });
useEffect(() => {
if (gitAccounts.length > 0) {
reset({ account: gitAccounts[0] });
}
}, [gitAccounts]);
// TODO: Get users and orgs from GitHub // TODO: Get users and orgs from GitHub
return ( return (
<form onSubmit={handleSubmit(() => {})}> <form onSubmit={handleSubmit(submitRepoHandler)}>
<div className="mb-2"> <div className="mb-2">
<Typography variant="h6">Create a repository</Typography> <Typography variant="h6">Create a repository</Typography>
<Typography color="gray"> <Typography color="gray">
@ -39,19 +112,19 @@ const CreateRepo = () => {
<input <input
type="radio" type="radio"
{...register('framework')} {...register('framework')}
value="reactNative" value="React"
className="h-5 w-5 text-indigo-600 rounded" className="h-5 w-5 text-indigo-600 rounded"
/> />
<span className="ml-2">^React Native</span> <span className="ml-2">^React</span>
</label> </label>
<label className="inline-flex items-center w-1/2 border rounded-lg p-2"> <label className="inline-flex items-center w-1/2 border rounded-lg p-2">
<input <input
type="radio" type="radio"
{...register('framework')} {...register('framework')}
className="h-5 w-5 text-indigo-600 rounded" className="h-5 w-5 text-indigo-600 rounded"
value="expo" value="Next"
/> />
<span className="ml-2">^Expo</span> <span className="ml-2">^Next</span>
</label> </label>
</div> </div>
</div> </div>
@ -61,12 +134,17 @@ const CreateRepo = () => {
<Controller <Controller
name="account" name="account"
control={control} control={control}
render={({ field: { onChange, value } }) => ( render={({ field }) => (
<Dropdown <AsyncSelect
onChange={onChange} {...field}
value={value} label={!field.value ? 'Select an account / Organization' : ''}
options={USER_OPTIONS} >
/> {gitAccounts.map((account, key) => (
<Option key={key} value={account}>
^ {account}
</Option>
))}
</AsyncSelect>
)} )}
/> />
</div> </div>
@ -93,11 +171,9 @@ const CreateRepo = () => {
</label> </label>
</div> </div>
<div className="mb-2"> <div className="mb-2">
<Link to="deploy"> <button className="bg-blue-500 rounded-xl p-2" type="submit">
<button className="bg-blue-500 rounded-xl p-2" type="submit"> Deploy ^
Deploy ^ </button>
</button>
</Link>
</div> </div>
</form> </form>
); );