Skip to content
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

build: include environment variables for chromatic #241

Merged
merged 6 commits into from
Dec 28, 2023
Merged
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
5 changes: 5 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,10 @@ const config: StorybookConfig = {
core: {
disableTelemetry: true,
},
env: (config) => ({
...config,
SKIP_ENV_VALIDATION: 'true',
STORYBOOK_ENVIRONMENT: JSON.stringify(process.env),
})
}
export default config
47 changes: 33 additions & 14 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,23 @@ import { httpBatchLink } from '@trpc/client'
import { createTRPCReact } from '@trpc/react-query'
import { useCallback, useMemo, useState } from 'react'
import superjson from 'superjson'
import { AppRouter } from '~/server/modules/_app'
import { type AppRouter } from '~/server/modules/_app'
import { theme } from '~/theme'

import { Box, Skeleton } from '@chakra-ui/react'
import { initialize, mswDecorator } from 'msw-storybook-addon'
import ErrorBoundary from '~/components/ErrorBoundary'
import Suspense from '~/components/Suspense'
import { format } from 'date-fns'
import { FeatureContext } from '~/components/AppProviders'
import {
type EnvContextReturn,
EnvProvider,
FeatureContext,
} from '~/components/AppProviders'
import { z } from 'zod'
import { LoginStateContext } from '~/features/auth'
import { merge } from 'lodash'
import { env } from '~/env.mjs'

