-
Notifications
You must be signed in to change notification settings - Fork 30
14890 show or hide the sign up newsletter component #14929
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 24 commits
74f916e
6c6ffc1
177412b
b999d89
da6e644
a5b9c4b
7ad7a22
6f12d91
bb54b31
4927841
8582503
b56c69f
618f558
08ba7cc
6e620a5
8eed71c
97e519d
f0de441
d5dbdaa
6161c89
67ce48a
c190059
0a9eb75
c2f8825
060eca6
57f734c
52c929d
66dc49d
da534de
b1607e5
1c9f788
c8da91b
ed448dd
8af09b5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import type { Decorator } from '@storybook/react-webpack5'; | ||
| import { customMockFetch } from '../../src/lib/mockRESTCalls'; | ||
|
|
||
| // Extend window type for auth state mock | ||
| declare global { | ||
| interface Window { | ||
| __STORYBOOK_AUTH_STATE__?: 'SignedIn' | 'SignedOut'; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Decorator for signed-out user state. | ||
| * Sets the auth state to 'SignedOut' so useAuthStatus returns { kind: 'SignedOut' }. | ||
| */ | ||
| export const signedOutDecorator: Decorator = (Story) => { | ||
| window.__STORYBOOK_AUTH_STATE__ = 'SignedOut'; | ||
| return <Story />; | ||
| }; | ||
|
|
||
| /** | ||
| * Creates a decorator for signed-in user state with custom newsletter subscriptions. | ||
| * Sets the auth state to 'SignedIn' and mocks the newsletters API response. | ||
| * | ||
| * @param subscriptions - Array of newsletter subscriptions to return from the API. | ||
| * Each subscription should have a `listId` string. | ||
| * @returns A Storybook decorator | ||
| * | ||
| * @example | ||
| * // User signed in but not subscribed to any newsletters | ||
| * decorators: [signedInDecorator([])] | ||
| * | ||
| * @example | ||
| * // User signed in and subscribed to newsletter with listId 4147 | ||
| * decorators: [signedInDecorator([{ listId: '4147' }])] | ||
| */ | ||
| export const signedInDecorator = ( | ||
| subscriptions: Array<{ listId: string }> = [], | ||
| ): Decorator => { | ||
| return (Story) => { | ||
| window.__STORYBOOK_AUTH_STATE__ = 'SignedIn'; | ||
| window.fetch = customMockFetch([ | ||
| { | ||
| mockedMethod: 'GET', | ||
| mockedUrl: /.*idapi\.theguardian\.com\/users\/me\/newsletters/, | ||
| mockedStatus: 200, | ||
| mockedBody: { | ||
| result: { | ||
| subscriptions, | ||
| }, | ||
| }, | ||
| }, | ||
| ]) as typeof window.fetch; | ||
| return <Story />; | ||
| }; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| /** | ||
| * Mock identity module for Storybook. | ||
| * | ||
| * This allows stories to control the authentication state | ||
| * by setting window.__STORYBOOK_AUTH_STATE__ before rendering. | ||
| */ | ||
|
|
||
| import type { AuthStatus, SignedIn } from '../../src/lib/identity'; | ||
|
|
||
| // Extend window type for our mock | ||
| declare global { | ||
| interface Window { | ||
| __STORYBOOK_AUTH_STATE__?: 'SignedIn' | 'SignedOut'; | ||
| } | ||
| } | ||
|
|
||
| const mockAccessToken = { | ||
| expiresAt: Date.now() / 1000 + 3600, | ||
| scopes: ['openid', 'profile', 'email'], | ||
| clockSkew: 0, | ||
| accessToken: 'mock-access-token-for-storybook', | ||
| claims: { | ||
| aud: 'guardian-frontend', | ||
| auth_time: Date.now() / 1000, | ||
| cid: 'guardian-frontend', | ||
| exp: Date.now() / 1000 + 3600, | ||
| iat: Date.now() / 1000, | ||
| iss: 'https://profile.theguardian.com', | ||
| jti: 'mock-jti', | ||
| scp: ['openid', 'profile', 'email'], | ||
| sub: 'mock-user-id', | ||
| uid: 'mock-uid', | ||
| ver: 1, | ||
| email_validated: true, | ||
| identity_username: 'storybook-user', | ||
| legacy_identity_id: 'mock-legacy-id', | ||
| user_groups: [], | ||
| }, | ||
| tokenType: 'Bearer' as const, | ||
| }; | ||
|
|
||
| const mockIdToken = { | ||
| idToken: 'mock-id-token-for-storybook', | ||
| issuer: 'https://profile.theguardian.com', | ||
| clientId: 'guardian-frontend', | ||
| nonce: 'mock-nonce', | ||
| clockSkew: 0, | ||
| expiresAt: Date.now() / 1000 + 3600, | ||
| scopes: ['openid', 'profile', 'email'], | ||
| claims: { | ||
| aud: 'guardian-frontend', | ||
| auth_time: Date.now() / 1000, | ||
| exp: Date.now() / 1000 + 3600, | ||
| iat: Date.now() / 1000, | ||
| iss: 'https://profile.theguardian.com', | ||
| sub: 'mock-user-id', | ||
| identity_username: 'storybook-user', | ||
| email_validated: true, | ||
| email: '[email protected]', | ||
| braze_uuid: 'mock-braze-uuid', | ||
| user_groups: [], | ||
| legacy_identity_id: 'mock-legacy-id', | ||
| amr: ['pwd'], | ||
| at_hash: 'mock-at-hash', | ||
| idp: 'guardian', | ||
| jti: 'mock-jti', | ||
| name: 'Storybook User', | ||
| nonce: 'mock-nonce', | ||
| ver: 1, | ||
| }, | ||
| }; | ||
|
|
||
| export async function getAuthState() { | ||
| const authState = window.__STORYBOOK_AUTH_STATE__ ?? 'SignedOut'; | ||
|
|
||
| if (authState === 'SignedIn') { | ||
| return { | ||
| isAuthenticated: true, | ||
| accessToken: mockAccessToken, | ||
| idToken: mockIdToken, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| isAuthenticated: false, | ||
| accessToken: undefined, | ||
| idToken: undefined, | ||
| }; | ||
| } | ||
|
|
||
| export function getSignedInStatus(authState: { | ||
| isAuthenticated: boolean; | ||
| accessToken?: typeof mockAccessToken; | ||
| idToken?: typeof mockIdToken; | ||
| }): AuthStatus { | ||
| if ( | ||
| authState.isAuthenticated && | ||
| authState.accessToken && | ||
| authState.idToken | ||
| ) { | ||
| return { | ||
| kind: 'SignedIn', | ||
| accessToken: authState.accessToken, | ||
| idToken: authState.idToken, | ||
| } as unknown as SignedIn; | ||
| } | ||
|
|
||
| return { kind: 'SignedOut' }; | ||
| } | ||
|
|
||
| export const getOptionsHeaders = (authStatus: SignedIn): RequestInit => { | ||
| return { | ||
| headers: { | ||
| Authorization: `Bearer ${authStatus.accessToken.accessToken}`, | ||
| 'X-GU-IS-OAUTH': 'true', | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| export const isUserLoggedIn = (): Promise<boolean> => | ||
| getAuthStatus().then((authStatus) => | ||
| authStatus.kind === 'SignedIn' ? true : false, | ||
| ); | ||
|
|
||
| export const getAuthStatus = async (): Promise<AuthStatus> => { | ||
|
||
| const authState = await getAuthState(); | ||
| return getSignedInStatus(authState); | ||
| }; | ||
|
|
||
| export async function isSignedInAuthState() { | ||
| return getAuthState(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import { useState } from 'react'; | ||
| import { useNewsletterSubscription } from '../lib/useNewsletterSubscription'; | ||
| import type { EmailSignUpProps } from './EmailSignup'; | ||
| import { EmailSignup } from './EmailSignup'; | ||
| import { InlineSkipToWrapper } from './InlineSkipToWrapper'; | ||
|
|
@@ -7,16 +9,39 @@ import { SecureSignup } from './SecureSignup.importable'; | |
|
|
||
| interface EmailSignUpWrapperProps extends EmailSignUpProps { | ||
| index: number; | ||
| listId: number; | ||
| identityName: string; | ||
| successDescription: string; | ||
| /** You should only set this to true if the privacy message will be shown elsewhere on the page */ | ||
| hidePrivacyMessage?: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * EmailSignUpWrapper as an importable island component. | ||
| * | ||
| * This component needs to be hydrated client-side because it uses | ||
| * the useNewsletterSubscription hook which depends on auth status | ||
| * to determine if the user is already subscribed to the newsletter. | ||
| * | ||
| * If the user is signed in and already subscribed, this component | ||
| * will return null (hide the signup form). | ||
| */ | ||
| export const EmailSignUpWrapper = ({ | ||
| index, | ||
| listId, | ||
| ...emailSignUpProps | ||
| }: EmailSignUpWrapperProps) => { | ||
| const [idApiUrl] = useState(() => { | ||
| if (typeof window === 'undefined') return undefined; | ||
| return window.guardian?.config?.page?.idApiUrl ?? undefined; | ||
JamieB-gu marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }); | ||
| const isSubscribed = useNewsletterSubscription(listId, idApiUrl); | ||
|
|
||
| // Don't render if user is signed in and already subscribed | ||
| if (isSubscribed === true) { | ||
| return null; | ||
| } | ||
|
||
|
|
||
| return ( | ||
| <InlineSkipToWrapper | ||
| id={`EmailSignup-skip-link-${index}`} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,35 +1,63 @@ | ||
| import type { Meta, StoryObj } from '@storybook/react-webpack5'; | ||
| import { EmailSignUpWrapper } from './EmailSignUpWrapper'; | ||
| import { | ||
| signedInDecorator, | ||
| signedOutDecorator, | ||
| } from '../../.storybook/decorators/authDecorator'; | ||
| import { EmailSignUpWrapper } from './EmailSignUpWrapper.importable'; | ||
|
|
||
| const meta: Meta<typeof EmailSignUpWrapper> = { | ||
| title: 'Components/EmailSignUpWrapper', | ||
| component: EmailSignUpWrapper, | ||
| }; | ||
|
|
||
| type Story = StoryObj<typeof EmailSignUpWrapper>; | ||
|
|
||
| const defaultArgs = { | ||
| index: 10, | ||
| listId: 4147, | ||
| identityName: 'the-recap', | ||
| description: | ||
| 'The best of our sports journalism from the past seven days and a heads-up on the weekend’s action', | ||
| "The best of our sports journalism from the past seven days and a heads-up on the weekend's action", | ||
| name: 'The Recap', | ||
| frequency: 'Weekly', | ||
| successDescription: "We'll send you The Recap every week", | ||
| theme: 'sport', | ||
| } satisfies Story['args']; | ||
| type Story = StoryObj<typeof EmailSignUpWrapper>; | ||
|
|
||
| // Default story - signed out user sees the signup form | ||
| export const DefaultStory: Story = { | ||
| args: { | ||
| hidePrivacyMessage: true, | ||
| ...defaultArgs, | ||
| }, | ||
| decorators: [signedOutDecorator], | ||
| }; | ||
|
|
||
| export const DefaultStoryWithPrivacy: Story = { | ||
| args: { | ||
| hidePrivacyMessage: false, | ||
| ...defaultArgs, | ||
| }, | ||
| decorators: [signedOutDecorator], | ||
| }; | ||
|
|
||
| // User is signed in but NOT subscribed - signup form is visible | ||
| export const SignedInNotSubscribed: Story = { | ||
| args: { | ||
| hidePrivacyMessage: false, | ||
| ...defaultArgs, | ||
| }, | ||
| decorators: [signedInDecorator([])], | ||
| }; | ||
|
|
||
| // User is signed in and IS subscribed - component returns null (hidden) | ||
| // Note: This story will render nothing as the component returns null when subscribed | ||
| export const SignedInAlreadySubscribed: Story = { | ||
| args: { | ||
| hidePrivacyMessage: false, | ||
| ...defaultArgs, | ||
| }, | ||
| decorators: [signedInDecorator([{ listId: String(defaultArgs.listId) }])], | ||
| }; | ||
|
|
||
| export default meta; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we have to mock all this? Is it not possible to just mock the useAuthStatus hook?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Note, this is a reply to George's comment above, but the GH review UI might not show this clearly).
Doesn't the
useAuthStatushook also return this data via theSignedIntype though? I can also see that theSecureSignupcomponent relies on a few different functions/hooks to retrieve information about the signed in status and email address, such aslazyFetchEmailWithTimeoutanduseIsSignedIn, which in turn rely on this?An alternative might be to use storybook's built-in mocking1 for the modules in question? Given that you've already got tests for
useNewsletterSubscriptionwe probably don't need to re-test the underlying implementation in storybook, as we just want to test the resulting component behaviour. Therefore we could add the following to storybook'spreview.tsto set up mocking for the relevant modules:Then we can use
beforeEachin storybook2 to mock the specific functionality we care about. So, for each story:LoadingState:
DefaultStory/DefaultStoryWithPrivacy:
SignedInNotSubscribed:
SignedInAlreadySubscribed:
I think this would allow you to avoid writing custom mocks for the identity module, and the auth decorators too. However, I doubt we have other examples of this in the codebase, as I believe it's a relatively recent feature (Storybook 9), so I'd be happy to discuss if helpful.
Footnotes
https://storybook.js.org/docs/writing-stories/mocking-data-and-modules/mocking-modules ↩
https://storybook.js.org/docs/writing-stories/mocking-data-and-modules/mocking-modules#using-automocked-modules-in-stories ↩