Skip to content
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
6 changes: 5 additions & 1 deletion .github/workflows/frontend-chromatic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,14 @@ jobs:
run: npm ci

- name: Publish to Chromatic
uses: chromaui/action@v11
uses: chromaui/action@v16
with:
workingDir: frontend
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
# Dev-mode build keeps React's act() in the bundle —
# production strips it and breaks play function synthetic
# events (Storybook #19758).
buildScriptName: build-storybook:dev
exitZeroOnChanges: true
exitOnceUploaded: true
onlyChanged: true
Expand Down
14 changes: 12 additions & 2 deletions frontend/.storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ const config = {
common: path.resolve(__dirname, '../common'),
components: path.resolve(__dirname, '../web/components'),
project: path.resolve(__dirname, '../web/project'),
// Stub CommonJS modules that break Storybook's ESM bundler.
// code-help contains SDK snippets using module.exports — not needed for component rendering.
'common/code-help': path.resolve(__dirname, 'mocks/code-help.js'),
// Stub CommonJS data layer that breaks ESM bundler
[path.resolve(__dirname, '../common/data/base/_data.js')]: path.resolve(__dirname, 'mocks/_data.js'),
// Mock dompurify (CJS/ESM export mismatch)
'dompurify': path.resolve(__dirname, 'mocks/dompurify.js'),
}

config.module = config.module || {}
Expand Down Expand Up @@ -77,8 +84,11 @@ const config = {
),
'@stencil/core/internal/client': false,
'@stencil/core': false,
'@ionic/react': false,
'ionicons/icons': false,
// Mock IonIcon so components that still use it (ClearFilters,
// NavSubLink, BreadcrumbSeparator, etc.) can render in stories
// without forcing each one to migrate to our Icon component.
'@ionic/react': path.resolve(__dirname, 'mocks/ionic-react.js'),
'ionicons/icons': path.resolve(__dirname, 'mocks/ionicons-icons.js'),
}

config.plugins = config.plugins || []
Expand Down
8 changes: 8 additions & 0 deletions frontend/.storybook/mocks/_data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Stub for common/data/base/_data — CommonJS file that breaks Storybook's ESM bundler.
// This is the Flux data layer used for API calls, not needed for component rendering.
module.exports = {
get: () => Promise.resolve(),
post: () => Promise.resolve(),
put: () => Promise.resolve(),
delete: () => Promise.resolve(),
}
3 changes: 3 additions & 0 deletions frontend/.storybook/mocks/code-help.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Stub for common/code-help — CommonJS files that break Storybook's ESM bundler.
// These are SDK code snippets used in the docs UI, not needed for component rendering.
module.exports = {}
7 changes: 7 additions & 0 deletions frontend/.storybook/mocks/dompurify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Mock for dompurify — the real module's CJS/ESM export mismatch breaks Storybook.
// Tooltip only uses sanitize() to clean HTML before rendering.
export function sanitize(html) {
return html
}

export default { sanitize }
23 changes: 23 additions & 0 deletions frontend/.storybook/mocks/ionic-react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Storybook mock for @ionic/react.
// Renders IonIcon as a small inline placeholder so components that depend on
// IonIcon (ClearFilters, NavSubLink, BreadcrumbSeparator, etc.) can still
// render in stories without forcing a refactor of production components.
import React from 'react'

export const IonIcon = ({ icon, color, ...rest }) =>
React.createElement('span', {
...rest,
'aria-hidden': true,
'data-stub-icon': typeof icon === 'string' ? icon : 'icon',
style: {
backgroundColor: color || 'currentColor',
borderRadius: '50%',
display: 'inline-block',
height: '1em',
verticalAlign: 'middle',
width: '1em',
...(rest.style || {}),
},
})

export default { IonIcon }
25 changes: 25 additions & 0 deletions frontend/.storybook/mocks/ionicons-icons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Storybook mock for ionicons/icons.
// Real exports are SVG strings; for stories, the IonIcon mock just needs each
// import to resolve to *something*, so we proxy any property access to its
// own name. This lets `import { closeCircle, apps } from 'ionicons/icons'`
// resolve cleanly without listing every icon name.
const handler = {
get: (_target, prop) => (typeof prop === 'string' ? prop : undefined),
}
const proxy = new Proxy({}, handler)

