From 0b904732f92cea514e5c3fdf23141c7b3eb35772 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 21 Jan 2026 15:09:28 +0100 Subject: [PATCH 1/2] feat: repository search --- .../components/ProfileGithubRepository.tsx | 119 ++++++++++++++++++ .../experience/UserExperienceItem.tsx | 22 +++- .../experience/UserExperiencesGroupedList.tsx | 6 +- .../forms/UserProjectExperienceForm.tsx | 38 +++--- packages/shared/src/graphql/autocomplete.ts | 31 +++++ packages/shared/src/graphql/user/profile.ts | 15 +++ .../shared/src/hooks/useUserExperienceForm.ts | 11 ++ .../settings/profile/experience/edit.tsx | 1 + 8 files changed, 222 insertions(+), 21 deletions(-) create mode 100644 packages/shared/src/features/profile/components/ProfileGithubRepository.tsx diff --git a/packages/shared/src/features/profile/components/ProfileGithubRepository.tsx b/packages/shared/src/features/profile/components/ProfileGithubRepository.tsx new file mode 100644 index 0000000000..5d33c78e04 --- /dev/null +++ b/packages/shared/src/features/profile/components/ProfileGithubRepository.tsx @@ -0,0 +1,119 @@ +import React, { useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useQuery } from '@tanstack/react-query'; +import Autocomplete from '../../../components/fields/Autocomplete'; +import { generateQueryKey, RequestKey } from '../../../lib/query'; +import { getAutocompleteGithubRepositories } from '../../../graphql/autocomplete'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import useDebounceFn from '../../../hooks/useDebounceFn'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../components/typography/Typography'; + +type ProfileGithubRepositoryProps = { + name: string; + label?: string; +}; + +const ProfileGithubRepository = ({ + name, + label = 'GitHub Repository', +}: ProfileGithubRepositoryProps) => { + const { user } = useAuthContext(); + const { + setValue, + watch, + formState: { errors }, + } = useFormContext(); + const repositorySearch = watch(name); + const repository = watch('repository'); + const { data, isLoading } = useQuery({ + queryKey: generateQueryKey( + RequestKey.Autocomplete, + user, + 'github-repository', + repositorySearch, + ), + queryFn: () => getAutocompleteGithubRepositories(repositorySearch), + enabled: !!repositorySearch && repositorySearch.length >= 2, + }); + + const handleSearch = (query: string) => { + if (query === '') { + setValue('repository', null); + } + setValue(name, query); + }; + + const handleSelect = (value: string) => { + const selectedRepo = data?.find((repo) => repo.id === value); + if (selectedRepo) { + setValue('repository', { + id: selectedRepo.id, + name: selectedRepo.fullName, + url: selectedRepo.url, + image: selectedRepo.image, + }); + setValue(name, selectedRepo.fullName); + } + }; + + const [debouncedQuery] = useDebounceFn((q) => handleSearch(q), 300); + + // Include saved repository in options so Autocomplete can display its image + const options = useMemo(() => { + const searchResults = + data?.map((repo) => ({ + image: repo.image, + label: repo.fullName, + value: repo.id, + })) || []; + + // Add saved repository if not already in search results + if (repository?.id) { + const existsInResults = searchResults.some( + (opt) => opt.value === repository.id, + ); + if (!existsInResults) { + return [ + { + image: repository.image, + label: repository.name, + value: repository.id, + }, + ...searchResults, + ]; + } + } + + return searchResults; + }, [data, repository]); + + return ( +
+ debouncedQuery(value)} + onSelect={(value) => handleSelect(value)} + options={options} + selectedValue={repository?.id} + label={label} + isLoading={isLoading} + resetOnBlur={false} + /> + {errors[name] && ( + + {errors[name]?.message as string} + + )} +
+ ); +}; + +export default ProfileGithubRepository; diff --git a/packages/shared/src/features/profile/components/experience/UserExperienceItem.tsx b/packages/shared/src/features/profile/components/experience/UserExperienceItem.tsx index 3b085ad984..63bb7b1aa3 100644 --- a/packages/shared/src/features/profile/components/experience/UserExperienceItem.tsx +++ b/packages/shared/src/features/profile/components/experience/UserExperienceItem.tsx @@ -76,6 +76,8 @@ export function UserExperienceItem({ endedAt, subtitle, image, + repository, + type, } = experience; const { skills, location, locationType, customLocation } = experience as UserExperienceWork; @@ -101,12 +103,16 @@ export function UserExperienceItem({ isWorkExperience && isExperienceVerified && !grouped; const shouldSwapCopies = experience.type === UserExperienceType.Education && !grouped; + const isOpenSource = experience.type === UserExperienceType.OpenSource; + + const companyOrRepoName = isOpenSource + ? repository?.name || company?.name || customCompanyName + : company?.name || customCompanyName; + const primaryCopy = shouldSwapCopies ? company?.name || customCompanyName : title; - const secondaryCopy = shouldSwapCopies - ? title - : company?.name || customCompanyName; + const secondaryCopy = shouldSwapCopies ? title : companyOrRepoName; const dateRange = formatDateRange(startedAt, endedAt); const loc = getDisplayLocation( @@ -128,7 +134,11 @@ export function UserExperienceItem({ )} {editUrl && ( @@ -189,8 +199,8 @@ export function UserExperienceItem({ )} {shouldShowVerifiedBadge && } - {url && ( - + {(url || repository?.url) && ( + diff --git a/packages/shared/src/features/profile/components/experience/UserExperiencesGroupedList.tsx b/packages/shared/src/features/profile/components/experience/UserExperiencesGroupedList.tsx index b6ac17662f..62867e53be 100644 --- a/packages/shared/src/features/profile/components/experience/UserExperiencesGroupedList.tsx +++ b/packages/shared/src/features/profile/components/experience/UserExperiencesGroupedList.tsx @@ -93,7 +93,11 @@ export function UserExperiencesGroupedList({
diff --git a/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx b/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx index 14b76c4357..a1e3294093 100644 --- a/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx +++ b/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import ControlledTextField from '../../../../../components/fields/ControlledTextField'; import ProfileCompany from '../../ProfileCompany'; +import ProfileGithubRepository from '../../ProfileGithubRepository'; import { HorizontalSeparator } from '../../../../../components/utilities'; import { Typography, @@ -26,13 +27,13 @@ type FormCopy = { const getFormCopy = (type: UserExperienceType): FormCopy => { if (type === UserExperienceType.OpenSource) { return { - titlePlaceholder: 'Ex: Name of the repository', + titlePlaceholder: 'Ex: Contributor', switchLabel: 'Active open-source contribution', switchDescription: 'Check if you are still actively contributing to this open-source project.', company: 'Repository*', startedtLabel: 'Active from', - urlLabel: 'Repository URL', + urlLabel: '', }; } @@ -62,11 +63,18 @@ const UserProjectExperienceForm = () => { fieldType="secondary" className={profileSecondaryFieldStyles} /> - + {type === UserExperienceType.OpenSource ? ( + + ) : ( + + )}
{
- + {type !== UserExperienceType.OpenSource && ( + + )}
Description diff --git a/packages/shared/src/graphql/autocomplete.ts b/packages/shared/src/graphql/autocomplete.ts index be47127c02..d61ece35b9 100644 --- a/packages/shared/src/graphql/autocomplete.ts +++ b/packages/shared/src/graphql/autocomplete.ts @@ -66,3 +66,34 @@ export const getAutocompleteCompanies = async ( }); return res.autocompleteCompany; }; + +export interface GitHubRepository { + id: string; + fullName: string; + url: string; + image: string; + description?: string | null; +} + +const AUTOCOMPLETE_GITHUB_REPOSITORY_QUERY = gql` + query AutocompleteGithubRepository($query: String!, $limit: Int) { + autocompleteGithubRepository(query: $query, limit: $limit) { + id + fullName + url + image + description + } + } +`; + +export const getAutocompleteGithubRepositories = async ( + query: string, + limit = 10, +): Promise => { + const res = await gqlClient.request(AUTOCOMPLETE_GITHUB_REPOSITORY_QUERY, { + query, + limit, + }); + return res.autocompleteGithubRepository; +}; diff --git a/packages/shared/src/graphql/user/profile.ts b/packages/shared/src/graphql/user/profile.ts index 6acb8aec05..ea3e7b5179 100644 --- a/packages/shared/src/graphql/user/profile.ts +++ b/packages/shared/src/graphql/user/profile.ts @@ -26,6 +26,7 @@ const excludedProperties = [ 'company', 'customLocation', 'image', + 'repositorySearch', ]; const USER_EXPERIENCE_FRAGMENT = gql` @@ -67,6 +68,12 @@ const USER_EXPERIENCE_FRAGMENT = gql` subdivision country } + repository { + id + name + url + image + } } `; @@ -168,6 +175,13 @@ export enum UserExperienceType { OpenSource = 'opensource', } +export interface Repository { + id: string; + name: string; + url: string; + image: string; +} + export interface UserExperience { id: string; type: UserExperienceType; @@ -184,6 +198,7 @@ export interface UserExperience { url?: string | null; verified?: boolean | null; customLocation?: Partial>; + repository?: Repository | null; } interface UserSkill { diff --git a/packages/shared/src/hooks/useUserExperienceForm.ts b/packages/shared/src/hooks/useUserExperienceForm.ts index bec8972a35..c0ad29cf0f 100644 --- a/packages/shared/src/hooks/useUserExperienceForm.ts +++ b/packages/shared/src/hooks/useUserExperienceForm.ts @@ -26,6 +26,15 @@ import { useLogContext } from '../contexts/LogContext'; import { LogEvent } from '../lib/log'; import useLogEventOnce from './log/useLogEventOnce'; +const repositorySchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1).max(200), + url: z.url(), + image: z.url(), + }) + .nullish(); + export const userExperienceInputBaseSchema = z .object({ type: z.enum(UserExperienceType), @@ -60,6 +69,8 @@ export const userExperienceInputBaseSchema = z ]) .optional() .default(null), + repository: repositorySchema, + repositorySearch: z.string().optional(), }) .refine( (data) => { diff --git a/packages/webapp/pages/settings/profile/experience/edit.tsx b/packages/webapp/pages/settings/profile/experience/edit.tsx index d7db6ee45b..e283e56cd7 100644 --- a/packages/webapp/pages/settings/profile/experience/edit.tsx +++ b/packages/webapp/pages/settings/profile/experience/edit.tsx @@ -140,6 +140,7 @@ export const getServerSideProps: GetServerSideProps = async ({ skills: result.skills?.map((skill) => skill.value), location: result.location, externalLocationId: result.location?.externalId || '', + repositorySearch: result.repository?.name || '', }, }, }; From a0f6f906fee1540270e0d02d93831e171ad05c02 Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 21 Jan 2026 15:28:10 +0100 Subject: [PATCH 2/2] update placeholder --- .../components/experience/forms/UserProjectExperienceForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx b/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx index a1e3294093..6e9b4f8af4 100644 --- a/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx +++ b/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx @@ -111,7 +111,7 @@ const UserProjectExperienceForm = () => {