使用 React 的 Material UI 仪表板📊
使用 Cube.js 的分析后端
采用 Material UI 的前端
具有多个图表的交互式仪表板
带数据表的多页仪表板
GenAI LIVE! | 2025年6月4日
Material UI是最流行的 React UI 框架。其灵感源自 Google 的Material Design,它提供了大量现成的组件,可以快速轻松地构建 Web 应用程序(包括仪表板)。
在本教程中,我们将学习如何构建一个包含 KPI、图表和数据表的全栈仪表板。我们将从数据库中的数据讲解到可交互、可过滤、可搜索的管理仪表板。
我们将使用 Cube.js 作为我们的分析 API。它省去了构建 API 层、生成 SQL 和查询数据库的所有麻烦。它还提供了许多生产级功能,例如用于优化性能的多级缓存、多租户、安全性等等。
下面您可以看到我们将要构建的应用程序的动画图。此外,您还可以查看GitHub 上的现场演示和完整源代码。
使用 Cube.js 的分析后端
我们将为一家希望追踪其整体业绩和订单状态的电商公司构建仪表板。假设该公司将数据保存在 SQL 数据库中。因此,为了在仪表板上显示这些数据,我们将创建一个分析后端。
首先,我们需要安装 Cube.js 命令行工具(CLI)。为了方便起见,我们先在机器上全局安装它。
$ npm install -g cubejs-cli
然后,安装 CLI 后,我们可以通过运行单个命令来创建一个基本的后端。Cube.js 支持所有流行的数据库,并且后端将预先配置为与特定的数据库类型一起使用:
$ cubejs create <project name> -d <database type>
我们将使用PostgreSQL数据库。请确保您已安装 PostgreSQL。
要创建后端,我们运行以下命令:
$ cubejs create react-material-dashboard -d postgres
现在我们可以下载并导入 PostgreSQL 的示例电子商务数据集:
$ curl http://cube.dev/downloads/ecom-dump.sql > ecom-dump.sql
$ createdb ecom
$ psql --dbname ecom -f ecom-dump.sql
数据库准备就绪后,即可配置后端连接到数据库。为此,我们通过.env
Cube.js 项目文件夹根目录中的文件 ( react-material-dashboard
) 提供了一些选项:
CUBEJS_DB_NAME=ecom
CUBEJS_DB_TYPE=postgres
CUBEJS_API_SECRET=secret
现在我们可以运行后端了!
在开发模式下,后端还将运行 Cube.js Playground。这是一个节省时间的 Web 应用程序,可帮助创建数据模式、测试图表并生成 React 仪表板样板。在 Cube.js 项目文件夹中运行以下命令:
$ node index.js
接下来,在浏览器中打开http://localhost:4000 。
我们将使用 Cube.js Playground 创建数据模式。它本质上是一段 JavaScript 代码,以声明式的方式描述数据,定义度量和维度等分析实体,并将它们映射到 SQL 查询。以下是可用于描述用户数据的模式示例。
cube(`Users`, {
sql: `SELECT * FROM users`,
measures: {
count: {
sql: `id`,
type: `count`
},
},
dimensions: {
city: {
sql: `city`,
type: `string`
},
signedUp: {
sql: `created_at`,
type: `time`
},
companyName: {
sql: `company_name`,
type: `string`
},
},
});
Cube.js 可以根据数据库表生成简单的数据模式。如果您的数据库中已经有一组重要的表,可以考虑使用数据模式生成功能,因为它可以节省时间。
对于我们的后端,我们选择line_items
、、和表orders
,然后单击“生成架构”。结果,我们将在文件夹中生成 4 个文件 - 每个表一个架构文件。products
users
schema
生成架构后,我们可以通过 Web UI 构建示例图表。具体操作如下:导航至“构建”选项卡,并从架构中选择一些度量和维度。
在“构建”选项卡中,您可以使用不同的可视化库构建示例图表,并检查图表创建的各个方面,从生成的 SQL 一直到用于渲染图表的 JavaScript 代码。您还可以检查发送到 Cube.js 后端的 JSON 编码的 Cube.js 查询。
采用 Material UI 的前端
从头开始创建复杂的仪表板通常需要时间和精力。
Cube.js 园地可以为你生成任何所选前端框架和图表库的模板。要为我们的仪表板创建模板,请导航至“仪表板应用”并使用以下选项:
- 框架:React
- 主模板:React Material UI Static
- 图表库:Chart.js
恭喜!现在我们的项目中有了dashboard-app
文件夹。此文件夹包含我们分析仪表板的所有前端代码。
现在是时候添加 Material UI 框架了。为了获得美观的仪表盘,我们将使用自定义的 Material UI 主题。您可以从文档中了解如何创建自定义的 Material UI 主题。现在,让我们从 GitHub 下载一个预配置的主题:
$ curl -LJO https://github.com/cube-js/cube.js/tree/master/examples/material-ui-dashboard/dashboard-app/src/theme/theme.zip
然后,让我们安装最适合 Material UI 的 Roboto 字体:
$ npm install typeface-roboto
现在我们可以将主题和字体添加到前端代码中了。我们使用ThemeProvider
Material UI 中的代码,并在文件中进行以下更改App.js
:
// ...
- import { makeStyles } from "@material-ui/core/styles";
+ import { makeStyles, ThemeProvider } from "@material-ui/core/styles";
+ import theme from './theme';
+ import 'typeface-roboto'
+ import palette from "./theme/palette";
// ...
const useStyles = makeStyles((theme) => ({
root: {
flexGrow: 1,
+ margin: '-8px',
+ backgroundColor: palette.primary.light,
},
}));
const AppLayout = ({children}) => {
const classes = useStyles();
return (
+ <ThemeProvider theme={theme}>
<div className={classes.root}>
<Header/>
<div>{children}</div>
</div>
+ </ThemeProvider>
);
};
// ...
连接前端和后端只剩下 Cube.js 查询了。我们可以在 Cube.js 园地中生成查询。访问http://localhost:4000/,导航到“构建”选项卡,然后选择以下查询参数:
- 衡量指标:订单数量
- 维度:订单状态
- 数据范围:本周
- 图表类型:条形图
我们可以复制所显示图表的 Cube.js 查询并在我们的仪表板应用程序中使用它。
为此,让我们创建一个通用<BarChart />
组件,该组件反过来将使用ChartRenderer
组件。创建src/components/BarChart.js
包含以下内容的文件:
import React from "react";
import clsx from "clsx";
import PropTypes from "prop-types";
import { makeStyles } from '@material-ui/styles';
import ChartRenderer from './ChartRenderer'
import {
Card,
CardContent,
Divider,
} from "@material-ui/core";
const useStyles = makeStyles(() => ({
root: {},
chartContainer: {
position: "relative",
padding: "19px 0"
}
}));
const BarChart = props => {
const { className, query, ...rest } = props;
const classes = useStyles();
return (
<Card {...rest} className={clsx(classes.root, className)}>
<CardContent>
<div className={classes.chartContainer}>
<ChartRenderer vizState={{ query, chartType: 'bar' }}/>
</div>
</CardContent>
</Card>
)
};
BarChart.propTypes = {
className: PropTypes.string
};
export default BarChart;
我们需要为<ChartRenderer />
组件添加一些自定义选项。这些选项会让条形图看起来更美观。
helpers
在文件夹中创建文件夹dashboard-app/src
。在helpers
文件夹中,创建BarOptions.js
包含以下内容的文件:
import palette from '../theme/palette';
export const BarOptions = {
responsive: true,
legend: { display: false },
cornerRadius: 50,
tooltips: {
enabled: true,
mode: 'index',
intersect: false,
borderWidth: 1,
borderColor: palette.divider,
backgroundColor: palette.white,
titleFontColor: palette.text.primary,
bodyFontColor: palette.text.secondary,
footerFontColor: palette.text.secondary,
},
layout: { padding: 0 },
scales: {
xAxes: [
{
barThickness: 12,
maxBarThickness: 10,
barPercentage: 0.5,
categoryPercentage: 0.5,
ticks: {
fontColor: palette.text.secondary,
},
gridLines: {
display: false,
drawBorder: false,
},
},
],
yAxes: [
{
ticks: {
fontColor: palette.text.secondary,
beginAtZero: true,
min: 0,
},
gridLines: {
borderDash: [2],
borderDashOffset: [2],
color: palette.divider,
drawBorder: false,
zeroLineBorderDash: [2],
zeroLineBorderDashOffset: [2],
zeroLineColor: palette.divider,
},
},
],
},
};
让我们编辑src/components/ChartRenderer.js
文件以将选项传递给<Bar />
组件:
// ...
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
+ import palette from '../theme/palette'
+ import moment from 'moment';
+ import { BarOptions } from '../helpers/BarOptions.js';
- const COLORS_SERIES = ['#FF6492', '#141446', '#7A77FF'];
+ const COLORS_SERIES = [palette.secondary.main, palette.primary.light, palette.secondary.light];
// ...
bar:
({ resultSet }) => {
const data = {
- labels: resultSet.categories().map((c) => c.category),
+ labels: resultSet.categories().map((c) => moment(c.category).format('DD/MM/YYYY')),
datasets: resultSet.series().map((s, index) => ({
label: s.title,
data: s.series.map((r) => r.value),
backgroundColor: COLORS_SERIES[index],
fill: false,
})),
};
- return <Bar data={data} options={BarOptions} />;
+ return <Bar data={data} options={BarOptions} />;
},
//...
现在是最后一步!让我们将条形图添加到仪表板。编辑src/pages/DashboardPage.js
并使用以下内容:
import React from 'react';
import { Grid } from '@material-ui/core';
import { makeStyles } from '@material-ui/styles';
import BarChart from '../components/BarChart.js'
const useStyles = makeStyles(theme => ({
root: {
padding: theme.spacing(4)
},
}));
const barChartQuery = {
measures: ['Orders.count'],
timeDimensions: [
{
dimension: 'Orders.createdAt',
granularity: 'day',
dateRange: 'This week',
},
],
dimensions: ['Orders.status'],
filters: [
{
dimension: 'Orders.status',
operator: 'notEquals',
values: ['completed'],
},
],
};
const Dashboard = () => {
const classes = useStyles();
return (
<div className={classes.root}>
<Grid
container
spacing={4}
>
<Grid
item
lg={8}
md={12}
xl={9}
xs={12}
>
<BarChart query={barChartQuery}/>
</Grid>
</Grid>
</div>
);
};
export default Dashboard;
这就是我们展示第一张图表所需的全部内容!🎉
在下一部分中,我们将让用户将日期范围从“本周”更改为其他预定义值,从而使此图表具有交互性。
具有多个图表的交互式仪表板
在上一部分中,我们创建了一个分析后端和一个包含第一个图表的基本仪表板。现在,我们将扩展仪表板,使其能够一目了然地查看我们电商公司的关键绩效指标。
自定义日期范围
作为第一步,我们将允许用户更改现有图表的日期范围。
我们将使用单独的<BarChartHeader />
组件来控制日期范围。让我们创建src/components/BarChartHeader.js
包含以下内容的文件:
import React from 'react';
import PropTypes from 'prop-types';
import { makeStyles } from '@material-ui/styles';
import { CardHeader, Button } from '@material-ui/core';
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
const useStyles = makeStyles(() => ({
headerButton: {
letterSpacing: '0.4px',
},
}));
const BarChartHeader = (props) => {
const { setDateRange, dateRange, dates } = props;
const defaultDates = ['This week', 'This month', 'Last 7 days', 'Last month'];
const classes = useStyles();
const [anchorEl, setAnchorEl] = React.useState(null);
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = (date) => {
setDateRange(date);
setAnchorEl(null);
};
return (
<CardHeader
action={
<div>
<Button
className={classes.headerButton}
size="small"
variant="text"
aria-controls="simple-menu"
aria-haspopup="true"
onClick={handleClick}
>
{dateRange} <ArrowDropDownIcon />
</Button>
<Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={() => handleClose(dateRange)}
>
{dates ?
dates.map((date) => (
<MenuItem key={date} onClick={() => handleClose(date)}>{date}</MenuItem>
))
: defaultDates.map((date) => (
<MenuItem key={date} onClick={() => handleClose(date)}>{date}</MenuItem>
))}
</Menu>
</div>
}
title="Latest Sales"
/>
);
};
BarChartHeader.propTypes = {
className: PropTypes.string,
};
export default BarChartHeader;
现在让我们将此<BarChartHeader />
组件添加到现有图表中。在文件中进行以下更改src/components/BarChart.js
:
// ...
import ChartRenderer from './ChartRenderer'
+ import BarChartHeader from "./BarChartHeader";
// ...
const BarChart = (props) => {
- const { className, query, ...rest } = props;
+ const { className, query, dates, ...rest } = props;
const classes = useStyles();
+ const [dateRange, setDateRange] = React.useState(dates ? dates[0] : 'This week');
+ let queryWithDate = {...query,
+ timeDimensions: [
+ {
+ dimension: query.timeDimensions[0].dimension,
+ granularity: query.timeDimensions[0].granularity,
+ dateRange: `${dateRange}`
+ }
+ ],
+ };
return (
<Card {...rest} className={clsx(classes.root, className)}>
+ <BarChartHeader dates={dates} dateRange={dateRange} setDateRange={setDateRange} />
+ <Divider />
<CardContent>
<div className={classes.chartContainer}>
<ChartRenderer vizState={{ query: queryWithDate, chartType: 'bar' }}/>
</div>
</CardContent>
</Card>
)
};
// ...
做得好!🎉 我们的仪表板应用程序如下所示:
关键绩效指标图表
KPI 图表可用于显示业务指标,提供有关我们电商公司当前绩效的信息。该图表由网格图块组成,每个图块将显示特定类别的单个 KPI 数字值。
首先,让我们使用该react-countup
包为 KPI 图表上的值添加计数动画。在dashboard-app
文件夹中运行以下命令:
npm install --save react-countup
新建我们已准备好添加新<KPIChart/>
组件。添加src/components/KPIChart.js
包含以下内容的组件:
import React from 'react';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import { makeStyles } from '@material-ui/styles';
import { Card, CardContent, Grid, Typography, LinearProgress } from '@material-ui/core';
import { useCubeQuery } from '@cubejs-client/react';
import CountUp from 'react-countup';
import CircularProgress from '@material-ui/core/CircularProgress';
const useStyles = makeStyles((theme) => ({
root: {
height: '100%',
},
content: {
alignItems: 'center',
display: 'flex',
},
title: {
fontWeight: 500,
},
progress: {
marginTop: theme.spacing(3),
height: '8px',
borderRadius: '10px',
},
difference: {
marginTop: theme.spacing(2),
display: 'flex',
alignItems: 'center',
},
differenceIcon: {
color: theme.palette.error.dark,
},
differenceValue: {
marginRight: theme.spacing(1),
},
green: {
color: theme.palette.success.dark,
},
red: {
color: theme.palette.error.dark,
},
}));
const KPIChart = (props) => {
const classes = useStyles();
const { className, title, progress, query, difference, duration, ...rest } = props;
const { resultSet, error, isLoading } = useCubeQuery(query);
const differenceQuery = {...query,
"timeDimensions": [
{
"dimension": `${difference || query.measures[0].split('.')[0]}.createdAt`,
"granularity": null,
"dateRange": "This year"
}
]};
const differenceValue = useCubeQuery(differenceQuery);
if (isLoading || differenceValue.isLoading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress color="secondary" />
</div>
);
}
if (error || differenceValue.error) {
return <pre>{(error || differenceValue.error).toString()}</pre>;
}
if (!resultSet || !differenceValue.resultSet) {
return null
}
if (resultSet && differenceValue.resultSet) {
let postfix = null;
let prefix = null;
const measureKey = resultSet.seriesNames()[0].key;
const annotations = resultSet.tableColumns().find(tableColumn => tableColumn.key === measureKey);
const format = annotations.format || (annotations.meta && annotations.meta.format);
if (format === 'percent') {
postfix = '%'
} else if (format === 'currency') {
prefix = '$'
}
let value = null;
let fullValue = resultSet.seriesNames().map((s) => resultSet.totalRow()[s.key])[0];
if (difference) {
value = differenceValue.resultSet.totalRow()[differenceQuery.measures[0]] / fullValue * 100;
}
return (
<Card {...rest} className={clsx(classes.root, className)}>
<CardContent>
<Grid container justify="space-between">
<Grid item>
<Typography className={classes.title} color="textSecondary" gutterBottom variant="body2">
{title}
</Typography>
<Typography variant="h3">
{prefix}
<CountUp
end={fullValue}
duration={duration}
separator=","
decimals={0}
/>
{postfix}
</Typography>
</Grid>
</Grid>
{progress ? (
<LinearProgress
className={classes.progress}
value={fullValue}
variant="determinate"
/>
) : null}
{difference ? (
<div className={classes.difference}>
<Typography className={classes.differenceValue} variant="body2">
{value > 1 ? (
<span className={classes.green}>{value.toFixed(1)}%</span>
) : (
<span className={classes.red}>{value.toFixed(1)}%</span>
)}
</Typography>
<Typography className={classes.caption} variant="caption">
Since this year
</Typography>
</div>
) : null}
</CardContent>
</Card>
);
}
};
KPIChart.propTypes = {
className: PropTypes.string,
title: PropTypes.string,
};
export default KPIChart;
让我们学习如何在数据架构中创建自定义度量并显示其值。在电子商务业务中,了解已完成订单的份额至关重要。为了让用户能够监控此指标,我们希望将其显示在 KPI 图表上。因此,我们将修改数据架构,添加一个自定义度量( percentOfCompletedOrders
),该度量将根据另一个度量 ( ) 计算份额completedCount
。
让我们自定义“Orders”模式。打开schema/Orders.js
Cube.js项目根文件夹中的文件并进行以下更改:
- 添加
completedCount
度量 - 添加
percentOfCompletedOrders
度量
cube(`Orders`, {
sql: `SELECT * FROM public.orders`,
// ...
measures: {
count: {
type: `count`,
drillMembers: [id, createdAt]
},
number: {
sql: `number`,
type: `sum`
},
+ completedCount: {
+ sql: `id`,
+ type: `count`,
+ filters: [
+ { sql: `${CUBE}.status = 'completed'` }
+ ]
+ },
+ percentOfCompletedOrders: {
+ sql: `${completedCount}*100.0/${count}`,
+ type: `number`,
+ format: `percent`
+ }
},
// ...
});
现在,我们准备将显示多个 KPI 的 KPI 图表添加到仪表板。对src/pages/DashboardPage.js
文件进行以下更改:
// ...
+ import KPIChart from '../components/KPIChart';
import BarChart from '../components/BarChart.js'
// ...
+ const cards = [
+ {
+ title: 'ORDERS',
+ query: { measures: ['Orders.count'] },
+ difference: 'Orders',
+ duration: 1.25,
+ },
+ {
+ title: 'TOTAL USERS',
+ query: { measures: ['Users.count'] },
+ difference: 'Users',
+ duration: 1.5,
+ },
+ {
+ title: 'COMPLETED ORDERS',
+ query: { measures: ['Orders.percentOfCompletedOrders'] },
+ progress: true,
+ duration: 1.75,
+ },
+ {
+ title: 'TOTAL PROFIT',
+ query: { measures: ['LineItems.price'] },
+ duration: 2.25,
+ },
+ ];
const Dashboard = () => {
const classes = useStyles();
return (
<div className={classes.root}>
<Grid
container
spacing={4}
>
+ {cards.map((item, index) => {
+ return (
+ <Grid
+ key={item.title + index}
+ item
+ lg={3}
+ sm={6}
+ xl={3}
+ xs={12}
+ >
+ <KPIChart {...item}/>
+ </Grid>
+ )
+ })}
<Grid
item
lg={8}
md={12}
xl={9}
xs={12}
>
<BarChart/>
</Grid>
</Grid>
</div>
);
};
太棒了!🎉现在我们的仪表盘上有一排美观且信息丰富的 KPI 指标:
甜甜圈图
现在,使用 KPI 图表,我们的用户能够监控已完成订单的占比。此外,订单还分为两种:“已处理”订单(已确认但尚未发货)和“已发货”订单(实质上是指已接收发货但尚未完成的订单)。
为了让用户能够监控所有这些类型的订单,我们需要在仪表盘中添加一个图表。最好使用圆环图,因为它非常有助于直观地展示某个指标在多个状态(例如,所有类型的订单)之间的分布情况。
首先,和上一部分一样,我们将图表选项放入一个单独的文件中。让我们创建src/helpers/DoughnutOptions.js
包含以下内容的文件:
import palette from "../theme/palette";
export const DoughnutOptions = {
legend: {
display: false
},
responsive: true,
maintainAspectRatio: false,
cutoutPercentage: 80,
layout: { padding: 0 },
tooltips: {
enabled: true,
mode: "index",
intersect: false,
borderWidth: 1,
borderColor: palette.divider,
backgroundColor: palette.white,
titleFontColor: palette.text.primary,
bodyFontColor: palette.text.secondary,
footerFontColor: palette.text.secondary
}
};
然后,让我们创建src/components/DoughnutChart.js
具有以下内容的新图表:
import React from 'react';
import { Doughnut } from 'react-chartjs-2';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import { makeStyles, useTheme } from '@material-ui/styles';
import { Card, CardHeader, CardContent, Divider, Typography } from '@material-ui/core';
import { useCubeQuery } from '@cubejs-client/react';
import CircularProgress from '@material-ui/core/CircularProgress';
import { DoughnutOptions } from '../helpers/DoughnutOptions.js';
const useStyles = makeStyles((theme) => ({
root: {
height: '100%',
},
chartContainer: {
marginTop: theme.spacing(3),
position: 'relative',
height: '300px',
},
stats: {
marginTop: theme.spacing(2),
display: 'flex',
justifyContent: 'center',
},
status: {
textAlign: 'center',
padding: theme.spacing(1),
},
title: {
color: theme.palette.text.secondary,
paddingBottom: theme.spacing(1),
},
statusIcon: {
color: theme.palette.icon,
},
}));
const DoughnutChart = (props) => {
const { className, query, ...rest } = props;
const classes = useStyles();
const theme = useTheme();
const { resultSet, error, isLoading } = useCubeQuery(query);
if (isLoading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress color="secondary" />
</div>
);
}
if (error) {
return <pre>{error.toString()}</pre>;
}
if (!resultSet) {
return null
}
if (resultSet) {
const COLORS_SERIES = [
theme.palette.secondary.light,
theme.palette.secondary.lighten,
theme.palette.secondary.main,
];
const data = {
labels: resultSet.categories().map((c) => c.category),
datasets: resultSet.series().map((s) => ({
label: s.title,
data: s.series.map((r) => r.value),
backgroundColor: COLORS_SERIES,
hoverBackgroundColor: COLORS_SERIES,
})),
};
const reducer = (accumulator, currentValue) => accumulator + currentValue;
return (
<Card {...rest} className={clsx(classes.root, className)}>
<CardHeader title="Orders status" />
<Divider />
<CardContent>
<div className={classes.chartContainer}>
<Doughnut data={data} options={DoughnutOptions} />
</div>
<div className={classes.stats}>
{resultSet.series()[0].series.map((status) => (
<div className={classes.status} key={status.category}>
<Typography variant="body1" className={classes.title}>
{status.category}
</Typography>
<Typography variant="h2">{((status.value/resultSet.series()[0].series.map(el => el.value).reduce(reducer)) * 100).toFixed(0)}%</Typography>
</div>
))}
</div>
</CardContent>
</Card>
);
}
};
DoughnutChart.propTypes = {
className: PropTypes.string,
};
export default DoughnutChart;
最后一步是将新图表添加到仪表板。让我们修改src/pages/DashboardPage.js
文件:
// ...
import DataCard from '../components/DataCard';
import BarChart from '../components/BarChart.js'
+ import DoughnutChart from '../components/DoughnutChart.js'
// ...
+ const doughnutChartQuery = {
+ measures: ['Orders.count'],
+ timeDimensions: [
+ {
+ dimension: 'Orders.createdAt',
+ },
+ ],
+ filters: [],
+ dimensions: ['Orders.status'],
+ };
//...
return (
<div className={classes.root}>
<Grid
container
spacing={4}
>
// ...
+ <Grid
+ item
+ lg={4}
+ md={6}
+ xl={3}
+ xs={12}
+ >
+ <DoughnutChart query={doughnutChartQuery}/>
+ </Grid>
</Grid>
</div>
);
太棒了!🎉 现在我们的仪表盘第一页已经完成了:
如果您喜欢我们仪表板的布局,请查看Devias Kit Admin Dashboard,这是一个使用 Material UI 组件制作的开源 React 仪表板。
带数据表的多页仪表板
现在,我们有一个单页仪表盘,可以显示汇总的业务指标,并提供多个 KPI 的概览。但是,我们无法获取特定订单或一系列订单的信息。
我们将通过在仪表板上添加第二个页面来解决这个问题,该页面包含所有订单的信息。在该页面上,我们将使用Material UI 中的数据表组件,该组件非常适合显示表格数据。它提供许多丰富的功能,例如排序、搜索、分页、内联编辑和行选择。
但是,我们需要一种在两个页面之间导航的方法。所以,让我们添加一个导航侧栏。
导航侧栏
首先,让我们下载仪表板应用程序的预建布局和图像。运行以下命令,将文件解压layout.zip
到src/layouts
文件夹,并将images.zip
文件解压到public/images
文件夹:
curl -LJO https://github.com/cube-js/cube.js/tree/master/examples/material-ui-dashboard/dashboard-app/src/layouts/layouts.zip
curl -LJO https://github.com/cube-js/cube.js/tree/master/examples/material-ui-dashboard/dashboard-app/public/images/images.zip
现在我们可以将此布局添加到应用程序中。让我们修改该src/App.js
文件:
// ...
import 'typeface-roboto';
- import Header from "./components/Header";
+ import { Main } from './layouts'
// ...
const AppLayout = ({children}) => {
const classes = useStyles();
return (
<ThemeProvider theme={theme}>
+ <Main>
<div className={classes.root}>
- <Header/>
<div>{children}</div>
</div>
+ </Main>
</ThemeProvider>
);
};
哇!🎉 这是我们的导航侧栏,可用于在仪表板的不同页面之间切换:
订单数据表
为了获取数据表的数据,我们需要自定义数据模式并定义一些新指标:订单中的商品数量(其大小)、订单的价格和用户的全名。
首先,让我们在文件中的“用户”模式中添加全名schema/Users.js
:
cube(`Users`, {
sql: `SELECT * FROM public.users`,
// ...
dimensions: {
// ...
firstName: {
sql: `first_name`,
type: `string`
},
lastName: {
sql: `last_name`,
type: `string`
},
+ fullName: {
+ sql: `CONCAT(${firstName}, ' ', ${lastName})`,
+ type: `string`
+ },
age: {
sql: `age`,
type: `number`
},
createdAt: {
sql: `created_at`,
type: `time`
}
}
});
然后,让我们向文件中的“订单”模式添加其他措施schema/Orders.js
。
对于这些度量,我们将使用Cube.js 的子查询功能。您可以使用子查询维度来引用维度内其他多维数据集的度量。定义此类维度的方法如下:
cube(`Orders`, {
sql: `SELECT * FROM public.orders`,
dimensions: {
id: {
sql: `id`,
type: `number`,
primaryKey: true,
+ shown: true
},
status: {
sql: `status`,
type: `string`
},
createdAt: {
sql: `created_at`,
type: `time`
},
completedAt: {
sql: `completed_at`,
type: `time`
},
+ size: {
+ sql: `${LineItems.count}`,
+ subQuery: true,
+ type: 'number'
+ },
+
+ price: {
+ sql: `${LineItems.price}`,
+ subQuery: true,
+ type: 'number'
+ }
}
});
现在我们可以添加新页面了。打开src/index.js
文件并添加新路由和默认重定向:
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
- import { HashRouter as Router, Route } from "react-router-dom";
+ import { HashRouter as Router, Route, Switch, Redirect } from "react-router-dom";
import DashboardPage from "./pages/DashboardPage";
+ import DataTablePage from './pages/DataTablePage';
ReactDOM.render(
<React.StrictMode>
<Router>
<App>
- <Route key="index" exact path="/" component={DashboardPage} />
+ <Switch>
+ <Redirect exact from="/" to="/dashboard"/>
+ <Route key="index" exact path="/dashboard" component={DashboardPage} />
+ <Route key="table" path="/orders" component={DataTablePage} />
+ <Redirect to="/dashboard" />
+ </Switch>
</App>
</Router>
</React.StrictMode>,
document.getElementById("root")
);
serviceWorker.unregister();
下一步是创建新路由中引用的页面。添加src/pages/DataTablePage.js
包含以下内容的文件:
import React from "react";
import { makeStyles } from "@material-ui/styles";
import Table from "../components/Table.js";
const useStyles = makeStyles(theme => ({
root: {
padding: theme.spacing(4)
},
content: {
marginTop: 15
},
}));
const DataTablePage = () => {
const classes = useStyles();
const query = {
"limit": 500,
"timeDimensions": [
{
"dimension": "Orders.createdAt",
"granularity": "day"
}
],
"dimensions": [
"Users.id",
"Orders.id",
"Orders.size",
"Users.fullName",
"Users.city",
"Orders.price",
"Orders.status",
"Orders.createdAt"
]
};
return (
<div className={classes.root}>
<div className={classes.content}>
<Table query={query}/>
</div>
</div>
);
};
export default DataTablePage;
请注意,此组件包含一个 Cube.js 查询。稍后我们将修改此查询以启用数据过滤功能。
所有数据项都通过该组件进行渲染<Table />
,查询结果的更改也会反映在表中。让我们在文件<Table />
中创建此组件src/components/Table.js
,其内容如下:
import React, { useState } from "react";
import clsx from "clsx";
import PropTypes from "prop-types";
import moment from "moment";
import PerfectScrollbar from "react-perfect-scrollbar";
import { makeStyles } from "@material-ui/styles";
import { useCubeQuery } from "@cubejs-client/react";
import CircularProgress from "@material-ui/core/CircularProgress";
import {
Card,
CardActions,
CardContent,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TablePagination, Typography
} from "@material-ui/core";
import StatusBullet from "./StatusBullet";
import palette from "../theme/palette";
const useStyles = makeStyles(theme => ({
root: {
padding: 0
},
content: {
padding: 0
},
head: {
backgroundColor: palette.background.gray
},
inner: {
minWidth: 1050
},
nameContainer: {
display: "flex",
alignItems: "baseline"
},
status: {
marginRight: theme.spacing(2)
},
actions: {
justifyContent: "flex-end"
},
}));
const statusColors = {
completed: "success",
processing: "info",
shipped: "danger"
};
const TableComponent = props => {
const { className, query, cubejsApi, ...rest } = props;
const classes = useStyles();
const [rowsPerPage, setRowsPerPage] = useState(10);
const [page, setPage] = useState(0);
const tableHeaders = [
{
text: "Order id",
value: "Orders.id"
},
{
text: "Orders size",
value: "Orders.size"
},
{
text: "Full Name",
value: "Users.fullName"
},
{
text: "User city",
value: "Users.city"
},
{
text: "Order price",
value: "Orders.price"
},
{
text: "Status",
value: "Orders.status"
},
{
text: "Created at",
value: "Orders.createdAt"
}
];
const { resultSet, error, isLoading } = useCubeQuery(query, { cubejsApi });
if (isLoading) {
return <div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}><CircularProgress color="secondary" /></div>;
}
if (error) {
return <pre>{error.toString()}</pre>;
}
if (resultSet) {
let orders = resultSet.tablePivot();
const handlePageChange = (event, page) => {
setPage(page);
};
const handleRowsPerPageChange = event => {
setRowsPerPage(event.target.value);
};
return (
<Card
{...rest}
padding={"0"}
className={clsx(classes.root, className)}
>
<CardContent className={classes.content}>
<PerfectScrollbar>
<div className={classes.inner}>
<Table>
<TableHead className={classes.head}>
<TableRow>
{tableHeaders.map((item) => (
<TableCell key={item.value + Math.random()}
className={classes.hoverable}
>
<span>{item.text}</span>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{orders.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map(obj => (
<TableRow
className={classes.tableRow}
hover
key={obj["Orders.id"]}
>
<TableCell>
{obj["Orders.id"]}
</TableCell>
<TableCell>
{obj["Orders.size"]}
</TableCell>
<TableCell>
{obj["Users.fullName"]}
</TableCell>
<TableCell>
{obj["Users.city"]}
</TableCell>
<TableCell>
{"$ " + obj["Orders.price"]}
</TableCell>
<TableCell>
<StatusBullet
className={classes.status}
color={statusColors[obj["Orders.status"]]}
size="sm"
/>
{obj["Orders.status"]}
</TableCell>
<TableCell>
{moment(obj["Orders.createdAt"]).format("DD/MM/YYYY")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</PerfectScrollbar>
</CardContent>
<CardActions className={classes.actions}>
<TablePagination
component="div"
count={orders.length}
onChangePage={handlePageChange}
onChangeRowsPerPage={handleRowsPerPageChange}
page={page}
rowsPerPage={rowsPerPage}
rowsPerPageOptions={[5, 10, 25, 50, 100]}
/>
</CardActions>
</Card>
);
} else {
return null
}
};
TableComponent.propTypes = {
className: PropTypes.string,
query: PropTypes.object.isRequired
};
export default TableComponent;
该表格包含一个带有自定义<StatusBullet />
组件的单元格,该组件用彩色圆点显示订单状态。让我们在src/components/StatusBullet.js
文件中创建此组件,内容如下:
import React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { makeStyles } from '@material-ui/styles';
const useStyles = makeStyles(theme => ({
root: {
display: 'inline-block',
borderRadius: '50%',
flexGrow: 0,
flexShrink: 0
},
sm: {
height: theme.spacing(1),
width: theme.spacing(1)
},
md: {
height: theme.spacing(2),
width: theme.spacing(2)
},
lg: {
height: theme.spacing(3),
width: theme.spacing(3)
},
neutral: {
backgroundColor: theme.palette.neutral
},
primary: {
backgroundColor: theme.palette.primary.main
},
info: {
backgroundColor: theme.palette.info.main
},
warning: {
backgroundColor: theme.palette.warning.main
},
danger: {
backgroundColor: theme.palette.error.main
},
success: {
backgroundColor: theme.palette.success.main
}
}));
const StatusBullet = props => {
const { className, size, color, ...rest } = props;
const classes = useStyles();
return (
<span
{...rest}
className={clsx(
{
[classes.root]: true,
[classes[size]]: size,
[classes[color]]: color
},
className
)}
/>
);
};
StatusBullet.propTypes = {
className: PropTypes.string,
color: PropTypes.oneOf([
'neutral',
'primary',
'info',
'success',
'warning',
'danger'
]),
size: PropTypes.oneOf(['sm', 'md', 'lg'])
};
StatusBullet.defaultProps = {
size: 'md',
color: 'default'
};
export default StatusBullet;
太棒了!🎉现在我们有了一个显示所有订单信息的表格:
然而,仅使用提供的控件很难探索这些订单。为了解决这个问题,我们将添加一个带有过滤器的综合工具栏,并使表格具有交互性。
首先,让我们添加一些依赖项。在文件夹中运行以下命令dashboard-app
:
npm install --save @date-io/date-fns@1.x date-fns @date-io/moment@1.x moment @material-ui/lab/Autocomplete
然后,<Toolbar />
在src/components/Toolbar.js
文件中创建具有以下内容的组件:
import "date-fns";
import React from "react";
import PropTypes from "prop-types";
import clsx from "clsx";
import { makeStyles } from "@material-ui/styles";
import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import Tab from "@material-ui/core/Tab";
import Tabs from "@material-ui/core/Tabs";
import withStyles from "@material-ui/core/styles/withStyles";
import palette from "../theme/palette";
const AntTabs = withStyles({
root: {
borderBottom: `1px solid ${palette.primary.main}`,
},
indicator: {
backgroundColor: `${palette.primary.main}`,
},
})(Tabs);
const AntTab = withStyles((theme) => ({
root: {
textTransform: 'none',
minWidth: 25,
fontSize: 12,
fontWeight: theme.typography.fontWeightRegular,
marginRight: theme.spacing(0),
color: palette.primary.dark,
opacity: 0.6,
'&:hover': {
color: `${palette.primary.main}`,
opacity: 1,
},
'&$selected': {
color: `${palette.primary.main}`,
fontWeight: theme.typography.fontWeightMedium,
outline: 'none',
},
'&:focus': {
color: `${palette.primary.main}`,
outline: 'none',
},
},
selected: {},
}))((props) => <Tab disableRipple {...props} />);
const useStyles = makeStyles(theme => ({
root: {},
row: {
marginTop: theme.spacing(1)
},
spacer: {
flexGrow: 1
},
importButton: {
marginRight: theme.spacing(1)
},
exportButton: {
marginRight: theme.spacing(1)
},
searchInput: {
marginRight: theme.spacing(1)
},
formControl: {
margin: 25,
fullWidth: true,
display: "flex",
wrap: "nowrap"
},
date: {
marginTop: 3
},
range: {
marginTop: 13
}
}));
const Toolbar = props => {
const { className,
statusFilter,
setStatusFilter,
tabs,
...rest } = props;
const [tabValue, setTabValue] = React.useState(statusFilter);
const classes = useStyles();
const handleChangeTab = (e, value) => {
setTabValue(value);
setStatusFilter(value);
};
return (
<div
{...rest}
className={clsx(classes.root, className)}
>
<Grid container spacing={4}>
<Grid
item
lg={3}
sm={6}
xl={3}
xs={12}
m={2}
>
<div className={classes}>
<AntTabs value={tabValue} onChange={(e,value) => {handleChangeTab(e,value)}} aria-label="ant example">
{tabs.map((item) => (<AntTab key={item} label={item} />))}
</AntTabs>
<Typography className={classes.padding} />
</div>
</Grid>
</Grid>
</div>
);
};
Toolbar.propTypes = {
className: PropTypes.string
};
export default Toolbar;
请注意,我们已经<Tab />
使用样式和setStatusFilter
通过 props 传递的方法自定义了组件。现在,我们可以将此组件、props 和过滤器添加到父组件中。让我们修改src/pages/DataTablePage.js
文件:
import React from "react";
import { makeStyles } from "@material-ui/styles";
+ import Toolbar from "../components/Toolbar.js";
import Table from "../components/Table.js";
const useStyles = makeStyles(theme => ({
root: {
padding: theme.spacing(4)
},
content: {
marginTop: 15
},
}));
const DataTablePage = () => {
const classes = useStyles();
+ const tabs = ['All', 'Shipped', 'Processing', 'Completed'];
+ const [statusFilter, setStatusFilter] = React.useState(0);
const query = {
"limit": 500,
"timeDimensions": [
{
"dimension": "Orders.createdAt",
"granularity": "day"
}
],
"dimensions": [
"Users.id",
"Orders.id",
"Orders.size",
"Users.fullName",
"Users.city",
"Orders.price",
"Orders.status",
"Orders.createdAt"
],
+ "filters": [
+ {
+ "dimension": "Orders.status",
+ "operator": tabs[statusFilter] !== 'All' ? "equals" : "set",
+ "values": [
+ `${tabs[statusFilter].toLowerCase()}`
+ ]
+ }
+ ]
};
return (
<div className={classes.root}>
+ <Toolbar
+ statusFilter={statusFilter}
+ setStatusFilter={setStatusFilter}
+ tabs={tabs}
+ />
<div className={classes.content}>
<Table
query={query}/>
</div>
</div>
);
};
export default DataTablePage;
完美!🎉 现在数据表新增了一个过滤器,可以在不同类型的订单之间切换:
但是,订单还有其他参数,例如价格和日期。让我们为这些参数创建过滤器。为此,请修改src/components/Toolbar.js
文件:
import "date-fns";
import React from "react";
import PropTypes from "prop-types";
import clsx from "clsx";
import { makeStyles } from "@material-ui/styles";
import Grid from "@material-ui/core/Grid";
import Typography from "@material-ui/core/Typography";
import Tab from "@material-ui/core/Tab";
import Tabs from "@material-ui/core/Tabs";
import withStyles from "@material-ui/core/styles/withStyles";
import palette from "../theme/palette";
+ import DateFnsUtils from "@date-io/date-fns";
+ import {
+ MuiPickersUtilsProvider,
+ KeyboardDatePicker
+ } from "@material-ui/pickers";
+ import Slider from "@material-ui/core/Slider";
// ...
const Toolbar = props => {
const { className,
+ startDate,
+ setStartDate,
+ finishDate,
+ setFinishDate,
+ priceFilter,
+ setPriceFilter,
statusFilter,
setStatusFilter,
tabs,
...rest } = props;
const [tabValue, setTabValue] = React.useState(statusFilter);
+ const [rangeValue, rangeSetValue] = React.useState(priceFilter);
const classes = useStyles();
const handleChangeTab = (e, value) => {
setTabValue(value);
setStatusFilter(value);
};
+ const handleDateChange = (date) => {
+ setStartDate(date);
+ };
+ const handleDateChangeFinish = (date) => {
+ setFinishDate(date);
+ };
+ const handleChangeRange = (event, newValue) => {
+ rangeSetValue(newValue);
+ };
+ const setRangeFilter = (event, newValue) => {
+ setPriceFilter(newValue);
+ };
return (
<div
{...rest}
className={clsx(classes.root, className)}
>
<Grid container spacing={4}>
<Grid
item
lg={3}
sm={6}
xl={3}
xs={12}
m={2}
>
<div className={classes}>
<AntTabs value={tabValue} onChange={(e,value) => {handleChangeTab(e,value)}} aria-label="ant example">
{tabs.map((item) => (<AntTab key={item} label={item} />))}
</AntTabs>
<Typography className={classes.padding} />
</div>
</Grid>
+ <Grid
+ className={classes.date}
+ item
+ lg={3}
+ sm={6}
+ xl={3}
+ xs={12}
+ m={2}
+ >
+ <MuiPickersUtilsProvider utils={DateFnsUtils}>
+ <Grid container justify="space-around">
+ <KeyboardDatePicker
+ id="date-picker-dialog"
+ label={<span style={{opacity: 0.6}}>Start Date</span>}
+ format="MM/dd/yyyy"
+ value={startDate}
+ onChange={handleDateChange}
+ KeyboardButtonProps={{
+ "aria-label": "change date"
+ }}
+ />
+ </Grid>
+ </MuiPickersUtilsProvider>
+ </Grid>
+ <Grid
+ className={classes.date}
+ item
+ lg={3}
+ sm={6}
+ xl={3}
+ xs={12}
+ m={2}
+ >
+ <MuiPickersUtilsProvider utils={DateFnsUtils}>
+ <Grid container justify="space-around">
+ <KeyboardDatePicker
+ id="date-picker-dialog-finish"
+ label={<span style={{opacity: 0.6}}>Finish Date</span>}
+ format="MM/dd/yyyy"
+ value={finishDate}
+ onChange={handleDateChangeFinish}
+ KeyboardButtonProps={{
+ "aria-label": "change date"
+ }}
+ />
+ </Grid>
+ </MuiPickersUtilsProvider>
+ </Grid>
+ <Grid
+ className={classes.range}
+ item
+ lg={3}
+ sm={6}
+ xl={3}
+ xs={12}
+ m={2}
+ >
+ <Typography id="range-slider">
+ Order price range
+ </Typography>
+ <Slider
+ value={rangeValue}
+ onChange={handleChangeRange}
+ onChangeCommitted={setRangeFilter}
+ aria-labelledby="range-slider"
+ valueLabelDisplay="auto"
+ min={0}
+ max={2000}
+ />
+ </Grid>
</Grid>
</div>
);
};
Toolbar.propTypes = {
className: PropTypes.string
};
export default Toolbar;
为了使这些过滤器正常工作,我们需要将它们连接到父组件:添加状态、修改查询,并向<Toolbar />
组件添加新的 props。此外,我们还需要为数据表添加排序功能。因此,请src/pages/DataTablePage.js
按如下方式修改文件:
// ...
const DataTablePage = () => {
const classes = useStyles();
const tabs = ['All', 'Shipped', 'Processing', 'Completed'];
const [statusFilter, setStatusFilter] = React.useState(0);
+ const [startDate, setStartDate] = React.useState(new Date("2019-01-01T00:00:00"));
+ const [finishDate, setFinishDate] = React.useState(new Date("2022-01-01T00:00:00"));
+ const [priceFilter, setPriceFilter] = React.useState([0, 200]);
+ const [sorting, setSorting] = React.useState(['Orders.createdAt', 'desc']);
const query = {
"limit": 500,
+ "order": {
+ [`${sorting[0]}`]: sorting[1]
+ },
"measures": [
"Orders.count"
],
"timeDimensions": [
{
"dimension": "Orders.createdAt",
+ "dateRange": [startDate, finishDate],
"granularity": "day"
}
],
"dimensions": [
"Users.id",
"Orders.id",
"Orders.size",
"Users.fullName",
"Users.city",
"Orders.price",
"Orders.status",
"Orders.createdAt"
],
"filters": [
{
"dimension": "Orders.status",
"operator": tabs[statusFilter] !== 'All' ? "equals" : "set",
"values": [
`${tabs[statusFilter].toLowerCase()}`
]
},
+ {
+ "dimension": "Orders.price",
+ "operator": "gt",
+ "values": [
+ `${priceFilter[0]}`
+ ]
+ },
+ {
+ "dimension": "Orders.price",
+ "operator": "lt",
+ "values": [
+ `${priceFilter[1]}`
+ ]
+ },
]
};
return (
<div className={classes.root}>
<Toolbar
+ startDate={startDate}
+ setStartDate={setStartDate}
+ finishDate={finishDate}
+ setFinishDate={setFinishDate}
+ priceFilter={priceFilter}
+ setPriceFilter={setPriceFilter}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
tabs={tabs}
/>
<div className={classes.content}>
<Table
+ sorting={sorting}
+ setSorting={setSorting}
query={query}/>
</div>
</div>
);
};
export default DataTablePage;
太棒了!🎉 我们添加了一些实用的过滤器。事实上,您还可以添加更多带有自定义逻辑的过滤器。请参阅文档,了解过滤器格式选项。
还有一件事。我们已经向工具栏添加了排序属性,但我们还需要将它们传递给<Table />
组件。为了解决这个问题,让我们修改src/components/Table.js
文件:
// ...
+ import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
+ import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import { useCubeQuery } from "@cubejs-client/react";
import CircularProgress from "@material-ui/core/CircularProgress";
// ...
const useStyles = makeStyles(theme => ({
// ...
actions: {
justifyContent: "flex-end"
},
+ tableRow: {
+ padding: '0 5px',
+ cursor: "pointer",
+ '.MuiTableRow-root.MuiTableRow-hover&:hover': {
+ backgroundColor: palette.primary.action
+ }
+ },
+ hoverable: {
+ "&:hover": {
+ color: `${palette.primary.normal}`,
+ cursor: `pointer`
+ }
+ },
+ arrow: {
+ fontSize: 10,
+ position: "absolute"
+ }
}));
const statusColors = {
completed: "success",
processing: "info",
shipped: "danger"
};
const TableComponent = props => {
- const { className, query, cubejsApi, ...rest } = props;
+ const { className, sorting, setSorting, query, cubejsApi, ...rest } = props;
// ...
if (resultSet) {
//...
+ const handleSetSorting = str => {
+ setSorting([str, sorting[1] === "desc" ? "asc" : "desc"]);
+ };
return (
// ...
<TableHead className={classes.head}>
<TableRow>
{tableHeaders.map((item) => (
<TableCell key={item.value + Math.random()} className={classes.hoverable}
+ onClick={() => {
+ handleSetSorting(`${item.value}`);
+ }}
>
<span>{item.text}</span>
+ <Typography
+ className={classes.arrow}
+ variant="body2"
+ component="span"
+ >
+ {(sorting[0] === item.value) ? (sorting[1] === "desc" ? <KeyboardArrowUpIcon/> :
+ <KeyboardArrowDownIcon/>) : null}
+ </Typography>
</TableCell>
))}
</TableRow>
</TableHead>
// ...
太棒了!🎉 现在我们有了完全支持过滤和排序的数据表:
用户深入页面
我们构建的数据表可以查找特定订单的信息。但是,我们的电商业务非常成功,退货率也很高,这意味着用户很有可能在一段时间内多次下单。因此,让我们添加一个向下钻取页面,以探索特定用户的完整订单信息。
由于这是一个新页面,让我们向src/index.js
文件添加一条新路线:
// ...
<Switch>
<Redirect exact from="/" to="/dashboard" />
<Route key="index" exact path="/dashboard" component={DashboardPage} />
<Route key="table" path="/orders" component={DataTablePage} />
+ <Route key="table" path="/user/:id" component={UsersPage} />
<Redirect to="/dashboard" />
</Switch>
// ...
为了使此路线正常工作,我们还需要添加src/pages/UsersPage.js
包含以下内容的文件:
import React from 'react';
import { useParams } from 'react-router-dom';
import { makeStyles } from '@material-ui/styles';
import { useCubeQuery } from '@cubejs-client/react';
import { Grid } from '@material-ui/core';
import AccountProfile from '../components/AccountProfile';
import BarChart from '../components/BarChart';
import CircularProgress from '@material-ui/core/CircularProgress';
import UserSearch from '../components/UserSearch';
import KPIChart from '../components/KPIChart';
const useStyles = makeStyles((theme) => ({
root: {
padding: theme.spacing(4),
},
row: {
display: 'flex',
margin: '0 -15px',
},
info: {
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
},
sales: {
marginTop: theme.spacing(4),
},
loaderWrap: {
width: '100%',
height: '100%',
minHeight: 'calc(100vh - 64px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
}));
const UsersPage = (props) => {
const classes = useStyles();
let { id } = useParams();
const query = {
measures: ['Users.count'],
timeDimensions: [
{
dimension: 'Users.createdAt',
},
],
dimensions: [
'Users.id',
'Products.id',
'Users.firstName',
'Users.lastName',
'Users.gender',
'Users.age',
'Users.city',
'LineItems.itemPrice',
'Orders.createdAt',
],
filters: [
{
dimension: 'Users.id',
operator: 'equals',
values: [`${id}`],
},
],
};
const barChartQuery = {
measures: ['Orders.count'],
timeDimensions: [
{
dimension: 'Orders.createdAt',
granularity: 'month',
dateRange: 'This week',
},
],
dimensions: ['Orders.status'],
filters: [
{
dimension: 'Users.id',
operator: 'equals',
values: [id],
},
],
};
const cards = [
{
title: 'ORDERS',
query: {
measures: ['Orders.count'],
filters: [
{
dimension: 'Users.id',
operator: 'equals',
values: [`${id}`],
},
],
},
duration: 1.25,
},
{
title: 'TOTAL SALES',
query: {
measures: ['LineItems.price'],
filters: [
{
dimension: 'Users.id',
operator: 'equals',
values: [`${id}`],
},
],
},
duration: 1.5,
},
];
const { resultSet, error, isLoading } = useCubeQuery(query);
if (isLoading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress color="secondary" />
</div>
);
}
if (error) {
return <pre>{error.toString()}</pre>;
}
if (!resultSet) {
return null;
}
if (resultSet) {
let data = resultSet.tablePivot();
let userData = data[0];
return (
<div className={classes.root}>
<Grid container spacing={4}>
<Grid item lg={4} sm={6} xl={4} xs={12}>
<UserSearch />
<AccountProfile
userFirstName={userData['Users.firstName']}
userLastName={userData['Users.lastName']}
gender={userData['Users.gender']}
age={userData['Users.age']}
city={userData['Users.city']}
id={id}
/>
</Grid>
<Grid item lg={8} sm={6} xl={4} xs={12}>
<div className={classes.row}>
{cards.map((item, index) => {
return (
<Grid className={classes.info} key={item.title + index} item lg={6} sm={6} xl={6} xs={12}>
<KPIChart {...item} />
</Grid>
);
})}
</div>
<div className={classes.sales}>
<BarChart query={barChartQuery} dates={['This year', 'Last year']} />
</div>
</Grid>
</Grid>
</div>
);
}
};
export default UsersPage;
最后一件事是,通过点击包含用户全名的单元格,数据表可以导航到此页面。我们修改如下src/components/Table.js
:
// ...
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp';
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
+ import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import { useCubeQuery } from '@cubejs-client/react';
import CircularProgress from '@material-ui/core/CircularProgress';
// ...
<TableCell>{obj['Orders.id']}</TableCell>
<TableCell>{obj['Orders.size']}</TableCell>
+ <TableCell
+ className={classes.hoverable}
+ onClick={() => handleClick(`/user/${obj['Users.id']}`)}
+ >
+ {obj['Users.fullName']}
+
+ <Typography className={classes.arrow} variant="body2" component="span">
+ <OpenInNewIcon fontSize="small" />
+ </Typography>
+ </TableCell>
<TableCell>{obj['Users.city']}</TableCell>
<TableCell>{'$ ' + obj['Orders.price']}</TableCell>
// ...
以下是我们最终得到的结果:
就这些!😇 恭喜你完成本指南!🎉
现在,您应该能够创建由 Cube.js 提供支持的综合分析仪表板,并使用 React 和 Material UI 来显示汇总指标和详细信息。
欢迎随意探索使用 Cube.js 可以实现的其他示例,例如实时仪表板指南和开源 Web 分析平台指南。
鏂囩珷鏉ユ簮锛�https://dev.to/cubejs/material-ui-dashboard-with-react-jn4