// Initialize MSW
initialize({
Expand All @@ -30,7 +36,19 @@ initialize({

const trpc = createTRPCReact<AppRouter>()

const SetupDecorator: Decorator = (page) => {
const StorybookEnvDecorator: Decorator = (story) => {
const mockEnv: EnvContextReturn['env'] = merge(
{
NEXT_PUBLIC_APP_NAME: 'Starter Kit',
NEXT_PUBLIC_ENABLE_SGID: false,
NEXT_PUBLIC_ENABLE_STORAGE: false,
},
env
)
return <EnvProvider env={mockEnv}>{story()}</EnvProvider>
}

const SetupDecorator: Decorator = (story) => {
const [queryClient] = useState(
new QueryClient({
defaultOptions: {
Expand All @@ -53,16 +71,16 @@ const SetupDecorator: Decorator = (page) => {
<Suspense fallback={<Skeleton width="100vw" height="100vh" />}>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{page()}
{story()}
</QueryClientProvider>
</trpc.Provider>
</Suspense>
</ErrorBoundary>
)
}

export const mockFeatureFlagsDecorator: Decorator<Args> = (
storyFn,
export const MockFeatureFlagsDecorator: Decorator<Args> = (
story,
{ parameters }
) => {
const featureSchema = z
Expand All @@ -77,12 +95,12 @@ export const mockFeatureFlagsDecorator: Decorator<Args> = (

return (
<FeatureContext.Provider value={features}>
{storyFn()}
{story()}
</FeatureContext.Provider>
)
}

const LoginStateDecorator: Decorator<Args> = (storyFn, { parameters }) => {
const LoginStateDecorator: Decorator<Args> = (story, { parameters }) => {
const [hasLoginStateFlag, setLoginStateFlag] = useState(
Boolean(parameters.loginState)
)
Expand All @@ -103,16 +121,16 @@ const LoginStateDecorator: Decorator<Args> = (storyFn, { parameters }) => {
setHasLoginStateFlag,
}}
>
{storyFn()}
{story()}
</LoginStateContext.Provider>
)
}

export const mockDateDecorator: Decorator<Args> = (storyFn, { parameters }) => {
export const MockDateDecorator: Decorator<Args> = (story, { parameters }) => {
mockdate.reset()

if (!parameters.mockdate) {
return storyFn()
return story()
}

mockdate.set(parameters.mockdate)
Expand All @@ -133,21 +151,22 @@ export const mockDateDecorator: Decorator<Args> = (storyFn, { parameters }) => {
>
Mocking date: {mockedDate}
</Box>
{storyFn()}
{story()}
</Box>
)
}

const decorators: Decorator[] = [
StorybookEnvDecorator,
mswDecorator,
withThemeFromJSXProvider({
themes: {
default: theme,
},
Provider: ThemeProvider,
}),
mockDateDecorator,
mockFeatureFlagsDecorator,
MockDateDecorator,
MockFeatureFlagsDecorator,
LoginStateDecorator,
SetupDecorator,
]
Expand Down
23 changes: 23 additions & 0 deletions src/components/AppProviders/EnvProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// This file allows us to pass in environment variables to our app.
// Used to allow overriding of environment variables in storybook tests.
import { createContext, type PropsWithChildren } from 'react'

import { type env } from '~/env.mjs'

// Typescript magic to only select keys from object T that start with a prefix S.
type PickStartsWith<T extends object, S extends string> = {
[K in keyof T as K extends `${S}${string}` ? K : never]: T[K]
}

export interface EnvContextReturn {
env: PickStartsWith<typeof env, 'NEXT_PUBLIC'>
}

export const EnvContext = createContext<EnvContextReturn | undefined>(undefined)

export const EnvProvider = ({
children,
env,
}: PropsWithChildren<EnvContextReturn>): JSX.Element => {
return <EnvContext.Provider value={{ env }}>{children}</EnvContext.Provider>
}
3 changes: 2 additions & 1 deletion src/components/AppProviders/FeatureProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type PropsWithChildren,
useContext,
} from 'react'
import { env } from '~/env.mjs'
import { useEnv } from '~/hooks/useEnv'

type FeatureContextProps = {
storage: boolean
Expand Down Expand Up @@ -45,6 +45,7 @@ export const useFeatures = (): FeatureContextProps => {

// Provider hook that return the current features object
const useProvideFeatures = () => {
const { env } = useEnv()
return {
storage: !!env.NEXT_PUBLIC_ENABLE_STORAGE,
sgid: !!env.NEXT_PUBLIC_ENABLE_SGID,
Expand Down
1 change: 1 addition & 0 deletions src/components/AppProviders/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './EnvProvider'
export * from './FeatureProvider'
13 changes: 13 additions & 0 deletions src/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,19 @@ if (!!process.env.SKIP_ENV_VALIDATION == false) {
return target[/** @type {keyof typeof target} */ (prop)]
},
})
} else if (process.env.STORYBOOK) {
const parsed = client
.partial()
.safeParse(JSON.parse(process.env.STORYBOOK_ENVIRONMENT ?? '{}'))
if (parsed.success === false) {
console.error(
'❌ Invalid environment variables:',
parsed.error.flatten().fieldErrors
)
throw new Error('Invalid environment variables')
}
// @ts-expect-error Injection of environment variables is optional
env = parsed.data
}

export { env }
11 changes: 11 additions & 0 deletions src/hooks/useEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useContext } from 'react'

import { EnvContext, type EnvContextReturn } from '~/components/AppProviders'

export const useEnv = (): EnvContextReturn => {
const context = useContext(EnvContext)
if (context === undefined) {
throw new Error('useEnv must be used within a EnvProvider')
}
return context
}
39 changes: 21 additions & 18 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,35 @@ import { type NextPageWithLayout } from '~/lib/types'
import { DefaultLayout } from '~/templates/layouts/DefaultLayout'
import { theme } from '~/theme'
import { trpc } from '~/utils/trpc'
import { FeatureProvider } from '~/components/AppProviders'
import { EnvProvider, FeatureProvider } from '~/components/AppProviders'
import { LoginStateProvider } from '~/features/auth'
import { env } from '~/env.mjs'

type AppPropsWithAuthAndLayout = AppProps & {
Component: NextPageWithLayout
}

const MyApp = ((props: AppPropsWithAuthAndLayout) => {
return (
// Must wrap Jotai's provider in SSR context, see https://jotai.org/docs/guides/nextjs#provider.
<Provider>
<LoginStateProvider>
<ThemeProvider theme={theme}>
<FeatureProvider>
<ErrorBoundary>
<Suspense fallback={<Skeleton width="100vw" height="100vh" />}>
<ChildWithLayout {...props} />
{process.env.NODE_ENV !== 'production' && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</Suspense>
</ErrorBoundary>
</FeatureProvider>
</ThemeProvider>
</LoginStateProvider>
</Provider>
<EnvProvider env={env}>
{/* Must wrap Jotai's provider in SSR context, see https://jotai.org/docs/guides/nextjs#provider. */}
<Provider>
<LoginStateProvider>
<ThemeProvider theme={theme}>
<FeatureProvider>
<ErrorBoundary>
<Suspense fallback={<Skeleton width="100vw" height="100vh" />}>
<ChildWithLayout {...props} />
{process.env.NODE_ENV !== 'production' && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</Suspense>
</ErrorBoundary>
</FeatureProvider>
</ThemeProvider>
</LoginStateProvider>
</Provider>
</EnvProvider>
)
}) as AppType

Expand Down
8 changes: 5 additions & 3 deletions src/pages/sign-in/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { PublicPageWrapper } from '~/components/AuthWrappers'

import { RestrictedMiniFooter } from '~/components/RestrictedMiniFooter'
import Suspense from '~/components/Suspense'
import { env } from '~/env.mjs'
import {
BackgroundBox,
BaseGridLayout,
Expand All @@ -15,11 +14,14 @@ import {
SignInContextProvider,
SignInForm,
} from '~/features/sign-in/components'
import { useEnv } from '~/hooks/useEnv'
import { type NextPageWithLayout } from '~/lib/types'

const title = env.NEXT_PUBLIC_APP_NAME

const SignIn: NextPageWithLayout = () => {
const {
env: { NEXT_PUBLIC_APP_NAME: title },
} = useEnv()

return (
<PublicPageWrapper strict>
<BackgroundBox>
Expand Down
4 changes: 3 additions & 1 deletion src/templates/layouts/DefaultLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import Head from 'next/head'
import { type ReactNode } from 'react'
import { env } from '~/env.mjs'
import { useEnv } from '~/hooks/useEnv'

type DefaultLayoutProps = { children: ReactNode }

export const DefaultLayout = ({ children }: DefaultLayoutProps) => {
const { env } = useEnv()

return (
<>
<Head>
Expand Down
Loading