From 57437e5f1b6dd4b3323ebfeb05a50aeea4ec434d Mon Sep 17 00:00:00 2001 From: WK Wong Date: Wed, 26 Aug 2026 23:38:33 +0800 Subject: [PATCH 1/3] docs(ui): add stories for Profile page --- app/pages/profile/[identity]/index.stories.ts | 86 +++++++++++++++++++ app/storybook/mocks/handlers/profile.ts | 68 +++++++++++++++ app/storybook/render-page.ts | 26 ++++++ 3 files changed, 180 insertions(+) create mode 100644 app/pages/profile/[identity]/index.stories.ts create mode 100644 app/storybook/mocks/handlers/profile.ts create mode 100644 app/storybook/render-page.ts diff --git a/app/pages/profile/[identity]/index.stories.ts b/app/pages/profile/[identity]/index.stories.ts new file mode 100644 index 0000000000..b4e7b2ee92 --- /dev/null +++ b/app/pages/profile/[identity]/index.stories.ts @@ -0,0 +1,86 @@ +import Profile from './index.vue' +import type { Meta, StoryObj } from '@storybook-vue/nuxt' +import { expect, userEvent } from 'storybook/test' +import { pageDecorator } from '../../../../.storybook/decorators' +import { + mockAuthSessionHandler, + mockPackageLikesHandler, + mockProfile, + mockProfileHandle, + mockProfileHandler, + mockProfileLikesHandler, + mockUpdateProfileHandler, +} from '../../../storybook/mocks/handlers/profile' +import { renderPageAt } from '../../../storybook/render-page' + +const PROFILE_PATH = `/profile/${mockProfileHandle}` + +const meta = { + component: Profile, + + render: renderPageAt(Profile, PROFILE_PATH), + + beforeEach({ msw }) { + msw.use( + mockProfileHandler(), + mockProfileLikesHandler(), + mockPackageLikesHandler, + mockAuthSessionHandler(), + mockUpdateProfileHandler, + ) + }, + + parameters: { + layout: 'fullscreen', + }, + + decorators: [pageDecorator], +} satisfies Meta + +export default meta +type Story = StoryObj + +/** Public profile with profile metadata and several liked packages. */ +export const Default: Story = {} + +/** Authenticated as the profile owner so the edit action is available. */ +export const Owner: Story = { + beforeEach({ msw }) { + msw.use(mockAuthSessionHandler(mockProfileHandle)) + }, +} + +/** Owner profile with the edit form opened and its editable controls visible. */ +export const Editing: Story = { + ...Owner, + play: async ({ canvas }) => { + await userEvent.click(await canvas.findByRole('button', { name: /edit/i })) + + await expect(canvas.getByText(/display name/i)).toBeVisible() + await expect(await canvas.findByDisplayValue(mockProfile.displayName)).toBeVisible() + await expect(canvas.getByText(/description/i)).toBeVisible() + await expect(canvas.getByDisplayValue(mockProfile.description ?? '')).toBeVisible() + await expect(canvas.getByText(/website/i)).toBeVisible() + await expect(canvas.getByDisplayValue(mockProfile.website ?? '')).toBeVisible() + await expect(canvas.getByRole('button', { name: /cancel/i })).toBeVisible() + await expect(canvas.getByRole('button', { name: /save/i })).toBeVisible() + }, +} + +/** Missing npmx profile record with no likes; authenticated as another user so the invite section appears. */ +export const Invite: Story = { + beforeEach({ msw }) { + msw.use( + mockProfileHandler({ recordExists: false }), + mockProfileLikesHandler([]), + mockAuthSessionHandler('other-user.test'), + ) + }, +} + +/** Existing profile with no liked packages and no invite section. */ +export const WithoutLikes: Story = { + beforeEach({ msw }) { + msw.use(mockProfileLikesHandler([])) + }, +} diff --git a/app/storybook/mocks/handlers/profile.ts b/app/storybook/mocks/handlers/profile.ts new file mode 100644 index 0000000000..c44bf60c1b --- /dev/null +++ b/app/storybook/mocks/handlers/profile.ts @@ -0,0 +1,68 @@ +import { http, HttpResponse } from 'msw' +import type { NPMXProfile, PackageLikes } from '#shared/types/social' + +export const mockProfileHandle = 'mock-steward' + +export const mockProfile: NPMXProfile = { + displayName: mockProfileHandle, + description: 'Maintains small tools for package metadata, docs, and release workflows.', + website: `https://github.com/${mockProfileHandle}`, + handle: mockProfileHandle, + recordExists: true, +} + +export const mockLikedPackages = [ + 'https://npmx.dev/package/nuxt', + 'https://npmx.dev/package/vitest', +] + +export function createMockAuthSession(handle: string) { + return { + did: `did:plc:${handle}`, + handle, + pds: 'https://bsky.social', + } +} + +export function mockProfileHandler(overrides: Partial = {}) { + return http.get(`/api/social/profile/${mockProfileHandle}`, () => + HttpResponse.json({ + ...mockProfile, + ...overrides, + }), + ) +} + +export function mockProfileLikesHandler(likes = mockLikedPackages) { + return http.get(`/api/social/profile/${mockProfileHandle}/likes`, () => + HttpResponse.json({ + cursor: null, + likes, + }), + ) +} + +export const mockPackageLikesHandler = http.get('/api/social/likes/:pkg', ({ params }) => { + const pkg = String(params.pkg) + const response: PackageLikes = { + totalLikes: + { + nuxt: 128, + vitest: 96, + }[pkg] ?? 12, + userHasLiked: false, + topLikedRank: null, + } + + return HttpResponse.json(response) +}) + +export function mockAuthSessionHandler(handle: string | null = null) { + return http.get('/api/auth/session', () => + HttpResponse.json(handle ? createMockAuthSession(handle) : null), + ) +} + +export const mockUpdateProfileHandler = http.put(`/api/social/profile/${mockProfileHandle}`, () => + HttpResponse.text('ok'), +) diff --git a/app/storybook/render-page.ts b/app/storybook/render-page.ts new file mode 100644 index 0000000000..5030d11bf0 --- /dev/null +++ b/app/storybook/render-page.ts @@ -0,0 +1,26 @@ +import { clearNuxtData, useRouter } from '#app' +import { PageRouteSymbol } from '#app/components/injections' +import { h, provide, shallowReactive, Suspense } from 'vue' +import type { Component } from 'vue' +import type { RouteLocationRaw } from 'vue-router' + +export function renderPageAt(component: Component, path: RouteLocationRaw) { + return () => ({ + setup() { + clearNuxtData() + + const router = useRouter() + const routeForStory = shallowReactive(router.resolve(path)) + provide(PageRouteSymbol, routeForStory) + + if (router.currentRoute.value.fullPath !== routeForStory.fullPath) { + router.replace(path).catch(() => {}) + } + + return () => + h(Suspense, null, { + default: () => h(component), + }) + }, + }) +} From 1d12570e99f3c202e6156d24e9d4bb074e454b2c Mon Sep 17 00:00:00 2001 From: WK Wong Date: Thu, 27 Aug 2026 00:29:35 +0800 Subject: [PATCH 2/3] docs(ui): type profile package likes mock --- app/storybook/mocks/handlers/profile.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/storybook/mocks/handlers/profile.ts b/app/storybook/mocks/handlers/profile.ts index c44bf60c1b..cae9446dfa 100644 --- a/app/storybook/mocks/handlers/profile.ts +++ b/app/storybook/mocks/handlers/profile.ts @@ -44,12 +44,12 @@ export function mockProfileLikesHandler(likes = mockLikedPackages) { export const mockPackageLikesHandler = http.get('/api/social/likes/:pkg', ({ params }) => { const pkg = String(params.pkg) + const packageLikesByName: Record = { + nuxt: 128, + vitest: 96, + } const response: PackageLikes = { - totalLikes: - { - nuxt: 128, - vitest: 96, - }[pkg] ?? 12, + totalLikes: packageLikesByName[pkg] ?? 12, userHasLiked: false, topLikedRank: null, } From 1fdc74cdae2021504cecbae285a4b7d79251cd38 Mon Sep 17 00:00:00 2001 From: WK Wong Date: Thu, 27 Aug 2026 01:04:13 +0800 Subject: [PATCH 3/3] chore: remove unused export --- app/storybook/mocks/handlers/profile.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/storybook/mocks/handlers/profile.ts b/app/storybook/mocks/handlers/profile.ts index cae9446dfa..a488bc0810 100644 --- a/app/storybook/mocks/handlers/profile.ts +++ b/app/storybook/mocks/handlers/profile.ts @@ -11,12 +11,9 @@ export const mockProfile: NPMXProfile = { recordExists: true, } -export const mockLikedPackages = [ - 'https://npmx.dev/package/nuxt', - 'https://npmx.dev/package/vitest', -] +const mockLikedPackages = ['https://npmx.dev/package/nuxt', 'https://npmx.dev/package/vitest'] -export function createMockAuthSession(handle: string) { +function createMockAuthSession(handle: string) { return { did: `did:plc:${handle}`, handle,