// Named imports webpack already saw in source files; ESM static analysis
// won't pick up Proxy access for named imports, so we re-export the most
// common ones explicitly. Add to this list as new IonIcon usages appear.
export const apps = 'apps'
export const checkmark = 'checkmark'
export const checkmarkCircle = 'checkmarkCircle'
export const chevronDown = 'chevronDown'
export const chevronForward = 'chevronForward'
export const chevronUp = 'chevronUp'
export const close = 'close'
export const closeCircle = 'closeCircle'
export const informationCircleOutline = 'informationCircleOutline'
export const statsChart = 'statsChart'

export default proxy
10 changes: 9 additions & 1 deletion frontend/.storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import React from 'react'
import PropTypes from 'prop-types'
import Utils from 'common/utils/utils'
import ReactSelect, { components as selectComponents } from 'react-select'
// Register safe globals that project-components.js would normally set.
// Only import components that use the automatic JSX transform (TSX files).
// Legacy .js files (Flex, Column, Input) use old JSX transform and crash here.
import Tooltip from '../web/components/Tooltip'
import Row from '../web/components/base/grid/Row'
import FormGroup from '../web/components/base/grid/FormGroup'

window.React = React
window.propTypes = PropTypes
Expand Down Expand Up @@ -47,10 +52,13 @@ global.Select = (props) =>
components: { ...props.components },
}),
)
global.Tooltip = Tooltip
window.Tooltip = Tooltip
window.Row = Row
window.FormGroup = FormGroup

