Skip to content
Draft
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
24 changes: 22 additions & 2 deletions frontend/web/components/pages/usage/UsageDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,27 @@ export type UsageDashboardProps = {
data: Res['organisationUsage'] | undefined
total: number
limit: PlanLimit
comparable?: boolean
showPlanCeiling?: boolean
projectName?: string
hasBillingPeriod: boolean
isError?: boolean
isLoading?: boolean
onRetry?: () => void
filters?: ReactNode
}

const UsageDashboard: FC<UsageDashboardProps> = ({
comparable,
data,
filters,
hasBillingPeriod,
isError,
isLoading,
limit,
onRetry,
projectName,
showPlanCeiling,
total,
}) => {
let content
Expand All @@ -38,16 +46,28 @@ const UsageDashboard: FC<UsageDashboardProps> = ({
title='Usage could not be loaded'
description='Something went wrong fetching usage for this period. Try again in a moment.'
icon='bar-chart'
action={
onRetry && (
<Button onClick={onRetry} theme='secondary'>
Try again
</Button>
)
}
/>
)
} else {
content = (
<>
<UsageMeter total={total} limit={limit} />
<UsageMeter
total={total}
limit={limit}
comparable={comparable}
projectName={projectName}
/>

<UsageOverTime
data={data}
limit={limit}
limit={showPlanCeiling ? limit : undefined}
isBillingPeriod={hasBillingPeriod}
/>
</>
Expand Down
35 changes: 29 additions & 6 deletions frontend/web/components/pages/usage/UsageDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { PeriodOption } from 'common/types/requests'
import UsageDashboard from './UsageDashboard'
import {
isBillingPeriodSelected,
isComparableToAllowance,
showsPlanCeiling,
periodsFor,
PeriodSelection,
planHasBillingPeriod,
Expand All @@ -25,11 +27,14 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
organisationId,
}) => {
const [project, setProject] = useState<string | undefined>()
const [projectName, setProjectName] = useState<string | undefined>()
const selectedProjectId = project ? Number(project) : undefined

const {
data: organisation,
isError: organisationFailed,
isLoading: loadingOrganisation,
refetch: refetchOrganisation,
} = useGetOrganisationQuery(
organisationId ? { id: organisationId } : skipToken,
)
Expand All @@ -45,19 +50,24 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
data,
isError: usageFailed,
isFetching: loadingUsage,
isUninitialized: usageNotStarted,
refetch: refetchUsage,
} = useGetOrganisationUsageQuery(
organisationId && organisation
? {
billing_period: billingPeriod,
organisationId,
projectId: project ? Number(project) : undefined,
projectId: selectedProjectId,
}
: skipToken,
)
const { data: subscriptionMeta, isLoading: loadingLimit } =
useGetSubscriptionMetadataQuery(
organisationId ? { id: organisationId } : skipToken,
)
const {
data: subscriptionMeta,
isLoading: loadingLimit,
refetch: refetchLimit,
} = useGetSubscriptionMetadataQuery(
organisationId ? { id: organisationId } : skipToken,
)
Comment on lines +64 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- changed hunk ---'
git diff -- frontend/web/components/pages/usage/UsageDashboardPage.tsx

printf '%s\n' '--- page definitions and usage ---'
sed -n '1,180p' frontend/web/components/pages/usage/UsageDashboardPage.tsx
fd -i 'UsageDashboard' frontend
rg -n -C 5 'useGetSubscriptionMetadataQuery|isError=|Try again|loadingLimit|subscriptionMeta' frontend/web/components/pages/usage frontend/common frontend/components frontend/project 2>/dev/null || true

Repository: Flagsmith/flagsmith

Length of output: 17401


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- UsageDashboard contract ---'
sed -n '1,120p' frontend/web/components/pages/usage/UsageDashboard.tsx

printf '%s\n' '--- subscription metadata endpoint ---'
sed -n '1,80p' frontend/common/services/useSubscriptionMetadata.ts

printf '%s\n' '--- shared service base ---'
rg -n -C 6 'const service|createApi|fetchBaseQuery|baseQuery' frontend/common/services frontend/common | head -160

Repository: Flagsmith/flagsmith

Length of output: 17024


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- RTK Query dependency and base service ---'
rg -n '"`@reduxjs/toolkit`"|reduxjs/toolkit' package.json frontend/package.json frontend/*/package.json 2>/dev/null || true
fd -i 'service' frontend/common -t f | head -40
fd -i 'package.json' . -t f -E node_modules -E dist -E build | head -30

Repository: Flagsmith/flagsmith

Length of output: 334


Surface subscription metadata failures.

Destructure isError: limitFailed from useGetSubscriptionMetadataQuery and include it in UsageDashboard's isError prop. Otherwise, a failed request can render the dashboard without a plan limit or the Try again action.


const periods = periodsFor(planIsBilled)

Expand All @@ -71,8 +81,18 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
total={data?.totals?.total ?? 0}
limit={subscriptionMeta?.max_api_calls}
hasBillingPeriod={isBillingPeriodSelected(billingPeriod)}
comparable={isComparableToAllowance(billingPeriod)}
showPlanCeiling={showsPlanCeiling(billingPeriod, selectedProjectId)}
projectName={selectedProjectId ? projectName : undefined}
isError={organisationFailed || usageFailed}
isLoading={loadingOrganisation || loadingUsage || loadingLimit}
onRetry={() => {
refetchOrganisation()
refetchLimit()
if (!usageNotStarted) {
refetchUsage()
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
filters={
<Row className='gap-3 align-items-end'>
<div className='usage-dashboard__filter'>
Expand All @@ -90,7 +110,10 @@ const UsageDashboardPage: FC<UsageDashboardPageProps> = ({
inputId='usage-project'
showAll
organisationId={organisationId}
onChange={setProject}
onChange={(id: string, name: string) => {
setProject(id)
setProjectName(name)
}}
value={project}
/>
</div>
Expand Down
29 changes: 29 additions & 0 deletions frontend/web/components/pages/usage/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Subscription } from 'common/types/responses'
import {
isComparableToAllowance,
showsPlanCeiling,
isBillingPeriodSelected,
planHasBillingPeriod,
periodsFor,
Expand Down Expand Up @@ -81,6 +83,33 @@ describe('UsageDashboard utils', () => {
})
})

describe('isComparableToAllowance', () => {
it('compares the 30 day window and the billing periods', () => {
expect(isComparableToAllowance(undefined)).toBe(true)
expect(isComparableToAllowance('current_billing_period')).toBe(true)
})

it('refuses the 90 day window against a 30 day allowance', () => {
expect(isComparableToAllowance('90_day_period')).toBe(false)
})
})

describe('showsPlanCeiling', () => {
it('draws the ceiling for the organisation over a comparable period', () => {
expect(showsPlanCeiling(undefined, undefined)).toBe(true)
expect(showsPlanCeiling('current_billing_period', undefined)).toBe(true)
})

it('drops it for one project, which will never reach the ceiling', () => {
expect(showsPlanCeiling(undefined, 12)).toBe(false)
expect(showsPlanCeiling('current_billing_period', 12)).toBe(false)
})

it('drops it for the 90 day window', () => {
expect(showsPlanCeiling('90_day_period', undefined)).toBe(false)
})
})

describe('periodsFor', () => {
it('offers the billing periods only when there is a term', () => {
expect(periodsFor(true).map((period) => period.value)).toContain(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { FC, ReactNode } from 'react'
import Format from 'common/utils/format'
import UsageBar from 'components/shared/UsageBar'
import {
PlanLimit,
toneFor,
usagePercent,
} from 'components/shared/UsageBar/utils'
import { meterCopy } from './utils'
import { PlanLimit } from 'components/shared/UsageBar/utils'
import { meterCopy, meterTone } from './utils'
import './UsageMeter.scss'

const WARN_AT = 75
Expand All @@ -15,39 +10,53 @@ const NOTIFICATION_THRESHOLDS = [WARN_AT, 100]
export type UsageMeterProps = {
total: number
limit: PlanLimit
comparable?: boolean
projectName?: string
note?: ReactNode
}

const UsageMeter: FC<UsageMeterProps> = ({ limit, note, total }) => {
const copy = meterCopy(total, limit)
const tone = toneFor(usagePercent(total, limit), WARN_AT)
const UsageMeter: FC<UsageMeterProps> = ({
comparable = true,
limit,
note,
projectName,
total,
}) => {
const copy = meterCopy(total, limit, comparable, projectName)
const tone = meterTone(total, limit, comparable, WARN_AT)

return (
<div className='p-4 mb-3 border border-default rounded-lg bg-surface-default'>
<div className='d-flex align-items-end justify-content-between gap-3 mb-4'>
<div>
<p className='fs-caption text-secondary mb-1'>Plan usage</p>
<p className='fs-caption text-secondary mb-1'>{copy.label}</p>
<div className='d-flex align-items-end gap-2'>
<span className={`usage-meter__percent fw-bold lh-1 text-${tone}`}>
<span
className={`usage-meter__percent fw-bold lh-1 ${
tone ? `text-${tone}` : ''
}`}
>
{copy.headline}
</span>
<span className='fs-captionSmall text-secondary'>
{copy.headlineCaption}
</span>
</div>
</div>
<div className='usage-meter__fraction text-end'>
<div>
<strong>{Format.shortenNumber(total)}</strong>
{copy.fractionSuffix}
{copy.fraction && (
<div className='usage-meter__fraction text-end'>
<div>
<strong>{copy.fraction.value}</strong>
{copy.fraction.suffix}
</div>
<div className='fs-captionSmall text-secondary'>
{copy.fraction.caption}
</div>
</div>
<div className='fs-captionSmall text-secondary'>
{copy.fractionCaption}
</div>
</div>
)}
</div>

{!!limit && (
{!!limit && comparable && (
<UsageBar
usage={total}
limit={limit}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { meterCopy } from 'components/pages/usage/components/UsageMeter/utils'
import {
meterCopy,
meterTone,
} from 'components/pages/usage/components/UsageMeter/utils'

describe('UsageMeter utils', () => {
describe('meterCopy', () => {
it('reads as a percentage of the limit when there is one', () => {
expect(meterCopy(1500000, 2000000)).toEqual({
fractionCaption: 'API calls used / plan limit',
fractionSuffix: ' / 2M',
fraction: {
caption: 'API calls used / plan limit',
suffix: ' / 2M',
value: '1.5M',
},
headline: '75%',
headlineCaption: 'of plan consumed',
label: 'Plan usage',
})
})

Expand All @@ -20,17 +27,57 @@ describe('UsageMeter utils', () => {
'falls back to the raw count when the limit is %p',
(limit) => {
expect(meterCopy(1500000, limit)).toEqual({
fractionCaption: 'API calls used',
fractionSuffix: '',
fraction: undefined,
headline: '1.5M',
headlineCaption: 'API calls',
label: 'Plan usage',
})
},
)

it('reports the count and names the allowance window when not comparable', () => {
expect(meterCopy(1500000, 2000000, false)).toEqual({
fraction: { caption: 'allowance per 30 days', value: '2M' },
headline: '1.5M',
headlineCaption: 'API calls',
label: 'Plan usage',
})
})

it('names the project and what the percentage is of when one is chosen', () => {
const copy = meterCopy(500000, 2000000, true, 'Checkout')

expect(copy.label).toBe('Plan usage · Checkout')
expect(copy.headline).toBe('25%')
expect(copy.headlineCaption).toBe('of your allowance')
})

it('keeps naming the project when the period rules the percentage out', () => {
const copy = meterCopy(500000, 2000000, false, 'Checkout')

expect(copy.label).toBe('Plan usage · Checkout')
expect(copy.headline).toBe('500K')
})

it('shows zero rather than NaN for an organisation with no calls', () => {
expect(meterCopy(0, null).headline).toBe('0')
expect(meterCopy(0, 2000000).headline).toBe('0%')
})
})

describe('meterTone', () => {
it('tracks the thresholds when the comparison holds', () => {
expect(meterTone(500000, 2000000, true, 75)).toBe('success')
expect(meterTone(1600000, 2000000, true, 75)).toBe('warning')
expect(meterTone(2000000, 2000000, true, 75)).toBe('danger')
})

it('has no tone to give when the comparison does not hold', () => {
expect(meterTone(9000000, 2000000, false, 75)).toBeUndefined()
})

it('has no tone to give without a limit', () => {
expect(meterTone(9000000, null, true, 75)).toBeUndefined()
})
})
})
Loading
Loading