36 |
37 |
38 |
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | export const bareConfig = { url: '', paths: [] }
2 |
3 | export const addPath = (config, path) => {
4 | const nextConfig = JSON.parse(JSON.stringify(config))
5 | nextConfig.paths.push({ path })
6 | return nextConfig
7 | }
8 |
9 | export const removePath = (config, path) => {
10 | const nextConfig = JSON.parse(JSON.stringify(config))
11 | nextConfig.paths = nextConfig.paths.filter(p => p.path !== path)
12 | return nextConfig
13 | }
14 |
15 | export const raisePath = (config, path) => {
16 | const nextConfig = JSON.parse(JSON.stringify(config))
17 | const index = nextConfig.paths.findIndex(p => p.path === path)
18 | const current = nextConfig.paths[index]
19 | nextConfig.paths[index] = nextConfig.paths[index - 1]
20 | nextConfig.paths[index - 1] = current
21 | return nextConfig
22 | }
23 |
24 | export const lowerPath = (config, path) => {
25 | const nextConfig = JSON.parse(JSON.stringify(config))
26 | const index = nextConfig.paths.findIndex(p => p.path === path)
27 | const current = nextConfig.paths[index]
28 | nextConfig.paths[index] = nextConfig.paths[index + 1]
29 | nextConfig.paths[index + 1] = current
30 | return nextConfig
31 | }
32 |
33 | export const setValues = (config, path, values) => {
34 | const nextConfig = JSON.parse(JSON.stringify(config))
35 | nextConfig.paths.find(p => p.path === path).values = values
36 | return nextConfig
37 | }
38 |
39 | export const setPathSearch = (config, path, search) => {
40 | const nextConfig = JSON.parse(JSON.stringify(config))
41 | nextConfig.paths.find(p => p.path === path).search = search
42 | return nextConfig
43 | }
44 |
45 | export const clearFilters = (config) => {
46 | const nextConfig = JSON.parse(JSON.stringify(config))
47 | nextConfig.paths.forEach(p => {
48 | delete p.values
49 | delete p.search
50 | })
51 | return nextConfig
52 | }
53 |
--------------------------------------------------------------------------------
/src/Filter.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Box, DropButton, Select, Text, TextInput } from 'grommet'
3 | import { Filter as FilterIcon } from 'grommet-icons'
4 | import { setPathSearch, setValues } from './config'
5 |
6 | const Filter = ({ config, setConfig, dataProps }) => {
7 | return (
8 | }
10 | hoverIndicator
11 | dropAlign={{ top: 'bottom', right: 'right' }}
12 | dropContent={(
13 |
14 | {config.paths.map(({ path, search, values }) => {
15 | const dataProp = dataProps.find(p => p.property === path)
16 | return (
17 |
23 |
24 | {path}
25 |
26 |
27 |
28 | {dataProp.options ? (
29 |
47 |
48 |
49 | )
50 | })}
51 |
52 | )}
53 | />
54 | )
55 | }
56 |
57 | export default Filter
58 |
--------------------------------------------------------------------------------
/src/Aggregate.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Box, Button, Grid, Heading, Layer, Meter, Text } from 'grommet'
3 | import { Close } from 'grommet-icons'
4 | import { datumValue } from './data'
5 |
6 | const colors =
7 | ['accent-1', 'neutral-1', 'accent-2', 'neutral-2', 'accent-3', 'neutral-3']
8 |
9 | const Aggregate = ({ config, data, dataProps, onClose }) => {
10 | const meters = config.paths
11 | .map(c => dataProps.find(dp => dp.property === c.path))
12 | .filter(dataProp => dataProp.options).map((dataProp) => {
13 | const counts = {}
14 | data.forEach((datum) => {
15 | const value = datumValue(datum, dataProp.property)
16 | if (!counts[value]) counts[value] = 0
17 | counts[value] += 1
18 | })
19 | return (
20 |
21 | {dataProp.property}
22 | ({
26 | label: k,
27 | color: colors[i % colors.length],
28 | value: (counts[k] / data.length) * 100,
29 | }))}
30 | />
31 |
32 | {Object.keys(counts).map((k, i) => (
33 |
34 |
35 |
36 | {k}
37 |
38 | {counts[k]}
39 |
40 | ))}
41 |
42 |
43 | )
44 | })
45 |
46 | return (
47 |
54 |
55 |
56 | {data.length} selected
57 |
58 | } hoverIndicator onClick={onClose} />
59 |
60 |
61 |
62 | {meters}
63 |
64 |
65 |
66 | )
67 | }
68 |
69 | export default Aggregate
70 |
--------------------------------------------------------------------------------
/src/data.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Box, Image } from 'grommet'
3 | import { Checkmark } from 'grommet-icons'
4 |
5 | export const datumValue = (datum, property) => {
6 | const parts = property.split('.')
7 | if (parts.length === 1) {
8 | return datum[property]
9 | }
10 | if (!datum[parts[0]]) {
11 | return undefined
12 | }
13 | return datumValue(datum[parts[0]], parts.slice(1).join('.'))
14 | }
15 |
16 | export const buildProps = (data, pathPrefix = []) => {
17 | let result = []
18 | const firstObj = pathPrefix.length
19 | ? datumValue(data[0], pathPrefix.join('.')) : data[0]
20 | const lastObj = pathPrefix.length
21 | ? datumValue(data[data.length-1], pathPrefix.join('.')) : data[data.length-1]
22 | const obj = firstObj || lastObj
23 | Object.keys(obj).forEach((key) => {
24 | const path = [...pathPrefix, key].join('.')
25 | const value = (firstObj && firstObj[key]) || lastObj[key]
26 | if (typeof value === 'string') {
27 | // build options
28 | const options = {}
29 | data.forEach(datum => {
30 | const value = datumValue(datum, path)
31 | options[value] = true
32 | })
33 | const isImage = (value && value.endsWith('.png'))
34 | result.push({
35 | property: path,
36 | align: isImage ? 'center' : 'start',
37 | header: path,
38 | example: value,
39 | options: Object.keys(options).length < 10
40 | ? Object.keys(options) : undefined,
41 | render: isImage
42 | ? value => (
43 |
44 |
45 |
46 | ) : undefined,
47 | })
48 | } else if (typeof value === 'number') {
49 | result.push({
50 | property: path,
51 | align: 'end',
52 | header: path,
53 | example: value,
54 | })
55 | } else if (typeof value === 'boolean') {
56 | result.push({
57 | property: path,
58 | align: 'center',
59 | header: path,
60 | example: value,
61 | render: value => (datumValue(value, path) ? : null),
62 | options: [true, false],
63 | })
64 | } else if (Array.isArray(value)) {
65 | // TODO
66 | } else if (value && typeof value === 'object') {
67 | result = result.concat(buildProps(data, path.split('.')))
68 | }
69 | })
70 | return result
71 | }
72 |
--------------------------------------------------------------------------------
/src/Start.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Box, Button, Form, FormField, Heading, Paragraph, Text } from 'grommet'
3 | import { Next } from 'grommet-icons'
4 | import { bareConfig } from './config'
5 |
6 | const examples = [
7 | 'https://api.spacexdata.com/v3/launches/past?order=desc'
8 | ]
9 |
10 | const Start = ({ setConfig }) => {
11 | const [recents, setRecents] = React.useState([])
12 |
13 | // load from local storage
14 | React.useEffect(() => {
15 | const stored = localStorage.getItem('dataSources')
16 | if (stored) setRecents(JSON.parse(stored))
17 | }, [])
18 |
19 | return (
20 |
21 | tabular
22 |
23 | Provide the URL of a JSON endpoint and see what it has to offer
24 |
25 |
43 | {recents.length > 0 && (
44 | Recent
45 | )}
46 | {recents.filter(r => r).map(recent => (
47 |
62 | ))}
63 | {examples.length > 0 && (
64 | Examples
65 | )}
66 | {examples.map(example => (
67 |
82 | ))}
83 |
84 | )
85 | }
86 |
87 | export default Start
88 |
--------------------------------------------------------------------------------
/src/Build.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Box, Button, RadioButton, Text, TextInput } from 'grommet'
3 | import { Add, Close, Down, Up, } from 'grommet-icons'
4 | import { addPath, removePath, raisePath, lowerPath } from './config'
5 |
6 | const Build = ({ config, setConfig, dataProps }) => {
7 | const [search, setSearch] = React.useState('')
8 |
9 | const searchExp = search ? new RegExp(search, 'i') : undefined
10 |
11 | return (
12 |
19 |
20 | setSearch(event.target.value)}
24 | />
25 |
26 |
27 |
28 | {config.paths
29 | // omit object properties which aren't columns
30 | .filter(({ path }) =>
31 | !dataProps.some(p => p.property === path && p.dataProps))
32 | .filter(({ path }) => !search || searchExp.test(path))
33 | .map(({ path }) => {
34 | return (
35 |
41 |
42 | {path}
43 |
44 |
45 | {
49 | const nextConfig = JSON.parse(JSON.stringify(config))
50 | nextConfig.primaryKey = path
51 | setConfig(nextConfig)
52 | }}
53 | />
54 |
55 | }
57 | hoverIndicator
58 | disabled={!config.paths.findIndex(p => p.path === path)}
59 | onClick={() => setConfig(raisePath(config, path))}
60 | />
61 | }
63 | hoverIndicator
64 | disabled={config.paths
65 | .findIndex(p => p.path === path) >= config.paths.length - 1}
66 | onClick={() => setConfig(lowerPath(config, path))}
67 | />
68 | }
70 | hoverIndicator
71 | onClick={() => setConfig(removePath(config, path))}
72 | />
73 |
74 |
75 |
76 | )
77 | })}
78 |
79 |
80 |
81 | {dataProps
82 | .filter(p =>
83 | !config.paths.some(({ path }) => (path === p.property && !p.dataProps)))
84 | .filter(p => !search || searchExp.test(p.property))
85 | .map(property => (
86 |
87 |
88 |
89 | {property.property}
90 |
91 |
92 |
93 |
94 | {property.example}
95 |
96 |
97 | }
99 | hoverIndicator
100 | onClick={() =>
101 | setConfig(addPath(config, property.property))}
102 | />
103 |
104 |
105 |
106 | ))}
107 |
108 |
109 | )
110 | }
111 |
112 | export default Build
113 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.1/8 is considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl)
104 | .then(response => {
105 | // Ensure service worker exists, and that we really are getting a JS file.
106 | const contentType = response.headers.get('content-type');
107 | if (
108 | response.status === 404 ||
109 | (contentType != null && contentType.indexOf('javascript') === -1)
110 | ) {
111 | // No service worker found. Probably a different app. Reload the page.
112 | navigator.serviceWorker.ready.then(registration => {
113 | registration.unregister().then(() => {
114 | window.location.reload();
115 | });
116 | });
117 | } else {
118 | // Service worker found. Proceed as normal.
119 | registerValidSW(swUrl, config);
120 | }
121 | })
122 | .catch(() => {
123 | console.log(
124 | 'No internet connection found. App is running in offline mode.'
125 | );
126 | });
127 | }
128 |
129 | export function unregister() {
130 | if ('serviceWorker' in navigator) {
131 | navigator.serviceWorker.ready.then(registration => {
132 | registration.unregister();
133 | });
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | Anchor, Box, Button, DataTable, Grommet, Paragraph, Text, TextInput, grommet,
4 | } from 'grommet'
5 | import { Next, Previous, Share, Unlink } from 'grommet-icons'
6 | import { bareConfig, clearFilters } from './config'
7 | import { datumValue, buildProps } from './data'
8 | import Loading from './Loading'
9 | import Start from './Start'
10 | import Build from './Build'
11 | import Filter from './Filter'
12 | import Detail from './Detail'
13 | import Aggregate from './Aggregate'
14 |
15 | const selectedRowStyle = { background: 'brand' }
16 |
17 | const getParams = () => {
18 | const { location } = window;
19 | const params = {};
20 | location.search.slice(1).split('&').forEach(p => {
21 | const [k, v] = p.split(/=(.+)/);
22 | params[k] = decodeURIComponent(v);
23 | });
24 | return params;
25 | }
26 |
27 | const App = () => {
28 | // config e.g. { url: '', primaryKey: '', paths: { path: '', values: [], search: '' }}
29 | const [config, setConfig] = React.useState({})
30 | const [fullData, setFullData] = React.useState()
31 | const [data, setData] = React.useState([])
32 | const [dataProps, setDataProps] = React.useState([])
33 | const [columns, setColumns] = React.useState([])
34 | const [edit, setEdit] = React.useState(true)
35 | const [datum, setDatum] = React.useState()
36 | const [search, setSearch] = React.useState('')
37 | const [select, setSelect] = React.useState(false)
38 | const [selected, setSelected] = React.useState({})
39 | const [filterSelected, setFilterSelected] = React.useState(false)
40 | const [aggregate, setAggregate] = React.useState(false)
41 |
42 | // load first data source from local storage
43 | React.useEffect(() => {
44 | // get config from location, if any
45 | const params = getParams();
46 | if (params.c) {
47 | try {
48 | setConfig(JSON.parse(params.c))
49 | } catch (e) {
50 | console.error('!!!', e)
51 | setConfig(bareConfig)
52 | }
53 | } else {
54 | let stored = localStorage.getItem('dataSources')
55 | if (stored) {
56 | const dataSources = JSON.parse(stored)
57 | stored = dataSources[0] && localStorage.getItem(dataSources[0])
58 | if (stored) {
59 | setConfig(JSON.parse(stored))
60 | } else {
61 | setConfig(bareConfig)
62 | }
63 | } else {
64 | setConfig(bareConfig)
65 | }
66 | }
67 | }, [])
68 |
69 | // save config to local storage when it changes
70 | React.useEffect(() => {
71 | if (config && config.url) {
72 | localStorage.setItem(config.url, JSON.stringify(config))
73 | }
74 | }, [config])
75 |
76 | // set columns when config or dataProps change
77 | React.useEffect(() => {
78 | if (dataProps.length > 0) {
79 | const nextColumns = config.paths
80 | .map(({ path }) => dataProps.find(p => p.property === path))
81 | setColumns(nextColumns)
82 | }
83 | }, [config.paths, dataProps])
84 |
85 | // load api data
86 | React.useEffect(() => {
87 | if (config.url) {
88 | setDataProps([])
89 | setFullData(undefined)
90 | setColumns([])
91 |
92 | fetch(config.url)
93 | .then(response => response.json())
94 | .then(responseData => {
95 | let nextFullData;
96 | if (Array.isArray(responseData)) nextFullData = responseData
97 | else if (typeof responseData === 'object') {
98 | // look for first array value
99 | Object.keys(responseData).some((key) => {
100 | if (Array.isArray(responseData[key])) nextFullData = responseData[key]
101 | return nextFullData
102 | })
103 | }
104 | const nextDataProps = buildProps(nextFullData)
105 | setDataProps(nextDataProps)
106 | setFullData(nextFullData)
107 | setData(nextFullData)
108 | })
109 | }
110 | }, [config.url])
111 |
112 | // set data when props, config, or search change
113 | React.useEffect(() => {
114 | if (config && fullData && dataProps.length > 0) {
115 | const searchExp = search ? new RegExp(search, 'i') : undefined
116 |
117 | const nextData = fullData.filter((datum) =>
118 | // if filtering on selected, ignore other filtering
119 | (filterSelected && selected[datumValue(datum, config.primaryKey)])
120 | || (!filterSelected
121 | // check if any property has a filter that doesn't match
122 | && !config.paths.some(({ path, search, values }) => {
123 | if (search) {
124 | const value = datumValue(datum, path)
125 | return !(new RegExp(search, 'i').test(value))
126 | }
127 | if (values && values.length > 0) {
128 | const value = datumValue(datum, path)
129 | return !values.some(v => v === value)
130 | }
131 | return false
132 | })
133 | // or if there is a search but no values match
134 | && (!searchExp || config.paths.some(({ path }) => {
135 | const value = datumValue(datum, path)
136 | return searchExp.test(value)
137 | })))
138 | )
139 | setData(nextData)
140 | }
141 | }, [config, dataProps, filterSelected, fullData, search, selected])
142 |
143 | return (
144 |
145 | {!config ?
146 | : (!config.url ?
147 | : (
148 |
149 |
150 |
151 | {/* app header */}
152 |
161 | }
163 | hoverIndicator
164 | onClick={() => setConfig(bareConfig)}
165 | />
166 |
167 | {config.url}
168 | {navigator.share && (
169 | }
171 | hoverIndicator
172 | onClick={() => navigator.share({
173 | title: 'tabular',
174 | text: 'tabular',
175 | url: `?c=${encodeURIComponent(JSON.stringify(config))}`,
176 | }).catch(() => {})}
177 | />
178 | )}
179 |
180 | : }
182 | hoverIndicator
183 | onClick={() => setEdit(!edit)}
184 | />
185 |
186 |
187 | {(!fullData || dataProps.length === 0) ? : (
188 |
189 |
190 | {/* when there are no columns yet */}
191 | {columns.length === 0 ? (
192 |
193 |
194 | Add some columns to build a table
195 |
196 |
197 | ) : (
198 |
199 | {/* data header */}
200 | setSearch(event.target.value)}
204 | />
205 |
206 |
207 | {
211 | setFilterSelected(false)
212 | setSelected({})
213 | setSelect(!select)
214 | }}
215 | />
216 |
217 |
218 | )}
219 |
220 | {/* select state */}
221 | {select && (
222 |
232 |
233 | {
237 | const nextSelected = {}
238 | data.forEach(datum => {
239 | const value = datumValue(datum, config.primaryKey)
240 | nextSelected[value] = selectedRowStyle
241 | })
242 | setSelected(nextSelected)
243 | }}
244 | />
245 | {Object.keys(selected).length > 0 && (
246 | {
250 | setSelected({})
251 | setFilterSelected(false)
252 | }}
253 | />
254 | )}
255 |
256 |
257 | {
262 | setFilterSelected(true)
263 | setSearch('')
264 | setConfig(clearFilters(config))
265 | }}
266 | />
267 |
273 | {aggregate && (
274 |
277 | selected[datumValue(datum, config.primaryKey)])}
278 | dataProps={dataProps}
279 | onClose={() => setAggregate(false)}
280 | />
281 | )}
282 |
283 | )}
284 |
285 | {/* table proper */}
286 |
287 | {
293 | if (select) {
294 | const nextSelected = JSON.parse(JSON.stringify(selected))
295 | const value = datumValue(datum, config.primaryKey)
296 | if (nextSelected[value]) {
297 | delete nextSelected[value]
298 | } else {
299 | nextSelected[value] = selectedRowStyle
300 | }
301 | setSelected(nextSelected)
302 | } else {
303 | setDatum(datum)
304 | }
305 | }}
306 | />
307 |
308 |
309 |
310 | )}
311 |
312 |
313 | {datum && }
314 |
315 | {edit && dataProps.length > 0 && (
316 |
317 | )}
318 |
319 | ))}
320 |
321 | )
322 | }
323 |
324 | export default App
325 |
--------------------------------------------------------------------------------