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
19 changes: 18 additions & 1 deletion 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 classNames from 'classnames';
import { FeedPreviewControls } from '../feeds';
import { REQUIRED_TAGS_THRESHOLD } from './common';
import { Origin } from '../../lib/log';
Expand All @@ -14,6 +15,9 @@ 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';

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

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

return (
<>
<h2 className="text-center font-bold typo-large-title">
{headline || 'Pick tags that are relevant to you'}
</h2>
{showPersonas && (
<>
<p className="mt-3 max-w-2xl text-center text-text-tertiary typo-callout">
Quick start: pick up to 3 roles to follow related tags.
</p>
<PersonaSelector className="mt-6" />
</>
)}
<TagSelection
className="mt-10 max-w-4xl"
className={classNames('max-w-4xl', showPersonas ? 'mt-6' : 'mt-10')}
searchElement={
<SearchField
aria-label="Pick tags that are relevant to you"
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>
);
}
29 changes: 29 additions & 0 deletions packages/shared/src/components/onboarding/onboardingPopBus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type PopListener = (tagNames: string[]) => void;
type RecommendListener = (tags: string[]) => void;

const popListeners = new Set<PopListener>();
const recommendListeners = new Set<RecommendListener>();

export function subscribePersonaSelection(listener: PopListener): () => void {
popListeners.add(listener);
return () => {
popListeners.delete(listener);
};
}

export function broadcastPersonaSelection(tagNames: string[]): void {
popListeners.forEach((listener) => listener(tagNames));
}

export function subscribeRecommendRequest(
listener: RecommendListener,
): () => void {
recommendListeners.add(listener);
return () => {
recommendListeners.delete(listener);
};
}

export function broadcastRecommendRequest(tags: string[]): void {
recommendListeners.forEach((listener) => listener(tags));
}
Loading
Loading