Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
05c82fd
feat(experimentation): add Experiment model and CRUD endpoints
Zaimwa9 May 25, 2026
005ae23
feat(experimentation): rebased main
Zaimwa9 May 25, 2026
b27797d
feat(experimentation): reworked-tests
Zaimwa9 May 25, 2026
43b0897
feat(experimentation): type-linting
Zaimwa9 May 25, 2026
5d55695
feat(experimentation): changed return status when experiment exists a…
Zaimwa9 May 25, 2026
4269a8a
feat(experimentation): added test coverage
Zaimwa9 May 25, 2026
52fa070
feat(experimentation): added test coverage for existing mv features
Zaimwa9 May 25, 2026
683fc69
feat(experimentation): extracted status actions
Zaimwa9 May 25, 2026
7eef7bf
feat(experimentation): add Experiment types to frontend
Zaimwa9 May 25, 2026
c835318
feat(experimentation): add RTK Query service for experiments
Zaimwa9 May 25, 2026
17360b0
feat(experimentation): add wizard utility components
Zaimwa9 May 25, 2026
c7d0bc2
feat(experimentation): add SetupStep with feature flag selector
Zaimwa9 May 25, 2026
07905c9
feat(experimentation): add AudienceStep, MeasurementStep, and ReviewStep
Zaimwa9 May 25, 2026
f71112d
feat(experimentation): add CreateExperimentWizard container
Zaimwa9 May 25, 2026
a60444f
feat(experimentation): rewrite ExperimentsPage with list and create w…
Zaimwa9 May 25, 2026
998df3c
refactor(experimentation): remove unused onCancel prop from wizard
Zaimwa9 May 25, 2026
a4519a9
feat(experimentation): add ContentCard component and replace Panel wi…
Zaimwa9 May 25, 2026
8ef2c9b
fix(experimentation): match POC layout with proper field styling and …
Zaimwa9 May 25, 2026
0173407
fix(experimentation): match POC stepper design with connecting lines …
Zaimwa9 May 25, 2026
6e02891
fix(experimentation): white background for hypothesis textarea
Zaimwa9 May 25, 2026
6b6fffb
fix(experimentation): resolve environment API key to numeric ID for f…
Zaimwa9 May 26, 2026
49ffcfc
feat(experimentation): add VariationTable component matching POC desi…
Zaimwa9 May 26, 2026
85102ae
fix(experimentation): rename to Create Experiment, add confirm modal,…
Zaimwa9 May 26, 2026
8aab7df
fix(experimentation): guard against null initial_value in VariationTable
Zaimwa9 May 26, 2026
10603f6
feat(experimentation): show warehouse setup prompt when no connection…
Zaimwa9 May 26, 2026
99df820
feat: wip list experiments
Zaimwa9 May 27, 2026
3e5a9e9
feat(experimentation): move transition logic to service layer
Zaimwa9 May 27, 2026
39b1a5d
feat(experimentation): use admin_client_new in tests
Zaimwa9 May 27, 2026
d560403
feat(experimentation): leaked pii and misc review feedback
Zaimwa9 May 27, 2026
ed932fb
feat(experimentation): return feature object along with experiment en…
Zaimwa9 May 27, 2026
421a070
feat(experimentation): type lint
Zaimwa9 May 27, 2026
eaf6280
Merge branch 'feat/scaffold-experimentation-models-and-cruds' of gith…
Zaimwa9 May 27, 2026
86db405
feat(experimentation): fixed N+1 query and consistent actions responses
Zaimwa9 May 27, 2026
4b46cc8
feat(experimentation): added pagination transation and delete guard o…
Zaimwa9 May 27, 2026
18cc27b
feat(experiment): rebased main
Zaimwa9 May 27, 2026
4430635
feat: reverted to sequential database commits
Zaimwa9 May 27, 2026
940c99b
feat: rebased main
Zaimwa9 May 27, 2026
b81ca3e
feat: misc-details
Zaimwa9 May 27, 2026
b4f5c7c
feat: rebased-main
Zaimwa9 May 27, 2026
fba6f11
feat: added tests for create race condition
Zaimwa9 May 27, 2026
35a5db0
feat: rebased
Zaimwa9 May 27, 2026
d15efdf
Merge branch 'feat/experiment-model-robustness' of github.com:Flagsmi…
Zaimwa9 May 27, 2026
c3bee29
feat: implemented pagination
Zaimwa9 May 27, 2026
db0e867
refactor(experiments): address code review feedback from PR #7596
Zaimwa9 May 28, 2026
444cdf3
Merge branch 'main' of github.com:Flagsmith/flagsmith into feat/exper…
Zaimwa9 May 28, 2026
40add43
feat: rebased-parent
Zaimwa9 May 28, 2026
e6e784e
feat(experiment): finetuned list view and added pagination
Zaimwa9 May 28, 2026
4cc5b90
feat(experiment): review feedback
Zaimwa9 May 28, 2026
04f4557
feat(experiment): changed ionicon with icons
Zaimwa9 May 28, 2026
da6bb2a
feat(experiment): rebased froexperiment wizard
Zaimwa9 May 28, 2026
02b5faa
feat(experiment): cleanup and refacto to follow design patterns
Zaimwa9 May 28, 2026
e3b08eb
feat(experiment): cleaned up rebases
Zaimwa9 May 28, 2026
b9b3618
feat(experiment): gemini comments
Zaimwa9 May 28, 2026
8a8dbb4
feat: fixed rebase loss and tokens instructions
Zaimwa9 May 28, 2026
6aeaff1
feat: dry on scss
Zaimwa9 May 28, 2026
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
73 changes: 64 additions & 9 deletions frontend/.claude/context/ui-patterns.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,63 @@
# UI Patterns & Best Practices

