├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .ncurc.json ├── .npmignore ├── .travis.yml ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.js └── src ├── .eslintrc ├── assets ├── chevron-left.svg ├── chevron-right.svg ├── inbox.svg └── more.svg ├── base ├── base.test.js └── index.js ├── column ├── column.test.js └── index.js ├── container ├── container.test.js └── index.js ├── index.d.ts ├── index.js ├── languages.js ├── pagination └── index.js ├── setupTests.js ├── style.css ├── table ├── index.js └── table.test.js ├── test-client.js └── utils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "modules": false 5 | }], 6 | "@babel/preset-react" 7 | ], 8 | "plugins": [ 9 | "@babel/plugin-proposal-function-bind", 10 | "@babel/plugin-proposal-class-properties" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | codecov: codecov/codecov@1.0.2 4 | jobs: 5 | build: 6 | working_directory: ~/feathers-react 7 | docker: 8 | - image: circleci/node:16.13.1 9 | steps: 10 | - checkout 11 | - run: 12 | name: update-npm 13 | command: 'sudo npm install -g npm@latest' 14 | - restore_cache: 15 | key: dependency-cache-{{ checksum "package.json" }} 16 | - run: 17 | name: install-npm-wee 18 | command: npm install 19 | - save_cache: 20 | key: dependency-cache-{{ checksum "package.json" }} 21 | paths: 22 | - ./node_modules 23 | - run: 24 | name: test 25 | command: npm test 26 | - store_artifacts: 27 | path: coverage 28 | prefix: coverage 29 | - codecov/upload: 30 | file: $HOME/feathers-react/coverage/lcov.info 31 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | jest: true 6 | }, 7 | extends: [ 8 | 'plugin:react/recommended', 9 | 'semistandard' 10 | ], 11 | parser: '@babel/eslint-parser', 12 | parserOptions: { 13 | ecmaFeatures: { 14 | jsx: true 15 | }, 16 | ecmaVersion: 12, 17 | sourceType: 'module' 18 | }, 19 | plugins: [ 20 | 'react' 21 | ], 22 | rules: {} 23 | }; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | example 25 | coverage 26 | -------------------------------------------------------------------------------- /.ncurc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reject": [ 3 | "react", 4 | "react-dom" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | .babelrc 3 | .eslintrc 4 | .gitignore 5 | .travis.yml 6 | rollup.config.js 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 9 4 | - 8 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feathers-react [![CircleCI](https://circleci.com/gh/silvestreh/feathers-react.svg?style=svg)](https://circleci.com/gh/silvestreh/feathers-react) [![codecov](https://codecov.io/gh/silvestreh/feathers-react/branch/master/graph/badge.svg?token=X8yCXb8yva)](https://codecov.io/gh/silvestreh/feathers-react) 2 | 3 | > A [Feathers](https://www.feathersjs.com) real-time React component library to display data 4 | 5 | ## Install 6 | 7 | ```bash 8 | npm install --save feathers-react 9 | ``` 10 | 11 | ## Documentation 12 | 13 | `feathers-react` consists of just a handful of React Components to help you display data coming from your Feathers API in real-time. Make sure to go through the [channels docs](https://docs.feathersjs.com/api/channels.html) to set up real-time events, otherwise the components won't update _automagically_. 14 | 15 | ### Table props 16 | 17 | 18 | 19 | | Name | Description | Required | Default value | 20 | |------|-------------|----------|---------------| 21 | | `service` | The Feathers service to get data from. | Yes | `undefined` | 22 | | `query` | A [Feathers query](https://docs.feathersjs.com/api/databases/querying.html) object to run against the specified `service`. | No | `{}` | 23 | | `keyProp` | The result's property to use as `key`. | No | `'id'` | 24 | | `onRowClick` | Click event handler for a table row. The function takes two arguments: the row's data and its `index`. | No | `(row, index) => {}` | 25 | | `sortable` | A `Boolean` that determines wether a header can be clicked to sort results | No | `undefined` | 26 | | `onDataChange` | A callback `Function` that is invoked after the table's data changed | No | `undefined` | 27 | | `countTemplate` | A string to use as template for showing items count. For example, `'Showing {start} to {end} of {total}'` would render something like `Showing 1 to 10 of 25`. | No | `undefined` | 28 | | `language` | The locale name to render translated text. Supported locales are `['fr_FR', 'en_US', 'es_ES']`. | No | `'en_US'` | 29 | | `usePagination` | Determines wether to use the `` component. | No | `true` | 30 | | `paginationProps` | An `Object` to override [`rc-pagination`](https://github.com/react-component/pagination)'s props. | No | `undefined` | 31 | 32 | ### Column props 33 | 34 | 35 | 36 | | Name | Description | Required | Default value | 37 | |------|-------------|----------|---------------| 38 | | `dataSource` | The result's property to extract data from. | Only when `render` is not defined | `undefined` | 39 | | `render` | A render function that takes two arguments: the data for the column and the row's data. For example, `imageUrl => ` would render an image in the table cell. | No | `undefined` | 40 | | `title` | A string to use as the header for the column. | No | `undefined` | 41 | | `width` | The column's visual width, in pixels. | No | `undefined` | 42 | 43 | ### Example 44 | 45 | In this simple example, the `` component takes a `client` prop which is a [Feathers client](https://docs.feathersjs.com/api/authentication/client.html). 46 | 47 | ```jsx 48 | import React from 'react'; 49 | import { Column, Table } from 'feathers-react'; 50 | import 'feathers-react/style.css'; 51 | 52 | export default ({ client }) => { 53 | const service = client.service('some-service'); 54 | const query = { $sort: { name: 1 } }; 55 | 56 | return ( 57 |
58 | ( 62 | {row.name} 63 | )} /> 64 | 67 |
68 | ); 69 | }; 70 | ``` 71 | 72 | ### Container props 73 | 74 | The `` component is a generic wrapper that you can use to present data in a different format than tabular. It shares most props with the `` component, the main difference is that it doesn't take any children, but has a `renderItem` prop to render data. 75 | 76 | | Name | Description | Required | Default value | 77 | |------|-------------|----------|---------------| 78 | | `service` | The Feathers service to get data from. | Yes | `undefined` | 79 | | `query` | A [Feathers query](https://docs.feathersjs.com/api/databases/querying.html) object to run against the specified `service`. | No | `{}` | 80 | | `emptyState` | An HTMLElement, React component, or String to render when there are no results. | No | `undefined` | 81 | | `keyProp` | The result's property to use as `key`. | No | `'id'` | 82 | | `renderItem` | A render function that can return a React component. The function takes two arguments: the row's data and its `index`. | Yes | `(row, index) => ` | 83 | | `itemsWrapper` | An HTMLElement or React component that will wrap rendered children. | No | `undefined` | 84 | | `separator` | A render function to use as a separator. It takes one argument: the current result being iterated. It requires both: `itemsWrapper` and `query.$sort` to be defined. | No | `undefined` | 85 | | `countTemplate` | A string to use as template for showing items count. For example, `'Showing {start} to {end} of {total}'` would render something like `Showing 1 to 10 of 25`. | No | `undefined` | 86 | | `language` | The locale name to render translated text. Supported locales are `['fr_FR', 'en_US', 'es_ES']`. | No | `'en_US'` | 87 | | `usePagination` | Determines wether to use the `` component. | No | `false` | 88 | | `hidePaginationOnSinglePage` | Hides the pagination component when there's only one page of data | No | `undefined` | 89 | | `paginationProps` | An `Object` to override [`rc-pagination`](https://github.com/react-component/pagination)'s props. | No | `undefined` | 90 | 91 | ### Example 92 | 93 | ```jsx 94 | import React from 'react'; 95 | import { Container } from 'feathers-react'; 96 | import 'feathers-react/style.css'; 97 | import Message from './message'; 98 | 99 | export default ({ client }) => { 100 | const service = client.service('messages'); 101 | const query = { $sort: { author: 1 } }; 102 | 103 | return ( 104 | } 108 | separator={message =>

Messages by {message.author}

} 109 | renderItem={message => ( 110 | 111 | )} /> 112 | ); 113 | }; 114 | ``` 115 | 116 | ## License 117 | 118 | MIT © [Silvestre Herrera](https://github.com/silvestreh) 119 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "feathers-react", 3 | "version": "0.5.5", 4 | "description": "A feathers-aware component", 5 | "author": "silvestreh", 6 | "license": "MIT", 7 | "repository": "silvestreh/feathers-react", 8 | "main": "dist/index.js", 9 | "module": "dist/index.es.js", 10 | "jsnext:main": "dist/index.es.js", 11 | "typings": "dist/index.d.ts", 12 | "engines": { 13 | "node": ">=8", 14 | "npm": ">=5" 15 | }, 16 | "scripts": { 17 | "pretest": "npm run lint", 18 | "test": "cross-env CI=1 react-scripts test --env=jsdom --coverage", 19 | "test:watch": "react-scripts test --env=jsdom --coverage --watch", 20 | "build": "rollup -c", 21 | "start": "rollup -c -w", 22 | "prepare": "npm run build", 23 | "release:patch": "npm test && npm version patch && git push origin --tags && npm publish", 24 | "release:minor": "npm test && npm version minor && git push origin --tags && npm publish", 25 | "release:major": "npm test && npm version major && git push origin --tags && npm publish", 26 | "lint": "eslint src/ --fix" 27 | }, 28 | "jest": { 29 | "collectCoverageFrom": [ 30 | "src/**/*.js", 31 | "!src/**/test.js", 32 | "!src/setupTests.js", 33 | "!src/test-client.js", 34 | "!src/index.js", 35 | "!src/languages.js" 36 | ] 37 | }, 38 | "peerDependencies": { 39 | "prop-types": ">=15", 40 | "react": ">=16", 41 | "react-dom": ">=16" 42 | }, 43 | "semistandard": { 44 | "parser": "babel-eslint", 45 | "env": { 46 | "es2017": true, 47 | "es6": true, 48 | "jest": true 49 | } 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.19.3", 53 | "@babel/eslint-parser": "^7.19.1", 54 | "@babel/plugin-proposal-class-properties": "^7.18.6", 55 | "@babel/plugin-proposal-function-bind": "^7.18.9", 56 | "@babel/preset-react": "^7.18.6", 57 | "@faker-js/faker": "^7.5.0", 58 | "@feathersjs/client": "^4.5.15", 59 | "@svgr/rollup": "^6.3.1", 60 | "@types/react": "^18.0.21", 61 | "@types/react-dom": "^18.0.6", 62 | "cross-env": "^7.0.3", 63 | "enzyme": "^3.11.0", 64 | "enzyme-adapter-react-16": "^1.15.6", 65 | "eslint": "^8.24.0", 66 | "eslint-config-semistandard": "^17.0.0", 67 | "eslint-config-standard": "^17.0.0", 68 | "eslint-plugin-import": "^2.26.0", 69 | "eslint-plugin-n": "^15.3.0", 70 | "eslint-plugin-node": "^11.1.0", 71 | "eslint-plugin-promise": "^6.0.1", 72 | "eslint-plugin-react": "^7.31.8", 73 | "lodash.times": "^4.3.2", 74 | "react": ">=16", 75 | "react-dom": ">=16", 76 | "react-scripts": "^5.0.1", 77 | "rollup": "^2.79.1", 78 | "rollup-plugin-babel": "^4.4.0", 79 | "rollup-plugin-commonjs": "^10.1.0", 80 | "rollup-plugin-copy": "^3.4.0", 81 | "rollup-plugin-filesize": "^9.1.2", 82 | "rollup-plugin-node-resolve": "^5.2.0", 83 | "rollup-plugin-peer-deps-external": "^2.2.4", 84 | "rollup-plugin-postcss": "^4.0.2", 85 | "rollup-plugin-terser": "^7.0.2", 86 | "rollup-plugin-url": "^3.0.1" 87 | }, 88 | "files": [ 89 | "dist" 90 | ], 91 | "dependencies": { 92 | "lodash.isequal": "^4.5.0", 93 | "lodash.omit": "^4.5.0", 94 | "rc-pagination": "^3.1.17", 95 | "sift": "^16.0.0" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import external from 'rollup-plugin-peer-deps-external'; 4 | import postcss from 'rollup-plugin-postcss'; 5 | import resolve from 'rollup-plugin-node-resolve'; 6 | import url from 'rollup-plugin-url'; 7 | import svgr from '@svgr/rollup'; 8 | import copy from 'rollup-plugin-copy'; 9 | import { terser } from 'rollup-plugin-terser'; 10 | import filesize from 'rollup-plugin-filesize'; 11 | 12 | import pkg from './package.json'; 13 | 14 | export default { 15 | input: 'src/index.js', 16 | output: [ 17 | { 18 | file: pkg.main, 19 | format: 'cjs', 20 | sourcemap: true, 21 | exports: 'named' 22 | }, 23 | { 24 | file: pkg.module, 25 | format: 'es', 26 | sourcemap: true, 27 | exports: 'named' 28 | } 29 | ], 30 | plugins: [ 31 | external(), 32 | postcss({ 33 | modules: true 34 | }), 35 | url(), 36 | svgr(), 37 | babel({ exclude: 'node_modules/**' }), 38 | resolve(), 39 | commonjs(), 40 | terser(), 41 | filesize(), 42 | copy({ 43 | targets: [ 44 | { src: 'src/style.css', dest: 'dist/' }, 45 | { src: 'src/index.d.ts', dest: 'dist/' } 46 | ] 47 | }) 48 | ] 49 | }; 50 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/chevron-left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/chevron-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/inbox.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/more.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/base/base.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import Component from '.'; 4 | import app from '../test-client'; 5 | 6 | const findById = (data, id) => data.find(message => message.id === id); 7 | const findByText = (data, text) => data.find(message => message.text === text); 8 | 9 | describe(' Component', () => { 10 | let service, wrapper, instance; 11 | 12 | beforeAll(done => { 13 | const query = { 14 | $search: 'Custom operator is ignored' 15 | }; 16 | service = app.service('messages'); 17 | wrapper = mount( 18 | 19 | ); 20 | instance = wrapper.instance(); 21 | 22 | instance.find() 23 | .then(() => { 24 | wrapper.update(); 25 | done(); 26 | }); 27 | }); 28 | 29 | it('handles real-time events', async () => { 30 | // Document removal 31 | expect(findById(wrapper.state().data, 'id-to-remove')).toBeTruthy(); 32 | await service.remove('id-to-remove'); 33 | wrapper.update(); 34 | expect(findById(wrapper.state().data, 'id-to-remove')).toBeFalsy(); 35 | 36 | // Document patching 37 | expect(findById(wrapper.state().data, 'id-to-patch').text).toBe('Patched message'); 38 | await service.patch('id-to-patch', { text: 'feathers-react rocks!' }); 39 | wrapper.update(); 40 | expect(findById(wrapper.state().data, 'id-to-patch').text).toBe('feathers-react rocks!'); 41 | 42 | // Document creation 43 | expect(findByText(wrapper.state().data, 'real-time woo!')).toBeFalsy(); 44 | await service.create({ text: 'real-time woo!' }); 45 | wrapper.update(); 46 | expect(findByText(wrapper.state().data, 'real-time woo!').text).toBeTruthy(); 47 | }); 48 | 49 | it('a patched record not matching the query anymore is dropped (and vice versa)', async () => { 50 | const query = { author: 'Silvestre' }; 51 | const wrapper = mount( 52 | 53 | ); 54 | await wrapper.instance().find(); 55 | const firstMessage = wrapper.state().data[0]; 56 | expect(findById(wrapper.state().data, firstMessage.id)).toBeTruthy(); 57 | await service.patch(firstMessage.id, { author: 'Someone Else' }); 58 | wrapper.update(); 59 | expect(findById(wrapper.state().data, firstMessage.id)).toBeFalsy(); 60 | await service.patch(firstMessage.id, { author: 'Silvestre' }); 61 | wrapper.update(); 62 | expect(findById(wrapper.state().data, firstMessage.id)).toBeTruthy(); 63 | }); 64 | 65 | it('can go wrong', async () => { 66 | service.find = jest.fn().mockImplementation(() => Promise.reject(new Error('Nope'))); 67 | expect(instance.find()).rejects.toThrow('Nope'); 68 | }); 69 | 70 | it('removes listeners on unmount', () => { 71 | const spy = jest.spyOn(instance.props.service, 'removeListener'); 72 | instance.componentWillUnmount(); 73 | expect(spy).toHaveBeenCalled(); 74 | }); 75 | 76 | it('calls a callback after fetching data', async () => { 77 | const spy = jest.fn(); 78 | const wrapper = mount(); 79 | await wrapper.instance().find(); 80 | expect(spy).toHaveBeenCalled(); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/base/index.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import isEqual from 'lodash.isequal'; 3 | import omit from 'lodash.omit'; 4 | import sift from 'sift'; 5 | import PropTypes from 'prop-types'; 6 | import { allowedOperators } from '../utils'; 7 | 8 | class FeathersReact extends Component { 9 | static propTypes = { 10 | keyProp: PropTypes.string, 11 | language: PropTypes.oneOf([ 12 | 'en_US', 13 | 'es_ES', 14 | 'fr_FR' 15 | ]), 16 | onDataChange: PropTypes.func, 17 | query: PropTypes.object, 18 | service: PropTypes.object.isRequired 19 | }; 20 | 21 | static defaultProps = { 22 | keyProp: 'id', 23 | language: 'en_US', 24 | query: {} 25 | }; 26 | 27 | state = { 28 | error: null, 29 | data: [], 30 | isLoading: false, 31 | pagination: null, 32 | $skip: 0, 33 | $sort: (this.props.query && this.props.query.$sort) || {} 34 | }; 35 | 36 | find = async () => { 37 | this.setState({ error: null, isLoading: true }); 38 | 39 | try { 40 | const { query, service } = this.props; 41 | const { $skip, $sort } = this.state; 42 | const q = { ...query, $skip, $sort }; 43 | const response = await service.find({ query: q }); 44 | const pagination = { 45 | current: response?.skip / response?.limit + 1, 46 | pageSize: response?.limit, 47 | total: response?.total 48 | }; 49 | 50 | if (typeof this.props.onDataChange === 'function') { 51 | this.props.onDataChange(response); 52 | } 53 | 54 | /* istanbul ignore next */ 55 | if (Array.isArray(response)) { 56 | return this.setState({ data: response, isLoading: false }); 57 | } 58 | 59 | return this.setState({ 60 | data: response?.data, 61 | isLoading: false, 62 | pagination 63 | }); 64 | } catch (error) { 65 | this.setState({ error, isLoading: false }); 66 | throw error; 67 | } 68 | }; 69 | 70 | handlePageChange = page => { 71 | const $skip = (page - 1) * this.state.pagination.pageSize; 72 | this.setState({ $skip }); 73 | }; 74 | 75 | isRecordInData = record => { 76 | const { data } = this.state; 77 | const { keyProp } = this.props; 78 | const index = data.findIndex(r => r[keyProp] === record[keyProp]); 79 | return { index, isInData: index >= 0 }; 80 | }; 81 | 82 | recordMatchesQuery = record => { 83 | const { query } = this.props; 84 | const keys = Object.keys(query) 85 | .filter(key => ( 86 | key.includes('$') && !allowedOperators.includes(key) 87 | )); 88 | const filter = sift(omit(query, ...keys)); 89 | 90 | return filter(record); 91 | }; 92 | 93 | handlePatch = updated => { 94 | const { data } = this.state; 95 | const shouldUpdate = this.isRecordInData(updated); 96 | 97 | if (shouldUpdate.isInData) { 98 | if (this.recordMatchesQuery(updated)) { 99 | data[shouldUpdate.index] = updated; 100 | this.setState({ data }); 101 | } else { 102 | this.handleRemove(updated); 103 | } 104 | } else { 105 | this.handleCreate(updated); 106 | } 107 | }; 108 | 109 | handleRemove = async removed => { 110 | const { service, query } = this.props; 111 | const { data, pagination } = this.state; 112 | const shouldRemove = this.isRecordInData(removed); 113 | let p = null; 114 | 115 | if (shouldRemove.isInData) { 116 | data.splice(shouldRemove.index, 1); 117 | 118 | /* istanbul ignore next */ 119 | if (!data.length && pagination && pagination.current > 1) { 120 | return this.handlePageChange(pagination.current - 1); 121 | } 122 | } 123 | 124 | if (shouldRemove.isInData && pagination.total > pagination.pageSize) { 125 | const $skip = (pagination.pageSize * pagination.current) - 1; 126 | const q = { ...query, $skip, $limit: 1 }; 127 | const response = await service.find({ query: q }); 128 | const nextItem = Array.isArray(response) ? response[0] : response.data[0]; 129 | 130 | if (nextItem) { 131 | data.push(nextItem); 132 | } 133 | } 134 | 135 | if (pagination) { 136 | p = { 137 | ...pagination, 138 | total: pagination.total - 1 139 | }; 140 | } 141 | 142 | this.setState({ data, pagination: p }); 143 | }; 144 | 145 | handleCreate = created => { 146 | const { data, pagination } = this.state; 147 | const shouldUpdate = this.isRecordInData(created); 148 | let p = null; 149 | 150 | if (this.recordMatchesQuery(created) && !shouldUpdate.isInData) { 151 | data.unshift(created); 152 | 153 | if (data.length > pagination.pageSize) { 154 | data.pop(); 155 | } 156 | } 157 | 158 | if (pagination) { 159 | p = { 160 | ...pagination, 161 | total: pagination.total + 1 162 | }; 163 | } 164 | 165 | this.setState({ data, pagination: p }); 166 | }; 167 | 168 | componentDidUpdate (prevProps, prevState) { 169 | const shouldFind = ( 170 | this.state.$skip !== prevState.$skip || 171 | !isEqual(this.props.query, prevProps.query) 172 | ); 173 | 174 | if (shouldFind) { 175 | this.find(); 176 | } 177 | } 178 | 179 | componentDidMount () { 180 | const { service } = this.props; 181 | 182 | service.on('patched', this.handlePatch); 183 | service.on('updated', this.handlePatch); 184 | service.on('removed', this.handleRemove); 185 | service.on('created', this.handleCreate); 186 | this.find(); 187 | } 188 | 189 | componentWillUnmount () { 190 | const { service } = this.props; 191 | 192 | service.removeListener('patched', this.handlePatch); 193 | service.removeListener('updated', this.handlePatch); 194 | service.removeListener('removed', this.handleRemove); 195 | service.removeListener('created', this.handleCreate); 196 | } 197 | 198 | render () { 199 | return null; 200 | } 201 | } 202 | 203 | export default FeathersReact; 204 | -------------------------------------------------------------------------------- /src/column/column.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Component from '.'; 4 | 5 | describe('', () => { 6 | it('displays data from row', () => { 7 | const wrapper = shallow( 8 | 12 | ); 13 | 14 | expect(wrapper.text()).toBe('Testing'); 15 | }); 16 | 17 | it('uses a custom render prop', () => { 18 | const url = 'http://path.com/to/image.png'; 19 | const wrapper = shallow( 20 | } 24 | /> 25 | ); 26 | 27 | expect(wrapper.find('img')).toHaveLength(1); 28 | expect(wrapper.find('img').props().src).toBe(url); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/column/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Column = ({ row, dataSource, render }) => { 5 | return ( 6 |
13 | ); 14 | }; 15 | 16 | Column.displayName = 'Column'; 17 | 18 | Column.propTypes = { 19 | dataSource: function (props, propName, componentName) { 20 | /* istanbul ignore next */ 21 | if (typeof props.render !== 'function' && typeof props[propName] !== 'string') { 22 | return new Error( 23 | 'Invalid ' + propName + ' supplied to ' + componentName + 24 | '. ' + propName + ' is required, unless a render prop is supplied.' 25 | ); 26 | } 27 | }, 28 | render: PropTypes.func, 29 | row: PropTypes.object, 30 | title: PropTypes.string, 31 | width: PropTypes.number 32 | }; 33 | 34 | export default Column; 35 | -------------------------------------------------------------------------------- /src/container/container.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import Component from '.'; 4 | import app from '../test-client'; 5 | 6 | describe(' Component', () => { 7 | let service, wrapper, instance; 8 | 9 | beforeAll(() => { 10 | const query = { $sort: { text: 1 } }; 11 | service = app.service('messages'); 12 | wrapper = mount( 13 |

{record.text}

} 19 | /> 20 | ); 21 | instance = wrapper.instance(); 22 | }); 23 | 24 | it('renders data using a render function', async () => { 25 | expect(wrapper.find('p')).toHaveLength(0); 26 | await instance.find(); 27 | wrapper.update(); 28 | expect(wrapper.find('p')).toHaveLength(10); 29 | }); 30 | 31 | it('counts documents in the response', async () => { 32 | const template = 'Showing {start} to {end} of {total}'; 33 | const wrapper = mount( 34 |

{record.text}

} 39 | /> 40 | ); 41 | await wrapper.instance().find(); 42 | wrapper.update(); 43 | expect(wrapper.find('.rc-pagination-total-text').text()).toBe('Showing 1 to 10 of 15'); 44 | }); 45 | 46 | it('supports having a wrapper', async () => { 47 | const wrapper = mount( 48 | } 50 | service={service} 51 | renderItem={(record, i) =>

{record.text}

} 52 | /> 53 | ); 54 | await wrapper.instance().find(); 55 | wrapper.update(); 56 | const itemsWrapper = wrapper.find('.wrapper-div'); 57 | expect(itemsWrapper).toHaveLength(1); 58 | expect(itemsWrapper.props().children).toHaveLength(10); 59 | }); 60 | 61 | it('can group results based on a $sort query', async () => { 62 | const wrapper = mount( 63 | } 65 | service={service} 66 | query={{ $sort: { author: 1 } }} 67 | separator={record =>

{record.author}

} 68 | renderItem={(record, i) =>

{record.text}

} 69 | /> 70 | ); 71 | await wrapper.instance().find(); 72 | wrapper.update(); 73 | expect(wrapper.find('h1')).toHaveLength(2); 74 | expect(wrapper.find({ children: 'Silvestre' })).toHaveLength(1); 75 | expect(wrapper.find({ children: 'Feathers' })).toHaveLength(1); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/container/index.js: -------------------------------------------------------------------------------- 1 | import React, { cloneElement } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Base from '../base'; 4 | import Pagination from '../pagination'; 5 | 6 | class FeathersContainer extends Base { 7 | static propTypes = { 8 | emptyState: PropTypes.node, 9 | hidePaginationOnSinglePage: PropTypes.bool, 10 | itemsWrapper: PropTypes.node, 11 | paginationProps: PropTypes.object, 12 | query: PropTypes.object, 13 | renderItem: PropTypes.func.isRequired, 14 | separator: PropTypes.func, 15 | usePagination: PropTypes.bool 16 | }; 17 | 18 | static defaultProps = { 19 | paginationProps: {} 20 | }; 21 | 22 | render () { 23 | const { 24 | countTemplate, 25 | emptyState, 26 | hidePaginationOnSinglePage, 27 | itemsWrapper, 28 | language, 29 | paginationProps, 30 | query, 31 | renderItem, 32 | separator, 33 | usePagination 34 | } = this.props; 35 | const sections = []; 36 | const { data, pagination } = this.state; 37 | const shouldShowPagination = hidePaginationOnSinglePage && pagination 38 | ? data.length >= pagination.pageSize 39 | : !!data.length; 40 | 41 | if (!data.length) return emptyState || null; 42 | 43 | /* istanbul ignore next */ 44 | if ((separator && !query.$sort) || (separator && !itemsWrapper)) { 45 | console.warn('[feathers-react]: \'separator\' prop requires both: a \'$sort\' property in your query, and the \'itemsWrapper\' prop to be defined'); 46 | return null; 47 | } 48 | 49 | if (separator && query.$sort) { 50 | const sortProp = Object.keys(query.$sort)[0]; 51 | 52 | data.forEach((item, index) => { 53 | const prevItem = data[index - 1]; 54 | 55 | if (index === 0) { 56 | sections.push(separator(item)); 57 | sections.push(cloneElement(itemsWrapper, { children: data.filter(i => i[sortProp] === item[sortProp]).map(renderItem) })); 58 | } else if (prevItem && item[sortProp] !== prevItem[sortProp]) { 59 | sections.push(separator(item)); 60 | sections.push(cloneElement(itemsWrapper, { children: data.filter(i => i[sortProp] === item[sortProp]).map(renderItem) })); 61 | } 62 | }); 63 | } 64 | 65 | return ( 66 | <> 67 | {itemsWrapper && separator && sections.map((section, index) => cloneElement(section, { key: index }))} 68 | {itemsWrapper && !separator && cloneElement(itemsWrapper, { children: data.map(renderItem) })} 69 | {!itemsWrapper && data.map(renderItem)} 70 | {usePagination && shouldShowPagination && 71 | { 75 | if (!countTemplate) return false; 76 | return countTemplate 77 | .replace('{start}', range[0]) 78 | .replace('{end}', range[1]) 79 | .replace('{total}', total); 80 | }} 81 | {...paginationProps} 82 | {...pagination} 83 | />} 84 | 85 | ); 86 | } 87 | } 88 | 89 | export default FeathersContainer; 90 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'feathers-react' { 2 | import React from 'react'; 3 | 4 | export interface PaginationProps { 5 | className?: string; 6 | current?: number; 7 | defaultCurrent?: number; 8 | defaultPageSize?: number; 9 | disabled?: boolean; 10 | hideOnSinglePage?: boolean; 11 | itemRender?: (page: number, type: 'page'|'prev'|'next'|'jump-prev'|'jump-next', originalElement: React.ReactNode) => React.ReactNode; 12 | jumpNextIcon?: React.ReactNode | ((props:PaginationProps) => React.ReactNode); 13 | jumpPrevIcon?: React.ReactNode | ((props:PaginationProps) => React.ReactNode); 14 | nextIcon?: React.ReactNode | ((props:PaginationProps) => React.ReactNode); 15 | onChange?: (current: number, pageSize?: number) => void; 16 | onShowSizeChange?: (current: number, size: number) => void; 17 | pageSize?: number; 18 | pageSizeOptions?: string[]; 19 | prevIcon?: React.ReactNode | ((props:PaginationProps) => React.ReactNode); 20 | showLessItems?: boolean; 21 | showPrevNextJumpers?: boolean; 22 | showQuickJumper?: boolean; 23 | showSizeChanger?: boolean; 24 | showTitle?: boolean; 25 | showTotal?: (total: number, range: [number, number]) => React.ReactNode; 26 | simple?: object|null; 27 | style?: React.CSSProperties; 28 | total?: number; 29 | totalBoundaryShowSizeChanger?: number; 30 | } 31 | 32 | export interface ColumnProps { 33 | dataSource?: string; 34 | render?: (value: any, record: any) => React.ReactNode; 35 | title?: string; 36 | width?: number|string; 37 | } 38 | 39 | export interface TableProps { 40 | children?: React.ReactNode; 41 | countTemplate?: string; 42 | keyProp?: string; 43 | language?: 'en_US' | 'fr_FR' | 'es_ES'; 44 | onDataChange?: (data: any) => void; 45 | onRowClick?: (record: any, index: number) => void; 46 | paginationProps?: PaginationProps; 47 | query?: object; 48 | service: any; 49 | sortable?: boolean; 50 | usePagination?: boolean; 51 | } 52 | 53 | export interface ContainerProps extends Omit { 54 | emptyState?: React.ReactNode; 55 | renderItem?: (item: any, index: number) => React.ReactNode; 56 | itemsWrapper?: React.ReactNode; 57 | separator?: (item: any) => React.ReactNode; 58 | hidePaginationOnSinglePage?: boolean; 59 | } 60 | 61 | export class Column extends React.Component {} 62 | export class Table extends React.Component {} 63 | export class Container extends React.Component {} 64 | } 65 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import FeathersReact from './base'; 2 | import C from './column'; 3 | import T from './table'; 4 | import FC from './container'; 5 | 6 | export const Column = C; 7 | export const Table = T; 8 | export const Container = FC; 9 | 10 | export default FeathersReact; 11 | -------------------------------------------------------------------------------- /src/languages.js: -------------------------------------------------------------------------------- 1 | export default { 2 | en_US: { 3 | items_per_page: '/ page', 4 | jump_to: 'Goto', 5 | jump_to_confirm: 'confirm', 6 | page: '', 7 | prev_page: 'Previous Page', 8 | next_page: 'Next Page', 9 | prev_5: 'Previous 5 Pages', 10 | next_5: 'Next 5 Pages', 11 | prev_3: 'Previous 3 Pages', 12 | next_3: 'Next 3 Pages', 13 | no_data: 'No data' 14 | }, 15 | es_ES: { 16 | items_per_page: '/ página', 17 | jump_to: 'Ir a', 18 | jump_to_confirm: 'confirmar', 19 | page: '', 20 | prev_page: 'Página anterior', 21 | next_page: 'Página siguiente', 22 | prev_5: '5 páginas previas', 23 | next_5: '5 páginas siguientes', 24 | prev_3: '3 páginas previas', 25 | next_3: '3 páginas siguientes', 26 | no_data: 'No hay datos' 27 | }, 28 | fr_FR: { 29 | items_per_page: '/ page', 30 | jump_to: 'Aller à', 31 | jump_to_confirm: 'confirmer', 32 | page: '', 33 | prev_page: 'Page précédente', 34 | next_page: 'Page suivante', 35 | prev_5: '5 Pages précédentes', 36 | next_5: '5 Pages suivantes', 37 | prev_3: '3 Pages précédentes', 38 | next_3: '3 Pages suivantes', 39 | no_data: 'Pas de données' 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/pagination/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import RCPagination from 'rc-pagination'; 3 | import PropTypes from 'prop-types'; 4 | import languages from '../languages'; 5 | import chevronLeft from '../assets/chevron-left.svg'; 6 | import chevronRight from '../assets/chevron-right.svg'; 7 | import more from '../assets/more.svg'; 8 | 9 | const Pagination = props => { 10 | const locale = languages[props.language || 'en_US']; 11 | 12 | return ( 13 | } 15 | nextIcon={{locale.next_page}} 16 | jumpPrevIcon={} 17 | jumpNextIcon={} 18 | locale={locale} 19 | {...props} 20 | /> 21 | ); 22 | }; 23 | 24 | Pagination.propTypes = { 25 | language: PropTypes.oneOf([ 26 | 'en_US', 27 | 'es_ES', 28 | 'fr_FR' 29 | ]) 30 | }; 31 | 32 | export default Pagination; 33 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .rc-pagination { 2 | align-items: center; 3 | display: flex; 4 | line-height: 1; 5 | list-style: none; 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | .rc-pagination-item { 11 | display: block; 12 | margin: 0 .25em; 13 | } 14 | 15 | .rc-pagination-item a { 16 | align-items: center; 17 | border: 1px solid rgba(0, 0, 0, .05); 18 | border-radius: .25em; 19 | color: #455A64; 20 | cursor: pointer; 21 | display: inline-flex; 22 | height: 2em; 23 | justify-content: center; 24 | outline: none; 25 | width: 2em; 26 | } 27 | 28 | .rc-pagination-item-active a { 29 | background: rgba(75, 77, 241, 1); 30 | color: white; 31 | } 32 | 33 | .rc-pagination-disabled .fr-pagination-icon { 34 | cursor: not-allowed; 35 | opacity: .25; 36 | } 37 | 38 | .rc-pagination-total-text { 39 | font-size: .85em; 40 | margin-right: auto; 41 | opacity: .5; 42 | } 43 | 44 | .rc-pagination-total-text:empty { 45 | display: none; 46 | } 47 | 48 | .fr-pagination-icon { 49 | cursor: pointer; 50 | opacity: .5; 51 | } 52 | 53 | .fr-table-wrapper { 54 | border-radius: .25em; 55 | box-shadow: 56 | 0 0.125em 0.25em rgba(0, 0, 0, 0.125), 57 | 0 1em 2em rgba(75, 77, 241, 0.1); 58 | color: #545466; 59 | overflow: hidden; 60 | position: relative; 61 | } 62 | 63 | .fr-table { 64 | border-collapse: collapse; 65 | table-layout: fixed; 66 | width: 100%; 67 | } 68 | 69 | .fr-table-head { 70 | background: #FCFCFF; 71 | border-bottom: 1px solid rgba(0, 0, 0, .025); 72 | } 73 | 74 | .fr-table-head .fr-table-content { 75 | color: rgba(0, 0, 0, .5); 76 | font-weight: 400; 77 | } 78 | 79 | .fr-table-column-sorting-button { 80 | appearance: none; 81 | background: none; 82 | border: none; 83 | } 84 | 85 | .fr-table-column-title { 86 | font-size: .85em; 87 | } 88 | 89 | .fr-table-column-sorting-indicator { 90 | align-items: center; 91 | display: inline-flex; 92 | font-size: 1.25em; 93 | height: 1em; 94 | justify-content: center; 95 | line-height: 1; 96 | transition: transform 250ms; 97 | width: 1em; 98 | } 99 | 100 | .fr-table-column-sorting-indicator span { 101 | display: inline-block; 102 | margin-top: -.25em; 103 | transform: scaleX(1.5); 104 | } 105 | 106 | .fr-table-content { 107 | font-size: .85em; 108 | line-height: 1.5; 109 | padding: 1em; 110 | text-align: left; 111 | } 112 | 113 | .fr-table-body { 114 | background: white; 115 | } 116 | 117 | .fr-table-row + .fr-table-row { 118 | border-top: 1px solid rgba(0, 0, 0, .025); 119 | } 120 | 121 | .fr-table-row-clickable { 122 | transition: background-color, 350ms; 123 | } 124 | 125 | .fr-table-row-clickable:hover { 126 | background-color: rgba(75, 77, 241, .05); 127 | cursor: pointer; 128 | } 129 | 130 | .fr-table-footer .fr-table-content { 131 | background: #FCFCFF; 132 | box-shadow: 133 | inset 0 .25em .25em -.25em rgba(0, 0, 0, .05), 134 | inset 0 1em 1em -1em rgba(75, 77, 241, .05); 135 | text-align: right; 136 | } 137 | 138 | .fr-table-no-data { 139 | padding: 2em; 140 | text-align: center; 141 | } 142 | 143 | .fr-table-no-data img { 144 | display: block; 145 | height: 3em; 146 | margin: 0 auto 1em; 147 | opacity: .5; 148 | } 149 | 150 | .fr-table-loading { 151 | align-items: center; 152 | background: rgba(255, 255, 255, .85); 153 | bottom: 0; 154 | display: flex; 155 | justify-content: center; 156 | left: 0; 157 | position: absolute; 158 | right: 0; 159 | top: 0; 160 | } 161 | 162 | .fr-table-loading::after { 163 | animation: spin .5s linear infinite; 164 | border: .125em solid rgba(75, 77, 241, 1); 165 | border-radius: 50%; 166 | border-top-color: transparent; 167 | content: ''; 168 | display: block; 169 | height: 1.5em; 170 | width: 1.5em; 171 | } 172 | 173 | @keyframes spin { 174 | from { transform: rotate(0deg); } 175 | to { transform: rotate(359deg); } 176 | } 177 | -------------------------------------------------------------------------------- /src/table/index.js: -------------------------------------------------------------------------------- 1 | import React, { Children, cloneElement } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import inbox from '../assets/inbox.svg'; 4 | import Pagination from '../pagination'; 5 | import FeathersReact from '../base'; 6 | import languages from '../languages'; 7 | 8 | class Table extends FeathersReact { 9 | static propTypes = { 10 | countTemplate: PropTypes.string, 11 | paginationProps: PropTypes.object, 12 | usePagination: PropTypes.bool 13 | }; 14 | 15 | handleRowClick = (row, index) => () => { 16 | const { onRowClick } = this.props; 17 | 18 | if (typeof onRowClick === 'function') { 19 | onRowClick(row, index); 20 | } 21 | }; 22 | 23 | handleSortClick = column => () => { 24 | const { $sort } = this.state; 25 | 26 | if (typeof $sort[column.dataSource] === 'number') { 27 | this.setState({ $sort: { [column.dataSource]: $sort[column.dataSource] > 0 ? -1 : 1 } }, this.find); 28 | } else { 29 | this.setState({ $sort: { [column.dataSource]: 1 } }, this.find); 30 | } 31 | }; 32 | 33 | getRowClassNames = () => { 34 | const { onRowClick } = this.props; 35 | let classNames = 'fr-table-row'; 36 | 37 | if (typeof onRowClick === 'function') { 38 | classNames = `${classNames} fr-table-row-clickable`; 39 | } 40 | 41 | return classNames; 42 | }; 43 | 44 | render () { 45 | const { data, isLoading, pagination, $sort } = this.state; 46 | const { 47 | children, 48 | keyProp, 49 | language, 50 | usePagination = true, 51 | countTemplate, 52 | paginationProps, 53 | sortable 54 | } = this.props; 55 | 56 | return ( 57 |
58 |
7 | { 8 | typeof render === 'function' 9 | ? render(row[dataSource], row) 10 | : row[dataSource] 11 | } 12 |
59 | 60 | 61 | {Children.map(children, (child, i) => ( 62 | child && 63 | 88 | ))} 89 | 90 | 91 | 92 | {!data.length && 93 | 94 | 98 | } 99 | {data.map((row, index) => ( 100 | 105 | {Children.map(children, child => ( 106 | child && cloneElement(child, { row, key: keyProp }) 107 | ))} 108 | 109 | ))} 110 | 111 | 112 | 113 | 129 | 130 | 131 |
68 | 87 |
95 | {languages[language].no_data} 96 | {languages[language].no_data} 97 |
child !== null).length}> 114 | {usePagination && !!data.length && 115 | { 119 | if (!countTemplate) return false; 120 | return countTemplate 121 | .replace('{start}', range[0]) 122 | .replace('{end}', range[1]) 123 | .replace('{total}', total); 124 | }} 125 | {...paginationProps} 126 | {...pagination} 127 | />} 128 |
132 | {isLoading &&
} 133 |
134 | ); 135 | } 136 | } 137 | 138 | export default Table; 139 | -------------------------------------------------------------------------------- /src/table/table.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import Component from '.'; 4 | import Column from '../column'; 5 | import app from '../test-client'; 6 | 7 | describe(' Component', () => { 8 | const onRowClick = jest.fn(); 9 | let service, wrapper, instance; 10 | 11 | beforeAll(() => { 12 | const query = { $sort: { text: 1 } }; 13 | service = app.service('messages'); 14 | wrapper = mount( 15 | 21 | 22 | 23 | 24 | ); 25 | instance = wrapper.instance(); 26 | }); 27 | 28 | it('calls service find', async () => { 29 | expect(wrapper.find('Column')).toHaveLength(0); 30 | await instance.find(); 31 | wrapper.update(); 32 | expect(wrapper.find('Column')).toHaveLength(20); 33 | }); 34 | 35 | it('works with an unpagianted response', async () => { 36 | const wrapper = mount( 37 | 38 | 39 | 40 | 41 | ); 42 | await wrapper.instance().find(); 43 | wrapper.update(); 44 | expect(wrapper.find('Column')).toHaveLength(20); 45 | }); 46 | 47 | it('can click a row', () => { 48 | const row = wrapper.find('tbody tr').first(); 49 | 50 | expect(wrapper.find('.fr-table-row-clickable')).toHaveLength(10); 51 | expect(onRowClick).not.toHaveBeenCalled(); 52 | row.simulate('click'); 53 | expect(onRowClick).toHaveBeenCalled(); 54 | }); 55 | 56 | it('handles pagination', () => { 57 | const spy = jest.spyOn(instance, 'find'); 58 | expect(spy).not.toHaveBeenCalled(); 59 | expect(wrapper.find('Pager')).toHaveLength(2); 60 | const promise = Promise.resolve(instance.handlePageChange(2)); 61 | 62 | return promise.then(() => { 63 | wrapper.update(); 64 | }).then(() => { 65 | expect(spy).toHaveBeenCalled(); 66 | }); 67 | }); 68 | 69 | it('counts documents in the response', async () => { 70 | const template = 'Showing {start} to {end} of {total}'; 71 | const wrapper = mount( 72 | 73 | 74 | 75 | 76 | ); 77 | await wrapper.instance().find(); 78 | wrapper.update(); 79 | expect(wrapper.find('.rc-pagination-total-text').text()).toBe('Showing 1 to 10 of 15'); 80 | }); 81 | 82 | it('can change sorting', () => { 83 | const sortingColumn = wrapper.find('th button').last(); 84 | const notSortingColumn = wrapper.find('th button').first(); 85 | 86 | // Text toggling ascending/descending order 87 | expect(instance.state.$sort).toMatchObject({ text: 1 }); 88 | sortingColumn.simulate('click'); 89 | wrapper.update(); 90 | expect(instance.state.$sort).toMatchObject({ text: -1 }); 91 | sortingColumn.simulate('click'); 92 | wrapper.update(); 93 | expect(instance.state.$sort).toMatchObject({ text: 1 }); 94 | 95 | // Test sorting by other column 96 | notSortingColumn.simulate('click'); 97 | wrapper.update(); 98 | expect(instance.state.$sort).toMatchObject({ id: 1 }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/test-client.js: -------------------------------------------------------------------------------- 1 | import feathers from '@feathersjs/client'; 2 | import { faker } from '@faker-js/faker'; 3 | import times from 'lodash.times'; 4 | 5 | const fakeId = () => Math.random().toString(36).substring(7); 6 | const fakeMsg = id => ({ 7 | id: typeof id === 'number' ? fakeId() : id, 8 | text: faker.lorem.sentence(), 9 | author: 'Silvestre' 10 | }); 11 | 12 | const feathersClient = () => { 13 | const app = feathers(); 14 | const data = [ 15 | ...times(8, fakeMsg), 16 | { id: 'id-to-remove', text: 'Removed message', author: 'Feathers' }, 17 | { id: 'id-to-patch', text: 'Patched message', author: 'Feathers' } 18 | ]; 19 | 20 | app.use('/messages', { 21 | create: async payload => ({ id: fakeId(), ...payload }), 22 | find: async params => { 23 | const { query } = params; 24 | 25 | return { 26 | data: query.$skip && query.$limit 27 | ? [{ 28 | id: fakeId(), 29 | text: 'Something random', 30 | author: 'Feathers' 31 | }] 32 | : query.author 33 | ? data.filter(({ author }) => author === query.author) 34 | : data, 35 | limit: query.$limit || 10, 36 | skip: query.$skip || 0, 37 | total: 15 38 | }; 39 | }, 40 | patch: async (id, payload) => ({ id, ...payload }), 41 | remove: async id => fakeMsg(id), 42 | update: async (id, payload) => ({ id, ...payload }) 43 | }); 44 | 45 | app.use('/not-paginated', { 46 | create: async payload => ({ id: fakeId(), ...payload }), 47 | find: async () => data, 48 | patch: async (id, payload) => ({ id, ...payload }), 49 | remove: async id => fakeMsg(id), 50 | update: async (id, payload) => ({ id, ...payload }) 51 | }); 52 | 53 | return app; 54 | }; 55 | 56 | export default feathersClient(); 57 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const allowedOperators = [ 2 | '$in', 3 | '$nin', 4 | '$exists', 5 | '$gte', 6 | '$gt', 7 | '$lte', 8 | '$lt', 9 | '$eq', 10 | '$ne', 11 | '$mod', 12 | '$all', 13 | '$and', 14 | '$or', 15 | '$nor', 16 | '$not', 17 | '$size', 18 | '$type', 19 | '$regex', 20 | '$where', 21 | '$elemMatch' 22 | ]; 23 | --------------------------------------------------------------------------------