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

mendersoftware / gui / 908425489

pending completion
908425489

Pull #3799

gitlab-ci

mzedel
chore: aligned loader usage in devices list with deployment devices list

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #3799: MEN-6553

4406 of 6423 branches covered (68.6%)

18 of 19 new or added lines in 3 files covered. (94.74%)

1777 existing lines in 167 files now uncovered.

8329 of 10123 relevant lines covered (82.28%)

144.7 hits per line

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

70.2
/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 { 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__';
7✔
33

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

36
const useStyles = makeStyles()(theme => ({
7✔
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 }) => {
7!
75
  const { eventHandlers = {} } = events[0];
2!
76
  const { onClick } = eventHandlers;
2✔
77
  return (
2✔
78
    <div className="flexbox column">
79
      {data.map(({ fill, x, title, tip, value }) => (
80
        <div
6✔
81
          className={`clickable ${classes.legendItem} ${showIndicators ? 'indicating' : ''}`}
6!
82
          key={x}
UNCOV
83
          onClick={e => onClick(e, { datum: { x } })}
×
84
          title={tip}
85
        >
86
          {showIndicators && <Square className={classes.indicator} style={{ fill }} />}
6!
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 }) => (
7✔
UNCOV
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 }) => {
7✔
103
  const [chartData, setChartData] = useState([]);
3✔
104
  const timer = useRef();
3✔
105

106
  useEffect(() => {
3✔
107
    setChartData(data.map(item => ({ ...item, y: 0 })));
3✔
108
    clearTimeout(timer.current);
1✔
109
    timer.current = setTimeout(() => setChartData(data), 700);
1✔
110
    return () => {
1✔
111
      clearTimeout(timer.current);
1✔
112
    };
113
  }, [JSON.stringify(data)]);
114

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

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

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

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

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

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

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

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

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

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

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

240
  const { distribution, totals } = useMemo(() => {
4✔
241
    if (isEmpty(data)) {
2✔
242
      return { distribution: [], totals: [] };
1✔
243
    }
244
    return initDistribution({ data, theme });
1✔
245
  }, [JSON.stringify(data), JSON.stringify(selection)]);
246

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

257
  const toggleRemoving = () => setRemoving(toggle);
4✔
258

259
  const onToggleEditClick = () => setEditing(toggle);
4✔
260

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

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

324
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