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
78 changes: 61 additions & 17 deletions packages/shared/src/components/onboarding/EditTag.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ReactElement } from 'react';
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { FeedPreviewControls } from '../feeds';
import { REQUIRED_TAGS_THRESHOLD } from './common';
import { Origin } from '../../lib/log';
Expand All @@ -14,6 +15,10 @@ import { useTagSearch } from '../../hooks/useTagSearch';
import { useViewSize, ViewSize } from '../../hooks/useViewSize';
import { SearchField } from '../fields/SearchField';
import { FunnelTargetId } from '../../features/onboarding/types/funnelEvents';
import { PersonaSelector } from './PersonaSelector';
import { useConditionalFeature } from '../../hooks/useConditionalFeature';
import { featureOnboardingPersonas } from '../../lib/featureManagement';
import { subscribePersonaSelection } from './onboardingPopBus';

interface EditTagProps {
feedSettings: FeedSettings;
Expand Down Expand Up @@ -45,26 +50,65 @@ export const EditTag = ({
});
const searchTags = searchResult?.searchTags.tags || [];

const { value: showPersonas } = useConditionalFeature({
feature: featureOnboardingPersonas,
shouldEvaluate: !!feedSettings,
});

const tagsRef = useRef<HTMLDivElement>(null);
const hasScrolledToTagsRef = useRef(false);

useEffect(() => {
if (!isMobile || !showPersonas) {
return undefined;
}
return subscribePersonaSelection(() => {
if (hasScrolledToTagsRef.current) {
return;
}
hasScrolledToTagsRef.current = true;
tagsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}, [isMobile, showPersonas]);

// When the persona feature is on, override any caller-supplied headline
// (Freyja funnel JSON or OnboardingV2 modal) with the persona-tuned copy.
// TODO: drop this override once Freyja's persona-experiment variant ships
// the new headline directly.
const resolvedHeadline = showPersonas
? 'Tune your feed'
: headline || 'Pick tags that are relevant to you';

return (
<>
<h2 className="text-center font-bold typo-large-title">
{headline || 'Pick tags that are relevant to you'}
{resolvedHeadline}
</h2>
<TagSelection
className="mt-10 max-w-4xl"
searchElement={
<SearchField
aria-label="Pick tags that are relevant to you"
autoFocus={!isMobile}
className="mb-10 w-full tablet:max-w-xs"
inputId="search-filters"
placeholder="Search javascript, php, git, etc…"
valueChanged={onSearch}
/>
}
searchQuery={searchQuery}
searchTags={searchTags}
/>
{showPersonas && (
<>
<p className="mt-3 max-w-2xl text-center text-text-tertiary typo-callout">
Pick a role to start fast, then add tags you like.
</p>
<PersonaSelector className="mt-6" />
</>
)}
<div ref={tagsRef} className="flex w-full flex-col items-center">
<TagSelection
className={classNames('max-w-4xl', showPersonas ? 'mt-6' : 'mt-10')}
searchElement={
<SearchField
aria-label="Pick tags that are relevant to you"
autoFocus={!isMobile}
className="mb-10 w-full tablet:max-w-xs"
inputId="search-filters"
placeholder="Search javascript, php, git, etc…"
valueChanged={onSearch}
/>
}
searchQuery={searchQuery}
searchTags={searchTags}
/>
</div>
{!hidePreview && (
<FeedPreviewControls
isOpen={isPreviewVisible}
Expand Down
129 changes: 129 additions & 0 deletions packages/shared/src/components/onboarding/PersonaSelector.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PersonaSelector } from './PersonaSelector';
import {
broadcastPersonaSelection,
broadcastRecommendRequest,
} from './onboardingPopBus';

jest.mock('./onboardingPopBus', () => {
const actual = jest.requireActual('./onboardingPopBus');
return {
...actual,
broadcastPersonaSelection: jest.fn(actual.broadcastPersonaSelection),
broadcastRecommendRequest: jest.fn(actual.broadcastRecommendRequest),
};
});

const mockOnFollowTags = jest.fn().mockResolvedValue({ successful: true });
const mockOnUnfollowTags = jest.fn().mockResolvedValue({ successful: true });
const mockLogEvent = jest.fn();
const mockRequest = jest.fn();

jest.mock('../../graphql/common', () => ({
gqlClient: { request: (...args: unknown[]) => mockRequest(...args) },
}));

jest.mock('../../hooks/useTagAndSource', () => ({
__esModule: true,
default: () => ({
onFollowTags: mockOnFollowTags,
onUnfollowTags: mockOnUnfollowTags,
}),
}));

jest.mock('../../contexts/LogContext', () => ({
useLogContext: () => ({ logEvent: mockLogEvent }),
}));

const personas = [
{ id: 'frontend', title: 'Frontend', emoji: '🌐', tags: ['react', 'css'] },
{ id: 'backend', title: 'Backend', emoji: '🖥️', tags: ['node', 'sql'] },
{ id: 'mobile', title: 'Mobile', emoji: '📱', tags: ['ios', 'android'] },
{ id: 'devops', title: 'DevOps', emoji: '☁️', tags: ['docker', 'k8s'] },
];

const renderComponent = () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<PersonaSelector />
</QueryClientProvider>,
);
};

describe('PersonaSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
mockRequest.mockResolvedValue({ onboardingPersonas: personas });
});

it('renders pills with emoji and title', async () => {
renderComponent();
expect(await screen.findByText('Frontend')).toBeInTheDocument();
expect(screen.getByText('Backend')).toBeInTheDocument();
});

it('follows tags and broadcasts pop + recommend on click', async () => {
renderComponent();
fireEvent.click(await screen.findByText('Frontend'));
await waitFor(() =>
expect(mockOnFollowTags).toHaveBeenCalledWith({
tags: ['react', 'css'],
requireLogin: true,
}),
);
expect(broadcastPersonaSelection).toHaveBeenCalledWith(['react', 'css']);
expect(broadcastRecommendRequest).toHaveBeenCalledWith(['react', 'css']);
});

it('allows multi-select without unfollowing previous persona', async () => {
renderComponent();
fireEvent.click(await screen.findByText('Frontend'));
await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1));

fireEvent.click(screen.getByText('Backend'));
await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(2));

expect(mockOnFollowTags).toHaveBeenLastCalledWith({
tags: ['node', 'sql'],
requireLogin: true,
});
expect(mockOnUnfollowTags).not.toHaveBeenCalled();
});

