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

mendersoftware / gui / 1113439055

19 Dec 2023 09:01PM UTC coverage: 82.752% (-17.2%) from 99.964%
1113439055

Pull #4258

gitlab-ci

mender-test-bot
chore: Types update

Signed-off-by: Mender Test Bot <mender@northern.tech>
Pull Request #4258: chore: Types update

4326 of 6319 branches covered (0.0%)

8348 of 10088 relevant lines covered (82.75%)

189.39 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