• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mendersoftware / gui / 1081664682

22 Nov 2023 02:11PM UTC coverage: 82.798% (-17.2%) from 99.964%
1081664682

Pull #4214

gitlab-ci

tranchitella
fix: Fixed the infinite page redirects when the back button is pressed

Remove the location and navigate from the useLocationParams.setValue callback
dependencies as they change the set function that is presented in other
useEffect dependencies. This happens when the back button is clicked, which
leads to the location changing infinitely.

Changelog: Title
Ticket: MEN-6847
Ticket: MEN-6796

Signed-off-by: Ihor Aleksandrychiev <ihor.aleksandrychiev@northern.tech>
Signed-off-by: Fabio Tranchitella <fabio.tranchitella@northern.tech>
Pull Request #4214: fix: Fixed the infinite page redirects when the back button is pressed

4319 of 6292 branches covered (0.0%)

8332 of 10063 relevant lines covered (82.8%)

191.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.0
/src/js/components/dashboard/widgets/distribution.js
1
// Copyright 2020 Northern.tech AS
2
//
3
//    Licensed under the Apache License, Version 2.0 (the "License");
4
//    you may not use this file except in compliance with the License.
5
//    You may obtain a copy of the License at
6
//
7
//        http://www.apache.org/licenses/LICENSE-2.0
8
//
9
//    Unless required by applicable law or agreed to in writing, software
10
//    distributed under the License is distributed on an "AS IS" BASIS,
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
//    See the License for the specific language governing permissions and
13
//    limitations under the License.
14
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
import { useNavigate } from 'react-router-dom';
16

17
import { Clear as ClearIcon, Settings, Square } from '@mui/icons-material';
18
import { IconButton, LinearProgress, linearProgressClasses, svgIconClasses } from '@mui/material';
19
import { makeStyles } from 'tss-react/mui';
20

21
import { VictoryBar, VictoryContainer, VictoryPie, VictoryStack } from 'victory';
22

23
import { ensureVersionString } from '../../../actions/deviceActions';
24
import { TIMEOUTS, chartTypes } from '../../../constants/appConstants';
25
import { ALL_DEVICES } from '../../../constants/deviceConstants';
26
import { rootfsImageVersion, softwareTitleMap } from '../../../constants/releaseConstants';
27
import { isEmpty, toggle } from '../../../helpers';
28
import { chartColorPalette } from '../../../themes/Mender';
29
import Loader from '../../common/loader';
30
import { ChartEditWidget, RemovalWidget } from './chart-addition';
31

32
const seriesOther = '__OTHER__';
6✔
33

34
const createColorClassName = hexColor => `color-${hexColor.slice(1)}`;
73✔
35

36
const useStyles = makeStyles()(theme => ({
6✔
37
  header: { minHeight: 30, [`.${svgIconClasses.root}`]: { marginLeft: theme.spacing() } },
38
  indicator: { fontSize: 10, minWidth: 'initial', marginLeft: 4 },
39
  legendItem: {
40
    alignItems: 'center',
41
    display: 'grid',
42
    gridTemplateColumns: '1fr max-content',
43
    columnGap: theme.spacing(2),
44
    '&.indicating': {
45
      gridTemplateColumns: 'min-content 1fr max-content',
46
      columnGap: theme.spacing()
47
    }
48
  },
49
  wrapper: {
50
    display: 'grid',
51
    gridTemplateColumns: '200px 1fr',
52
    columnGap: theme.spacing(2),
53
    marginBottom: theme.spacing(2),
54
    '&>.flexbox.column > *': {
55
      height: 20
56
    },
57
    '.barchart': {
58
      [`.${linearProgressClasses.root}`]: {
59
        backgroundColor: theme.palette.grey[400]
60
      },
61
      ...Object.values(chartColorPalette).reduce(
62
        (accu, color) => ({
30✔
63
          ...accu,
64
          [`.${createColorClassName(color)} .${linearProgressClasses.barColorPrimary}`]: { backgroundColor: color }
65
        }),
66
        {
67
          [`.${createColorClassName(theme.palette.grey[400])} .${linearProgressClasses.barColorPrimary}`]: { backgroundColor: theme.palette.grey[400] }
68
        }
69
      )
70
    }
71
  }
72
}));
73

