Merge pull request #138 from snowball-tools/ayungavis/T-4911-create-project-import-a-repository-section

[T-4911: feat] Re-styling and refactor create project import a repository section
This commit is contained in:
Wahyu Kurniawan 2024-03-05 22:41:43 +07:00 committed by GitHub
commit 3e42899f2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 197 additions and 154 deletions

View File

@ -1,81 +0,0 @@
import React, { useCallback } from 'react';
import toast from 'react-hot-toast';
import { useNavigate, useParams } from 'react-router-dom';
import { Chip, IconButton, Spinner } from '@material-tailwind/react';
import { relativeTimeISO } from '../../../utils/time';
import { GitRepositoryDetails } from '../../../types';
import { useGQLClient } from '../../../context/GQLClientContext';
import { GithubIcon, LockIcon } from 'components/shared/CustomIcon';
interface ProjectRepoCardProps {
repository: GitRepositoryDetails;
}
const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
const client = useGQLClient();
const navigate = useNavigate();
const [isLoading, setIsLoading] = React.useState(false);
const { orgSlug } = useParams();
const createProject = useCallback(async () => {
if (!repository) {
return;
}
setIsLoading(true);
const { addProject } = await client.addProject(orgSlug!, {
name: `${repository.owner!.login}-${repository.name}`,
prodBranch: repository.default_branch!,
repository: repository.full_name,
// TODO: Compute template from repo
template: 'webapp',
});
if (Boolean(addProject)) {
setIsLoading(false);
navigate(`import?projectId=${addProject.id}`);
} else {
setIsLoading(false);
toast.error('Failed to create project');
}
}, [client, repository]);
return (
<div
className="group flex items-center gap-4 text-gray-500 text-xs hover:bg-gray-100 p-2 cursor-pointer"
onClick={createProject}
>
<div className="w-10 h-10 bg-white rounded-md justify-center items-center gap-1.5 inline-flex">
<GithubIcon />
</div>
<div className="grow">
<div>
<span className="text-black">{repository.full_name}</span>
{repository.visibility === 'private' && (
<Chip
className="normal-case inline ml-6 font-normal text-xs text-xs bg-orange-50 border border-orange-200 text-orange-600 items-center gap-1 inline-flex"
size="sm"
value="Private"
icon={<LockIcon />}
/>
)}
</div>
<p>{repository.updated_at && relativeTimeISO(repository.updated_at)}</p>
</div>
{isLoading ? (
<Spinner className="h-4 w-4" />
) : (
<div className="hidden group-hover:block">
<IconButton size="sm" placeholder={''}>
{'>'}
</IconButton>
</div>
)}
</div>
);
};
export default ProjectRepoCard;

View File

@ -0,0 +1,114 @@
import React, { useCallback, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Spinner } from '@material-tailwind/react';
import { relativeTimeISO } from 'utils/time';
import { GitRepositoryDetails } from 'types';
import { useGQLClient } from 'context/GQLClientContext';
import {
ArrowRightCircleIcon,
GithubIcon,
LockIcon,
} from 'components/shared/CustomIcon';
import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast';
interface ProjectRepoCardProps {
repository: GitRepositoryDetails;
}
export const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({
repository,
}) => {
const client = useGQLClient();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { orgSlug } = useParams();
const { toast, dismiss } = useToast();
const createProject = useCallback(async () => {
if (!repository || !orgSlug) {
return toast({
id: 'missing-repository-or-org-slug',
title: 'Repository or organization slug is missing',
variant: 'error',
onDismiss: dismiss,
});
}
try {
setIsLoading(true);
const { addProject } = await client.addProject(orgSlug, {
name: `${repository.owner?.login}-${repository.name}`,
prodBranch: repository.default_branch as string,
repository: repository.full_name,
// TODO: Compute template from repo
template: 'webapp',
});
if (addProject) {
navigate(`import?projectId=${addProject.id}`);
} else {
toast({
id: 'failed-to-create-project',
title: 'Failed to create project',
variant: 'error',
onDismiss: dismiss,
});
}
} catch (error) {
console.error(error);
toast({
id: 'failed-to-create-project',
title: 'Failed to create project',
variant: 'error',
onDismiss: dismiss,
});
} finally {
setIsLoading(false);
}
}, [client, repository, orgSlug, setIsLoading, navigate, toast]);
return (
<div
className="group flex items-start sm:items-center gap-3 px-3 py-3 cursor-pointer rounded-xl hover:bg-base-bg-emphasized relative"
onClick={createProject}
>
{/* Icon container */}
<div className="w-10 h-10 bg-base-bg rounded-md justify-center items-center flex">
<GithubIcon />
</div>
{/* Content */}
<div className="flex flex-1 gap-3 flex-wrap">
<div className="flex flex-col gap-1">
<p className="text-elements-high-em text-sm font-medium tracking-[-0.006em]">
{repository.full_name}
</p>
<p className="text-elements-low-em text-xs">
{repository.updated_at && relativeTimeISO(repository.updated_at)}
</p>
</div>
{repository.visibility === 'private' && (
<div className="bg-orange-50 border border-orange-200 px-2 py-1 flex items-center gap-1 rounded-lg text-xs text-orange-600 h-fit">
<LockIcon />
Private
</div>
)}
</div>
{/* Right action */}
{isLoading ? (
<Spinner className="h-4 w-4 absolute right-3" />
) : (
<Button
variant="tertiary"
size="sm"
iconOnly
className="sm:group-hover:flex hidden absolute right-3"
>
<ArrowRightCircleIcon />
</Button>
)}
</div>
);
};

View File

@ -0,0 +1 @@
export * from './ProjectRepoCard';

View File

@ -3,13 +3,17 @@ import { Octokit } from 'octokit';
import assert from 'assert'; import assert from 'assert';
import { useDebounce } from 'usehooks-ts'; import { useDebounce } from 'usehooks-ts';
import { Button, Typography, Option } from '@material-tailwind/react'; import { Button, Typography } from '@material-tailwind/react';
import SearchBar from '../../SearchBar'; import { ProjectRepoCard } from 'components/projects/create/ProjectRepoCard';
import ProjectRepoCard from './ProjectRepoCard'; import { GitOrgDetails, GitRepositoryDetails } from 'types';
import { GitOrgDetails, GitRepositoryDetails } from '../../../types'; import {
import AsyncSelect from '../../shared/AsyncSelect'; ChevronGrabberHorizontal,
import { GithubIcon } from 'components/shared/CustomIcon'; GithubIcon,
SearchIcon,
} from 'components/shared/CustomIcon';
import { Select, SelectOption } from 'components/shared/Select';
import { Input } from 'components/shared/Input';
const DEFAULT_SEARCHED_REPO = ''; const DEFAULT_SEARCHED_REPO = '';
const REPOS_PER_PAGE = 5; const REPOS_PER_PAGE = 5;
@ -18,9 +22,9 @@ interface RepositoryListProps {
octokit: Octokit; octokit: Octokit;
} }
const RepositoryList = ({ octokit }: RepositoryListProps) => { export const RepositoryList = ({ octokit }: RepositoryListProps) => {
const [searchedRepo, setSearchedRepo] = useState(DEFAULT_SEARCHED_REPO); const [searchedRepo, setSearchedRepo] = useState(DEFAULT_SEARCHED_REPO);
const [selectedAccount, setSelectedAccount] = useState(''); const [selectedAccount, setSelectedAccount] = useState<SelectOption>();
const [orgs, setOrgs] = useState<GitOrgDetails[]>([]); const [orgs, setOrgs] = useState<GitOrgDetails[]>([]);
// TODO: Add new type for Git user when required // TODO: Add new type for Git user when required
const [gitUser, setGitUser] = useState<GitOrgDetails>(); const [gitUser, setGitUser] = useState<GitOrgDetails>();
@ -35,7 +39,7 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => {
const orgs = await octokit.rest.orgs.listForAuthenticatedUser(); const orgs = await octokit.rest.orgs.listForAuthenticatedUser();
setOrgs(orgs.data); setOrgs(orgs.data);
setGitUser(user.data); setGitUser(user.data);
setSelectedAccount(user.data.login); setSelectedAccount({ label: user.data.login, value: user.data.login });
}; };
fetchUserAndOrgs(); fetchUserAndOrgs();
@ -54,7 +58,7 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => {
let query = `${debouncedSearchedRepo} in:name fork:true`; let query = `${debouncedSearchedRepo} in:name fork:true`;
// Check if selected account is an organization // Check if selected account is an organization
if (selectedAccount === gitUser.login) { if (selectedAccount.value === gitUser.login) {
query = query + ` user:${selectedAccount}`; query = query + ` user:${selectedAccount}`;
} else { } else {
query = query + ` org:${selectedAccount}`; query = query + ` org:${selectedAccount}`;
@ -69,7 +73,7 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => {
return; return;
} }
if (selectedAccount === gitUser.login) { if (selectedAccount.value === gitUser.login) {
const result = await octokit.rest.repos.listForAuthenticatedUser({ const result = await octokit.rest.repos.listForAuthenticatedUser({
per_page: REPOS_PER_PAGE, per_page: REPOS_PER_PAGE,
affiliation: 'owner', affiliation: 'owner',
@ -78,7 +82,9 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => {
return; return;
} }
const selectedOrg = orgs.find((org) => org.login === selectedAccount); const selectedOrg = orgs.find(
(org) => org.login === selectedAccount.value,
);
assert(selectedOrg, 'Selected org not found in list'); assert(selectedOrg, 'Selected org not found in list');
const result = await octokit.rest.repos.listForOrg({ const result = await octokit.rest.repos.listForOrg({
@ -96,7 +102,7 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => {
const handleResetFilters = useCallback(() => { const handleResetFilters = useCallback(() => {
assert(gitUser, 'Git user is not available'); assert(gitUser, 'Git user is not available');
setSearchedRepo(DEFAULT_SEARCHED_REPO); setSearchedRepo(DEFAULT_SEARCHED_REPO);
setSelectedAccount(gitUser.login); setSelectedAccount({ label: gitUser.login, value: gitUser.login });
}, [gitUser]); }, [gitUser]);
const accounts = useMemo(() => { const accounts = useMemo(() => {
@ -107,35 +113,52 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => {
return [gitUser, ...orgs]; return [gitUser, ...orgs];
}, [octokit, orgs, gitUser]); }, [octokit, orgs, gitUser]);
const options = useMemo(() => {
return accounts.map((account) => ({
label: account.login,
value: account.login,
leftIcon: <GithubIcon />,
}));
}, [accounts]);
return ( return (
<div className="p-4"> <section className="space-y-3">
<div className="flex gap-2 mb-2 items-center"> {/* Dropdown and search */}
<div className="basis-1/3"> <div className="flex flex-col lg:flex-row gap-0 lg:gap-3 items-center">
<AsyncSelect <div className="lg:basis-1/3 w-full">
<Select
options={options}
placeholder="Select a repository"
value={selectedAccount} value={selectedAccount}
onChange={(value) => setSelectedAccount(value!)} leftIcon={selectedAccount ? <GithubIcon /> : undefined}
> rightIcon={<ChevronGrabberHorizontal />}
{accounts.map((account) => ( onChange={(value) => setSelectedAccount(value as SelectOption)}
<Option key={account.id} value={account.login}> />
<div className="flex items-center gap-2 justify-start">
<GithubIcon /> {account.login}
</div>
</Option>
))}
</AsyncSelect>
</div> </div>
<div className="basis-2/3 flex-grow flex items-center"> <div className="basis-2/3 flex w-full flex-grow">
<SearchBar <Input
className="w-full"
value={searchedRepo} value={searchedRepo}
onChange={(event) => setSearchedRepo(event.target.value)}
placeholder="Search for repository" placeholder="Search for repository"
leftIcon={<SearchIcon />}
onChange={(e) => setSearchedRepo(e.target.value)}
/> />
</div> </div>
</div> </div>
{/* Repository list */}
{Boolean(repositoryDetails.length) ? ( {Boolean(repositoryDetails.length) ? (
repositoryDetails.map((repo, key) => { <div className="flex flex-col gap-2">
return <ProjectRepoCard repository={repo} key={key} />; {repositoryDetails.map((repo, index) => (
}) <>
<ProjectRepoCard repository={repo} key={index} />
{/* Horizontal line */}
{index !== repositoryDetails.length - 1 && (
<div className="border-b border-border-separator/[0.06] w-full" />
)}
</>
))}
</div>
) : ( ) : (
<div className="mt-4 p-6 flex items-center justify-center"> <div className="mt-4 p-6 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -151,8 +174,6 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => {
</div> </div>
</div> </div>
)} )}
</div> </section>
); );
}; };
export default RepositoryList;

View File

@ -0,0 +1 @@
export * from './RepositoryList';

View File

@ -55,9 +55,9 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
}, [orgSlug, dismiss, isGitAuth, navigate, template, toast]); }, [orgSlug, dismiss, isGitAuth, navigate, template, toast]);
return ( return (
<button <div
className={cn( className={cn(
'flex items-center gap-3 px-3 py-3 bg-base-bg-alternate hover:bg-base-bg-emphasized rounded-2xl group relative', 'flex items-center gap-3 px-3 py-3 bg-base-bg-alternate hover:bg-base-bg-emphasized rounded-2xl group relative cursor-pointer',
{ {
'cursor-default': template?.isComingSoon, 'cursor-default': template?.isComingSoon,
}, },
@ -86,6 +86,6 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
<ArrowRightCircleIcon /> <ArrowRightCircleIcon />
</Button> </Button>
)} )}
</button> </div>
); );
}; };

View File

@ -8,6 +8,8 @@ export const inputTheme = tv(
'items-center', 'items-center',
'rounded-lg', 'rounded-lg',
'relative', 'relative',
'gap-2',
'w-full',
'placeholder:text-elements-disabled', 'placeholder:text-elements-disabled',
'disabled:cursor-not-allowed', 'disabled:cursor-not-allowed',
'disabled:bg-controls-disabled', 'disabled:bg-controls-disabled',
@ -27,7 +29,7 @@ export const inputTheme = tv(
'disabled:shadow-none', 'disabled:shadow-none',
'disabled:border-none', 'disabled:border-none',
], ],
icon: ['text-elements-mid-em'], icon: ['text-elements-low-em'],
iconContainer: [ iconContainer: [
'absolute', 'absolute',
'inset-y-0', 'inset-y-0',

View File

@ -87,12 +87,12 @@ export const Input = ({
); );
return ( return (
<div className="space-y-2"> <div className="flex flex-col gap-2 w-full">
{renderLabels} {renderLabels}
<div className={containerCls({ class: className })}> <div className={containerCls({ class: className })}>
{leftIcon && renderLeftIcon} {leftIcon && renderLeftIcon}
<input <input
className={cn(inputCls({ class: 'w-80' }), { className={cn(inputCls(), {
'pl-10': leftIcon, 'pl-10': leftIcon,
})} })}
{...props} {...props}

View File

@ -85,7 +85,7 @@ export const selectTheme = tv({
size: { size: {
md: { md: {
container: ['min-h-11'], container: ['min-h-11'],
inputWrapper: ['min-h-11', 'text-sm', 'pl-4', 'pr-4', 'py-1'], inputWrapper: ['min-h-11', 'text-sm', 'pl-4', 'pr-4'],
icon: ['h-[18px]', 'w-[18px]'], icon: ['h-[18px]', 'w-[18px]'],
helperText: 'text-sm', helperText: 'text-sm',
helperIcon: ['h-5', 'w-5'], helperIcon: ['h-5', 'w-5'],
@ -93,7 +93,7 @@ export const selectTheme = tv({
}, },
sm: { sm: {
container: ['min-h-8'], container: ['min-h-8'],
inputWrapper: ['min-h-8', 'text-xs', 'pl-3', 'pr-3', 'py-0.5'], inputWrapper: ['min-h-8', 'text-xs', 'pl-3', 'pr-3'],
icon: ['h-4', 'w-4'], icon: ['h-4', 'w-4'],
helperText: 'text-xs', helperText: 'text-xs',
helperIcon: ['h-4', 'w-4'], helperIcon: ['h-4', 'w-4'],

View File

@ -3,7 +3,6 @@ import React, {
useState, useState,
ComponentPropsWithoutRef, ComponentPropsWithoutRef,
useMemo, useMemo,
useCallback,
MouseEvent, MouseEvent,
useRef, useRef,
useEffect, useEffect,
@ -135,7 +134,9 @@ export const Select = ({
const theme = selectTheme({ size, error, variant, orientation }); const theme = selectTheme({ size, error, variant, orientation });
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [selectedItem, setSelectedItem] = useState<SelectOption | null>(null); const [selectedItem, setSelectedItem] = useState<SelectOption | null>(
(value as SelectOption) || null,
);
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>( const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>(
'bottom', 'bottom',
@ -166,22 +167,6 @@ export const Select = ({
} }
}, [dropdownOpen]); // Re-calculate whenever the dropdown is opened }, [dropdownOpen]); // Re-calculate whenever the dropdown is opened
useEffect(() => {
// If multiple selection is enabled, ensure the internal state is an array
if (multiple) {
if (Array.isArray(value)) {
// Directly use the provided array
setSelectedItems(value);
} else {
// Reset or set to empty array if the value is not an array
setSelectedItems([]);
}
} else {
// For single selection, directly set the selected item
setSelectedItem(value as SelectOption);
}
}, [value, multiple]);
const handleSelectedItemChange = (selectedItem: SelectOption | null) => { const handleSelectedItemChange = (selectedItem: SelectOption | null) => {
setSelectedItem(selectedItem); setSelectedItem(selectedItem);
setInputValue(selectedItem ? selectedItem.label : ''); setInputValue(selectedItem ? selectedItem.label : '');
@ -194,9 +179,9 @@ export const Select = ({
addSelectedItem, addSelectedItem,
removeSelectedItem, removeSelectedItem,
selectedItems, selectedItems,
setSelectedItems,
reset, reset,
} = useMultipleSelection<SelectOption>({ } = useMultipleSelection<SelectOption>({
selectedItems: multiple ? (value as SelectOption[]) : [],
onSelectedItemsChange: multiple onSelectedItemsChange: multiple
? undefined ? undefined
: ({ selectedItems }) => { : ({ selectedItems }) => {
@ -234,6 +219,7 @@ export const Select = ({
openMenu, openMenu,
} = useCombobox({ } = useCombobox({
items: filteredItems, items: filteredItems,
selectedItem: multiple ? null : (value as SelectOption) || null,
// @ts-expect-error there are two params but we don't need the second one // @ts-expect-error there are two params but we don't need the second one
isItemDisabled: (item) => item.disabled, isItemDisabled: (item) => item.disabled,
onInputValueChange: ({ inputValue = '' }) => setInputValue(inputValue), onInputValueChange: ({ inputValue = '' }) => setInputValue(inputValue),
@ -265,16 +251,12 @@ export const Select = ({
setInputValue(''); setInputValue('');
} }
}, },
selectedItem: multiple ? null : selectedItem,
// TODO: Make the input value empty when the dropdown is open, has a value, it is not multiple, and searchable // TODO: Make the input value empty when the dropdown is open, has a value, it is not multiple, and searchable
itemToString: (item) => (item && !multiple ? item.label : ''), itemToString: (item) => (item && !multiple ? item.label : ''),
}); });
const isSelected = useCallback( const isSelected = (item: SelectOption) =>
(item: SelectOption) => multiple ? selectedItems.includes(item) : selectedItem === item;
multiple ? selectedItems.includes(item) : selectedItem === item,
[selectedItems, selectedItem, multiple],
);
const handleClear = (e: MouseEvent<SVGSVGElement, globalThis.MouseEvent>) => { const handleClear = (e: MouseEvent<SVGSVGElement, globalThis.MouseEvent>) => {
e.stopPropagation(); e.stopPropagation();
@ -336,6 +318,7 @@ export const Select = ({
const isMultipleHasValue = multiple && selectedItems.length > 0; const isMultipleHasValue = multiple && selectedItems.length > 0;
const isMultipleHasValueButNotSearchable = const isMultipleHasValueButNotSearchable =
multiple && !searchable && selectedItems.length > 0; multiple && !searchable && selectedItems.length > 0;
const displayPlaceholder = useMemo(() => { const displayPlaceholder = useMemo(() => {
if (hideValues && isMultipleHasValue) { if (hideValues && isMultipleHasValue) {
return `${selectedItems.length} selected`; return `${selectedItems.length} selected`;
@ -391,6 +374,8 @@ export const Select = ({
'w-6': isMultipleHasValueButNotSearchable && !hideValues, 'w-6': isMultipleHasValueButNotSearchable && !hideValues,
// Add margin to the X icon // Add margin to the X icon
'ml-6': isMultipleHasValueButNotSearchable && clearable, 'ml-6': isMultipleHasValueButNotSearchable && clearable,
// Add padding if there's a left icon
'pl-7': leftIcon,
}, },
)} )}
/> />

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import templates from 'assets/templates'; import templates from 'assets/templates';
import RepositoryList from 'components/projects/create/RepositoryList'; import { RepositoryList } from 'components/projects/create/RepositoryList';
import ConnectAccount from 'components/projects/create/ConnectAccount'; import ConnectAccount from 'components/projects/create/ConnectAccount';
import { useOctokit } from 'context/OctokitContext'; import { useOctokit } from 'context/OctokitContext';
import { Heading } from 'components/shared/Heading'; import { Heading } from 'components/shared/Heading';
@ -13,8 +13,8 @@ const NewProject = () => {
return isAuth ? ( return isAuth ? (
<> <>
<div className="space-y-3"> <div className="space-y-3">
<Heading as="h3" className="font-medium text-lg"> <Heading as="h3" className="font-medium text-lg pl-1">
Start with template Start with a template
</Heading> </Heading>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
{templates.map((template) => { {templates.map((template) => {
@ -28,7 +28,7 @@ const NewProject = () => {
})} })}
</div> </div>
</div> </div>
<Heading as="h3" className="font-medium text-lg mt-10"> <Heading as="h3" className="font-medium text-lg mt-10 pl-1 mb-3">
Import a repository Import a repository
</Heading> </Heading>
<RepositoryList octokit={octokit} /> <RepositoryList octokit={octokit} />