Skip to content

Commit f034a63

Browse files
feat: custom repos (#5352)
1 parent b830b09 commit f034a63

8 files changed

Lines changed: 252 additions & 12 deletions

File tree

packages/shared/src/components/fields/Autocomplete.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import { Image } from '../image/Image';
1212
type AutocompleteOption = { value: string; label: string; image?: string };
1313

1414
interface AutocompleteProps
15-
extends Omit<TextFieldProps, 'inputId' | 'onChange' | 'onSelect'> {
15+
extends Omit<TextFieldProps, 'inputId' | 'onChange' | 'onSelect' | 'onBlur'> {
1616
name: string;
1717
onChange: (value: string) => void;
1818
onSelect: (value: string) => void;
19+
onBlur?: () => void;
1920
selectedValue?: string;
2021
options: Array<AutocompleteOption>;
2122
isLoading: boolean;
@@ -29,6 +30,7 @@ const Autocomplete = ({
2930
label,
3031
onChange,
3132
onSelect,
33+
onBlur: onBlurProp,
3234
selectedValue,
3335
defaultValue,
3436
resetOnBlur = true,
@@ -69,6 +71,7 @@ const Autocomplete = ({
6971
if (resetOnBlur) {
7072
setInput(options.find((opt) => opt.value === selectedValue)?.label || '');
7173
}
74+
onBlurProp?.();
7275
};
7376

7477
const isSecondaryField = restProps.fieldType === 'secondary';

packages/shared/src/components/fields/ControlledTextField.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type ControlledTextFieldProps = Pick<
1212
| 'hint'
1313
| 'fieldType'
1414
| 'className'
15+
| 'readOnly'
1516
>;
1617

1718
const ControlledTextField = ({

packages/shared/src/features/profile/components/ProfileGithubRepository.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,35 @@ const ProfileGithubRepository = ({
6161
}
6262
};
6363

64+
const handleBlur = () => {
65+
const currentSearch = repositorySearch?.trim();
66+
if (!currentSearch) {
67+
return;
68+
}
69+
70+
// If a GitHub repo is already selected and matches the search, keep it
71+
const repoFullName = repository?.owner
72+
? `${repository.owner}/${repository.name}`
73+
: repository?.name;
74+
if (repository?.id && repoFullName === currentSearch) {
75+
return;
76+
}
77+
78+
// Create custom repository from the search input
79+
const hasSlash = currentSearch.includes('/');
80+
const [owner, repoName] = hasSlash
81+
? currentSearch.split('/', 2)
82+
: [null, currentSearch];
83+
84+
setValue('repository', {
85+
id: null,
86+
owner: owner || null,
87+
name: repoName || currentSearch,
88+
url: null,
89+
image: null,
90+
});
91+
};
92+
6493
const [debouncedQuery] = useDebounceFn<string>((q) => handleSearch(q), 300);
6594

6695
const repositoryFullName = repository?.owner
@@ -103,6 +132,7 @@ const ProfileGithubRepository = ({
103132
defaultValue={repositorySearch || repositoryFullName || ''}
104133
onChange={(value) => debouncedQuery(value)}
105134
onSelect={(value) => handleSelect(value)}
135+
onBlur={handleBlur}
106136
options={options}
107137
selectedValue={repository?.id}
108138
label={label}

packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.spec.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ const FormWrapper: React.FC<FormWrapperProps> = ({
4040
endedAt: null,
4141
url: '',
4242
description: '',
43+
repository: null,
44+
repositorySearch: '',
4345
...defaultValues,
4446
},
4547
});
@@ -214,6 +216,89 @@ describe('UserProjectExperienceForm', () => {
214216
expect(screen.getByText('End date*')).toBeInTheDocument();
215217
});
216218

219+
it('should display Repository URL field for OpenSource type', () => {
220+
const { container } = render(
221+
<FormWrapper defaultValues={{ type: UserExperienceType.OpenSource }}>
222+
<UserProjectExperienceForm />
223+
</FormWrapper>,
224+
);
225+
226+
// Repository URL field should be present for OpenSource
227+
expect(screen.getByText('Repository URL*')).toBeInTheDocument();
228+
229+
// The URL field should use repository.url name
230+
const urlInput = getFieldByName(container, 'repository.url');
231+
expect(urlInput).toBeInTheDocument();
232+
});
233+
234+
it('should show Repository URL as read-only when GitHub repository is selected', () => {
235+
const { container } = render(
236+
<FormWrapper
237+
defaultValues={{
238+
type: UserExperienceType.OpenSource,
239+
repository: {
240+
id: 'github-repo-123',
241+
owner: 'facebook',
242+
name: 'react',
243+
url: 'https://github.com/facebook/react',
244+
image: 'https://example.com/image.png',
245+
},
246+
}}
247+
>
248+
<UserProjectExperienceForm />
249+
</FormWrapper>,
250+
);
251+
252+
// Label should not have asterisk when GitHub repo is selected
253+
expect(screen.getByText('Repository URL')).toBeInTheDocument();
254+
expect(screen.queryByText('Repository URL*')).not.toBeInTheDocument();
255+
256+
// The URL field should be read-only
257+
const urlInput = getFieldByName(container, 'repository.url');
258+
expect(urlInput).toHaveAttribute('readonly');
259+
});
260+
261+
it('should show Repository URL as editable when custom repository is entered', () => {
262+
const { container } = render(
263+
<FormWrapper
264+
defaultValues={{
265+
type: UserExperienceType.OpenSource,
266+
repository: {
267+
id: null,
268+
owner: 'myorg',
269+
name: 'myrepo',
270+
url: null,
271+
image: null,
272+
},
273+
}}
274+
>
275+
<UserProjectExperienceForm />
276+
</FormWrapper>,
277+
);
278+
279+
// Label should have asterisk for custom repo (URL is required)
280+
expect(screen.getByText('Repository URL*')).toBeInTheDocument();
281+
282+
// The URL field should NOT be read-only
283+
const urlInput = getFieldByName(container, 'repository.url');
284+
expect(urlInput).not.toHaveAttribute('readonly');
285+
});
286+
287+
it('should not show Publication URL field for OpenSource type', () => {
288+
const { container } = render(
289+
<FormWrapper defaultValues={{ type: UserExperienceType.OpenSource }}>
290+
<UserProjectExperienceForm />
291+
</FormWrapper>,
292+
);
293+
294+
// Should not have Publication URL label
295+
expect(screen.queryByText('Publication URL')).not.toBeInTheDocument();
296+
297+
// Should not have url field (only repository.url)
298+
const urlInput = getFieldByName(container, 'url');
299+
expect(urlInput).not.toBeInTheDocument();
300+
});
301+
217302
it('should display all required field indicators', () => {
218303
render(
219304
<FormWrapper>

packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const getFormCopy = (type: UserExperienceType): FormCopy => {
3333
'Check if you are still actively contributing to this open-source project.',
3434
company: 'Repository*',
3535
startedtLabel: 'Active from',
36-
urlLabel: '',
36+
urlLabel: 'Repository URL',
3737
};
3838
}
3939

@@ -52,7 +52,11 @@ const UserProjectExperienceForm = () => {
5252
const { watch } = useFormContext();
5353
const type = watch('type') as UserExperienceType;
5454
const current = watch('current');
55+
const repository = watch('repository');
5556
const copy = useMemo(() => getFormCopy(type), [type]);
57+
58+
const isGitHubRepository = !!repository?.id;
59+
const isOpenSource = type === UserExperienceType.OpenSource;
5660
return (
5761
<div className="flex flex-col gap-6">
5862
<div className="flex flex-col gap-2">
@@ -63,11 +67,21 @@ const UserProjectExperienceForm = () => {
6367
fieldType="secondary"
6468
className={profileSecondaryFieldStyles}
6569
/>
66-
{type === UserExperienceType.OpenSource ? (
67-
<ProfileGithubRepository
68-
name="repositorySearch"
69-
label={copy.company}
70-
/>
70+
{isOpenSource ? (
71+
<>
72+
<ProfileGithubRepository
73+
name="repositorySearch"
74+
label={copy.company}
75+
/>
76+
<ControlledTextField
77+
name="repository.url"
78+
label={`${copy.urlLabel}${isGitHubRepository ? '' : '*'}`}
79+
placeholder="Ex: https://github.com/owner/repo"
80+
fieldType="secondary"
81+
className={profileSecondaryFieldStyles}
82+
readOnly={isGitHubRepository}
83+
/>
84+
</>
7185
) : (
7286
<ProfileCompany
7387
name="customCompanyName"
@@ -107,7 +121,7 @@ const UserProjectExperienceForm = () => {
107121
</div>
108122
<HorizontalSeparator />
109123
<div className="flex flex-col gap-2">
110-
{type !== UserExperienceType.OpenSource && (
124+
{!isOpenSource && (
111125
<ControlledTextField
112126
name="url"
113127
label={copy.urlLabel}

packages/shared/src/graphql/user/profile.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,11 @@ export enum UserExperienceType {
177177
}
178178

179179
export interface Repository {
180-
id: string;
180+
id?: string | null;
181181
owner?: string | null;
182182
name: string;
183183
url: string;
184-
image: string;
184+
image?: string | null;
185185
}
186186

187187
export interface UserExperience {

packages/shared/src/hooks/useUserExperienceForm.spec.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,4 +316,110 @@ describe('useUserExperienceForm', () => {
316316
// Validation should fail for undefined startedAt
317317
expect(isValid).toBe(false);
318318
});
319+
320+
it('should validate repository with nullable id for custom repositories', async () => {
321+
const openSourceExperience: BaseUserExperience & {
322+
repository?: {
323+
id: string | null;
324+
owner: string | null;
325+
name: string;
326+
url: string;
327+
image: string | null;
328+
};
329+
} = {
330+
type: UserExperienceType.OpenSource,
331+
title: 'Open Source Contributor',
332+
description: 'Contributing to projects',
333+
startedAt: new Date('2023-01-01'),
334+
current: true,
335+
repository: {
336+
id: null, // Custom repository has null id
337+
owner: 'myorg',
338+
name: 'myrepo',
339+
url: 'https://gitlab.com/myorg/myrepo',
340+
image: null,
341+
},
342+
};
343+
344+
const { result } = renderHook(
345+
() => useUserExperienceForm({ defaultValues: openSourceExperience }),
346+
{ wrapper: createWrapper() },
347+
);
348+
349+
await act(async () => {
350+
const isValid = await result.current.methods.trigger('repository');
351+
// Should be valid even with null id
352+
expect(isValid).toBe(true);
353+
});
354+
});
355+
356+
it('should validate repository with GitHub id', async () => {
357+
const openSourceExperience: BaseUserExperience & {
358+
repository?: {
359+
id: string | null;
360+
owner: string | null;
361+
name: string;
362+
url: string;
363+
image: string | null;
364+
};
365+
} = {
366+
type: UserExperienceType.OpenSource,
367+
title: 'React Contributor',
368+
description: 'Contributing to React',
369+
startedAt: new Date('2023-01-01'),
370+
current: true,
371+
repository: {
372+
id: '10270250', // GitHub repository has string id
373+
owner: 'facebook',
374+
name: 'react',
375+
url: 'https://github.com/facebook/react',
376+
image: 'https://avatars.githubusercontent.com/u/69631?v=4',
377+
},
378+
};
379+
380+
const { result } = renderHook(
381+
() => useUserExperienceForm({ defaultValues: openSourceExperience }),
382+
{ wrapper: createWrapper() },
383+
);
384+
385+
await act(async () => {
386+
const isValid = await result.current.methods.trigger('repository');
387+
expect(isValid).toBe(true);
388+
});
389+
});
390+
391+
it('should require repository URL even for custom repositories', async () => {
392+
const openSourceExperience: BaseUserExperience & {
393+
repository?: {
394+
id: string | null;
395+
owner: string | null;
396+
name: string;
397+
url: string | null;
398+
image: string | null;
399+
};
400+
} = {
401+
type: UserExperienceType.OpenSource,
402+
title: 'Open Source Contributor',
403+
startedAt: new Date('2023-01-01'),
404+
current: true,
405+
repository: {
406+
id: null,
407+
owner: 'myorg',
408+
name: 'myrepo',
409+
url: null, // Missing URL should fail validation
410+
image: null,
411+
},
412+
};
413+
414+
const { result } = renderHook(
415+
() => useUserExperienceForm({ defaultValues: openSourceExperience }),
416+
{ wrapper: createWrapper() },
417+
);
418+
419+
await act(async () => {
420+
const isValid = await result.current.methods.trigger('repository');
421+
// Should be invalid without URL
422+
expect(isValid).toBe(false);
423+
});
424+
});
319425
});

packages/shared/src/hooks/useUserExperienceForm.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ import useLogEventOnce from './log/useLogEventOnce';
2828

2929
const repositorySchema = z
3030
.object({
31-
id: z.string().min(1),
31+
id: z.string().min(1).nullish(),
32+
owner: z.string().max(100).nullish(),
3233
name: z.string().min(1).max(200),
3334
url: z.url(),
34-
image: z.url(),
35+
image: z.url().nullish(),
3536
})
3637
.nullish();
3738

0 commit comments

Comments
 (0)