Testing Chart.js Plugin with React and Jest/Enzyme - reactjs

For my project, I am using React and Jest/Enzyme. I built out a reusable Doughnut Chart component and used a plugin to render text in the middle of the Doughnut Chart. Here is my code.
export default class DoughnutChart extends React.Component {
renderDoughnutChart = () => {
const { labels, datasetsLabel, datasetsData, datasetsBackgroundColor, displayTitle, titleText, displayLabels, doughnutCenterText, doughnutCenterColor, height, width, onClick } = this.props;
const plugin = {
beforeDraw: (chart) => {
if (chart.config.options.elements.center) {
//Get ctx from string
let ctx = chart.chart.ctx;
//Get options from the center object in options
let centerConfig = chart.config.options.elements.center;
let fontStyle = centerConfig.fontStyle || "Arial";
let txt = centerConfig.text;
let color = centerConfig.color || "#000";
let sidePadding = centerConfig.sidePadding || 20;
let sidePaddingCalculated = (sidePadding / 100) * (chart.innerRadius * 2);
//Start with a base font of 30px
ctx.font = "30px " + fontStyle;
//Get the width of the string and also the width of the element minus 10 to give it 5px side padding
let stringWidth = ctx.measureText(txt).width;
let elementWidth = (chart.innerRadius * 2) - sidePaddingCalculated;
// Find out how much the font can grow in width.
let widthRatio = elementWidth / stringWidth;
let newFontSize = Math.floor(30 * widthRatio);
let elementHeight = (chart.innerRadius * 2);
// Pick a new font size so it will not be larger than the height of label.
let fontSizeToUse = Math.min(newFontSize, elementHeight);
//Set font settings to draw it correctly.
ctx.textAlign = "center";
ctx.textBaseline = "middle";
let centerX = ((chart.chartArea.left + chart.chartArea.right) / 2);
let centerY = ((chart.chartArea.top + chart.chartArea.bottom) / 2);
ctx.font = fontSizeToUse + "px " + fontStyle;
ctx.fillStyle = color;
//Draw text in center
ctx.fillText(txt, centerX, centerY);
}
}
};
const data = {
labels: labels,
datasets: [{
label: datasetsLabel,
data: datasetsData,
backgroundColor: datasetsBackgroundColor,
}],
config: {
animation: {
animateRotate: true,
}
}
};
const options = {
maintainAspectRatio: false,
responsive: true,
title: {
display: displayTitle,
text: titleText,
fontSize: 24,
},
legend: {
display: displayLabels,
},
elements: {
center: {
text: doughnutCenterText,
color: doughnutCenterColor,
fontStyle: "Arial",
sidePadding: 40,
}
},
animation: {
animateRotate: true,
},
onClick: onClick,
};
if (!labels || !datasetsLabel || !datasetsData || !titleText) {
return null;
}
Chart.pluginService.register(plugin);
return (
<Doughnut
data={data}
options={options}
height={height}
width={width}
/>
);
}
render() {
return (
this.renderDoughnutChart()
);
}
}
This code displays the chart and the text in the middle perfectly for me. My problem is when I try to write tests for this component, it says the lines for the plugin isn't covered. I have a basic test here that tests the component renders itself and its props.
it("should render based on passed in props", () => {
let testOnClick = jest.fn();
let labels = ["Red", "Yellow", "Blue"];
let datasetsLabel = "test data sets label";
let datasetsData = [10, 20, 30];
let datasetsBackgroundColor = ["red", "yellow", "blue"];
let titleText = "Title";
let height = 300;
let width = 300;
let doughnutCenterText = "Hello";
let doughnutCenterColor = "white";
let wrapper = shallow(
<DoughnutChart
labels={labels}
datasetsLabel={datasetsLabel}
datasetsData={datasetsData}
datasetsBackgroundColor={datasetsBackgroundColor}
titleText={titleText}
height={height}
width={width}
onClick={testOnClick}
doughnutCenterText={doughnutCenterText}
doughnutCenterColor={doughnutCenterColor}
/>
);
expect(wrapper.find("Doughnut").props().data.labels).toEqual(labels);
expect(wrapper.find("Doughnut").props().data.datasets[0].label).toEqual(datasetsLabel);
expect(wrapper.find("Doughnut").props().data.datasets[0].data).toEqual(datasetsData);
expect(wrapper.find("Doughnut").props().data.datasets[0].backgroundColor).toEqual(datasetsBackgroundColor);
expect(wrapper.find("Doughnut").props().options.title.text).toEqual(titleText);
expect(wrapper.find("Doughnut").props().height).toEqual(height);
expect(wrapper.find("Doughnut").props().width).toEqual(width);
expect(wrapper.find("Doughnut").props().options.elements.center.text).toEqual(doughnutCenterText);
expect(wrapper.find("Doughnut").props().options.elements.center.color).toEqual(doughnutCenterColor);
});
I'm guessing the plugin applies all the configuration changes when the chart is drawn, so I don't understand why the tests do not hit the plugin lines.
If anyone can show me how to test the plugin or guide me in the right direction, I would greatly appreciate it! Thank you!

