@cdc/waffle-chart 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,510 @@
1
+ import React, { useState, useEffect, memo, useContext } from 'react'
2
+
3
+ import {
4
+ Accordion,
5
+ AccordionItem,
6
+ AccordionItemHeading,
7
+ AccordionItemPanel,
8
+ AccordionItemButton,
9
+ } from 'react-accessible-accordion'
10
+
11
+ import { useDebounce } from 'use-debounce'
12
+ import Context from '../context'
13
+ import ErrorBoundary from '@cdc/core/components/ErrorBoundary'
14
+ import { DATA_OPERATORS, DATA_FUNCTIONS } from '../CdcWaffleChart'
15
+
16
+ const TextField = memo((
17
+ {
18
+ label,
19
+ section = null,
20
+ subsection = null,
21
+ fieldName,
22
+ updateField,
23
+ value: stateValue,
24
+ type = 'input',
25
+ i = null, min = null, max = null,
26
+ ...attributes
27
+ }
28
+ ) => {
29
+
30
+ const [ value, setValue ] = useState(stateValue)
31
+ const [ debouncedValue ] = useDebounce(value, 500)
32
+
33
+ useEffect(() => {
34
+ if ('string' === typeof debouncedValue && stateValue !== debouncedValue) {
35
+ updateField(section, subsection, fieldName, debouncedValue, i)
36
+ }
37
+ }, [ debouncedValue, section, subsection, fieldName, i, stateValue, updateField ])
38
+
39
+ let name = subsection ? `${section}-${subsection}-${fieldName}` : `${section}-${subsection}-${fieldName}`
40
+
41
+ const onChange = (e) => {
42
+ if ('number' !== type || min === null) {
43
+ setValue(e.target.value)
44
+ } else {
45
+ if (!e.target.value || (parseFloat(min) <= parseFloat(e.target.value) & parseFloat(max) >= parseFloat(e.target.value))) {
46
+ setValue(e.target.value)
47
+ } else {
48
+ setValue(min.toString())
49
+ }
50
+ }
51
+ }
52
+
53
+ let formElement = <input type="text" name={name} onChange={onChange} {...attributes} value={value}/>
54
+
55
+ if ('textarea' === type) {
56
+ formElement = (
57
+ <textarea name={name} onChange={onChange} {...attributes} value={value}/>
58
+ )
59
+ }
60
+
61
+ if ('number' === type) {
62
+ formElement = <input type="number" name={name} onChange={onChange} {...attributes} value={value}/>
63
+ }
64
+
65
+ return (
66
+ <label>
67
+ {label && <span className="edit-label column-heading">{label}</span>}
68
+ {formElement}
69
+ </label>
70
+ )
71
+ })
72
+
73
+ const Select = memo((
74
+ {
75
+ label,
76
+ value,
77
+ options,
78
+ fieldName,
79
+ section = null,
80
+ subsection = null,
81
+ required = false,
82
+ updateField,
83
+ initial: initialValue,
84
+ ...attributes
85
+ }
86
+ ) => {
87
+
88
+ let optionsJsx = ''
89
+
90
+ if (Array.isArray(options)) { //Handle basic array
91
+ optionsJsx = options.map(optionName => <option value={optionName} key={optionName}>{optionName}</option>)
92
+ } else { //Handle object with value/name pairs
93
+ optionsJsx = []
94
+ for (const [ optionValue, optionName ] of Object.entries(options)) {
95
+ optionsJsx.push(<option value={optionValue} key={optionValue}>{optionName}</option>)
96
+ }
97
+ }
98
+
99
+ if (initialValue) {
100
+ optionsJsx.unshift(<option value="" key="initial">{initialValue}</option>)
101
+ }
102
+
103
+ return (
104
+ <label>
105
+ {label && <span className="edit-label">{label}</span>}
106
+ <select className={required && !value ? 'warning' : ''} name={fieldName} value={value} onChange={(event) => {
107
+ updateField(section, subsection, fieldName, event.target.value)
108
+ }} {...attributes}>
109
+ {optionsJsx}
110
+ </select>
111
+ </label>
112
+ )
113
+ })
114
+
115
+ const headerColors = [ 'theme-blue', 'theme-purple', 'theme-brown', 'theme-teal', 'theme-pink', 'theme-orange', 'theme-slate', 'theme-indigo', 'theme-cyan', 'theme-green', 'theme-amber' ]
116
+
117
+ const EditorPanel = memo(() => {
118
+ const {
119
+ config,
120
+ updateConfig,
121
+ loading,
122
+ data,
123
+ setParentConfig,
124
+ isDashboard
125
+ } = useContext(Context)
126
+
127
+ const [ displayPanel, setDisplayPanel ] = useState(true)
128
+
129
+ const enforceRestrictions = (updatedConfig) => {
130
+ //If there are any dependencies between fields, etc../
131
+ }
132
+
133
+ const updateField = (section, subsection, fieldName, newValue) => {
134
+ // Top level
135
+ if (null === section && null === subsection) {
136
+ let updatedConfig = { ...config, [fieldName]: newValue }
137
+
138
+ if ('filterColumn' === fieldName) {
139
+ updatedConfig.filterValue = ''
140
+ }
141
+
142
+ enforceRestrictions(updatedConfig)
143
+
144
+ updateConfig(updatedConfig)
145
+ return
146
+ }
147
+
148
+ const isArray = Array.isArray(config[section])
149
+
150
+ let sectionValue = isArray ? [ ...config[section], newValue ] : { ...config[section], [fieldName]: newValue }
151
+
152
+ if (null !== subsection) {
153
+ if (isArray) {
154
+ sectionValue = [ ...config[section] ]
155
+ sectionValue[subsection] = { ...sectionValue[subsection], [fieldName]: newValue }
156
+ } else if (typeof newValue === 'string') {
157
+ sectionValue[subsection] = newValue
158
+ } else {
159
+ sectionValue = { ...config[section], [subsection]: { ...config[section][subsection], [fieldName]: newValue } }
160
+ }
161
+ }
162
+
163
+ let updatedConfig = { ...config, [section]: sectionValue }
164
+
165
+ enforceRestrictions(updatedConfig)
166
+
167
+ updateConfig(updatedConfig)
168
+ }
169
+
170
+ const missingRequiredSections = () => {
171
+ //Whether to show error message if something is required to show a data-bite and isn't filled in
172
+ return false
173
+ }
174
+
175
+ useEffect(() => {
176
+ // Pass up to Editor if needed
177
+ if (setParentConfig) {
178
+ const newConfig = convertStateToConfig()
179
+
180
+ setParentConfig(newConfig)
181
+ }
182
+ // eslint-disable-next-line react-hooks/exhaustive-deps
183
+ }, [ config ])
184
+
185
+ useEffect(() => {
186
+ //Verify comparate data type
187
+ let operators = [ '<', '>', '<=', '>=' ]
188
+ if (config.dataConditionalComparate !== '') {
189
+ if (operators.indexOf(config.dataConditionalOperator) > -1 && isNaN(config.dataConditionalComparate)) {
190
+ updateConfig({ ...config, invalidComparate: true })
191
+ } else {
192
+ if (config.invalidComparate) {
193
+ updateConfig({ ...config, invalidComparate: false })
194
+ }
195
+ }
196
+ } else {
197
+ updateConfig({ ...config, invalidComparate: false })
198
+ }
199
+ }, [ config.dataConditionalOperator, config.dataConditionalComparate ])
200
+
201
+ const onBackClick = () => {
202
+ if (isDashboard) {
203
+ updateConfig({ ...config, editing: false })
204
+ } else {
205
+ setDisplayPanel(!displayPanel)
206
+ }
207
+ }
208
+
209
+ const Error = () => {
210
+ return (
211
+ <section className="waiting">
212
+ <section className="waiting-container">
213
+ <h3>Error With Configuration</h3>
214
+ <p>{config.runtime.editorErrorMessage}</p>
215
+ </section>
216
+ </section>
217
+ )
218
+ }
219
+
220
+ const Confirm = () => {
221
+
222
+ const confirmDone = (e) => {
223
+ e.preventDefault()
224
+
225
+ let newConfig = {...config}
226
+ delete newConfig.newViz
227
+
228
+ updateConfig(newConfig)
229
+ }
230
+
231
+ return (
232
+ <section className="waiting">
233
+ <section className="waiting-container">
234
+ <h3>Finish Configuring</h3>
235
+ <p>Set all required options to the left and confirm below to display a preview of the chart.</p>
236
+ <button className="btn" style={{ margin: '1em auto' }} disabled={missingRequiredSections()} onClick={confirmDone}>I'm Done</button>
237
+ </section>
238
+ </section>
239
+ )
240
+ }
241
+
242
+ const convertStateToConfig = () => {
243
+ let strippedState = JSON.parse(JSON.stringify(config))
244
+ if (false === missingRequiredSections()) {
245
+ delete strippedState.newViz
246
+ }
247
+ delete strippedState.runtime
248
+
249
+ return strippedState
250
+ }
251
+
252
+ const removeFilter = (index) => {
253
+ let filters = [ ...config.filters ]
254
+
255
+ filters.splice(index, 1)
256
+
257
+ updateConfig({ ...config, filters })
258
+ }
259
+
260
+ const updateFilterProp = (name, index, value) => {
261
+ let filters = [ ...config.filters ]
262
+
263
+ filters[index][name] = value
264
+
265
+ updateConfig({ ...config, filters })
266
+ }
267
+
268
+ const addNewFilter = () => {
269
+ let filters = config.filters ? [ ...config.filters ] : []
270
+
271
+ filters.push({ values: [] })
272
+
273
+ updateConfig({ ...config, filters })
274
+ }
275
+
276
+ const getColumns = (filter = true) => {
277
+ let columns = {}
278
+
279
+ data.map(row => Object.keys(row).forEach(columnName => columns[columnName] = true))
280
+
281
+ return Object.keys(columns)
282
+ }
283
+
284
+ const getFilterColumnValues = (index) => {
285
+ let filterDataOptions = []
286
+ const filterColumnName = config.filters[index].columnName
287
+ if (data && filterColumnName) {
288
+ data.forEach(function (row) {
289
+ if (undefined !== row[filterColumnName] && -1 === filterDataOptions.indexOf(row[filterColumnName])) {
290
+ filterDataOptions.push(row[filterColumnName])
291
+ }
292
+ })
293
+ filterDataOptions.sort()
294
+ }
295
+ return filterDataOptions
296
+ }
297
+
298
+ const toggleCustomDenom = () => {
299
+ let denom = { ...config }
300
+ updateConfig({ ...config, customDenom: !denom.customDenom })
301
+ }
302
+
303
+ if (loading) {
304
+ return null
305
+ }
306
+
307
+ return (
308
+ <ErrorBoundary component="EditorPanel">
309
+ {!config.newViz && config.runtime && config.runtime.editorErrorMessage && <Error/>}
310
+ {config.newViz && <Confirm/>}
311
+ <button className={displayPanel ? `editor-toggle` : `editor-toggle collapsed`}
312
+ title={displayPanel ? `Collapse Editor` : `Expand Editor`} onClick={onBackClick}/>
313
+ <section className={displayPanel ? 'editor-panel' : 'hidden editor-panel'}>
314
+ <h2>Configure Waffle Chart</h2>
315
+ <section className="form-container">
316
+ <form>
317
+ <Accordion allowZeroExpanded={true}>
318
+ <AccordionItem> {/* General */}
319
+ <AccordionItemHeading>
320
+ <AccordionItemButton>
321
+ General
322
+ </AccordionItemButton>
323
+ </AccordionItemHeading>
324
+ <AccordionItemPanel>
325
+ <TextField value={config.title} fieldName="title" label="Title" placeholder="Waffle Chart Title"
326
+ updateField={updateField}/>
327
+ <TextField type="textarea" value={config.content} fieldName="content" label="Message"
328
+ updateField={updateField}/>
329
+ <TextField value={config.subtext} fieldName="subtext" label="Subtext/Citation"
330
+ placeholder="Waffle Chart Subtext or Citation" updateField={updateField}/>
331
+ </AccordionItemPanel>
332
+ </AccordionItem>
333
+ <AccordionItem>
334
+ <AccordionItemHeading>
335
+ <AccordionItemButton>
336
+ Data
337
+ </AccordionItemButton>
338
+ </AccordionItemHeading>
339
+ <AccordionItemPanel>
340
+ <h4 style={{ fontWeight: '600' }}>Numerator</h4>
341
+ <div className="accordion__panel-section">
342
+ <Select value={config.dataColumn || ''} fieldName="dataColumn" label="Data Column"
343
+ updateField={updateField} initial="Select" options={getColumns()}/>
344
+ <Select value={config.dataFunction || ''} fieldName="dataFunction" label="Data Function"
345
+ updateField={updateField} initial="Select" options={DATA_FUNCTIONS}/>
346
+ <label><span className="edit-label">Data Conditional</span></label>
347
+ <div className="accordion__panel-row accordion__small-inputs">
348
+ <div className="accordion__panel-col">
349
+ <Select value={config.dataConditionalColumn || ''} fieldName="dataConditionalColumn"
350
+ updateField={updateField} initial="Select" options={getColumns()}/>
351
+ </div>
352
+ <div className="accordion__panel-col">
353
+ <Select value={config.dataConditionalOperator || ''} fieldName="dataConditionalOperator"
354
+ updateField={updateField} initial="Select" options={DATA_OPERATORS}/>
355
+ </div>
356
+ <div className="accordion__panel-col">
357
+ <TextField value={config.dataConditionalComparate} fieldName={'dataConditionalComparate'}
358
+ updateField={updateField}
359
+ className={config.invalidComparate ? 'accordion__input-error' : ''}/>
360
+ </div>
361
+ </div>
362
+ {config.invalidComparate &&
363
+ <div className="accordion__panel-error">Non-numerical comparate values can only be used with = or
364
+ ≠.</div>
365
+ }
366
+ </div>
367
+
368
+ <div className="accordion__panel-row align-center">
369
+ <div className="accordion__panel-col">
370
+ <h4 style={{ fontWeight: '600' }}>Denominator</h4>
371
+ </div>
372
+ <div className="accordion__panel-col">
373
+ <div className="d-flex justify-end">
374
+ <label className={'accordion__panel-label--inline'}>Select from data</label>
375
+ <div className={`accordion__panel-checkbox${config.customDenom ? ' checked' : ''}`}
376
+ onClick={() => toggleCustomDenom()}/>
377
+ </div>
378
+ </div>
379
+ </div>
380
+ <div className="accordion__panel-section">
381
+ {!config.customDenom &&
382
+ <div className="accordion__panel-row align-center">
383
+ <div className="accordion__panel-col">
384
+ <TextField value={config.dataDenom} fieldName="dataDenom" updateField={updateField}/>
385
+ </div>
386
+ <div className="accordion__panel-col">
387
+ <label className={'accordion__panel-label--muted'}>default (100)</label>
388
+ </div>
389
+ </div>
390
+ }
391
+ {config.customDenom &&
392
+ <>
393
+ <Select value={config.dataDenomColumn || ''} fieldName="dataDenomColumn" label="Data Column"
394
+ updateField={updateField} initial="Select" options={getColumns()}/>
395
+ <Select value={config.dataDenomFunction || ''} fieldName="dataDenomFunction" label="Data Function"
396
+ updateField={updateField} initial="Select" options={DATA_FUNCTIONS}/>
397
+ </>
398
+ }
399
+ </div>
400
+ <ul className="column-edit">
401
+ <li className="three-col">
402
+ <TextField value={config.prefix} fieldName="prefix" label="Prefix" updateField={updateField}/>
403
+ <TextField value={config.suffix} fieldName="suffix" label="Suffix" updateField={updateField}/>
404
+ <TextField type="number" value={config.roundToPlace} fieldName="roundToPlace" label="Round"
405
+ updateField={updateField}/>
406
+ </li>
407
+ </ul>
408
+ </AccordionItemPanel>
409
+ </AccordionItem>
410
+ <AccordionItem>
411
+ <AccordionItemHeading>
412
+ <AccordionItemButton>
413
+ Filters
414
+ </AccordionItemButton>
415
+ </AccordionItemHeading>
416
+ <AccordionItemPanel>
417
+ <ul className="filters-list">
418
+ {config.filters && config.filters.map((filter, index) => (
419
+ <fieldset className="edit-block" key={index}>
420
+ <button type="button" className="remove-column" onClick={() => {
421
+ removeFilter(index)
422
+ }}>Remove
423
+ </button>
424
+ <label>
425
+ <span className="edit-label column-heading">Column</span>
426
+ <select value={filter.columnName} onChange={(e) => {
427
+ updateFilterProp('columnName', index, e.target.value)
428
+ }}>
429
+ <option value="">- Select Option -</option>
430
+ {getColumns().map((dataKey, index) => (
431
+ <option value={dataKey} key={index}>{dataKey}</option>
432
+ ))}
433
+ </select>
434
+ </label>
435
+ <label>
436
+ <span className="edit-label column-heading">Column Value</span>
437
+ <select value={filter.columnValue} onChange={(e) => {
438
+ updateFilterProp('columnValue', index, e.target.value)
439
+ }}>
440
+ <option value="">- Select Option -</option>
441
+ {getFilterColumnValues(index).map((dataKey, index) => (
442
+ <option value={dataKey} key={index}>{dataKey}</option>
443
+ ))}
444
+ </select>
445
+ </label>
446
+ </fieldset>
447
+ )
448
+ )}
449
+ </ul>
450
+
451
+ <button type="button" onClick={addNewFilter} className="btn btn-primary">Add Filter</button>
452
+ </AccordionItemPanel>
453
+ </AccordionItem>
454
+ <AccordionItem>
455
+ <AccordionItemHeading>
456
+ <AccordionItemButton>
457
+ Visual
458
+ </AccordionItemButton>
459
+ </AccordionItemHeading>
460
+ <AccordionItemPanel>
461
+ <Select value={config.shape} fieldName="shape" label="Shape"
462
+ updateField={updateField} options={[ 'circle', 'square', 'person' ]}/>
463
+
464
+ <div className="accordion__panel-row accordion__small-inputs" style={{marginTop: '1em'}}>
465
+ <div className="accordion__panel-col">
466
+ <TextField type="number" value={config.nodeWidth} fieldName="nodeWidth" label="Width" updateField={updateField}/>
467
+ </div>
468
+ <div className="accordion__panel-col">
469
+ <TextField type="number" value={config.nodeSpacer} fieldName="nodeSpacer" label="Spacer" updateField={updateField}/>
470
+ </div>
471
+ </div>
472
+
473
+ <Select value={config.orientation} fieldName="orientation" label="Layout"
474
+ updateField={updateField} options={[ 'horizontal', 'vertical' ]}/>
475
+
476
+ <label><span className="edit-label column-heading">Data Point Font Size</span></label>
477
+ <div className="accordion__panel-row accordion__small-inputs align-center">
478
+ <div className="accordion__panel-col">
479
+ <TextField type="number" value={config.fontSize} fieldName="fontSize" updateField={updateField}/>
480
+ </div>
481
+ <div className="accordion__panel-col">
482
+ <label className={'accordion__panel-label--muted'}>default (50px)</label>
483
+ </div>
484
+ </div>
485
+
486
+ <Select value={config.overallFontSize} fieldName="overallFontSize" label="Overall Font Size"
487
+ updateField={updateField} options={[ 'small', 'medium', 'large' ]}/>
488
+
489
+ <label className="header">
490
+ <span className="edit-label">Theme</span>
491
+ <ul className="color-palette">
492
+ {headerColors.map((palette) => (
493
+ <li title={palette} key={palette} onClick={() => {
494
+ updateConfig({ ...config, theme: palette })
495
+ }} className={config.theme === palette ? 'selected ' + palette : palette}>
496
+ </li>
497
+ ))}
498
+ </ul>
499
+ </label>
500
+ </AccordionItemPanel>
501
+ </AccordionItem>
502
+ </Accordion>
503
+ </form>
504
+ </section>
505
+ </section>
506
+ </ErrorBoundary>
507
+ )
508
+ })
509
+
510
+ export default EditorPanel
package/src/context.js ADDED
@@ -0,0 +1,5 @@
1
+ import { createContext } from 'react';
2
+
3
+ const ConfigContext = createContext({});
4
+
5
+ export default ConfigContext;
@@ -0,0 +1,27 @@
1
+ export default {
2
+ "title": "Waffle Chart",
3
+ "content": "",
4
+ "subtext": "",
5
+ "orientation": "horizontal",
6
+ "data": "",
7
+ "filters": [],
8
+ "fontSize": "",
9
+ "overallFontSize": "medium",
10
+ "dataColumn": "",
11
+ "dataFunction": "",
12
+ "dataConditionalColumn": "",
13
+ "dataConditionalOperator": "",
14
+ "dataConditionalComparate": "",
15
+ "invalidComparate": false,
16
+ "customDenom": false,
17
+ "dataDenom": "100",
18
+ "dataDenomColumn": "",
19
+ "dataDenomFunction": "",
20
+ "suffix": "%",
21
+ "roundToPlace": "0",
22
+ "shape": "circle",
23
+ "nodeWidth": "10",
24
+ "nodeSpacer": "2",
25
+ "theme": "theme-blue",
26
+ "type": "waffle-chart"
27
+ }
package/src/index.html ADDED
@@ -0,0 +1,11 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
6
+ </head>
7
+ <body>
8
+ <div class="react-container" data-config="/examples/example-config.json"></div>
9
+ <noscript>You need to enable JavaScript to run this app.</noscript>
10
+ </body>
11
+ </html>
package/src/index.js ADDED
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ import { render } from 'react-dom';
3
+ import CdcWaffleChart from './CdcWaffleChart'
4
+
5
+ const domContainers = document.querySelectorAll('.react-container');
6
+
7
+ let isEditor = window.location.href.includes('editor=true');
8
+
9
+ domContainers.forEach((domContainer) => {
10
+ render(
11
+ <React.StrictMode>
12
+ <CdcWaffleChart configUrl={domContainer.attributes['data-config'].value} isEditor={isEditor} />
13
+ </React.StrictMode>,
14
+ domContainer
15
+ );
16
+ });