Skip to content

Commit

Permalink
contact events noisiness stat (#526)
Browse files Browse the repository at this point in the history
  • Loading branch information
EduardZaydler authored Jul 22, 2024
1 parent 7d212a3 commit 676e6c7
Show file tree
Hide file tree
Showing 37 changed files with 1,191 additions and 184 deletions.
3 changes: 2 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
/flow-typed
webpack.*.js
src/TriggerGrammar/parser.terms.ts
src/TriggerGrammar/parser.ts
src/TriggerGrammar/parser.ts
htmlLegendPlugin.d.ts
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"@tanstack/react-table": "8.17.3",
"@types/codemirror": "5.60.1",
"chart.js": "4.4.1",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-plugin-zoom": "^2.0.1",
"codemirror": "6.0.1",
"color-hash": "1.0.3",
"date-fns": "2.9.0",
Expand Down
Binary file modified playwright/snapshots/ContactList/contactlist--few-items.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified playwright/snapshots/ContactList/contactlist--one-item.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion src/Components/ContactEditModal/ContactEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,13 @@ const ContactEditModal: FC<IContactEditModalProps> = ({
</FileExport>
<Fill />
<div>
<Hint text="This contact is being used in current subscriptions">
<Hint
text={
isDeleteContactButtonDisabled
? "This contact is being used in current subscriptions"
: ""
}
>
<Button
use="danger"
loading={isDeleting}
Expand Down
75 changes: 75 additions & 0 deletions src/Components/ContactEventStats/Components/ContactEventsChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { useMemo, useState } from "react";
import { Bar } from "react-chartjs-2";
import { Chart as ChartJS, ChartOptions, registerables } from "chart.js";
import {
EContactEventsInterval,
groupEventsByInterval,
IContactEvent,
} from "../../../Domain/Contact";
import { getStatusColor, Status } from "../../../Domain/Status";
import { createHtmlLegendPlugin } from "./htmlLegendPlugin";
import { Select } from "@skbkontur/react-ui/components/Select";
import zoomPlugin from "chartjs-plugin-zoom";
import { getContactEventsChartOptions } from "../../../helpers/getChartOptions";

ChartJS.register(...registerables);

interface IContactEventsBarChartProps {
events: IContactEvent[];
}

export const ContactEventsChart: React.FC<IContactEventsBarChartProps> = ({ events }) => {
const [interval, setInterval] = useState<EContactEventsInterval>(EContactEventsInterval.hour);

const groupedTransitions = useMemo(() => groupEventsByInterval(events, interval), [
events,
interval,
]);

const labels = useMemo(() => Object.keys(groupedTransitions), [events, interval]);

const transitionTypes = useMemo(() => {
const types = new Set<string>();
Object.values(groupedTransitions).forEach((transitions) =>
Object.keys(transitions).forEach((transition) => types.add(transition))
);
return types;
}, [events, interval]);

const datasets = useMemo(() => {
return Array.from(transitionTypes).map((transition) => ({
label: transition,
data: labels.map((timestamp) => groupedTransitions[timestamp][transition]),
backgroundColor: getStatusColor(transition.split(" to ")[1] as Status),
}));
}, [events, interval]);

return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "10px",
alignItems: "baseline",
}}
>
<span style={{ fontSize: "18px" }}>Trigger transitions</span>
<span>
<label>Select Interval </label>
<Select
value={interval}
onValueChange={setInterval}
items={Object.values(EContactEventsInterval)}
/>
</span>
</div>
<div id="contact-events-legend-container" />
<Bar
plugins={[createHtmlLegendPlugin(false), zoomPlugin]}
data={{ labels, datasets }}
options={getContactEventsChartOptions(interval) as ChartOptions<"bar">}
/>
</div>
);
};
52 changes: 52 additions & 0 deletions src/Components/ContactEventStats/Components/Legend.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
.legend-list {
display: flex;
gap: 5px;
justify-content: center;
flex-wrap: wrap;
margin: 0;
padding: 0;
max-width: 100%;
}

.legend-item {
align-items: center;
cursor: pointer;
display: flex;
margin-left: 10px;
white-space: nowrap;
}

.legend-item.hidden {
display: none;
}

.legend-item.active {
opacity: 1;
font-weight: bold;
}

.legend-box {
display: inline-block;
border-radius: 9999px;
height: 6px;
margin-right: 10px;
width: 17px;
}

.legend-text {
margin: 0;
padding: 0;
}

.legend-link {
margin-left: 5px;
}

.legend-link-icon {
height: 16px;
width: 16px;
}

.legend-toggle-icon {
cursor: pointer;
}
54 changes: 54 additions & 0 deletions src/Components/ContactEventStats/Components/TriggerEventsChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import "chartjs-adapter-date-fns";
import React, { useMemo } from "react";
import { Bar } from "react-chartjs-2";
import { Chart as ChartJS, ChartOptions, registerables } from "chart.js";
import { IContactEvent } from "../../../Domain/Contact";
import { getColor } from "../../Tag/Tag";
import { createHtmlLegendPlugin } from "./htmlLegendPlugin";
import { triggerEventsChartOptions } from "../../../helpers/getChartOptions";

ChartJS.register(...registerables);

interface ITriggerEventsBarChartProps {
events: IContactEvent[];
}

