1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-28 00:17:12 +01:00

feat(1-3262): initial impl of new month/range picker (#9122)

This PR implements a first version of the new month/range picker for the
data usage graphs. It's minimally hooked up to the existing
functionality to not take anything away.

This primary purpose of this PR is to get the design and interaction out
on sandbox so that UX can have a look and we can make adjustments.

As such, there are a few things in the code that we'll want to clean up
before removing the flag later:
- for faster iteration, I've used a lot of CSS nesting and element
selectors. this isn't usually how we do it here, so we'll probably want
to extract into styled components later
- there is a temporary override of the value in the period selector so
that you can select ranges. It won't affect the chart state, but it
affects the selector state. Again, this lets you see how it acts and
works.
- I've added a `NewHeader` component because the existing setup smushed
the selector (it's a MUI grid setup, which isn't very flexible). I don't
know what we want to do with this in the end, but the existing chart
*does* have some problems when you resize your window, at least
(although this is likely due to the chart, and can be solved in the same
way that we did for the personal dashboards).


![image](https://github.com/user-attachments/assets/f3ce3ff9-bab3-4d00-afbe-56f5624fbe16)
This commit is contained in:
Thomas Heartman 2025-01-21 12:15:43 +01:00 committed by GitHub
parent 08a28c99d6
commit 857c91b803
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 286 additions and 21 deletions

View File

@ -1,4 +1,4 @@
import { useMemo, type VFC, useState, useEffect } from 'react';
import { useMemo, useState, useEffect, type FC } from 'react';
import useTheme from '@mui/material/styles/useTheme';
import styled from '@mui/material/styles/styled';
import { usePageTitle } from 'hooks/usePageTitle';
@ -34,6 +34,8 @@ import { formatTickValue } from 'component/common/Chart/formatTickValue';
import { useTrafficLimit } from './hooks/useTrafficLimit';
import { BILLING_TRAFFIC_BUNDLE_PRICE } from 'component/admin/billing/BillingDashboard/BillingPlan/BillingPlan';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { PeriodSelector } from './PeriodSelector';
import { useUiFlag } from 'hooks/useUiFlag';
const StyledBox = styled(Box)(({ theme }) => ({
display: 'grid',
@ -139,9 +141,17 @@ const createBarChartOptions = (
},
});
export const NetworkTrafficUsage: VFC = () => {
// this is primarily for dev purposes. The existing grid is very inflexible, so we might want to change it, but for demoing the design, this is enough.
const NewHeader = styled('div')(() => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}));
export const NetworkTrafficUsage: FC = () => {
usePageTitle('Network - Data Usage');
const theme = useTheme();
const showMultiMonthSelector = useUiFlag('dataUsageMultiMonthView');
const { isOss } = useUiConfig();
@ -269,30 +279,49 @@ export const NetworkTrafficUsage: VFC = () => {
}
/>
<StyledBox>
<Grid container component='header' spacing={2}>
<Grid item xs={12} md={10}>
{showMultiMonthSelector ? (
<NewHeader>
<NetworkTrafficUsagePlanSummary
usageTotal={usageTotal}
includedTraffic={includedTraffic}
overageCost={overageCost}
estimatedMonthlyCost={estimatedMonthlyCost}
/>
</Grid>
<Grid item xs={12} md={2}>
<Select
id='dataperiod-select'
name='dataperiod'
options={selectablePeriods}
value={period}
onChange={(e) => setPeriod(e.target.value)}
style={{
minWidth: '100%',
marginBottom: theme.spacing(2),
}}
formControlStyles={{ width: '100%' }}
<PeriodSelector
selectedPeriod={period}
setPeriod={setPeriod}
/>
</NewHeader>
) : (
<Grid container component='header' spacing={2}>
<Grid item xs={12} md={10}>
<NetworkTrafficUsagePlanSummary
usageTotal={usageTotal}
includedTraffic={includedTraffic}
overageCost={overageCost}
estimatedMonthlyCost={
estimatedMonthlyCost
}
/>
</Grid>
<Grid item xs={12} md={2}>
<Select
id='dataperiod-select'
name='dataperiod'
options={selectablePeriods}
value={period}
onChange={(e) =>
setPeriod(e.target.value)
}
style={{
minWidth: '100%',
marginBottom: theme.spacing(2),
}}
formControlStyles={{ width: '100%' }}
/>
</Grid>
</Grid>
</Grid>
)}
<Grid item xs={12} md={2}>
<Bar
data={data}

View File

@ -0,0 +1,230 @@
import { styled } from '@mui/material';
import { type FC, useState } from 'react';
export type Period = {
key: string;
dayCount: number;
label: string;
year: number;
month: number;
selectable: boolean;
shortLabel: string;
};
export const toSelectablePeriod = (
date: Date,
label?: string,
selectable = true,
): Period => {
const year = date.getFullYear();
const month = date.getMonth();
const period = `${year}-${(month + 1).toString().padStart(2, '0')}`;
const dayCount = new Date(year, month + 1, 0).getDate();
return {
key: period,
year,
month,
dayCount,
shortLabel: date.toLocaleString('en-US', {
month: 'short',
}),
label:
label ||
date.toLocaleString('en-US', { month: 'long', year: 'numeric' }),
selectable,
};
};
const currentDate = new Date(Date.now());
const currentPeriod = toSelectablePeriod(currentDate, 'Current month');
const getSelectablePeriods = (): Period[] => {
const selectablePeriods = [currentPeriod];
for (
let subtractMonthCount = 1;
subtractMonthCount < 12;
subtractMonthCount++
) {
// JavaScript wraps around the year, so we don't need to handle that.
const date = new Date(
currentDate.getFullYear(),
currentDate.getMonth() - subtractMonthCount,
1,
);
selectablePeriods.push(
toSelectablePeriod(date, undefined, date > new Date('2024-03-31')),
);
}
return selectablePeriods;
};
const Wrapper = styled('article')(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
border: `2px solid ${theme.palette.divider}`,
padding: theme.spacing(3),
display: 'flex',
flexFlow: 'column',
gap: theme.spacing(2),
button: {
cursor: 'pointer',
border: 'none',
background: 'none',
fontSize: theme.typography.body1.fontSize,
padding: theme.spacing(0.5),
borderRadius: theme.shape.borderRadius,
'&.selected': {
backgroundColor: theme.palette.secondary.light,
},
},
'button:disabled': {
cursor: 'default',
},
}));
const MonthSelector = styled('article')(({ theme }) => ({
border: 'none',
hgroup: {
h3: {
margin: 0,
fontSize: theme.typography.h3.fontSize,
},
p: {
color: theme.palette.text.secondary,
fontSize: theme.typography.body2.fontSize,
},
marginBottom: theme.spacing(1),
},
}));
const MonthGrid = styled('ul')(({ theme }) => ({
listStyle: 'none',
padding: 0,
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
rowGap: theme.spacing(1),
columnGap: theme.spacing(2),
}));
const RangeSelector = styled('article')(({ theme }) => ({
display: 'flex',
flexFlow: 'column',
gap: theme.spacing(0.5),
h4: {
fontSize: theme.typography.body2.fontSize,
margin: 0,
color: theme.palette.text.secondary,
},
}));
const RangeList = styled('ul')(({ theme }) => ({
listStyle: 'none',
padding: 0,
'li + li': {
marginTop: theme.spacing(1),
},
button: {
marginLeft: `-${theme.spacing(0.5)}`,
},
}));
type Selection =
| {
type: 'month';
value: string;
}
| {
type: 'range';
monthsBack: number;
};
type Props = {
selectedPeriod: string;
setPeriod: (period: string) => void;
};
export const PeriodSelector: FC<Props> = ({ selectedPeriod, setPeriod }) => {
const selectablePeriods = getSelectablePeriods();
// this is for dev purposes; only to show how the design will work when you select a range.
const [tempOverride, setTempOverride] = useState<Selection | null>();
const select = (value: Selection) => {
if (value.type === 'month') {
setTempOverride(null);
setPeriod(value.value);
} else {
setTempOverride(value);
}
};
const rangeOptions = [3, 6, 12].map((monthsBack) => ({
value: monthsBack,
label: `Last ${monthsBack} months`,
}));
return (
<Wrapper>
<MonthSelector>
<hgroup>
<h3>Select month</h3>
<p>Last 12 months</p>
</hgroup>
<MonthGrid>
{selectablePeriods.map((period, index) => (
<li key={period.label}>
<button
className={
!tempOverride &&
period.key === selectedPeriod
? 'selected'
: ''
}
type='button'
disabled={!period.selectable}
onClick={() => {
select({
type: 'month',
value: period.key,
});
}}
>
{period.shortLabel}
</button>
</li>
))}
</MonthGrid>
</MonthSelector>
<RangeSelector>
<h4>Range</h4>
<RangeList>
{rangeOptions.map((option) => (
<li key={option.label}>
<button
className={
tempOverride &&
tempOverride.type === 'range' &&
option.value === tempOverride.monthsBack
? 'selected'
: ''
}
type='button'
onClick={() => {
select({
type: 'range',
monthsBack: option.value,
});
}}
>
Last {option.value} months
</button>
</li>
))}
</RangeList>
</RangeSelector>
</Wrapper>
);
};

View File

@ -48,8 +48,7 @@ const calculateTrafficDataCost = (
return unitCount * trafficUnitCost;
};
const padMonth = (month: number): string =>
month < 10 ? `0${month}` : `${month}`;
const padMonth = (month: number): string => month.toString().padStart(2, '0');
export const toSelectablePeriod = (
date: Date,

View File

@ -93,6 +93,7 @@ export type UiFlags = {
sortProjectRoles?: boolean;
lifecycleImprovements?: boolean;
frontendHeaderRedesign?: boolean;
dataUsageMultiMonthView?: boolean;
};
export interface IVersionInfo {

View File

@ -62,7 +62,8 @@ export type IFlagKey =
| 'uniqueSdkTracking'
| 'sortProjectRoles'
| 'lifecycleImprovements'
| 'frontendHeaderRedesign';
| 'frontendHeaderRedesign'
| 'dataUsageMultiMonthView';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -299,6 +300,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_FRONTEND_HEADER_REDESIGN,
false,
),
dataUsageMultiMonthView: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_DATA_USAGE_MULTI_MONTH_VIEW,
false,
),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -58,6 +58,7 @@ process.nextTick(async () => {
uniqueSdkTracking: true,
lifecycleImprovements: true,
frontendHeaderRedesign: true,
dataUsageMultiMonthView: true,
},
},
authentication: {