Skip to content
Open
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
26 changes: 22 additions & 4 deletions frontend/common/services/useExperiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,28 @@ export const experimentService = service
invalidatesTags: (_res, _err, { experimentId }) => [
{ id: experimentId, type: 'ExperimentResults' },
],
query: ({ environmentId, experimentId }) => ({
method: 'POST',
url: `environments/${environmentId}/experiments/${experimentId}/results/refresh/`,
}),
queryFn: async (
{ environmentId, experimentId },
_api,
_extraOptions,
baseQuery,
) => {
const result = await baseQuery({
method: 'POST',
url: `environments/${environmentId}/experiments/${experimentId}/results/refresh/`,
})
if (result.error) {
const retryAfter =
result.meta?.response?.headers?.get('Retry-After')
return {
error: {
...result.error,
retryAfter: retryAfter ? parseInt(retryAfter, 10) : null,
},
}
}
return { data: undefined }
},
}),
refreshExperimentExposures: builder.mutation<
Res['experimentExposures'],
Expand Down
121 changes: 121 additions & 0 deletions frontend/documentation/components/RefreshControl.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React from 'react'
import type { Meta, StoryObj } from 'storybook'

import RefreshControl from 'components/base/forms/RefreshControl'
import { themeClassNames } from 'components/base/forms/Button'

const themeOptions = Object.keys(themeClassNames) as Array<
keyof typeof themeClassNames
>

const meta: Meta<typeof RefreshControl> = {
argTypes: {
children: {
control: 'text',
description: 'Button label. Defaults to "Refresh".',
},
disabled: {
control: 'boolean',
description: 'Disables the button, preventing interaction.',
},
disabledReason: {
control: 'text',
description: 'Tooltip explaining why the button is disabled.',
},
isRefreshing: {
control: 'boolean',
description:
'Shows a busy label and disables the button while a refresh is in flight.',
},
label: {
control: 'text',
description:
'Related message rendered beneath the button — e.g. a retry countdown, an in-progress notice, or an error.',
},
theme: {
control: 'select',
description: 'Visual variant of the button.',
options: themeOptions,
table: { defaultValue: { summary: 'secondary' } },
},
},
args: {
children: 'Refresh',
disabled: false,
isRefreshing: false,
onRefresh: () => {},
theme: 'secondary',
},
component: RefreshControl,
parameters: { layout: 'centered' },
title: 'Components/RefreshControl',
}

export default meta

type Story = StoryObj<typeof RefreshControl>

export const Default: Story = {}

export const Refreshing: Story = {
args: {
isRefreshing: true,
label: 'Computing… results will update automatically.',
},
parameters: {
docs: {
description: {
story:
'While a refresh is in flight the button shows a busy label and disables itself. The `label` slot carries the in-progress message.',
},
},
},
}

export const Throttled: Story = {
args: {
disabled: true,
label: 'Computing, retry in 4m 30s',
},
parameters: {
docs: {
description: {
story:
'After hitting the API rate limit (HTTP 429), the caller disables the button and feeds a Retry-After countdown into `label`.',
},
},
},
}

export const Disabled: Story = {
args: {
disabled: true,
disabledReason: 'Refresh is disabled because the experiment is complete.',
},
parameters: {
docs: {
description: {
story:
'Disabled state with a `disabledReason` surfaced as the button tooltip.',
},
},
},
}

export const PrimaryWithError: Story = {
args: {
children: 'Refresh results',
label: (
<span className='text-danger'>The last results computation failed.</span>
),
theme: 'primary',
},
parameters: {
docs: {
description: {
story:
'Primary theme as used on the experiment results header, with an error message in the `label` slot.',
},
},
},
}
39 changes: 39 additions & 0 deletions frontend/web/components/base/forms/RefreshControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { FC, ReactNode } from 'react'
import Button, { themeClassNames } from './Button'

type RefreshControlProps = {
onRefresh: () => void
isRefreshing: boolean
disabled: boolean
disabledReason?: string
theme?: keyof typeof themeClassNames
label?: ReactNode
children?: ReactNode
}

const RefreshControl: FC<RefreshControlProps> = ({
children,
disabled,
disabledReason,
isRefreshing,
label,
onRefresh,
theme = 'secondary',
}) => (
<div className='d-flex flex-column align-items-end'>
<Button
disabled={disabled || isRefreshing}
onClick={onRefresh}
size='small'
theme={theme}
title={disabled ? disabledReason : undefined}
>
{isRefreshing ? 'Refreshing…' : children ?? 'Refresh'}
</Button>
{label ? (
<div className='text-muted fs-caption mt-1 text-end'>{label}</div>
) : null}
</div>
)

export default RefreshControl
Comment on lines +1 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd keep this one out of base/forms and move it next to its consumers in experiments/results/components.