it('disables additional personas after 3 are selected', async () => {
renderComponent();
fireEvent.click(await screen.findByText('Frontend'));
await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByText('Backend'));
await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByText('Mobile'));
await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(3));

const devopsButton = screen.getByText('DevOps').closest('button');
expect(devopsButton).toBeDisabled();

fireEvent.click(screen.getByText('DevOps'));
expect(mockOnFollowTags).toHaveBeenCalledTimes(3);
});

it('deselects only the clicked persona', async () => {
renderComponent();
fireEvent.click(await screen.findByText('Frontend'));
await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByText('Backend'));
await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(2));

fireEvent.click(screen.getByText('Frontend'));
await waitFor(() =>
expect(mockOnUnfollowTags).toHaveBeenCalledWith({
tags: ['react', 'css'],
}),
);
expect(mockOnUnfollowTags).toHaveBeenCalledTimes(1);
});
});
161 changes: 161 additions & 0 deletions packages/shared/src/components/onboarding/PersonaSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type { ReactElement } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import { useQuery } from '@tanstack/react-query';
import { gqlClient } from '../../graphql/common';
import type { GQLPersona } from '../../graphql/feedSettings';
import { GET_ONBOARDING_PERSONAS_QUERY } from '../../graphql/feedSettings';
import useTagAndSource from '../../hooks/useTagAndSource';
import { useLogContext } from '../../contexts/LogContext';
import { LogEvent, Origin } from '../../lib/log';
import { disabledRefetch } from '../../lib/func';
import { RequestKey, StaleTime, generateQueryKey } from '../../lib/query';
import { Button, ButtonColor } from '../buttons/Button';
import { ButtonVariant } from '../buttons/common';
import { ElementPlaceholder } from '../ElementPlaceholder';
import {
broadcastPersonaSelection,
broadcastRecommendRequest,
} from './onboardingPopBus';

export const MAX_PERSONAS = 3;

interface PersonaSelectorProps {
className?: string;
feedId?: string;
}

export function PersonaSelector({
className,
feedId,
}: PersonaSelectorProps): ReactElement | null {
const { logEvent } = useLogContext();
const [activeIds, setActiveIds] = useState<Set<string>>(new Set());
const { onFollowTags, onUnfollowTags } = useTagAndSource({
origin: Origin.OnboardingPersona,
feedId,
});

const {
data: personas,
isPending,
isError,
} = useQuery<GQLPersona[]>({
queryKey: generateQueryKey(
RequestKey.Tags,
undefined,
'onboardingPersonas',
),
queryFn: async () => {
const result = await gqlClient.request<{
onboardingPersonas: GQLPersona[];
}>(GET_ONBOARDING_PERSONAS_QUERY, {});
return result.onboardingPersonas;
},
...disabledRefetch,
staleTime: StaleTime.OneHour,
});

const handleClick = async (persona: GQLPersona) => {
const isActive = activeIds.has(persona.id);
const isAtCap = !isActive && activeIds.size >= MAX_PERSONAS;
if (isAtCap) {
return;
}

logEvent({
event_name: LogEvent.SelectOnboardingPersona,
target_type: 'persona',
target_id: persona.id,
extra: JSON.stringify({
action: isActive ? 'deselect' : 'select',
tags_count: persona.tags.length,
active_count: isActive ? activeIds.size - 1 : activeIds.size + 1,
}),
});

if (isActive) {
await onUnfollowTags({ tags: persona.tags });
setActiveIds((prev) => {
const next = new Set(prev);
next.delete(persona.id);
return next;
});
return;
}

broadcastPersonaSelection(persona.tags);
await onFollowTags({ tags: persona.tags, requireLogin: true });
broadcastRecommendRequest(persona.tags);
setActiveIds((prev) => {
const next = new Set(prev);
next.add(persona.id);
return next;
});
};

if (isError) {
return null;
}

const isAtCap = activeIds.size >= MAX_PERSONAS;

return (
<div
role="group"
aria-label="Pick a role to follow related tags"
aria-busy={isPending}
className={classNames(
'flex w-full max-w-4xl flex-wrap justify-center gap-3',
className,
)}
>
{isPending &&
Array.from({ length: 10 }).map((_, i) => (
<ElementPlaceholder
// eslint-disable-next-line react/no-array-index-key
key={i}
className="h-9 w-32 rounded-12"
/>
))}
{!isPending &&
personas?.map((persona) => {
const isActive = activeIds.has(persona.id);
const isDisabled = !isActive && isAtCap;
const buttonContent = (
<>
<span aria-hidden className="mr-2">
{persona.emoji}
</span>
{persona.title}
</>
);

if (isActive) {
return (
<Button
key={persona.id}
pressed
variant={ButtonVariant.Primary}
color={ButtonColor.Cabbage}
onClick={() => handleClick(persona)}
>
{buttonContent}
</Button>
);
}

return (
<Button
key={persona.id}
variant={ButtonVariant.Float}
disabled={isDisabled}
onClick={() => handleClick(persona)}
>
{buttonContent}
</Button>
);
})}
</div>
);
}
Loading
Loading