Merge pull request #150 from snowball-tools/andrehadianto/T-4912-create-project-create-repository-page

[T-4912] create project create repository page
This commit is contained in:
Andre Hadianto 2024-03-06 09:39:33 +08:00 committed by GitHub
commit 621ca8926e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 175 additions and 117 deletions

View File

@ -0,0 +1,21 @@
import React from 'react';
import { CustomIcon, CustomIconProps } from './CustomIcon';
export const LinkChainIcon = (props: CustomIconProps) => {
return (
<CustomIcon
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
{...props}
>
<path
d="M9.75027 5.52371L10.7168 4.55722C13.1264 2.14759 17.0332 2.14759 19.4428 4.55722C21.8524 6.96684 21.8524 10.8736 19.4428 13.2832L18.4742 14.2519M5.52886 9.74513L4.55722 10.7168C2.14759 13.1264 2.1476 17.0332 4.55722 19.4428C6.96684 21.8524 10.8736 21.8524 13.2832 19.4428L14.2478 18.4782M9.5 14.5L14.5 9.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</CustomIcon>
);
};

View File

@ -40,6 +40,7 @@ export * from './GithubStrokeIcon';
export * from './BranchStrokeIcon'; export * from './BranchStrokeIcon';
export * from './StorageIcon'; export * from './StorageIcon';
export * from './LinkIcon'; export * from './LinkIcon';
export * from './LinkChainIcon';
export * from './CursorBoxIcon'; export * from './CursorBoxIcon';
export * from './CommitIcon'; export * from './CommitIcon';
export * from './RocketIcon'; export * from './RocketIcon';

View File