74
const ChartLegend = ({ classes, data, events = [], showIndicators = true }) => {
6!
75
  const { eventHandlers = {} } = events[0];
12!
76
  const { onClick } = eventHandlers;
12✔
77
  return (
12✔
78
    <div className="flexbox column">
79
      {data.map(({ fill, x, title, tip, value }) => (
80
        <div
35✔
81
          className={`clickable ${classes.legendItem} ${showIndicators ? 'indicating' : ''}`}
35!
82
          key={x}
83
          onClick={e => onClick(e, { datum: { x } })}
×
84
          title={tip}
85
        >
86
          {showIndicators && <Square className={classes.indicator} style={{ fill }} />}
35!
87
          <div className="text-overflow">{title}</div>
88
          <div>{value.toLocaleString()}</div>
89
        </div>
90
      ))}
91
    </div>
92
  );
93
};
94

95
const VictoryBarChart = ({ data, totals, ...remainder }) => (
6✔
96
  <VictoryStack {...remainder} animate={{ duration: 700, onLoad: { duration: 700 } }} horizontal padding={{ left: 0, right: 0, top: 0, bottom: 15 }}>
×
97
    <VictoryBar alignment="start" barWidth={16} sortKey={['y']} sortOrder="ascending" data={data} />
98
    <VictoryBar alignment="start" barWidth={16} sortKey={['y']} sortOrder="descending" data={totals} />
99
  </VictoryStack>
100
);
101

102
const BarChart = ({ data, events }) => {
6✔
103
  const [chartData, setChartData] = useState([]);
14✔
104
  const timer = useRef();
14✔
105

106
  useEffect(() => {
14✔
107
    setChartData(data.map(item => ({ ...item, y: 0 })));
5✔
108
    clearTimeout(timer.current);
2✔
109
    timer.current = setTimeout(() => setChartData(data), TIMEOUTS.debounceDefault);
2✔
110
    return () => {
2✔
111
      clearTimeout(timer.current);
2✔
112
    };
113
    // eslint-disable-next-line react-hooks/exhaustive-deps
114
  }, [JSON.stringify(data)]);
115

116
  const { eventHandlers = {} } = events[0];
14!
117
  const { onClick } = eventHandlers;
14✔
118
  return (
14✔
119
    <div className="flexbox column">
120
      {chartData.map(({ fill, x, y, tip }) => (
121
        <div className="clickable flexbox column barchart" key={x} onClick={e => onClick(e, { datum: { x } })} title={tip} style={{ justifyContent: 'center' }}>
38✔
122
          <LinearProgress className={createColorClassName(fill)} variant="determinate" value={y} style={{ height: 8 }} />
123
        </div>
124
      ))}
125
    </div>
126
  );
127
};
128

129
const ChartContainer = ({ className, children, innerRef, style = {} }) => (
6✔
130
  <div className={className} ref={innerRef} style={style}>
12✔
131
    {children}
132
  </div>
133
);
134

135
const BarChartContainer = ({ classes = {}, data, events, ...remainder }) => (
6!
136
  <ChartContainer className={classes.wrapper}>
12✔
137
    <ChartLegend classes={classes} data={data} events={events} showIndicators={false} />
138
    <BarChart {...remainder} data={data} events={events} />
139
  </ChartContainer>
140
);
141

142
const PieChart = props => <VictoryPie {...props} padding={{ left: 0, right: 0, top: 0, bottom: 15 }} />;
6✔
143

144
const padding = 10;
6✔
145
const PieChartContainer = ({ classes = {}, ...chartProps }) => {
6!
146
  const ref = useRef();
×
147
  let height;
148
  if (ref.current) {
×
149
    // use the widget height, remove the space the header takes up and account for widget padding (top + padding) + own padding for the chart
150
    height = ref.current.parentElement.offsetHeight - ref.current.parentElement.children[0].offsetHeight - 3 * padding;
×
151
  }
152
  return (
×
153
    <ChartContainer className={classes.wrapper} innerRef={ref} style={{ height }}>
154
      <ChartLegend {...chartProps} classes={classes} />
155
      {height && <PieChart {...chartProps} containerComponent={<VictoryContainer style={{ height }} />} />}
×
156
    </ChartContainer>
157
  );
158
};
159

160
const VictoryBarChartContainer = ({ classes = {}, ...chartProps }) => (
6!
161
  <ChartContainer className={classes.wrapper}>
×
162
    <ChartLegend {...chartProps} classes={classes} />
163
    <VictoryBarChart {...chartProps} />
164
  </ChartContainer>
165
);
166

167
const chartTypeComponentMap = {
6✔
168
  [chartTypes.bar.key]: BarChartContainer,
169
  [`${chartTypes.bar.key}-alternative`]: VictoryBarChartContainer,
170
  [chartTypes.pie.key]: PieChartContainer
171
};
172

173
const initDistribution = ({ data, theme }) => {
6✔
174
  const { items, otherCount, total } = data;
2✔
175
  const numberOfItems = items.length > chartColorPalette.length ? chartColorPalette.length - 1 : items.length;
2!
176
  const colors = chartColorPalette.slice(0, numberOfItems).reverse();
2✔
177
  let distribution = items.slice(0, colors.length).reduce(
2✔
178
    (accu, { key, count }, index) => [
4✔
179
      {
180
        x: key || '-',
4!
181
        y: (count / total) * 100, //value,
182
        title: key || '-',
4!
183
        tip: key || '-',
4!
184
        fill: chartColorPalette[index],
185
        value: count
186
      },
187
      ...accu
188
    ],
189
    []
190
  );
191
  if (items.length > chartColorPalette.length || otherCount) {
2✔
192
    distribution.splice(0, 0, {
1✔
193
      x: seriesOther,
194
      title: 'Other',
195
      tip: 'Other',
196
      y: (otherCount / total) * 100,
197
      fill: chartColorPalette[chartColorPalette.length - 1],
198
      value: otherCount
199
    });
200
  }
201
  distribution.sort((pairA, pairB) => pairB.y - pairA.y);
4✔
202
  // y: formatValue(item.y, total)
203
  const totals = distribution.map(({ x, y }) => ({ value: total, x, y: 100 - y, fill: theme.palette.grey[400] }));
5✔
204
  return { distribution, totals };
2✔
205
};
206

207
export const Header = ({ chartType }) => {
6✔
208
  const { classes } = useStyles();
16✔
209
  const { Icon } = chartTypes[chartType];
16✔
210
  return (
16✔
211
    <div className={`flexbox center-aligned ${classes.header}`}>
212
      Software distribution
213
      <Icon />
214
    </div>
215
  );
216
};
217

