├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── TODO.md ├── package-lock.json ├── package.json └── src ├── OData.js ├── OData.test.js └── utils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"], 3 | "plugins": ["transform-react-jsx", "transform-object-rest-spread", "transform-class-properties"] 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | dist 12 | 13 | # misc 14 | .DS_Store 15 | .env 16 | npm-debug.log 17 | yarn-error.log -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-present, Sean Lynch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-odata 2 | 3 | React component to declaratively fetch from OData v4 endpoints 4 | 5 | ## Install 6 | ``` 7 | yarn add react-odata 8 | ``` 9 | or 10 | ``` 11 | npm install --save react-odata 12 | ``` 13 | 14 | ## Usage 15 | ### Import 16 | ```js 17 | import OData from 'react-odata'; 18 | ``` 19 | 20 | ### Basic 21 | ```js 22 | const baseUrl = 'http://services.odata.org/V4/TripPinService/People'; 23 | const query = { filter: { FirstName: 'Russell' } }; 24 | 25 | 26 | { ({ loading, error, data }) => ( 27 |
28 | { loading && {/* handle loading here */} } 29 | { error && {/* handle error here */} } 30 | { data && {/* handle data here */}} 31 |
32 | )} 33 |
34 | ``` 35 | - See [odata-query](https://github.com/techniq/odata-query) for supported `query` syntax 36 | 37 | ### Passes remaining props to underlying `` component 38 | ```js 39 | 40 | ``` 41 | - See [react-fetch-component](https://github.com/techniq/react-fetch-component) for additional props 42 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## Features 2 | - Use PropTypes and/or Flow 3 | 4 | ## Other 5 | - Add tests 6 | - How to deploy (`npm install -g np; np --no-check`) 7 | - Example integrating with downshift / react-autosuggest 8 | - Example wrapping component per endpoint 9 | - Setup UMD build 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-odata", 3 | "version": "11.0.0-1", 4 | "author": "Sean Lynch ", 5 | "license": "MIT", 6 | "repository": "techniq/react-odata", 7 | "files": [ 8 | "dist" 9 | ], 10 | "main": "dist/OData.js", 11 | "devDependencies": { 12 | "babel-cli": "^6.26.0", 13 | "babel-jest": "^23.0.1", 14 | "babel-plugin-transform-class-properties": "^6.24.1", 15 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 16 | "babel-plugin-transform-react-jsx": "^6.24.1", 17 | "babel-preset-env": "^1.7.0", 18 | "fetch-mock": "^6.4.4", 19 | "jest": "^23.1.0", 20 | "node-fetch": "^2.3.0", 21 | "odata-query": "^5.4.0", 22 | "react": "^16.7.0-alpha.2", 23 | "react-dom": "^16.7.0-alpha.2", 24 | "react-fetch-component": "^8.0.0-5", 25 | "react-testing-library": "^3.1.7", 26 | "rimraf": "^2.6.2" 27 | }, 28 | "peerDependencies": { 29 | "odata-query": "^5.0.0", 30 | "react": "^16.7.0-alpha.2", 31 | "react-dom": "^16.7.0-alpha.2", 32 | "react-fetch-component": "^8.0.0-5" 33 | }, 34 | "scripts": { 35 | "test": "jest", 36 | "test-watch": "jest --watch", 37 | "clean": "rimraf dist", 38 | "prebuild": "npm run clean -s", 39 | "build": "NODE_ENV=production babel src -d dist --ignore test.js", 40 | "preversion": "npm run build" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/OData.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Fetch, { useFetch, FetchContext } from 'react-fetch-component'; 3 | import buildQuery from 'odata-query'; 4 | 5 | import { isFunction } from './utils'; 6 | 7 | function buildUrl(baseUrl, query) { 8 | return query !== false && baseUrl + buildQuery(query); 9 | } 10 | 11 | function useOData({ baseUrl, defaultQuery, query, ...props }) { 12 | const [state, setState] = useState({ 13 | query: defaultQuery 14 | }); 15 | state.setQuery = (updater, cb) => 16 | setState( 17 | prevState => ({ 18 | query: { 19 | ...prevState.query, 20 | ...(updater === 'function' ? updater(prevState) : updater) 21 | } 22 | }), 23 | cb 24 | ); 25 | 26 | const fetchState = useFetch({ 27 | url: query !== false && buildUrl(baseUrl, { ...query, ...state.query }), 28 | fetchFunction: (url, options, updateOptions) => { 29 | url = typeof url === 'string' ? url : buildUrl(baseUrl, url); 30 | return fetch(url, options, updateOptions); 31 | }, 32 | ...props 33 | }); 34 | 35 | return { ...fetchState, ...state }; 36 | } 37 | 38 | const OData = ({ children, ...props }) => { 39 | const state = useOData(props); 40 | return ( 41 | 42 | {isFunction(children) ? ( 43 | {children} 44 | ) : ( 45 | children 46 | )} 47 | 48 | ); 49 | }; 50 | OData.Consumer = FetchContext.Consumer; 51 | 52 | export { useOData, buildQuery }; 53 | export default OData; 54 | -------------------------------------------------------------------------------- /src/OData.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, wait, Simulate } from 'react-testing-library'; 3 | import fetchMock from 'fetch-mock'; 4 | 5 | import OData from './OData'; 6 | 7 | afterEach(fetchMock.restore); 8 | 9 | it('fetches all if query is not set', async () => { 10 | const data = { hello: 'world' }; 11 | fetchMock.once('*', data); 12 | 13 | const mockHandler = jest.fn(); 14 | mockHandler.mockReturnValue(
); 15 | 16 | const {} = render({mockHandler}); 17 | 18 | await wait(() => expect(mockHandler.mock.calls.length).toBe(3)); 19 | 20 | // Initial state 21 | expect(mockHandler.mock.calls[0][0]).toMatchObject({ loading: true }); 22 | 23 | // Loading... 24 | expect(mockHandler.mock.calls[1][0]).toMatchObject({ 25 | loading: true, 26 | request: {} 27 | }); 28 | 29 | // Data loaded 30 | expect(mockHandler.mock.calls[2][0]).toMatchObject({ 31 | loading: false, 32 | data, 33 | request: {}, 34 | response: {} 35 | }); 36 | 37 | expect(fetchMock.called('*')).toBe(true); 38 | }); 39 | 40 | it('does not fetch if query is false', async () => { 41 | const data = { hello: 'world' }; 42 | fetchMock.once('*', data); 43 | 44 | const mockHandler = jest.fn(); 45 | mockHandler.mockReturnValue(
); 46 | 47 | const {} = render( 48 | 49 | {mockHandler} 50 | 51 | ); 52 | 53 | await wait(() => expect(mockHandler.mock.calls.length).toBe(1)); 54 | 55 | // Initial state 56 | expect(mockHandler.mock.calls[0][0]).toMatchObject({ loading: true }); 57 | 58 | expect(fetchMock.called('*')).toBe(false); 59 | }); 60 | 61 | it('does not re-fetch if `query` changed to false', async () => { 62 | const url = 'http://localhost?$top=10'; 63 | const data = { name: 'foo' }; 64 | fetchMock.get(url, data); 65 | 66 | let savedProps = null; 67 | 68 | const mockChildren = jest.fn(props => { 69 | savedProps = props; 70 | return
; 71 | }); 72 | 73 | const { rerender } = render( 74 | 75 | {mockChildren} 76 | 77 | ); 78 | await wait(() => expect(mockChildren.mock.calls.length).toBe(3)); 79 | 80 | expect(fetchMock.called(url)).toBe(true); 81 | expect(mockChildren.mock.calls[0][0]).toMatchObject({ 82 | loading: true, 83 | request: {} 84 | }); 85 | expect(mockChildren.mock.calls[1][0]).toMatchObject({ 86 | loading: true, 87 | request: {} 88 | }); 89 | expect(mockChildren.mock.calls[2][0]).toMatchObject({ 90 | loading: false, 91 | data, 92 | request: {}, 93 | response: {} 94 | }); 95 | 96 | rerender( 97 | 98 | {mockChildren} 99 | 100 | ); 101 | await wait(() => expect(mockChildren.mock.calls.length).toBe(4)); 102 | 103 | expect(fetchMock.calls(url).length).toBe(1); 104 | }); 105 | 106 | it('supports manually fetching data when "manual" prop set and "fetch" is called', async () => { 107 | const url = 'http://localhost'; 108 | const data = { hello: 'world' }; 109 | fetchMock.once(url, data); 110 | 111 | let savedProps = null; 112 | 113 | const mockChildren = jest.fn(props => { 114 | savedProps = props; 115 | return
; 116 | }); 117 | 118 | const {} = render( 119 | 120 | {mockChildren} 121 | 122 | ); 123 | 124 | expect(fetchMock.called(url)).toBe(false); 125 | await wait(() => expect(mockChildren.mock.calls.length).toBe(1)); // initial state 126 | expect(mockChildren.mock.calls[0][0]).toMatchObject({ 127 | loading: null, 128 | request: {} 129 | }); 130 | 131 | savedProps.fetch(); 132 | 133 | expect(fetchMock.called(url)).toBe(true); 134 | await wait(() => expect(mockChildren.mock.calls.length).toBe(3)); // loading / data 135 | expect(mockChildren.mock.calls[1][0]).toMatchObject({ 136 | loading: true, 137 | request: {} 138 | }); 139 | expect(mockChildren.mock.calls[2][0]).toMatchObject({ 140 | loading: false, 141 | data, 142 | request: {}, 143 | response: {} 144 | }); 145 | }); 146 | 147 | it('passes original query props if "fetch" function does not provide different', async () => { 148 | const url = 'http://localhost?$top=10'; 149 | const data = { name: 'foo' }; 150 | fetchMock.get(url, data); 151 | 152 | let savedProps = null; 153 | 154 | const mockChildren = jest.fn(props => { 155 | savedProps = props; 156 | return
; 157 | }); 158 | 159 | const {} = render( 160 | 161 | {mockChildren} 162 | 163 | ); 164 | 165 | expect(fetchMock.called(url)).toBe(false); 166 | await wait(() => expect(mockChildren.mock.calls.length).toBe(1)); // initial loading 167 | expect(mockChildren.mock.calls[0][0]).toMatchObject({ 168 | loading: null, 169 | request: {} 170 | }); 171 | 172 | savedProps.fetch(); 173 | 174 | expect(fetchMock.called(url)).toBe(true); 175 | await wait(() => expect(mockChildren.mock.calls.length).toBe(3)); // loading / data 176 | expect(mockChildren.mock.calls[1][0]).toMatchObject({ 177 | loading: true, 178 | request: {} 179 | }); 180 | expect(mockChildren.mock.calls[2][0]).toMatchObject({ 181 | loading: false, 182 | data, 183 | request: {}, 184 | response: {} 185 | }); 186 | }); 187 | 188 | it('supports passing query props to "fetch" function', async () => { 189 | const url1 = 'http://localhost?$top=10'; 190 | const data1 = { name: 'foo' }; 191 | fetchMock.get(url1, data1); 192 | const url2 = 'http://localhost?$top=10&$skip=10'; 193 | const data2 = { name: 'bar' }; 194 | fetchMock.get(url2, data2); 195 | 196 | let savedProps = null; 197 | 198 | const mockChildren = jest.fn(props => { 199 | savedProps = props; 200 | return
; 201 | }); 202 | 203 | const {} = render( 204 | 205 | {mockChildren} 206 | 207 | ); 208 | 209 | await wait(() => expect(mockChildren.mock.calls.length).toBe(3)); 210 | expect(fetchMock.called(url1)).toBe(true); 211 | expect(fetchMock.called(url2)).toBe(false); 212 | expect(mockChildren.mock.calls[0][0]).toMatchObject({ 213 | loading: true, 214 | request: {} 215 | }); 216 | expect(mockChildren.mock.calls[1][0]).toMatchObject({ 217 | loading: true, 218 | request: {} 219 | }); 220 | expect(mockChildren.mock.calls[2][0]).toMatchObject({ 221 | loading: false, 222 | data: data1, 223 | request: {}, 224 | response: {} 225 | }); 226 | 227 | savedProps.fetch({ top: 10, skip: 10 }); 228 | 229 | expect(fetchMock.called(url2)).toBe(true); 230 | 231 | await wait(() => expect(mockChildren.mock.calls.length).toBe(5)); 232 | expect(mockChildren.mock.calls[3][0]).toMatchObject({ 233 | loading: true, 234 | request: {} 235 | }); 236 | expect(mockChildren.mock.calls[4][0]).toMatchObject({ 237 | loading: false, 238 | data: data2, 239 | request: {}, 240 | response: {} 241 | }); 242 | }); 243 | 244 | it('does not re-fetch if `query` does not change and component is re-rendered', async () => { 245 | const url = 'http://localhost?$top=10'; 246 | const data = { name: 'foo' }; 247 | fetchMock.get(url, data); 248 | 249 | let savedProps = null; 250 | 251 | const mockChildren = jest.fn(props => { 252 | savedProps = props; 253 | return
; 254 | }); 255 | 256 | const { rerender } = render( 257 | 258 | {mockChildren} 259 | 260 | ); 261 | 262 | await wait(() => expect(mockChildren.mock.calls.length).toBe(3)); 263 | expect(fetchMock.called(url)).toBe(true); 264 | expect(mockChildren.mock.calls[0][0]).toMatchObject({ 265 | loading: true, 266 | request: {} 267 | }); 268 | expect(mockChildren.mock.calls[1][0]).toMatchObject({ 269 | loading: true, 270 | request: {} 271 | }); 272 | expect(mockChildren.mock.calls[2][0]).toMatchObject({ 273 | loading: false, 274 | data, 275 | request: {}, 276 | response: {} 277 | }); 278 | 279 | rerender( 280 | 281 | {mockChildren} 282 | 283 | ); 284 | expect(mockChildren.mock.calls.length).toBe(4); 285 | 286 | expect(fetchMock.calls(url).length).toBe(1); 287 | }); 288 | 289 | it('supports passing `defaultQuery` prop', async () => { 290 | const url = 'http://localhost?$top=10'; 291 | const data = { name: 'foo' }; 292 | fetchMock.get(url, data); 293 | 294 | let savedProps = null; 295 | 296 | const mockChildren = jest.fn(props => { 297 | savedProps = props; 298 | return
; 299 | }); 300 | 301 | const {} = render( 302 | 303 | {mockChildren} 304 | 305 | ); 306 | 307 | await wait(() => expect(mockChildren.mock.calls.length).toBe(3)); // initial, loading, data 308 | expect(fetchMock.called(url)).toBe(true); 309 | expect(mockChildren.mock.calls[0][0]).toMatchObject({ 310 | loading: true, 311 | request: {} 312 | }); 313 | expect(mockChildren.mock.calls[1][0]).toMatchObject({ 314 | loading: true, 315 | request: {} 316 | }); 317 | expect(mockChildren.mock.calls[2][0]).toMatchObject({ 318 | loading: false, 319 | data, 320 | request: {}, 321 | response: {} 322 | }); 323 | }); 324 | 325 | it('supports updating query via context', async () => { 326 | const url1 = 'http://localhost?$top=10'; 327 | const data1 = { name: 'foo' }; 328 | fetchMock.get(url1, data1); 329 | const url2 = 'http://localhost?$top=10&$skip=10'; 330 | const data2 = { name: 'bar' }; 331 | fetchMock.get(url2, data2); 332 | 333 | let savedProps = null; 334 | 335 | const mockChildren = jest.fn(props => { 336 | savedProps = props; 337 | return ( 338 |
339 | 340 | {({ setQuery }) => ( 341 | 342 | )} 343 | 344 |
345 | ); 346 | }); 347 | 348 | const { getByText } = render( 349 | 350 | {mockChildren} 351 | 352 | ); 353 | 354 | await wait(() => expect(mockChildren.mock.calls.length).toBe(3)); 355 | expect(fetchMock.called(url1)).toBe(true); 356 | expect(fetchMock.called(url2)).toBe(false); 357 | expect(mockChildren.mock.calls[0][0]).toMatchObject({ 358 | loading: true, 359 | request: {} 360 | }); 361 | expect(mockChildren.mock.calls[1][0]).toMatchObject({ 362 | loading: true, 363 | request: {} 364 | }); 365 | expect(mockChildren.mock.calls[2][0]).toMatchObject({ 366 | loading: false, 367 | data: data1, 368 | request: {}, 369 | response: {} 370 | }); 371 | 372 | Simulate.click(getByText('Click me')); 373 | 374 | await wait(() => expect(mockChildren.mock.calls.length).toBe(6)); 375 | expect(fetchMock.called(url2)).toBe(true); 376 | 377 | expect(mockChildren.mock.calls[3][0]).toMatchObject({ 378 | loading: false 379 | }); 380 | expect(mockChildren.mock.calls[4][0]).toMatchObject({ 381 | loading: true, 382 | request: {} 383 | }); 384 | expect(mockChildren.mock.calls[5][0]).toMatchObject({ 385 | loading: false, 386 | data: data2, 387 | request: {}, 388 | response: {} 389 | }); 390 | }); 391 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const isFunction = value => typeof value === 'function'; 2 | export const isObject = value => typeof value === 'object'; 3 | --------------------------------------------------------------------------------