diff --git a/packages/shared/src/components/fields/Autocomplete.tsx b/packages/shared/src/components/fields/Autocomplete.tsx index 3f44351e10..73532b77e6 100644 --- a/packages/shared/src/components/fields/Autocomplete.tsx +++ b/packages/shared/src/components/fields/Autocomplete.tsx @@ -12,10 +12,11 @@ import { Image } from '../image/Image'; type AutocompleteOption = { value: string; label: string; image?: string }; interface AutocompleteProps - extends Omit { + extends Omit { name: string; onChange: (value: string) => void; onSelect: (value: string) => void; + onBlur?: () => void; selectedValue?: string; options: Array; isLoading: boolean; @@ -29,6 +30,7 @@ const Autocomplete = ({ label, onChange, onSelect, + onBlur: onBlurProp, selectedValue, defaultValue, resetOnBlur = true, @@ -69,6 +71,7 @@ const Autocomplete = ({ if (resetOnBlur) { setInput(options.find((opt) => opt.value === selectedValue)?.label || ''); } + onBlurProp?.(); }; const isSecondaryField = restProps.fieldType === 'secondary'; diff --git a/packages/shared/src/components/fields/ControlledTextField.tsx b/packages/shared/src/components/fields/ControlledTextField.tsx index b07493c322..98a043c9d2 100644 --- a/packages/shared/src/components/fields/ControlledTextField.tsx +++ b/packages/shared/src/components/fields/ControlledTextField.tsx @@ -12,6 +12,7 @@ type ControlledTextFieldProps = Pick< | 'hint' | 'fieldType' | 'className' + | 'readOnly' >; const ControlledTextField = ({ diff --git a/packages/shared/src/features/profile/components/ProfileGithubRepository.tsx b/packages/shared/src/features/profile/components/ProfileGithubRepository.tsx index 2eca6bdc4e..0bc481aed0 100644 --- a/packages/shared/src/features/profile/components/ProfileGithubRepository.tsx +++ b/packages/shared/src/features/profile/components/ProfileGithubRepository.tsx @@ -61,6 +61,35 @@ const ProfileGithubRepository = ({ } }; + const handleBlur = () => { + const currentSearch = repositorySearch?.trim(); + if (!currentSearch) { + return; + } + + // If a GitHub repo is already selected and matches the search, keep it + const repoFullName = repository?.owner + ? `${repository.owner}/${repository.name}` + : repository?.name; + if (repository?.id && repoFullName === currentSearch) { + return; + } + + // Create custom repository from the search input + const hasSlash = currentSearch.includes('/'); + const [owner, repoName] = hasSlash + ? currentSearch.split('/', 2) + : [null, currentSearch]; + + setValue('repository', { + id: null, + owner: owner || null, + name: repoName || currentSearch, + url: null, + image: null, + }); + }; + const [debouncedQuery] = useDebounceFn((q) => handleSearch(q), 300); const repositoryFullName = repository?.owner @@ -103,6 +132,7 @@ const ProfileGithubRepository = ({ defaultValue={repositorySearch || repositoryFullName || ''} onChange={(value) => debouncedQuery(value)} onSelect={(value) => handleSelect(value)} + onBlur={handleBlur} options={options} selectedValue={repository?.id} label={label} diff --git a/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.spec.tsx b/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.spec.tsx index d19051e735..68dd40cd0a 100644 --- a/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.spec.tsx +++ b/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.spec.tsx @@ -40,6 +40,8 @@ const FormWrapper: React.FC = ({ endedAt: null, url: '', description: '', + repository: null, + repositorySearch: '', ...defaultValues, }, }); @@ -214,6 +216,89 @@ describe('UserProjectExperienceForm', () => { expect(screen.getByText('End date*')).toBeInTheDocument(); }); + it('should display Repository URL field for OpenSource type', () => { + const { container } = render( + + + , + ); + + // Repository URL field should be present for OpenSource + expect(screen.getByText('Repository URL*')).toBeInTheDocument(); + + // The URL field should use repository.url name + const urlInput = getFieldByName(container, 'repository.url'); + expect(urlInput).toBeInTheDocument(); + }); + + it('should show Repository URL as read-only when GitHub repository is selected', () => { + const { container } = render( + + + , + ); + + // Label should not have asterisk when GitHub repo is selected + expect(screen.getByText('Repository URL')).toBeInTheDocument(); + expect(screen.queryByText('Repository URL*')).not.toBeInTheDocument(); + + // The URL field should be read-only + const urlInput = getFieldByName(container, 'repository.url'); + expect(urlInput).toHaveAttribute('readonly'); + }); + + it('should show Repository URL as editable when custom repository is entered', () => { + const { container } = render( + + + , + ); + + // Label should have asterisk for custom repo (URL is required) + expect(screen.getByText('Repository URL*')).toBeInTheDocument(); + + // The URL field should NOT be read-only + const urlInput = getFieldByName(container, 'repository.url'); + expect(urlInput).not.toHaveAttribute('readonly'); + }); + + it('should not show Publication URL field for OpenSource type', () => { + const { container } = render( + + + , + ); + + // Should not have Publication URL label + expect(screen.queryByText('Publication URL')).not.toBeInTheDocument(); + + // Should not have url field (only repository.url) + const urlInput = getFieldByName(container, 'url'); + expect(urlInput).not.toBeInTheDocument(); + }); + it('should display all required field indicators', () => { render( 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 6e9b4f8af4..7a286c9bba 100644 --- a/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx +++ b/packages/shared/src/features/profile/components/experience/forms/UserProjectExperienceForm.tsx @@ -33,7 +33,7 @@ const getFormCopy = (type: UserExperienceType): FormCopy => { 'Check if you are still actively contributing to this open-source project.', company: 'Repository*', startedtLabel: 'Active from', - urlLabel: '', + urlLabel: 'Repository URL', }; } @@ -52,7 +52,11 @@ const UserProjectExperienceForm = () => { const { watch } = useFormContext(); const type = watch('type') as UserExperienceType; const current = watch('current'); + const repository = watch('repository'); const copy = useMemo(() => getFormCopy(type), [type]); + + const isGitHubRepository = !!repository?.id; + const isOpenSource = type === UserExperienceType.OpenSource; return (
@@ -63,11 +67,21 @@ const UserProjectExperienceForm = () => { fieldType="secondary" className={profileSecondaryFieldStyles} /> - {type === UserExperienceType.OpenSource ? ( - + {isOpenSource ? ( + <> + + + ) : ( {
- {type !== UserExperienceType.OpenSource && ( + {!isOpenSource && ( { // Validation should fail for undefined startedAt expect(isValid).toBe(false); }); + + it('should validate repository with nullable id for custom repositories', async () => { + const openSourceExperience: BaseUserExperience & { + repository?: { + id: string | null; + owner: string | null; + name: string; + url: string; + image: string | null; + }; + } = { + type: UserExperienceType.OpenSource, + title: 'Open Source Contributor', + description: 'Contributing to projects', + startedAt: new Date('2023-01-01'), + current: true, + repository: { + id: null, // Custom repository has null id + owner: 'myorg', + name: 'myrepo', + url: 'https://gitlab.com/myorg/myrepo', + image: null, + }, + }; + + const { result } = renderHook( + () => useUserExperienceForm({ defaultValues: openSourceExperience }), + { wrapper: createWrapper() }, + ); + + await act(async () => { + const isValid = await result.current.methods.trigger('repository'); + // Should be valid even with null id + expect(isValid).toBe(true); + }); + }); + + it('should validate repository with GitHub id', async () => { + const openSourceExperience: BaseUserExperience & { + repository?: { + id: string | null; + owner: string | null; + name: string; + url: string; + image: string | null; + }; + } = { + type: UserExperienceType.OpenSource, + title: 'React Contributor', + description: 'Contributing to React', + startedAt: new Date('2023-01-01'), + current: true, + repository: { + id: '10270250', // GitHub repository has string id + owner: 'facebook', + name: 'react', + url: 'https://github.com/facebook/react', + image: 'https://avatars.githubusercontent.com/u/69631?v=4', + }, + }; + + const { result } = renderHook( + () => useUserExperienceForm({ defaultValues: openSourceExperience }), + { wrapper: createWrapper() }, + ); + + await act(async () => { + const isValid = await result.current.methods.trigger('repository'); + expect(isValid).toBe(true); + }); + }); + + it('should require repository URL even for custom repositories', async () => { + const openSourceExperience: BaseUserExperience & { + repository?: { + id: string | null; + owner: string | null; + name: string; + url: string | null; + image: string | null; + }; + } = { + type: UserExperienceType.OpenSource, + title: 'Open Source Contributor', + startedAt: new Date('2023-01-01'), + current: true, + repository: { + id: null, + owner: 'myorg', + name: 'myrepo', + url: null, // Missing URL should fail validation + image: null, + }, + }; + + const { result } = renderHook( + () => useUserExperienceForm({ defaultValues: openSourceExperience }), + { wrapper: createWrapper() }, + ); + + await act(async () => { + const isValid = await result.current.methods.trigger('repository'); + // Should be invalid without URL + expect(isValid).toBe(false); + }); + }); }); diff --git a/packages/shared/src/hooks/useUserExperienceForm.ts b/packages/shared/src/hooks/useUserExperienceForm.ts index c0ad29cf0f..df5d1aa346 100644 --- a/packages/shared/src/hooks/useUserExperienceForm.ts +++ b/packages/shared/src/hooks/useUserExperienceForm.ts @@ -28,10 +28,11 @@ import useLogEventOnce from './log/useLogEventOnce'; const repositorySchema = z .object({ - id: z.string().min(1), + id: z.string().min(1).nullish(), + owner: z.string().max(100).nullish(), name: z.string().min(1).max(200), url: z.url(), - image: z.url(), + image: z.url().nullish(), }) .nullish();