change(ui): cards menu
This commit is contained in:
parent
0e42041aa8
commit
ecec4d8fd7
8 changed files with 265 additions and 158 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { NoContent } from 'UI';
|
||||
import { Styles, AvgLabel } from '../../common';
|
||||
import {
|
||||
ComposedChart, Bar, BarChart, CartesianGrid, ResponsiveContainer,
|
||||
import {
|
||||
ComposedChart, Bar, BarChart, CartesianGrid, ResponsiveContainer,
|
||||
XAxis, YAxis, ReferenceLine, Tooltip
|
||||
} from 'recharts';
|
||||
import { NO_METRIC_DATA } from 'App/constants/messages'
|
||||
|
|
@ -41,26 +41,25 @@ const PercentileLine = props => {
|
|||
|
||||
interface Props {
|
||||
data: any
|
||||
metric?: any
|
||||
}
|
||||
function ResponseTimeDistribution(props: Props) {
|
||||
const { data, metric } = props;
|
||||
const { data } = props;
|
||||
const colors = Styles.colors;
|
||||
|
||||
return (
|
||||
<NoContent
|
||||
size="small"
|
||||
title={NO_METRIC_DATA}
|
||||
show={ metric.data.chart.length === 0 }
|
||||
show={ data.chart.length === 0 }
|
||||
style={ { height: '240px' } }
|
||||
>
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
<AvgLabel text="Avg" unit="ms" className="ml-3" count={metric.data.value} />
|
||||
<AvgLabel text="Avg" unit="ms" className="ml-3" count={data.value} />
|
||||
</div>
|
||||
<div className="flex mb-4">
|
||||
<ResponsiveContainer height={ 240 } width="100%">
|
||||
<ComposedChart
|
||||
data={metric.data.chart}
|
||||
data={data.chart}
|
||||
margin={Styles.chartMargins}
|
||||
barSize={50}
|
||||
>
|
||||
|
|
@ -68,7 +67,7 @@ function ResponseTimeDistribution(props: Props) {
|
|||
<XAxis
|
||||
{...Styles.xaxis}
|
||||
dataKey="responseTime"
|
||||
label={{
|
||||
label={{
|
||||
...Styles.axisLabelLeft,
|
||||
angle: 0,
|
||||
offset: 0,
|
||||
|
|
@ -87,7 +86,7 @@ function ResponseTimeDistribution(props: Props) {
|
|||
/>
|
||||
<Bar minPointSize={1} name="Calls" dataKey="count" stackId="a" fill={colors[2]} label="Backend" />
|
||||
<Tooltip {...Styles.tooltip} labelFormatter={val => 'Page Response Time: ' + val} />
|
||||
{ metric.data.percentiles && metric.data.percentiles.map((item: any, i: number) => (
|
||||
{ data.percentiles && data.percentiles.map((item: any, i: number) => (
|
||||
<ReferenceLine
|
||||
key={i}
|
||||
label={
|
||||
|
|
@ -111,13 +110,13 @@ function ResponseTimeDistribution(props: Props) {
|
|||
</ResponsiveContainer>
|
||||
<ResponsiveContainer height={ 240 } width="10%">
|
||||
<BarChart
|
||||
data={metric.data.extremeValues}
|
||||
data={data.extremeValues}
|
||||
margin={Styles.chartMargins}
|
||||
barSize={40}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={ false } stroke="#EEEEEE" />
|
||||
<XAxis {...Styles.xaxis} dataKey="time" />
|
||||
<YAxis hide type="number" domain={[0, metric.data.total]} {...Styles.yaxis} allowDecimals={false} />
|
||||
<YAxis hide type="number" domain={[0, data.total]} {...Styles.yaxis} allowDecimals={false} />
|
||||
<Tooltip {...Styles.tooltip} />
|
||||
<Bar minPointSize={1} name="Extreme Values" dataKey="count" stackId="a" fill={colors[0]} />
|
||||
</BarChart>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,17 @@ import ByBrowser from './Examples/SessionsBy/ByBrowser';
|
|||
import BySystem from './Examples/SessionsBy/BySystem';
|
||||
import ByCountry from './Examples/SessionsBy/ByCountry';
|
||||
import ByUrl from './Examples/SessionsBy/ByUrl';
|
||||
import { ERRORS, FUNNEL, INSIGHTS, PERFORMANCE, TABLE, TIMESERIES, USER_PATH, WEB_VITALS } from 'App/constants/card';
|
||||
import {
|
||||
CLICKMAP,
|
||||
ERRORS,
|
||||
FUNNEL,
|
||||
INSIGHTS,
|
||||
PERFORMANCE,
|
||||
TABLE,
|
||||
TIMESERIES,
|
||||
USER_PATH,
|
||||
WEB_VITALS
|
||||
} from 'App/constants/card';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import { Activity, BarChart, TableCellsMerge, TrendingUp } from 'lucide-react';
|
||||
import WebVital from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/WebVital';
|
||||
|
|
@ -25,6 +35,7 @@ import SlowestDomains
|
|||
from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/SlowestDomains';
|
||||
import SpeedIndexByLocationExample
|
||||
from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SpeedIndexByLocationExample';
|
||||
import HeatmapsExample from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/HeatmapsExample';
|
||||
|
||||
export const CARD_CATEGORY = {
|
||||
PRODUCT_ANALYTICS: 'product-analytics',
|
||||
|
|
@ -90,6 +101,14 @@ export const CARD_LIST: CardType[] = [
|
|||
totalDropDueToIssues: 294
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Heatmaps',
|
||||
key: CLICKMAP,
|
||||
cardType: CLICKMAP,
|
||||
metricOf: 'sessionCount',
|
||||
category: CARD_CATEGORIES[0].key,
|
||||
example: HeatmapsExample
|
||||
},
|
||||
{
|
||||
title: 'Path Finder',
|
||||
key: USER_PATH,
|
||||
|
|
@ -122,6 +141,7 @@ export const CARD_LIST: CardType[] = [
|
|||
example: ByIssues
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
title: 'Insights',
|
||||
key: INSIGHTS,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import ExCard from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/ExCard';
|
||||
import heatmapRenderer from 'Player/web/addons/simpleHeatmap';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
type: string;
|
||||
onCard: (card: string) => void;
|
||||
}
|
||||
|
||||
function HeatmapsExample(props: Props) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const pointMap: Record<string, { times: number; data: number[], original: any }> = {};
|
||||
let maxIntensity = 0;
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const x = Math.floor(Math.random() * 300);
|
||||
const y = Math.floor(Math.random() * 180);
|
||||
const key = `${x}-${y}`;
|
||||
if (!pointMap[key]) {
|
||||
pointMap[key] = {
|
||||
times: Math.floor(Math.random() * 100),
|
||||
data: [x, y],
|
||||
original: { x, y }
|
||||
};
|
||||
}
|
||||
|
||||
maxIntensity = Math.max(maxIntensity, pointMap[key].times);
|
||||
}
|
||||
|
||||
const heatmapData: number[][] = [];
|
||||
for (const key in pointMap) {
|
||||
const { data, times } = pointMap[key];
|
||||
heatmapData.push([...data, times]);
|
||||
}
|
||||
|
||||
heatmapRenderer
|
||||
.setCanvas(canvasRef?.current!)
|
||||
.setData(heatmapData)
|
||||
.setRadius(15, 10)
|
||||
.setMax(maxIntensity)
|
||||
.resize()
|
||||
.draw();
|
||||
}, []);
|
||||
|
||||
|
||||
// const data = {};
|
||||
return (
|
||||
<ExCard
|
||||
{...props}
|
||||
>
|
||||
<canvas ref={canvasRef}
|
||||
style={{ width: '100%', height: '224px', backgroundColor: '#F4F4F4', borderRadius: '10px' }} />
|
||||
</ExCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapsExample;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import ExCard from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/ExCard';
|
||||
import CustomMetricOverviewChart from 'Components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricOverviewChart';
|
||||
import ResponseTimeDistribution from 'Components/Dashboard/Widgets/PredefinedWidgets/ResponseTimeDistribution';
|
||||
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
type: string;
|
||||
onCard: (card: string) => void;
|
||||
}
|
||||
|
||||
function PageResponseTimeDistributionExample(props: Props) {
|
||||
const data = {
|
||||
chart: []
|
||||
}
|
||||
return (
|
||||
<ExCard
|
||||
{...props}
|
||||
>
|
||||
<ResponseTimeDistribution data={data} />
|
||||
</ExCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageResponseTimeDistributionExample;
|
||||
|
|
@ -54,8 +54,8 @@ function DashboardWidgetGrid(props: Props) {
|
|||
}
|
||||
dashboardId={dashboardId}
|
||||
siteId={siteId}
|
||||
isWidget={false}
|
||||
grid="other"
|
||||
showMenu={true}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,159 +1,161 @@
|
|||
import React, {useRef} from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import cn from 'classnames';
|
||||
import {Card, Tooltip, Button} from 'antd';
|
||||
import {useDrag, useDrop} from 'react-dnd';
|
||||
import { Card, Tooltip, Button } from 'antd';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import WidgetChart from '../WidgetChart';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useStore} from 'App/mstore';
|
||||
import {withRouter, RouteComponentProps} from 'react-router-dom';
|
||||
import {withSiteId, dashboardMetricDetails} from 'App/routes';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import { withSiteId, dashboardMetricDetails } from 'App/routes';
|
||||
import TemplateOverlay from './TemplateOverlay';
|
||||
import stl from './widgetWrapper.module.css';
|
||||
import {FilterKey} from 'App/types/filter/filterType';
|
||||
import { FilterKey } from 'App/types/filter/filterType';
|
||||
import LazyLoad from 'react-lazyload';
|
||||
import {TIMESERIES} from "App/constants/card";
|
||||
import CardMenu from "Components/Dashboard/components/WidgetWrapper/CardMenu";
|
||||
import AlertButton from "Components/Dashboard/components/WidgetWrapper/AlertButton";
|
||||
import { TIMESERIES } from 'App/constants/card';
|
||||
import CardMenu from 'Components/Dashboard/components/WidgetWrapper/CardMenu';
|
||||
import AlertButton from 'Components/Dashboard/components/WidgetWrapper/AlertButton';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
widget?: any;
|
||||
index?: number;
|
||||
moveListItem?: any;
|
||||
isPreview?: boolean;
|
||||
isTemplate?: boolean;
|
||||
dashboardId?: string;
|
||||
siteId?: string;
|
||||
active?: boolean;
|
||||
history?: any;
|
||||
onClick?: () => void;
|
||||
isWidget?: boolean;
|
||||
hideName?: boolean;
|
||||
grid?: string;
|
||||
isGridView?: boolean;
|
||||
className?: string;
|
||||
widget?: any;
|
||||
index?: number;
|
||||
moveListItem?: any;
|
||||
isPreview?: boolean;
|
||||
isTemplate?: boolean;
|
||||
dashboardId?: string;
|
||||
siteId?: string;
|
||||
active?: boolean;
|
||||
history?: any;
|
||||
onClick?: () => void;
|
||||
isWidget?: boolean;
|
||||
hideName?: boolean;
|
||||
grid?: string;
|
||||
isGridView?: boolean;
|
||||
showMenu?: boolean;
|
||||
}
|
||||
|
||||
function WidgetWrapperNew(props: Props & RouteComponentProps) {
|
||||
const {dashboardStore} = useStore();
|
||||
const {
|
||||
isWidget = false,
|
||||
active = false,
|
||||
index = 0,
|
||||
moveListItem = null,
|
||||
isPreview = false,
|
||||
isTemplate = false,
|
||||
siteId,
|
||||
grid = '',
|
||||
isGridView = false,
|
||||
} = props;
|
||||
const widget: any = props.widget;
|
||||
const isTimeSeries = widget.metricType === TIMESERIES;
|
||||
const isPredefined = widget.metricType === 'predefined';
|
||||
const dashboard = dashboardStore.selectedDashboard;
|
||||
const { dashboardStore } = useStore();
|
||||
const {
|
||||
isWidget = false,
|
||||
active = false,
|
||||
index = 0,
|
||||
moveListItem = null,
|
||||
isPreview = false,
|
||||
isTemplate = false,
|
||||
siteId,
|
||||
grid = '',
|
||||
isGridView = false,
|
||||
showMenu = false
|
||||
} = props;
|
||||
const widget: any = props.widget;
|
||||
const isTimeSeries = widget.metricType === TIMESERIES;
|
||||
const isPredefined = widget.metricType === 'predefined';
|
||||
const dashboard = dashboardStore.selectedDashboard;
|
||||
|
||||
const [{isDragging}, dragRef] = useDrag({
|
||||
type: 'item',
|
||||
item: {index, grid},
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
const [{ isDragging }, dragRef] = useDrag({
|
||||
type: 'item',
|
||||
item: { index, grid },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging()
|
||||
})
|
||||
});
|
||||
|
||||
const [{isOver, canDrop}, dropRef] = useDrop({
|
||||
accept: 'item',
|
||||
drop: (item: any) => {
|
||||
if (item.index === index || item.grid !== grid) return;
|
||||
moveListItem(item.index, index);
|
||||
},
|
||||
canDrop(item) {
|
||||
return item.grid === grid;
|
||||
},
|
||||
collect: (monitor: any) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
const [{ isOver, canDrop }, dropRef] = useDrop({
|
||||
accept: 'item',
|
||||
drop: (item: any) => {
|
||||
if (item.index === index || item.grid !== grid) return;
|
||||
moveListItem(item.index, index);
|
||||
},
|
||||
canDrop(item) {
|
||||
return item.grid === grid;
|
||||
},
|
||||
collect: (monitor: any) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop()
|
||||
})
|
||||
});
|
||||
|
||||
const onChartClick = () => {
|
||||
// if (!isWidget || isPredefined) return;
|
||||
props.history.push(
|
||||
withSiteId(dashboardMetricDetails(dashboard?.dashboardId, widget.metricId), siteId)
|
||||
);
|
||||
};
|
||||
|
||||
const ref: any = useRef(null);
|
||||
const dragDropRef: any = dragRef(dropRef(ref));
|
||||
const addOverlay =
|
||||
isTemplate ||
|
||||
(!isPredefined &&
|
||||
isWidget &&
|
||||
widget.metricOf !== FilterKey.ERRORS &&
|
||||
widget.metricOf !== FilterKey.SESSIONS);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'relative group rounded-lg',
|
||||
'col-span-' + widget.config.col,
|
||||
{'hover:shadow-sm': !isTemplate && isWidget},
|
||||
)}
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
borderColor:
|
||||
(canDrop && isOver) || active ? '#394EFF' : isPreview ? 'transparent' : '#EEEEEE',
|
||||
}}
|
||||
ref={dragDropRef}
|
||||
onClick={props.onClick ? props.onClick : () => null}
|
||||
id={`widget-${widget.widgetId}`}
|
||||
title={!props.hideName ? widget.name : null}
|
||||
extra={isWidget ? [
|
||||
<div className="flex items-center" id="no-print">
|
||||
{!isPredefined && isTimeSeries && !isGridView && (
|
||||
<AlertButton seriesId={widget.series[0] && widget.series[0].seriesId}/>
|
||||
)}
|
||||
|
||||
{!isTemplate && !isGridView && (
|
||||
<CardMenu card={widget} key="card-menu"/>
|
||||
)}
|
||||
</div>
|
||||
] : []}
|
||||
styles={{
|
||||
header: {
|
||||
padding: '0 14px',
|
||||
borderBottom: 'none',
|
||||
minHeight: 44,
|
||||
fontWeight: 500,
|
||||
fontSize: 14,
|
||||
},
|
||||
body: {
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{!isTemplate && isWidget && isPredefined && (
|
||||
<Tooltip title="Cannot drill down system provided metrics">
|
||||
<div
|
||||
className={cn(stl.drillDownMessage, 'disabled text-gray text-sm invisible group-hover:visible')}>
|
||||
{'Cannot drill down system provided metrics'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{addOverlay && <TemplateOverlay onClick={onChartClick} isTemplate={isTemplate}/>}
|
||||
|
||||
<LazyLoad offset={!isTemplate ? 100 : 600}>
|
||||
<div className="px-4" onClick={onChartClick}>
|
||||
<WidgetChart
|
||||
isPreview={isPreview}
|
||||
metric={widget}
|
||||
isTemplate={isTemplate}
|
||||
isWidget={isWidget}
|
||||
/>
|
||||
</div>
|
||||
</LazyLoad>
|
||||
</Card>
|
||||
const onChartClick = () => {
|
||||
// if (!isWidget || isPredefined) return;
|
||||
props.history.push(
|
||||
withSiteId(dashboardMetricDetails(dashboard?.dashboardId, widget.metricId), siteId)
|
||||
);
|
||||
};
|
||||
|
||||
const ref: any = useRef(null);
|
||||
const dragDropRef: any = dragRef(dropRef(ref));
|
||||
const addOverlay =
|
||||
isTemplate ||
|
||||
(!isPredefined &&
|
||||
isWidget &&
|
||||
widget.metricOf !== FilterKey.ERRORS &&
|
||||
widget.metricOf !== FilterKey.SESSIONS);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'relative group rounded-lg',
|
||||
'col-span-' + widget.config.col,
|
||||
{ 'hover:shadow-sm': !isTemplate && isWidget }
|
||||
)}
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
borderColor:
|
||||
(canDrop && isOver) || active ? '#394EFF' : isPreview ? 'transparent' : '#EEEEEE'
|
||||
}}
|
||||
ref={dragDropRef}
|
||||
onClick={props.onClick ? props.onClick : () => null}
|
||||
id={`widget-${widget.widgetId}`}
|
||||
title={!props.hideName ? widget.name : null}
|
||||
extra={[
|
||||
<div className="flex items-center" id="no-print">
|
||||
{!isPredefined && isTimeSeries && !isGridView && (
|
||||
<AlertButton seriesId={widget.series[0] && widget.series[0].seriesId} />
|
||||
)}
|
||||
|
||||
{showMenu && (
|
||||
<CardMenu card={widget} key="card-menu" />
|
||||
)}
|
||||
</div>
|
||||
]}
|
||||
styles={{
|
||||
header: {
|
||||
padding: '0 14px',
|
||||
borderBottom: 'none',
|
||||
minHeight: 44,
|
||||
fontWeight: 500,
|
||||
fontSize: 14
|
||||
},
|
||||
body: {
|
||||
padding: 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isTemplate && isWidget && isPredefined && (
|
||||
<Tooltip title="Cannot drill down system provided metrics">
|
||||
<div
|
||||
className={cn(stl.drillDownMessage, 'disabled text-gray text-sm invisible group-hover:visible')}>
|
||||
{'Cannot drill down system provided metrics'}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{addOverlay && <TemplateOverlay onClick={onChartClick} isTemplate={isTemplate} />}
|
||||
|
||||
<LazyLoad offset={!isTemplate ? 100 : 600}>
|
||||
<div className="px-4" onClick={onChartClick}>
|
||||
<WidgetChart
|
||||
isPreview={isPreview}
|
||||
metric={widget}
|
||||
isTemplate={isTemplate}
|
||||
isWidget={isWidget}
|
||||
/>
|
||||
</div>
|
||||
</LazyLoad>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(observer(WidgetWrapperNew));
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ function FunnelBar(props: Props) {
|
|||
{/* @ts-ignore */}
|
||||
<div className="flex items-center">
|
||||
<Icon name="arrow-right-short" size="20" color="green" />
|
||||
<span className="mx-1 font-medium">{filter.sessionsCount} Sessions</span>
|
||||
<span className="mx-1">{filter.sessionsCount} Sessions</span>
|
||||
<span className="color-gray-medium text-sm">
|
||||
({filter.completedPercentage}%) Completed
|
||||
</span>
|
||||
|
|
@ -70,7 +70,7 @@ function FunnelBar(props: Props) {
|
|||
<Space className="items-center">
|
||||
<Icon name="caret-down-fill" color={filter.droppedCount > 0 ? 'red' : 'gray-light'} size={16} />
|
||||
<span
|
||||
className={'font-medium mx-1 ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>{filter.droppedCount} Sessions</span>
|
||||
className={'mx-1 ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>{filter.droppedCount} Sessions</span>
|
||||
<span
|
||||
className={'text-sm ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>({filter.droppedPercentage}%) Dropped</span>
|
||||
</Space>
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export const Stage = observer(({ stage, index, isWidget, uxt, focusStage, focuse
|
|||
>
|
||||
<IndexNumber index={index} />
|
||||
{!uxt ? <Funnelbar index={index} filter={stage} focusStage={focusStage} focusedFilter={focusedFilter} /> : <UxTFunnelBar filter={stage} />}
|
||||
{!isWidget && !uxt && <BarActions bar={stage} />}
|
||||
{/*{!isWidget && !uxt && <BarActions bar={stage} />}*/}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue