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
11 changes: 11 additions & 0 deletions src/app/onboarding/tags/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Metadata } from '@/molecules/Metadata/Metadata';
import { TagsOfInterest } from '@/templates/Onboarding/TagsOfInterest/TagsOfInterest';

export const metadata = Metadata({
title: 'Tags of Interest - Onboarding',
description: 'Onboarding tags of interest page on pubky app.',
});

export default function TagsPage() {
return <TagsOfInterest />;
}
2 changes: 2 additions & 0 deletions src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum ONBOARDING_ROUTES {
PUBKY = '/onboarding/pubky',
SCAN = '/onboarding/scan',
HUMAN = '/onboarding/human',
TAGS = '/onboarding/tags',
}

export enum AUTH_ROUTES {
Expand Down Expand Up @@ -89,6 +90,7 @@ export const PUBLIC_ROUTES: string[] = [

export const ALLOWED_ROUTES = [
ONBOARDING_ROUTES.PROFILE,
ONBOARDING_ROUTES.TAGS,
APP_ROUTES.HOME,
APP_ROUTES.FEED,
APP_ROUTES.SEARCH,
Expand Down
12 changes: 12 additions & 0 deletions src/components/molecules/Fab/Fab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ const mockUseAuthStatus = vi.fn(() => ({
const mockIsPublicExploreRoute = vi.fn(() => false);
const mockRequireAuth = vi.fn((action: () => void) => action());
const mockUseFabAction = vi.fn<() => FabAction>(() => ({ kind: 'createPost', ariaLabel: 'New post' }));
const mockUsePathname = vi.fn(() => '/home');

vi.mock('next/navigation', () => ({
usePathname: () => mockUsePathname(),
}));

vi.mock('@/hooks/useAuthStatus/useAuthStatus', () => ({
useAuthStatus: () => mockUseAuthStatus(),
Expand Down Expand Up @@ -109,6 +114,7 @@ describe('Fab', () => {
mockIsPublicExploreRoute.mockReturnValue(false);
mockRequireAuth.mockImplementation((action: () => void) => action());
mockUseFabAction.mockReturnValue({ kind: 'createPost', ariaLabel: 'New post' });
mockUsePathname.mockReturnValue('/home');
useCollectionReorderStore.setState({ activeCollectionId: null });
});

Expand Down Expand Up @@ -160,6 +166,12 @@ describe('Fab', () => {
expect(container.firstChild).toBeNull();
});

it('returns null on onboarding routes even when fully authenticated', () => {
mockUsePathname.mockReturnValue('/onboarding/tags');
const { container } = render(<Fab />);
expect(container.firstChild).toBeNull();
});

describe('createPost action', () => {
it('renders the new post dialog and opens it on click', () => {
render(<Fab />);
Expand Down
9 changes: 8 additions & 1 deletion src/components/molecules/Fab/Fab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState } from 'react';
import { usePathname } from 'next/navigation';
import { Plus } from 'lucide-react';
import { Button } from '@/atoms/Button/Button';
import { useAuthStatus } from '@/hooks/useAuthStatus/useAuthStatus';
Expand All @@ -26,6 +27,10 @@ import { useCollectionReorderStore } from '@/stores/collectionReorder/collection
* - Shows for authenticated users (opens the context dialog)
* - Shows for unauthenticated users on public explore routes (opens sign-in)
* - Hidden on landing page and other non-public routes for unauthenticated users
* - Hidden on onboarding routes: the flow has its own primary actions
* (Back/Continue) that the FAB would overlap, and creating posts
* mid-onboarding is out of flow (reachable once fully authenticated,
* e.g. the tags step)
* - Hidden while a collection is in reorder mode (reorder mode is for
* reordering, not adding posts; the flag bridges from the page via the
* `collectionReorder` store since the FAB lives outside the page tree)
Expand All @@ -37,15 +42,17 @@ import { useCollectionReorderStore } from '@/stores/collectionReorder/collection
*/
export function Fab() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const { isFullyAuthenticated, isLoading } = useAuthStatus();
const { isPublicExploreRoute } = usePublicRoute();
const { requireAuth } = useRequireAuth();
const action = useFabAction();
const isReorderActive = useCollectionReorderStore((state) => state.activeCollectionId !== null);

const isOnboardingRoute = pathname?.startsWith('/onboarding') ?? false;
// Show FAB for authenticated users OR unauthenticated users on public explore routes
const shouldShow = isFullyAuthenticated || isPublicExploreRoute;
if (isLoading || !shouldShow || isReorderActive) {
if (isLoading || !shouldShow || isReorderActive || isOnboardingRoute) {
return null;
}
const buttonClasses = cn(
Expand Down
2 changes: 1 addition & 1 deletion src/components/molecules/Header/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ describe('Header Components', () => {

const progressSteps = screen.getByTestId('progress-steps');
expect(progressSteps).toHaveAttribute('data-current', '3');
expect(progressSteps).toHaveAttribute('data-total', '5');
expect(progressSteps).toHaveAttribute('data-total', '4');
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/components/molecules/Header/Header.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ exports[`Header Components - Snapshots > matches snapshot for HeaderOnboarding 1
<div
data-current="3"
data-testid="progress-steps"
data-total="5"
data-total="4"
>
Progress Steps
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/molecules/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const HeaderTitle = ({ currentTitle }: { currentTitle: string }) => {
);
};
export const HeaderOnboarding = ({ currentStep }: { currentStep: number }) => {
return <ProgressSteps currentStep={currentStep} totalSteps={5} />;
return <ProgressSteps currentStep={currentStep} totalSteps={4} />;
};
export function HeaderSocialLinks({ ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
Expand Down
4 changes: 2 additions & 2 deletions src/components/molecules/PostTag/PostTag.types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { HTMLAttributes, MouseEvent } from 'react';
import type { ButtonHTMLAttributes, MouseEvent } from 'react';

export interface PostTagProps extends Omit<HTMLAttributes<HTMLButtonElement>, 'onClick' | 'color'> {
export interface PostTagProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'color'> {
/** Tag label text */
label: string;
/** Number of posts with this tag (optional) */
Expand Down
129 changes: 120 additions & 9 deletions src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HOME_ROUTES } from '@/app/routes';
import { ONBOARDING_ROUTES } from '@/app/routes';
import { AuthController } from '@/controllers/auth/auth';
import { FileController } from '@/controllers/file/file';
import { ProfileController } from '@/controllers/profile/profile';
import { useCurrentUserProfile } from '@/hooks/useCurrentUserProfile/useCurrentUserProfile';
import { ServerErrorCode } from '@/libs/error/error.codes';
import { Err } from '@/libs/error/error.factories';
import { ErrorService } from '@/libs/error/error.types';
import { UserValidator } from '@/pipes/user/user.validator';
import { useAuthStore } from '@/stores/auth/auth.store';
import { useOnboardingStore } from '@/stores/onboarding/onboarding.store';
import { asOpaque } from '@/test-utils/type-assertions';
import { CreateProfileForm } from './CreateProfileForm';

vi.mock('@/atoms/Dialog/Dialog', () => {
Expand Down Expand Up @@ -64,14 +66,21 @@ vi.mock('facehash', () => ({
vi.mock('@/stores/onboarding/onboarding.store', () => ({
useOnboardingStore: vi.fn(),
}));
vi.mock('@/stores/auth/auth.store', () => ({
useAuthStore: vi.fn(),
vi.mock('@/stores/auth/auth.store', () => {
const useAuthStore = Object.assign(vi.fn(), {
getState: vi.fn(() => ({ hasProfile: false })),
});
return { useAuthStore };
});
vi.mock('@/hooks/useCurrentUserProfile/useCurrentUserProfile', () => ({
useCurrentUserProfile: vi.fn(() => ({ userDetails: null, currentUserPubky: 'test-public-key' })),
}));
vi.mock('@/controllers/profile/profile', () => ({
ProfileController: {
upload: vi.fn(),
create: vi.fn(),
commitCreate: vi.fn(),
commitUpdate: vi.fn(),
},
}));
vi.mock('@/controllers/file/file', () => ({
Expand Down Expand Up @@ -186,8 +195,14 @@ vi.mock('@/atoms/Card/Card', () => {

vi.mock('@/atoms/Container/Container', () => {
return {
Container: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="container" className={className}>
// Forwards rest props so components that set their own data-testid (e.g. the
// profile form skeleton) keep it instead of the mock's fallback.
Container: ({
children,
className,
...props
}: { children?: React.ReactNode; className?: string } & Record<string, unknown>) => (
<div data-testid="container" className={className} {...props}>
{children}
</div>
),
Expand Down Expand Up @@ -460,12 +475,16 @@ describe('CreateProfileForm', () => {
vi.mocked(useAuthStore).mockReturnValue({
selectCurrentUserPubky: vi.fn(() => mockPubky),
});
vi.mocked(useAuthStore.getState).mockReturnValue(
asOpaque<ReturnType<typeof useAuthStore.getState>>({ hasProfile: false }),
);

// Reset all mock functions
mockPush.mockReset();
mockToast.mockReset();
vi.mocked(FileController.commitCreate).mockReset();
vi.mocked(ProfileController.commitCreate).mockReset();
vi.mocked(ProfileController.commitUpdate).mockReset();
vi.mocked(UserValidator.check).mockReset();
vi.mocked(AuthController.bootstrapWithDelay).mockReset();
});
Expand Down Expand Up @@ -865,8 +884,8 @@ describe('CreateProfileForm', () => {
// Button text should change to "Try again!"
expect(continueButton).toHaveTextContent('Try again!');

// Should not navigate to feed page
expect(mockPush).not.toHaveBeenCalledWith(HOME_ROUTES.HOME);
// Should not navigate to the tags step
expect(mockPush).not.toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS);
});

// Verify the mocks were called in the correct order
Expand Down Expand Up @@ -915,8 +934,8 @@ describe('CreateProfileForm', () => {

// Wait for the success handling to complete
await waitFor(() => {
// Should navigate to feed page
expect(mockPush).toHaveBeenCalledWith(HOME_ROUTES.HOME);
// Should navigate to the onboarding tags step
expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS);
});

// Verify that setShowWelcomeDialog(true) was called
Expand Down Expand Up @@ -1047,4 +1066,96 @@ describe('CreateProfileForm', () => {
expect(screen.getByTestId('card')).toBeInTheDocument();
});
});

describe('Profile revisit (Back from tags step, hasProfile=true)', () => {
const revisitUserDetails = {
id: mockPubky,
name: 'Existing Name',
bio: 'Existing bio',
links: [],
status: null,
image: null,
indexed_at: 1,
};

beforeEach(() => {
vi.mocked(useAuthStore.getState).mockReturnValue(
asOpaque<ReturnType<typeof useAuthStore.getState>>({ hasProfile: true }),
);
vi.mocked(useCurrentUserProfile).mockReturnValue({
userDetails: revisitUserDetails,
currentUserPubky: mockPubky,
});
});

it('renders in edit mode prefilled from the current user details', async () => {
render(<CreateProfileForm />);

await waitFor(() => {
const nameInput = screen.getAllByTestId('molecules-input')[0];
expect(nameInput).toHaveValue('Existing Name');
});

const continueButton = screen.getByTestId('continue-button');
expect(continueButton).toHaveTextContent('Save Profile');
});

it('renders the loading skeleton instead of the interactive form until the profile arrives', () => {
// Local miss + pending/failed fetch: userDetails has not arrived. An interactive
// form here would let edits (text, avatar file) be overwritten or resurface once
// hydration lands, so nothing editable may render.
vi.mocked(useCurrentUserProfile).mockReturnValue({
userDetails: null,
currentUserPubky: mockPubky,
});

render(<CreateProfileForm />);

expect(screen.getByTestId('profile-form-skeleton')).toBeInTheDocument();
expect(screen.queryAllByTestId('molecules-input')).toHaveLength(0);
expect(screen.queryByTestId('continue-button')).not.toBeInTheDocument();
expect(ProfileController.commitUpdate).not.toHaveBeenCalled();
});

it('enables the back button and navigates to the tags step without saving', async () => {
render(<CreateProfileForm />);

const backButton = screen.getByTestId('back-button');
expect(backButton).not.toBeDisabled();

fireEvent.click(backButton);

expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS);
expect(ProfileController.commitUpdate).not.toHaveBeenCalled();
expect(ProfileController.commitCreate).not.toHaveBeenCalled();
});

it('submits via commitUpdate (not commitCreate) and returns to the tags step', async () => {
vi.mocked(UserValidator.check).mockReturnValue({
data: {
name: 'Existing Name',
bio: 'Existing bio',
links: [],
},
error: [],
});
vi.mocked(ProfileController.commitUpdate).mockResolvedValue(undefined);

render(<CreateProfileForm />);

await waitFor(() => {
expect(screen.getAllByTestId('molecules-input')[0]).toHaveValue('Existing Name');
});

fireEvent.click(screen.getByTestId('continue-button'));

await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(ONBOARDING_ROUTES.TAGS);
});

expect(ProfileController.commitUpdate).toHaveBeenCalled();
expect(ProfileController.commitCreate).not.toHaveBeenCalled();
expect(AuthController.bootstrapWithDelay).not.toHaveBeenCalled();
});
});
});
Loading
Loading