mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
Feat: chart 'no-data' placeholder (#6172)
This commit is contained in:
parent
3e7c2bb30e
commit
c224d7dc4c
@ -118,6 +118,11 @@ const PremiumFeatures = {
|
||||
url: 'https://docs.getunleash.io/reference/actions',
|
||||
label: 'Actions',
|
||||
},
|
||||
dashboard: {
|
||||
plan: FeaturePlan.ENTERPRISE,
|
||||
url: '', // FIXME: url
|
||||
label: 'Dashboard',
|
||||
},
|
||||
};
|
||||
|
||||
type PremiumFeatureType = keyof typeof PremiumFeatures;
|
||||
|
@ -100,6 +100,7 @@ export const ExecutiveDashboard: VFC = () => {
|
||||
<Widget title='Users' order={userTrendsOrder} span={chartSpan}>
|
||||
<UsersChart
|
||||
userTrends={executiveDashboardData.userTrends}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</Widget>
|
||||
<Widget
|
||||
@ -115,6 +116,7 @@ export const ExecutiveDashboard: VFC = () => {
|
||||
<Widget title='Number of flags' order={4} span={chartSpan}>
|
||||
<FlagsChart
|
||||
flagTrends={executiveDashboardData.flagTrends}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</Widget>
|
||||
<Widget
|
||||
|
@ -2,14 +2,46 @@ import { useMemo, type VFC } from 'react';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { ExecutiveSummarySchema } from 'openapi';
|
||||
import { LineChart } from '../LineChart/LineChart';
|
||||
import { LineChart, NotEnoughData } from '../LineChart/LineChart';
|
||||
|
||||
interface IFlagsChartProps {
|
||||
flagTrends: ExecutiveSummarySchema['flagTrends'];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const FlagsChart: VFC<IFlagsChartProps> = ({ flagTrends }) => {
|
||||
export const FlagsChart: VFC<IFlagsChartProps> = ({
|
||||
flagTrends,
|
||||
isLoading,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const notEnoughData = flagTrends.length < 2;
|
||||
const placeholderData = useMemo(
|
||||
() => ({
|
||||
labels: Array.from({ length: 15 }, (_, i) => i + 1).map(
|
||||
(i) =>
|
||||
new Date(Date.now() - (15 - i) * 7 * 24 * 60 * 60 * 1000),
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Total flags',
|
||||
data: [
|
||||
43, 66, 55, 65, 62, 72, 75, 73, 80, 65, 62, 61, 69, 70,
|
||||
77,
|
||||
],
|
||||
borderColor: theme.palette.primary.light,
|
||||
backgroundColor: theme.palette.primary.light,
|
||||
},
|
||||
{
|
||||
label: 'Stale',
|
||||
data: [3, 5, 4, 6, 2, 7, 5, 3, 8, 3, 5, 11, 8, 4, 3],
|
||||
borderColor: theme.palette.warning.border,
|
||||
backgroundColor: theme.palette.warning.border,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
|
||||
const data = useMemo(
|
||||
() => ({
|
||||
labels: flagTrends.map((item) => item.date),
|
||||
@ -31,5 +63,10 @@ export const FlagsChart: VFC<IFlagsChartProps> = ({ flagTrends }) => {
|
||||
[theme, flagTrends],
|
||||
);
|
||||
|
||||
return <LineChart data={data} />;
|
||||
return (
|
||||
<LineChart
|
||||
data={notEnoughData || isLoading ? placeholderData : data}
|
||||
cover={notEnoughData ? <NotEnoughData /> : isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,3 +1,45 @@
|
||||
import { lazy } from 'react';
|
||||
import { type ScriptableContext } from 'chart.js';
|
||||
import { Typography } from '@mui/material';
|
||||
|
||||
export const LineChart = lazy(() => import('./LineChartComponent'));
|
||||
|
||||
export const fillGradient =
|
||||
(a: string, b: string) => (context: ScriptableContext<'line'>) => {
|
||||
const chart = context.chart;
|
||||
const { ctx, chartArea } = chart;
|
||||
if (!chartArea) {
|
||||
return;
|
||||
}
|
||||
const gradient = ctx.createLinearGradient(
|
||||
0,
|
||||
chartArea.bottom,
|
||||
0,
|
||||
chartArea.top,
|
||||
);
|
||||
gradient.addColorStop(0, a);
|
||||
gradient.addColorStop(1, b);
|
||||
return gradient;
|
||||
};
|
||||
|
||||
export const fillGradientPrimary = fillGradient(
|
||||
'rgba(129, 122, 254, 0)',
|
||||
'rgba(129, 122, 254, 0.12)',
|
||||
);
|
||||
|
||||
export const NotEnoughData = () => (
|
||||
<>
|
||||
<Typography
|
||||
variant='body1'
|
||||
component='h4'
|
||||
sx={(theme) => ({
|
||||
paddingBottom: theme.spacing(1),
|
||||
})}
|
||||
>
|
||||
Not enough data
|
||||
</Typography>
|
||||
<Typography variant='body2'>
|
||||
Two or more weeks of data are needed to show a chart.
|
||||
</Typography>
|
||||
</>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, type VFC } from 'react';
|
||||
import { type ReactNode, useMemo, useState, type VFC } from 'react';
|
||||
import {
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
@ -20,16 +20,27 @@ import {
|
||||
type ILocationSettings,
|
||||
} from 'hooks/useLocationSettings';
|
||||
import { ChartTooltip, TooltipState } from './ChartTooltip/ChartTooltip';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { styled } from '@mui/material';
|
||||
|
||||
const createOptions = (
|
||||
theme: Theme,
|
||||
locationSettings: ILocationSettings,
|
||||
setTooltip: React.Dispatch<React.SetStateAction<TooltipState | null>>,
|
||||
isPlaceholder?: boolean,
|
||||
) =>
|
||||
({
|
||||
responsive: true,
|
||||
...(isPlaceholder
|
||||
? {
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
plugins: {
|
||||
legend: {
|
||||
display: !isPlaceholder,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
@ -113,7 +124,10 @@ const createOptions = (
|
||||
color: theme.palette.divider,
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
ticks: { color: theme.palette.text.secondary },
|
||||
ticks: {
|
||||
color: theme.palette.text.secondary,
|
||||
display: !isPlaceholder,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
type: 'time',
|
||||
@ -126,11 +140,38 @@ const createOptions = (
|
||||
},
|
||||
ticks: {
|
||||
color: theme.palette.text.secondary,
|
||||
display: !isPlaceholder,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as const;
|
||||
|
||||
const StyledContainer = styled('div')(({ theme }) => ({
|
||||
position: 'relative',
|
||||
}));
|
||||
|
||||
const StyledCover = styled('div')(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
zIndex: theme.zIndex.appBar,
|
||||
'&::before': {
|
||||
zIndex: theme.zIndex.fab,
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
opacity: 0.8,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledCoverContent = styled('div')(({ theme }) => ({
|
||||
zIndex: theme.zIndex.modal,
|
||||
margin: 'auto',
|
||||
color: theme.palette.text.secondary,
|
||||
textAlign: 'center',
|
||||
}));
|
||||
|
||||
// Vertical line on the hovered chart, filled with gradient. Highlights a section of a chart when you hover over datapoints
|
||||
const customHighlightPlugin = {
|
||||
id: 'customLine',
|
||||
@ -164,18 +205,20 @@ const customHighlightPlugin = {
|
||||
const LineChartComponent: VFC<{
|
||||
data: ChartData<'line', (number | ScatterDataPoint | null)[], unknown>;
|
||||
aspectRatio?: number;
|
||||
}> = ({ data, aspectRatio }) => {
|
||||
cover?: ReactNode;
|
||||
}> = ({ data, aspectRatio, cover }) => {
|
||||
const theme = useTheme();
|
||||
const { locationSettings } = useLocationSettings();
|
||||
|
||||
const [tooltip, setTooltip] = useState<null | TooltipState>(null);
|
||||
const options = useMemo(
|
||||
() => createOptions(theme, locationSettings, setTooltip),
|
||||
() =>
|
||||
createOptions(theme, locationSettings, setTooltip, Boolean(cover)),
|
||||
[theme, locationSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledContainer>
|
||||
<Line
|
||||
options={options}
|
||||
data={data}
|
||||
@ -183,8 +226,18 @@ const LineChartComponent: VFC<{
|
||||
height={aspectRatio ? 100 : undefined}
|
||||
width={aspectRatio ? 100 * aspectRatio : undefined}
|
||||
/>
|
||||
<ChartTooltip tooltip={tooltip} />
|
||||
</>
|
||||
<ConditionallyRender
|
||||
condition={!cover}
|
||||
show={<ChartTooltip tooltip={tooltip} />}
|
||||
elseShow={
|
||||
<StyledCover>
|
||||
<StyledCoverContent>
|
||||
{cover !== true ? cover : ' '}
|
||||
</StyledCoverContent>
|
||||
</StyledCover>
|
||||
}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -2,15 +2,46 @@ import { useMemo, type VFC } from 'react';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { ExecutiveSummarySchema } from 'openapi';
|
||||
import { LineChart } from '../LineChart/LineChart';
|
||||
import {
|
||||
fillGradientPrimary,
|
||||
LineChart,
|
||||
NotEnoughData,
|
||||
} from '../LineChart/LineChart';
|
||||
import { type ScriptableContext } from 'chart.js';
|
||||
|
||||
interface IUsersChartProps {
|
||||
userTrends: ExecutiveSummarySchema['userTrends'];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const UsersChart: VFC<IUsersChartProps> = ({ userTrends }) => {
|
||||
export const UsersChart: VFC<IUsersChartProps> = ({
|
||||
userTrends,
|
||||
isLoading,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const notEnoughData = userTrends.length < 2;
|
||||
const placeholderData = useMemo(
|
||||
() => ({
|
||||
labels: Array.from({ length: 15 }, (_, i) => i + 1).map(
|
||||
(i) =>
|
||||
new Date(Date.now() - (15 - i) * 7 * 24 * 60 * 60 * 1000),
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Total users',
|
||||
data: [
|
||||
3, 5, 15, 17, 25, 40, 47, 48, 55, 65, 62, 72, 75, 73,
|
||||
80,
|
||||
],
|
||||
borderColor: theme.palette.primary.light,
|
||||
backgroundColor: fillGradientPrimary,
|
||||
fill: true,
|
||||
order: 3,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
const data = useMemo(
|
||||
() => ({
|
||||
labels: userTrends.map((item) => item.date),
|
||||
@ -19,22 +50,7 @@ export const UsersChart: VFC<IUsersChartProps> = ({ userTrends }) => {
|
||||
label: 'Total users',
|
||||
data: userTrends.map((item) => item.total),
|
||||
borderColor: theme.palette.primary.light,
|
||||
backgroundColor: (context: ScriptableContext<'line'>) => {
|
||||
const chart = context.chart;
|
||||
const { ctx, chartArea } = chart;
|
||||
if (!chartArea) {
|
||||
return;
|
||||
}
|
||||
const gradient = ctx.createLinearGradient(
|
||||
0,
|
||||
chartArea.bottom,
|
||||
0,
|
||||
chartArea.top,
|
||||
);
|
||||
gradient.addColorStop(0, 'rgba(129, 122, 254, 0)');
|
||||
gradient.addColorStop(1, 'rgba(129, 122, 254, 0.12)');
|
||||
return gradient;
|
||||
},
|
||||
backgroundColor: fillGradientPrimary,
|
||||
fill: true,
|
||||
order: 3,
|
||||
},
|
||||
@ -57,5 +73,10 @@ export const UsersChart: VFC<IUsersChartProps> = ({ userTrends }) => {
|
||||
[theme, userTrends],
|
||||
);
|
||||
|
||||
return <LineChart data={data} />;
|
||||
return (
|
||||
<LineChart
|
||||
data={notEnoughData || isLoading ? placeholderData : data}
|
||||
cover={notEnoughData ? <NotEnoughData /> : isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user