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
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function CreateSharedPostModal({
currentPercentage={user?.profileCompletion?.percentage}
requiredPercentage={requiredPercentage}
description="Update your profile to share posts in squads."
profileCompletion={user?.profileCompletion}
buttonSize={ButtonSize.Small}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ProfileCompletionPostGate } from './ProfileCompletionPostGate';

describe('ProfileCompletionPostGate', () => {
it('should render missing profile items when profile completion is available', () => {
render(
<ProfileCompletionPostGate
currentPercentage={20}
requiredPercentage={70}
description="Add your profile details to keep post creation available."
profileCompletion={{
percentage: 20,
hasProfileImage: false,
hasHeadline: false,
hasExperienceLevel: true,
hasWork: false,
hasEducation: true,
}}
/>,
);

expect(
screen.getByText('Add Profile image, Headline and Work experience.'),
).toBeInTheDocument();
expect(
screen.queryByText('You are 50% away from the 70% requirement.'),
).not.toBeInTheDocument();
});

it('should fall back to the percentage requirement message when profile completion is missing', () => {
render(
<ProfileCompletionPostGate
currentPercentage={20}
requiredPercentage={70}
description="Add your profile details to keep post creation available."
/>,
);

expect(
screen.getByText('You are 50% away from the 70% requirement.'),
).toBeInTheDocument();
});

it('should keep the profile CTA destination unchanged', () => {
render(
<ProfileCompletionPostGate
currentPercentage={20}
requiredPercentage={70}
description="Add your profile details to keep post creation available."
/>,
);

expect(
screen.getByRole('link', { name: 'Complete profile' }),
).toHaveAttribute('href', '/settings/profile');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import {
} from '../../typography/Typography';
import Link from '../../utilities/Link';
import { settingsUrl } from '../../../lib/constants';
import type { ProfileCompletion } from '../../../lib/user';
import {
formatCompletionDescription,
getCompletionItems,
} from '../../../lib/profileCompletion';
import {
profileCompletionButtonBg,
profileCompletionCardBg,
Expand All @@ -21,6 +26,7 @@ interface ProfileCompletionPostGateProps {
currentPercentage?: number;
requiredPercentage?: number;
description: string;
profileCompletion?: ProfileCompletion;
compact?: boolean;
buttonSize?: ButtonSize;
}
Expand All @@ -30,14 +36,22 @@ export function ProfileCompletionPostGate({
currentPercentage = 0,
requiredPercentage = 0,
description,
profileCompletion,
compact = false,
buttonSize = ButtonSize.Medium,
}: ProfileCompletionPostGateProps): ReactElement {
const normalizedCurrent = Math.max(0, Math.min(100, currentPercentage));
const normalizedRequired = Math.max(0, Math.min(100, requiredPercentage));
const completionGap = Math.max(0, normalizedRequired - normalizedCurrent);
const incompleteItems = profileCompletion
? getCompletionItems(profileCompletion).filter((item) => !item.completed)
: [];
const missingItemsDescription =
incompleteItems.length > 0
? formatCompletionDescription(incompleteItems)
: null;
const requirementMessage =
normalizedRequired > 0
!missingItemsDescription && normalizedRequired > 0
? `You are ${completionGap}% away from the ${normalizedRequired}% requirement.`
: null;

Expand Down Expand Up @@ -77,6 +91,14 @@ export function ProfileCompletionPostGate({
</Typography>
</div>
</div>
{missingItemsDescription && (
<Typography
type={TypographyType.Footnote}
color={TypographyColor.Tertiary}
>
{missingItemsDescription}
</Typography>
)}
{requirementMessage && (
<Typography
type={TypographyType.Footnote}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,71 +9,21 @@ import {
} from '../../../../components/typography/Typography';
import { InfoIcon, MoveToIcon } from '../../../../components/icons';
import { IconSize } from '../../../../components/Icon';
import type { ProfileCompletion as ProfileCompletionData } from '../../../../lib/user';
import Link from '../../../../components/utilities/Link';
import { anchorDefaultRel } from '../../../../lib/strings';
import { webappUrl } from '../../../../lib/constants';
import { useAuthContext } from '../../../../contexts/AuthContext';
import CloseButton from '../../../../components/CloseButton';
import { ButtonSize } from '../../../../components/buttons/Button';
import { useProfileCompletionIndicator } from '../../../../hooks/profile/useProfileCompletionIndicator';

type CompletionItem = {
label: string;
completed: boolean;
redirectPath: string;
};
import {
formatCompletionDescription,
getCompletionItems,
} from '../../../../lib/profileCompletion';

type ProfileCompletionProps = {
className?: string;
};

const getCompletionItems = (
completion: ProfileCompletionData,
): CompletionItem[] => {
return [
{
label: 'Profile image',
completed: completion.hasProfileImage,
redirectPath: `${webappUrl}settings/profile`,
},
{
label: 'Headline',
completed: completion.hasHeadline,
redirectPath: `${webappUrl}settings/profile?field=bio`,
},
{
label: 'Experience level',
completed: completion.hasExperienceLevel,
redirectPath: `${webappUrl}settings/profile?field=experienceLevel`,
},
{
label: 'Work experience',
completed: completion.hasWork,
redirectPath: `${webappUrl}settings/profile/experience/work`,
},
{
label: 'Education',
completed: completion.hasEducation,
redirectPath: `${webappUrl}settings/profile/experience/education`,
},
];
};

const formatDescription = (incompleteItems: CompletionItem[]): string => {
if (incompleteItems.length === 0) {
return 'Profile completed!';
}

const labels = incompleteItems.map((item) => item.label);
const formattedList =
labels.length === 1
? labels[0]
: `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`;

return `Add ${formattedList}.`;
};

export const ProfileCompletion = ({
className,
}: ProfileCompletionProps): ReactElement | null => {
Expand All @@ -92,7 +42,7 @@ export const ProfileCompletion = ({
);

const description = useMemo(
() => formatDescription(incompleteItems),
() => formatCompletionDescription(incompleteItems),
[incompleteItems],
);

Expand Down
60 changes: 60 additions & 0 deletions packages/shared/src/lib/profileCompletion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
formatCompletionDescription,
getCompletionItems,
} from './profileCompletion';
import type { ProfileCompletion } from './user';

const profileCompletion: ProfileCompletion = {
percentage: 20,
hasProfileImage: false,
hasHeadline: false,
hasExperienceLevel: true,
hasWork: false,
hasEducation: true,
};

describe('profileCompletion', () => {
it('should return completion items in the expected priority order', () => {
expect(
getCompletionItems(profileCompletion).map((item) => item.label),
).toEqual([
'Profile image',
'Headline',
'Experience level',
'Work experience',
'Education',
]);
});

it('should return only incomplete completion items', () => {
expect(
getCompletionItems(profileCompletion)
.filter((item) => !item.completed)
.map((item) => item.label),
).toEqual(['Profile image', 'Headline', 'Work experience']);
});

it('should format a single incomplete item description', () => {
expect(
formatCompletionDescription([
{
label: 'Headline',
completed: false,
redirectPath: '/settings/profile?field=bio',
},
]),
).toBe('Add Headline.');
});

it('should format a multi-item incomplete description', () => {
expect(
formatCompletionDescription(
getCompletionItems(profileCompletion).filter((item) => !item.completed),
),
).toBe('Add Profile image, Headline and Work experience.');
});

it('should format the completed state description', () => {
expect(formatCompletionDescription([])).toBe('Profile completed!');
});
});
56 changes: 56 additions & 0 deletions packages/shared/src/lib/profileCompletion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ProfileCompletion } from './user';
import { webappUrl } from './constants';

export type CompletionItem = {
label: string;
completed: boolean;
redirectPath: string;
};

export const getCompletionItems = (
completion: ProfileCompletion,
): CompletionItem[] => {
return [
{
label: 'Profile image',
completed: completion.hasProfileImage,
redirectPath: `${webappUrl}settings/profile`,
},
{
label: 'Headline',
completed: completion.hasHeadline,
redirectPath: `${webappUrl}settings/profile?field=bio`,
},
{
label: 'Experience level',
completed: completion.hasExperienceLevel,
redirectPath: `${webappUrl}settings/profile?field=experienceLevel`,
},
{
label: 'Work experience',
completed: completion.hasWork,
redirectPath: `${webappUrl}settings/profile/experience/work`,
},
{
label: 'Education',
completed: completion.hasEducation,
redirectPath: `${webappUrl}settings/profile/experience/education`,
},
];
};

export const formatCompletionDescription = (
incompleteItems: CompletionItem[],
): string => {
if (incompleteItems.length === 0) {
return 'Profile completed!';
}

const labels = incompleteItems.map((item) => item.label);
const formattedList =
labels.length === 1
? labels[0]
: `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`;

return `Add ${formattedList}.`;
};
1 change: 1 addition & 0 deletions packages/webapp/pages/squads/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ function CreatePost(): ReactElement {
currentPercentage={user?.profileCompletion?.percentage}
requiredPercentage={requiredPercentage}
description="Add your profile details to keep post creation available."
profileCompletion={user?.profileCompletion}
buttonSize={ButtonSize.Medium}
/>
</WritePageContainer>
Expand Down
Loading