diff --git a/app/pages/org/[org].stories.ts b/app/pages/org/[org].stories.ts new file mode 100644 index 0000000000..0911a6e83c --- /dev/null +++ b/app/pages/org/[org].stories.ts @@ -0,0 +1,137 @@ +import Org from './[org].vue' +import type { Meta, StoryObj } from '@storybook-vue/nuxt' +import { pageDecorator } from '../../../.storybook/decorators' +import { + mockOrgPackagesSuccess, + mockOrgPackagesSingle, + mockOrgPackagesEmpty, + mockOrgPackagesNotFound, + mockOrgPackagesLoading, +} from '../../storybook/mocks/handlers/registry-org' + +const meta: Meta = { + component: Org, + parameters: { + layout: 'fullscreen', + }, + decorators: [pageDecorator], +} + +export default meta +type Story = StoryObj + +/** + * Default org page showing the @npmx organization with multiple packages. + * Displays package list with filtering, sorting, and view mode controls. + * The MSW handler mocks both the org packages endpoint and Algolia search. + */ +export const Default: Story = { + parameters: { + msw: { handlers: mockOrgPackagesSuccess }, + }, + render: () => ({ + components: { Org }, + setup() { + const isReady = ref(false) + useRouter() + .replace('/org/npmx') + .then(() => { + isReady.value = true + }) + return { isReady } + }, + template: '', + }), +} + +/** + * Organization with only a single package. + * Shows the org page layout with minimal content. + */ +export const SinglePackage: Story = { + parameters: { + msw: { handlers: mockOrgPackagesSingle }, + }, + render: () => ({ + components: { Org }, + setup() { + const isReady = ref(false) + useRouter() + .replace('/org/single-org') + .then(() => { + isReady.value = true + }) + return { isReady } + }, + template: '', + }), +} + +/** + * Empty organization with zero packages. + * Shows the "This organization has no packages" message. + */ +export const EmptyOrg: Story = { + parameters: { + msw: { handlers: mockOrgPackagesEmpty }, + }, + render: () => ({ + components: { Org }, + setup() { + const isReady = ref(false) + useRouter() + .replace('/org/empty-org') + .then(() => { + isReady.value = true + }) + return { isReady } + }, + template: '', + }), +} + +/** + * Organization not found (404 error). + * The org endpoint returns a 404 error and the page displays an error state. + */ +export const NotFound: Story = { + parameters: { + msw: { handlers: mockOrgPackagesNotFound }, + }, + render: () => ({ + components: { Org }, + setup() { + const isReady = ref(false) + useRouter() + .replace('/org/nonexistent-org') + .then(() => { + isReady.value = true + }) + return { isReady } + }, + template: '', + }), +} + +/** + * Loading state when the API request is pending. + * MSW handlers delay responses indefinitely to show the loading spinner. + */ +export const Loading: Story = { + parameters: { + msw: { handlers: mockOrgPackagesLoading }, + }, + render: () => ({ + components: { Org }, + setup() { + const isReady = ref(false) + useRouter() + .replace('/org/npmx') + .then(() => { + isReady.value = true + }) + return { isReady } + }, + template: '', + }), +} diff --git a/app/storybook/mocks/handlers/registry-org.ts b/app/storybook/mocks/handlers/registry-org.ts new file mode 100644 index 0000000000..f7c841958a --- /dev/null +++ b/app/storybook/mocks/handlers/registry-org.ts @@ -0,0 +1,166 @@ +import { http, HttpResponse } from 'msw' + +/** + * Helper to create mock AlgoliaHit objects (mimics Algolia API response format) + */ +function createMockAlgoliaHit( + name: string, + overrides: { + description?: string + version?: string + downloadsLast30Days?: number + keywords?: string[] + modified?: number + license?: string + } = {}, +) { + return { + objectID: name, + name, + version: overrides.version || '1.2.3', + description: overrides.description || `Mock package ${name}`, + modified: overrides.modified || new Date('2026-01-22T10:07:07.000Z').getTime(), + homepage: `https://github.com/org/${name.replace('@', '').replace('/', '-')}`, + repository: { + url: `https://github.com/org/${name.replace('@', '').replace('/', '-')}`, + type: 'git', + }, + owners: [ + { + name: 'Patak Dog', + email: 'patak@patak.dog', + }, + ], + downloadsLast30Days: overrides.downloadsLast30Days || 100000, + downloadsRatio: 1, + popular: (overrides.downloadsLast30Days || 100000) > 50000, + keywords: overrides.keywords || [], + deprecated: false, + isDeprecated: false, + license: overrides.license || 'MIT', + isSecurityHeld: false, + } +} + +/** + * Mock handler: Org with multiple packages (default success scenario) + */ +export const mockOrgPackagesSuccess = [ + // Return the org package list + http.get('/api/registry/org/:org/packages', ({ params }) => { + const org = params.org as string + const packages = [ + `@${org}/xmpn`, + `@${org}/schema`, + `@${org}/i18n`, + `@${org}/noodle`, + `@${org}/tester`, + `${org}`, + ] + + return HttpResponse.json({ + packages, + count: packages.length, + }) + }), + + // Mock Algolia getObjects endpoint for package metadata + http.post('https://*.algolia.net/1/indexes/*/objects', async ({ request }) => { + const body = (await request.json()) as any + const requests = body?.requests || [] + + // Return AlgoliaHit objects for each requested package + const results = requests.map((req: any) => { + const packageName = req.objectID + const orgMatch = packageName.match(/@([\w-]+)\//) || [packageName, packageName] + const org = orgMatch[1] + const packageShortName = packageName.replace(`@${org}/`, '').replace(org, '') + + return createMockAlgoliaHit(packageName, { + description: `${org.charAt(0).toUpperCase() + org.slice(1)} ${packageShortName} - mocked package`, + downloadsLast30Days: 88477, + keywords: [org, packageShortName], + modified: new Date('2026-01-22T10:07:07.000Z').getTime(), + }) + }) + + return HttpResponse.json({ results }) + }), +] + +/** + * Mock handler: Org with single package + */ +export const mockOrgPackagesSingle = [ + http.get('/api/registry/org/:org/packages', ({ params }) => { + const org = params.org as string + const packageName = `@${org}/only-package` + + return HttpResponse.json({ + packages: [packageName], + count: 1, + }) + }), + + http.post('https://*.algolia.net/1/indexes/*/objects', async ({ request }) => { + const body = (await request.json()) as any + const requests = body?.requests || [] + + // Return AlgoliaHit objects for each requested package + const results = requests.map((req: any) => { + const packageName = req.objectID + + return createMockAlgoliaHit(packageName, { + description: 'The only package in this organization', + downloadsLast30Days: 5308, // 1234 weekly + keywords: ['single', 'lonely'], + modified: new Date('2026-01-22T10:07:07.000Z').getTime(), + }) + }) + + return HttpResponse.json({ results }) + }), +] + +/** + * Mock handler: Empty org (no packages) + */ +export const mockOrgPackagesEmpty = [ + http.get('/api/registry/org/:org/packages', () => { + return HttpResponse.json({ + packages: [], + count: 0, + }) + }), +] + +/** + * Mock handler: Org not found (404 error) + */ +export const mockOrgPackagesNotFound = [ + http.get('/api/registry/org/:org/packages', () => { + return HttpResponse.json( + { + error: 'Not Found', + message: 'Organization not found', + }, + { status: 404 }, + ) + }), +] + +/** + * Mock handler: Loading state (requests never resolve) + */ +export const mockOrgPackagesLoading = [ + http.get('/api/registry/org/:org/packages', async () => { + // Delay indefinitely to show loading state + await new Promise(() => {}) + return HttpResponse.json({ packages: [], count: 0 }) + }), + http.post('https://*.algolia.net/1/indexes/*/objects', async () => { + // Delay indefinitely to show loading state + await new Promise(() => {}) + return HttpResponse.json({ results: [] }) + }), +]