218
export const DistributionReport = ({ data, getGroupDevices, groups, onClick, onSave, selection = {}, software: softwareTree }) => {
6✔
219
  const {
220
    attribute: attributeSelection,
221
    group: groupSelection = '',
2✔
222
    chartType: chartTypeSelection = chartTypes.bar.key,
2✔
223
    software: softwareSelection = rootfsImageVersion
2✔
224
  } = selection;
14✔
225
  const [editing, setEditing] = useState(false);
14✔
226
  const [removing, setRemoving] = useState(false);
14✔
227
  const [chartType, setChartType] = useState(chartTypes.bar.key);
14✔
228
  const [software, setSoftware] = useState('');
14✔
229
  const [group, setGroup] = useState('');
14✔
230
  const navigate = useNavigate();
14✔
231
  const { classes, theme } = useStyles();
14✔
232

233
  useEffect(() => {
14✔
234
    setSoftware(softwareSelection || attributeSelection);
2✔
235
    setGroup(groupSelection);
2✔
236
    setChartType(chartTypeSelection);
2✔
237
    setRemoving(false);
2✔
238
    getGroupDevices(groupSelection, { page: 1, perPage: 1 });
2✔
239
  }, [attributeSelection, groupSelection, chartTypeSelection, softwareSelection, getGroupDevices]);
240

241
  const { distribution, totals } = useMemo(() => {
14✔
242
    if (isEmpty(data)) {
3✔
243
      return { distribution: [], totals: [] };
1✔
244
    }
245
    return initDistribution({ data, theme });
2✔
246
    // eslint-disable-next-line react-hooks/exhaustive-deps
247
  }, [JSON.stringify(data), JSON.stringify(selection)]);
248

249
  const onSliceClick = useCallback(
14✔
250
    (e, { datum: { x: target } }) => {
251
      if (target === seriesOther) {
×
252
        return;
×
253
      }
254
      navigate(`/devices/accepted?inventory=${group ? `group:eq:${group}&` : ''}${ensureVersionString(software, attributeSelection)}:eq:${target}`);
×
255
    },
256
    [attributeSelection, group, navigate, software]
257
  );
258

259
  const toggleRemoving = () => setRemoving(toggle);
14✔
260

261
  const onToggleEditClick = () => setEditing(toggle);
14✔
262

263
  const onSaveClick = selection => {
14✔
264
    setChartType(selection.chartType);
×
265
    setSoftware(selection.software);
×
266
    setGroup(selection.group);
×
267
    onSave(selection);
×
268
    setEditing(false);
×
269
  };
270

271
  const Chart = chartTypeComponentMap[chartType];
14✔
272
  const chartProps = {
14✔
273
    classes,
274
    data: distribution,
275
    domainPadding: 0,
276
    events: [{ target: 'data', eventHandlers: { onClick: onSliceClick } }],
277
    standalone: true,
278
    style: { data: { fill: ({ datum }) => datum.fill } },
×
279
    labels: () => null
×
280
  };
281
  const couldHaveDevices = !group || groups[group]?.deviceIds.length;
14!
282
  if (removing) {
14!
283
    return <RemovalWidget onCancel={toggleRemoving} onClick={onClick} />;
×
284
  }
285
  if (editing) {
14!
286
    return (
×
287
      <ChartEditWidget
288
        groups={groups}
289
        onSave={onSaveClick}
290
        onCancel={onToggleEditClick}
291
        selection={{ ...selection, chartType, group, software }}
292
        software={softwareTree}
293
      />
294
    );
295
  }
296
  return (
14✔
297
    <div className="widget chart-widget">
298
      <div className="margin-bottom-small">
299
        <div className="flexbox space-between margin-bottom-small">
300
          <Header chartType={chartType} />
301
          <div className="flexbox center-aligned" style={{ zIndex: 1 }}>
302
            <IconButton onClick={onToggleEditClick} size="small">
303
              <Settings fontSize="small" />
304
            </IconButton>
305
            <IconButton onClick={toggleRemoving} size="small">
306
              <ClearIcon fontSize="small" />
307
            </IconButton>
308
          </div>
309
        </div>
310
        <div className="flexbox space-between slightly-smaller">
311
          <div>{softwareTitleMap[software] ? softwareTitleMap[software].title : software}</div>
14✔
312
          <div>{group || ALL_DEVICES}</div>
28✔
313
        </div>
314
      </div>
315
      {distribution.length || totals.length ? (
30✔
316
        <Chart {...chartProps} totals={totals} />
317
      ) : couldHaveDevices ? (
2!
318
        <div className="muted flexbox centered">There are no devices that match the selected criteria.</div>
319
      ) : (
320
        <Loader show={true} />
321
      )}
322
    </div>
323
  );
324
};
325

326
export default DistributionReport;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc