Material UI - самый популярный фреймворк React UI. Созданный на основе Material Design от Google, Material UI предоставляет множество готовых к использованию компонентов для быстрого и простого создания веб-приложений, включая информационные панели.

В этом руководстве мы узнаем, как создать полнофункциональную панель мониторинга с ключевыми показателями эффективности, диаграммами и таблицей данных. Мы перейдем от данных в базе данных к интерактивной панели администратора с возможностью фильтрации и поиска.

Мы собираемся использовать Cube.js для нашего API аналитики. Он устраняет всю суету построения уровня API, генерации SQL и запросов к базе данных. Он также предоставляет множество функций производственного уровня, таких как многоуровневое кэширование для оптимальной производительности, мультитенантность, безопасность и многое другое.

Ниже вы можете увидеть анимированное изображение приложения, которое мы собираемся создать. Также посмотрите живую демонстрацию и полный исходный код, доступные на GitHub.

Бэкэнд Аналитики с Cube.js

Мы собираемся создать панель управления для компании, занимающейся электронной коммерцией, которая хочет отслеживать свою общую эффективность и статусы заказов. Предположим, компания хранит свои данные в базе данных SQL. Итак, чтобы отображать эти данные на панели инструментов, мы собираемся создать серверную часть аналитики.

Во-первых, нам нужно установить утилиту командной строки (CLI) Cube.js. Для удобства давайте установим ее глобально на нашем компьютере.

$ npm install -g cubejs-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. Это экономящее время веб-приложение, которое помогает создавать схему данных, тестировать диаграммы и генерировать шаблон панели мониторинга 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, products и users и нажимаем «Создать схему». ” В результате у нас будет 4 сгенерированных файла в папке schema - по одному файлу схемы на таблицу.

После создания схемы мы можем создавать образцы диаграмм через веб-интерфейс. Для этого перейдите на вкладку «Построить» и выберите некоторые показатели и измерения из схемы.

Вкладка «Сборка» - это место, где вы можете создавать образцы диаграмм с использованием различных библиотек визуализации и проверять каждый аспект того, как была создана эта диаграмма, начиная с сгенерированного SQL и заканчивая кодом JavaScript для визуализации диаграммы. Вы также можете проверить запрос Cube.js, закодированный с помощью JSON, который отправляется в серверную часть Cube.js.

Фронтенд с материальным интерфейсом

Создание сложной информационной панели с нуля обычно требует времени и усилий.

Cube.js Playground может сгенерировать для вас шаблон для любой выбранной внешней среды и библиотеки диаграмм. Чтобы создать шаблон для нашей панели инструментов, перейдите в «Приложение Dashboard» и используйте следующие параметры:

  • Фреймворк: React
  • Основной шаблон: React Material UI Static
  • Библиотека диаграмм: Chart.js

Поздравляю! Теперь у нас есть папка dashboard-app в нашем проекте. Эта папка содержит весь код нашего аналитического дашборда.

Пришло время добавить структуру пользовательского интерфейса материала. Чтобы получить красивую панель управления, мы будем использовать настраиваемую тему пользовательского интерфейса материала. Вы можете узнать о создании собственных тем пользовательского интерфейса материалов из документации. А пока давайте загрузим предварительно настроенную тему с GitHub:

$ curl -LJO https://github.com/cube-js/cube.js/tree/master/examples/material-ui-dashboard/dashboard-app/src/theme/theme.zip

Затем давайте установим шрифт Roboto, который лучше всего работает с Material UI:

$ npm install typeface-roboto

Теперь мы можем включить тему и шрифт в наш код внешнего интерфейса. Давайте воспользуемся ThemeProvider из пользовательского интерфейса материала и внесем следующие изменения в файл 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 Playground. Перейдите на 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 для определенной категории.

Во-первых, давайте воспользуемся пакетом response-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).

Давайте настроим схему «Заказы». Откройте файл 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, панель React Dashboard с открытым исходным кодом, созданную с использованием компонентов пользовательского интерфейса материала.

Многостраничная информационная панель с таблицей данных

Теперь у нас есть одностраничная информационная панель, которая отображает агрегированные бизнес-метрики и дает краткий обзор нескольких ключевых показателей эффективности. Однако невозможно получить информацию о конкретном заказе или диапазоне заказов.

Мы собираемся исправить это, добавив в нашу панель управления вторую страницу с информацией обо всех заказах. На этой странице мы будем использовать компонент Таблица данных из пользовательского интерфейса материала, который отлично подходит для отображения табличных данных. Он предоставляет множество разнообразных функций, таких как сортировка, поиск, разбиение на страницы, встроенное редактирование и выбор строк.

Однако нам понадобится способ перемещаться между двумя страницами. Итак, давайте добавим боковую панель навигации.

Боковая панель навигации

Во-первых, давайте загрузим готовый макет и изображения для нашего приложения для панели инструментов. Выполните эти команды, извлеките файл layout.zip в папку src / layouts, а файл images.zip в общедоступный Папка / 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 instal --save @date-io/[email protected] date-fns @date-io/[email protected] 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, который передается через свойства. Теперь мы можем добавить этот компонент, свойства и фильтр к родительскому компоненту. Давайте изменим файл 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 /›. Также мы добавим сортировку в таблицу данных. Итак, измените файл 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']}
+                       &nbsp;
+                       <Typography className={classes.arrow} variant="body2" component="span">
+                         <OpenInNewIcon fontSize="small" />
+                       </Typography>
+                     </TableCell>
                      <TableCell>{obj['Users.city']}</TableCell>
                      <TableCell>{'$ ' + obj['Orders.price']}</TableCell>
// ...

Вот что мы в итоге получили:

И это все! 😇 Поздравляем с изучением этого руководства! 🎉

Также проверьте живую демонстрацию и полный исходный код, доступные на GitHub.

Теперь вы сможете создавать комплексные аналитические панели мониторинга на базе Cube.js и использовать React и Material UI для отображения агрегированных показателей и подробной информации.

Не стесняйтесь изучать другие примеры того, что можно сделать с Cube.js, такие как Руководство по панели инструментов в реальном времени и Руководство по платформе веб-аналитики с открытым исходным кодом.