ui: some improvements for cards list view, funnels and general filter display

This commit is contained in:
nick-delirium 2024-12-20 14:28:35 +01:00
parent fbf7d716a6
commit 3b7d86d8c6
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
14 changed files with 700 additions and 656 deletions

View file

@ -49,7 +49,7 @@ function CustomTooltip(props: Props) {
};
return (
<div
className={'flex flex-col gap-1 bg-white shadow border rounded p-2 z-30'}
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}>

View file

@ -50,6 +50,9 @@ export default {
tickFormatterBytes: val => Math.round(val / 1024 / 1024),
chartMargins: {left: 0, right: 20, top: 10, bottom: 5},
tooltip: {
wrapperStyle: {
zIndex: 999,
},
contentStyle: {
padding: '5px',
background: 'white',

View file

@ -1,31 +1,57 @@
import React from 'react';
import { ItemMenu } from 'UI';
import { observer } from 'mobx-react-lite';
import { useStore } from "App/mstore";
import { useStore } from 'App/mstore';
import { ENTERPRISE_REQUEIRED } from 'App/constants';
import { Dropdown, Button } from 'antd';
import { EllipsisVertical } from 'lucide-react';
import { Icon } from 'UI';
interface Props {
editHandler: (isTitle: boolean) => void;
deleteHandler: any;
renderReport: any;
editHandler: (isTitle: boolean) => void;
deleteHandler: any;
renderReport: any;
}
function DashboardOptions(props: Props) {
const { userStore } = useStore();
const isEnterprise = userStore.isEnterprise;
const { editHandler, deleteHandler, renderReport } = props;
const menuItems = [
{ icon: 'pencil', text: 'Rename', onClick: () => editHandler(true) },
{ icon: 'users', text: 'Visibility & Access', onClick: editHandler },
{ icon: 'trash', text: 'Delete', onClick: deleteHandler },
{ icon: 'pdf-download', text: 'Download Report', onClick: renderReport, disabled: !isEnterprise, tooltipTitle: ENTERPRISE_REQUEIRED }
]
const { userStore } = useStore();
const isEnterprise = userStore.isEnterprise;
const { editHandler, deleteHandler, renderReport } = props;
return (
<ItemMenu
bold
items={menuItems}
/>
);
const menu = {
items: [
{
icon: <Icon name={'pencil'} />,
key: 'rename',
label: 'Rename',
onClick: () => editHandler(true),
},
{
icon: <Icon name={'users'} />,
key: 'visibility',
label: 'Visibility & Access',
onClick: editHandler,
},
{
icon: <Icon name={'trash'} />,
key: 'delete',
label: 'Delete',
onClick: deleteHandler,
},
{
icon: <Icon name={'pdf-download'} />,
key: 'download',
label: 'Download Report',
onClick: renderReport,
disabled: !isEnterprise,
tooltipTitle: ENTERPRISE_REQUEIRED,
},
],
};
return (
<Dropdown menu={menu}>
<Button id={'ignore-prop'} icon={<EllipsisVertical size={16} />} />
</Dropdown>
);
}
export default observer(DashboardOptions);

View file

@ -117,6 +117,7 @@ interface Props {
expandable?: boolean;
isHeatmap?: boolean;
removeEvents?: boolean;
defaultClosed?: boolean;
}
function FilterSeries(props: Props) {
@ -131,8 +132,9 @@ function FilterSeries(props: Props) {
expandable = false,
isHeatmap,
removeEvents,
defaultClosed,
} = props;
const [expanded, setExpanded] = useState(hideHeader || !expandable);
const [expanded, setExpanded] = useState(!defaultClosed || hideHeader);
const { series, seriesIndex } = props;
const [prevLength, setPrevLength] = useState(0);
@ -140,12 +142,13 @@ function FilterSeries(props: Props) {
if (
series.filter.filters.length === 1 &&
prevLength === 0 &&
seriesIndex === 0
seriesIndex === 0 &&
!defaultClosed
) {
setExpanded(true);
}
setPrevLength(series.filter.filters.length);
}, [series.filter.filters.length]);
}, [series.filter.filters.length, defaultClosed]);
const onUpdateFilter = (filterIndex: any, filter: any) => {
series.filter.updateFilter(filterIndex, filter);

View file

@ -9,6 +9,7 @@ import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { toast } from 'react-toastify';
import { useHistory } from 'react-router';
import { EllipsisVertical } from "lucide-react";
interface Props extends RouteComponentProps {
metric: any;
@ -175,7 +176,7 @@ const MetricListItem: React.FC<Props> = ({
menu={{ items: menuItems, onClick: onMenuClick }}
trigger={['click']}
>
<Button type="text" icon={<MoreOutlined />} />
<Button id={'ignore-prop'} icon={<EllipsisVertical size={16} />} />
</Dropdown>
</div>
{renderModal()}

View file

@ -1,29 +1,23 @@
import React, { useEffect } from 'react';
import { PageTitle, Icon } from 'UI';
import { Segmented, Button, Popover } from 'antd';
import { PageTitle } from 'UI';
import { Button, Popover } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import AddCardSection from '../AddCardSection/AddCardSection';
import MetricsSearch from '../MetricsSearch';
import Select from 'Shared/Select';
import {Select as AntSelect} from 'antd';
import { useStore } from 'App/mstore';
import { observer, useObserver } from 'mobx-react-lite';
import { DROPDOWN_OPTIONS } from 'App/constants/card';
import { observer } from 'mobx-react-lite';
function MetricViewHeader() {
const { metricStore } = useStore();
const filter = metricStore.filter;
const [showAddCardModal, setShowAddCardModal] = React.useState(false);
const modalBgRef = React.useRef<HTMLDivElement>(null);
// Set the default sort order to 'desc'
useEffect(() => {
// Set the default sort order to 'desc'
metricStore.updateKey('sort', { by: 'desc' });
}, [metricStore]);
return (
<div>
<div className="flex items-center justify-between px-6">
<div className="flex items-center justify-between px-4 pb-2">
<div className="flex items-baseline mr-3">
<PageTitle title="Cards" className="" />
</div>
@ -31,7 +25,6 @@ function MetricViewHeader() {
<Popover arrow={false} overlayInnerStyle={{ padding: 0, borderRadius: '0.75rem' }} content={<AddCardSection fit inCards />} trigger={'click'}>
<Button
type="primary"
onClick={() => setShowAddCardModal(true)}
icon={<PlusOutlined />}
>
Create Card
@ -42,96 +35,9 @@ function MetricViewHeader() {
</div>
</div>
</div>
<div className="border-y px-6 py-1 mt-2 flex items-center w-full justify-between">
<div className="items-center flex gap-4">
<AntSelect
options={[
{ label: 'All Card Types', value: 'all' },
...DROPDOWN_OPTIONS,
]}
name="type"
defaultValue={filter.type}
onChange={({ value }) =>
metricStore.updateKey('filter', { ...filter, type: value.value })
}
/>
<DashboardDropdown
plain={false}
onChange={(value: any) =>
metricStore.updateKey('filter', { ...filter, dashboard: value })
}
/>
</div>
</div>
</div>
);
}
export default observer(MetricViewHeader);
function DashboardDropdown({
onChange,
plain = false,
}: {
plain?: boolean;
onChange: (val: any) => void;
}) {
const { dashboardStore, metricStore } = useStore();
const dashboardOptions = dashboardStore.dashboards.map((i, l) => ({
key: `${i.dashboardId}_${l}`,
label: i.name,
value: i.dashboardId,
}));
return (
<Select
isSearchable={true}
placeholder="Filter by Dashboard"
plain={plain}
options={dashboardOptions}
value={metricStore.filter.dashboard}
onChange={({ value }: any) => onChange(value)}
isMulti={true}
color="black"
/>
);
}
function ListViewToggler() {
const { metricStore } = useStore();
const listView = useObserver(() => metricStore.listView);
return (
<div className="flex items-center">
<Segmented
size="small"
options={[
{
label: (
<div className={'flex items-center gap-2'}>
<Icon name={'list-alt'} color={'inherit'} />
<div>List</div>
</div>
),
value: 'list',
},
{
label: (
<div className={'flex items-center gap-2'}>
<Icon name={'grid'} color={'inherit'} />
<div>Grid</div>
</div>
),
value: 'grid',
},
]}
onChange={(val) => {
metricStore.updateKey('listView', val === 'list');
}}
value={listView ? 'list' : 'grid'}
/>
</div>
);
}

View file

@ -3,6 +3,8 @@ import { Checkbox, Table, Typography } from 'antd';
import MetricListItem from '../MetricListItem';
import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface';
import Widget from 'App/mstore/types/widget';
import { LockOutlined, TeamOutlined } from ".store/@ant-design-icons-virtual-981121729b/package";
import { Switch, Tag, Tooltip } from ".store/antd-virtual-9b6c8c01be/package";
const { Text } = Typography;
@ -23,6 +25,8 @@ interface Props {
disableSelection?: boolean;
allSelected?: boolean;
existingCardIds?: number[];
showOwn?: boolean;
toggleOwn: () => void;
}
const ListView: React.FC<Props> = (props: Props) => {
@ -33,7 +37,9 @@ const ListView: React.FC<Props> = (props: Props) => {
toggleSelection,
disableSelection = false,
allSelected = false,
toggleAll
toggleAll,
showOwn,
toggleOwn,
} = props;
const [sorter, setSorter] = useState<{ field: string; order: 'ascend' | 'descend' }>({
field: 'lastModified',
@ -101,6 +107,7 @@ const ListView: React.FC<Props> = (props: Props) => {
key: 'title',
className: 'cap-first',
sorter: true,
width: '25%',
render: (text: string, metric: Metric) => (
<MetricListItem
key={metric.metricId}
@ -121,7 +128,7 @@ const ListView: React.FC<Props> = (props: Props) => {
dataIndex: 'owner',
key: 'owner',
className: 'capitalize',
width: '30%',
width: '16.67%',
sorter: true,
render: (text: string, metric: Metric) => (
<MetricListItem
@ -162,7 +169,38 @@ const ListView: React.FC<Props> = (props: Props) => {
// )
// },
{
title: '',
title: (
<div className={'flex items-center justify-start gap-2'}>
<div>Visibility</div>
<Tooltip
title="Toggle to view your own or team's cards."
placement="topRight"
>
<Switch
checked={!showOwn}
onChange={() =>
toggleOwn()
}
checkedChildren={'Team'}
unCheckedChildren={'Private'}
/>
</Tooltip>
</div>
),
width: '16.67%',
dataIndex: 'isPublic',
render: (isPublic: boolean) => (
<Tag
icon={isPublic ? <TeamOutlined /> : <LockOutlined />}
bordered={false}
className="rounded-lg"
>
{isPublic ? 'Team' : 'Private'}
</Tag>
),
},
{
title: 'Options',
key: 'options',
className: 'text-right',
width: '5%',
@ -183,7 +221,6 @@ const ListView: React.FC<Props> = (props: Props) => {
dataSource={paginatedData}
rowKey="metricId"
onChange={handleTableChange}
size='middle'
rowSelection={
!disableSelection
? {

View file

@ -1,6 +1,6 @@
import { observer, useObserver } from 'mobx-react-lite';
import React, { useEffect, useMemo, useState } from 'react';
import { NoContent, Pagination, Icon, Loader } from 'UI';
import { NoContent, Pagination, Loader } from 'UI';
import { useStore } from 'App/mstore';
import { sliceListPerPage } from 'App/utils';
import GridView from './GridView';
@ -8,24 +8,34 @@ import ListView from './ListView';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
function MetricsList({
siteId,
onSelectionChange
}: {
siteId,
onSelectionChange,
}: {
siteId: string;
onSelectionChange?: (selected: any[]) => void;
}) {
const { metricStore, dashboardStore } = useStore();
const metricsSearch = metricStore.filter.query;
const listView = useObserver(() => metricStore.listView);
const [selectedMetrics, setSelectedMetrics] = useState<any>([]);
const dashboard = dashboardStore.selectedDashboard;
const existingCardIds = useMemo(() => dashboard?.widgets?.map(i => parseInt(i.metricId)), [dashboard]);
const cards = useMemo(() => !!onSelectionChange ? metricStore.filteredCards.filter(i => !existingCardIds?.includes(parseInt(i.metricId))) : metricStore.filteredCards, [metricStore.filteredCards]);
const existingCardIds = useMemo(
() => dashboard?.widgets?.map((i) => parseInt(i.metricId)),
[dashboard]
);
const cards = useMemo(
() =>
!!onSelectionChange
? metricStore.filteredCards.filter(
(i) => !existingCardIds?.includes(parseInt(i.metricId))
)
: metricStore.filteredCards,
[metricStore.filteredCards]
);
const loading = metricStore.isLoading;
useEffect(() => {
metricStore.fetchList();
void metricStore.fetchList();
}, []);
useEffect(() => {
@ -43,63 +53,52 @@ function MetricsList({
}
};
const lenth = cards.length;
const length = cards.length;
useEffect(() => {
metricStore.updateKey('sessionsPage', 1);
}, []);
const showOwn = metricStore.filter.showMine;
const toggleOwn = () => {
metricStore.updateKey('showMine', !showOwn);
}
return (
<Loader loading={loading}>
<NoContent
show={lenth === 0}
show={length === 0}
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_CARDS} size={60} />
<div className="text-center mt-4 text-lg font-medium">
{metricsSearch !== '' ? 'No matching results' : 'You haven\'t created any cards yet'}
{metricsSearch !== ''
? 'No matching results'
: "You haven't created any cards yet"}
</div>
</div>
}
subtext="Utilize cards to visualize key user interactions or product performance metrics."
>
{listView ? (
<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) : [])
}
/>
) : (
<>
<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-semibold">{Math.min(cards.length, metricStore.pageSize)}</span> out
of <span className="font-semibold">{cards.length}</span> cards
</div>
<Pagination
page={metricStore.page}
total={lenth}
onPageChange={(page) => metricStore.updateKey('page', page)}
limit={metricStore.pageSize}
debounceRequest={100}
/>
</div>
</>
)}
<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}
/>
</NoContent>
</Loader>
);

View file

@ -51,9 +51,9 @@ function RangeGranularity({
)
}
const PAST_24_HR_MS = 24 * 60 * 60 * 1000
function calculateGranularities(periodDurationMs: number) {
const granularities = [
{ label: 'By minute', durationMs: 60 * 1000 },
{ label: 'Hourly', durationMs: 60 * 60 * 1000 },
{ label: 'Daily', durationMs: 24 * 60 * 60 * 1000 },
{ label: 'Weekly', durationMs: 7 * 24 * 60 * 60 * 1000 },
@ -62,6 +62,12 @@ function calculateGranularities(periodDurationMs: number) {
];
const result = [];
if (periodDurationMs === PAST_24_HR_MS) {
// if showing for 1 day, show by minute split as well
granularities.push(
{ label: 'By minute', durationMs: 60 * 1000 },
)
}
for (const granularity of granularities) {
if (periodDurationMs >= granularity.durationMs) {

View file

@ -40,6 +40,7 @@ function WidgetFormNew() {
export default observer(WidgetFormNew);
const FilterSection = observer(({ metric, excludeFilterKeys }: any) => {
const defaultClosed = React.useRef(metric.exists())
const isTable = metric.metricType === TABLE;
const isHeatMap = metric.metricType === HEATMAP;
const isFunnel = metric.metricType === FUNNEL;
@ -74,6 +75,7 @@ const FilterSection = observer(({ metric, excludeFilterKeys }: any) => {
series={series}
onRemoveSeries={() => metric.removeSeries(index)}
canDelete={metric.series.length > 1}
defaultClosed={defaultClosed.current}
emptyMessage={
isTable
? 'Filter data using any event or attribute. Use Add Step button below to do so.'

View file

@ -63,31 +63,20 @@ function FunnelBarData({
index?: number;
isHorizontal?: boolean;
}) {
const vertFillBarStyle = {
width: `${data.completedPercentageTotal}%`,
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1]
height: '100%',
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1],
};
const horizontalFillBarStyle = {
width: '100%',
height: `${data.completedPercentageTotal}%`,
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1]
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1],
}
const vertEmptyBarStyle = {
width: `${100.1 - data.completedPercentageTotal}%`,
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
height: '100%',
background: isFocused
? 'rgba(204, 0, 0, 0.3)'
: 'repeating-linear-gradient(325deg, lightgray, lightgray 1px, #FFF1F0 1px, #FFF1F0 6px)',
@ -96,10 +85,6 @@ function FunnelBarData({
const horizontalEmptyBarStyle = {
height: `${100.1 - data.completedPercentageTotal}%`,
width: '100%',
position: 'absolute',
top: 0,
right: 0,
left: 0,
background: isFocused
? 'rgba(204, 0, 0, 0.3)'
: 'repeating-linear-gradient(325deg, lightgray, lightgray 1px, #FFF1F0 1px, #FFF1F0 6px)',
@ -120,13 +105,15 @@ function FunnelBarData({
borderRadius: isHorizontal ? undefined : '.5rem',
overflow: 'hidden',
opacity: isComp ? 0.7 : 1,
display: 'flex',
flexDirection: isHorizontal ? 'column-reverse' : 'row',
}}
>
<div
className="flex items-center"
className={cn("flex", isHorizontal ? 'justify-center items-start pt-1' : 'justify-end items-center pr-1')}
style={fillBarStyle}
>
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
<div className="color-white flex items-center font-medium leading-3">
{data.completedPercentageTotal}%
</div>
</div>

View file

@ -64,8 +64,10 @@ const FilterAutoComplete = observer(
const filterKey = `${_params.type}${_params.key || ''}`;
const topValues = filterStore.topValues[filterKey] || [];
const loadTopValues = () => {
void filterStore.fetchTopValues(_params.type, _params.key);
const loadTopValues = async () => {
setLoading(true)
await filterStore.fetchTopValues(_params.type, _params.key);
setLoading(false)
};
useEffect(() => {
@ -78,7 +80,7 @@ const FilterAutoComplete = observer(
}
}, [topValues, initialFocus]);
useEffect(loadTopValues, [_params.type]);
useEffect(() => { void loadTopValues() }, [_params.type]);
const loadOptions = async (
inputValue: string,

View file

@ -44,17 +44,16 @@ function FilterSelection(props: Props) {
<div className="relative flex-shrink-0">
<OutsideClickDetectingDiv
className="relative"
onClickOutside={() =>
setTimeout(function () {
onClickOutside={() => {
setTimeout(() => {
setShowModal(false);
}, 200)
}, 0)
}
}
>
{children ? (
React.cloneElement(children, {
onClick: (e) => {
e.stopPropagation();
e.preventDefault();
setShowModal(true);
},
disabled: disabled,

File diff suppressed because it is too large Load diff