mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-28 17:55:15 +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',
|
url: 'https://docs.getunleash.io/reference/actions',
|
||||||
label: 'Actions',
|
label: 'Actions',
|
||||||
},
|
},
|
||||||
|
dashboard: {
|
||||||
|
plan: FeaturePlan.ENTERPRISE,
|
||||||
|
url: '', // FIXME: url
|
||||||
|
label: 'Dashboard',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type PremiumFeatureType = keyof typeof PremiumFeatures;
|
type PremiumFeatureType = keyof typeof PremiumFeatures;
|
||||||
|
@ -100,6 +100,7 @@ export const ExecutiveDashboard: VFC = () => {
|
|||||||
<Widget title='Users' order={userTrendsOrder} span={chartSpan}>
|
<Widget title='Users' order={userTrendsOrder} span={chartSpan}>
|
||||||
<UsersChart
|
<UsersChart
|
||||||
userTrends={executiveDashboardData.userTrends}
|
userTrends={executiveDashboardData.userTrends}
|
||||||
|
isLoading={loading}
|
||||||
/>
|
/>
|
||||||
</Widget>
|
</Widget>
|
||||||
<Widget
|
<Widget
|
||||||
@ -115,6 +116,7 @@ export const ExecutiveDashboard: VFC = () => {
|
|||||||
<Widget title='Number of flags' order={4} span={chartSpan}>
|
<Widget title='Number of flags' order={4} span={chartSpan}>
|
||||||
<FlagsChart
|
<FlagsChart
|
||||||
flagTrends={executiveDashboardData.flagTrends}
|
flagTrends={executiveDashboardData.flagTrends}
|
||||||
|
isLoading={loading}
|
||||||
/>
|
/>
|
||||||
</Widget>
|
</Widget>
|
||||||
<Widget
|
<Widget
|
||||||
|
@ -2,14 +2,46 @@ import { useMemo, type VFC } from 'react';
|
|||||||
import 'chartjs-adapter-date-fns';
|
import 'chartjs-adapter-date-fns';
|
||||||
import { useTheme } from '@mui/material';
|
import { useTheme } from '@mui/material';
|
||||||
import { ExecutiveSummarySchema } from 'openapi';
|
import { ExecutiveSummarySchema } from 'openapi';
|
||||||
import { LineChart } from '../LineChart/LineChart';
|
import { LineChart, NotEnoughData } from '../LineChart/LineChart';
|
||||||
|
|
||||||
interface IFlagsChartProps {
|
interface IFlagsChartProps {
|
||||||
flagTrends: ExecutiveSummarySchema['flagTrends'];
|
flagTrends: ExecutiveSummarySchema['flagTrends'];
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FlagsChart: VFC<IFlagsChartProps> = ({ flagTrends }) => {
|
export const FlagsChart: VFC<IFlagsChartProps> = ({
|
||||||
|
flagTrends,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
const theme = useTheme();
|
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(
|
const data = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
labels: flagTrends.map((item) => item.date),
|
labels: flagTrends.map((item) => item.date),
|
||||||
@ -31,5 +63,10 @@ export const FlagsChart: VFC<IFlagsChartProps> = ({ flagTrends }) => {
|
|||||||
[theme, 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 { lazy } from 'react';
|
||||||
|
import { type ScriptableContext } from 'chart.js';
|
||||||
|
import { Typography } from '@mui/material';
|
||||||
|
|
||||||
export const LineChart = lazy(() => import('./LineChartComponent'));
|
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 {
|
import {
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
@ -20,16 +20,27 @@ import {
|
|||||||
type ILocationSettings,
|
type ILocationSettings,
|
||||||
} from 'hooks/useLocationSettings';
|
} from 'hooks/useLocationSettings';
|
||||||
import { ChartTooltip, TooltipState } from './ChartTooltip/ChartTooltip';
|
import { ChartTooltip, TooltipState } from './ChartTooltip/ChartTooltip';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { styled } from '@mui/material';
|
||||||
|
|
||||||
const createOptions = (
|
const createOptions = (
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
locationSettings: ILocationSettings,
|
locationSettings: ILocationSettings,
|
||||||
setTooltip: React.Dispatch<React.SetStateAction<TooltipState | null>>,
|
setTooltip: React.Dispatch<React.SetStateAction<TooltipState | null>>,
|
||||||
|
isPlaceholder?: boolean,
|
||||||
) =>
|
) =>
|
||||||
({
|
({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
...(isPlaceholder
|
||||||
|
? {
|
||||||
|
animation: {
|
||||||
|
duration: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
|
display: !isPlaceholder,
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: {
|
labels: {
|
||||||
boxWidth: 12,
|
boxWidth: 12,
|
||||||
@ -113,7 +124,10 @@ const createOptions = (
|
|||||||
color: theme.palette.divider,
|
color: theme.palette.divider,
|
||||||
borderColor: theme.palette.divider,
|
borderColor: theme.palette.divider,
|
||||||
},
|
},
|
||||||
ticks: { color: theme.palette.text.secondary },
|
ticks: {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
display: !isPlaceholder,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
type: 'time',
|
type: 'time',
|
||||||
@ -126,11 +140,38 @@ const createOptions = (
|
|||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
|
display: !isPlaceholder,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}) as const;
|
}) 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
|
// Vertical line on the hovered chart, filled with gradient. Highlights a section of a chart when you hover over datapoints
|
||||||
const customHighlightPlugin = {
|
const customHighlightPlugin = {
|
||||||
id: 'customLine',
|
id: 'customLine',
|
||||||
@ -164,18 +205,20 @@ const customHighlightPlugin = {
|
|||||||
const LineChartComponent: VFC<{
|
const LineChartComponent: VFC<{
|
||||||
data: ChartData<'line', (number | ScatterDataPoint | null)[], unknown>;
|
data: ChartData<'line', (number | ScatterDataPoint | null)[], unknown>;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
}> = ({ data, aspectRatio }) => {
|
cover?: ReactNode;
|
||||||
|
}> = ({ data, aspectRatio, cover }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { locationSettings } = useLocationSettings();
|
const { locationSettings } = useLocationSettings();
|
||||||
|
|
||||||
const [tooltip, setTooltip] = useState<null | TooltipState>(null);
|
const [tooltip, setTooltip] = useState<null | TooltipState>(null);
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
() => createOptions(theme, locationSettings, setTooltip),
|
() =>
|
||||||
|
createOptions(theme, locationSettings, setTooltip, Boolean(cover)),
|
||||||
[theme, locationSettings],
|
[theme, locationSettings],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<StyledContainer>
|
||||||
<Line
|
<Line
|
||||||
options={options}
|
options={options}
|
||||||
data={data}
|
data={data}
|
||||||
@ -183,8 +226,18 @@ const LineChartComponent: VFC<{
|
|||||||
height={aspectRatio ? 100 : undefined}
|
height={aspectRatio ? 100 : undefined}
|
||||||
width={aspectRatio ? 100 * aspectRatio : 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 'chartjs-adapter-date-fns';
|
||||||
import { useTheme } from '@mui/material';
|
import { useTheme } from '@mui/material';
|
||||||
import { ExecutiveSummarySchema } from 'openapi';
|
import { ExecutiveSummarySchema } from 'openapi';
|
||||||
import { LineChart } from '../LineChart/LineChart';
|
import {
|
||||||
|
fillGradientPrimary,
|
||||||
|
LineChart,
|
||||||
|
NotEnoughData,
|
||||||
|
} from '../LineChart/LineChart';
|
||||||
import { type ScriptableContext } from 'chart.js';
|
import { type ScriptableContext } from 'chart.js';
|
||||||
|
|
||||||
interface IUsersChartProps {
|
interface IUsersChartProps {
|
||||||
userTrends: ExecutiveSummarySchema['userTrends'];
|
userTrends: ExecutiveSummarySchema['userTrends'];
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UsersChart: VFC<IUsersChartProps> = ({ userTrends }) => {
|
export const UsersChart: VFC<IUsersChartProps> = ({
|
||||||
|
userTrends,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
const theme = useTheme();
|
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(
|
const data = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
labels: userTrends.map((item) => item.date),
|
labels: userTrends.map((item) => item.date),
|
||||||
@ -19,22 +50,7 @@ export const UsersChart: VFC<IUsersChartProps> = ({ userTrends }) => {
|
|||||||
label: 'Total users',
|
label: 'Total users',
|
||||||
data: userTrends.map((item) => item.total),
|
data: userTrends.map((item) => item.total),
|
||||||
borderColor: theme.palette.primary.light,
|
borderColor: theme.palette.primary.light,
|
||||||
backgroundColor: (context: ScriptableContext<'line'>) => {
|
backgroundColor: fillGradientPrimary,
|
||||||
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;
|
|
||||||
},
|
|
||||||
fill: true,
|
fill: true,
|
||||||
order: 3,
|
order: 3,
|
||||||
},
|
},
|
||||||
@ -57,5 +73,10 @@ export const UsersChart: VFC<IUsersChartProps> = ({ userTrends }) => {
|
|||||||
[theme, 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