Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string>((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 (
<div className="flex flex-col gap-1">
<Autocomplete
name={name}
defaultValue={repositorySearch || repository?.name || ''}
onChange={(value) => debouncedQuery(value)}
onSelect={(value) => handleSelect(value)}
options={options}
selectedValue={repository?.id}
label={label}
isLoading={isLoading}
resetOnBlur={false}
/>
{errors[name] && (
<Typography
type={TypographyType.Caption1}
color={TypographyColor.StatusError}
>
{errors[name]?.message as string}
</Typography>
)}
</div>
);
};

export default ProfileGithubRepository;
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export function UserExperienceItem({
endedAt,
subtitle,
image,
repository,
type,
} = experience;
const { skills, location, locationType, customLocation } =
experience as UserExperienceWork;
Expand All @@ -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(
Expand All @@ -128,7 +134,11 @@ export function UserExperienceItem({
<Image
className="h-8 w-8 rounded-max object-cover"
type={ImageType.Organization}
src={company?.image || image}
src={
type === UserExperienceType.OpenSource
? repository?.image || company?.image
: company?.image || image
}
/>
)}
{editUrl && (
Expand Down Expand Up @@ -189,8 +199,8 @@ export function UserExperienceItem({
</button>
)}
{shouldShowVerifiedBadge && <VerifiedBadge />}
{url && (
<Link href={url} passHref>
{(url || repository?.url) && (
<Link href={repository?.url || url} passHref>
<a target="_blank">
<OpenLinkIcon className="size-4 text-text-secondary" />
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ export function UserExperiencesGroupedList({
<Image
className="h-8 w-8 rounded-max object-cover"
type={ImageType.Organization}
src={first.company?.image || first.image}
src={
experienceType === UserExperienceType.OpenSource
? first.repository?.image || first.company?.image || first.image
: first.company?.image || first.image
}
/>
<div className="flex flex-1 flex-col">
<div className="flex flex-wrap items-center gap-1">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: '',
};
}

Expand Down Expand Up @@ -62,11 +63,18 @@ const UserProjectExperienceForm = () => {
fieldType="secondary"
className={profileSecondaryFieldStyles}
/>
<ProfileCompany
name="customCompanyName"
label={copy.company}
type={AutocompleteType.Company}
/>
{type === UserExperienceType.OpenSource ? (
<ProfileGithubRepository
name="repositorySearch"
label={copy.company}
/>
) : (
<ProfileCompany
name="customCompanyName"
label={copy.company}
type={AutocompleteType.Company}
/>
)}
</div>
<HorizontalSeparator />
<CurrentExperienceSwitch
Expand Down Expand Up @@ -99,13 +107,15 @@ const UserProjectExperienceForm = () => {
</div>
<HorizontalSeparator />
<div className="flex flex-col gap-2">
<ControlledTextField
name="url"
label={copy.urlLabel}
placeholder="Ex: https://github.com/username/repo"
fieldType="secondary"
className={profileSecondaryFieldStyles}
/>
{type !== UserExperienceType.OpenSource && (
<ControlledTextField
name="url"
label={copy.urlLabel}
placeholder="Ex: https://example.com/page"
fieldType="secondary"
className={profileSecondaryFieldStyles}
/>
)}
<div className="flex flex-col gap-2">
<Typography type={TypographyType.Callout} bold>
Description
Expand Down
31 changes: 31 additions & 0 deletions packages/shared/src/graphql/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GitHubRepository[]> => {
const res = await gqlClient.request(AUTOCOMPLETE_GITHUB_REPOSITORY_QUERY, {
query,
limit,
});
return res.autocompleteGithubRepository;
};
15 changes: 15 additions & 0 deletions packages/shared/src/graphql/user/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const excludedProperties = [
'company',
'customLocation',
'image',
'repositorySearch',
];

const USER_EXPERIENCE_FRAGMENT = gql`
Expand Down Expand Up @@ -67,6 +68,12 @@ const USER_EXPERIENCE_FRAGMENT = gql`
subdivision
country
}
repository {
id
name
url
image
}
}
`;

Expand Down Expand Up @@ -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;
Expand All @@ -184,6 +198,7 @@ export interface UserExperience {
url?: string | null;
verified?: boolean | null;
customLocation?: Partial<Pick<TLocation, 'city' | 'subdivision' | 'country'>>;
repository?: Repository | null;
}

interface UserSkill {
Expand Down
11 changes: 11 additions & 0 deletions packages/shared/src/hooks/useUserExperienceForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -60,6 +69,8 @@ export const userExperienceInputBaseSchema = z
])
.optional()
.default(null),
repository: repositorySchema,
repositorySearch: z.string().optional(),
})
.refine(
(data) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const getServerSideProps: GetServerSideProps<PageProps> = async ({
skills: result.skills?.map((skill) => skill.value),
location: result.location,
externalLocationId: result.location?.externalId || '',
repositorySearch: result.repository?.name || '',
},
},
};
Expand Down