Looking at what it actually does over Button: swap the label to "Refreshing…", fold isRefreshing into disabled, map disabledReason to title, and a caption slot underneath. So it's really "a Button with a loading state" plus a refresh-specific caption, and both its importers are experiment panels. I don't think it earns a spot as a shared primitive yet.

By the way, I like the idea of loading state, we should add a isLoading prop that can provide this behaviour easily.

(if it moves local, the Storybook story can be moved under Experiments or be dropped, your call)

Image

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import moment from 'moment'
import { LineChart } from 'components/charts'
import ContentCard from 'components/base/grid/ContentCard'
import Button from 'components/base/forms/Button'
import RefreshControl from 'components/base/forms/RefreshControl'
import Icon from 'components/icons/Icon'
import { colorIconDanger } from 'common/theme/tokens'
import {
useGetExperimentExposuresQuery,
useRefreshExperimentExposuresMutation,
Expand All @@ -21,9 +24,14 @@ import {
canRefreshExposures,
deriveExposuresViewState,
} from './exposuresViewState'
import AsOfRefreshControl, { AsOfLabel } from './AsOfRefreshControl'
import './results.scss'

const AsOfLabel: FC<{ asOf: string | null }> = ({ asOf }) => (
<span className='text-muted fs-caption'>
{asOf ? `As of ${moment.utc(asOf).format('D MMM YYYY, HH:mm')} UTC` : ''}
</span>
)

const buildLegendLabels = (totals: VariantTotal[]): Record<string, string> => {
const labels: Record<string, string> = {}
totals.forEach((t) => {
Expand Down Expand Up @@ -133,49 +141,46 @@ const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({
[payload, identities],
)

const isRefreshing = viewState.kind === 'refreshing' || isSubmitting
const isRefreshing =
refreshRequested || viewState.kind === 'refreshing' || isSubmitting
const headline = payload ? getHeadlineTotal(payload) : 0
const hasData = !!payload && headline > 0

const handleRefresh = useCallback(async () => {
setRefreshRequested(true)
setPollStartedAt(Date.now())
const result = await refresh({
environmentId,
experimentId: experiment.id,
})
if ('error' in result && result.error) {
setRefreshRequested(false)
setPollStartedAt(null)
const seconds = parseRetryAfter(result.error)
if (seconds !== null) {
setRetryAfter(seconds)
} else {
toast('Failed to refresh exposures', 'danger')
}
} else {
setRefreshRequested(true)
setPollStartedAt(Date.now())
}
}, [refresh, environmentId, experiment.id])

const action = (
<div className='d-flex flex-column align-items-end'>
<AsOfRefreshControl
asOf={exposures?.as_of ?? null}
disabled={
!availability.canRefresh || isRefreshing || retryAfter !== null
}
disabledReason={
availability.reason
? REFRESH_DISABLED_COPY[availability.reason]
: undefined
}
isRefreshing={isRefreshing && hasData}
onRefresh={handleRefresh}
/>
{retryAfter !== null && (
<div className='text-muted fs-caption mt-1 text-end'>
Computing, retry in {formatCountdown(retryAfter)}
</div>
)}
</div>
<RefreshControl
disabled={!availability.canRefresh || isRefreshing || retryAfter !== null}
disabledReason={
availability.reason
? REFRESH_DISABLED_COPY[availability.reason]
: undefined
}
isRefreshing={isRefreshing && hasData}
label={
retryAfter !== null
? `Computing, retry in ${formatCountdown(retryAfter)}`
: undefined
}
onRefresh={handleRefresh}
/>
)

const asOf = exposures?.as_of ?? null
Expand Down Expand Up @@ -207,7 +212,7 @@ const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({
<>
<br />
<span className='d-inline-flex align-items-center gap-1 text-danger'>
<Icon fill='#e53e3e' name='warning' width={14} />
<Icon fill={colorIconDanger} name='warning' width={14} />
The last exposure computation failed. Showing previously
computed data.
</span>
Expand Down Expand Up @@ -244,7 +249,7 @@ const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({
<>
<br />
<span className='d-inline-flex align-items-center gap-1 text-danger'>
<Icon fill='#e53e3e' name='warning' width={14} />
<Icon fill={colorIconDanger} name='warning' width={14} />
The last exposure computation failed. Showing previously
computed data.
</span>
Expand All @@ -256,7 +261,7 @@ const ExperimentExposuresPanel: FC<ExperimentExposuresPanelProps> = ({

{!payload && viewState.kind === 'error' && (
<div className='d-flex align-items-center justify-content-center gap-1 text-danger fs-caption py-4'>
<Icon fill='#e53e3e' name='warning' width={14} />
<Icon fill={colorIconDanger} name='warning' width={14} />
The last exposure computation failed.
</div>
)}
Expand Down
Loading
Loading