├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .nycrc ├── .travis.yml ├── LICENSE ├── README.md ├── docs └── screenshots │ └── general.png ├── example ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ └── index.html └── src │ ├── App.js │ ├── index.js │ └── posts.js ├── makefile ├── mocha.opts ├── package-lock.json ├── package.json ├── src ├── index.js ├── lib │ └── custom-lodash.js └── mui │ ├── field │ ├── EmbeddedArrayField.js │ ├── EmbeddedArrayField.spec.js │ └── index.js │ ├── form │ ├── EmbeddedArrayInputFormField.js │ └── isRequired.js │ ├── index.js │ └── input │ ├── EmbeddedArrayInput.js │ ├── EmbeddedArrayInput.spec.js │ └── index.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0", 5 | "react" 6 | ], 7 | "plugins": [ 8 | [ 9 | "transform-runtime", 10 | { 11 | "polyfill": false, 12 | "regenerator": true 13 | } 14 | ] 15 | ], 16 | "env": { 17 | "test": { 18 | "plugins": [ 19 | ["istanbul", { "exclude": "**/*.spec.js" }] 20 | ] 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | example/static 4 | docs/_site/ 5 | docs/js/ 6 | docs/css/ -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "es6": true, 5 | "mocha": true, 6 | "node": true, 7 | "browser": true, 8 | }, 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:import/errors", 12 | "plugin:import/warnings", 13 | "plugin:react/recommended", 14 | "prettier", 15 | "prettier/react" 16 | ], 17 | "plugins": [ 18 | "react", 19 | "prettier" 20 | ], 21 | "rules": { 22 | "prettier/prettier": ["error", { 23 | "printWidth": 120, 24 | "singleQuote": true, 25 | "tabWidth": 4, 26 | "trailingComma": "all" 27 | }], 28 | "react/prop-types": ["warn"], 29 | "import/no-extraneous-dependencies": "off", 30 | "no-console": ["error", { "allow": ["warn", "error"] }], 31 | "no-unused-vars": ["error", { "ignoreRestSiblings": true }] 32 | } 33 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | node_modules 3 | .nvmrc 4 | .nyc_output 5 | .coverage-cache 6 | coverage 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .babelrc 3 | .eslintrc 4 | Makefile 5 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMap": false, 3 | "instrument": false, 4 | "reporter": [ 5 | "cobertura", 6 | "lcov", 7 | "text" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '7.8.0' 4 | env: 5 | global: 6 | - NODE_ENV=test 7 | dist: trusty 8 | cache: 9 | directories: 10 | - node_modules 11 | branches: 12 | only: 13 | - master 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-present, Gildas Garcia, Marmelab 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 NON INFRINGEMENT. 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/dw/aor-embedded-array.svg)](https://www.npmjs.com/package/aor-embedded-array) 2 | [![npm](https://img.shields.io/npm/v/aor-embedded-array.svg)](https://www.npmjs.com/package/aor-embedded-array) 3 | [![npm](https://img.shields.io/npm/l/aor-embedded-array.svg)](https://www.npmjs.com/package/aor-embedded-array) 4 | [![Travis](https://travis-ci.org/MhdSyrwan/aor-embedded-array.svg?branch=master)](https://travis-ci.org/MhdSyrwan/aor-embedded-array) 5 | # aor-embedded-array 6 | 7 | A custom field/input component for [Admin-on-rest](https://github.com/marmelab/admin-on-rest/) that provides the ability to represent embedded arrays. 8 | 9 | ![screenshot](docs/screenshots/general.png) 10 | 11 | ## Installation 12 | 13 | Install with: 14 | 15 | ```sh 16 | npm install --save aor-embedded-array 17 | ``` 18 | 19 | or 20 | 21 | ```sh 22 | yarn add aor-embedded-array 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### Basic Usage 28 | 29 | Define the `Create` and `Edit` View like this: 30 | 31 | ```jsx 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ``` 41 | 42 | Define the `Show` and `List` View like this: 43 | 44 | ```jsx 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | ``` 54 | 55 | For primitive arrays, define the Views the same way but without the source prop for the unique child: 56 | 57 | ```jsx 58 | 59 | 60 | 61 | ``` 62 | 63 | ### Using Custom action buttons 64 | 65 | ```jsx 66 | import FlatButton from 'material-ui/FlatButton'; 67 | import ActionDeleteIcon from 'material-ui/svg-icons/action/delete'; 68 | const CustomDeleteButton = ({items, index}) => ( 69 | } 74 | onClick={() => { 75 | // Take custom action 76 | console.log(items, index); 77 | items.remove(index); 78 | }} 79 | /> 80 | ) 81 | ``` 82 | 83 | ```jsx 84 | var style = { 85 | actionsContainerStyle: { 86 | display: "inline-block", 87 | clear: "both", 88 | float: "right", 89 | padding: "2em 0em 0em 0em" 90 | } 91 | } 92 | ]} 95 | > 96 | 97 | 98 | 99 | ``` 100 | 101 | ### Customizing Add and Remove buttons' labels 102 | You can make use of the translation system provided by `admin-on-rest` and set the following translation paths: 103 | 1. `aor.input.embedded_array.add` to set Add Button's label. 104 | 2. `aor.input.embedded_array.remove` to set Remove Button's label. 105 | 106 | Also, you can change the translation path's themselves by providing values for props `labelAdd` and `labelRemove`. 107 | 108 | You can learn more about admin-on-rest's translation system from this link: https://marmelab.com/admin-on-rest/Translation.html 109 | 110 | ### Passing props to customize style 111 | 112 | There are four style props you can pass to customize style, those are `actionsContainerStyle`, `innerContainerStyle`, `labelStyle` & `insertDividers`. 113 | 114 | Default values of those are as follows 115 | 116 | ```js 117 | actionsContainerStyle: { 118 | clear: 'both', 119 | margin: '1em', 120 | display: 'block', 121 | textAlign: 'right', 122 | }, 123 | ``` 124 | 125 | ```js 126 | innerContainerStyle: { 127 | padding: '0 1em 1em 1em', 128 | width: '90%', 129 | display: 'inline-block', 130 | }, 131 | ``` 132 | 133 | ```js 134 | labelContainerStyle: { 135 | padding: '1.2em 1em 0 0', 136 | width: '90%', 137 | display: 'inline-block', 138 | }, 139 | ``` 140 | 141 | ```js 142 | labelStyle: { 143 | top: 0, 144 | position: 'relative', 145 | textTransform: 'capitalize', 146 | }, 147 | ``` 148 | 149 | You can also pass `insertDividers` value as `true` or `false` to get the divider or not. Default value for `insertDividers` is true. 150 | -------------------------------------------------------------------------------- /docs/screenshots/general.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MhdSyrwan/aor-embedded-array/c5178ffb9762c9b2eb2b44dfd1d33551b9816a17/docs/screenshots/general.png -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://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 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | Example usage of aor-embedded-array -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "react-scripts": "1.1.1", 7 | "aor-embedded-array": "../" 8 | }, 9 | "scripts": { 10 | "start": "react-scripts start", 11 | "build": "react-scripts build", 12 | "test": "react-scripts test --env=jsdom", 13 | "eject": "react-scripts eject" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React App 8 | 9 | 10 | 13 |
14 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { simpleRestClient, Admin, Resource } from 'admin-on-rest'; 3 | 4 | import { PostList, PostEdit, PostCreate, PostIcon } from './posts'; 5 | 6 | export default class App extends React.Component { 7 | render() { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import App from './App'; 4 | 5 | render(, document.getElementById('root')); 6 | -------------------------------------------------------------------------------- /example/src/posts.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | List, 4 | Datagrid, 5 | Edit, 6 | Create, 7 | SimpleForm, 8 | DateField, 9 | TextField, 10 | EditButton, 11 | DisabledInput, 12 | TextInput, 13 | LongTextInput, 14 | DateInput, 15 | } from 'admin-on-rest'; 16 | import { EmbeddedArrayInput, EmbeddedArrayField } from 'aor-embedded-array'; 17 | import BookIcon from 'material-ui/svg-icons/action/book'; 18 | export const PostIcon = BookIcon; 19 | 20 | export const PostList = props => 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ; 37 | 38 | const PostTitle = ({ record }) => { 39 | return ( 40 | 41 | Post {record ? `"${record.title}"` : ''} 42 | 43 | ); 44 | }; 45 | 46 | export const PostEdit = props => 47 | } {...props}> 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ; 62 | 63 | export const PostCreate = props => 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | ; 77 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build help 2 | 3 | help: 4 | @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 5 | 6 | install: package.json ## Install dependencies 7 | @yarn 8 | 9 | clean: ## Clean up the lib folder for building 10 | @rm -rf lib 11 | 12 | build: clean ## Compile ES6 files to JS 13 | @NODE_ENV=production ./node_modules/.bin/babel \ 14 | --out-dir=lib \ 15 | --stage=0 \ 16 | --ignore=*.spec.js \ 17 | ./src 18 | 19 | watch: ## Continuously compile ES6 files to JS 20 | @NODE_ENV=production ./node_modules/.bin/babel \ 21 | --out-dir=lib \ 22 | --stage=0 \ 23 | --ignore=*.spec.js \ 24 | --watch \ 25 | ./src 26 | 27 | test: ## Launch unit tests 28 | @NODE_ENV=test ./node_modules/.bin/nyc \ 29 | ./node_modules/.bin/mocha \ 30 | --opts ./mocha.opts \ 31 | "./src/**/*.spec.js" 32 | 33 | inspect-test: ## Launch unit tests 34 | @NODE_ENV=test ./node_modules/.bin/nyc \ 35 | ./node_modules/.bin/mocha \ 36 | --opts ./mocha.opts \ 37 | --inspect-brk \ 38 | "./src/**/*.spec.js" 39 | 40 | 41 | watch-test: ## Launch unit tests and watch for changes 42 | @NODE_ENV=test ./node_modules/.bin/nyc \ 43 | ./node_modules/.bin/mocha \ 44 | --opts ./mocha.opts \ 45 | --watch \ 46 | "./src/**/*.spec.js" 47 | 48 | format: ## Format the source code 49 | @./node_modules/.bin/eslint --fix ./src 50 | -------------------------------------------------------------------------------- /mocha.opts: -------------------------------------------------------------------------------- 1 | --require babel-polyfill 2 | --compilers js:babel-core/register 3 | --colors 4 | --reporter=spec 5 | --timeout=5000 6 | --recursive 7 | --sort 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aor-embedded-array", 3 | "version": "0.1.0", 4 | "description": "Embeded Array Field/Input for Admin On Rest", 5 | "main": "./lib/index.js", 6 | "repository": "https://github.com/mhdsyrwan/aor-embedded-array.git", 7 | "authors": [ 8 | "Muhammad Al-Seyrawan " 9 | ], 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "make build", 13 | "clean": "make clean", 14 | "format": "make format", 15 | "precommit": "lint-staged", 16 | "prepublish": "make build", 17 | "test": "make test", 18 | "watch-test": "make watch-test" 19 | }, 20 | "lint-staged": { 21 | "*.js": [ 22 | "eslint --fix ./src", 23 | "git add" 24 | ] 25 | }, 26 | "devDependencies": { 27 | "admin-on-rest": "~1.3.0", 28 | "babel-cli": "~6.24.1", 29 | "babel-core": "~6.25.0", 30 | "babel-eslint": "7.2.3", 31 | "babel-plugin-istanbul": "~4.1.3", 32 | "babel-preset-es2015": "~6.24.1", 33 | "babel-preset-react": "~6.24.1", 34 | "babel-preset-stage-0": "~6.24.1", 35 | "enzyme": "~2.9.1", 36 | "eslint": "~4.2.0", 37 | "eslint-config-prettier": "~2.3.0", 38 | "eslint-plugin-import": "~2.7.0", 39 | "eslint-plugin-jsx-a11y": "~6.0.2", 40 | "eslint-plugin-mocha": "~4.11.0", 41 | "eslint-plugin-prettier": "~2.1.2", 42 | "eslint-plugin-react": "~7.1.0", 43 | "expect": "~1.20.2", 44 | "husky": "~0.14.3", 45 | "istanbul-cobertura-badger": "~1.3.0", 46 | "lint-staged": "~4.0.1", 47 | "mocha": "~3.4.2", 48 | "nyc": "~11.0.3", 49 | "prettier": "~1.5.2", 50 | "react": "~15.6.1", 51 | "react-addons-test-utils": "~15.6.0", 52 | "react-dom": "~15.6.1" 53 | }, 54 | "dependencies": { 55 | "babel-plugin-transform-runtime": "~6.23.0", 56 | "lodash.get": "~4.4.2", 57 | "lodash.map": "^4.6.0", 58 | "lodash.merge": "^4.6.1", 59 | "prop-types": "~15.5.10" 60 | }, 61 | "peerDependencies": { 62 | "admin-on-rest": "~1.3" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { EmbeddedArrayField, EmbeddedArrayInput } from './mui'; 2 | 3 | export { EmbeddedArrayField, EmbeddedArrayInput }; 4 | -------------------------------------------------------------------------------- /src/lib/custom-lodash.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash.get' 2 | import map from 'lodash.map' 3 | import merge from 'lodash.merge' 4 | 5 | export default { get, map, merge } 6 | -------------------------------------------------------------------------------- /src/mui/field/EmbeddedArrayField.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { SimpleShowLayout } from 'admin-on-rest'; 4 | import _ from '../../lib/custom-lodash'; 5 | 6 | /** 7 | * A container component that shows embedded array elements as a list of input sets 8 | * 9 | * You must define the fields and pass them as children or only use one field for primitive arrays. 10 | * 11 | * @example Display all the items of an order 12 | * // order = { 13 | * // id: 123, 14 | * // items: [ 15 | * // { qty: 1, price: 10 }, 16 | * // { qty: 3, price: 15 }, 17 | * // ], 18 | * // } 19 | * 20 | * 21 | * 22 | * 23 | * @example Display all the tags of a product 24 | * // product = { 25 | * // id: 123, 26 | * // tags: [ 27 | * // 'relaxation', 28 | * // 'chair', 29 | * // ], 30 | * // } 31 | * 32 | * 33 | * 34 | */ 35 | export class EmbeddedArrayField extends Component { 36 | render() { 37 | const { resource, children, source, record } = this.props; 38 | const layoutProps = { resource, basePath: '/', record }; 39 | const elements = _.get(record, source) || []; 40 | const elementsWithIndex = _.map(elements, (el, k) => _.merge(el, { _index: k })); 41 | 42 | return ( 43 |
44 | {_.map( 45 | elementsWithIndex, 46 | (element, i) => 47 | 48 | {React.Children.map(children, child => 49 | React.cloneElement(child, { 50 | source: child.props.source 51 | ? `${source}[${i}].${child.props.source}` 52 | : `${source}[${i}]`, 53 | }), 54 | )} 55 | , 56 | this, 57 | )} 58 |
59 | ); 60 | } 61 | } 62 | 63 | EmbeddedArrayField.propTypes = { 64 | addLabel: PropTypes.bool, 65 | basePath: PropTypes.string, 66 | children: PropTypes.node.isRequired, 67 | data: PropTypes.object, 68 | label: PropTypes.string, 69 | record: PropTypes.object, 70 | resource: PropTypes.string, 71 | source: PropTypes.string.isRequired, 72 | }; 73 | 74 | EmbeddedArrayField.defaultProps = { 75 | addLabel: true, 76 | }; 77 | 78 | export default EmbeddedArrayField; 79 | -------------------------------------------------------------------------------- /src/mui/field/EmbeddedArrayField.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import assert from 'assert'; 3 | import { shallow } from 'enzyme'; 4 | import EmbeddedArrayField from './EmbeddedArrayField'; 5 | import { TextField, UrlField } from 'admin-on-rest'; 6 | 7 | const record = { 8 | links: [ 9 | { 10 | url: 'https://www.google.com/', 11 | context: 'Search engine', 12 | }, 13 | { 14 | url: 'https://www.bing.com/', 15 | context: 'Search engine', 16 | }, 17 | ], 18 | }; 19 | 20 | describe('', () => { 21 | it('should display 2 nested SimpleShowLayouts', () => { 22 | const wrapper = shallow( 23 | 24 | 25 | 26 | , 27 | ); 28 | assert.equal(2, wrapper.find('SimpleShowLayout').length); 29 | assert.deepEqual( 30 | [['links[0].url', 'links[0].context'], ['links[1].url', 'links[1].context']], 31 | wrapper.find('SimpleShowLayout').map(s => s.prop('children').map(c => c.props.source)), 32 | ); 33 | }); 34 | }); 35 | 36 | const primitiveRecord = { 37 | links: ['https://www.google.com/', 'https://www.bing.com/'], 38 | }; 39 | 40 | describe(' with primitive record', () => { 41 | it('should display 2 nested SimpleShowLayouts', () => { 42 | const wrapper = shallow( 43 | 44 | 45 | , 46 | ); 47 | assert.equal(2, wrapper.find('SimpleShowLayout').length); 48 | assert.deepEqual( 49 | [['links[0]'], ['links[1]']], 50 | wrapper.find('SimpleShowLayout').map(s => s.prop('children').map(c => c.props.source)), 51 | ); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/mui/field/index.js: -------------------------------------------------------------------------------- 1 | import EmbeddedArrayField from './EmbeddedArrayField'; 2 | export { EmbeddedArrayField }; 3 | -------------------------------------------------------------------------------- /src/mui/form/EmbeddedArrayInputFormField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Field } from 'redux-form'; 3 | 4 | import { Labeled } from 'admin-on-rest'; 5 | import isRequired from './isRequired'; 6 | 7 | /** 8 | * A helper Input component for EmbeddedArrayInput 9 | * 10 | * It's an alternative to FormField that provides the ability to prefix the source/name 11 | * with a string you provide 12 | * 13 | * @example 14 | * 15 | */ 16 | const EmbeddedArrayInputFormField = ({ input, prefix, ...rest }) => { 17 | const name = input.props.source ? `${prefix}.${input.props.source}` : prefix; 18 | const source = name; 19 | 20 | if (input.props.addField) { 21 | if (input.props.addLabel) { 22 | return ( 23 | 31 | {input} 32 | 33 | ); 34 | } 35 | return ( 36 | 44 | ); 45 | } 46 | if (input.props.addLabel) { 47 | return ( 48 | 49 | {input} 50 | 51 | ); 52 | } 53 | return typeof input.type === 'string' 54 | ? input 55 | : React.cloneElement(input, { 56 | ...rest, 57 | source: name, 58 | }); 59 | }; 60 | 61 | export default EmbeddedArrayInputFormField; 62 | -------------------------------------------------------------------------------- /src/mui/form/isRequired.js: -------------------------------------------------------------------------------- 1 | import { required } from 'admin-on-rest'; 2 | 3 | const isRequired = validate => { 4 | if (validate === required) return true; 5 | if (Array.isArray(validate)) { 6 | return validate.includes(required); 7 | } 8 | return false; 9 | }; 10 | 11 | export default isRequired; 12 | -------------------------------------------------------------------------------- /src/mui/index.js: -------------------------------------------------------------------------------- 1 | import { EmbeddedArrayField } from './field'; 2 | import { EmbeddedArrayInput } from './input'; 3 | 4 | export { EmbeddedArrayField, EmbeddedArrayInput }; 5 | -------------------------------------------------------------------------------- /src/mui/input/EmbeddedArrayInput.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { FieldArray } from 'redux-form'; 4 | import inflection from 'inflection'; 5 | 6 | import FlatButton from 'material-ui/FlatButton'; 7 | import TextFieldLabel from 'material-ui/TextField/TextFieldLabel'; 8 | import ContentAdd from 'material-ui/svg-icons/content/add'; 9 | import ActionDeleteIcon from 'material-ui/svg-icons/action/delete'; 10 | import Divider from 'material-ui/Divider'; 11 | 12 | import { translate } from 'admin-on-rest'; 13 | 14 | import EmbeddedArrayInputFormField from '../form/EmbeddedArrayInputFormField'; 15 | 16 | /** 17 | * An Input component for generating/editing an embedded array 18 | * 19 | * 20 | * Use it with any set of input components as children, like ``, 21 | * ``, ``, etc. 22 | * 23 | * You must define the targeted field for each child or only use one child for primitive arrays. 24 | * 25 | * @example 26 | * export const CommentEdit = (props) => ( 27 | * 28 | * 29 | * 30 | * 31 | * 32 | * 33 | * 34 | * 35 | * 36 | * 37 | * 38 | * ); 39 | * @example 40 | * export const CommentEdit = (props) => ( 41 | * 42 | * 43 | * 44 | * 45 | * 46 | * 47 | * 48 | * ); 49 | */ 50 | export class EmbeddedArrayInput extends Component { 51 | static propTypes = { 52 | addLabel: PropTypes.bool.isRequired, 53 | addField: PropTypes.bool.isRequired, 54 | allowEmpty: PropTypes.bool.isRequired, 55 | allowAdd: PropTypes.bool.isRequired, 56 | allowRemove: PropTypes.bool.isRequired, 57 | arrayElStyle: PropTypes.object, 58 | basePath: PropTypes.string, 59 | children: PropTypes.node.isRequired, 60 | disabled: PropTypes.bool, 61 | elStyle: PropTypes.object, 62 | input: PropTypes.object, 63 | label: PropTypes.string, 64 | labelAdd: PropTypes.string.isRequired, 65 | labelRemove: PropTypes.string.isRequired, 66 | meta: PropTypes.object, 67 | onChange: PropTypes.func, 68 | resource: PropTypes.string, 69 | readOnly: PropTypes.bool, 70 | record: PropTypes.object, 71 | source: PropTypes.string, 72 | customButtons: PropTypes.node, 73 | actionsContainerStyle: PropTypes.object, 74 | innerContainerStyle: PropTypes.object, 75 | labelContainerStyle: PropTypes.object, 76 | labelStyle: PropTypes.object, 77 | insertDividers: PropTypes.bool, 78 | }; 79 | 80 | static defaultProps = { 81 | addLabel: false, 82 | addField: false, 83 | allowEmpty: true, 84 | allowAdd: true, 85 | allowRemove: true, 86 | labelAdd: 'aor.input.embedded_array.add', 87 | labelRemove: 'aor.input.embedded_array.remove', 88 | insertDividers: true, 89 | actionsContainerStyle: { 90 | clear: 'both', 91 | margin: '1em', 92 | display: 'block', 93 | textAlign: 'right', 94 | }, 95 | innerContainerStyle: { 96 | padding: '0 1em 1em 1em', 97 | width: '90%', 98 | display: 'inline-block', 99 | }, 100 | labelContainerStyle: { 101 | padding: '1.2em 1em 0 0', 102 | width: '90%', 103 | display: 'inline-block', 104 | }, 105 | labelStyle: { 106 | top: 0, 107 | position: 'relative', 108 | textTransform: 'capitalize', 109 | }, 110 | }; 111 | 112 | static contextTypes = { 113 | muiTheme: PropTypes.object.isRequired, 114 | }; 115 | 116 | renderListItem = ({ 117 | allowRemove, 118 | items, 119 | inputs, 120 | member, 121 | index, 122 | translate, 123 | labelRemove, 124 | readOnly, 125 | disabled, 126 | customButtons, 127 | actionsContainerStyle, 128 | innerContainerStyle, 129 | }) => { 130 | const removeItem = () => items.remove(index); 131 | const passedProps = { 132 | resource: this.props.resource, 133 | basePath: this.props.basePath, 134 | record: this.props.record, 135 | }; 136 | 137 | return ( 138 |
139 |
140 | {React.Children.map( 141 | inputs, 142 | input => 143 | input && 144 |
149 | 150 |
, 151 | )} 152 |
153 | {(customButtons || (allowRemove && !readOnly && !disabled)) && 154 |
155 | {allowRemove && 156 | !readOnly && 157 | !disabled && 158 | } 162 | onClick={removeItem} 163 | />} 164 | {customButtons && customButtons.map(button => React.cloneElement(button, { items, index }))} 165 |
} 166 |
167 | ); 168 | }; 169 | 170 | renderList = ({ fields: items }) => { 171 | const { 172 | children, 173 | style, 174 | translate, 175 | labelRemove, 176 | labelAdd, 177 | allowAdd, 178 | allowRemove, 179 | readOnly, 180 | disabled, 181 | customButtons, 182 | actionsContainerStyle, 183 | innerContainerStyle, 184 | insertDividers, 185 | } = this.props; 186 | const createItem = () => items.push(); 187 | 188 | return ( 189 |
190 |
191 | {items.map((member, index) => 192 |
193 | {this.renderListItem({ 194 | items, 195 | inputs: children, 196 | member, 197 | index, 198 | translate, 199 | labelRemove, 200 | allowRemove, 201 | readOnly, 202 | disabled, 203 | customButtons, 204 | actionsContainerStyle, 205 | innerContainerStyle, 206 | })} 207 | {insertDividers && index < items.length - 1 && } 208 |
, 209 | )} 210 |
211 |
212 | {allowAdd && 213 | !readOnly && 214 | !disabled && 215 | } 218 | label={translate(labelAdd, { _: 'Add' })} 219 | onClick={createItem} 220 | />} 221 |
222 | ); 223 | }; 224 | 225 | render() { 226 | const { source, label, addLabel, translate, resource, labelStyle, labelContainerStyle } = this.props; 227 | const labelStyleMuiTheme = Object.assign(labelStyle, { 228 | color: this.context.muiTheme ? this.context.muiTheme.textField.focusColor : '', 229 | }); 230 | 231 | const minimizedLabel = 232 | typeof label !== 'undefined' 233 | ? translate(label, { _: label }) 234 | : translate( 235 | `resources.${resource}.fields.${source.replace(/\./g, '.fields.').replace(/\[\d+\]/g, '')}.name`, 236 | { 237 | _: inflection.humanize(source.split('.').pop()), 238 | }, 239 | ); 240 | 241 | const labelElement = 242 | !addLabel && 243 |
244 | 245 | {minimizedLabel} 246 | 247 |
; 248 | 249 | return ( 250 |
251 | {labelElement} 252 | 253 |
254 | ); 255 | } 256 | } 257 | 258 | export default translate(EmbeddedArrayInput); 259 | -------------------------------------------------------------------------------- /src/mui/input/EmbeddedArrayInput.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import assert from 'assert'; 3 | import { shallow } from 'enzyme'; 4 | 5 | import { TextInput } from 'admin-on-rest'; 6 | 7 | import { EmbeddedArrayInput } from './EmbeddedArrayInput'; 8 | 9 | import FlatButton from 'material-ui/FlatButton'; 10 | 11 | describe('', () => { 12 | const defaultProps = { 13 | source: 'sub_items', 14 | children: [, ], 15 | resource: 'the_items', 16 | translate: x => x, 17 | }; 18 | 19 | it('should render FieldArray Element', () => { 20 | const wrapper = shallow(); 21 | const fieldArrayElement = wrapper.find('FieldArray'); 22 | assert.equal(fieldArrayElement.prop('name'), 'sub_items'); 23 | }); 24 | 25 | it('should render 4 EmbeddedArrayInputFormField elements', () => { 26 | // instantiating an EmbeddedArrayInput to test its renderList function 27 | const embeddedArrayInput = new EmbeddedArrayInput(defaultProps); 28 | 29 | // mocking redux-form FieldArray items array 30 | const items = ['sub_items[0]', 'sub_items[1]']; 31 | 32 | // shallow rendering the renderList helper to test its contents 33 | const renderList = shallow(embeddedArrayInput.renderList({ fields: items })); 34 | 35 | // It should render the container 36 | assert.equal(renderList.find('.EmbeddedArrayInputContainer').length, 1); 37 | 38 | // It should render a container for each item 39 | assert.equal(renderList.find('.EmbeddedArrayInputItemContainer').length, 2); 40 | 41 | // Totally there should be 4 EmbeddedArrayInputFormField 42 | assert.equal(renderList.find('EmbeddedArrayInputFormField').length, 4); 43 | 44 | // It should render 2 items: sub_items[0] and sub_items[1] 45 | // each with 2 fields: price and qty 46 | assert.deepEqual( 47 | renderList.find('EmbeddedArrayInputFormField').map(el => { 48 | const input = el.prop('input'); 49 | return [input.type.name, el.prop('prefix'), input.props.source]; 50 | }), 51 | [ 52 | ['TextInput', 'sub_items[0]', 'price'], 53 | ['TextInput', 'sub_items[0]', 'qty'], 54 | ['TextInput', 'sub_items[1]', 'price'], 55 | ['TextInput', 'sub_items[1]', 'qty'], 56 | ], 57 | ); 58 | }); 59 | }); 60 | 61 | describe(' with primitive child', () => { 62 | const defaultProps = { 63 | source: 'sub_items', 64 | children: [], 65 | resource: 'the_items', 66 | translate: x => x, 67 | }; 68 | 69 | it('should render FieldArray Element', () => { 70 | const wrapper = shallow(); 71 | const fieldArrayElement = wrapper.find('FieldArray'); 72 | assert.equal(fieldArrayElement.prop('name'), 'sub_items'); 73 | }); 74 | 75 | it('should render 4 EmbeddedArrayInputFormField elements', () => { 76 | // instantiating an EmbeddedArrayInput to test its renderList function 77 | const embeddedArrayInput = new EmbeddedArrayInput(defaultProps); 78 | 79 | // mocking redux-form FieldArray items array 80 | const items = ['sub_items[0]', 'sub_items[1]']; 81 | 82 | // shallow rendering the renderList helper to test its contents 83 | const renderList = shallow(embeddedArrayInput.renderList({ fields: items })); 84 | 85 | // It should render the container 86 | assert.equal(renderList.find('.EmbeddedArrayInputContainer').length, 1); 87 | 88 | // It should render a container for each item 89 | assert.equal(renderList.find('.EmbeddedArrayInputItemContainer').length, 2); 90 | 91 | // Totally there should be 2 EmbeddedArrayInputFormField 92 | assert.equal(renderList.find('EmbeddedArrayInputFormField').length, 2); 93 | 94 | // It should render 2 items: sub_items[0] and sub_items[1] 95 | assert.deepEqual( 96 | renderList.find('EmbeddedArrayInputFormField').map(el => { 97 | const input = el.prop('input'); 98 | return [input.type.name, el.prop('prefix')]; 99 | }), 100 | [['TextInput', 'sub_items[0]'], ['TextInput', 'sub_items[1]']], 101 | ); 102 | }); 103 | }); 104 | 105 | describe(' with custom action buttons', () => { 106 | const CustomDeleteButton = ({ items, index }) => 107 | items.remove(index)} />; 108 | 109 | const defaultProps = { 110 | source: 'sub_items', 111 | children: [], 112 | resource: 'the_items', 113 | translate: x => x, 114 | customButtons: [], 115 | actionsContainerStyle: { padding: '1em' }, 116 | allowRemove: false, 117 | }; 118 | 119 | it('should render FieldArray Element', () => { 120 | const wrapper = shallow(); 121 | const fieldArrayElement = wrapper.find('FieldArray'); 122 | assert.equal(fieldArrayElement.prop('name'), 'sub_items'); 123 | }); 124 | 125 | it('should render 2 CustomDeleteButton elements', () => { 126 | // instantiating an EmbeddedArrayInput to test its renderList function 127 | const embeddedArrayInput = new EmbeddedArrayInput(defaultProps); 128 | 129 | // mocking redux-form FieldArray items array 130 | const items = ['sub_items[0]', 'sub_items[1]']; 131 | 132 | // shallow rendering the renderList helper to test its contents 133 | const renderList = shallow(embeddedArrayInput.renderList({ fields: items })); 134 | 135 | // Totally there should be 2 CustomDeleteButton 136 | assert.equal(renderList.find('CustomDeleteButton').length, 2); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/mui/input/index.js: -------------------------------------------------------------------------------- 1 | import EmbeddedArrayInput from './EmbeddedArrayInput'; 2 | export { EmbeddedArrayInput }; 3 | --------------------------------------------------------------------------------