@ -47,15 +47,15 @@ export const Input = ({
helperIcon: helperIconCls, helperIcon: helperIconCls,
} = inputTheme({ ...styleProps }); } = inputTheme({ ...styleProps });
const renderLabels = useMemo( const renderLabels = useMemo(() => {
() => ( if (!label && !description) return null;
<div className="space-y-1"> return (
<div className="flex flex-col gap-y-1">
<p className={labelCls()}>{label}</p> <p className={labelCls()}>{label}</p>
<p className={descriptionCls()}>{description}</p> <p className={descriptionCls()}>{description}</p>
</div> </div>
),
[labelCls, descriptionCls, label, description],
); );
}, [labelCls, descriptionCls, label, description]);
const renderLeftIcon = useMemo(() => { const renderLeftIcon = useMemo(() => {
return ( return (
@ -73,8 +73,9 @@ export const Input = ({
); );
}, [cloneIcon, iconCls, iconContainerCls, rightIcon]); }, [cloneIcon, iconCls, iconContainerCls, rightIcon]);
const renderHelperText = useMemo( const renderHelperText = useMemo(() => {
() => ( if (!helperText) return null;
return (
<div className={helperTextCls()}> <div className={helperTextCls()}>
{state && {state &&
cloneIcon(<WarningIcon className={helperIconCls()} />, { cloneIcon(<WarningIcon className={helperIconCls()} />, {
@ -82,12 +83,11 @@ export const Input = ({
})} })}
<p>{helperText}</p> <p>{helperText}</p>
</div> </div>
),
[cloneIcon, state, helperIconCls, helperText, helperTextCls],
); );
}, [cloneIcon, state, helperIconCls, helperText, helperTextCls]);
return ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-y-2">
{renderLabels} {renderLabels}
<div className={containerCls({ class: className })}> <div className={containerCls({ class: className })}>
{leftIcon && renderLeftIcon} {leftIcon && renderLeftIcon}

View File

@ -2,7 +2,7 @@ import { VariantProps, tv } from 'tailwind-variants';
export const radioTheme = tv({ export const radioTheme = tv({
slots: { slots: {
root: ['flex', 'gap-3', 'flex-wrap'], root: ['flex', 'gap-3'],
wrapper: ['flex', 'items-center', 'gap-2', 'group'], wrapper: ['flex', 'items-center', 'gap-2', 'group'],
label: ['text-sm', 'tracking-[-0.006em]', 'text-elements-high-em'], label: ['text-sm', 'tracking-[-0.006em]', 'text-elements-high-em'],
radio: [ radio: [
@ -39,15 +39,34 @@ export const radioTheme = tv({
'after:data-[state=checked]:group-hover:bg-elements-on-primary', 'after:data-[state=checked]:group-hover:bg-elements-on-primary',
'after:data-[state=checked]:group-focus-visible:bg-elements-on-primary', 'after:data-[state=checked]:group-focus-visible:bg-elements-on-primary',
], ],
icon: ['w-[18px]', 'h-[18px]'],
}, },
variants: { variants: {
orientation: { orientation: {
vertical: { root: ['flex-col'] }, vertical: { root: ['flex-col'] },
horizontal: { root: ['flex-row'] }, horizontal: { root: ['flex-row'] },
}, },
variant: {
unstyled: {},
card: {
wrapper: [
'px-4',
'py-3',
'rounded-lg',
'border',
'border-border-interactive',
'bg-controls-tertiary',
'shadow-button',
'w-full',
'cursor-pointer',
],
label: ['select-none', 'cursor-pointer'],
},
},
}, },
defaultVariants: { defaultVariants: {
orientation: 'vertical', orientation: 'vertical',
variant: 'unstyled',
}, },
}); });

View File

@ -49,14 +49,15 @@ export const Radio = ({
className, className,
options, options,
orientation, orientation,
variant,
...props ...props
}: RadioProps) => { }: RadioProps) => {
const { root } = radioTheme({ orientation }); const { root } = radioTheme({ orientation, variant });
return ( return (
<RadixRoot {...props} className={root({ className })}> <RadixRoot {...props} className={root({ className })}>
{options.map((option) => ( {options.map((option) => (
<RadioItem key={option.value} {...option} /> <RadioItem key={option.value} variant={variant} {...option} />
))} ))}
</RadixRoot> </RadixRoot>
); );

View File

@ -1,13 +1,16 @@
import React, { ComponentPropsWithoutRef } from 'react'; import React, { ReactNode, ComponentPropsWithoutRef } from 'react';
import { import {
Item as RadixRadio, Item as RadixRadio,
Indicator as RadixIndicator, Indicator as RadixIndicator,
RadioGroupItemProps, RadioGroupItemProps,
RadioGroupIndicatorProps, RadioGroupIndicatorProps,
} from '@radix-ui/react-radio-group'; } from '@radix-ui/react-radio-group';
import { radioTheme } from './Radio.theme'; import { RadioTheme, radioTheme } from './Radio.theme';
import { cloneIcon } from 'utils/cloneIcon';
export interface RadioItemProps extends RadioGroupItemProps { export interface RadioItemProps
extends RadioGroupItemProps,
Pick<RadioTheme, 'variant'> {
/** /**
* The wrapper props of the radio item. * The wrapper props of the radio item.
* You can use this prop to customize the wrapper props. * You can use this prop to customize the wrapper props.
@ -27,6 +30,10 @@ export interface RadioItemProps extends RadioGroupItemProps {
* The id of the radio item. * The id of the radio item.
*/ */
id?: string; id?: string;
/**
* The left icon of the radio item.
*/
leftIcon?: ReactNode;
/** /**
* The label of the radio item. * The label of the radio item.
*/ */
@ -41,18 +48,29 @@ export const RadioItem = ({
wrapperProps, wrapperProps,
labelProps, labelProps,
indicatorProps, indicatorProps,
leftIcon,
label, label,
id, id,
variant,
...props ...props
}: RadioItemProps) => { }: RadioItemProps) => {
const { wrapper, label: labelClass, radio, indicator } = radioTheme(); const {
wrapper,
label: labelClass,
radio,
indicator,
icon,
} = radioTheme({ variant });
// Generate a unique id for the radio item from the label if the id is not provided // Generate a unique id for the radio item from the label if the id is not provided
const kebabCaseLabel = label?.toLowerCase().replace(/\s+/g, '-'); const kebabCaseLabel = label?.toLowerCase().replace(/\s+/g, '-');
const componentId = id ?? kebabCaseLabel; const componentId = id ?? kebabCaseLabel;
return ( return (
<div className={wrapper({ className: wrapperProps?.className })}> <label
htmlFor={componentId}
className={wrapper({ className: wrapperProps?.className })}
>
<RadixRadio {...props} className={radio({ className })} id={componentId}> <RadixRadio {...props} className={radio({ className })} id={componentId}>
<RadixIndicator <RadixIndicator
forceMount forceMount
@ -60,15 +78,20 @@ export const RadioItem = ({
className={indicator({ className: indicatorProps?.className })} className={indicator({ className: indicatorProps?.className })}
/> />
</RadixRadio> </RadixRadio>
{leftIcon && (
<span>
{cloneIcon(leftIcon, { className: icon(), 'aria-hidden': true })}
</span>
)}
{label && ( {label && (
<label <label
{...labelProps} {...labelProps}
className={labelClass({ className: labelProps?.className })}
htmlFor={componentId} htmlFor={componentId}
className={labelClass({ className: labelProps?.className })}
> >
{label} {label}
</label> </label>
)} )}
</div> </label>
); );
}; };

View File

@ -266,15 +266,15 @@ export const Select = ({
onClear?.(); onClear?.();
}; };
const renderLabels = useMemo( const renderLabels = useMemo(() => {
() => ( if (!label && !description) return null;
<div className="space-y-1"> return (
<div className="flex flex-col gap-y-1">
<p className={theme.label()}>{label}</p> <p className={theme.label()}>{label}</p>
<p className={theme.description()}>{description}</p> <p className={theme.description()}>{description}</p>
</div> </div>
),
[theme, label, description],
); );
}, [theme, label, description]);
const renderLeftIcon = useMemo(() => { const renderLeftIcon = useMemo(() => {
return ( return (
@ -302,8 +302,9 @@ export const Select = ({
); );
}, [cloneIcon, theme, rightIcon]); }, [cloneIcon, theme, rightIcon]);
const renderHelperText = useMemo( const renderHelperText = useMemo(() => {
() => ( if (!helperText) return null;
return (
<div className={theme.helperText()}> <div className={theme.helperText()}>
{error && {error &&
cloneIcon(<WarningIcon className={theme.helperIcon()} />, { cloneIcon(<WarningIcon className={theme.helperIcon()} />, {
@ -311,9 +312,8 @@ export const Select = ({
})} })}
<p>{helperText}</p> <p>{helperText}</p>
</div> </div>
),
[cloneIcon, error, theme, helperText],
); );
}, [cloneIcon, error, theme, helperText]);
const isMultipleHasValue = multiple && selectedItems.length > 0; const isMultipleHasValue = multiple && selectedItems.length > 0;
const isMultipleHasValueButNotSearchable = const isMultipleHasValueButNotSearchable =

View File

@ -6,9 +6,13 @@ import {
useSearchParams, useSearchParams,
} from 'react-router-dom'; } from 'react-router-dom';
import { Avatar } from '@material-tailwind/react';
import templates from '../../../../assets/templates'; import templates from '../../../../assets/templates';
import {
LinkChainIcon,
TemplateIcon,
TemplateIconType,
} from 'components/shared/CustomIcon';
import { Heading } from 'components/shared/Heading';
import { Steps } from 'components/shared/Steps'; import { Steps } from 'components/shared/Steps';
// TODO: Set dynamic route for template and load details from DB // TODO: Set dynamic route for template and load details from DB
@ -44,19 +48,24 @@ const CreateWithTemplate = () => {
return ( return (
<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 items-center"> <div className="flex flex-col lg:flex-row justify-between w-5/6 my-4 bg-base-bg-alternate rounded-xl p-6 gap-3 items-start lg:items-center">
<Avatar variant="rounded" src="/gray.png" placeholder={''} /> <div className="flex items-center gap-3">
<div className="grow px-2">{template?.name}</div> <TemplateIcon type={template?.icon as TemplateIconType} size={48} />
<Heading className="font-medium">{template?.name}</Heading>
</div>
<div> <div>
<a <a
href={`https://github.com/${template?.repoFullName}`} href={`https://github.com/${template?.repoFullName}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex gap-1.5 items-center text-sm"
> >
^{' '} <LinkChainIcon size={18} />
<span className="underline">
{Boolean(template?.repoFullName) {Boolean(template?.repoFullName)
? template?.repoFullName ? template?.repoFullName
: 'Template not supported'} : 'Template not supported'}
</span>
</a> </a>
</div> </div>
</div> </div>

View File

@ -1,15 +1,18 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useForm, Controller, SubmitHandler } from 'react-hook-form'; import { useForm, SubmitHandler, Controller } from 'react-hook-form';
import { useNavigate, useOutletContext, useParams } from 'react-router-dom'; import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import assert from 'assert'; import assert from 'assert';
import { Button, Option, Typography } from '@material-tailwind/react';
import { useOctokit } from '../../../../../context/OctokitContext'; import { useOctokit } from '../../../../../context/OctokitContext';
import { useGQLClient } from '../../../../../context/GQLClientContext'; import { useGQLClient } from '../../../../../context/GQLClientContext';
import AsyncSelect from '../../../../../components/shared/AsyncSelect';
import { Template } from '../../../../../types'; import { Template } from '../../../../../types';
import { Heading } from 'components/shared/Heading';
import { Input } from 'components/shared/Input';
import { Select, SelectOption } from 'components/shared/Select';
import { ArrowRightCircleFilledIcon } from 'components/shared/CustomIcon';
import { Checkbox } from 'components/shared/Checkbox';
import { Button } from 'components/shared/Button';
type SubmitRepoValues = { type SubmitRepoValues = {
framework: string; framework: string;
@ -93,7 +96,7 @@ const CreateRepo = () => {
fetchUserAndOrgs(); fetchUserAndOrgs();
}, [octokit]); }, [octokit]);
const { register, handleSubmit, control, reset } = useForm<SubmitRepoValues>({ const { handleSubmit, control, reset } = useForm<SubmitRepoValues>({
defaultValues: { defaultValues: {
framework: 'React', framework: 'React',
repoName: '', repoName: '',
@ -110,87 +113,68 @@ const CreateRepo = () => {
return ( return (
<form onSubmit={handleSubmit(submitRepoHandler)}> <form onSubmit={handleSubmit(submitRepoHandler)}>
<div className="mb-2"> <div className="flex flex-col gap-4 lg:gap-7 w-full">
<Typography variant="h6" placeholder={''}>
Create a repository
</Typography>
<Typography color="gray" placeholder={''}>
The project will be cloned into this repository
</Typography>
</div>
<div className="mb-2">
<h5>Framework</h5>
<div className="flex items-center gap-2">
<label className="inline-flex items-center w-1/2 border rounded-lg p-2">
<input
type="radio"
{...register('framework')}
value="React"
className="h-5 w-5 text-indigo-600 rounded"
/>
<span className="ml-2">^React</span>
</label>
<label className="inline-flex items-center w-1/2 border rounded-lg p-2">
<input
type="radio"
{...register('framework')}
className="h-5 w-5 text-indigo-600 rounded"
value="Next"
/>
<span className="ml-2">^Next</span>
</label>
</div>
</div>
<div className="mb-2">
<h5>Git account</h5>
<div> <div>
<Heading as="h3" className="text-lg font-medium">
Create a repository
</Heading>
<Heading as="h5" className="text-sm font-sans text-elements-low-em">
The project will be cloned into this repository
</Heading>
</div>
<div className="flex flex-col justify-start gap-3">
<span className="text-sm text-elements-high-em">Git account</span>
<Controller <Controller
name="account" name="account"
control={control} control={control}
render={({ field }) => ( render={({ field: { value, onChange } }) => (
<AsyncSelect {...field}> <Select
{gitAccounts.map((account, key) => ( value={{ value } as SelectOption}
<Option key={key} value={account}> onChange={(value) => onChange((value as SelectOption).value)}
^ {account} options={
</Option> gitAccounts.map((account) => ({
))} value: account,
</AsyncSelect> label: account,
})) ?? []
}
/>
)} )}
/> />
</div> </div>
<div className="flex flex-col justify-start gap-3">
<span className="text-sm text-elements-high-em">Name the repo</span>
<Controller
name="repoName"
control={control}
render={({ field: { value, onChange } }) => (
<Input value={value} onChange={onChange} />
)}
/>
</div> </div>
<div className="mb-2">
<h5>Name the repo</h5>
<div> <div>
<input <Controller
type="text" name="isPrivate"
className="border border-gray-300 rounded p-2 w-full focus:border-blue-300 focus:outline-none focus:shadow-outline-blue" control={control}
placeholder="" render={({ field: { value, onChange } }) => (
{...register('repoName')} <Checkbox
label="Make this repo private"
checked={value}
onCheckedChange={onChange}
/>
)}
/> />
</div> </div>
</div> <div>
<div className="mb-2">
<label className="inline-flex items-center">
<input
type="checkbox"
className="h-5 w-5 text-indigo-600 rounded"
{...register('isPrivate')}
/>
<span className="ml-2">Make this repo private</span>
</label>
</div>
<div className="mb-2">
<Button <Button
className="bg-blue-500 rounded-xl p-2"
type="submit" type="submit"
size="lg"
disabled={!Boolean(template.repoFullName) || isLoading} disabled={!Boolean(template.repoFullName) || isLoading}
loading={isLoading} rightIcon={<ArrowRightCircleFilledIcon />}
placeholder={''}
> >
Deploy ^ Deploy
</Button> </Button>
</div> </div>
</div>
</form> </form>
); );
}; };