export const TriggerEventsChart: React.FC<ITriggerEventsBarChartProps> = ({ events }) => {
const groupedEvents = useMemo(
() =>
events.reduce<Record<string, number>>((acc, event) => {
acc[event.trigger_id] = (acc[event.trigger_id] || 0) + 1;
return acc;
}, {}),
[events]
);

const sortedEvents = useMemo(() => {
return Object.entries(groupedEvents).sort(([, a], [, b]) => b - a);
}, [events]);

const datasets = sortedEvents.map(([triggerId, count]) => ({
label: triggerId,
data: [count],
backgroundColor: getColor(triggerId).backgroundColor,
}));

const data = {
labels: [""],
datasets,
};

return (
<>
<span style={{ fontSize: "18px", marginBottom: "10px", display: "inline-block" }}>
Grouped by trigger
</span>
<div id="trigger-events-legend-container" />
<Bar
data={data}
plugins={[createHtmlLegendPlugin(true)]}
options={triggerEventsChartOptions as ChartOptions<"bar">}
/>
</>
);
};
154 changes: 154 additions & 0 deletions src/Components/ContactEventStats/Components/htmlLegendPlugin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React from "react";
import ReactDOM from "react-dom";
import { Chart, LegendItem, Plugin } from "chart.js";
import LinkIcon from "@skbkontur/react-icons/Link";
import { Link } from "@skbkontur/react-ui/components/Link";
import ArrowUpIcon from "@skbkontur/react-icons/ArrowChevronUp";
import ArrowDownIcon from "@skbkontur/react-icons/ArrowChevronDown";
import classNames from "classnames/bind";

import styles from "./Legend.less";

const cn = classNames.bind(styles);

let lastClickedIndex: number | null | undefined = null;
let isExpanded = false;
const maxVisibleItems = 7;

const LegendItemComponent: React.FC<{
item: LegendItem;
index: number;
chart: Chart;
showLinks: boolean;
updateLegendStyles: () => void;
}> = ({ item, index, chart, showLinks, updateLegendStyles }) => {
if (!item.text || item.text.trim() === "") {
return null;
}

const handleClick = () => {
const isVisible = chart.isDatasetVisible(item.datasetIndex as number);
if (lastClickedIndex === item.datasetIndex && isVisible) {
chart.data.datasets.forEach((_, idx: number) => {
chart.setDatasetVisibility(idx, true);
});
lastClickedIndex = null;
} else {
chart.data.datasets.forEach((_, idx: number) => {
chart.setDatasetVisibility(idx, idx === item.datasetIndex);
});
lastClickedIndex = item.datasetIndex;
}
chart.update();
updateLegendStyles();
};

return (
<li
id={`legend-item-${item.datasetIndex}`}
className={cn("legend-item", {
hidden: index >= maxVisibleItems && !isExpanded,
active: lastClickedIndex === item.datasetIndex,
})}
onClick={handleClick}
>
<span
className={cn("legend-box")}
style={{
background: item.fillStyle as string,
borderColor: item.strokeStyle as string,
borderWidth: item.lineWidth + "px",
}}
/>
<span className={cn("legend-text")} style={{ color: item.fontColor as string }}>
{item.text}
</span>
{showLinks && (
<Link
href={`/trigger/${item.text}`}
target="_blank"
className={cn("legend-link")}
onClick={(e) => e.stopPropagation()}
icon={<LinkIcon />}
/>
)}
</li>
);
};

const Legend: React.FC<{
chart: Chart;
items: LegendItem[];
showLinks: boolean;
updateLegendStyles: () => void;
}> = ({ chart, items, showLinks, updateLegendStyles }) => {
const IconComponent = isExpanded ? ArrowUpIcon : ArrowDownIcon;

const toggleExpand = () => {
isExpanded = !isExpanded;
chart.update();
};

return (
<ul className={cn("legend-list")}>
{items.map((item, index) => (
<LegendItemComponent
key={item.datasetIndex}
item={item}
index={index}
chart={chart}
showLinks={showLinks}
updateLegendStyles={updateLegendStyles}
/>
))}
{items.length > maxVisibleItems && (
<IconComponent className={cn("legend-toggle-icon")} onClick={toggleExpand} />
)}
</ul>
);
};

export const createHtmlLegendPlugin = (showLinks: boolean): Plugin<"bar"> => ({
id: "htmlLegend",
afterUpdate(chart) {
const containerID = chart.options.plugins?.htmlLegend?.containerID || "";
const legendContainer = document.getElementById(containerID);

if (legendContainer) {
const items = chart.options.plugins?.legend?.labels?.generateLabels?.(
chart
) as LegendItem[];
const updateLegendStyles = () => {
const ul = legendContainer.querySelector("ul");
if (ul) {
const legendItems = ul.querySelectorAll("li");
legendItems.forEach((legendItem) => {
if (
parseInt(legendItem.id.replace("legend-item-", "")) === lastClickedIndex
) {
legendItem.classList.add(cn("active"));
} else {
legendItem.classList.remove(cn("active"));
}
});

if (lastClickedIndex === null) {
legendItems.forEach((legendItem) => {
legendItem.classList.remove(cn("active"));
});
}
}
};

ReactDOM.render(
<Legend
chart={chart}
items={items}
showLinks={showLinks}
updateLegendStyles={updateLegendStyles}
/>,
legendContainer
);
}
},
});
Loading

0 comments on commit 676e6c7

Please sign in to comment.