Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/shared/src/components/fields/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { Image } from '../image/Image';
type AutocompleteOption = { value: string; label: string; image?: string };

interface AutocompleteProps
extends Omit<TextFieldProps, 'inputId' | 'onChange' | 'onSelect'> {
extends Omit<TextFieldProps, 'inputId' | 'onChange' | 'onSelect' | 'onBlur'> {
name: string;
onChange: (value: string) => void;
onSelect: (value: string) => void;
onBlur?: () => void;
selectedValue?: string;
options: Array<AutocompleteOption>;
isLoading: boolean;
Expand All @@ -29,6 +30,7 @@ const Autocomplete = ({
label,
onChange,
onSelect,
onBlur: onBlurProp,
selectedValue,
defaultValue,
resetOnBlur = true,
Expand Down Expand Up @@ -69,6 +71,7 @@ const Autocomplete = ({
if (resetOnBlur) {
setInput(options.find((opt) => opt.value === selectedValue)?.label || '');
}
onBlurProp?.();
};

const isSecondaryField = restProps.fieldType === 'secondary';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type ControlledTextFieldProps = Pick<
| 'hint'
| 'fieldType'
| 'className'
| 'readOnly'
>;

const ControlledTextField = ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>((q) => handleSearch(q), 300);

const repositoryFullName = repository?.owner
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ const FormWrapper: React.FC<FormWrapperProps> = ({
endedAt: null,
url: '',
description: '',
repository: null,
repositorySearch: '',
...defaultValues,
},
});
Expand Down Expand Up @@ -214,6 +216,89 @@ describe('UserProjectExperienceForm', () => {
expect(screen.getByText('End date*')).toBeInTheDocument();
});

it('should display Repository URL field for OpenSource type', () => {
const { container } = render(
<FormWrapper defaultValues={{ type: UserExperienceType.OpenSource }}>
<UserProjectExperienceForm />
</FormWrapper>,
);

// 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(
<FormWrapper
defaultValues={{
type: UserExperienceType.OpenSource,
repository: {
id: 'github-repo-123',
owner: 'facebook',
name: 'react',
url: 'https://github.com/facebook/react',
image: 'https://example.com/image.png',
},
}}
>
<UserProjectExperienceForm />
</FormWrapper>,
);

// 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(
<FormWrapper
defaultValues={{
type: UserExperienceType.OpenSource,
repository: {
id: null,
owner: 'myorg',
name: 'myrepo',
url: null,
image: null,
},
}}
>
<UserProjectExperienceForm />
</FormWrapper>,
);

// 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(
<FormWrapper defaultValues={{ type: UserExperienceType.OpenSource }}>
<UserProjectExperienceForm />
</FormWrapper>,
);

// 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(
<FormWrapper>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}

Expand All @@ -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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
Expand All @@ -63,11 +67,21 @@ const UserProjectExperienceForm = () => {
fieldType="secondary"
className={profileSecondaryFieldStyles}
/>
{type === UserExperienceType.OpenSource ? (
<ProfileGithubRepository
name="repositorySearch"
label={copy.company}
/>
{isOpenSource ? (
<>
<ProfileGithubRepository
name="repositorySearch"
label={copy.company}
/>
<ControlledTextField
name="repository.url"
label={`${copy.urlLabel}${isGitHubRepository ? '' : '*'}`}
placeholder="Ex: https://github.com/owner/repo"
fieldType="secondary"
className={profileSecondaryFieldStyles}
readOnly={isGitHubRepository}
/>
</>
) : (
<ProfileCompany
name="customCompanyName"
Expand Down Expand Up @@ -107,7 +121,7 @@ const UserProjectExperienceForm = () => {
</div>
<HorizontalSeparator />
<div className="flex flex-col gap-2">
{type !== UserExperienceType.OpenSource && (
{!isOpenSource && (
<ControlledTextField
name="url"
label={copy.urlLabel}
Expand Down
4 changes: 2 additions & 2 deletions packages/shared/src/graphql/user/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,11 @@ export enum UserExperienceType {
}

export interface Repository {
id: string;
id?: string | null;
owner?: string | null;
name: string;
url: string;
image: string;
image?: string | null;
}

export interface UserExperience {
Expand Down
106 changes: 106 additions & 0 deletions packages/shared/src/hooks/useUserExperienceForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,4 +316,110 @@ describe('useUserExperienceForm', () => {
// 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);
});
});
});
5 changes: 3 additions & 2 deletions packages/shared/src/hooks/useUserExperienceForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down