**Use design tokens, utility classes, and primitive components before writing anything custom.** Each tier composes the one below — pick the highest tier that fits before reaching for SCSS.

## Design Tokens & Primitives

### Colour Tokens

**Location:** `common/theme/tokens.ts` (auto-generated from `common/theme/tokens.json`)

Never hardcode hex colours in TSX or SCSS. Use:
- **In SCSS:** CSS variables directly — `var(--color-text-success)`, `var(--color-border-action)`, `var(--color-surface-muted)`
- **In TSX (inline styles / chart props):** JS token constants — `colorTextSuccess`, `colorBorderAction`, `colorSurfaceMuted`

Token categories: `colorText*`, `colorIcon*`, `colorBorder*`, `colorSurface*`, `colorChart*`, `radius*`, `shadow*`, `duration*`, `easing*`.

Comment thread
Zaimwa9 marked this conversation as resolved.
### Utility Classes

Before writing custom SCSS, check for token-driven utilities:

- **Token utilities** (`web/styles/_token-utilities.scss`): `bg-surface-*`, `text-*`, `border-*`, `rounded-*`, `shadow-*`, `transition-*`
- **Bootstrap utilities** (layout / spacing): `d-flex`, `flex-column`, `gap-*`, `p-*`, `m-*`, `text-center`, `align-items-*`, `justify-content-*`

Prefer utility classes over one-off SCSS rules. Combine them freely — `className='d-flex gap-3 bg-surface-muted rounded-lg p-3'`.

### Primitive Components — Use Before Building

Before creating a custom element, check if an existing primitive fits:

| Need | Primitive | Location |
|------|-----------|----------|
| Coloured dot / swatch | `ColorSwatch` | `components/ColorSwatch.tsx` — accepts `color`, `size` (`sm`/`md`/`lg`), `shape` (`square`/`circle`) |
| Text input | `Input` | `components/base/forms/Input.js` — has `search` prop for built-in search icon |
| Labelled field (text, textarea) | `InputGroup` | `components/base/forms/InputGroup.js` — has `textarea` prop, `title`, handles label + layout |
| Icons | `Icon` | `components/icons/Icon.tsx` — project's own icon set. **Never use external icon libraries** (ionicons, etc.) |
| Confirm dialog | `openConfirm` | `components/base/Modal` — see Confirmation Dialogs section below |

### Inline Styles

Avoid inline `style={}` props. Acceptable exceptions:
- Flex layout fixes (`minWidth: 0` for overflow prevention)
- Dynamic values that genuinely vary at runtime (e.g. chart dimensions)

For fixed dimensions (widths, padding), prefer SCSS classes.

## Code Organisation

### Component File Structure

Multi-file components (TSX + SCSS) use a folder structure with a barrel export:

```
ComponentName/
├── ComponentName.tsx
├── ComponentName.scss
└── index.ts ← barrel export
```

Single-file components without their own styles can live as a single `.tsx` next to peers — no folder needed.

Comment thread
Zaimwa9 marked this conversation as resolved.
## Storybook (Optional)

When working on complex or unfamiliar components, you can query Storybook MCP (`list-all-documentation`, then `get-documentation`) to discover existing components, their props, and visual examples. This is optional — for simple changes, grepping the codebase is fine.
Expand Down Expand Up @@ -70,7 +128,7 @@ Use Bootstrap classes for responsive behavior:

## Tabs Component