/** @type { import('storybook').Preview } */
const preview = {
tags: ['autodocs'],
globalTypes: {
theme: {
description: 'Dark mode toggle',
Expand Down
16 changes: 16 additions & 0 deletions frontend/.storybook/stubs/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,26 @@
// New components should NOT import Utils — use dedicated utilities instead.
// TODO: Remove once legacy .js files are migrated to TypeScript with imports.

import Color from 'color'

const Utils = {
colour: (input) => {
try {
return Color(input)
} catch {
return Color('#9DA4AE')
}
},
escapeHtml: (s) => String(s ?? ''),
fromParam: () =>
Object.fromEntries(
new URLSearchParams(typeof window !== 'undefined' ? window.location.search : ''),
),
GUID: () => Math.random().toString(36).slice(2),
getFlagsmithHasFeature: () => false,
getFlagsmithValue: () => '',
getPlansPermission: () => true,
isSaas: () => false,
keys: {
isEscape: (e) => e.key === 'Escape' || e.keyCode === 27,
},
Expand Down
45 changes: 45 additions & 0 deletions frontend/common/utils/__tests__/convertToPConfidence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Tests for the convertToPConfidence function extracted from Confidence.tsx
// The function maps p-values to confidence levels.

describe('convertToPConfidence', () => {
// Inline the function to test it independently of the component
const convertToPConfidence = (value: number) => {
if (value > 0.05) return 'LOW'
if (value >= 0.01) return 'REASONABLE'
if (value > 0.002) return 'HIGH'
return 'VERY_HIGH'
}

it('returns LOW for p-value above 0.05', () => {
expect(convertToPConfidence(0.1)).toBe('LOW')
expect(convertToPConfidence(0.5)).toBe('LOW')
expect(convertToPConfidence(1)).toBe('LOW')
})

it('returns REASONABLE for p-value between 0.01 and 0.05', () => {
expect(convertToPConfidence(0.05)).toBe('REASONABLE')
expect(convertToPConfidence(0.03)).toBe('REASONABLE')
expect(convertToPConfidence(0.01)).toBe('REASONABLE')
})

it('returns HIGH for p-value between 0.002 and 0.01', () => {
expect(convertToPConfidence(0.009)).toBe('HIGH')
expect(convertToPConfidence(0.005)).toBe('HIGH')
expect(convertToPConfidence(0.003)).toBe('HIGH')
})

it('returns VERY_HIGH for p-value at or below 0.002', () => {
expect(convertToPConfidence(0.002)).toBe('VERY_HIGH')
expect(convertToPConfidence(0.001)).toBe('VERY_HIGH')
expect(convertToPConfidence(0)).toBe('VERY_HIGH')
})

it('handles boundary values correctly', () => {
expect(convertToPConfidence(0.05)).toBe('REASONABLE') // exactly 0.05
expect(convertToPConfidence(0.0500001)).toBe('LOW') // just above 0.05
expect(convertToPConfidence(0.01)).toBe('REASONABLE') // exactly 0.01
expect(convertToPConfidence(0.0099)).toBe('HIGH') // just below 0.01
expect(convertToPConfidence(0.002)).toBe('VERY_HIGH') // exactly 0.002
expect(convertToPConfidence(0.0021)).toBe('HIGH') // just above 0.002
})
})
38 changes: 38 additions & 0 deletions frontend/common/utils/__tests__/fromParam.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Tests for URL parameter parsing (replacement for Utils.fromParam)
// The inline implementation uses Object.fromEntries(new URLSearchParams(...))

describe('fromParam (URLSearchParams)', () => {
const fromParam = (search: string) =>
Object.fromEntries(new URLSearchParams(search))

it('returns empty object for empty search string', () => {
expect(fromParam('')).toEqual({})
})

it('parses a single parameter', () => {
expect(fromParam('?tab=features')).toEqual({ tab: 'features' })
})

it('parses multiple parameters', () => {
expect(fromParam('?tab=features&page=2&search=hello')).toEqual({
page: '2',
search: 'hello',
tab: 'features',
})
})

it('decodes encoded characters', () => {
expect(fromParam('?name=hello%20world&q=a%26b')).toEqual({
name: 'hello world',
q: 'a&b',
})
})

it('handles parameters without values', () => {
expect(fromParam('?flag=')).toEqual({ flag: '' })
})

it('works without leading question mark', () => {
expect(fromParam('tab=settings')).toEqual({ tab: 'settings' })
})
})
5 changes: 4 additions & 1 deletion frontend/documentation/DecisionFramework.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Meta } from '@storybook/addon-docs/blocks'

<Meta title="Design System/Decision Framework" />
<Meta
title="Design System/Decision Framework"
parameters={{ chromatic: { disableSnapshot: true } }}
/>

# Where does a new colour go?

Expand Down
5 changes: 4 additions & 1 deletion frontend/documentation/Introduction.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
{/* Introduction.mdx */}
import { Meta } from '@storybook/addon-docs/blocks'

<Meta title="Introduction" />
<Meta
title="Introduction"
parameters={{ chromatic: { disableSnapshot: true } }}
/>

# Flagsmith Frontend

Expand Down
5 changes: 4 additions & 1 deletion frontend/documentation/TokenMaintenance.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Meta } from '@storybook/addon-docs/blocks'

<Meta title="Design System/Token Maintenance" />
<Meta
title="Design System/Token Maintenance"
parameters={{ chromatic: { disableSnapshot: true } }}
/>

# Token Maintenance Guide

Expand Down
5 changes: 4 additions & 1 deletion frontend/documentation/Typography.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Meta } from '@storybook/addon-docs/blocks'

<Meta title="Design System/Typography" />
<Meta
title="Design System/Typography"
parameters={{ chromatic: { disableSnapshot: true } }}
/>

# Typography Tokens

Expand Down
36 changes: 36 additions & 0 deletions frontend/documentation/components/AccordionCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react'
import type { Meta, StoryObj } from 'storybook'

import AccordionCard from 'components/base/accordion/AccordionCard'

const meta: Meta = {
parameters: {
docs: {
description: {
component:
'Collapsible card with a header, chevron toggle, and animated body. Use `defaultOpen` to start expanded and `isLoading` to show a spinner in place of content while fetching.',
},
},
layout: 'padded',
},
title: 'Components/Patterns/AccordionCard',
}
export default meta

type Story = StoryObj

export const Default: Story = {
render: () => (
<AccordionCard title='Summary'>
<p className='mb-0'>Accordion content goes here.</p>
</AccordionCard>
),
}

export const DefaultOpen: Story = {
render: () => (
<AccordionCard title='Details' defaultOpen>
<p className='mb-0'>This accordion starts open.</p>
</AccordionCard>
),
}
Loading
Loading