Can you post the output of your test? Is it checking the actual node_module plugin for chart.js or is it the one that you've built?
If it's trying to test against node_modules then it's best to ignore that entire directory from your tests via your package.json like so.
"jest": {
"testPathIgnorePatterns": [
"./node_modules/"
],
"collectCoverageFrom": [
"!/node_modules/*"
]
}

Related

How to changes the photo width and height in React Native Vision Camera or chop it?

I am simply trying to get a square image and lower the size of the image as much a possible while keep image text clear.
This is my attempt but it does not work.
const capturePhoto = async () => {
const options = {
qualityPrioritization: 'speed',
skipMetadata: true,
flash: 'off',
width: 500,
height: 500,
};
if (camera.current !== null) {
const photo = await camera.current.takePhoto(options);
const {height, width, path} = photo;
console.log(
`The photo has a height of ${height} and a width of ${width}.`,
);
}
};

ChartJS 3 Doesn't Show Data Until A Legend Is Clicked

I get some data from back-end to show. When I inspect element with React Developer Tools, I can see that data is there but not shown in production. ChartJS version is 3.8, not react-chartjs
I was having the same problem in development, too, but solved it by setting a unique key with key={Math.random()}. In development build, it works just fine. Problem occurs in production. I deploy my app on Firebase.
I wait for data before rendering:
{isAnyFetching ? "Loading..." : <BarChart01 data={chartData} width={595} height={248} key={Math.random()} />}
I tried giving an array of zeroes until data is loaded to be sure chartData changed to trigger re-render by changing the state of the chart component. I also tried giving an extraKey prop and change it with useEffect to re-render again.
The whole chart component is:
function BarChart01({
data,
width,
height
}) {
const canvas = useRef(null);
const legend = useRef(null);
useEffect(() => {
const ctx = canvas.current;
// eslint-disable-next-line no-unused-vars
const chart = new Chart(ctx, {
type: 'bar',
data: data,
options: {
layout: {
padding: {
top: 12,
bottom: 16,
left: 20,
right: 20,
},
},
scales: {
y: {
grid: {
drawBorder: false,
},
ticks: {
maxTicksLimit: 6,
callback: (value) => formatValue(value),
},
},
x: {
type: 'time',
time: {
parser: 'MM-DD-YYYY',
unit: 'month',
displayFormats: {
month: 'MMM YY',
},
},
grid: {
display: false,
drawBorder: false,
},
},
},
plugins: {
legend: {
display: true,
},
tooltip: {
callbacks: {
title: () => false, // Disable tooltip title
label: (context) => formatValue(context.parsed.y),
},
},
},
interaction: {
intersect: false,
mode: 'nearest',
},
animation: {
duration: 500,
},
maintainAspectRatio: false,
resizeDelay: 200,
},
plugins: [{
id: 'htmlLegend',
afterUpdate(c, args, options) {
const ul = legend.current;
if (!ul) return;
// Remove old legend items
while (ul.firstChild) {
ul.firstChild.remove();
}
// Reuse the built-in legendItems generator
const items = c.options.plugins.legend.labels.generateLabels(c);
items.forEach((item) => {
const li = document.createElement('li');
li.style.marginRight = tailwindConfig().theme.margin[4];
// Button element
const button = document.createElement('button');
button.style.display = 'inline-flex';
button.style.alignItems = 'center';
button.style.opacity = item.hidden ? '.3' : '';
button.onclick = () => {
c.setDatasetVisibility(item.datasetIndex, !c.isDatasetVisible(item.datasetIndex));
c.update();
};
// Color box
const box = document.createElement('span');
box.style.display = 'block';
box.style.width = tailwindConfig().theme.width[3];
box.style.height = tailwindConfig().theme.height[3];
box.style.borderRadius = tailwindConfig().theme.borderRadius.full;
box.style.marginRight = tailwindConfig().theme.margin[2];
box.style.borderWidth = '3px';
box.style.borderColor = item.fillStyle;
box.style.pointerEvents = 'none';
// Label
const labelContainer = document.createElement('span');
labelContainer.style.display = 'flex';
labelContainer.style.alignItems = 'center';
const value = document.createElement('span');
value.style.color = tailwindConfig().theme.colors.slate[800];
value.style.fontSize = tailwindConfig().theme.fontSize['3xl'][0];
value.style.lineHeight = tailwindConfig().theme.fontSize['3xl'][1].lineHeight;
value.style.fontWeight = tailwindConfig().theme.fontWeight.bold;
value.style.marginRight = tailwindConfig().theme.margin[2];
value.style.pointerEvents = 'none';
const label = document.createElement('span');
label.style.color = tailwindConfig().theme.colors.slate[500];
label.style.fontSize = tailwindConfig().theme.fontSize.sm[0];
label.style.lineHeight = tailwindConfig().theme.fontSize.sm[1].lineHeight;
const theValue = c.data.datasets[item.datasetIndex].data.reduce((a, b) => a + b, 0);
const valueText = document.createTextNode(formatValue(theValue));
const labelText = document.createTextNode(item.text);
value.appendChild(valueText);
label.appendChild(labelText);
li.appendChild(button);
button.appendChild(box);
button.appendChild(labelContainer);
labelContainer.appendChild(value);
labelContainer.appendChild(label);
ul.appendChild(li);
});
},
}],
});
chart.update();
return () => chart.destroy();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
return (
<>
<div className="px-5 py-3">
<ul ref={legend} className="flex flex-wrap"></ul>
</div>
<div className="grow">
<canvas ref={canvas} width={width} height={height}></canvas>
</div>
</>
);
}
According to the code, it seems like
button.onclick = () => {
c.setDatasetVisibility(item.datasetIndex,!c.isDatasetVisible(item.datasetIndex));
c.update();
};
part in forEach loop is responsible of this update operation when I click on a label. So it somehow doesn't call update function in production as it should as useEffect listens to data prop.

React JS animate function not working in Safari Browser in Apple Macbook

Ex.
const coords = cardElement.getBoundingClientRect();
const pileCoords = toElement.getBoundingClientRect();
if (pileCoords.height == 0 || coords.height == 0) {
return;
}
const scale = pileCoords.height / coords.height;
const cardClone = cardElement.cloneNode(true);
const toClone = toElement.cloneNode(true);
toElement.style.display = "none";
toElement.parentNode.appendChild(toClone);
document.body.appendChild(cardClone);
cardClone.style.position = "absolute";
cardClone.style.top = 0;
cardClone.style.left = 0;
const duration = 400;
if ( typeof cardClone.animate === "function" ) {
cardClone.animate(
[
{
transformOrigin: "top left",
transform: `translate(${coords.left}px, ${coords.top}px)`,
},
{
transformOrigin: "top left",
transform: `translate(${pileCoords.left}px, ${pileCoords.top}px) scale(${scale})`,
},
],
{
duration,
easing: "ease-in-out",
fill: "both",
}
);
}
Hello guys,
I am using animate function in ReactJS PWA in Safari Browser Apple Macbook. I am trying to fix it, but Safari Browser not support animate function.
Thank You.

React ChartJS - difficulty manipulating data to a certain format

I have data stored in MongoDB that looks like this: (Example of 1 document below)
{_id: XXX,
project: 'Project ABC',
amount: 300,
expenseIncurredDate: 2022-03-15T08:24:46.000+00:00
}
I am trying to display my data in a stacked bar chart where the x-axis is year-month, and the y-axis is amount. Within each year-month, there can be several expenses incurred by different projects.
I am trying to achieve something like this, except that x-axis should show year-month (e.g. Jan 2021). Each project should be represented by one color for every year-month.
I am using Chart.js. Below is my Bar component.
import { Bar } from "react-chartjs-2";
import { Chart as ChartJS } from "chart.js/auto";
import React from "react";
const BarChart = ({ chartData }) => {
const options = {
responsive: true,
legend: {
display: false,
},
type: "bar",
scales: {
xAxes: [
{
stacked: true,
},
],
yAxes: [
{
stacked: true,
},
],
},
};
return <Bar data={chartData} options={options} />;
};
export default BarChart;
I am manipulating my data in another component:
let datasets = [];
function random_bg_color() {
var x = Math.floor(Math.random() * 256);
var y = 100 + Math.floor(Math.random() * 256);
var z = 50 + Math.floor(Math.random() * 256);
var bgColor = "rgb(" + x + "," + y + "," + z + ")";
return bgColor;
}
let amounts = myExpenses.map((expense) => expense.amount);
myExpenses.forEach((expense) => {
const expenseObj = {
label: expense.project,
stack: 1,
borderWidth: 1,
backgroundColor: random_bg_color(),
data: amounts,
};
datasets.push(expenseObj);
});
let data = {
labels: myExpenses.map((expense) =>
moment(expense.expenseDate, "DD-MM-YYYY")
),
datasets: datasets,
};
I know this doesn't work as it isn't returning amounts in the correct order/grouped by year & month.
let amounts = myExpenses.map((expense) => expense.amount);
I am having difficulty trying to group the amount by year month. Would appreciate any help on this, thank you.

Functional Component Not Re-rendering after data is updated

I am building a dashboard filled with Esri maps that are editable. The structure of the components is something like this:
<Dashboard>
<Visuals>
<EventsMap>
<PointsLayer/>
</EventsMap>
</Visuals>
</Dashboard>
When a user edits something inside of the Dashboards component(i.e. the color of the points on a map) the data does get passed through to PointsLayer which then should re-render and show the updated color, but only updates if I refresh the page. Is it because I don't have a render method? The PointsLayer component:
import {useState, useEffect} from 'react';
import {loadModules} from 'esri-loader';
import {getFormattedDate} from 'Lib/helpers';
import styles from './Summary.module.css';
import point from "#arcgis/core/geometry/Point";
const PointsLayer = (props) => {
const data = props.data;
const color = data?.color;
console.log(color)
const humanize = (str) =>{
let i, frags = str.split('_');
for (i=0; i<frags.length; i++) {
frags[i] = frags[i].charAt(0).toUpperCase() + frags[i].slice(1);
}
return frags.join(' ');
}
const pluralize = (val, word, plural = word + 's') => {
const _pluralize = (num, word, plural = word + 's') =>
[1, -1].includes(Number(num)) ? word : plural;
if (typeof val === 'object') return (num, word) => _pluralize(num, word, val[word]);
return _pluralize(val, word, plural);
};
const [graphic, setGraphic] = useState(null);
useEffect(() => {
loadModules(['esri/Graphic']).then(([Graphic]) => {
// Parse out the Lat-Long from each Event and the doc_count
for (let i = 0; i < data?.events.length; i++) {
const point = {
type: "point", // autocasts as new Point
longitude: data?.events[i]?.location.split(",")[1],
latitude: data?.events[i]?.location.split(",")[0]
};
// Create a symbol for rendering the graphic
const symbol = {
type: "simple-marker", // autocasts as new SimpleMarkerSymbol()
style: "circle", color: color, // Color Selected on popup
size: "12px", outline: {
color: [255, 255, 255], // White
width: 1.5
}
};
// Create attributes for popup
const attributes = {
watcherType: humanize(data?.events[i]?.doc_fields?.watcher_type),
eventCount: data?.events[i]?.doc_count,
plural: pluralize(data?.events[i]?.doc_count, 'Event'),
deviceName: data?.events[i]?.key,
lat: data?.events[i]?.location.split(",")[0],
long: data?.events[i]?.location.split(",")[1],
account: data?.events[i]?.doc_fields?.account_id,
address: data?.events[i]?.doc_fields['#service_address'],
meterId: data?.events[i]?.doc_fields?.meter_id,
lastEvent: getFormattedDate(data?.events[i]?.doc_fields['#time_raised_last'], '')
};
// Create popup template
const popupTemplate = {
title: "{eventCount} {watcherType} {plural}",
content:
"<ul><li><b>Address:</b> {address}</li>" +
"<li><b>Account ID:</b> {account}</li>" +
"<li><b>Meter ID:</b> {meterId}</li>" +
"<li><b>Last Event:</b> {lastEvent}</li>" +
"<li><a href='https://maps.google.com/maps?q=&layer=c&cbll={lat},{long}&cbp='>Google Street View</a></li></ul>"
};
// Add the multiPoints to a new graphic
const graphic = new Graphic({
geometry: point,
symbol: symbol,
attributes: attributes,
popupTemplate: popupTemplate
});
setGraphic(graphic);
props.view.graphics.add(graphic);
}
}).catch((err) => console.error(err));
return function cleanup() {
props.view.graphics.remove(graphic);
};
}, []);
return null;
}
export default PointsLayer
An image to visualize what I am working on:
Try adding the graphic state to the useEffect dependency array [graphic]
useEffect(function, [graphic]) or useEffect(function, [props]) but props may cause more re-renders than you may want
A more complete example would look like this.
const [graphic, setGraphic] = useState(null);
useEffect(() => {
loadModules(['esri/Graphic']).then(([Graphic]) => {
// Parse out the Lat-Long from each Event and the doc_count
for (let i = 0; i < data?.events.length; i++) {
const point = {
type: "point", // autocasts as new Point
longitude: data?.events[i]?.location.split(",")[1],
latitude: data?.events[i]?.location.split(",")[0]
};
// Create a symbol for rendering the graphic
const symbol = {
type: "simple-marker", // autocasts as new SimpleMarkerSymbol()
style: "circle", color: color, // Color Selected on popup
size: "12px", outline: {
color: [255, 255, 255], // White
width: 1.5
}
};
// Create attributes for popup
const attributes = {
watcherType: humanize(data?.events[i]?.doc_fields?.watcher_type),
eventCount: data?.events[i]?.doc_count,
plural: pluralize(data?.events[i]?.doc_count, 'Event'),
deviceName: data?.events[i]?.key,
lat: data?.events[i]?.location.split(",")[0],
long: data?.events[i]?.location.split(",")[1],
account: data?.events[i]?.doc_fields?.account_id,
address: data?.events[i]?.doc_fields['#service_address'],
meterId: data?.events[i]?.doc_fields?.meter_id,
lastEvent: getFormattedDate(data?.events[i]?.doc_fields['#time_raised_last'], '')
};
// Create popup template
const popupTemplate = {
title: "{eventCount} {watcherType} {plural}",
content:
"<ul><li><b>Address:</b> {address}</li>" +
"<li><b>Account ID:</b> {account}</li>" +
"<li><b>Meter ID:</b> {meterId}</li>" +
"<li><b>Last Event:</b> {lastEvent}</li>" +
"<li><a href='https://maps.google.com/maps?q=&layer=c&cbll={lat},{long}&cbp='>Google Street View</a></li></ul>"
};
// Add the multiPoints to a new graphic
const graphic = new Graphic({
geometry: point,
symbol: symbol,
attributes: attributes,
popupTemplate: popupTemplate
});
setGraphic(graphic);
props.view.graphics.add(graphic);
}
}).catch((err) => console.error(err));
return function cleanup() {
props.view.graphics.remove(graphic);
};
}, [graphic]);
or using props,
const [graphic, setGraphic] = useState(null);
useEffect(() => {
loadModules(['esri/Graphic']).then(([Graphic]) => {
// Parse out the Lat-Long from each Event and the doc_count
for (let i = 0; i < data?.events.length; i++) {
const point = {
type: "point", // autocasts as new Point
longitude: data?.events[i]?.location.split(",")[1],
latitude: data?.events[i]?.location.split(",")[0]
};
// Create a symbol for rendering the graphic
const symbol = {
type: "simple-marker", // autocasts as new SimpleMarkerSymbol()
style: "circle", color: color, // Color Selected on popup
size: "12px", outline: {
color: [255, 255, 255], // White
width: 1.5
}
};
// Create attributes for popup
const attributes = {
watcherType: humanize(data?.events[i]?.doc_fields?.watcher_type),
eventCount: data?.events[i]?.doc_count,
plural: pluralize(data?.events[i]?.doc_count, 'Event'),
deviceName: data?.events[i]?.key,
lat: data?.events[i]?.location.split(",")[0],
long: data?.events[i]?.location.split(",")[1],
account: data?.events[i]?.doc_fields?.account_id,
address: data?.events[i]?.doc_fields['#service_address'],
meterId: data?.events[i]?.doc_fields?.meter_id,
lastEvent: getFormattedDate(data?.events[i]?.doc_fields['#time_raised_last'], '')
};
// Create popup template
const popupTemplate = {
title: "{eventCount} {watcherType} {plural}",
content:
"<ul><li><b>Address:</b> {address}</li>" +
"<li><b>Account ID:</b> {account}</li>" +
"<li><b>Meter ID:</b> {meterId}</li>" +
"<li><b>Last Event:</b> {lastEvent}</li>" +
"<li><a href='https://maps.google.com/maps?q=&layer=c&cbll={lat},{long}&cbp='>Google Street View</a></li></ul>"
};
// Add the multiPoints to a new graphic
const graphic = new Graphic({
geometry: point,
symbol: symbol,
attributes: attributes,
popupTemplate: popupTemplate
});
setGraphic(graphic);
props.view.graphics.add(graphic);
}
}).catch((err) => console.error(err));
return function cleanup() {
props.view.graphics.remove(graphic);
};
}, [props]);
I would think just using the graphic state would be closer to what you are looking for. Leaving the dependency array empty causes the useEffect hook to only fire on the initial component mount.
Adding the graphic state to the array tells the useEffect to watch for changes in the graphic state, and if it changes, refire the useEffect again
This post may be helpful Hooks and Dependency Arrays

Resources