**Location:** `components/base/forms/Tabs.tsx`
**Location:** `components/navigation/TabMenu/Tabs.tsx`

### Basic Usage

Expand Down Expand Up @@ -163,13 +221,10 @@ openConfirm({
```

### Parameters
- `title: string` - Dialog title
- `title: ReactNode` - Dialog title
- `body: ReactNode` - Dialog content (can be JSX)
- `onYes: (closeModal: () => void) => void` - Callback when user confirms
- `onYes: () => void` - Callback when user confirms. The modal closes automatically after `onYes` returns.
- `onNo?: () => void` - Optional callback when user cancels
- `challenge?: string` - Optional challenge text user must type to confirm

### Key Points
- The `onYes` callback receives a `closeModal` function
- Always call `closeModal()` when the action completes successfully
- Can be async - use `async (closeModal) => { ... }`
- `yesText?: string` - Label for the confirm button (default "OK")
- `noText?: string` - Label for the cancel button (default "Cancel")
- `destructive?: boolean` - Renders the confirm button in danger styling. Use for delete/discard actions.
56 changes: 52 additions & 4 deletions frontend/common/services/useExperiment.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { Res } from 'common/types/responses'
import { Req } from 'common/types/requests'
import { service } from 'common/service'
import Utils from 'common/utils/utils'
import transformCorePaging from 'common/transformCorePaging'

export const experimentService = service
.enhanceEndpoints({ addTagTypes: ['Experiment'] })
.injectEndpoints({
endpoints: (builder) => ({
completeExperiment: builder.mutation<
Res['experiment'],
Req['experimentAction']
>({
invalidatesTags: [{ id: 'LIST', type: 'Experiment' }],
query: ({ environmentId, experimentId }) => ({
method: 'POST',
url: `environments/${environmentId}/experiments/${experimentId}/complete/`,
}),
}),
createExperiment: builder.mutation<
Res['experiment'],
Req['createExperiment']
Expand All @@ -17,14 +29,50 @@ export const experimentService = service
url: `environments/${environmentId}/experiments/`,
}),
}),
deleteExperiment: builder.mutation<void, Req['deleteExperiment']>({
invalidatesTags: [{ id: 'LIST', type: 'Experiment' }],
query: ({ environmentId, experimentId }) => ({
method: 'DELETE',
url: `environments/${environmentId}/experiments/${experimentId}/`,
}),
}),
getExperiments: builder.query<Res['experiments'], Req['getExperiments']>({
providesTags: [{ id: 'LIST', type: 'Experiment' }],
query: ({ environmentId }) => ({
url: `environments/${environmentId}/experiments/`,
query: ({ environmentId, ...rest }) => ({
url: `environments/${environmentId}/experiments/?${Utils.toParam(
rest,
)}`,
}),
transformResponse: (res, _, req) => transformCorePaging(req, res),
}),
pauseExperiment: builder.mutation<
Res['experiment'],
Req['experimentAction']
>({
invalidatesTags: [{ id: 'LIST', type: 'Experiment' }],
query: ({ environmentId, experimentId }) => ({
method: 'POST',
url: `environments/${environmentId}/experiments/${experimentId}/pause/`,
}),
}),
startExperiment: builder.mutation<
Res['experiment'],
Req['experimentAction']
>({
invalidatesTags: [{ id: 'LIST', type: 'Experiment' }],
query: ({ environmentId, experimentId }) => ({
method: 'POST',
url: `environments/${environmentId}/experiments/${experimentId}/start/`,
}),
}),
}),
})

export const { useCreateExperimentMutation, useGetExperimentsQuery } =
experimentService
export const {
useCompleteExperimentMutation,
useCreateExperimentMutation,
useDeleteExperimentMutation,
useGetExperimentsQuery,
usePauseExperimentMutation,
useStartExperimentMutation,
} = experimentService
8 changes: 7 additions & 1 deletion frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
StageActionType,
StageActionBody,
ChangeRequest,
ExperimentStatus,
FlagsmithValue,
TagStrategy,
} from './responses'
Expand Down Expand Up @@ -984,10 +985,15 @@ export type Req = {
name?: string
config?: Record<string, string>
}
getExperiments: { environmentId: string }
getExperiments: PagedRequest<{
environmentId: string
status?: ExperimentStatus
}>
createExperiment: {
environmentId: string
body: { name: string; hypothesis: string; feature: number }
}
experimentAction: { environmentId: string; experimentId: number }
deleteExperiment: { environmentId: string; experimentId: number }
// END OF TYPES
}
18 changes: 16 additions & 2 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,11 +579,21 @@ export type FeatureType = 'STANDARD' | 'MULTIVARIATE'

export type ExperimentStatus = 'created' | 'running' | 'paused' | 'completed'

export type ExperimentStatusCounts = Record<ExperimentStatus, number>

export type ExperimentFeature = {
id: number
name: string
type: FeatureType
initial_value: string | null
multivariate_options: MultivariateOption[]
}

export type Experiment = {
id: number
name: string
hypothesis: string
feature: number
feature: ExperimentFeature
status: ExperimentStatus
created_at: string
updated_at: string
Expand Down Expand Up @@ -1363,7 +1373,11 @@ export type Res = {
gitlabIssues: PagedResponse<GitLabIssue>
gitlabMergeRequests: PagedResponse<GitLabMergeRequest>
warehouseConnections: WarehouseConnection[]
experiments: Experiment[]
experiments: PagedResponse<Experiment> & {
currentPage: number
pageSize: number
status_counts?: ExperimentStatusCounts
}
experiment: Experiment
// END OF TYPES
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
justify-content: space-between;
}

.input-container {
display: block;
}

&__title {
font-size: var(--font-body-size, 0.875rem);
font-weight: var(--font-weight-bold, 700);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { FC, useCallback, useMemo } from 'react'
import { ExperimentStatus } from 'common/types/responses'
import {
useCompleteExperimentMutation,
useDeleteExperimentMutation,
usePauseExperimentMutation,
useStartExperimentMutation,
} from 'common/services/useExperiment'
import DropdownMenu from 'components/base/DropdownMenu'
Comment thread
Zaimwa9 marked this conversation as resolved.

type ExperimentActionDropdownProps = {
experimentId: number
experimentName: string
status: ExperimentStatus
environmentId: string
}

const ExperimentActionDropdown: FC<ExperimentActionDropdownProps> = ({
environmentId,
experimentId,
experimentName,
status,
}) => {
const [startExperiment] = useStartExperimentMutation()
const [pauseExperiment] = usePauseExperimentMutation()
const [completeExperiment] = useCompleteExperimentMutation()
const [deleteExperiment] = useDeleteExperimentMutation()

const params = useMemo(
() => ({ environmentId, experimentId }),
[environmentId, experimentId],
)

const handleStart = useCallback(async () => {
try {
await startExperiment(params).unwrap()
toast('Experiment started')
} catch {
toast('Failed to start experiment', 'danger')
}
}, [startExperiment, params])

const handlePause = useCallback(async () => {
try {
await pauseExperiment(params).unwrap()
toast('Experiment paused')
} catch {
toast('Failed to pause experiment', 'danger')
}
}, [pauseExperiment, params])

const handleComplete = useCallback(() => {
openConfirm({
body: (
<span>
Are you sure you want to mark <strong>{experimentName}</strong> as
completed? This action cannot be undone.
</span>
),
noText: 'Cancel',
onYes: async () => {
try {
await completeExperiment(params).unwrap()
toast('Experiment completed')
} catch {
toast('Failed to complete experiment', 'danger')
}
},
title: 'Complete experiment?',
yesText: 'Complete',
})
}, [completeExperiment, experimentName, params])
Comment thread
Zaimwa9 marked this conversation as resolved.

const handleDelete = useCallback(() => {
openConfirm({
body: (
<span>
Are you sure you want to delete <strong>{experimentName}</strong>?
This action cannot be undone.
</span>
),
destructive: true,
noText: 'Cancel',
onYes: async () => {
try {
await deleteExperiment(params).unwrap()
toast('Experiment deleted')
} catch {
toast('Failed to delete experiment', 'danger')
}
},
title: 'Delete experiment?',
yesText: 'Delete',
})
}, [deleteExperiment, experimentName, params])
Comment thread
Zaimwa9 marked this conversation as resolved.

const items = useMemo(() => {
switch (status) {
case 'created':
return [
{ label: 'Start Experiment', onClick: handleStart },
{
className: 'text-danger',
label: 'Delete',
onClick: handleDelete,
},
]
case 'running':
return [
{ label: 'Pause Experiment', onClick: handlePause },
{ label: 'Mark as Completed', onClick: handleComplete },
]
case 'paused':
return [
{ label: 'Resume Experiment', onClick: handleStart },
{ label: 'Mark as Completed', onClick: handleComplete },
]
default:
return []
}
}, [status, handleStart, handlePause, handleComplete, handleDelete])

if (status === 'completed') return null

return <DropdownMenu items={items} />
}

export default ExperimentActionDropdown
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ExperimentActionDropdown'
Loading
Loading