Product Analytics UI Improvements. (#2896)

* Various improvements Cards, OmniSearch and Cards  Listing

* Improved cards listing page

* Various improvements in product analytics

* Charts UI improvements

---------

Co-authored-by: nick-delirium <nikita@openreplay.com>
This commit is contained in:
Sudheer Salavadi 2024-12-23 03:56:45 -05:00 committed by GitHub
parent 415e24b9a0
commit fe4bbcda6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 444 additions and 236 deletions

View file

@ -44,9 +44,9 @@ function CustomAreaChart(props: Props) {
<Legend iconType={'circle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
)}
<CartesianGrid
strokeDasharray="3 3"
strokeDasharray="1 3"
vertical={false}
stroke="#EEEEEE"
stroke="rgba(0,0,0,.15)"
/>
<XAxis {...Styles.xaxis} dataKey="time" interval={'equidistantPreserveStart'} />
<YAxis
@ -64,10 +64,11 @@ function CustomAreaChart(props: Props) {
<Area
key={key}
name={key}
type="monotone"
type="linear"
dataKey={key}
stroke={colors[index]}
color={colors[index]}
fill={colors[index]}
fillOpacity={0.3}
legendType={key === 'Total' ? 'none' : 'line'}
dot={false}
// strokeDasharray={'4 3'} FOR COPMARISON ONLY

View file

@ -25,17 +25,15 @@ interface Props {
}
const getPath = (x, y, width, height) => {
const radius = Math.min(width / 2, height / 2);
const radius = 4;
return `
M${x + radius},${y}
H${x + width - radius}
A${radius},${radius} 0 0 1 ${x + width},${y + radius}
V${y + height - radius}
A${radius},${radius} 0 0 1 ${x + width - radius},${y + height}
H${x + radius}
A${radius},${radius} 0 0 1 ${x},${y + height - radius}
Q${x + width},${y} ${x + width},${y + radius}
V${y + height}
H${x}
V${y + radius}
A${radius},${radius} 0 0 1 ${x + radius},${y}
Q${x},${y} ${x + radius},${y}
Z
`;
};
@ -45,17 +43,13 @@ const PillBar = (props) => {
return (
<g transform={`translate(${x}, ${y})`}>
<rect
width={width}
height={height}
rx={10}
ry={10}
<path
d={getPath(0, 0, width, height)}
fill={fill}
/>
{striped && (
<rect
width={width}
height={height}
<path
d={getPath(0, 0, width, height)}
clipPath="url(#pillClip)"
fill="url(#diagonalStripes)"
/>
@ -64,8 +58,6 @@ const PillBar = (props) => {
);
};
function CustomBarChart(props: Props) {
const {
data = { chart: [], namesMap: [] },
@ -84,7 +76,6 @@ function CustomBarChart(props: Props) {
return item;
});
// we mix 1 original, then 1 comparison, etc
const mergedNameMap: { data: any, isComp: boolean, index: number }[] = [];
for (let i = 0; i < data.namesMap.length; i++) {
mergedNameMap.push({ data: data.namesMap[i], isComp: false, index: i });
@ -92,34 +83,55 @@ function CustomBarChart(props: Props) {
mergedNameMap.push({ data: compData.namesMap[i], isComp: true, index: i });
}
}
// Filter out comparison items for legend
const legendItems = mergedNameMap.filter(item => !item.isComp);
return (
<ResponsiveContainer height={240} width="100%">
<BarChart
data={resultChart}
margin={Styles.chartMargins}
onClick={onClick}
barSize={10}
>
<defs>
<clipPath id="pillClip">
<rect x="0" y="0" width="100%" height="100%" rx="10" ry="10" />
<rect x="0" y="0" width="100%" height="100%" rx="4" ry="4" />
</clipPath>
<pattern
id="diagonalStripes"
patternUnits="userSpaceOnUse"
width="8"
height="8"
width="4"
height="4"
patternTransform="rotate(45)"
>
<line x1="0" y="0" x2="0" y2="8" stroke="white" strokeWidth="6" />
<line
x1="0"
y1="0"
x2="0"
y2="4"
stroke="#FFFFFF"
strokeWidth="2"
/>
</pattern>
</defs>
{!hideLegend && (
<Legend iconType={'circle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
<Legend
iconType="rect"
wrapperStyle={{ top: inGrid ? undefined : -18 }}
payload={legendItems.map(item => ({
value: item.data,
type: 'rect',
color: colors[item.index],
id: item.data
}))}
/>
)}
<CartesianGrid
strokeDasharray="3 3"
strokeDasharray="1 3"
vertical={false}
stroke="#EEEEEE"
stroke="rgba(0,0,0,.15)"
/>
<XAxis
{...Styles.xaxis}
@ -145,11 +157,22 @@ function CustomBarChart(props: Props) {
fill={colors[item.index]}
stroke={colors[item.index]}
shape={(barProps: any) => (
<PillBar {...barProps} fill={colors[item.index]} barKey={item.index} stroke={colors[item.index]} striped={item.isComp} />
<PillBar
{...barProps}
fill={colors[item.index]}
barKey={item.index}
stroke={colors[item.index]}
striped={item.isComp}
/>
)}
legendType={'line'}
legendType="rect"
activeBar={
<PillBar fill={colors[item.index]} stroke={colors[item.index]} barKey={item.index} striped={item.isComp} />
<PillBar
fill={colors[item.index]}
stroke={colors[item.index]}
barKey={item.index}
striped={item.isComp}
/>
}
/>
))}
@ -158,4 +181,4 @@ function CustomBarChart(props: Props) {
);
}
export default CustomBarChart;
export default CustomBarChart;

View file

@ -16,7 +16,7 @@ function BigNumChart(props: Props) {
values,
} = props;
return (
<div className={'flex justify-around gap-2 w-full'} style={{ height: 240 }}>
<div className={'flex flex-row flex-wrap gap-2 -mt-6'} style={{ height: 240 }}>
{values.map((val, i) => (
<BigNum
key={i}
@ -49,7 +49,7 @@ function BigNum({ color, series, value, label, compData, valueLabel }: {
return `${(((value - compData) / compData) * 100).toFixed(2)}%`;
}, [value, compData])
return (
<div className={'flex flex-col gap-2 py-8 items-center'}>
<div className={'flex flex-col flex-auto justify-center items-center hover:bg-teal/5'}>
<div className={'flex items-center gap-2 font-medium text-gray-darkest'}>
<div className={'rounded w-4 h-4'} style={{ background: color }} />
<div>{series}</div>

View file

@ -8,49 +8,44 @@ interface PayloadItem {
name: string;
value: number;
prevValue?: number;
color?: string;
payload?: any;
}
interface Props {
active: boolean;
payload: PayloadItem[];
label: string;
activeKey?: string;
hoveredSeries: string | null;
}
function CustomTooltip(props: Props) {
const { active, payload, label, activeKey } = props;
if (!active || !payload?.length) return null;
const { active, payload, label, hoveredSeries } = props;
if (!active || !payload?.length || !hoveredSeries) return null;
const shownPayloads: PayloadItem[] = payload.filter((p) => !p.hide);
const currentSeries: PayloadItem[] = [];
const previousSeriesMap: Record<string, any> = {};
// Find the current and comparison payloads
const currentPayload = payload.find(p => p.name === hoveredSeries);
const comparisonPayload = payload.find(p =>
p.name === `${hoveredSeries.replace(' (Comparison)', '')} (Comparison)` ||
p.name === `${hoveredSeries} (Comparison)`
);
shownPayloads.forEach((item) => {
if (item.name.startsWith('Previous ')) {
const originalName = item.name.replace('Previous ', '');
previousSeriesMap[originalName] = item.value;
} else {
currentSeries.push(item);
}
});
if (!currentPayload) return null;
const transformedArray = currentSeries.map((item) => {
const prevValue = previousSeriesMap[item.name] || null;
return {
...item,
prevValue,
};
});
// Create transformed array with comparison data
const transformedArray = [{
...currentPayload,
prevValue: comparisonPayload ? comparisonPayload.value : null
}];
const isHigher = (item: { value: number; prevValue: number }) =>
item.prevValue !== null && item.prevValue < item.value;
const getPercentDelta = (val: number, prevVal: number) =>
(((val - prevVal) / prevVal) * 100).toFixed(2);
const isHigher = (item: { value: number; prevValue: number }) => {
return item.prevValue !== null && item.prevValue < item.value;
};
const getPercentDelta = (val, prevVal) => {
return (((val - prevVal) / prevVal) * 100).toFixed(2);
};
return (
<div
className={'flex flex-col gap-1 bg-white shadow border rounded p-2 z-50'}
>
<div className={'flex flex-col gap-1 bg-white shadow border rounded p-2 z-50'}>
{transformedArray.map((p, index) => (
<React.Fragment key={p.name + index}>
<div className={'flex gap-2 items-center'}>
@ -64,20 +59,20 @@ function CustomTooltip(props: Props) {
</div>
<div
style={{ borderLeft: `2px solid ${p.color}` }}
className={'flex flex-col py-2 px-2 ml-2'}
className={'flex flex-col px-2 ml-2 '}
>
<div className={'text-disabled-text text-sm'}>
<div className={'text-neutral-600 text-sm'}>
{label}, {formatTimeOrDate(p.payload.timestamp)}
</div>
<div className={'flex items-center gap-1'}>
<div className={'font-medium'}>{p.value}</div>
{p.prevValue ? (
{p.prevValue !== null && (
<CompareTag
isHigher={isHigher(p)}
absDelta={Math.abs(p.value - p.prevValue)}
delta={getPercentDelta(p.value, p.prevValue)}
/>
) : null}
)}
</div>
</div>
</React.Fragment>
@ -99,7 +94,7 @@ export function CompareTag({
<div
className={cn(
'px-2 py-1 w-fit rounded flex items-center gap-1',
isHigher ? 'bg-green2 text-xs' : 'bg-red2 text-xs'
isHigher ? 'bg-green2/10 text-xs' : 'bg-red2/10 text-xs'
)}
>
{!isHigher ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
@ -109,4 +104,4 @@ export function CompareTag({
);
}
export default CustomTooltip;
export default CustomTooltip;

View file

@ -0,0 +1,39 @@
import React from 'react';
import { Legend } from 'recharts';
interface CustomLegendProps {
payload?: any[];
}
function CustomLegend({ payload }: CustomLegendProps) {
return (
<div className="custom-legend" style={{ display: 'flex', justifyContent:'center', gap: '1rem', flexWrap: 'wrap' }}>
{payload?.map((entry) => (
<div key={entry.value} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{entry.value.includes('(Comparison)') ? (
<div
style={{
width: 20,
height: 2,
backgroundImage: 'linear-gradient(to right, black 50%, transparent 50%)',
backgroundSize: '4px 2px',
backgroundRepeat: 'repeat-x',
}}
/>
) : (
<div
style={{
width: 20,
height: 2,
backgroundColor: entry.color,
}}
/>
)}
<span className='text-sm'>{entry.value}</span>
</div>
))}
</div>
);
}
export default CustomLegend;

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import CustomTooltip from "../CustomChartTooltip";
import { Styles } from '../../common';
import {
@ -37,10 +37,35 @@ function CustomMetricLineChart(props: Props) {
inGrid,
} = props;
const [hoveredSeries, setHoveredSeries] = useState<string | null>(null);
const handleMouseOver = (key) => () => {
setHoveredSeries(key);
};
const handleMouseLeave = () => {
setHoveredSeries(null);
};
// const resultChart = data.chart.map((item, i) => {
// if (compData && compData.chart[i]) return { ...compData.chart[i], ...item };
// return item;
// });
const resultChart = data.chart.map((item, i) => {
if (compData && compData.chart[i]) return { ...compData.chart[i], ...item }
return item
})
if (compData && compData.chart[i]) {
const comparisonItem: Record<string, any> = {};
Object.keys(compData.chart[i]).forEach(key => {
if (key !== 'time') {
comparisonItem[`${key}_comparison`] = (compData.chart[i] as any)[key];
}
});
return { ...item, ...comparisonItem };
}
return item;
});
return (
<ResponsiveContainer height={240} width="100%">
@ -48,65 +73,99 @@ function CustomMetricLineChart(props: Props) {
data={resultChart}
margin={Styles.chartMargins}
onClick={onClick}
onMouseLeave={handleMouseLeave}
>
{!hideLegend && (
<Legend iconType={'circle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
<Legend wrapperStyle={{ top: inGrid ? undefined : -18 }}
payload={
(data.namesMap as string[]).map((key: string, index: number) => ({
value: key,
type: 'line',
color: colors[index],
id: key,
}))
}
/>
)}
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="#EEEEEE"
/>
<CartesianGrid strokeDasharray="1 3" vertical={false} stroke="rgba(0,0,0,.15)" />
<XAxis {...Styles.xaxis} dataKey="time" interval={'equidistantPreserveStart'} />
<YAxis
{...yaxis}
allowDecimals={false}
tickFormatter={(val) => Styles.tickFormatter(val)}
label={{
...Styles.axisLabelLeft,
value: label || 'Number of Sessions',
}}
label={{ ...Styles.axisLabelLeft, value: label || 'Number of Sessions' }}
/>
<Tooltip {...Styles.tooltip} content={CustomTooltip} />
<Tooltip {...Styles.tooltip} content={<CustomTooltip hoveredSeries={hoveredSeries} />} />
{Array.isArray(data.namesMap) &&
data.namesMap.map((key, index) => key ? (
<Line
key={key}
name={key}
animationDuration={0}
type="monotone"
dataKey={key}
stroke={colors[index]}
fillOpacity={1}
strokeWidth={2}
strokeOpacity={key === 'Total' ? 0 : 0.6}
legendType={key === 'Total' ? 'none' : 'line'}
dot={false}
activeDot={{ fill: colors[index]}}
/>
) : null)}
{compData?.namesMap?.map((key, i) => data.namesMap[i] ? (
<Line
key={key}
name={key}
animationDuration={0}
type="monotone"
dataKey={key}
stroke={colors[i]}
fillOpacity={1}
strokeWidth={2}
strokeOpacity={0.6}
legendType={'line'}
dot={false}
strokeDasharray={'4 3'}
activeDot={{
fill: colors[i],
}}
/>
) : null)}
data.namesMap.map((key, index) =>
key ? (
<Line
key={key}
name={key}
type="linear"
dataKey={key}
stroke={colors[index]}
fillOpacity={1}
strokeWidth={2}
strokeOpacity={hoveredSeries && hoveredSeries !== key ? 0.2 : 1}
legendType={key === 'Total' ? 'none' : 'line'}
dot={false}
activeDot={
hoveredSeries === key
? {
r: 8,
stroke: '#fff',
strokeWidth: 2,
fill: colors[index],
filter: 'drop-shadow(0px 0px 1px rgba(0, 0, 0, 0.2))',
}
: false
}
onMouseOver={handleMouseOver(key)}
style={{ cursor: 'pointer' }}
animationDuration={1000}
animationEasing="ease-in-out"
/>
) : null
)}
{compData?.namesMap?.map((key, i) =>
data.namesMap[i] ? (
<Line
key={key}
name={`${key} (Comparison)`}
type="linear"
dataKey={`${key}_comparison`}
stroke={colors[i]}
fillOpacity={1}
strokeWidth={2}
strokeOpacity={0.6}
legendType="line"
dot={false}
strokeDasharray="4 2"
onMouseOver={handleMouseOver(`${key} (Comparison)`)}
activeDot={
hoveredSeries === `${key} (Comparison)`
? {
r: 8,
stroke: '#fff',
strokeWidth: 2,
fill: colors[i],
filter: 'drop-shadow(0px 0px 0px rgba(0, 0, 0, 0.2))',
}
: false
}
style={{ cursor: 'pointer' }}
animationDuration={1000}
animationEasing="ease-in-out"
/>
) : null
)}
</LineChart>
</ResponsiveContainer>
);
}
export default observer(CustomMetricLineChart);
export default observer(CustomMetricLineChart);

View file

@ -63,15 +63,14 @@ function CustomMetricPieChart(props: Props) {
>
<ResponsiveContainer height={240} width="100%">
<PieChart>
<Legend iconType={'circle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
<Legend iconType={'triangle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
<Pie
isAnimationActive={false}
data={values}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={70}
// fill={colors[0]}
innerRadius={20}
outerRadius={60}
activeIndex={1}
onClick={onClickHandler}
labelLine={({
@ -149,7 +148,7 @@ function CustomMetricPieChart(props: Props) {
{values && values.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={Styles.colorsPie[index % Styles.colorsPie.length]}
fill={Styles.safeColors[index % Styles.safeColors.length]}
/>
))}
</Pie>

View file

@ -8,6 +8,7 @@ const compareColors = ['#192EDB', '#6272FF', '#808DFF', '#B3BBFF', '#C9CFFF'];
const compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse();
const customMetricColors = ['#394EFF', '#3EAAAF', '#565D97'];
const colorsPie = colors.concat(["#DDDDDD"]);
const safeColors = ['#394EFF', '#3EAAAF', '#9276da', '#ceba64', "#bc6f9d", '#966fbc', '#64ce86', '#e06da3', '#6dabe0'];
const countView = count => {
const isMoreThanK = count >= 1000;
@ -22,6 +23,7 @@ export default {
colorsx,
compareColors,
compareColorsx,
safeColors,
lineColor: '#2A7B7F',
lineColorCompare: '#394EFF',
strokeColor: compareColors[0],

View file

@ -2,7 +2,8 @@ import React from 'react';
import BackButton from 'Shared/Breadcrumb/BackButton';
import { withSiteId } from 'App/routes';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { PageTitle, confirm, Tooltip } from 'UI';
import { PageTitle, confirm } from 'UI';
import { Tooltip } from 'antd';
import SelectDateRange from 'Shared/SelectDateRange';
import { useStore } from 'App/mstore';
import DashboardOptions from '../DashboardOptions';
@ -53,7 +54,8 @@ function DashboardHeader(props: Props) {
focusTitle={focusTitle}
/>
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-b-gray-light">
<div className="flex items-center justify-between px-4 pt-4 bg-white">
<div className="flex items-center gap-2" style={{ flex: 3 }}>
<BackButton siteId={siteId} compact />

View file

@ -1,4 +1,7 @@
import { LockOutlined, TeamOutlined } from '@ant-design/icons';
import { observer } from 'mobx-react-lite';
import React from 'react';
import { useHistory } from 'react-router';
import {
Empty,
Switch,
@ -10,17 +13,13 @@ import {
Dropdown,
Button,
} from 'antd';
import { observer } from 'mobx-react-lite';
import React from 'react';
import { useHistory } from 'react-router';
import { LockOutlined, TeamOutlined, MoreOutlined } from '@ant-design/icons';
import { checkForRecent } from 'App/date';
import { useStore } from 'App/mstore';
import Dashboard from 'App/mstore/types/dashboard';
import { dashboardSelected, withSiteId } from 'App/routes';
import CreateDashboardButton from 'Components/Dashboard/components/CreateDashboardButton';
import { Icon, confirm } from 'UI';
import { EllipsisVertical } from 'lucide-react';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import DashboardEditModal from '../DashboardEditModal';
@ -106,6 +105,7 @@ function DashboardList() {
}
checkedChildren={'Team'}
unCheckedChildren={'Private'}
className='toggle-team-private'
/>
</Tooltip>
</div>
@ -124,7 +124,7 @@ function DashboardList() {
},
{
title: 'Options',
title: '',
dataIndex: 'dashboardId',
width: '5%',
render: (id) => (
@ -161,7 +161,7 @@ function DashboardList() {
},
}}
>
<Button id={'ignore-prop'} icon={<EllipsisVertical size={16} />} />
<Button id={'ignore-prop'} icon={<MoreOutlined />} type='text' className='btn-dashboards-list-item-more-options' />
</Dropdown>
),
},
@ -223,6 +223,8 @@ function DashboardList() {
showTotal: (total, range) =>
`Showing ${range[0]}-${range[1]} of ${total} items`,
size: 'small',
simple: 'true',
className: 'px-4 pr-8 mb-0',
}}
onRow={(record) => ({
onClick: (e) => {

View file

@ -28,7 +28,7 @@ function DashboardSearch() {
value={query}
allowClear
name="dashboardsSearch"
className="w-full"
className="w-full btn-search-dashboard"
placeholder="Filter by dashboard title"
onChange={write}
onSearch={(value) => dashboardStore.updateKey('filter', { ...dashboardStore.filter, query: value })}

View file

@ -57,7 +57,7 @@ function AreaChartCard(props: Props) {
margin={Styles.chartMargins}
>
{gradientDef}
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#EEEEEE"/>
<CartesianGrid strokeDasharray="1 3" vertical={false} stroke="rgba(0,0,0,1.5)"/>
<XAxis {...Styles.xaxis} dataKey="time" interval={3}/>
<YAxis
{...Styles.yaxis}

View file

@ -98,7 +98,7 @@ function DashboardView(props: Props) {
const isSaas = /app\.openreplay\.com/.test(originStr);
return (
<Loader loading={loading}>
<div style={{maxWidth: '1360px', margin: 'auto'}} className={'rounded border border-gray-light bg-gray-light-blue'}>
<div style={{maxWidth: '1360px', margin: 'auto'}} className={'rounded-lg shadow-sm overflow-hidden bg-white border'}>
{/* @ts-ignore */}
<DashboardHeader renderReport={props.renderReport} siteId={siteId} dashboardId={dashboardId}/>
{isSaas ? <AiQuery /> : null}

View file

@ -4,7 +4,7 @@ import WidgetWrapperNew from 'Components/Dashboard/components/WidgetWrapper/Widg
import { observer } from 'mobx-react-lite';
import AddCardSection from '../AddCardSection/AddCardSection';
import cn from 'classnames';
import { Button, Popover } from 'antd'
import { Button, Popover, Tooltip } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Loader } from 'UI';
@ -39,7 +39,7 @@ function DashboardWidgetGrid(props: Props) {
{list?.map((item: any, index: any) => (
<div
key={item.widgetId}
className={cn('col-span-' + item.config.col, 'group relative px-6 py-2 hover:bg-active-blue w-full')}
className={cn('col-span-' + item.config.col, 'group relative pl-6 pr-4 py-4 hover:bg-active-blue w-full rounded-xl')}
>
<WidgetWrapperNew
index={index}
@ -60,7 +60,9 @@ function DashboardWidgetGrid(props: Props) {
)}
>
<Popover arrow={false} overlayInnerStyle={{ padding: 0, borderRadius: '0.75rem' }} content={<AddCardSection />} trigger={'click'}>
<Tooltip title="Add Card">
<Button icon={<PlusOutlined size={14} />} shape={'circle'} size={'small'} />
</Tooltip>
</Popover>
</div>
</div>

View file

@ -50,6 +50,7 @@ const FilterSeriesHeader = observer(
onRemove: (seriesIndex: any) => void;
canDelete: boolean | undefined;
toggleExpand: () => void;
onChange: () => void;
}) => {
const onUpdate = (name: any) => {
props.series.update('name', name);
@ -66,6 +67,7 @@ const FilterSeriesHeader = observer(
seriesIndex={props.seriesIndex}
name={props.series.name}
onUpdate={onUpdate}
onChange={props.onChange}
/>
{!props.expanded && (
<FilterCountLabels

View file

@ -1,10 +1,10 @@
import React, { useState, useRef, useEffect } from 'react';
import { Icon } from 'UI';
import { Input, Tooltip } from 'antd';
interface Props {
name: string;
onUpdate: (name: string) => void;
onChange: () => void;
seriesIndex?: number;
}
@ -16,6 +16,7 @@ function SeriesName(props: Props) {
const write = ({ target: { value } }) => {
setName(value);
props.onChange();
};
const onBlur = () => {
@ -51,6 +52,7 @@ function SeriesName(props: Props) {
onBlur={onBlur}
onKeyDown={onKeyDown}
className="bg-white text-lg border-transparent rounded-lg font-medium ps-2"
maxLength={22}
/>
) : (
<Tooltip title="Double click to rename.">

View file

@ -23,7 +23,7 @@ interface Props extends RouteComponentProps {
function MetricTypeIcon({ type }: any) {
return (
<Tooltip title={<div className="capitalize">{TYPE_NAMES[type]}</div>}>
<Avatar src={<Icon name={TYPE_ICONS[type]} size="16" color="tealx" />} size="small" className="bg-tealx-lightest mr-2" />
<Avatar src={<Icon name={TYPE_ICONS[type]} size="16" color="tealx" />} size="default" className="bg-tealx-lightest mr-2 cursor-default avatar-card-list-item" />
</Tooltip>
);
}
@ -171,12 +171,12 @@ const MetricListItem: React.FC<Props> = ({
case 'options':
return (
<>
<div className='flex justify-end'>
<div className='flex justify-end pr-4'>
<Dropdown
menu={{ items: menuItems, onClick: onMenuClick }}
trigger={['click']}
>
<Button id={'ignore-prop'} icon={<EllipsisVertical size={16} />} />
<Button id={'ignore-prop'} icon={<EllipsisVertical size={16} />} className='btn-cards-list-item-more-options' />
</Dropdown>
</div>
{renderModal()}

View file

@ -2,6 +2,10 @@ import React, { useEffect } from 'react';
import { PageTitle } from 'UI';
import { Button, Popover } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
import { PageTitle, Icon } from 'UI';
import { Segmented, Button, Popover, Space, Dropdown, Menu } from 'antd';
import { PlusOutlined, DownOutlined } from '@ant-design/icons';
import AddCardSection from '../AddCardSection/AddCardSection';
import MetricsSearch from '../MetricsSearch';
import { useStore } from 'App/mstore';
@ -9,29 +13,63 @@ import { observer } from 'mobx-react-lite';
function MetricViewHeader() {
const { metricStore } = useStore();
const filter = metricStore.filter;
const [showAddCardModal, setShowAddCardModal] = useState(false);
useEffect(() => {
// Set the default sort order to 'desc'
metricStore.updateKey('sort', { by: 'desc' });
}, [metricStore]);
// Handler for dropdown menu selection
const handleMenuClick = ({ key }) => {
metricStore.updateKey('filter', { ...filter, type: key });
};
// Dropdown menu options
const menu = (
<Menu onClick={handleMenuClick}>
<Menu.Item key="all">All Types</Menu.Item>
{DROPDOWN_OPTIONS.map((option) => (
<Menu.Item key={option.value}>{option.label}</Menu.Item>
))}
</Menu>
);
return (
<div>
<div className="flex items-center justify-between px-4 pb-2">
<div className="flex items-baseline mr-3">
<PageTitle title="Cards" className="" />
<div className="flex items-center justify-between pr-4">
<div className="flex items-center gap-2 ps-4">
<PageTitle title="Cards" className="cursor-default" />
<Space>
<Dropdown overlay={menu} trigger={['click']} className=''>
<Button type="text" size='small' className='mt-1'>
{filter.type === 'all' ? 'All Types' : DROPDOWN_OPTIONS.find(opt => opt.value === filter.type)?.label || 'Select Type'}
<DownOutlined />
</Button>
</Dropdown>
</Space>
</div>
<div className="ml-auto flex items-center">
<Popover arrow={false} overlayInnerStyle={{ padding: 0, borderRadius: '0.75rem' }} content={<AddCardSection fit inCards />} trigger={'click'}>
<div className="ml-auto flex items-center gap-3">
<Popover
arrow={false}
overlayInnerStyle={{ padding: 0, borderRadius: '0.75rem' }}
content={<AddCardSection fit inCards />}
trigger="click"
>
<Button
type="primary"
icon={<PlusOutlined />}
className='btn-create-card'
>
Create Card
</Button>
</Popover>
<div className="ml-4 w-1/4" style={{ minWidth: 300 }}>
<Space>
<MetricsSearch />
</Space>
</div>
</div>
</div>
</div>
</div>

View file

@ -105,7 +105,7 @@ const ListView: React.FC<Props> = (props: Props) => {
),
dataIndex: 'name',
key: 'title',
className: 'cap-first',
className: 'cap-first pl-4',
sorter: true,
width: '25%',
render: (text: string, metric: Metric) => (
@ -233,13 +233,6 @@ const ListView: React.FC<Props> = (props: Props) => {
}
: undefined
}
// footer={() => (
// <div className="flex justify-end">
// <Checkbox name="slack" checked={allSelected} onClick={toggleAll}>
// Select All
// </Checkbox>
// </div>
// )}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
@ -248,7 +241,8 @@ const ListView: React.FC<Props> = (props: Props) => {
className: 'px-4',
showLessItems: true,
showTotal: () => totalMessage,
showQuickJumper: true
size: 'small',
simple: 'true',
}}
/>
);

View file

@ -1,6 +1,6 @@
import { observer, useObserver } from 'mobx-react-lite';
import { observer } from 'mobx-react-lite';
import React, { useEffect, useMemo, useState } from 'react';
import { NoContent, Pagination, Loader } from 'UI';
import { NoContent, Loader } from 'UI';
import { useStore } from 'App/mstore';
import { sliceListPerPage } from 'App/utils';
import GridView from './GridView';
@ -16,6 +16,7 @@ function MetricsList({
}) {
const { metricStore, dashboardStore } = useStore();
const metricsSearch = metricStore.filter.query;
const listView = metricStore.listView;
const [selectedMetrics, setSelectedMetrics] = useState<any>([]);
const dashboard = dashboardStore.selectedDashboard;
@ -77,28 +78,51 @@ function MetricsList({
</div>
</div>
}
subtext="Utilize cards to visualize key user interactions or product performance metrics."
subtext={
metricsSearch !== ''
? ''
: 'Utilize cards to visualize key user interactions or product performance metrics.'
}
>
<ListView
disableSelection={!onSelectionChange}
siteId={siteId}
list={cards}
selectedList={selectedMetrics}
existingCardIds={existingCardIds}
toggleSelection={toggleMetricSelection}
allSelected={cards.length === selectedMetrics.length}
toggleAll={({ target: { checked, name } }) =>
setSelectedMetrics(
checked
? cards
.map((i: any) => i.metricId)
.slice(0, 30 - existingCardIds!.length)
: []
)
}
showOwn={showOwn}
toggleOwn={toggleOwn}
/>
{listView ? (
<ListView
disableSelection={!onSelectionChange}
siteId={siteId}
list={cards}
selectedList={selectedMetrics}
existingCardIds={existingCardIds}
toggleSelection={toggleMetricSelection}
allSelected={cards.length === selectedMetrics.length}
showOwn={showOwn}
toggleOwn={toggleOwn}
toggleAll={({ target: { checked, name } }) =>
setSelectedMetrics(checked ? cards.map((i: any) => i.metricId).slice(0, 30 - existingCardIds!.length) : [])
}
/>
) : (
<>
<GridView
siteId={siteId}
list={sliceListPerPage(cards, metricStore.page - 1, metricStore.pageSize)}
selectedList={selectedMetrics}
toggleSelection={toggleMetricSelection}
/>
<div className="w-full flex items-center justify-between py-4 px-6 border-t">
<div className="">
Showing{' '}
<span className="font-medium">{Math.min(cards.length, metricStore.pageSize)}</span> out
of <span className="font-medium">{cards.length}</span> cards
</div>
<Pagination
page={metricStore.page}
total={lenth}
onPageChange={(page) => metricStore.updateKey('page', page)}
limit={metricStore.pageSize}
debounceRequest={100}
/>
</div>
</>
)}
</NoContent>
</Loader>
);

View file

@ -27,7 +27,7 @@ function MetricsSearch() {
value={query}
allowClear
name="metricsSearch"
className="w-full"
className="w-full input-search-card"
placeholder="Filter by title or owner"
onChange={write}
/>

View file

@ -11,7 +11,9 @@ function MetricsView({ siteId }: Props) {
return (
<div style={{ maxWidth: '1360px', margin: 'auto' }} className="bg-white rounded-lg shadow-sm pt-4 border">
<MetricViewHeader siteId={siteId} />
<div className='pt-3'>
<MetricsList siteId={siteId} />
</div>
</div>
);
}

View file

@ -56,7 +56,7 @@ function WidgetChart(props: Props) {
const period = dashboardStore.period;
const drillDownPeriod = dashboardStore.drillDownPeriod;
const drillDownFilter = dashboardStore.drillDownFilter;
const colors = Styles.customMetricColors;
const colors = Styles.safeColors;
const [loading, setLoading] = useState(true);
const params = { density: dashboardStore.selectedDensity };
const metricParams = _metric.params;

View file

@ -112,12 +112,13 @@ function WidgetDatatable(props: Props) {
return (
<div className={cn('relative -mx-4 px-2', showTable ? 'pt-6' : '')}>
<div
className={
'absolute left-0 right-0 top-0 border-t border-t-gray-lighter'
}
className={cn(
'absolute left-0 right-0 -top-3 border-t border-t-gray-lighter',
{ 'hidden': !showTable }
)}
/>
<div
className={'absolute top-0 left-1/2 z-10'}
className={'absolute -top-3 left-1/2 z-10'}
style={{ transform: 'translate(-50%, -50%)' }}
>
<Button

View file

@ -87,21 +87,18 @@ const FilterSection = observer(({ metric, excludeFilterKeys }: any) => {
))}
{!isSingleSeries && canAddSeries && (
<Card
styles={{ body: { padding: '4px' } }}
className="rounded-xl shadow-sm mb-2"
>
<div
onClick={() => {
if (!canAddSeries) return;
metric.addSeries();
}}
className="w-full cursor-pointer flex items-center py-2 justify-center gap-2 font-medium hover:text-teal"
>
<PlusIcon size={16} />
Add Series
</div>
</Card>
<Button
onClick={() => {
if (!canAddSeries) return;
metric.addSeries();
}}
size='small'
type='text'
className="w-full cursor-pointer flex items-center py-2 justify-center gap-2 font-medium hover:text-teal btn-add-series"
>
<PlusIcon size={16} />
Add Series
</Button>
)}
</>
);

View file

@ -1,5 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import { Icon } from 'UI';
import { Input, Tooltip } from 'antd';
import cn from 'classnames';

View file

@ -1,6 +1,8 @@
import React, { useEffect, useState } from 'react';
import { NoContent, Loader, Pagination, Button } from 'UI';
import Select from 'Shared/Select';
import { NoContent, Loader, Pagination } from 'UI';
import {Button, Tag, Tooltip, Select, Dropdown} from 'antd';
import {UndoOutlined, DownOutlined} from '@ant-design/icons'
//import Select from 'Shared/Select';
import cn from 'classnames';
import { useStore } from 'App/mstore';
import SessionItem from 'Shared/SessionItem';
@ -11,7 +13,6 @@ import useIsMounted from 'App/hooks/useIsMounted';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { numberWithCommas } from 'App/utils';
import { HEATMAP } from 'App/constants/card';
import { Tag } from 'antd';
interface Props {
className?: string;
@ -34,6 +35,15 @@ function WidgetSessions(props: Props) {
const filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
const metaList = customFieldStore.list.map((i: any) => i.key);
const seriesDropdownItems = seriesOptions.map((option) => ({
key: option.value,
label: (
<div onClick={() => setActiveSeries(option.value)}>
{option.label}
</div>
)
}));
const writeOption = ({ value }: any) => setActiveSeries(value.value);
useEffect(() => {
if (!data) return;
@ -119,27 +129,39 @@ function WidgetSessions(props: Props) {
<div className={cn(className, 'bg-white p-3 pb-0 rounded-xl shadow-sm border mt-3')}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-baseline">
<div className="flex items-baseline gap-2">
<h2 className="text-xl">{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}</h2>
<div className="ml-2 color-gray-medium">
{metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null}
between <span className="font-medium color-gray-darkest">{startTime}</span> and{' '}
<span className="font-medium color-gray-darkest">{endTime}</span>{' '}
</div>
{hasFilters && <Tooltip title='Clear Drilldown' placement='top'><Button type='text' size='small' onClick={clearFilters}><UndoOutlined /></Button></Tooltip>}
</div>
{hasFilters && widget.metricType === 'table' &&
<div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
{hasFilters && widget.metricType === 'table' && <div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
</div>
<div className="flex items-center gap-4">
{hasFilters && <Button variant="text-primary" onClick={clearFilters}>Clear Filters</Button>}
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
<div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium">Filter by Series</span>
<Select options={seriesOptions} defaultValue={'all'} onChange={writeOption} plain />
</div>
)}
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
<div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium">Filter by Series</span>
<Dropdown
menu={{
items: seriesDropdownItems,
selectable: true,
selectedKeys: [activeSeries]
}}
trigger={['click']}
>
<Button type="text" size='small'>
{seriesOptions.find(option => option.value === activeSeries)?.label || 'Select Series'}
<DownOutlined />
</Button>
</Dropdown>
</div>
)}
</div>
</div>

View file

@ -8,7 +8,6 @@ import { Button, Space, Tooltip } from 'antd';
import CardViewMenu from 'Components/Dashboard/components/WidgetView/CardViewMenu';
import { Link2 } from 'lucide-react'
import copy from 'copy-to-clipboard';
import MetricTypeSelector from "../MetricTypeSelector";
interface Props {
onClick?: () => void;
@ -22,6 +21,10 @@ function WidgetViewHeader({ onClick, onSave }: Props) {
const { metricStore } = useStore();
const widget = metricStore.instance;
const handleSave = () => {
onSave();
};
const copyUrl = () => {
const url = window.location.href;
copy(url)
@ -36,19 +39,21 @@ function WidgetViewHeader({ onClick, onSave }: Props) {
onClick={onClick}
>
<h1 className="mb-0 text-2xl mr-4 min-w-fit ">
<WidgetName
name={widget.name}
onUpdate={(name) => metricStore.merge({ name })}
canEdit={true}
/>
<WidgetName
name={widget.name}
onUpdate={(name) => {
metricStore.merge({ name });
}}
canEdit={true}
/>
</h1>
<Space>
<Button
type={
metricStore.isSaving || (widget.exists() && !widget.hasChanged) ? 'text' : 'primary'
}
onClick={onSave}
onClick={handleSave}
loading={metricStore.isSaving}
disabled={metricStore.isSaving || (widget.exists() && !widget.hasChanged)}
className='font-medium btn-update-card'
@ -56,15 +61,13 @@ function WidgetViewHeader({ onClick, onSave }: Props) {
{widget.exists() ? 'Update' : 'Create'}
</Button>
<MetricTypeSelector />
{/* <MetricTypeSelector /> */}
<Tooltip title={tooltipText}>
<Button type='text' className='btn-copy-card-url' disabled={!widget.exists()} onClick={copyUrl} icon={<Link2 size={16} strokeWidth={1}/> }></Button>
</Tooltip>
<CardViewMenu />
<CardViewMenu />
</Space>
</div>
);

View file

@ -142,7 +142,7 @@ function FunnelBarData({
/>
<span
className={
'mx-1 ' + (data.droppedCount > 0 ? 'color-red' : 'disabled')
'mr-1 text-sm' + (data.droppedCount > 0 ? 'color-red' : 'disabled')
}
>
{data.droppedCount} Skipped
@ -183,7 +183,7 @@ export function UxTFunnelBar(props: Props) {
backgroundColor: '#6272FF',
}}
>
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
<div className="color-white absolute right-0 flex items-center font-medium mr-1 leading-3 text-sm">
{(
(filter.completed / (filter.completed + filter.skipped)) *
100

View file

@ -9,7 +9,7 @@ import { getNewIcon } from "../FilterModal/FilterModal";
const ASSIST_ROUTE = assistRoute();
interface Props {
filter?: any; // event/filter
filter?: any;
onFilterClick: (filter: any) => void;
children?: any;
excludeFilterKeys?: Array<string>;
@ -69,7 +69,7 @@ function FilterSelection(props: Props) {
}}
onClick={() => setShowModal(true)}
>
<div className='text-xs text-neutral-500/90 hover:border-neutral-400 '>{getNewIcon(filter)}</div>
<div className='text-xs text-neutral-500/90 hover:border-neutral-400'>{getNewIcon(filter)}</div>
<div className={'text-neutral-500/90 flex gap-2 hover:border-neutral-400 '}>{`${filter.category}`}</div>
<div
className="rounded-lg overflow-hidden whitespace-nowrap text-ellipsis mr-auto truncate "