diff --git a/packages/frontend/src/components/projects/create/ProjectRepoCard.tsx b/packages/frontend/src/components/projects/create/ProjectRepoCard.tsx deleted file mode 100644 index fbcf50b4..00000000 --- a/packages/frontend/src/components/projects/create/ProjectRepoCard.tsx +++ /dev/null @@ -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 = ({ 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 ( -
-
- -
-
-
- {repository.full_name} - {repository.visibility === 'private' && ( - } - /> - )} -
-

{repository.updated_at && relativeTimeISO(repository.updated_at)}

-
- {isLoading ? ( - - ) : ( -
- - {'>'} - -
- )} -
- ); -}; - -export default ProjectRepoCard; diff --git a/packages/frontend/src/components/projects/create/ProjectRepoCard/ProjectRepoCard.tsx b/packages/frontend/src/components/projects/create/ProjectRepoCard/ProjectRepoCard.tsx new file mode 100644 index 00000000..b06fc233 --- /dev/null +++ b/packages/frontend/src/components/projects/create/ProjectRepoCard/ProjectRepoCard.tsx @@ -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 = ({ + 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 ( +
+ {/* Icon container */} +
+ +
+ {/* Content */} +
+
+

+ {repository.full_name} +

+

+ {repository.updated_at && relativeTimeISO(repository.updated_at)} +

+
+ {repository.visibility === 'private' && ( +
+ + Private +
+ )} +
+ {/* Right action */} + {isLoading ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/packages/frontend/src/components/projects/create/ProjectRepoCard/index.ts b/packages/frontend/src/components/projects/create/ProjectRepoCard/index.ts new file mode 100644 index 00000000..78472497 --- /dev/null +++ b/packages/frontend/src/components/projects/create/ProjectRepoCard/index.ts @@ -0,0 +1 @@ +export * from './ProjectRepoCard'; diff --git a/packages/frontend/src/components/projects/create/RepositoryList.tsx b/packages/frontend/src/components/projects/create/RepositoryList/RepositoryList.tsx similarity index 58% rename from packages/frontend/src/components/projects/create/RepositoryList.tsx rename to packages/frontend/src/components/projects/create/RepositoryList/RepositoryList.tsx index f09e61d6..14af57e6 100644 --- a/packages/frontend/src/components/projects/create/RepositoryList.tsx +++ b/packages/frontend/src/components/projects/create/RepositoryList/RepositoryList.tsx @@ -3,13 +3,17 @@ import { Octokit } from 'octokit'; import assert from 'assert'; 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 './ProjectRepoCard'; -import { GitOrgDetails, GitRepositoryDetails } from '../../../types'; -import AsyncSelect from '../../shared/AsyncSelect'; -import { GithubIcon } from 'components/shared/CustomIcon'; +import { ProjectRepoCard } from 'components/projects/create/ProjectRepoCard'; +import { GitOrgDetails, GitRepositoryDetails } from 'types'; +import { + ChevronGrabberHorizontal, + GithubIcon, + SearchIcon, +} from 'components/shared/CustomIcon'; +import { Select, SelectOption } from 'components/shared/Select'; +import { Input } from 'components/shared/Input'; const DEFAULT_SEARCHED_REPO = ''; const REPOS_PER_PAGE = 5; @@ -18,9 +22,9 @@ interface RepositoryListProps { octokit: Octokit; } -const RepositoryList = ({ octokit }: RepositoryListProps) => { +export const RepositoryList = ({ octokit }: RepositoryListProps) => { const [searchedRepo, setSearchedRepo] = useState(DEFAULT_SEARCHED_REPO); - const [selectedAccount, setSelectedAccount] = useState(''); + const [selectedAccount, setSelectedAccount] = useState(); const [orgs, setOrgs] = useState([]); // TODO: Add new type for Git user when required const [gitUser, setGitUser] = useState(); @@ -35,7 +39,7 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => { const orgs = await octokit.rest.orgs.listForAuthenticatedUser(); setOrgs(orgs.data); setGitUser(user.data); - setSelectedAccount(user.data.login); + setSelectedAccount({ label: user.data.login, value: user.data.login }); }; fetchUserAndOrgs(); @@ -54,7 +58,7 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => { let query = `${debouncedSearchedRepo} in:name fork:true`; // Check if selected account is an organization - if (selectedAccount === gitUser.login) { + if (selectedAccount.value === gitUser.login) { query = query + ` user:${selectedAccount}`; } else { query = query + ` org:${selectedAccount}`; @@ -69,7 +73,7 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => { return; } - if (selectedAccount === gitUser.login) { + if (selectedAccount.value === gitUser.login) { const result = await octokit.rest.repos.listForAuthenticatedUser({ per_page: REPOS_PER_PAGE, affiliation: 'owner', @@ -78,7 +82,9 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => { 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'); const result = await octokit.rest.repos.listForOrg({ @@ -96,7 +102,7 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => { const handleResetFilters = useCallback(() => { assert(gitUser, 'Git user is not available'); setSearchedRepo(DEFAULT_SEARCHED_REPO); - setSelectedAccount(gitUser.login); + setSelectedAccount({ label: gitUser.login, value: gitUser.login }); }, [gitUser]); const accounts = useMemo(() => { @@ -107,35 +113,52 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => { return [gitUser, ...orgs]; }, [octokit, orgs, gitUser]); + const options = useMemo(() => { + return accounts.map((account) => ({ + label: account.login, + value: account.login, + leftIcon: , + })); + }, [accounts]); + return ( -
-
-
- + {/* Dropdown and search */} +
+
+ setSearchedRepo(event.target.value)} placeholder="Search for repository" + leftIcon={} + onChange={(e) => setSearchedRepo(e.target.value)} />
+ + {/* Repository list */} {Boolean(repositoryDetails.length) ? ( - repositoryDetails.map((repo, key) => { - return ; - }) +
+ {repositoryDetails.map((repo, index) => ( + <> + + {/* Horizontal line */} + {index !== repositoryDetails.length - 1 && ( +
+ )} + + ))} +
) : (
@@ -151,8 +174,6 @@ const RepositoryList = ({ octokit }: RepositoryListProps) => {
)} -
+ ); }; - -export default RepositoryList; diff --git a/packages/frontend/src/components/projects/create/RepositoryList/index.ts b/packages/frontend/src/components/projects/create/RepositoryList/index.ts new file mode 100644 index 00000000..dc3bc8c5 --- /dev/null +++ b/packages/frontend/src/components/projects/create/RepositoryList/index.ts @@ -0,0 +1 @@ +export * from './RepositoryList'; diff --git a/packages/frontend/src/components/projects/create/TemplateCard/TemplateCard.tsx b/packages/frontend/src/components/projects/create/TemplateCard/TemplateCard.tsx index ad073c2e..afae4827 100644 --- a/packages/frontend/src/components/projects/create/TemplateCard/TemplateCard.tsx +++ b/packages/frontend/src/components/projects/create/TemplateCard/TemplateCard.tsx @@ -55,9 +55,9 @@ export const TemplateCard: React.FC = ({ }, [orgSlug, dismiss, isGitAuth, navigate, template, toast]); return ( - )} - +
); }; diff --git a/packages/frontend/src/components/shared/Input/Input.theme.ts b/packages/frontend/src/components/shared/Input/Input.theme.ts index b1ecb705..0144d578 100644 --- a/packages/frontend/src/components/shared/Input/Input.theme.ts +++ b/packages/frontend/src/components/shared/Input/Input.theme.ts @@ -8,6 +8,8 @@ export const inputTheme = tv( 'items-center', 'rounded-lg', 'relative', + 'gap-2', + 'w-full', 'placeholder:text-elements-disabled', 'disabled:cursor-not-allowed', 'disabled:bg-controls-disabled', @@ -27,7 +29,7 @@ export const inputTheme = tv( 'disabled:shadow-none', 'disabled:border-none', ], - icon: ['text-elements-mid-em'], + icon: ['text-elements-low-em'], iconContainer: [ 'absolute', 'inset-y-0', diff --git a/packages/frontend/src/components/shared/Input/Input.tsx b/packages/frontend/src/components/shared/Input/Input.tsx index fb3fd7d6..dd88bd80 100644 --- a/packages/frontend/src/components/shared/Input/Input.tsx +++ b/packages/frontend/src/components/shared/Input/Input.tsx @@ -87,12 +87,12 @@ export const Input = ({ ); return ( -
+
{renderLabels}
{leftIcon && renderLeftIcon} (null); + const [selectedItem, setSelectedItem] = useState( + (value as SelectOption) || null, + ); const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownPosition, setDropdownPosition] = useState<'top' | 'bottom'>( 'bottom', @@ -166,22 +167,6 @@ export const Select = ({ } }, [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) => { setSelectedItem(selectedItem); setInputValue(selectedItem ? selectedItem.label : ''); @@ -194,9 +179,9 @@ export const Select = ({ addSelectedItem, removeSelectedItem, selectedItems, - setSelectedItems, reset, } = useMultipleSelection({ + selectedItems: multiple ? (value as SelectOption[]) : [], onSelectedItemsChange: multiple ? undefined : ({ selectedItems }) => { @@ -234,6 +219,7 @@ export const Select = ({ openMenu, } = useCombobox({ items: filteredItems, + selectedItem: multiple ? null : (value as SelectOption) || null, // @ts-expect-error – there are two params but we don't need the second one isItemDisabled: (item) => item.disabled, onInputValueChange: ({ inputValue = '' }) => setInputValue(inputValue), @@ -265,16 +251,12 @@ export const Select = ({ 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 itemToString: (item) => (item && !multiple ? item.label : ''), }); - const isSelected = useCallback( - (item: SelectOption) => - multiple ? selectedItems.includes(item) : selectedItem === item, - [selectedItems, selectedItem, multiple], - ); + const isSelected = (item: SelectOption) => + multiple ? selectedItems.includes(item) : selectedItem === item; const handleClear = (e: MouseEvent) => { e.stopPropagation(); @@ -336,6 +318,7 @@ export const Select = ({ const isMultipleHasValue = multiple && selectedItems.length > 0; const isMultipleHasValueButNotSearchable = multiple && !searchable && selectedItems.length > 0; + const displayPlaceholder = useMemo(() => { if (hideValues && isMultipleHasValue) { return `${selectedItems.length} selected`; @@ -391,6 +374,8 @@ export const Select = ({ 'w-6': isMultipleHasValueButNotSearchable && !hideValues, // Add margin to the X icon 'ml-6': isMultipleHasValueButNotSearchable && clearable, + // Add padding if there's a left icon + 'pl-7': leftIcon, }, )} /> diff --git a/packages/frontend/src/pages/org-slug/projects/create/index.tsx b/packages/frontend/src/pages/org-slug/projects/create/index.tsx index 1c86ee3b..49eeed96 100644 --- a/packages/frontend/src/pages/org-slug/projects/create/index.tsx +++ b/packages/frontend/src/pages/org-slug/projects/create/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; 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 { useOctokit } from 'context/OctokitContext'; import { Heading } from 'components/shared/Heading'; @@ -13,8 +13,8 @@ const NewProject = () => { return isAuth ? ( <>
- - Start with template + + Start with a template
{templates.map((template) => { @@ -28,7 +28,7 @@ const NewProject = () => { })}
- + Import a repository