├── .npmignore ├── .gitignore ├── .travis.yml ├── src ├── View │ ├── ShowView.js │ ├── ExportView.js │ ├── BatchDeleteView.js │ ├── DashboardView.js │ ├── MenuView.js │ ├── DeleteView.js │ ├── EditView.js │ ├── CreateView.js │ ├── ListView.js │ └── View.js ├── Field │ ├── EmailField.js │ ├── TextField.js │ ├── PasswordField.js │ ├── ChoicesField.js │ ├── JsonField.js │ ├── FloatField.js │ ├── TemplateField.js │ ├── ReferenceManyField.js │ ├── DateTimeField.js │ ├── FileField.js │ ├── WysiwygField.js │ ├── ChoiceField.js │ ├── BooleanField.js │ ├── NumberField.js │ ├── DateField.js │ ├── ReferencedListField.js │ ├── EmbeddedListField.js │ ├── ReferenceField.js │ └── Field.js ├── Queries │ ├── Queries.js │ ├── WriteQueries.js │ └── ReadQueries.js ├── Utils │ ├── orderElement.js │ ├── stringUtils.js │ ├── ReferenceExtractor.js │ ├── PromisesResolver.js │ └── objectProperties.js ├── Collection.js ├── Dashboard.js ├── Entry.js ├── DataStore │ └── DataStore.js ├── Menu │ └── Menu.js ├── Factory.js ├── Entity │ └── Entity.js └── Application.js ├── Makefile ├── tests ├── mock │ ├── PromisesResolver.js │ └── mixins.js └── lib │ ├── Utils │ ├── stringUtilsTest.js │ ├── orderElementTest.js │ ├── PromisesResolverTest.js │ ├── ReferenceExtractorTest.js │ └── objectPropertiesTest.js │ ├── Field │ ├── TemplateFieldTest.js │ ├── ReferencedListFieldTest.js │ ├── ReferenceFieldTest.js │ └── FieldTest.js │ ├── View │ ├── MenuViewTest.js │ ├── ListViewTest.js │ └── ViewTest.js │ ├── DashboardTest.js │ ├── FactoryTest.js │ ├── Queries │ ├── WriteQueriesTest.js │ └── ReadQueriesTest.js │ ├── Entity │ └── EntityTest.js │ ├── DataStore │ └── DataStore.js │ ├── EntryTest.js │ └── Menu │ └── MenuTest.js ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | sudo: false 5 | cache: 6 | directories: 7 | - node_modules 8 | branches: 9 | only: 10 | - master 11 | -------------------------------------------------------------------------------- /src/View/ShowView.js: -------------------------------------------------------------------------------- 1 | import View from './View'; 2 | 3 | class ShowView extends View { 4 | constructor(name) { 5 | super(name); 6 | this._type = 'ShowView'; 7 | } 8 | } 9 | 10 | export default ShowView; 11 | -------------------------------------------------------------------------------- /src/Field/EmailField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class EmailField extends Field { 4 | constructor(name) { 5 | super(name); 6 | this._type = "email"; 7 | } 8 | } 9 | 10 | export default EmailField; 11 | -------------------------------------------------------------------------------- /src/Field/TextField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class TextField extends Field { 4 | constructor(name) { 5 | super(name); 6 | this._type = "text"; 7 | } 8 | } 9 | 10 | export default TextField; 11 | -------------------------------------------------------------------------------- /src/Field/PasswordField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class PasswordField extends Field { 4 | constructor(name) { 5 | super(name); 6 | this._type = "password"; 7 | } 8 | } 9 | 10 | export default PasswordField; 11 | -------------------------------------------------------------------------------- /src/Field/ChoicesField.js: -------------------------------------------------------------------------------- 1 | import ChoiceField from "./ChoiceField"; 2 | 3 | class ChoicesField extends ChoiceField { 4 | constructor(name) { 5 | super(name); 6 | this._type = "choices"; 7 | } 8 | } 9 | 10 | export default ChoicesField; 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | @npm install 3 | 4 | transpile: 5 | @mkdir -p lib/ 6 | @rm -rf lib/* 7 | @./node_modules/.bin/babel src/ -d lib/ --source-maps > /dev/null 8 | 9 | test: 10 | @./node_modules/mocha/bin/mocha --compilers js:babel/register --recursive tests/ 11 | -------------------------------------------------------------------------------- /src/Field/JsonField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class JsonField extends Field { 4 | constructor(name) { 5 | super(name); 6 | this._type = "json"; 7 | this._flattenable = false; 8 | } 9 | } 10 | 11 | export default JsonField; 12 | -------------------------------------------------------------------------------- /tests/mock/PromisesResolver.js: -------------------------------------------------------------------------------- 1 | 2 | import buildPromise from "./mixins"; 3 | 4 | var PromisesResolver = { 5 | allEvenFailed: function() { return buildPromise([]); }, 6 | empty: function() { return buildPromise(); } 7 | }; 8 | 9 | export default PromisesResolver; 10 | -------------------------------------------------------------------------------- /src/View/ExportView.js: -------------------------------------------------------------------------------- 1 | import ListView from './ListView'; 2 | 3 | class ExportView extends ListView { 4 | constructor(name) { 5 | super(name); 6 | this._fields = []; 7 | this._type = 'ExportView'; 8 | } 9 | } 10 | 11 | export default ExportView; 12 | -------------------------------------------------------------------------------- /src/Field/FloatField.js: -------------------------------------------------------------------------------- 1 | import NumberField from "./NumberField"; 2 | 3 | class FloatField extends NumberField { 4 | constructor(name) { 5 | super(name); 6 | this._type = 'float'; 7 | this._format = '0.000'; 8 | } 9 | } 10 | 11 | export default FloatField; 12 | -------------------------------------------------------------------------------- /src/Field/TemplateField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class TemplateField extends Field { 4 | constructor(name) { 5 | super(name); 6 | this._type = "template"; 7 | this._flattenable = false; 8 | } 9 | } 10 | 11 | export default TemplateField; 12 | -------------------------------------------------------------------------------- /src/Field/ReferenceManyField.js: -------------------------------------------------------------------------------- 1 | import ReferenceField from "./ReferenceField"; 2 | 3 | class ReferenceManyField extends ReferenceField { 4 | constructor(name) { 5 | super(name); 6 | this._type = 'reference_many'; 7 | } 8 | } 9 | 10 | export default ReferenceManyField; 11 | -------------------------------------------------------------------------------- /src/View/BatchDeleteView.js: -------------------------------------------------------------------------------- 1 | import View from './View'; 2 | 3 | class BatchDeleteView extends View { 4 | constructor(name) { 5 | super(name); 6 | 7 | this._type = 'BatchDeleteView'; 8 | this._enabled = true; 9 | } 10 | } 11 | 12 | export default BatchDeleteView; 13 | -------------------------------------------------------------------------------- /src/Queries/Queries.js: -------------------------------------------------------------------------------- 1 | 2 | class Queries { 3 | constructor(RestWrapper, PromisesResolver, Application) { 4 | this._restWrapper = RestWrapper; 5 | this._promisesResolver = PromisesResolver; 6 | this._application = Application; 7 | } 8 | } 9 | 10 | export default Queries; 11 | -------------------------------------------------------------------------------- /src/Utils/orderElement.js: -------------------------------------------------------------------------------- 1 | export default { 2 | order: function (input) { 3 | var results = [], 4 | objectKey; 5 | 6 | for (objectKey in input) { 7 | results.push(input[objectKey]); 8 | } 9 | 10 | return results.sort((e1, e2) => e1.order() - e2.order()); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/Collection.js: -------------------------------------------------------------------------------- 1 | import ListView from './View/ListView'; 2 | 3 | class Collection extends ListView { 4 | 5 | setEntity(entity) { 6 | this.entity = entity; 7 | if (!this._name) { 8 | this._name = entity.name(); 9 | } 10 | return this; 11 | } 12 | } 13 | 14 | export default Collection; 15 | -------------------------------------------------------------------------------- /src/View/DashboardView.js: -------------------------------------------------------------------------------- 1 | import ListView from './ListView'; 2 | 3 | class DashboardView extends ListView { 4 | setEntity(entity) { 5 | this.entity = entity; 6 | if (!this._name) { 7 | this._name = entity.name(); 8 | } 9 | return this; 10 | } 11 | } 12 | 13 | export default DashboardView; 14 | -------------------------------------------------------------------------------- /src/Field/DateTimeField.js: -------------------------------------------------------------------------------- 1 | import DateField from "./DateField"; 2 | 3 | class DateTimeField extends DateField { 4 | constructor(name) { 5 | super(name); 6 | 7 | this._format = null; 8 | this._parse = function(date) { 9 | return date; 10 | }; 11 | 12 | this._type = 'datetime'; 13 | } 14 | } 15 | 16 | export default DateTimeField; 17 | -------------------------------------------------------------------------------- /src/Field/FileField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class FileField extends Field { 4 | constructor(name) { 5 | super(name); 6 | this._type = "file"; 7 | this._uploadInformation = { 8 | url: '/upload', 9 | accept: '*' 10 | }; 11 | } 12 | 13 | uploadInformation(information) { 14 | if (!arguments.length) return this._uploadInformation; 15 | this._uploadInformation = information; 16 | return this; 17 | } 18 | } 19 | 20 | export default FileField; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "admin-config", 3 | "version": "0.12.4", 4 | "private": false, 5 | "files": [ 6 | "*.md", 7 | "lib" 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/marmelab/admin-config.git" 12 | }, 13 | "engines": { 14 | "node": ">=0.10.0" 15 | }, 16 | "devDependencies": { 17 | "babel": "^5.5.3", 18 | "chai": "^2.3.0", 19 | "mocha": "^2.2.5", 20 | "sinon": "^1.14.1" 21 | }, 22 | "scripts": { 23 | "prepublish": "make transpile", 24 | "test": "make test" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Utils/stringUtils.js: -------------------------------------------------------------------------------- 1 | export default { 2 | /** 3 | * @see http://stackoverflow.com/questions/10425287/convert-string-to-camelcase-with-regular-expression 4 | * @see http://phpjs.org/functions/ucfirst/ 5 | */ 6 | camelCase: function(text) { 7 | if (!text) { 8 | return text; 9 | } 10 | 11 | let f = text.charAt(0).toUpperCase(); 12 | text = f + text.substr(1); 13 | 14 | return text.replace(/[-_.\s](.)/g, function (match, group1) { 15 | return ' ' + group1.toUpperCase(); 16 | }); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/Field/WysiwygField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class WysiwygField extends Field { 4 | constructor(name) { 5 | super(name); 6 | this._type = "wysiwyg"; 7 | this._stripTags = false; 8 | this._sanitize = true; 9 | } 10 | 11 | stripTags(value) { 12 | if (!arguments.length) return this._stripTags; 13 | this._stripTags = value; 14 | return this; 15 | } 16 | 17 | sanitize(value) { 18 | if (!arguments.length) return this._sanitize; 19 | this._sanitize = value; 20 | return this; 21 | } 22 | } 23 | 24 | export default WysiwygField; 25 | -------------------------------------------------------------------------------- /tests/mock/mixins.js: -------------------------------------------------------------------------------- 1 | function buildPromise(output) { 2 | return (function() { 3 | var result; 4 | return { 5 | 'then': (cb) => { 6 | result = cb(output); 7 | 8 | if (result && result.then) { 9 | // The result is already a promise, just return it 10 | return result; 11 | } 12 | 13 | // We chain the result into a new promise 14 | return buildPromise(result); 15 | }, 16 | 'finally': (cb) => { 17 | cb(); 18 | return this; 19 | } 20 | }; 21 | }()); 22 | } 23 | 24 | export default buildPromise; 25 | -------------------------------------------------------------------------------- /src/Field/ChoiceField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class ChoiceField extends Field { 4 | constructor(name) { 5 | super(name); 6 | this._type = "choice"; 7 | this._choices = []; 8 | } 9 | 10 | choices(choices) { 11 | if (!arguments.length) return this._choices; 12 | this._choices = choices; 13 | 14 | return this; 15 | } 16 | 17 | getLabelForChoice(value, entry) { 18 | let choices = typeof(this._choices) === 'function' ? this._choices(entry) : this._choices; 19 | let choice = choices.filter(c => c.value == value).pop(); 20 | return choice ? choice.label : null; 21 | } 22 | } 23 | 24 | export default ChoiceField; 25 | -------------------------------------------------------------------------------- /tests/lib/Utils/stringUtilsTest.js: -------------------------------------------------------------------------------- 1 | const assert = require('chai').assert; 2 | 3 | import {camelCase} from '../../../lib/Utils/stringUtils'; 4 | 5 | describe('StringUtils', () => { 6 | describe('camelCase()', () => { 7 | it('should not change an already camel-cased string', () => assert.equal(camelCase('Foo'), 'Foo')); 8 | it('should not change an uppercase string', () => assert.equal(camelCase('FOO'), 'FOO')); 9 | it('should camel-case a lowercase string', () => assert.equal(camelCase('foo'), 'Foo')); 10 | it('should camel-case all words string', () => assert.equal(camelCase('foo bar'), 'Foo Bar')); 11 | it('should camel-case all sub strings', () => assert.equal(camelCase('foo_bar-baz.foo'), 'Foo Bar Baz Foo')); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/Field/BooleanField.js: -------------------------------------------------------------------------------- 1 | import ChoiceField from "./ChoiceField"; 2 | 3 | class BooleanField extends ChoiceField { 4 | constructor(name) { 5 | super(name); 6 | this._type = "boolean"; 7 | this._choices = [ 8 | { value: null, label: 'undefined' }, 9 | { value: true, label: 'true' }, 10 | { value: false, label: 'false' } 11 | ]; 12 | this._filterChoices = [ 13 | { value: true, label: 'true' }, 14 | { value: false, label: 'false' } 15 | ]; 16 | } 17 | 18 | filterChoices(filterChoices) { 19 | if (!arguments.length) return this._filterChoices; 20 | this._filterChoices = filterChoices; 21 | 22 | return this; 23 | } 24 | } 25 | 26 | export default BooleanField; 27 | -------------------------------------------------------------------------------- /src/View/MenuView.js: -------------------------------------------------------------------------------- 1 | import View from "./View"; 2 | 3 | class MenuView extends View { 4 | constructor(name) { 5 | super(name); 6 | this._type = 'MenuView'; 7 | this._icon = null; 8 | } 9 | 10 | get enabled() { 11 | return this._enabled === null ? this.entity._views['ListView'].enabled : this._enabled; 12 | } 13 | 14 | icon() { 15 | if (arguments.length) { 16 | console.warn('entity.menuView() is deprecated. Please use the Menu class instead'); 17 | this._icon = arguments[0]; 18 | return this; 19 | } 20 | 21 | if (this._icon === null) { 22 | return ''; 23 | } 24 | 25 | return this._icon; 26 | } 27 | } 28 | 29 | export default MenuView; 30 | -------------------------------------------------------------------------------- /src/Dashboard.js: -------------------------------------------------------------------------------- 1 | class Dashboard { 2 | constructor() { 3 | this._collections = {}; 4 | this._template = null; 5 | } 6 | 7 | addCollection(collection) { 8 | this._collections[collection.name()] = collection; 9 | return this; 10 | } 11 | 12 | collections(collections) { 13 | if (arguments.length) { 14 | this._collections = collections; 15 | return this; 16 | } 17 | return this._collections; 18 | } 19 | 20 | hasCollections() { 21 | return Object.keys(this._collections).length > 0; 22 | } 23 | 24 | template(template) { 25 | if (arguments.length) { 26 | this._template = template; 27 | return this; 28 | } 29 | return this._template; 30 | } 31 | } 32 | 33 | export default Dashboard; 34 | -------------------------------------------------------------------------------- /tests/lib/Utils/orderElementTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import orderElement from "../../../lib/Utils/orderElement"; 4 | 5 | describe('orderElement', () => { 6 | 7 | describe("order()", () => { 8 | 9 | it('should order all elements', () => { 10 | var elements = [ 11 | {order: function () { return 1; }, name: 'field1'}, 12 | {order: function () { return 0; }, name: 'field2'}, 13 | {order: function () { return 3; }, name: 'field3'} 14 | ]; 15 | 16 | var orderedElements = orderElement.order(elements); 17 | 18 | // Check that elements are ordered 19 | assert.equal(orderedElements.length, 3); 20 | assert.equal(orderedElements[0].name, 'field2'); 21 | assert.equal(orderedElements[1].name, 'field1'); 22 | assert.equal(orderedElements[2].name, 'field3'); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/Field/NumberField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class NumberField extends Field { 4 | constructor(name) { 5 | super(name); 6 | this._type = "number"; 7 | this._format = undefined; 8 | } 9 | 10 | /** 11 | * Specify format pattern for number to string conversion. 12 | * 13 | * Based on NumeralJs, which uses a syntax similar to Excel. 14 | * 15 | * {@link} http://numeraljs.com/ 16 | * {@link} https://github.com/baumandm/angular-numeraljs 17 | * {@example} 18 | * 19 | * nga.field('height', 'number').format('$0,0.00'); 20 | */ 21 | format(value) { 22 | if (!arguments.length) return this._format; 23 | this._format = value; 24 | return this; 25 | } 26 | 27 | fractionSize(decimals) { 28 | console.warn('NumberField.fractionSize() is deprecated, use NumberField.format() instead'); 29 | this.format('0.' + '0'.repeat(decimals)); 30 | return this; 31 | } 32 | 33 | } 34 | 35 | export default NumberField; 36 | -------------------------------------------------------------------------------- /tests/lib/Field/TemplateFieldTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import TemplateField from "../../../lib/Field/TemplateField"; 4 | 5 | describe('TemplateField', function() { 6 | describe('template()', function() { 7 | it('should accept string values', function () { 8 | var field = new TemplateField().template('hello!'); 9 | assert.equal(field.getTemplateValue(), 'hello!'); 10 | }); 11 | 12 | it('should accept function values', function () { 13 | var field = new TemplateField().template(function () { return 'hello function !'; }); 14 | assert.equal(field.getTemplateValue(), 'hello function !'); 15 | }); 16 | }); 17 | 18 | describe('getTemplateValue()', function() { 19 | it('should return the template function executed with the supplied data', function() { 20 | var field = new TemplateField().template(function (name) { return 'hello ' + name + ' !'; }); 21 | assert.equal(field.getTemplateValue('John'), 'hello John !'); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/Field/DateField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class DateField extends Field { 4 | constructor(name) { 5 | super(name); 6 | 7 | this._format = null; 8 | this._parse = function(date) { 9 | if (date instanceof Date) { 10 | // the datepicker returns a JS Date object, with hours, minutes and timezone 11 | // in order to convert it back to date, we must remove the timezone, then 12 | // remove hours and minutes 13 | date.setMinutes(date.getMinutes() - date.getTimezoneOffset()); 14 | 15 | let dateString = date.toJSON(); 16 | return dateString ? dateString.substr(0,10) : null; 17 | } 18 | return date; 19 | }; 20 | this._type = "date"; 21 | } 22 | 23 | format(value) { 24 | if (!arguments.length) return this._format; 25 | this._format = value; 26 | return this; 27 | } 28 | 29 | parse(value) { 30 | if (!arguments.length) return this._parse; 31 | this._parse = value; 32 | return this; 33 | } 34 | } 35 | 36 | export default DateField; 37 | -------------------------------------------------------------------------------- /src/Utils/ReferenceExtractor.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | getReferencedLists(fields) { 4 | return this.indexByName(fields.filter(f => f.type() === 'referenced_list')); 5 | }, 6 | getReferences(fields, withRemoteComplete, optimized = null) { 7 | let references = fields.filter(f => f.type() === 'reference' || f.type() === 'reference_many'); 8 | if (withRemoteComplete === true) { 9 | references = references.filter(r => r.remoteComplete()); 10 | } else if (withRemoteComplete === false) { 11 | references = references.filter(r => !r.remoteComplete()); 12 | } 13 | if (optimized !== null) { 14 | references = references.filter(r => r.hasSingleApiCall() === optimized) 15 | } 16 | return this.indexByName(references); 17 | }, 18 | getNonOptimizedReferences(fields, withRemoteComplete) { 19 | return this.getReferences(fields, withRemoteComplete, false); 20 | }, 21 | getOptimizedReferences(fields, withRemoteComplete) { 22 | return this.getReferences(fields, withRemoteComplete, true); 23 | }, 24 | indexByName(references) { 25 | return references.reduce((referencesByName, reference) => { 26 | referencesByName[reference.name()] = reference; 27 | return referencesByName; 28 | }, {}); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # admin-config [![Build Status](https://travis-ci.org/marmelab/admin-config.svg?branch=master)](https://travis-ci.org/marmelab/admin-config) 2 | 3 | Common files used in both [ng-admin](https://github.com/marmelab/ng-admin) and [ng-admin-react](https://github.com/marmelab/ng-admin-react). 4 | 5 | ## Installation 6 | 7 | ```sh 8 | make install 9 | ``` 10 | 11 | ## Including In Another Library 12 | 13 | Require whatever class you need directly. 14 | 15 | ```js 16 | // es5 17 | var NumberField = require('admin-config/lib/Field/NumberField'); 18 | // es6 19 | import NumberField from "admin-config/lib/Field/NumberField"; 20 | ``` 21 | 22 | Admin-config is written in ES6. You'll need a transpiler to use any of the classes (we recommend [Webpack](http://webpack.github.io/) and [babel](https://babeljs.io/)). Here is an example Webpack configuration: 23 | 24 | ```js 25 | module.exports = { 26 | // ... 27 | module: { 28 | loaders: [ 29 | { test: /node_modules\/admin-config\/.*\.js$/, loader: 'babel' } 30 | ] 31 | } 32 | }; 33 | ``` 34 | 35 | ## Transpiling 36 | 37 | In order to increase this library compatibility and to not force other users of this 38 | library to use Babel, you need to transpile your ES6 code from `src/` to good old ES5 39 | code (in `lib/`). 40 | 41 | Just run: 42 | 43 | ``` sh 44 | make transpile 45 | ``` 46 | 47 | And you are done! 48 | 49 | ## Running Tests 50 | 51 | ```sh 52 | make test 53 | ``` 54 | -------------------------------------------------------------------------------- /src/Utils/PromisesResolver.js: -------------------------------------------------------------------------------- 1 | 2 | class PromisesResolver { 3 | static empty(value) { 4 | return new Promise((resolve) => { 5 | resolve(value); 6 | }); 7 | } 8 | 9 | static allEvenFailed(promises) { 10 | if (!Array.isArray(promises)) { 11 | throw Error('allEvenFailed can only handle an array of promises'); 12 | } 13 | 14 | return new Promise((resolve, reject) => { 15 | if (promises.length === 0) { 16 | return resolve([]); 17 | } 18 | 19 | let states = [], 20 | results = []; 21 | 22 | promises.forEach((promise, key) => { 23 | states[key] = false; // promises are not resolved by default 24 | }); 25 | 26 | promises.forEach((promise, key) => { 27 | function resolveState(result) { 28 | states[key] = true; 29 | results[key] = result; // result may be an error 30 | for (let i in states) { 31 | if (!states[i]) { 32 | return; 33 | } 34 | } 35 | 36 | resolve(results); 37 | } 38 | 39 | function resolveSuccess(result) { 40 | return resolveState({status: 'success', result: result}); 41 | } 42 | 43 | function resolveError(result) { 44 | return resolveState({status: 'error', error: result}) 45 | } 46 | 47 | // whether the promise ends with success or error, consider it done 48 | promise.then(resolveSuccess, resolveError); 49 | }); 50 | }); 51 | } 52 | } 53 | 54 | export default PromisesResolver; 55 | -------------------------------------------------------------------------------- /tests/lib/Utils/PromisesResolverTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var expect = require('chai').expect; 3 | 4 | import PromisesResolver from "../../../lib/Utils/PromisesResolver"; 5 | 6 | describe('PromisesResolver', () => { 7 | 8 | describe("allEvenFailed()", () => { 9 | 10 | it('should throw an exception when the argument is not an array', () => { 11 | var error = 'allEvenFailed can only handle an array of promises'; 12 | 13 | expect(() => { PromisesResolver.allEvenFailed('nope') }).to.throw(error); 14 | expect(() => { PromisesResolver.allEvenFailed() }).to.throw(error); 15 | expect(() => { PromisesResolver.allEvenFailed(1) }).to.throw(error); 16 | }); 17 | 18 | it('should resolve all promises', (done) => { 19 | let p1Result = false, 20 | p2Result = false, 21 | p1 = new Promise((resolve, reject) => { 22 | resolve('p1'); 23 | }), 24 | p2 = new Promise((resolve, reject) => { 25 | resolve('p2'); 26 | }); 27 | 28 | p1.then((result) => { 29 | p1Result = result; 30 | }); 31 | p2.then((result) => { 32 | p2Result = result; 33 | }); 34 | 35 | let result = PromisesResolver.allEvenFailed([p1, p2]); 36 | 37 | // Check that all promises were resolved 38 | result.then(() => { 39 | assert.equal(p1Result, 'p1'); 40 | assert.equal(p2Result, 'p2'); 41 | 42 | // assert.equal does not throw an error when failing, 43 | // so we use a callback that timeout in case of error 44 | done(); 45 | }); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /tests/lib/View/MenuViewTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import Entity from "../../../lib/Entity/Entity"; 4 | import MenuView from "../../../lib/View/MenuView"; 5 | 6 | describe('MenuView', function() { 7 | 8 | describe('disable()', () => { 9 | it('should be disabled by default', () => { 10 | const view = new MenuView(); 11 | view.setEntity(new Entity('post')); 12 | assert.isFalse(view.enabled); 13 | }); 14 | 15 | it('should be enabled if there\'s an enabled list view within the entity', () => { 16 | const entity = new Entity('post'); 17 | entity.listView().enable(); 18 | var view = new MenuView(); 19 | view.setEntity(entity); 20 | assert.isTrue(view.enabled); 21 | }); 22 | 23 | it('should be disabled if there\'s a disabled list view within the entity', () => { 24 | const entity = new Entity('post'); 25 | entity.listView().disable(); 26 | var view = new MenuView(); 27 | view.setEntity(entity); 28 | assert.isFalse(view.enabled); 29 | }); 30 | 31 | it('should be disabled if we disable it, even if there\'s an enabled list view within the entity', () => { 32 | const entity = new Entity('post'); 33 | entity.listView().enable(); 34 | var view = new MenuView() 35 | view.setEntity(entity); 36 | view.disable(); 37 | assert.isFalse(view.enabled); 38 | }); 39 | }) 40 | 41 | describe('icon', function() { 42 | it('should default to list glyphicon', function() { 43 | var view = new MenuView(new Entity('post')); 44 | assert.equal('', view.icon()); 45 | }); 46 | 47 | it('should be given icon otherwise', function() { 48 | var view = new MenuView(new Entity('post')).icon(''); 49 | assert.equal('', view.icon()); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/lib/DashboardTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import Dashboard from "../../lib/Dashboard"; 4 | 5 | describe('Dashboard', () => { 6 | describe('collections()', () => { 7 | it('should be an empty object by default', () => { 8 | assert.deepEqual(new Dashboard().collections(), {}) 9 | }); 10 | }); 11 | describe('addCollection()', () => { 12 | it('should add a collection', () => { 13 | let dashboard = new Dashboard(); 14 | const collection = { IAmAFakeCollection: true, name: () => 'foo' }; 15 | dashboard.addCollection(collection); 16 | assert.deepEqual(dashboard.collections(), { foo: collection }) 17 | }); 18 | }); 19 | describe('hasCollections()', () => { 20 | it('should return false for empty dashboards', () => { 21 | let dashboard = new Dashboard(); 22 | assert.notOk(dashboard.hasCollections()); 23 | }); 24 | it('should return true for non-empty dashboards', () => { 25 | let dashboard = new Dashboard(); 26 | dashboard.addCollection({ name: () => 'bar' }); 27 | assert.ok(dashboard.hasCollections()); 28 | }); 29 | }); 30 | describe('template()', () => { 31 | it('should return null by default', () => { 32 | let dashboard = new Dashboard(); 33 | assert.isNull(dashboard.template()); 34 | }); 35 | it('should return the template when called with no argument', () => { 36 | let dashboard = new Dashboard(); 37 | dashboard._template = 'foo'; 38 | assert.equal('foo', dashboard.template()); 39 | }); 40 | it('should set the template when called with an argument', () => { 41 | let dashboard = new Dashboard(); 42 | dashboard.template('foo'); 43 | assert.equal('foo', dashboard.template()); 44 | }); 45 | it('should return the dashboard when called with an argument', () => { 46 | let dashboard = new Dashboard(); 47 | assert.strictEqual(dashboard, dashboard.template('bar')); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /tests/lib/Field/ReferencedListFieldTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import Entry from "../../../lib/Entry"; 4 | import Entity from "../../../lib/Entity/Entity"; 5 | import Field from "../../../lib/Field/Field"; 6 | import ReferencedListField from "../../../lib/Field/ReferencedListField"; 7 | import ReferenceManyField from "../../../lib/Field/ReferenceManyField"; 8 | 9 | describe('ReferencedListField', function() { 10 | it('should retrieve referenceMany fields.', function () { 11 | var entity = new Entity('comments'); 12 | 13 | var referencedList = new ReferencedListField('myField'), 14 | ref1 = new ReferenceManyField('ref1'), 15 | ref2 = new ReferenceManyField('ref2'); 16 | 17 | referencedList 18 | .targetEntity(entity) 19 | .targetFields([ref1, ref2]); 20 | 21 | var references = referencedList.targetFields(); 22 | assert.equal(references.length, 2); 23 | assert.equal(references[0].name(), 'ref1'); 24 | }); 25 | 26 | it('should return information about grid column.', function () { 27 | var entity = new Entity('comments'); 28 | 29 | var referencedList = new ReferencedListField('myField'), 30 | field1 = new Field('f1').label('Field 1'), 31 | field2 = new Field('f2').label('Field 2'); 32 | 33 | referencedList 34 | .targetEntity(entity) 35 | .targetFields([field1, field2]); 36 | 37 | var columns = referencedList.getGridColumns(); 38 | 39 | assert.equal(columns.length, 2); 40 | assert.equal(columns[0].label, 'Field 1'); 41 | assert.equal(columns[1].field.name(), 'f2'); 42 | }); 43 | 44 | it('should store target entity configuration', function () { 45 | var entity = new Entity('comments'); 46 | 47 | var post = new Entity('posts'); 48 | post.editionView() 49 | .addField(new ReferencedListField('comments') 50 | .targetEntity(entity) 51 | .targetField(new Field('id')) 52 | ); 53 | 54 | assert.isNotNull(post.views['EditView'].getField('comments').targetEntity().listView()); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/lib/FactoryTest.js: -------------------------------------------------------------------------------- 1 | import Factory from "../../lib/Factory"; 2 | import Field from "../../lib/Field/Field"; 3 | import Collection from "../../lib/Collection"; 4 | 5 | var assert = require('chai').assert; 6 | 7 | describe('Factory', function() { 8 | describe('field', function() { 9 | it('should return new field from given type with correct name if type is registered', function() { 10 | var factory = new Factory(); 11 | class CustomField extends Field { 12 | constructor(name) { 13 | super(name); 14 | this._type = 'custom-string'; 15 | } 16 | } 17 | 18 | factory.registerFieldType('custom-string', CustomField); 19 | 20 | var field = factory.field('title', 'custom-string'); 21 | assert.equal('title', field.name()); 22 | assert.equal('custom-string', field.type()); 23 | }); 24 | 25 | it('should throw an error if type has not been already registered', function() { 26 | var factory = new Factory(); 27 | 28 | try { 29 | factory.field('title', 'non-existing-type'); 30 | } catch(e) { 31 | assert.equal('Unknown field type "non-existing-type".', e.message); 32 | return; 33 | } 34 | 35 | assert.equal(true, false); 36 | }); 37 | 38 | it('should return a string field by default', function() { 39 | var factory = new Factory(); 40 | factory.registerFieldType('string', Field); 41 | 42 | var field = factory.field('title'); 43 | assert.equal('string', field.type()); 44 | }); 45 | }); 46 | 47 | describe('collection()', () => { 48 | it('should return a new Collection with the correct entity', () => { 49 | var factory = new Factory(); 50 | var dummyEntity = { name: () => 'foo' }; 51 | var collection = factory.collection(dummyEntity); 52 | assert.instanceOf(collection, Collection); 53 | assert.equal(collection.getEntity(), dummyEntity); 54 | assert.equal(collection.name(), 'foo'); 55 | }) 56 | }) 57 | }); 58 | -------------------------------------------------------------------------------- /src/Field/ReferencedListField.js: -------------------------------------------------------------------------------- 1 | import ReferenceField from "./ReferenceField"; 2 | import ReferenceExtractor from '../Utils/ReferenceExtractor'; 3 | 4 | class ReferencedListField extends ReferenceField { 5 | constructor(name) { 6 | super(name); 7 | this._type = 'referenced_list'; 8 | this._targetReferenceField = null; 9 | this._targetFields = []; 10 | this._detailLink = false; 11 | this._listActions = []; 12 | this._entryCssClasses = null; 13 | } 14 | 15 | targetReferenceField(value) { 16 | if (!arguments.length) return this._targetReferenceField; 17 | this._targetReferenceField = value; 18 | return this; 19 | } 20 | 21 | targetFields(value) { 22 | if (!arguments.length) return this._targetFields; 23 | this._targetFields = value; 24 | 25 | return this; 26 | } 27 | 28 | getGridColumns() { 29 | let columns = []; 30 | for (let i = 0, l = this._targetFields.length ; i < l ; i++) { 31 | let field = this._targetFields[i]; 32 | columns.push({ 33 | field: field, 34 | label: field.label() 35 | }); 36 | } 37 | 38 | return columns; 39 | } 40 | 41 | getSortFieldName() { 42 | if (!this.sortField()) { 43 | return null; 44 | } 45 | 46 | return this._targetEntity.name() + '_ListView.' + this.sortField(); 47 | } 48 | 49 | listActions(actions) { 50 | if (!arguments.length) { 51 | return this._listActions; 52 | } 53 | 54 | this._listActions = actions; 55 | 56 | return this; 57 | } 58 | 59 | entryCssClasses(classes) { 60 | if (!arguments.length) { 61 | return this._entryCssClasses; 62 | } 63 | 64 | this._entryCssClasses = classes; 65 | 66 | return this; 67 | } 68 | 69 | getReferences(withRemoteComplete) { 70 | return ReferenceExtractor.getReferences(this._targetFields, withRemoteComplete); 71 | } 72 | 73 | getNonOptimizedReferences(withRemoteComplete) { 74 | return ReferenceExtractor.getNonOptimizedReferences(this._targetFields, withRemoteComplete); 75 | } 76 | 77 | getOptimizedReferences(withRemoteComplete) { 78 | return ReferenceExtractor.getOptimizedReferences(this._targetFields, withRemoteComplete); 79 | } 80 | } 81 | 82 | export default ReferencedListField; 83 | -------------------------------------------------------------------------------- /src/Entry.js: -------------------------------------------------------------------------------- 1 | import {clone, cloneAndFlatten, cloneAndNest} from './Utils/objectProperties'; 2 | 3 | 4 | class Entry { 5 | constructor(entityName, values, identifierValue) { 6 | this._entityName = entityName; 7 | this.values = values || {}; 8 | this._identifierValue = identifierValue; 9 | this.listValues = {}; 10 | } 11 | 12 | get entityName() { 13 | return this._entityName; 14 | } 15 | 16 | get identifierValue() { 17 | return this._identifierValue; 18 | } 19 | 20 | static createForFields(fields, entityName) { 21 | let entry = new Entry(entityName); 22 | fields.forEach(field => { 23 | entry.values[field.name()] = field.defaultValue(); 24 | }); 25 | return entry; 26 | 27 | } 28 | 29 | /** 30 | * Map a JS object from the REST API Response to an Entry 31 | * 32 | * @return {Entry} 33 | */ 34 | static createFromRest(restEntry, fields = [], entityName = '', identifierName = 'id') { 35 | if (!restEntry || Object.keys(restEntry).length == 0) { 36 | return Entry.createForFields(fields, entityName); 37 | } 38 | const excludedFields = fields.filter(f => !f.flattenable()).map(f => f.name()); 39 | 40 | let values = cloneAndFlatten(restEntry, excludedFields); 41 | 42 | fields.forEach(field => { 43 | let fieldName = field.name(); 44 | values[fieldName] = field.getMappedValue(values[fieldName], values); 45 | }); 46 | 47 | return new Entry(entityName, values, values[identifierName]); 48 | } 49 | 50 | /** 51 | * Map an array of JS objects from the REST API Response to an array of Entries 52 | * 53 | * @return {Array[Entry]} 54 | */ 55 | static createArrayFromRest(restEntries, fields, entityName, identifierName) { 56 | return restEntries.map(e => Entry.createFromRest(e, fields, entityName, identifierName)); 57 | } 58 | 59 | /** 60 | * Transform an Entry to a JS object for the REST API Request 61 | * 62 | * @return {Object} 63 | */ 64 | transformToRest(fields) { 65 | 66 | let restEntry = clone(this.values); 67 | fields.forEach(field => { 68 | let fieldName = field.name(); 69 | if (fieldName in restEntry) { 70 | restEntry[fieldName] = field.getTransformedValue(restEntry[fieldName], restEntry) 71 | } 72 | }); 73 | 74 | return cloneAndNest(restEntry); 75 | } 76 | 77 | } 78 | 79 | export default Entry; 80 | -------------------------------------------------------------------------------- /src/Queries/WriteQueries.js: -------------------------------------------------------------------------------- 1 | import Queries from './Queries' 2 | 3 | class WriteQueries extends Queries { 4 | 5 | /** 6 | * Create a new entity 7 | * Post the data to the API to create the new object 8 | * 9 | * @param {View} view the formView related to the entity 10 | * @param {Object} rawEntity the entity's object 11 | * 12 | * @returns {promise} the new object 13 | */ 14 | createOne(view, rawEntity) { 15 | return this._restWrapper 16 | .createOne(rawEntity, view.entity.name(), this._application.getRouteFor(view.entity, view.getUrl(), view.type), view.entity.createMethod()); 17 | } 18 | 19 | /** 20 | * Update an entity 21 | * Put the data to the API to create the new object 22 | * 23 | * @param {View} view the formView related to the entity 24 | * @param {Object} rawEntity the entity's object 25 | * @param {String} originEntityId if entity identifier is modified 26 | * 27 | * @returns {promise} the updated object 28 | */ 29 | updateOne(view, rawEntity, originEntityId) { 30 | let entityId = originEntityId || rawEntity[view.entity.identifier().name()]; 31 | 32 | // Update element data 33 | return this._restWrapper 34 | .updateOne(rawEntity, view.entity.name(), this._application.getRouteFor(view.entity, view.getUrl(entityId), view.type, entityId, view.identifier()), view.entity.updateMethod()); 35 | } 36 | 37 | /** 38 | * Delete an entity 39 | * Delete the data to the API 40 | * 41 | * @param {String} view the formView related to the entity 42 | * @param {*} entityId the entity's id 43 | * 44 | * @returns {promise} 45 | */ 46 | deleteOne(view, entityId) { 47 | return this._restWrapper 48 | .deleteOne(view.entity.name(), this._application.getRouteFor(view.entity, view.getUrl(entityId), view.type, entityId, view.identifier()), view.entity.deleteMethod()); 49 | } 50 | 51 | /** 52 | * Delete a batch of entity 53 | * Delete the data to the API 54 | * 55 | * @param {String} view the formView related to the entity 56 | * @param {*} entityIds the entities ids 57 | * 58 | * @returns {promise} 59 | */ 60 | batchDelete(view, entityIds) { 61 | let deleteOne = this.deleteOne.bind(this) 62 | let promises = entityIds.map(function (id) { 63 | return deleteOne(view, id); 64 | }); 65 | 66 | return this._promisesResolver.allEvenFailed(promises); 67 | } 68 | } 69 | 70 | export default WriteQueries 71 | -------------------------------------------------------------------------------- /tests/lib/Field/ReferenceFieldTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import Entry from "../../../lib/Entry"; 4 | import Entity from "../../../lib/Entity/Entity"; 5 | import Field from "../../../lib/Field/Field"; 6 | import ReferenceField from "../../../lib/Field/ReferenceField"; 7 | 8 | describe('ReferenceField', function() { 9 | describe('detailLink', function() { 10 | it('should be a detail link by default', function() { 11 | var field = new ReferenceField('foo'); 12 | assert.equal(true, field.isDetailLink()); 13 | }); 14 | }); 15 | 16 | it('Should create a fake view to keep entity', function () { 17 | var post = new Entity('posts'), 18 | comment = new Entity('comments'); 19 | 20 | comment.listView() 21 | .addField(new ReferenceField('post_id') 22 | .targetEntity(post) 23 | .targetField(new Field('id')) 24 | ); 25 | 26 | var fieldName = comment.views["ListView"].getField('post_id').targetEntity().name(); 27 | assert.equal(fieldName, 'posts'); 28 | }); 29 | 30 | describe('getSortFieldName', function () { 31 | it('should retrieve sortField', function () { 32 | var ref = new ReferenceField('human_id'), 33 | human = new Entity('human'); 34 | 35 | ref.entries = [ 36 | new Entry({ id: 1, human_id: 1, name: 'Suna'}), 37 | new Entry({ id: 2, human_id: 2, name: 'Boby'}), 38 | new Entry({ id: 3, human_id: 1, name: 'Mizute'}) 39 | ]; 40 | 41 | ref 42 | .targetEntity(human) 43 | .targetField(new Field('name')) 44 | .sortField('name') 45 | .sortDir('DESC'); 46 | 47 | human 48 | .identifier(new Field('id')) 49 | .editionView(); 50 | 51 | assert.equal(ref.getSortFieldName(), 'human_ListView.name'); 52 | }); 53 | }); 54 | 55 | describe('getIdentifierValues', function () { 56 | it('Should return identifier values', function () { 57 | var view = new ReferenceField('tags'), 58 | identifiers; 59 | 60 | identifiers = view.getIdentifierValues([{_id: 1, tags:[1, 3]}, {_id:3, id:6, tags:[4, 3]}]); 61 | assert.deepEqual(identifiers, ['1', '3', '4']); 62 | }); 63 | 64 | it('Should not return undefined values', function () { 65 | var view = new ReferenceField('tags'), 66 | identifiers; 67 | 68 | identifiers = view.getIdentifierValues([{_id: 1, tags:undefined}, {_id:3, id:6, tags:[3]}]); 69 | assert.deepEqual(identifiers, ['3']); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /tests/lib/Utils/ReferenceExtractorTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import ReferenceExtractor from "../../../lib/Utils/ReferenceExtractor"; 4 | 5 | describe('ReferenceExtractor', () => { 6 | 7 | describe('getReferences', () => { 8 | it('should return an empty object of empty field array', () => { 9 | assert.deepEqual({}, ReferenceExtractor.getReferences([])); 10 | }); 11 | 12 | it('should index by reference name', () => { 13 | const fields = [ 14 | { type() { return 'reference' }, name() { return 'foo' } }, 15 | { type() { return 'reference' }, name() { return 'bar' } } 16 | ]; 17 | assert.deepEqual({ 18 | foo: fields[0], 19 | bar: fields[1] 20 | }, ReferenceExtractor.getReferences(fields)); 21 | }) 22 | 23 | it('should filter out non-reference fields', () => { 24 | const fields = [ 25 | { type() { return 'reference' }, name() { return 'foo' } }, 26 | { type() { return 'reference_many' }, name() { return 'bar' } }, 27 | { type() { return 'referenced_list' }, name() { return 'baz' } }, 28 | { type() { return 'string' }, name() { return 'boo' } } 29 | ]; 30 | assert.deepEqual({ 31 | foo: fields[0], 32 | bar: fields[1] 33 | }, ReferenceExtractor.getReferences(fields)); 34 | }) 35 | }) 36 | 37 | describe('getReferencedLists', () => { 38 | it('should return an empty object of empty field array', () => { 39 | assert.deepEqual({}, ReferenceExtractor.getReferencedLists([])); 40 | }); 41 | 42 | it('should index by reference name', () => { 43 | const fields = [ 44 | { type() { return 'referenced_list' }, name() { return 'foo' } }, 45 | { type() { return 'referenced_list' }, name() { return 'bar' } } 46 | ]; 47 | assert.deepEqual({ 48 | foo: fields[0], 49 | bar: fields[1] 50 | }, ReferenceExtractor.getReferencedLists(fields)); 51 | }) 52 | 53 | it('should filter out non-referenced_list fields', () => { 54 | const fields = [ 55 | { type() { return 'reference' }, name() { return 'foo' } }, 56 | { type() { return 'reference_many' }, name() { return 'bar' } }, 57 | { type() { return 'referenced_list' }, name() { return 'baz' } }, 58 | { type() { return 'string' }, name() { return 'boo' } } 59 | ]; 60 | assert.deepEqual({ 61 | baz: fields[2] 62 | }, ReferenceExtractor.getReferencedLists(fields)); 63 | }) 64 | }) 65 | 66 | }); 67 | -------------------------------------------------------------------------------- /tests/lib/Utils/objectPropertiesTest.js: -------------------------------------------------------------------------------- 1 | const assert = require('chai').assert; 2 | 3 | import {cloneAndFlatten, cloneAndNest} from '../../../lib/Utils/objectProperties'; 4 | 5 | describe('cloneAndFlatten()', () => { 6 | it('should not allow a non-object parameter', () => 7 | assert.throws(() => cloneAndFlatten(2)) 8 | ); 9 | it('should return same values for flat objects', () => 10 | assert.deepEqual(cloneAndFlatten({ foo: 1, bar: 'baz'}), { foo: 1, bar: 'baz'}) 11 | ); 12 | it('should copy null values', () => 13 | assert.deepEqual(cloneAndFlatten({ foo: null }), { foo: null }) 14 | ); 15 | it('should clone the input', () => { 16 | let object = { foo: 1, bar: 'baz'}; 17 | let flatObject = cloneAndFlatten(object); 18 | assert.notStrictEqual(flatObject, object); 19 | flatObject.foo = 2; 20 | assert.equal(object.foo, 1); 21 | }); 22 | it('should flatten nested objects', () => 23 | assert.deepEqual(cloneAndFlatten({ a: 1, b: { c: 2 }, d: { e: 3, f: { g: 4, h: 5 } } }), 24 | { a: 1, 'b.c': 2, 'd.e': 3, 'd.f.g': 4, 'd.f.h': 5 }) 25 | ); 26 | it('should not flatten arrays', () => 27 | assert.deepEqual(cloneAndFlatten({ a: [1, 2, 3] }), { a: [1, 2, 3]}) 28 | ); 29 | it('should not flatten strings', () => 30 | assert.deepEqual(cloneAndFlatten({ a: "hello, world" }), { a: "hello, world" }) 31 | ); 32 | it('should not flatten dates', () => { 33 | let d = new Date(); 34 | assert.deepEqual(cloneAndFlatten({ a: d }), { a: d }) 35 | }); 36 | it('should not flatten excluded properties', () => 37 | assert.deepEqual(cloneAndFlatten({ a: 1, b: { c: 2 }, d: { e: 3, f: { g: 4, h: 5 } } }, ['d']), 38 | { a: 1, 'b.c': 2, d: { e: 3, f: { g: 4, h: 5 } } }) 39 | ); 40 | }); 41 | 42 | describe('cloneAndNest()', () => { 43 | it('should not allow a non-object parameter', () => 44 | assert.throws(() => cloneAndNest(2)) 45 | ); 46 | it('should return same values for flat objects', () => 47 | assert.deepEqual(cloneAndNest({ foo: 1, bar: 'baz'}), { foo: 1, bar: 'baz'}) 48 | ); 49 | it('should copy null values', () => 50 | assert.deepEqual(cloneAndNest({ foo: null }), { foo: null }) 51 | ); 52 | it('should clone the input', () => { 53 | let object = { foo: 1, bar: 'baz'}; 54 | let nestedObject = cloneAndNest(object); 55 | assert.notStrictEqual(nestedObject, object); 56 | nestedObject.foo = 2; 57 | assert.equal(object.foo, 1); 58 | }); 59 | it('should nest flattened objects', () => 60 | assert.deepEqual(cloneAndNest({ a: 1, 'b.c': 2, 'd.e': 3, 'd.f.g': 4, 'd.f.h': 5 }), 61 | { a: 1, b: { c: 2 }, d: { e: 3, f: { g: 4, h: 5 } } }) 62 | ); 63 | it('should not error on null nested objects', () => 64 | assert.deepEqual(cloneAndNest({ a: null, 'a.b': null }), 65 | { a: null }) 66 | ); 67 | }); 68 | -------------------------------------------------------------------------------- /src/Utils/objectProperties.js: -------------------------------------------------------------------------------- 1 | function isObject(value) { 2 | if (value === null) return false; 3 | if (typeof value !== 'object') return false; 4 | if (Array.isArray(value)) return false; 5 | if (Object.prototype.toString.call(value) === '[object Date]') return false; 6 | return true; 7 | } 8 | 9 | export function clone(object) { 10 | return Object.keys(object).reduce((values, name) => { 11 | if (object.hasOwnProperty(name)) { 12 | values[name] = object[name]; 13 | } 14 | return values; 15 | }, {}); 16 | } 17 | 18 | /* 19 | * Flatten nested object into a single level object with 'foo.bar' property names 20 | * 21 | * The parameter object is left unchanged. All values in the returned object are scalar. 22 | * 23 | * cloneAndFlatten({ a: 1, b: { c: 2 }, d: { e: 3, f: { g: 4, h: 5 } }, i: { j: 6 } }, ['i']) 24 | * // { a: 1, 'b.c': 2, 'd.e': 3, 'd.f.g': 4, 'd.f.h': 5, i: { j: 6 } } } 25 | * 26 | * @param {Object} object 27 | * @param {String[]} excludedProperties 28 | * @return {Object} 29 | */ 30 | export function cloneAndFlatten(object, excludedProperties = []) { 31 | if (typeof object !== 'object') { 32 | throw new Error('Expecting an object parameter'); 33 | } 34 | return Object.keys(object).reduce((values, name) => { 35 | if (!object.hasOwnProperty(name)) return values; 36 | if (isObject(object[name])) { 37 | if (excludedProperties.indexOf(name) === -1) { 38 | let flatObject = cloneAndFlatten(object[name]); 39 | Object.keys(flatObject).forEach(flatObjectKey => { 40 | if (!flatObject.hasOwnProperty(flatObjectKey)) return; 41 | values[name + '.' + flatObjectKey] = flatObject[flatObjectKey]; 42 | }) 43 | } else { 44 | values[name] = clone(object[name]); 45 | } 46 | } else { 47 | values[name] = object[name]; 48 | } 49 | return values; 50 | }, {}); 51 | }; 52 | 53 | /* 54 | * Clone flattened object into a nested object 55 | * 56 | * The parameter object is left unchanged. 57 | * 58 | * cloneAndNest({ a: 1, 'b.c': 2, 'd.e': 3, 'd.f.g': 4, 'd.f.h': 5 } ) 59 | * // { a: 1, b: { c: 2 }, d: { e: 3, f: { g: 4, h: 5 } } } 60 | * 61 | * @param {Object} object 62 | * @return {Object} 63 | */ 64 | export function cloneAndNest(object) { 65 | if (typeof object !== 'object') { 66 | throw new Error('Expecting an object parameter'); 67 | } 68 | return Object.keys(object).reduce((values, name) => { 69 | if (!object.hasOwnProperty(name)) return values; 70 | name.split('.').reduce((previous, current, index, list) => { 71 | if (previous != null) { 72 | if (typeof previous[current] === 'undefined') previous[current] = {}; 73 | if (index < (list.length - 1)) { 74 | return previous[current]; 75 | }; 76 | previous[current] = object[name]; 77 | } 78 | }, values) 79 | return values; 80 | }, {}) 81 | } 82 | -------------------------------------------------------------------------------- /src/DataStore/DataStore.js: -------------------------------------------------------------------------------- 1 | class DataStore { 2 | constructor() { 3 | this._entries = {}; 4 | } 5 | 6 | setEntries(name, entries) { 7 | this._entries[name] = entries; 8 | 9 | return this; 10 | } 11 | 12 | addEntry(name, entry) { 13 | if (!(name in this._entries)) { 14 | this._entries[name] = []; 15 | } 16 | 17 | this._entries[name].push(entry); 18 | } 19 | 20 | getEntries(name) { 21 | return this._entries[name] || []; 22 | } 23 | 24 | /** 25 | * Get first entry satisfying a filter function 26 | * 27 | * @example datastore.getEntry('books', book => book.title === 'War and Peace'); 28 | */ 29 | getFirstEntry(name, filter = () => true) { 30 | return this.getEntries(name) 31 | .filter(filter) 32 | .shift(); 33 | } 34 | 35 | getChoices(field) { 36 | let identifier = field.targetEntity().identifier().name(); 37 | let name = field.targetField().name(); 38 | 39 | return this.getEntries(field.targetEntity().uniqueId + '_choices').map(function(entry) { 40 | return { 41 | value: entry.values[identifier], 42 | label: entry.values[name] 43 | }; 44 | }); 45 | } 46 | 47 | fillReferencesValuesFromCollection(collection, referencedValues, fillSimpleReference) { 48 | fillSimpleReference = typeof (fillSimpleReference) === 'undefined' ? false : fillSimpleReference; 49 | 50 | for (let i = 0, l = collection.length; i < l; i++) { 51 | collection[i] = this.fillReferencesValuesFromEntry(collection[i], referencedValues, fillSimpleReference); 52 | } 53 | 54 | return collection; 55 | } 56 | 57 | fillReferencesValuesFromEntry(entry, referencedValues, fillSimpleReference) { 58 | for (let referenceField in referencedValues) { 59 | let reference = referencedValues[referenceField], 60 | choices = this.getReferenceChoicesById(reference), 61 | entries = [], 62 | identifier = reference.getMappedValue(entry.values[referenceField], entry.values); 63 | 64 | if (reference.type() === 'reference_many') { 65 | for (let i in identifier) { 66 | let id = identifier[i]; 67 | entries.push(choices[id]); 68 | } 69 | 70 | entry.listValues[referenceField] = entries; 71 | } else if (fillSimpleReference && identifier != null && identifier in choices) { 72 | entry.listValues[referenceField] = reference.getMappedValue(choices[identifier], entry.values); 73 | } 74 | } 75 | 76 | return entry; 77 | } 78 | 79 | getReferenceChoicesById(field) { 80 | let result = {}, 81 | targetField = field.targetField().name(), 82 | targetIdentifier = field.targetEntity().identifier().name(), 83 | entries = this.getEntries(field.targetEntity().uniqueId + '_values'); 84 | 85 | for (let i = 0, l = entries.length ; i < l ; i++) { 86 | let entry = entries[i]; 87 | result[entry.values[targetIdentifier]] = entry.values[targetField]; 88 | } 89 | 90 | return result; 91 | } 92 | } 93 | 94 | export default DataStore; 95 | -------------------------------------------------------------------------------- /src/Field/EmbeddedListField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | import Entity from "../Entity/Entity"; 3 | 4 | /** 5 | * Map an embedded list in the entry 6 | * 7 | * @example 8 | * 9 | * { 10 | * id: 123, 11 | * title: "hello, world", 12 | * comments: [ 13 | * { date: "2015-09-30", author: "John Doe", body: "Lorem Ipsum" }, 14 | * { date: "2015-10-02", author: "Jane Doe", body: "Sic dolor amet" } 15 | * ] 16 | * } 17 | * 18 | * let commentsField = new EmbeddedListField('comments') 19 | * .targetFields([ 20 | * new DateField('date'), 21 | * new StringField('author'), 22 | * new StringField('body') 23 | * ]) 24 | */ 25 | class EmbeddedListField extends Field { 26 | constructor(name) { 27 | super(name); 28 | this._type = 'embedded_list'; 29 | this._flattenable = false; 30 | this._targetEntity = new Entity(); // link to an empty entity by default 31 | this._targetFields = []; 32 | this._sortField = null; 33 | this._sortDir = null; 34 | this._permanentFilters = null; 35 | this._listActions = []; 36 | } 37 | 38 | /** 39 | * Optionally set the target Entity 40 | * 41 | * Useful if the embedded entries can be edited in standalone 42 | */ 43 | targetEntity(entity) { 44 | if (!arguments.length) { 45 | return this._targetEntity; 46 | } 47 | this._targetEntity = entity; 48 | 49 | return this; 50 | } 51 | 52 | /** 53 | * List the fields to map in the embedded entries 54 | * 55 | * @example 56 | * 57 | * embeddedListField.targetFields([ 58 | * new DateField('date'), 59 | * new StringField('author'), 60 | * new StringField('body') 61 | * ]) 62 | */ 63 | targetFields(value) { 64 | if (!arguments.length) return this._targetFields; 65 | this._targetFields = value; 66 | 67 | return this; 68 | } 69 | 70 | /** 71 | * Name of the field used for sorting. 72 | * 73 | * @param string 74 | */ 75 | sortField() { 76 | if (arguments.length) { 77 | this._sortField = arguments[0]; 78 | return this; 79 | } 80 | 81 | return this._sortField ? this._sortField : this.targetEntity().identifier().name(); 82 | } 83 | 84 | /** 85 | * Direction used for sorting. 86 | * 87 | * @param String either 'ASC' or 'DESC' 88 | */ 89 | sortDir() { 90 | if (arguments.length) { 91 | this._sortDir = arguments[0]; 92 | return this; 93 | } 94 | 95 | return this._sortDir; 96 | } 97 | 98 | listActions(actions) { 99 | if (!arguments.length) { 100 | return this._listActions; 101 | } 102 | 103 | this._listActions = actions; 104 | 105 | return this; 106 | } 107 | 108 | /** 109 | * Define permanent filters to be added to the REST API calls 110 | * 111 | * nga.field('post_id', 'reference').permanentFilters({ 112 | * published: true 113 | * }); 114 | * // related API call will be /posts/:id?published=true 115 | * 116 | * @param {Object} filters list of filters to apply to the call 117 | */ 118 | permanentFilters(filters) { 119 | if (!arguments.length) { 120 | return this._permanentFilters; 121 | } 122 | 123 | this._permanentFilters = filters; 124 | 125 | return this; 126 | } 127 | } 128 | 129 | export default EmbeddedListField; 130 | -------------------------------------------------------------------------------- /src/Menu/Menu.js: -------------------------------------------------------------------------------- 1 | import Entity from '../Entity/Entity' 2 | 3 | function alwaysFalse() { 4 | return false; 5 | } 6 | 7 | var uuid = 0; 8 | var autoClose = true; 9 | 10 | class Menu { 11 | constructor() { 12 | this._link = null; 13 | this._activeFunc = alwaysFalse; 14 | this._title = null; 15 | this._icon = false; 16 | this._children = []; 17 | this._template = false; 18 | this._autoClose = true; 19 | this.uuid = uuid++; 20 | } 21 | 22 | title() { 23 | if (arguments.length) { 24 | this._title = arguments[0]; 25 | return this; 26 | } 27 | return this._title; 28 | } 29 | 30 | isLink() { 31 | return !!this._link; 32 | } 33 | 34 | link() { 35 | if (arguments.length) { 36 | this._link = arguments[0]; 37 | if (this._activeFunc == alwaysFalse) { 38 | this._activeFunc = url => url.indexOf(this._link) === 0; 39 | } 40 | return this; 41 | } 42 | return this._link; 43 | } 44 | 45 | autoClose() { 46 | if (arguments.length) { 47 | autoClose = arguments[0]; 48 | return this; 49 | } 50 | return autoClose; 51 | } 52 | 53 | active(activeFunc) { 54 | if (arguments.length) { 55 | this._activeFunc = arguments[0]; 56 | return this; 57 | } 58 | return this._activeFunc; 59 | } 60 | 61 | isActive(url) { 62 | return this._activeFunc(url); 63 | } 64 | 65 | isChildActive(url) { 66 | return this.isActive(url) || (this.children().filter(menu => menu.isChildActive(url)).length > 0); 67 | } 68 | 69 | addChild(child) { 70 | if (!(child instanceof Menu)) { 71 | throw new Error('Only Menu instances are accepted as children of a Menu'); 72 | } 73 | this._children.push(child); 74 | return this; 75 | } 76 | 77 | hasChild() { 78 | return this._children.length > 0; 79 | } 80 | 81 | getChildByTitle(title) { 82 | return this.children().filter(child => child.title() == title).pop(); 83 | } 84 | 85 | children() { 86 | if (arguments.length) { 87 | this._children = arguments[0]; 88 | return this; 89 | } 90 | return this._children; 91 | } 92 | 93 | icon() { 94 | if (arguments.length) { 95 | this._icon = arguments[0]; 96 | return this; 97 | } 98 | return this._icon; 99 | } 100 | 101 | template() { 102 | if (arguments.length) { 103 | this._template = arguments[0]; 104 | return this; 105 | } 106 | return this._template; 107 | } 108 | 109 | populateFromEntity(entity) { 110 | if (!(entity instanceof Entity)) { 111 | throw new Error('populateFromEntity() only accepts an Entity parameter'); 112 | } 113 | this.title(entity.label()); 114 | this.active(path => path.indexOf(`/${entity.name()}/`) === 0 ); 115 | 116 | let search = ""; 117 | const defaultFilters = entity.listView().filters() 118 | .filter(filter => filter.pinned() && filter.defaultValue()) 119 | .reduce((filters, currentFilter) => Object.assign( 120 | {}, 121 | filters, 122 | { 123 | [currentFilter.name()] : currentFilter.getTransformedValue(currentFilter.defaultValue()) 124 | } 125 | ), {}); 126 | 127 | if(Object.keys(defaultFilters).length){ 128 | const encodedDefaultFilters = encodeURIComponent(JSON.stringify(defaultFilters)); 129 | search = `?search=${encodedDefaultFilters}` 130 | } 131 | 132 | this.link(`/${entity.name()}/list${search}`); 133 | // deprecated 134 | this.icon(entity.menuView().icon()); 135 | return this; 136 | } 137 | } 138 | 139 | export default Menu; 140 | -------------------------------------------------------------------------------- /tests/lib/Queries/WriteQueriesTest.js: -------------------------------------------------------------------------------- 1 | let assert = require('chai').assert, 2 | sinon = require('sinon'); 3 | 4 | import WriteQueries from "../../../lib/Queries/WriteQueries"; 5 | import DataStore from "../../../lib/DataStore/DataStore"; 6 | import PromisesResolver from "../../mock/PromisesResolver"; 7 | import Entity from "../../../lib/Entity/Entity"; 8 | import TextField from "../../../lib/Field/TextField"; 9 | import buildPromise from "../../mock/mixins"; 10 | 11 | describe('WriteQueries', () => { 12 | let writeQueries, 13 | restWrapper = {}, 14 | application = {}, 15 | entity, 16 | view; 17 | 18 | beforeEach(() => { 19 | application = { 20 | getRouteFor: (entity, generatedUrl, viewType, id) => { 21 | let url = 'http://localhost/' + encodeURIComponent(entity.name()); 22 | if (id) { 23 | url += '/' + encodeURIComponent(id); 24 | } 25 | 26 | return url; 27 | } 28 | }; 29 | 30 | writeQueries = new WriteQueries(restWrapper, PromisesResolver, application); 31 | entity = new Entity('cat'); 32 | view = entity.views["CreateView"] 33 | .addField(new TextField('name')); 34 | }); 35 | 36 | describe('createOne', () => { 37 | it('should POST an entity when calling createOne', () => { 38 | let rawEntity = {name: 'Mizu'}; 39 | 40 | restWrapper.createOne = sinon.stub().returns(buildPromise({data: rawEntity})); 41 | 42 | writeQueries.createOne(view, rawEntity) 43 | .then(rawEntry => { 44 | assert(restWrapper.createOne.calledWith(rawEntity, 'cat', 'http://localhost/cat')); 45 | 46 | let dataStore = new DataStore(); 47 | let entry = view.mapEntry(rawEntry); 48 | assert.equal(entry.values['data.name'], 'Mizu'); 49 | }); 50 | }); 51 | }); 52 | 53 | describe('updateOne', () => { 54 | let rawEntity = {id: 3, name: 'Mizu'}, 55 | updatedEntity = {id: 3, name: 'Mizute'}; 56 | 57 | restWrapper.updateOne = sinon.stub().returns(buildPromise({data: updatedEntity})); 58 | 59 | it('should PUT an entity when calling updateOne', () => { 60 | writeQueries.updateOne(view, rawEntity) 61 | .then(rawEntry => { 62 | assert(restWrapper.updateOne.calledWith(rawEntity, 'cat', 'http://localhost/cat/3')); 63 | 64 | let dataStore = new DataStore(); 65 | let entry = view.mapEntry(rawEntry); 66 | assert.equal(entry.values['data.name'], 'Mizute'); 67 | }); 68 | }); 69 | 70 | it('should PUT an entity when calling updateOne with an id', () => { 71 | writeQueries.updateOne(view, rawEntity, 3) 72 | .then(rawEntry => { 73 | assert(restWrapper.updateOne.calledWith(rawEntity, 'cat', 'http://localhost/cat/3')); 74 | 75 | let dataStore = new DataStore(); 76 | let entry = view.mapEntry(rawEntry); 77 | assert.equal(entry.values['data.name'], 'Mizute'); 78 | }); 79 | }); 80 | }); 81 | 82 | describe("deleteOne", () => { 83 | restWrapper.deleteOne = sinon.stub().returns(buildPromise({})); 84 | 85 | it('should DELETE an entity when calling deleteOne', () => { 86 | writeQueries.deleteOne(view, 1) 87 | .then(() => { 88 | assert(restWrapper.deleteOne.calledWith('cat', 'http://localhost/cat/1')); 89 | }); 90 | }); 91 | }); 92 | 93 | describe("batchDelete", () => { 94 | it('should DELETE entities when calling batchEntities', () => { 95 | restWrapper.deleteOne = sinon.stub().returns(buildPromise({})); 96 | 97 | writeQueries.batchDelete(view, [1, 2]) 98 | .then(() => { 99 | assert(restWrapper.deleteOne.calledTwice); 100 | assert(restWrapper.deleteOne.calledWith('cat', 'http://localhost/cat/1')); 101 | assert(restWrapper.deleteOne.calledWith('cat', 'http://localhost/cat/2')); 102 | }); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/Factory.js: -------------------------------------------------------------------------------- 1 | import Application from "./Application"; 2 | import Entity from "./Entity/Entity"; 3 | import DataStore from "./DataStore/DataStore"; 4 | import PromisesResolver from "./Utils/PromisesResolver"; 5 | 6 | import ReadQueries from "./Queries/ReadQueries"; 7 | import WriteQueries from "./Queries/WriteQueries"; 8 | 9 | import Field from "./Field/Field"; 10 | import BooleanField from "./Field/BooleanField"; 11 | import ChoiceField from "./Field/ChoiceField"; 12 | import ChoicesField from "./Field/ChoicesField"; 13 | import DateField from "./Field/DateField"; 14 | import DateTimeField from "./Field/DateTimeField"; 15 | import EmailField from "./Field/EmailField"; 16 | import EmbeddedListField from "./Field/EmbeddedListField"; 17 | import FloatField from "./Field/FloatField.js"; 18 | import FileField from "./Field/FileField"; 19 | import JsonField from "./Field/JsonField"; 20 | import NumberField from "./Field/NumberField"; 21 | import PasswordField from "./Field/PasswordField"; 22 | import ReferenceField from "./Field/ReferenceField"; 23 | import ReferencedListField from "./Field/ReferencedListField"; 24 | import ReferenceManyField from "./Field/ReferenceManyField"; 25 | import TemplateField from "./Field/TemplateField"; 26 | import TextField from "./Field/TextField"; 27 | import WysiwygField from "./Field/WysiwygField"; 28 | 29 | import Menu from './Menu/Menu'; 30 | import Collection from './Collection'; 31 | import Dashboard from './Dashboard'; 32 | import Entry from './Entry'; 33 | 34 | class Factory { 35 | constructor() { 36 | this._fieldTypes = []; 37 | this._init(); 38 | } 39 | 40 | application(name, debug) { 41 | return new Application(name, debug); 42 | } 43 | 44 | entity(name) { 45 | return new Entity(name); 46 | } 47 | 48 | field(name, type) { 49 | type = type || 'string'; 50 | 51 | if (!(type in this._fieldTypes)) { 52 | throw new Error(`Unknown field type "${type}".`); 53 | } 54 | 55 | return new this._fieldTypes[type](name); 56 | } 57 | 58 | registerFieldType(name, constructor) { 59 | this._fieldTypes[name] = constructor; 60 | } 61 | 62 | getFieldConstructor(name) { 63 | return this._fieldTypes[name]; 64 | } 65 | 66 | menu(entity) { 67 | let menu = new Menu(); 68 | if (entity) { 69 | menu.populateFromEntity(entity); 70 | } 71 | return menu; 72 | } 73 | 74 | dashboard() { 75 | return new Dashboard(); 76 | } 77 | 78 | collection(entity) { 79 | let collection = new Collection(); 80 | if (entity) { 81 | collection.setEntity(entity); 82 | } 83 | return collection; 84 | } 85 | 86 | getEntryConstructor() { 87 | return Entry; 88 | } 89 | 90 | getDataStore() { 91 | return new DataStore(); 92 | } 93 | 94 | getReadQueries(RestWrapper, PromisesResolver, Application) { 95 | return new ReadQueries(RestWrapper, PromisesResolver, Application); 96 | } 97 | 98 | getWriteQueries(RestWrapper, PromisesResolver, Application) { 99 | return new WriteQueries(RestWrapper, PromisesResolver, Application); 100 | } 101 | 102 | getPromisesResolver() { 103 | return PromisesResolver; 104 | } 105 | 106 | _init() { 107 | this.registerFieldType('boolean', BooleanField); 108 | this.registerFieldType('choice', ChoiceField); 109 | this.registerFieldType('choices', ChoicesField); 110 | this.registerFieldType('date', DateField); 111 | this.registerFieldType('datetime', DateTimeField); 112 | this.registerFieldType('email', EmailField); 113 | this.registerFieldType('embedded_list', EmbeddedListField); 114 | this.registerFieldType('float', FloatField); 115 | this.registerFieldType('string', Field); 116 | this.registerFieldType('file', FileField); 117 | this.registerFieldType('json', JsonField); 118 | this.registerFieldType('number', NumberField); 119 | this.registerFieldType('password', PasswordField); 120 | this.registerFieldType('reference', ReferenceField); 121 | this.registerFieldType('reference_many', ReferenceManyField); 122 | this.registerFieldType('referenced_list', ReferencedListField); 123 | this.registerFieldType('template', TemplateField); 124 | this.registerFieldType('text', TextField); 125 | this.registerFieldType('wysiwyg', WysiwygField); 126 | } 127 | } 128 | 129 | export default Factory; 130 | -------------------------------------------------------------------------------- /src/View/DeleteView.js: -------------------------------------------------------------------------------- 1 | import View from './View'; 2 | 3 | class DeleteView extends View { 4 | constructor(name) { 5 | super(name); 6 | this._type = 'DeleteView'; 7 | this._enabled = true; 8 | this._submitCreationSuccess = null; 9 | this._submitCreationError = null; 10 | } 11 | 12 | /** 13 | * Add a function to be executed after the delete succeeds. 14 | * 15 | * This is the ideal place to use the response to delete the entry, or 16 | * redirect to another view. 17 | * 18 | * If the function returns false, the default execution workflow is stopped. 19 | * This means that the function must provide a custom workflow. 20 | * 21 | * If the function throws an exception, the onSubmitError callback will 22 | * execute. 23 | * 24 | * The syntax depends on the framework calling the function. 25 | * 26 | * With ng-admin, the function can be an angular injectable, listing 27 | * required dependencies in an array. Among other, the function can receive 28 | * the following services: 29 | * - $event: the form submission event 30 | * - entry: the current Entry instance 31 | * - entity: the current entity 32 | * - form: the form object (for form validation and errors) 33 | * - progression: the controller for the loading indicator 34 | * - notification: the controller for top notifications 35 | * 36 | * The function can be asynchronous, in which case it should return 37 | * a Promise. 38 | * 39 | * @example 40 | * 41 | * post.deletionView().onSubmitSuccess(['progression', 'notification', '$state', 'entry', 'entity', function(progression, notification, $state, entry, entity) { 42 | * // stop the progress bar 43 | * progression.done(); 44 | * // add a notification 45 | * notification.log(`Element #${entry._identifierValue} successfully deleted.`, { addnCls: 'humane-flatty-success' }); 46 | * // redirect to the list view 47 | * $state.go($state.get('list'), { entity: entity.name() }); 48 | * // cancel the default action (redirect to the edition view) 49 | * return false; 50 | * }]) 51 | */ 52 | onSubmitSuccess(onSubmitSuccess) { 53 | if (!arguments.length) return this._onSubmitSuccess; 54 | this._onSubmitSuccess = onSubmitSuccess; 55 | return this; 56 | } 57 | 58 | /** 59 | * Add a function to be executed after the delete request receives a failed 60 | * http response from the server. 61 | * 62 | * This is the ideal place to use the response to delete the entry, display 63 | * server-side validation error, or redirect to another view. 64 | * 65 | * If the function returns false, the default execution workflow is stopped. 66 | * This means that the function must provide a custom workflow. 67 | * 68 | * The syntax depends on the framework calling the function. 69 | * 70 | * With ng-admin, the function can be an angular injectable, listing 71 | * required dependencies in an array. Among other, the function can receive 72 | * the following services: 73 | * - $event: the form submission event 74 | * - error: the response from the server 75 | * - errorMessage: the error message based on the response 76 | * - entry: the current Entry instance 77 | * - entity: the current entity 78 | * - form: the form object (for form validation and errors) 79 | * - progression: the controller for the loading indicator 80 | * - notification: the controller for top notifications 81 | * 82 | * The function can be asynchronous, in which case it should return 83 | * a Promise. 84 | * 85 | * @example 86 | * 87 | * post.deletionView().onSubmitError(['error', 'form', 'progression', 'notification', function(error, form, progression, notification) { 88 | * // stop the progress bar 89 | * progression.done(); 90 | * // add a notification 91 | * notification.log(`Failed to delete element.`, { addnCls: 'humane-flatty-error' }); 92 | * // cancel the default action (default error messages) 93 | * return false; 94 | * }]); 95 | */ 96 | onSubmitError(onSubmitError) { 97 | if (!arguments.length) return this._onSubmitError; 98 | this._onSubmitError = onSubmitError; 99 | return this; 100 | } 101 | } 102 | 103 | export default DeleteView; 104 | -------------------------------------------------------------------------------- /src/View/EditView.js: -------------------------------------------------------------------------------- 1 | import View from './View'; 2 | 3 | class EditView extends View { 4 | constructor(name) { 5 | super(name); 6 | this._type = 'EditView'; 7 | this._submitCreationSuccess = null; 8 | this._submitCreationError = null; 9 | } 10 | 11 | /** 12 | * Add a function to be executed after the update succeeds. 13 | * 14 | * This is the ideal place to use the response to update the entry, or 15 | * redirect to another view. 16 | * 17 | * If the function returns false, the default execution workflow is stopped. 18 | * This means that the function must provide a custom workflow. 19 | * 20 | * If the function throws an exception, the onSubmitError callback will 21 | * execute. 22 | * 23 | * The syntax depends on the framework calling the function. 24 | * 25 | * With ng-admin, the function can be an angular injectable, listing 26 | * required dependencies in an array. Among other, the function can receive 27 | * the following services: 28 | * - $event: the form submission event 29 | * - entry: the current Entry instance 30 | * - entity: the current entity 31 | * - form: the form object (for form validation and errors) 32 | * - progression: the controller for the loading indicator 33 | * - notification: the controller for top notifications 34 | * 35 | * The function can be asynchronous, in which case it should return 36 | * a Promise. 37 | * 38 | * @example 39 | * 40 | * post.editionView().onSubmitSuccess(['progression', 'notification', '$state', 'entry', 'entity', function(progression, notification, $state, entry, entity) { 41 | * // stop the progress bar 42 | * progression.done(); 43 | * // add a notification 44 | * notification.log(`Element #${entry._identifierValue} successfully edited.`, { addnCls: 'humane-flatty-success' }); 45 | * // redirect to the list view 46 | * $state.go($state.get('list'), { entity: entity.name() }); 47 | * // cancel the default action (redirect to the edition view) 48 | * return false; 49 | * }]) 50 | */ 51 | onSubmitSuccess(onSubmitSuccess) { 52 | if (!arguments.length) return this._onSubmitSuccess; 53 | this._onSubmitSuccess = onSubmitSuccess; 54 | return this; 55 | } 56 | 57 | /** 58 | * Add a function to be executed after the update request receives a failed 59 | * http response from the server. 60 | * 61 | * This is the ideal place to use the response to update the entry, display 62 | * server-side validation error, or redirect to another view. 63 | * 64 | * If the function returns false, the default execution workflow is stopped. 65 | * This means that the function must provide a custom workflow. 66 | * 67 | * The syntax depends on the framework calling the function. 68 | * 69 | * With ng-admin, the function can be an angular injectable, listing 70 | * required dependencies in an array. Among other, the function can receive 71 | * the following services: 72 | * - $event: the form submission event 73 | * - error: the response from the server 74 | * - errorMessage: the error message based on the response 75 | * - entry: the current Entry instance 76 | * - entity: the current entity 77 | * - form: the form object (for form validation and errors) 78 | * - progression: the controller for the loading indicator 79 | * - notification: the controller for top notifications 80 | * 81 | * The function can be asynchronous, in which case it should return 82 | * a Promise. 83 | * 84 | * @example 85 | * 86 | * post.editionView().onSubmitError(['error', 'form', 'progression', 'notification', function(error, form, progression, notification) { 87 | * // mark fields based on errors from the response 88 | * error.violations.forEach(violation => { 89 | * if (form[violation.propertyPath]) { 90 | * form[violation.propertyPath].$valid = false; 91 | * } 92 | * }); 93 | * // stop the progress bar 94 | * progression.done(); 95 | * // add a notification 96 | * notification.log(`Some values are invalid, see details in the form`, { addnCls: 'humane-flatty-error' }); 97 | * // cancel the default action (default error messages) 98 | * return false; 99 | * }]); 100 | */ 101 | onSubmitError(onSubmitError) { 102 | if (!arguments.length) return this._onSubmitError; 103 | this._onSubmitError = onSubmitError; 104 | return this; 105 | } 106 | } 107 | 108 | export default EditView; 109 | -------------------------------------------------------------------------------- /src/View/CreateView.js: -------------------------------------------------------------------------------- 1 | import View from './View'; 2 | 3 | class CreateView extends View { 4 | constructor(name) { 5 | super(name); 6 | this._type = 'CreateView'; 7 | this._submitCreationSuccess = null; 8 | this._submitCreationError = null; 9 | } 10 | 11 | /** 12 | * Add a function to be executed after the creation succeeds. 13 | * 14 | * This is the ideal place to use the response to update the entry, or 15 | * redirect to another view. 16 | * 17 | * If the function returns false, the default execution workflow is stopped. 18 | * This means that the function must provide a custom workflow. 19 | * 20 | * If the function throws an exception, the onSubmitError callback will 21 | * execute. 22 | * 23 | * The syntax depends on the framework calling the function. 24 | * 25 | * With ng-admin, the function can be an angular injectable, listing 26 | * required dependencies in an array. Among other, the function can receive 27 | * the following services: 28 | * - $event: the form submission event 29 | * - entry: the current Entry instance 30 | * - entity: the current entity 31 | * - form: the form object (for form validation and errors) 32 | * - progression: the controller for the loading indicator 33 | * - notification: the controller for top notifications 34 | * 35 | * The function can be asynchronous, in which case it should return 36 | * a Promise. 37 | * 38 | * @example 39 | * 40 | * post.creationView().onSubmitSuccess(['progression', 'notification', '$state', 'entry', 'entity', function(progression, notification, $state, entry, entity) { 41 | * // stop the progress bar 42 | * progression.done(); 43 | * // add a notification 44 | * notification.log(`Element #${entry._identifierValue} successfully created.`, { addnCls: 'humane-flatty-success' }); 45 | * // redirect to the list view 46 | * $state.go($state.get('list'), { entity: entity.name() }); 47 | * // cancel the default action (redirect to the edition view) 48 | * return false; 49 | * }]) 50 | * 51 | */ 52 | onSubmitSuccess(onSubmitSuccess) { 53 | if (!arguments.length) return this._onSubmitSuccess; 54 | this._onSubmitSuccess = onSubmitSuccess; 55 | return this; 56 | } 57 | 58 | /** 59 | * Add a function to be executed after the creation request receives a 60 | * failed http response from the server. 61 | * 62 | * This is the ideal place to use the response to update the entry, display 63 | * server-side validation error, or redirect to another view. 64 | * 65 | * If the function returns false, the default execution workflow is stopped. 66 | * This means that the function must provide a custom workflow. 67 | * 68 | * The syntax depends on the framework calling the function. 69 | * 70 | * With ng-admin, the function can be an angular injectable, listing 71 | * required dependencies in an array. Among other, the function can receive 72 | * the following services: 73 | * - $event: the form submission event 74 | * - error: the response from the server 75 | * - errorMessage: the error message based on the response 76 | * - entry: the current Entry instance 77 | * - entity: the current entity 78 | * - form: the form object (for form validation and errors) 79 | * - progression: the controller for the loading indicator 80 | * - notification: the controller for top notifications 81 | * 82 | * The function can be asynchronous, in which case it should return 83 | * a Promise. 84 | * 85 | * @example 86 | * 87 | * post.creationView().onSubmitError(['error', 'form', 'progression', 'notification', function(error, form, progression, notification) { 88 | * // mark fields based on errors from the response 89 | * error.violations.forEach(violation => { 90 | * if (form[violation.propertyPath]) { 91 | * form[violation.propertyPath].$valid = false; 92 | * } 93 | * }); 94 | * // stop the progress bar 95 | * progression.done(); 96 | * // add a notification 97 | * notification.log(`Some values are invalid, see details in the form`, { addnCls: 'humane-flatty-error' }); 98 | * // cancel the default action (default error messages) 99 | * return false; 100 | * }]); 101 | */ 102 | onSubmitError(onSubmitError) { 103 | if (!arguments.length) return this._onSubmitError; 104 | this._onSubmitError = onSubmitError; 105 | return this; 106 | } 107 | } 108 | 109 | export default CreateView; 110 | -------------------------------------------------------------------------------- /tests/lib/Entity/EntityTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import Entity from '../../../lib/Entity/Entity'; 4 | import Field from '../../../lib/Field/Field'; 5 | 6 | describe('Entity', function() { 7 | describe('views', function() { 8 | it('should create all views when creating new entity', function() { 9 | var entity = new Entity('post'); 10 | assert.deepEqual([ 11 | 'DashboardView', 12 | 'MenuView', 13 | 'ListView', 14 | 'CreateView', 15 | 'EditView', 16 | 'DeleteView', 17 | 'BatchDeleteView', 18 | 'ExportView', 19 | 'ShowView' 20 | ], Object.keys(entity.views)); 21 | }); 22 | }); 23 | 24 | describe('label', function() { 25 | it('should return given label if already set', function() { 26 | var post = new Entity('post').label('Article'); 27 | assert.equal('Article', post.label()); 28 | }); 29 | 30 | it('should return entity name if no label has been set', function() { 31 | var post = new Entity('post'); 32 | assert.equal('Post', post.label()); 33 | }); 34 | }); 35 | 36 | describe('readOnly()', function() { 37 | var entity; 38 | 39 | beforeEach(function() { 40 | entity = new Entity('post'); 41 | }); 42 | 43 | it('should not be read-only by default', function() { 44 | assert.equal(false, entity.isReadOnly); 45 | }); 46 | 47 | it('should set read-only attribute', function() { 48 | entity.readOnly(); 49 | assert.equal(true, entity.isReadOnly); 50 | }); 51 | 52 | it('should disable all edition views', function() { 53 | entity.readOnly(); 54 | entity.views.ListView.enable(); 55 | entity.views.DashboardView.enable(); 56 | 57 | assert.equal(true, entity.menuView().enabled); 58 | assert.equal(true, entity.dashboardView().enabled); 59 | assert.equal(true, entity.listView().enabled); 60 | assert.equal(false, entity.creationView().enabled); 61 | assert.equal(false, entity.editionView().enabled); 62 | assert.equal(false, entity.deletionView().enabled); 63 | }); 64 | }); 65 | 66 | describe('createMethod', function() { 67 | it('should return given createMethod if already set', function() { 68 | var post = new Entity('post').createMethod('put'); 69 | assert.equal('put', post.createMethod()); 70 | }); 71 | 72 | it('should return null if no createMethod has been set', function() { 73 | var post = new Entity('post'); 74 | assert.equal(null, post.createMethod()); 75 | }); 76 | }); 77 | 78 | describe('updateMethod', function() { 79 | it('should return given updateMethod if already set', function() { 80 | var post = new Entity('post').updateMethod('post'); 81 | assert.equal('post', post.updateMethod()); 82 | }); 83 | 84 | it('should return null if no updateMethod has been set', function() { 85 | var post = new Entity('post'); 86 | assert.equal(null, post.updateMethod()); 87 | }); 88 | }); 89 | 90 | describe('retrieveMethod', function() { 91 | it('should return given retrieveMethod if already set', function() { 92 | var post = new Entity('post').retrieveMethod('post'); 93 | assert.equal('post', post.retrieveMethod()); 94 | }); 95 | 96 | it('should return null if no retrieveMethod has been set', function() { 97 | var post = new Entity('post'); 98 | assert.equal(null, post.retrieveMethod()); 99 | }); 100 | }); 101 | 102 | describe('deleteMethod', function() { 103 | it('should return given deleteMethod if already set', function() { 104 | var post = new Entity('post').deleteMethod('post'); 105 | assert.equal('post', post.deleteMethod()); 106 | }); 107 | 108 | it('should return null if no deleteMethod has been set', function() { 109 | var post = new Entity('post'); 110 | assert.equal(null, post.deleteMethod()); 111 | }); 112 | }); 113 | 114 | describe('identifier', function() { 115 | it('should set default identifier', function() { 116 | var post = new Entity('post'); 117 | assert.equal('id', post.identifier().name()); 118 | }); 119 | 120 | it('should set custom identifier', function() { 121 | var post = new Entity('post').identifier(new Field('my_id')); 122 | assert.equal('my_id', post.identifier().name()); 123 | }); 124 | 125 | it('should throw error on wrong argument', function() { 126 | assert.throw(function () { new Entity('post').identifier('my_id'); }, Error, 'Entity post: identifier must be an instance of Field'); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /tests/lib/DataStore/DataStore.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import DataStore from "../../../lib/DataStore/DataStore"; 4 | import Entity from "../../../lib/Entity/Entity"; 5 | import Entry from "../../../lib/Entry"; 6 | import Field from "../../../lib/Field/Field"; 7 | import ReferenceField from "../../../lib/Field/ReferenceField"; 8 | import ReferenceManyField from "../../../lib/Field/ReferenceManyField"; 9 | import View from "../../../lib/View/View"; 10 | 11 | describe('DataStore', function() { 12 | var dataStore; 13 | 14 | beforeEach(function() { 15 | dataStore = new DataStore(); 16 | }); 17 | 18 | describe('getReferenceChoicesById', function () { 19 | it('should retrieve choices by id.', function () { 20 | var ref = new ReferenceField('human_id'), 21 | human = new Entity('human'); 22 | 23 | human 24 | .identifier(new Field('id')) 25 | .editionView(); 26 | 27 | ref 28 | .targetEntity(human) 29 | .targetField(new Field('name')); 30 | 31 | dataStore.setEntries(human.uniqueId + '_values', [ 32 | {values: { id: 1, human_id: 1, name: 'Suna'}}, 33 | {values: { id: 2, human_id: 2, name: 'Boby'}}, 34 | {values: { id: 3, human_id: 1, name: 'Mizute'}} 35 | ]); 36 | 37 | var choices = dataStore.getReferenceChoicesById(ref); 38 | assert.equal(ref.type(), 'reference'); 39 | assert.deepEqual(choices, { 40 | 1: 'Suna', 41 | 2: 'Boby', 42 | 3: 'Mizute' 43 | }); 44 | }); 45 | }); 46 | 47 | describe('getChoices', function () { 48 | it('should retrieve choices.', function () { 49 | var ref = new ReferenceField('human_id'), 50 | human = new Entity('human'); 51 | 52 | human 53 | .identifier(new Field('id')) 54 | .editionView(); 55 | 56 | ref 57 | .targetField(new Field('name')) 58 | .targetEntity(human); 59 | 60 | dataStore.setEntries(human.uniqueId + '_choices', [ 61 | new Entry('human', { id: 1, human_id: 1, name: 'Suna'}), 62 | new Entry('human', { id: 2, human_id: 2, name: 'Boby'}), 63 | new Entry('human', { id: 3, human_id: 1, name: 'Mizute'}) 64 | ]); 65 | 66 | assert.equal(ref.type(), 'reference'); 67 | assert.deepEqual(dataStore.getChoices(ref), [ 68 | { value: 1, label: 'Suna'}, 69 | { value: 2, label: 'Boby'}, 70 | { value: 3, label: 'Mizute'} 71 | ]); 72 | }); 73 | }); 74 | 75 | describe('fillReferencesValuesFromCollection', function() { 76 | it('should fill reference values of a collection', function () { 77 | var entry1 = new Entry(), 78 | entry2 = new Entry(), 79 | entry3 = new Entry(), 80 | human = new Entity('humans'), 81 | tag = new Entity('tags'), 82 | ref1 = new ReferenceField('human_id'), 83 | ref2 = new ReferenceManyField('tags'); 84 | 85 | human.editionView().identifier(new Field('id')); 86 | tag.editionView().identifier(new Field('id')); 87 | ref1 88 | .targetEntity(human) 89 | .targetField(new Field('name')); 90 | dataStore.setEntries(ref1.targetEntity().uniqueId + '_values', [ 91 | {values: {id: 1, name: 'Bob'}}, 92 | {values: {id: 2, name: 'Daniel'}}, 93 | {values: {id: 3, name: 'Jack'}} 94 | ]); 95 | 96 | ref2 97 | .targetEntity(tag) 98 | .targetField(new Field('label')); 99 | dataStore.setEntries(ref2.targetEntity().uniqueId + '_values', [ 100 | {values: {id: 1, label: 'Photo'}}, 101 | {values: {id: 2, label: 'Watch'}}, 102 | {values: {id: 3, label: 'Panda'}} 103 | ]); 104 | 105 | entry1.values.human_id = 1; 106 | entry1.values.tags = [1, 3]; 107 | entry2.values.human_id = 1; 108 | entry2.values.tags = [2]; 109 | entry3.values.human_id = 3; 110 | 111 | var collection = [entry1, entry2, entry3]; 112 | var referencedValues = { 113 | human_id: ref1, 114 | tags: ref2 115 | }; 116 | 117 | collection = dataStore.fillReferencesValuesFromCollection(collection, referencedValues, true); 118 | 119 | assert.equal(collection.length, 3); 120 | assert.equal(collection[0].listValues.human_id, 'Bob'); 121 | assert.deepEqual(collection[0].listValues.tags, ['Photo', 'Panda']); 122 | assert.deepEqual(collection[1].listValues.tags, ['Watch']); 123 | assert.equal(collection[2].listValues.human_id, 'Jack'); 124 | assert.deepEqual(collection[2].listValues.tags, []); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /tests/lib/EntryTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import Entity from "../../lib/Entity/Entity"; 4 | import Entry from "../../lib/Entry"; 5 | import Field from "../../lib/Field/Field"; 6 | import ReferenceManyField from "../../lib/Field/ReferenceManyField"; 7 | import JsonField from "../../lib/Field/JsonField"; 8 | 9 | describe('Entry', function() { 10 | 11 | describe('createFromRest()', function() { 12 | 13 | it('should return an entry with no value if REST entry is empty and fields is empty', function() { 14 | var mappedEntry = Entry.createFromRest({}, []); 15 | assert.deepEqual({}, mappedEntry.values); 16 | }); 17 | 18 | it('should return an entry with default values if REST entry is empty', function() { 19 | var fields = [ 20 | new Field('id'), 21 | new Field('title').defaultValue('The best title'), 22 | new Field('author.name'), 23 | new ReferenceManyField('tags'), 24 | new JsonField('address').defaultValue({ number: null, street: null, city: null }) 25 | ]; 26 | var mappedEntry = Entry.createFromRest({}, fields); 27 | assert.deepEqual({ 28 | id: null, 29 | title: 'The best title', 30 | 'author.name': null, 31 | tags: null, 32 | address: { number: null, street: null, city: null } 33 | }, mappedEntry.values); 34 | }); 35 | 36 | it('should map each value to related field if existing', () => { 37 | var mappedEntry = Entry.createFromRest({ 38 | id: 1, 39 | title: 'ng-admin + ES6 = pure awesomeness!', 40 | body: 'Really, it rocks!', 41 | tags: [1, 2, 4] 42 | }); 43 | 44 | assert.deepEqual({ 45 | id: 1, 46 | title: 'ng-admin + ES6 = pure awesomeness!', 47 | body: 'Really, it rocks!', 48 | tags: [1, 2, 4] 49 | }, mappedEntry.values); 50 | }); 51 | 52 | it('should map even if the value is absent', () => { 53 | var fields = [new Field('foo').map((v,e) => e.tags.join(' '))]; 54 | var mappedEntry = Entry.createFromRest({ 55 | tags: [1, 2, 3, 4] 56 | }, fields); 57 | assert.deepEqual({ 58 | foo: '1 2 3 4', 59 | tags: [1, 2, 3, 4] 60 | }, mappedEntry.values) 61 | }); 62 | 63 | it('should set as identifierValue value for identifier field', () => { 64 | var mappedEntry = Entry.createFromRest({ id: 1 }); 65 | assert.equal(1, mappedEntry.identifierValue); 66 | }); 67 | 68 | it('should set as identifierValue value using identifier field name', () => { 69 | var mappedEntry = Entry.createFromRest({ id: 1, foo: 2 }, [], '', 'foo'); 70 | assert.equal(2, mappedEntry.identifierValue); 71 | }); 72 | 73 | it('should flatten nested entries', () => { 74 | var mappedEntry = Entry.createFromRest({ 75 | id: 1, 76 | title: 'ng-admin + ES6 = pure awesomeness!', 77 | author: { name: 'John Doe' } 78 | }); 79 | 80 | assert.deepEqual({ 81 | id: 1, 82 | title: 'ng-admin + ES6 = pure awesomeness!', 83 | 'author.name': 'John Doe' 84 | }, mappedEntry.values); 85 | }); 86 | 87 | it('should not flatten nested entries mapped by a not flattenable field', () => { 88 | var mappedEntry = Entry.createFromRest({ 89 | id: 1, 90 | title: 'ng-admin + ES6 = pure awesomeness!', 91 | address: { number: 21, street: 'JumpStreet', city: 'Vancouver' } // mapped as JSON field => not flattenable 92 | }, [new JsonField('address')]); 93 | 94 | assert.deepEqual({ 95 | id: 1, 96 | title: 'ng-admin + ES6 = pure awesomeness!', 97 | address: { number: 21, street: 'JumpStreet', city: 'Vancouver' } 98 | }, mappedEntry.values); 99 | }); 100 | 101 | it('should apply map() functions from fields', () => { 102 | var mappedEntry = Entry.createFromRest({ 103 | id: 1, 104 | title: 'ng-admin + ES6 = pure awesomeness!' 105 | }, [new Field('title').map(s => s.toUpperCase())]); 106 | assert.deepEqual({ 107 | id: 1, 108 | title: 'NG-ADMIN + ES6 = PURE AWESOMENESS!' 109 | }, mappedEntry.values); 110 | }) 111 | }); 112 | 113 | describe('transformToRest()', function() { 114 | it('should provide both the value and entry when invoking the callback', () => { 115 | var field = new Field('foo'); 116 | field.defaultValue(2); 117 | 118 | var entry = Entry.createForFields([field]); 119 | 120 | field.transform((value, entry) => { 121 | assert.equal(value, 2); 122 | assert.deepEqual(entry, {foo: 2}); 123 | }); 124 | 125 | entry.transformToRest([field]); 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /tests/lib/View/ListViewTest.js: -------------------------------------------------------------------------------- 1 | /* global describe,it */ 2 | 3 | var assert = require('chai').assert; 4 | 5 | import Entity from '../../../lib/Entity/Entity'; 6 | import Entry from '../../../lib/Entry'; 7 | import Field from '../../../lib/Field/Field'; 8 | import ReferenceField from '../../../lib/Field/ReferenceField'; 9 | import ReferenceManyField from '../../../lib/Field/ReferenceManyField'; 10 | import ListView from '../../../lib/View/ListView'; 11 | 12 | describe('ListView', function() { 13 | describe('listActions()', function () { 14 | it('should return the view', function () { 15 | var view = new ListView(); 16 | assert.equal(view.listActions(['edit']), view); 17 | }); 18 | 19 | it('should store the listActions for the Datagrid', function () { 20 | var view = new ListView(); 21 | assert.deepEqual(view.listActions(['edit']).listActions(), ['edit']); 22 | }); 23 | }); 24 | 25 | describe('map()', function () { 26 | it('should apply the function argument to all list values', function () { 27 | var list = new ListView('allCats'); 28 | list 29 | .setEntity(new Entity('cats').identifier(new Field('id'))) 30 | .addField(new Field('name').map(function (value) { 31 | return value.substr(0, 5) + '...'; 32 | })); 33 | 34 | var entries = list.mapEntries([ 35 | { id: 1, human_id: 1, name: 'Suna'}, 36 | { id: 2, human_id: 2, name: 'Boby'}, 37 | { id: 3, human_id: 1, name: 'Mizute'} 38 | ]); 39 | 40 | assert.equal(entries[0].values.id, 1); 41 | assert.equal(entries[0].values.name, 'Suna...'); 42 | assert.equal(entries[2].values.id, 3); 43 | assert.equal(entries[2].values.name, 'Mizut...'); 44 | }); 45 | }); 46 | 47 | describe('getFilterReferences()', function() { 48 | it('should return only reference and reference_many fields', function() { 49 | var post = new Entity('post'); 50 | var category = new ReferenceField('category'); 51 | var tags = new ReferenceManyField('tags'); 52 | var view = new ListView(post) 53 | .fields([ 54 | new Field('title'), 55 | tags 56 | ]) 57 | .filters([ 58 | category 59 | ]); 60 | 61 | assert.deepEqual({category: category}, view.getFilterReferences()); 62 | }); 63 | 64 | it('should return only filter reference with refresh complete if withRemoteComplete is true', function() { 65 | var post = new Entity('post'); 66 | var category = new ReferenceField('category').remoteComplete(true); 67 | var tags = new ReferenceManyField('tags').remoteComplete(false); 68 | 69 | var view = new ListView(post) 70 | .fields([ 71 | new Field('title'), 72 | tags 73 | ]) 74 | .filters([ 75 | category 76 | ]); 77 | 78 | assert.deepEqual({category: category}, view.getFilterReferences(true)); 79 | }); 80 | 81 | it('should return only filter reference with no remote complete if withRemoteComplete is set to false', function() { 82 | var post = new Entity('post'); 83 | var category = new ReferenceField('category').remoteComplete(true); 84 | var tags = new ReferenceManyField('tags').remoteComplete(false); 85 | var view = new ListView(post) 86 | .fields([ 87 | new Field('title'), 88 | tags 89 | ]) 90 | .filters([ 91 | category 92 | ]); 93 | 94 | assert.deepEqual({ tags: tags }, view.getReferences(false)); 95 | }); 96 | }); 97 | 98 | describe('getEntryCssClasses', function() { 99 | var view; 100 | 101 | beforeEach(function() { 102 | view = new ListView(new Entity('post')); 103 | }); 104 | 105 | it('should return result of callback called with entry if function', function() { 106 | view.entryCssClasses(entry => entry.values.id % 2 ? "odd-entry" : "even-entry"); 107 | 108 | var entry = new Entry('post', { id: 1 }); 109 | assert.equal("odd-entry", view.getEntryCssClasses(entry)); 110 | 111 | entry = new Entry('post', { id: 2 }); 112 | assert.equal("even-entry", view.getEntryCssClasses(entry)); 113 | }); 114 | 115 | it('should concatenate all elements if array', function() { 116 | view.entryCssClasses(["important", "approved"]); 117 | assert.equal("important approved", view.getEntryCssClasses()); 118 | }); 119 | 120 | it('should return passed classes if neither function nor array', function() { 121 | view.entryCssClasses("important approved"); 122 | assert.equal("important approved", view.getEntryCssClasses()); 123 | }); 124 | 125 | it('should return an empty string by default', function() { 126 | assert.equal(view.getEntryCssClasses(), ''); 127 | }); 128 | }); 129 | 130 | }); 131 | -------------------------------------------------------------------------------- /src/Field/ReferenceField.js: -------------------------------------------------------------------------------- 1 | import Field from "./Field"; 2 | 3 | class ReferenceField extends Field { 4 | constructor(name) { 5 | super(name); 6 | this._type = 'reference'; 7 | this._targetEntity = null; 8 | this._targetField = null; 9 | this._perPage = 30; 10 | this._permanentFilters = null; 11 | this._sortField = null; 12 | this._sortDir = null; 13 | this._singleApiCall = false; 14 | this._detailLink = true; 15 | this._remoteComplete = false; 16 | this._remoteCompleteOptions= { 17 | refreshDelay: 500 18 | }; 19 | } 20 | 21 | perPage(perPage) { 22 | if (!arguments.length) return this._perPage; 23 | this._perPage = perPage; 24 | return this; 25 | } 26 | 27 | datagridName() { 28 | return this._targetEntity.name() + '_ListView'; 29 | } 30 | 31 | targetEntity(entity) { 32 | if (!arguments.length) { 33 | return this._targetEntity; 34 | } 35 | this._targetEntity = entity; 36 | 37 | return this; 38 | } 39 | 40 | targetField(field) { 41 | if (!arguments.length) return this._targetField; 42 | this._targetField = field; 43 | 44 | return this; 45 | } 46 | 47 | /** 48 | * Define permanent filters to be added to the REST API calls 49 | * 50 | * nga.field('post_id', 'reference').permanentFilters({ 51 | * published: true 52 | * }); 53 | * // related API call will be /posts/:id?published=true 54 | * 55 | * @param {Object} filters list of filters to apply to the call 56 | */ 57 | permanentFilters(filters) { 58 | if (!arguments.length) { 59 | return this._permanentFilters; 60 | } 61 | 62 | this._permanentFilters = filters; 63 | 64 | return this; 65 | } 66 | 67 | /** 68 | * @deprecated use permanentFilters() instead 69 | */ 70 | filters(filters) { 71 | console.warn('ReferenceField.filters() is deprecated, please use ReferenceField.permanentFilters() instead'); 72 | return this.permanentFilters(filters); 73 | } 74 | 75 | sortField() { 76 | if (arguments.length) { 77 | this._sortField = arguments[0]; 78 | return this; 79 | } 80 | 81 | return this._sortField; 82 | } 83 | 84 | sortDir() { 85 | if (arguments.length) { 86 | this._sortDir = arguments[0]; 87 | return this; 88 | } 89 | 90 | return this._sortDir; 91 | } 92 | 93 | singleApiCall(singleApiCall) { 94 | if (!arguments.length) return this._singleApiCall; 95 | this._singleApiCall = singleApiCall; 96 | return this; 97 | } 98 | 99 | hasSingleApiCall() { 100 | return typeof this._singleApiCall === 'function'; 101 | } 102 | 103 | getSingleApiCall(identifiers) { 104 | return this.hasSingleApiCall() ? this._singleApiCall(identifiers) : this._singleApiCall; 105 | } 106 | 107 | getIdentifierValues(rawValues) { 108 | let results = {}; 109 | let identifierName = this._name; 110 | for (let i = 0, l = rawValues.length ; i < l ; i++) { 111 | let identifier = rawValues[i][identifierName]; 112 | if (identifier == null) { 113 | continue; 114 | } 115 | 116 | if (identifier instanceof Array) { 117 | for (let j in identifier) { 118 | results[identifier[j]] = true; 119 | } 120 | continue; 121 | } 122 | 123 | results[identifier] = true; 124 | } 125 | 126 | return Object.keys(results); 127 | } 128 | 129 | getSortFieldName() { 130 | if (!this.sortField()) { 131 | return null; 132 | } 133 | 134 | return this._targetEntity.name() + '_ListView.' + this.sortField(); 135 | } 136 | 137 | /** 138 | * Enable autocompletion using REST API for choices. 139 | * 140 | * Available options are: 141 | * 142 | * * `refreshDelay`: minimal delay between two API calls in milliseconds. By default: 500. 143 | * * `searchQuery`: a function returning the parameters to add to the query string basd on the input string. 144 | * 145 | * new ReferenceField('authors') 146 | * .targetEntity(author) 147 | * .targetField(new Field('name')) 148 | * .remoteComplete(true, { 149 | * refreshDelay: 300, 150 | * // populate choices from the response of GET /tags?q=XXX 151 | * searchQuery: function(search) { return { q: search }; } 152 | * }) 153 | * .perPage(10) // limit the number of results to 10 154 | * 155 | * @param {Boolean} remoteComplete true to enable remote complete. False by default 156 | * @param {Object} options Remote completion options (optional) 157 | */ 158 | remoteComplete(remoteComplete, options) { 159 | if (!arguments.length) return this._remoteComplete; 160 | this._remoteComplete = remoteComplete; 161 | if (options) { 162 | this.remoteCompleteOptions(options); 163 | } 164 | return this; 165 | } 166 | 167 | remoteCompleteOptions(options) { 168 | if (!arguments.length) return this._remoteCompleteOptions; 169 | this._remoteCompleteOptions = options; 170 | return this; 171 | } 172 | } 173 | 174 | export default ReferenceField; 175 | -------------------------------------------------------------------------------- /src/View/ListView.js: -------------------------------------------------------------------------------- 1 | import View from './View'; 2 | import orderElement from "../Utils/orderElement"; 3 | 4 | class ListView extends View { 5 | constructor(name) { 6 | super(name); 7 | 8 | this._type = 'ListView'; 9 | this._perPage = 30; 10 | this._infinitePagination = false; 11 | this._listActions = []; 12 | this._batchActions = ['delete']; 13 | this._filters = []; 14 | this._permanentFilters = {}; 15 | this._exportFields = null; 16 | this._exportOptions = {}; 17 | this._entryCssClasses = null; 18 | 19 | this._sortField = 'id'; 20 | this._sortDir = 'DESC'; 21 | } 22 | 23 | perPage() { 24 | if (!arguments.length) { return this._perPage; } 25 | this._perPage = arguments[0]; 26 | return this; 27 | } 28 | 29 | /** @deprecated Use perPage instead */ 30 | limit() { 31 | if (!arguments.length) { return this.perPage(); } 32 | return this.perPage(arguments[0]); 33 | } 34 | 35 | sortField() { 36 | if (arguments.length) { 37 | this._sortField = arguments[0]; 38 | return this; 39 | } 40 | 41 | return this._sortField; 42 | } 43 | 44 | sortDir() { 45 | if (arguments.length) { 46 | this._sortDir = arguments[0]; 47 | return this; 48 | } 49 | 50 | return this._sortDir; 51 | } 52 | 53 | getSortFieldName() { 54 | return this.name() + '.' + this._sortField; 55 | } 56 | 57 | infinitePagination() { 58 | if (arguments.length) { 59 | this._infinitePagination = arguments[0]; 60 | return this; 61 | } 62 | 63 | return this._infinitePagination; 64 | } 65 | 66 | actions(actions) { 67 | if (!arguments.length) { 68 | return this._actions; 69 | } 70 | 71 | this._actions = actions; 72 | 73 | return this; 74 | } 75 | 76 | exportFields(exportFields) { 77 | if (!arguments.length) { 78 | return this._exportFields; 79 | } 80 | 81 | this._exportFields = exportFields; 82 | 83 | return this; 84 | } 85 | 86 | exportOptions(exportOptions) { 87 | if (!arguments.length) { 88 | return this._exportOptions; 89 | } 90 | 91 | this._exportOptions = exportOptions; 92 | 93 | return this; 94 | } 95 | 96 | batchActions(actions) { 97 | if (!arguments.length) { 98 | return this._batchActions; 99 | } 100 | 101 | this._batchActions = actions; 102 | 103 | return this; 104 | } 105 | 106 | /** 107 | * Define permanent filters to be added to the REST API calls 108 | * 109 | * posts.listView().permanentFilters({ 110 | * published: true 111 | * }); 112 | * // related API call will be /posts?published=true 113 | * 114 | * @param {Object} filters list of filters to apply to the call 115 | */ 116 | permanentFilters(filters) { 117 | if (!arguments.length) { 118 | return this._permanentFilters; 119 | } 120 | 121 | this._permanentFilters = filters; 122 | 123 | return this; 124 | } 125 | 126 | /** 127 | * Define filters the user can add to the datagrid 128 | * 129 | * posts.listView().filters([ 130 | * nga.field('title'), 131 | * nga.field('age', 'number') 132 | * ]); 133 | * 134 | * @param {Field[]} filters list of filters to add to the GUI 135 | */ 136 | filters(filters) { 137 | if (!arguments.length) { 138 | return this._filters; 139 | } 140 | 141 | this._filters = orderElement.order(filters); 142 | 143 | return this; 144 | } 145 | 146 | getFilterReferences(withRemoteComplete) { 147 | let result = {}; 148 | let lists = this._filters.filter(f => f.type() === 'reference'); 149 | 150 | var filterFunction = null; 151 | if (withRemoteComplete === true) { 152 | filterFunction = f => f.remoteComplete(); 153 | } else if (withRemoteComplete === false) { 154 | filterFunction = f => !f.remoteComplete(); 155 | } 156 | 157 | if (filterFunction !== null) { 158 | lists = lists.filter(filterFunction); 159 | } 160 | 161 | for (let i = 0, c = lists.length ; i < c ; i++) { 162 | let list = lists[i]; 163 | result[list.name()] = list; 164 | } 165 | 166 | return result; 167 | } 168 | 169 | listActions(actions) { 170 | if (!arguments.length) { 171 | return this._listActions; 172 | } 173 | 174 | this._listActions = actions; 175 | 176 | return this; 177 | } 178 | 179 | entryCssClasses(classes) { 180 | if (!arguments.length) { 181 | return this._entryCssClasses; 182 | } 183 | 184 | this._entryCssClasses = classes; 185 | 186 | return this; 187 | } 188 | 189 | getEntryCssClasses(entry) { 190 | if (!this._entryCssClasses) { 191 | return ''; 192 | } 193 | 194 | if (this._entryCssClasses.constructor === Array) { 195 | return this._entryCssClasses.join(' '); 196 | } 197 | 198 | if (typeof(this._entryCssClasses) === 'function') { 199 | return this._entryCssClasses(entry); 200 | } 201 | 202 | return this._entryCssClasses; 203 | } 204 | } 205 | 206 | export default ListView; 207 | -------------------------------------------------------------------------------- /tests/lib/Menu/MenuTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import Menu from "../../../lib/Menu/Menu"; 4 | import ChoicesField from "../../../lib/Field/ChoicesField"; 5 | import Entity from "../../../lib/Entity/Entity"; 6 | 7 | describe('Menu', () => { 8 | 9 | describe('constructor', () => { 10 | it('should return an empty menu', () => { 11 | var menu = new Menu(); 12 | assert.isNull(menu.link()); 13 | assert.isNull(menu.title()); 14 | assert.isFalse(menu.icon()); 15 | assert.deepEqual([], menu.children()); 16 | assert.isFalse(menu.template()); 17 | }) 18 | }); 19 | 20 | describe('link', () => { 21 | it('should set link and active function', () => { 22 | var menu = new Menu().link('/foo/bar'); 23 | assert.equal('/foo/bar', menu.link()); 24 | assert.isTrue(menu.isActive('/foo/bar')); 25 | }); 26 | it('should return link when called with no argument', () => 27 | assert.equal('/foo/bar', new Menu().link('/foo/bar').link()) 28 | ); 29 | }); 30 | 31 | describe('isActive', () => { 32 | it('should return false by default', () => 33 | assert.isFalse(new Menu().isActive()) 34 | ); 35 | it('should return true for the link', () => 36 | assert.isTrue(new Menu().link('/foo').isActive('/foo')) 37 | ); 38 | it('should return true for the link followed by something else', () => 39 | assert.isTrue(new Menu().link('/foo').isActive('/foo/bar')) 40 | ); 41 | it('should return false for something different than link', () => 42 | assert.isFalse(new Menu().link('/foo/bar').isActive('/foo/far')) 43 | ); 44 | }); 45 | 46 | describe('active', () => { 47 | it('should override the function used to determine if the menu is active', () => 48 | assert.isTrue(new Menu().link('/foo/bar').active(() => true).isActive('/foo/far')) 49 | ); 50 | }); 51 | 52 | describe('addChild', () => { 53 | it('should not accept anything other than a Menu', () => 54 | assert.throw(() => new Menu().addChild('foo')) 55 | ); 56 | it('should add a Child Menu', () => { 57 | var menu = new Menu(); 58 | assert.isFalse(menu.hasChild()); 59 | var submenu = new Menu(); 60 | menu.addChild(submenu); 61 | assert.isTrue(menu.hasChild()); 62 | assert.deepEqual([submenu], menu.children()); 63 | }); 64 | }); 65 | 66 | describe('getChildByTitle', () => { 67 | it('should return undefined when no child', () => { 68 | assert.isUndefined(new Menu().getChildByTitle('foo')); 69 | }); 70 | it('should return the first matching Menu', () => { 71 | let menu = new Menu(); 72 | let fooMenu = new Menu().title('Foo'); 73 | menu 74 | .addChild(fooMenu) 75 | .addChild(new Menu().title('Bar')); 76 | assert.deepEqual(fooMenu, menu.getChildByTitle('Foo')); 77 | }) 78 | }); 79 | 80 | describe('icon', () => { 81 | it('should return false by default', () => assert.isFalse(new Menu().icon())); 82 | it('should set the icon', () => assert.equal('foo', new Menu().icon('foo').icon())) 83 | }); 84 | 85 | describe('template', () => { 86 | it('should return false by default', () => assert.isFalse(new Menu().template())); 87 | it('should set the template', () => assert.equal('foo', new Menu().template('foo').template())) 88 | }); 89 | 90 | describe('populateFromEntity', () => { 91 | it('should fail if passed anything else than an Entity', () => { 92 | assert.throw(() => new Menu().populateFromEntity('foo'), 'populateFromEntity() only accepts an Entity parameter') 93 | }); 94 | it('should set label according to Entity', () => { 95 | assert.equal('Comments', new Menu().populateFromEntity(new Entity('comments')).title()); 96 | }); 97 | it('should set link according to entity', () => { 98 | assert.equal('/comments/list', new Menu().populateFromEntity(new Entity('comments')).link()); 99 | }); 100 | it('should set active function to entity', () => { 101 | let menu = new Menu().populateFromEntity(new Entity('comments')); 102 | assert.isTrue(menu.isActive('/comments/list')); 103 | assert.isTrue(menu.isActive('/comments/edit/2')); 104 | assert.isFalse(menu.isActive('/posts/list')); 105 | }); 106 | it('should set icon according to MenuView', () => { 107 | let entity = new Entity('comments'); 108 | entity.menuView().icon(''); 109 | assert.equal('', new Menu().populateFromEntity(entity).icon()); 110 | }); 111 | 112 | it('should include pinned filters with default value inside link', () => { 113 | let entity = new Entity('comments'); 114 | 115 | const statusFilter = new ChoicesField('status'); 116 | statusFilter.choices([ 117 | { label: 'rejected', value: 'rejected' }, 118 | { label: 'approved', value: 'approved' }, 119 | { label: 'pending', value: 'pending' }, 120 | ]); 121 | statusFilter.pinned(true); 122 | statusFilter.defaultValue(['rejected', 'approved']) 123 | 124 | entity.listView().filters([statusFilter]); 125 | 126 | assert.equal( 127 | new Menu().populateFromEntity(entity).link(), 128 | '/comments/list?search=%7B%22status%22%3A%5B%22rejected%22%2C%22approved%22%5D%7D' 129 | ); 130 | }); 131 | }); 132 | 133 | }); 134 | -------------------------------------------------------------------------------- /src/Entity/Entity.js: -------------------------------------------------------------------------------- 1 | import stringUtils from "../Utils/stringUtils"; 2 | import Field from "../Field/Field"; 3 | import DashboardView from '../View/DashboardView'; 4 | import MenuView from '../View/MenuView'; 5 | import ListView from '../View/ListView'; 6 | import CreateView from '../View/CreateView'; 7 | import EditView from '../View/EditView'; 8 | import DeleteView from '../View/DeleteView'; 9 | import ShowView from '../View/ShowView'; 10 | import BatchDeleteView from '../View/BatchDeleteView'; 11 | import ExportView from '../View/ExportView'; 12 | 13 | var index = 0; 14 | 15 | class Entity { 16 | constructor(name) { 17 | this._name = name; 18 | this._uniqueId = this._name + '_' + index++; 19 | this._baseApiUrl = null; 20 | this._label = null; 21 | this._identifierField = new Field("id"); 22 | this._isReadOnly = false; 23 | this._errorMessage = null; 24 | this._order = 0; 25 | this._url = null; 26 | this._createMethod = null; // manually set the HTTP-method for create operation, defaults to post 27 | this._updateMethod = null; // manually set the HTTP-method for update operation, defaults to put 28 | this._retrieveMethod = null; // manually set the HTTP-method for the get operation, defaults to get 29 | this._deleteMethod = null; // manually set the HTTP-method for the delete operation, defaults to delete 30 | 31 | 32 | this._initViews(); 33 | } 34 | 35 | get uniqueId() { 36 | return this._uniqueId; 37 | } 38 | 39 | get views() { 40 | return this._views; 41 | } 42 | 43 | label() { 44 | if (arguments.length) { 45 | this._label = arguments[0]; 46 | return this; 47 | } 48 | 49 | if (this._label === null) { 50 | return stringUtils.camelCase(this._name); 51 | } 52 | 53 | return this._label; 54 | } 55 | 56 | name() { 57 | if (arguments.length) { 58 | this._name = arguments[0]; 59 | return this; 60 | } 61 | 62 | return this._name; 63 | } 64 | 65 | menuView() { 66 | return this._views["MenuView"]; 67 | } 68 | 69 | dashboardView() { 70 | return this._views["DashboardView"]; 71 | } 72 | 73 | listView() { 74 | return this._views["ListView"]; 75 | } 76 | 77 | creationView() { 78 | return this._views["CreateView"]; 79 | } 80 | 81 | editionView() { 82 | return this._views["EditView"]; 83 | } 84 | 85 | deletionView() { 86 | return this._views["DeleteView"]; 87 | } 88 | 89 | batchDeleteView() { 90 | return this._views["BatchDeleteView"]; 91 | } 92 | 93 | exportView() { 94 | return this._views["ExportView"]; 95 | } 96 | 97 | showView() { 98 | return this._views["ShowView"]; 99 | } 100 | 101 | baseApiUrl(baseApiUrl) { 102 | if (!arguments.length) return this._baseApiUrl; 103 | this._baseApiUrl = baseApiUrl; 104 | return this; 105 | } 106 | 107 | _initViews() { 108 | this._views = { 109 | "DashboardView": new DashboardView().setEntity(this), 110 | "MenuView": new MenuView().setEntity(this), 111 | "ListView": new ListView().setEntity(this), 112 | "CreateView": new CreateView().setEntity(this), 113 | "EditView": new EditView().setEntity(this), 114 | "DeleteView": new DeleteView().setEntity(this), 115 | "BatchDeleteView": new BatchDeleteView().setEntity(this), 116 | "ExportView": new ExportView().setEntity(this), 117 | "ShowView": new ShowView().setEntity(this) 118 | }; 119 | } 120 | 121 | identifier(value) { 122 | if (!arguments.length) return this._identifierField; 123 | if (!(value instanceof Field)) { 124 | throw new Error('Entity ' + this.name() + ': identifier must be an instance of Field.'); 125 | } 126 | this._identifierField = value; 127 | return this; 128 | } 129 | 130 | readOnly() { 131 | this._isReadOnly = true; 132 | 133 | this._views["CreateView"].disable(); 134 | this._views["EditView"].disable(); 135 | this._views["DeleteView"].disable(); 136 | this._views["BatchDeleteView"].disable(); 137 | 138 | return this; 139 | } 140 | 141 | get isReadOnly() { 142 | return this._isReadOnly; 143 | } 144 | 145 | getErrorMessage(response) { 146 | if (typeof(this._errorMessage) === 'function') { 147 | return this._errorMessage(response); 148 | } 149 | 150 | return this._errorMessage; 151 | } 152 | 153 | errorMessage(errorMessage) { 154 | if (!arguments.length) return this._errorMessage; 155 | this._errorMessage = errorMessage; 156 | return this; 157 | } 158 | 159 | order(order) { 160 | if (!arguments.length) return this._order; 161 | this._order = order; 162 | return this; 163 | } 164 | 165 | url(url) { 166 | if (!arguments.length) return this._url; 167 | this._url = url; 168 | return this; 169 | } 170 | 171 | getUrl(viewType, identifierValue, identifierName) { 172 | if (typeof(this._url) === 'function') { 173 | return this._url(this.name(), viewType, identifierValue, identifierName); 174 | } 175 | 176 | return this._url; 177 | } 178 | 179 | createMethod(createMethod) { 180 | if (!arguments.length) return this._createMethod; 181 | this._createMethod = createMethod; 182 | return this; 183 | } 184 | 185 | updateMethod(updateMethod) { 186 | if (!arguments.length) return this._updateMethod; 187 | this._updateMethod = updateMethod; 188 | return this; 189 | } 190 | 191 | retrieveMethod(retrieveMethod) { 192 | if (!arguments.length) return this._retrieveMethod; 193 | this._retrieveMethod = retrieveMethod; 194 | return this; 195 | } 196 | 197 | deleteMethod(deleteMethod) { 198 | if (!arguments.length) return this._deleteMethod; 199 | this._deleteMethod = deleteMethod; 200 | return this; 201 | } 202 | } 203 | 204 | export default Entity; 205 | -------------------------------------------------------------------------------- /tests/lib/Field/FieldTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import Entry from "../../../lib/Entry"; 4 | import Field from "../../../lib/Field/Field"; 5 | 6 | describe('Field', function() { 7 | describe('detailLink', function() { 8 | it('should be false if not specified', function() { 9 | var field = new Field('foo'); 10 | assert.equal(false, field.isDetailLink()); 11 | }); 12 | 13 | it('should be true if not specified and if name is "id"', function() { 14 | var field = new Field('id'); 15 | assert.equal(true, field.isDetailLink()); 16 | }); 17 | 18 | it('should return given value if already set, whatever name may be', function() { 19 | var field = new Field('id'); 20 | field.detailLink = false; 21 | 22 | assert.equal(false, field.isDetailLink()); 23 | }); 24 | }); 25 | 26 | describe('label', function() { 27 | it('should be based on name if non label has been provided', function() { 28 | var field = new Field('first_name'); 29 | assert.equal('First Name', field.label()); 30 | }); 31 | 32 | it('should return the camelCased name by default', function () { 33 | assert.equal(new Field('myField').label(), 'MyField'); 34 | assert.equal(new Field('my_field_1').label(), 'My Field 1'); 35 | assert.equal(new Field('my-field-2').label(), 'My Field 2'); 36 | assert.equal(new Field('my_field-3').label(), 'My Field 3'); 37 | }); 38 | 39 | it('should be given value if already provided', function() { 40 | var field = new Field('first_name').label('Prénom'); 41 | assert.equal('Prénom', field.label()); 42 | }); 43 | }); 44 | 45 | describe('getCssClasses', function() { 46 | var field; 47 | 48 | beforeEach(function() { 49 | field = new Field('title'); 50 | }); 51 | 52 | it('should return result of callback called with entry if function', function() { 53 | field.cssClasses(entry => entry.values.id % 2 ? "odd-entry" : "even-entry"); 54 | 55 | var entry = new Entry('post', { id: 1 }); 56 | assert.equal("odd-entry", field.getCssClasses(entry)); 57 | 58 | entry = new Entry('post', { id: 2 }); 59 | assert.equal("even-entry", field.getCssClasses(entry)); 60 | }); 61 | 62 | it('should concatenate all elements if array', function() { 63 | field.cssClasses(["important", "approved"]); 64 | assert.equal("important approved", field.getCssClasses()); 65 | }); 66 | 67 | it('should return passed classes if neither function nor array', function() { 68 | field.cssClasses("important approved"); 69 | assert.equal("important approved", field.getCssClasses()); 70 | }); 71 | 72 | it('should return an empty string by default', function() { 73 | assert.equal(field.getCssClasses(), ''); 74 | }); 75 | 76 | it('should return an class string as set by cssClasses(string)', function() { 77 | field.cssClasses('foo bar'); 78 | assert.equal(field.getCssClasses(), 'foo bar'); 79 | }); 80 | 81 | it('should return an class string as set by cssClasses(array)', function() { 82 | field.cssClasses(['foo', 'bar']); 83 | assert.equal(field.getCssClasses(), 'foo bar'); 84 | }); 85 | 86 | it('should return an class string as set by cssClasses(function)', function() { 87 | field.cssClasses(function() { return 'foo bar'; }); 88 | assert.equal(field.getCssClasses(), 'foo bar'); 89 | }); 90 | 91 | }); 92 | 93 | describe('validation()', function() { 94 | it('should have sensible defaults', function() { 95 | assert.deepEqual(new Field().validation(), {required: false, minlength : 0, maxlength : 99999}); 96 | }); 97 | 98 | it('should allow to override parts of the validation settings', function() { 99 | var field = new Field().validation({ required: true }); 100 | assert.deepEqual(field.validation(), {required: true, minlength : 0, maxlength : 99999}); 101 | }); 102 | 103 | it('should allow to remove parts of the validation settings', function() { 104 | var field = new Field().validation({ minlength: null }); 105 | assert.deepEqual(field.validation(), {required: false, maxlength : 99999}); 106 | }); 107 | }); 108 | 109 | describe('map()', function() { 110 | it('should add a map function', function() { 111 | var fooFunc = function(a) { return a; } 112 | var field = new Field().map(fooFunc); 113 | assert.ok(field.hasMaps()); 114 | assert.deepEqual(field.map(), [fooFunc]); 115 | }); 116 | it('should allow multiple calls', function() { 117 | var fooFunc = function(a) { return a; } 118 | var barFunc = function(a) { return a + 1; } 119 | var field = new Field().map(fooFunc).map(barFunc); 120 | assert.deepEqual(field.map(), [fooFunc, barFunc]); 121 | }); 122 | }); 123 | 124 | describe('getMappedValue()', function() { 125 | it('should return the value argument if no maps', function() { 126 | var field = new Field(); 127 | assert.equal(field.getMappedValue('foobar'), 'foobar'); 128 | }); 129 | it('should return the passed transformed by maps', function() { 130 | var field = new Field() 131 | .map(function add1(a) { return a + 1; }) 132 | .map(function times2(a) { return a * 2; }); 133 | assert.equal(field.getMappedValue(3), 8); 134 | }); 135 | }); 136 | 137 | describe('template()', function() { 138 | it('should accept string values', function () { 139 | var field = new Field().template('hello!'); 140 | assert.equal(field.getTemplateValue(), 'hello!'); 141 | }); 142 | 143 | it('should accept function values', function () { 144 | var field = new Field().template(function () { return 'hello function !'; }); 145 | assert.equal(field.getTemplateValue(), 'hello function !'); 146 | }); 147 | }); 148 | 149 | describe('getTemplateValue()', function() { 150 | it('should return the template function executed with the supplied data', function() { 151 | var field = new Field().template(function (name) { return 'hello ' + name + ' !'; }); 152 | assert.equal(field.getTemplateValue('John'), 'hello John !'); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/Field/Field.js: -------------------------------------------------------------------------------- 1 | import stringUtils from "../Utils/stringUtils"; 2 | 3 | class Field { 4 | constructor(name) { 5 | this._name = name || Math.random().toString(36).substring(7); 6 | this._detailLink = (name === 'id'); 7 | this._type = "string"; 8 | this._order = null; 9 | this._label = null; 10 | this._maps = []; 11 | this._transforms = []; 12 | this._attributes = {}; 13 | this._cssClasses = null; 14 | this._validation = { required: false, minlength : 0, maxlength : 99999 }; 15 | this._defaultValue = null; 16 | this._editable = true; 17 | this._sortable = true; 18 | this._detailLinkRoute = 'edit'; 19 | this._pinned = false; 20 | this._flattenable = true; 21 | this._helpText = null; 22 | this.dashboard = true; 23 | this.list = true; 24 | this._template = () => ''; 25 | this._templateIncludesLabel = false; 26 | } 27 | 28 | label() { 29 | if (arguments.length) { 30 | this._label = arguments[0]; 31 | return this; 32 | } 33 | 34 | if (this._label === null) { 35 | return stringUtils.camelCase(this._name); 36 | } 37 | 38 | return this._label; 39 | } 40 | 41 | type() { 42 | return this._type; 43 | } 44 | 45 | name() { 46 | if (arguments.length) { 47 | this._name = arguments[0]; 48 | return this; 49 | } 50 | 51 | return this._name; 52 | } 53 | 54 | order() { 55 | if (arguments.length) { 56 | if(arguments[1] !== true) { 57 | console.warn('Setting order with Field.order is deprecated, order directly in fields array'); 58 | } 59 | this._order = arguments[0]; 60 | return this; 61 | } 62 | 63 | return this._order; 64 | } 65 | 66 | isDetailLink(detailLink) { 67 | if (arguments.length) { 68 | this._detailLink = arguments[0]; 69 | return this; 70 | } 71 | 72 | if (this._detailLink === null) { 73 | return this._name === 'id'; 74 | } 75 | 76 | return this._detailLink; 77 | } 78 | 79 | set detailLink(isDetailLink) { 80 | return this._detailLink = isDetailLink; 81 | } 82 | 83 | /** 84 | * Add a function to be applied to the response object to turn it into an entry 85 | */ 86 | map(fn) { 87 | if (!fn) return this._maps; 88 | if (typeof(fn) !== "function") { 89 | let type = typeof(fn); 90 | throw new Error(`Map argument should be a function, ${type} given.`); 91 | } 92 | 93 | this._maps.push(fn); 94 | 95 | return this; 96 | } 97 | 98 | hasMaps() { 99 | return !!this._maps.length; 100 | } 101 | 102 | getMappedValue(value, entry) { 103 | for (let i in this._maps) { 104 | value = this._maps[i](value, entry); 105 | } 106 | 107 | return value; 108 | } 109 | 110 | /** 111 | * Add a function to be applied to the entry to turn it into a response object 112 | */ 113 | transform(fn) { 114 | if (!fn) return this._transforms; 115 | if (typeof(fn) !== "function") { 116 | let type = typeof(fn); 117 | throw new Error(`transform argument should be a function, ${type} given.`); 118 | } 119 | 120 | this._transforms.push(fn); 121 | 122 | return this; 123 | } 124 | 125 | hasTranforms() { 126 | return !!this._transforms.length; 127 | } 128 | 129 | getTransformedValue(value, entry) { 130 | for (let i in this._transforms) { 131 | value = this._transforms[i](value, entry); 132 | } 133 | 134 | return value; 135 | } 136 | 137 | attributes(attributes) { 138 | if (!arguments.length) { 139 | return this._attributes; 140 | } 141 | 142 | this._attributes = attributes; 143 | 144 | return this; 145 | } 146 | 147 | cssClasses(classes) { 148 | if (!arguments.length) return this._cssClasses; 149 | this._cssClasses = classes; 150 | return this; 151 | } 152 | 153 | getCssClasses(entry) { 154 | if (!this._cssClasses) { 155 | return ''; 156 | } 157 | 158 | if (this._cssClasses.constructor === Array) { 159 | return this._cssClasses.join(' '); 160 | } 161 | 162 | if (typeof(this._cssClasses) === 'function') { 163 | return this._cssClasses(entry); 164 | } 165 | 166 | return this._cssClasses; 167 | } 168 | 169 | validation(validation) { 170 | if (!arguments.length) { 171 | return this._validation; 172 | } 173 | 174 | for (let property in validation) { 175 | if (!validation.hasOwnProperty(property)) continue; 176 | if (validation[property] === null) { 177 | delete this._validation[property]; 178 | } else { 179 | this._validation[property] = validation[property]; 180 | } 181 | } 182 | 183 | return this; 184 | } 185 | 186 | defaultValue(defaultValue) { 187 | if (!arguments.length) return this._defaultValue; 188 | this._defaultValue = defaultValue; 189 | return this; 190 | } 191 | 192 | editable(editable) { 193 | if (!arguments.length) return this._editable; 194 | this._editable = editable; 195 | return this; 196 | } 197 | 198 | sortable(sortable) { 199 | if (!arguments.length) return this._sortable; 200 | this._sortable = sortable; 201 | return this; 202 | } 203 | 204 | detailLinkRoute(route) { 205 | if (!arguments.length) return this._detailLinkRoute; 206 | this._detailLinkRoute = route; 207 | return this; 208 | } 209 | 210 | pinned(pinned) { 211 | if (!arguments.length) return this._pinned; 212 | this._pinned = pinned; 213 | return this; 214 | } 215 | 216 | flattenable() { 217 | return this._flattenable; 218 | } 219 | 220 | helpText() { 221 | if (arguments.length) { 222 | this._helpText = arguments[0]; 223 | return this; 224 | } 225 | 226 | return this._helpText; 227 | } 228 | 229 | getTemplateValue(data) { 230 | if (typeof(this._template) === 'function') { 231 | return this._template(data); 232 | } 233 | 234 | return this._template; 235 | } 236 | 237 | getTemplateValueWithLabel(data) { 238 | return this._templateIncludesLabel ? this.getTemplateValue(data) : false; 239 | } 240 | 241 | templateIncludesLabel(templateIncludesLabel) { 242 | if (!arguments.length) return this._templateIncludesLabel; 243 | this._templateIncludesLabel = templateIncludesLabel; 244 | return this; 245 | } 246 | 247 | template(template, templateIncludesLabel = false) { 248 | if (!arguments.length) return this._template; 249 | this._template = template; 250 | this._templateIncludesLabel = templateIncludesLabel; 251 | return this; 252 | } 253 | } 254 | 255 | export default Field; 256 | -------------------------------------------------------------------------------- /src/Application.js: -------------------------------------------------------------------------------- 1 | import Menu from './Menu/Menu'; 2 | import Collection from './Collection'; 3 | import Dashboard from './Dashboard'; 4 | import orderElement from "./Utils/orderElement"; 5 | 6 | class Application { 7 | constructor(title='ng-admin', debug=true) { 8 | this._baseApiUrl = ''; 9 | this._customTemplate = function(viewName) {}; 10 | this._title = title; 11 | this._menu = null; 12 | this._dashboard = null; 13 | this._layout = false; 14 | this._header = false; 15 | this._entities = []; 16 | this._errorMessage = this.defaultErrorMessage; 17 | this._debug = debug; 18 | } 19 | 20 | defaultErrorMessage(response) { 21 | let body = response.data; 22 | 23 | if (typeof body === 'object') { 24 | body = JSON.stringify(body); 25 | } 26 | 27 | return 'Oops, an error occured : (code: ' + response.status + ') ' + body; 28 | } 29 | 30 | get entities() { 31 | return this._entities; 32 | } 33 | 34 | getViewsOfType(type) { 35 | return orderElement.order( 36 | this.entities.map(entity => entity.views[type]) 37 | .filter(view => view.enabled) 38 | ); 39 | } 40 | 41 | getRouteFor(entity, viewUrl, viewType, identifierValue, identifierName) { 42 | let baseApiUrl = entity.baseApiUrl() || this.baseApiUrl(), 43 | url = viewUrl || entity.getUrl(viewType, identifierValue, identifierName); 44 | 45 | // If the view or the entity don't define the url, retrieve it from the baseURL of the entity or the app 46 | if (!url) { 47 | url = baseApiUrl + encodeURIComponent(entity.name()); 48 | if (identifierValue != null) { 49 | url += '/' + encodeURIComponent(identifierValue); 50 | } 51 | } else if (!/^(?:[a-z]+:)?\/\//.test(url)) { 52 | // Add baseUrl for relative URL 53 | url = baseApiUrl + url; 54 | } 55 | 56 | return url; 57 | } 58 | 59 | debug(debug) { 60 | if (!arguments.length) return this._debug; 61 | this._debug = debug; 62 | return this; 63 | } 64 | 65 | layout(layout) { 66 | if (!arguments.length) return this._layout; 67 | this._layout = layout; 68 | return this; 69 | } 70 | 71 | header(header) { 72 | if (!arguments.length) return this._header; 73 | this._header = header; 74 | return this; 75 | } 76 | 77 | title(title) { 78 | if (!arguments.length) return this._title; 79 | this._title = title; 80 | return this; 81 | } 82 | 83 | /** 84 | * Getter/Setter for the main application menu 85 | * 86 | * If the getter is called first, it will return a menu based on entities. 87 | * 88 | * application.addEntity(new Entity('posts')); 89 | * application.addEntity(new Entity('comments')); 90 | * application.menu(); // Menu { children: [ Menu { title: "Posts" }, Menu { title: "Comments" } ]} 91 | * 92 | * If the setter is called first, all subsequent calls to the getter will return the set menu. 93 | * 94 | * application.addEntity(new Entity('posts')); 95 | * application.addEntity(new Entity('comments')); 96 | * application.menu(new Menu().addChild(new Menu().title('Foo'))); 97 | * application.menu(); // Menu { children: [ Menu { title: "Foo" } ]} 98 | * 99 | * @see Menu 100 | */ 101 | menu(menu) { 102 | if (!arguments.length) { 103 | if (!this._menu) { 104 | this._menu = this.buildMenuFromEntities(); 105 | } 106 | return this._menu 107 | } 108 | 109 | this._menu = menu; 110 | return this; 111 | } 112 | 113 | buildMenuFromEntities() { 114 | return new Menu().children( 115 | this.entities 116 | .filter(entity => entity.menuView().enabled) 117 | .sort((e1, e2) => e1.menuView().order() - e2.menuView().order()) 118 | .map(entity => new Menu().populateFromEntity(entity)) 119 | ); 120 | } 121 | 122 | dashboard(dashboard) { 123 | if (!arguments.length) { 124 | if (!this._dashboard) { 125 | this._dashboard = this.buildDashboardFromEntities(); 126 | } 127 | return this._dashboard 128 | } 129 | this._dashboard = dashboard; 130 | return this; 131 | } 132 | 133 | buildDashboardFromEntities() { 134 | let dashboard = new Dashboard() 135 | this.entities 136 | .filter(entity => entity.dashboardView().enabled) 137 | .map(entity => { 138 | dashboard.addCollection(entity.dashboardView()); // yep, a collection is a ListView, and so is a DashboardView - forgive this duck typing for BC sake 139 | }); 140 | if (!dashboard.hasCollections()) { 141 | // still no collection from dashboardViews, let's use listViews instead 142 | this.entities 143 | .filter(entity => entity.listView().enabled) 144 | .map((entity, index) => { 145 | let collection = new Collection(); 146 | let listView = entity.listView(); 147 | collection.setEntity(entity); 148 | collection.perPage(listView.perPage()) 149 | collection.sortField(listView.sortField()) 150 | collection.sortDir(listView.sortDir()) 151 | collection.order(index); 152 | // use only the first 3 cols 153 | collection.fields(listView.fields().filter((el, index) => index < 3)); 154 | dashboard.addCollection(collection); 155 | }); 156 | } 157 | return dashboard; 158 | } 159 | 160 | customTemplate(customTemplate) { 161 | if (!arguments.length) return this._customTemplate; 162 | this._customTemplate = customTemplate; 163 | return this; 164 | } 165 | 166 | baseApiUrl(url) { 167 | if (!arguments.length) return this._baseApiUrl; 168 | this._baseApiUrl = url; 169 | return this; 170 | } 171 | 172 | addEntity(entity) { 173 | if (!entity) { 174 | throw new Error("No entity given"); 175 | } 176 | 177 | this._entities.push(entity); 178 | 179 | return this; 180 | } 181 | 182 | getEntity(entityName) { 183 | let foundEntity = this._entities.filter(e => e.name() === entityName)[0]; 184 | if (!foundEntity) { 185 | throw new Error(`Unable to find entity "${entityName}"`); 186 | } 187 | 188 | return foundEntity; 189 | } 190 | 191 | hasEntity(fieldName) { 192 | return !!(this._entities.filter(f => f.name() === fieldName).length); 193 | } 194 | 195 | getViewByEntityAndType(entityName, type) { 196 | return this._entities 197 | .filter(e => e.name() === entityName)[0] 198 | .views[type]; 199 | } 200 | 201 | getErrorMessage(response) { 202 | if (typeof(this._errorMessage) === 'function') { 203 | return this._errorMessage(response); 204 | } 205 | 206 | return this._errorMessage; 207 | } 208 | 209 | errorMessage(errorMessage) { 210 | if (!arguments.length) return this._errorMessage; 211 | this._errorMessage = errorMessage; 212 | return this; 213 | } 214 | 215 | getErrorMessageFor(view, response) { 216 | return ( 217 | view.getErrorMessage(response) 218 | || view.getEntity().getErrorMessage(response) 219 | || this.getErrorMessage(response) 220 | ); 221 | } 222 | 223 | getEntityNames() { 224 | return this.entities.map(f => f.name()); 225 | } 226 | } 227 | 228 | export default Application; 229 | -------------------------------------------------------------------------------- /src/View/View.js: -------------------------------------------------------------------------------- 1 | import Entry from '../Entry'; 2 | import ReferenceExtractor from '../Utils/ReferenceExtractor'; 3 | import { clone, cloneAndFlatten, cloneAndNest } from '../Utils/objectProperties'; 4 | 5 | class View { 6 | constructor(name) { 7 | this.entity = null; 8 | this._actions = null; 9 | this._title = false; 10 | this._description = ''; 11 | this._template = null; 12 | 13 | this._enabled = null; 14 | this._fields = []; 15 | this._type = null; 16 | this._name = name; 17 | this._order = 0; 18 | this._errorMessage = null; 19 | this._url = null; 20 | this._prepare = null; 21 | } 22 | 23 | get enabled() { 24 | return this._enabled === null ? !!this._fields.length : this._enabled; 25 | } 26 | 27 | title(title) { 28 | if (!arguments.length) return this._title; 29 | this._title = title; 30 | return this; 31 | } 32 | 33 | description() { 34 | if (arguments.length) { 35 | this._description = arguments[0]; 36 | return this; 37 | } 38 | 39 | return this._description; 40 | } 41 | 42 | name(name) { 43 | if (!arguments.length) { 44 | return this._name || this.entity.name() + '_' + this._type; 45 | } 46 | 47 | this._name = name; 48 | return this; 49 | } 50 | 51 | disable() { 52 | this._enabled = false; 53 | 54 | return this; 55 | } 56 | 57 | enable() { 58 | this._enabled = true; 59 | 60 | return this; 61 | } 62 | 63 | /** 64 | * @deprecated Use getter "enabled" instead 65 | */ 66 | isEnabled() { 67 | return this.enabled; 68 | } 69 | 70 | /** 71 | * @deprecated Use getter "entity" instead 72 | */ 73 | getEntity() { 74 | return this.entity; 75 | } 76 | 77 | /** 78 | * @deprecated Specify entity at view creation or use "entity" setter instead 79 | */ 80 | setEntity(entity) { 81 | this.entity = entity; 82 | if (!this._name) { 83 | this._name = entity.name() + '_' + this._type; 84 | } 85 | 86 | return this; 87 | } 88 | 89 | /* 90 | * Supports various syntax 91 | * fields([ Field1, Field2 ]) 92 | * fields(Field1, Field2) 93 | * fields([Field1, {Field2, Field3}]) 94 | * fields(Field1, {Field2, Field3}) 95 | * fields({Field2, Field3}) 96 | */ 97 | fields() { 98 | if (!arguments.length) return this._fields; 99 | 100 | [].slice.call(arguments).map(function(argument) { 101 | View.flatten(argument).map(arg => this.addField(arg)); 102 | }, this); 103 | 104 | return this; 105 | } 106 | 107 | hasFields() { 108 | return this.fields.length > 0; 109 | } 110 | 111 | removeFields() { 112 | this._fields = []; 113 | return this; 114 | } 115 | 116 | getFields() { 117 | return this._fields; 118 | } 119 | 120 | getField(fieldName) { 121 | return this._fields.filter(f => f.name() === fieldName)[0]; 122 | } 123 | 124 | getFieldsOfType(type) { 125 | return this._fields.filter(f => f.type() === type); 126 | } 127 | 128 | addField(field) { 129 | if (field.order() === null) { 130 | field.order(this._fields.length, true); 131 | } 132 | this._fields.push(field); 133 | this._fields = this._fields.sort((a, b) => (a.order() - b.order())); 134 | 135 | return this; 136 | } 137 | 138 | static flatten(arg) { 139 | if (arg.constructor.name === 'Object') { 140 | console.warn('Passing literal of Field to fields method is deprecated use array instead'); 141 | let result = []; 142 | for (let fieldName in arg) { 143 | result = result.concat(View.flatten(arg[fieldName])); 144 | } 145 | return result; 146 | } 147 | if (Array.isArray(arg)) { 148 | return arg.reduce(function(previous, current) { 149 | return previous.concat(View.flatten(current)) 150 | }, []); 151 | } 152 | // arg is a scalar 153 | return [arg]; 154 | } 155 | 156 | get type() { 157 | return this._type; 158 | } 159 | 160 | order(order) { 161 | if (!arguments.length) return this._order; 162 | this._order = order; 163 | return this; 164 | } 165 | 166 | getReferences(withRemoteComplete) { 167 | return ReferenceExtractor.getReferences(this._fields, withRemoteComplete); 168 | } 169 | 170 | getNonOptimizedReferences(withRemoteComplete) { 171 | return ReferenceExtractor.getNonOptimizedReferences(this._fields, withRemoteComplete); 172 | } 173 | 174 | getOptimizedReferences(withRemoteComplete) { 175 | return ReferenceExtractor.getOptimizedReferences(this._fields, withRemoteComplete); 176 | } 177 | 178 | getReferencedLists() { 179 | return ReferenceExtractor.getReferencedLists(this._fields); 180 | } 181 | 182 | template(template) { 183 | if (!arguments.length) { 184 | return this._template; 185 | } 186 | 187 | this._template = template; 188 | 189 | return this; 190 | } 191 | 192 | identifier() { 193 | return this.entity.identifier(); 194 | } 195 | 196 | actions(actions) { 197 | if (!arguments.length) return this._actions; 198 | this._actions = actions; 199 | return this; 200 | } 201 | 202 | getErrorMessage(response) { 203 | if (typeof(this._errorMessage) === 'function') { 204 | return this._errorMessage(response); 205 | } 206 | 207 | return this._errorMessage; 208 | } 209 | 210 | errorMessage(errorMessage) { 211 | if (!arguments.length) return this._errorMessage; 212 | this._errorMessage = errorMessage; 213 | return this; 214 | } 215 | 216 | url(url) { 217 | if (!arguments.length) return this._url; 218 | this._url = url; 219 | return this; 220 | } 221 | 222 | getUrl(identifierValue) { 223 | if (typeof(this._url) === 'function') { 224 | return this._url(identifierValue); 225 | } 226 | 227 | return this._url; 228 | } 229 | 230 | validate(entry) { 231 | this._fields.map(function (field) { 232 | let validation = field.validation(); 233 | 234 | if (typeof validation.validator === 'function') { 235 | validation.validator(entry.values[field.name()], entry.values); 236 | } 237 | }); 238 | } 239 | 240 | /** 241 | * Map a JS object from the REST API Response to an Entry 242 | */ 243 | mapEntry(restEntry) { 244 | return Entry.createFromRest(restEntry, this._fields, this.entity.name(), this.entity.identifier().name()); 245 | } 246 | 247 | mapEntries(restEntries) { 248 | return Entry.createArrayFromRest(restEntries, this._fields, this.entity.name(), this.entity.identifier().name()); 249 | } 250 | 251 | /** 252 | * Transform an Entry to a JS object for the REST API Request 253 | */ 254 | transformEntry(entry) { 255 | return entry.transformToRest(this._fields); 256 | } 257 | 258 | /** 259 | * Add a function to be executed before the view renders 260 | * 261 | * This is the ideal place to prefetch related entities and manipulate 262 | * the dataStore. 263 | * 264 | * The syntax depends on the framework calling the function. 265 | * 266 | * With ng-admin, the function can be an angular injectable, listing 267 | * required dependencies in an array. Among other, the function can receive 268 | * the following services: 269 | * - query: the query object (an object representation of the main request 270 | * query string) 271 | * - datastore: where the Entries are stored. The dataStore is accessible 272 | * during rendering 273 | * - view: the current View object 274 | * - entry: the current Entry instance (except in listView) 275 | * - Entry: the Entry constructor (required to transform an object from 276 | * the REST response to an Entry) 277 | * - window: the window object. If you need to fetch anything other than an 278 | * entry and pass it to the view layer, it's the only way. 279 | * 280 | * The function can be asynchronous, in which case it should return 281 | * a Promise. 282 | * 283 | * @example 284 | * 285 | * post.listView().prepare(['datastore', 'view', 'Entry', function(datastore, view, Entry) { 286 | * const posts = datastore.getEntries(view.getEntity().uniqueId); 287 | * const authorIds = posts.map(post => post.values.authorId).join(','); 288 | * return fetch('http://myapi.com/authors?id[]=' + authorIds) 289 | * .then(response => response.json()) 290 | * .then(authors => Entry.createArrayFromRest( 291 | * authors, 292 | * [new Field('first_name'), new Field('last_name')], 293 | * 'author' 294 | * )) 295 | * .then(authorEntries => datastore.setEntries('authors', authorEntries)); 296 | * }]); 297 | */ 298 | prepare(prepare) { 299 | if (!arguments.length) return this._prepare; 300 | this._prepare = prepare; 301 | return this; 302 | } 303 | 304 | doPrepare() { 305 | return this._prepare.apply(this, arguments); 306 | } 307 | } 308 | 309 | export default View; 310 | -------------------------------------------------------------------------------- /tests/lib/Queries/ReadQueriesTest.js: -------------------------------------------------------------------------------- 1 | let assert = require('chai').assert, 2 | sinon = require('sinon'); 3 | 4 | import ReadQueries from "../../../lib/Queries/ReadQueries"; 5 | import PromisesResolver from "../../mock/PromisesResolver"; 6 | import Entity from "../../../lib/Entity/Entity"; 7 | import ReferenceField from "../../../lib/Field/ReferenceField"; 8 | import ReferencedListField from "../../../lib/Field/ReferencedListField"; 9 | import TextField from "../../../lib/Field/TextField"; 10 | import Field from "../../../lib/Field/Field"; 11 | import buildPromise from "../../mock/mixins"; 12 | 13 | describe('ReadQueries', () => { 14 | let readQueries, 15 | restWrapper = {}, 16 | application = {}, 17 | rawCats, 18 | rawHumans, 19 | catEntity, 20 | humanEntity, 21 | catView, 22 | humanView; 23 | 24 | beforeEach(() => { 25 | application = { 26 | getRouteFor: (entity, generatedUrl, viewType, id) => { 27 | let url = 'http://localhost/' + encodeURIComponent(entity.name()); 28 | if (id) { 29 | url += '/' + encodeURIComponent(id); 30 | } 31 | 32 | return url; 33 | } 34 | }; 35 | 36 | readQueries = new ReadQueries(restWrapper, PromisesResolver, application); 37 | catEntity = new Entity('cat'); 38 | humanEntity = new Entity('human'); 39 | humanView = humanEntity.listView() 40 | .fields([ 41 | new Field('name'), 42 | new ReferencedListField('cat_id').targetEntity(catEntity).targetReferenceField('human_id') 43 | ]); 44 | catView = catEntity.listView() 45 | .addField(new TextField('name')) 46 | .addField(new ReferenceField('human_id').targetEntity(humanEntity).targetField(new Field('firstName'))); 47 | 48 | humanEntity.identifier(new Field('id')); 49 | 50 | rawCats = [ 51 | {"id": 1, "human_id": 1, "name": "Mizoute", "summary": "A Cat"}, 52 | {"id": 2, "human_id": 1, "name": "Suna", "summary": "A little Cat"} 53 | ]; 54 | 55 | rawHumans = [ 56 | {"id": 1, "firstName": "Daph"}, 57 | {"id": 2, "firstName": "Manu"}, 58 | {"id": 3, "firstName": "Daniel"} 59 | ]; 60 | }); 61 | 62 | describe("getOne", () => { 63 | 64 | it('should return the entity with all fields.', () => { 65 | let entity = new Entity('cat'); 66 | entity.views['ListView'] 67 | .addField(new TextField('name')); 68 | 69 | restWrapper.getOne = sinon.stub().returns(buildPromise({ 70 | data: { 71 | "id": 1, 72 | "name": "Mizoute", 73 | "summary": "A Cat" 74 | } 75 | })); 76 | 77 | readQueries.getOne(entity, 'list', 1) 78 | .then((rawEntry) => { 79 | assert(restWrapper.getOne.calledWith('cat', 'http://localhost/cat/1')); 80 | 81 | assert.equal(rawEntry.data.id, 1); 82 | assert.equal(rawEntry.data.name, 'Mizoute'); 83 | 84 | // Non mapped field should also be retrieved 85 | assert.equal(rawEntry.data.summary, "A Cat"); 86 | }); 87 | }); 88 | 89 | }); 90 | 91 | describe('getAll', () => { 92 | it('should return all data to display a ListView', () => { 93 | restWrapper.getList = sinon.stub().returns(buildPromise({data: rawCats, headers: () => {}})); 94 | PromisesResolver.allEvenFailed = sinon.stub().returns(buildPromise([ 95 | {status: 'success', result: rawHumans[0] }, 96 | {status: 'success', result: rawHumans[1] }, 97 | {status: 'success', result: rawHumans[2] } 98 | ])); 99 | 100 | readQueries.getAll(catView) 101 | .then((result) => { 102 | assert.equal(result.totalItems, 2); 103 | assert.equal(result.data.length, 2); 104 | 105 | assert.equal(result.data[0].id, 1); 106 | assert.equal(result.data[0].name, 'Mizoute'); 107 | 108 | assert.equal(result.data[0].human_id, 1); 109 | }); 110 | }); 111 | 112 | it('should send correct page params to the API call', () => { 113 | let spy = sinon.spy(readQueries, 'getRawValues'); 114 | 115 | readQueries.getAll(catView, 2); 116 | 117 | assert(spy.withArgs(catEntity, catView.name(), catView.type, 2).calledOnce); 118 | }); 119 | 120 | it('should send correct sort params to the API call', () => { 121 | let spy = sinon.spy(readQueries, 'getRawValues'); 122 | catView.sortField('name').sortDir('DESC'); 123 | let viewName = catView.name(); 124 | 125 | readQueries.getAll(catView, 1); 126 | readQueries.getAll(catView, 1, {}, 'unknow_ListView.name', 'ASC'); 127 | readQueries.getAll(catView, 1, {}, viewName + '.id', 'ASC'); 128 | 129 | assert(spy.withArgs(catEntity, viewName, catView.type, 1, catView.perPage(), {}, catView.filters(), viewName + '.name', 'DESC').callCount == 2); 130 | assert(spy.withArgs(catEntity, viewName, catView.type, 1, catView.perPage(), {}, catView.filters(), viewName + '.id', 'ASC').calledOnce); 131 | }); 132 | 133 | it('should send correct filter params to the API call', () => { 134 | let spy = sinon.spy(readQueries, 'getRawValues'); 135 | let entity = new Entity('cat'); 136 | 137 | readQueries.getAll(entity.listView(), 1, { name: 'foo'}); 138 | 139 | assert(spy.withArgs(entity, 'cat_ListView', 'ListView', 1, 30, { name: 'foo' }, [], 'cat_ListView.id', 'DESC').calledOnce); 140 | }); 141 | 142 | it('should include permanent filters', () => { 143 | let spy = sinon.spy(readQueries, 'getRawValues'); 144 | let entity = new Entity('cat'); 145 | entity.listView().permanentFilters({ bar: 1 }); 146 | 147 | readQueries.getAll(entity.listView(), 1); 148 | readQueries.getAll(entity.listView(), 1, { name: 'foo'}); 149 | 150 | assert(spy.withArgs(entity, 'cat_ListView', 'ListView', 1, 30, { bar: 1 }, [], 'cat_ListView.id', 'DESC').calledOnce); 151 | assert(spy.withArgs(entity, 'cat_ListView', 'ListView', 1, 30, { name: 'foo', bar: 1 }, [], 'cat_ListView.id', 'DESC').calledOnce); 152 | }); 153 | 154 | }); 155 | 156 | describe('getReferencedData', () => { 157 | it('should return all references data for a View with multiple calls', () => { 158 | let post = new Entity('posts'), 159 | author = new Entity('authors'), 160 | authorRef = new ReferenceField('author'); 161 | 162 | let rawPosts = [ 163 | {id: 1, author: 'abc'}, 164 | {id: 2, author: '19DFE'} 165 | ]; 166 | 167 | let rawAuthors = [ 168 | {id: 'abc', name: 'Rollo'}, 169 | {id: '19DFE', name: 'Ragna'} 170 | ]; 171 | 172 | authorRef.targetEntity(author); 173 | authorRef.targetField(new Field('name')); 174 | post.views["ListView"] 175 | .addField(authorRef); 176 | 177 | restWrapper.getOne = sinon.stub().returns(buildPromise({})); 178 | PromisesResolver.allEvenFailed = sinon.stub().returns(buildPromise([ 179 | {status: 'success', result: rawAuthors[0] }, 180 | { status: 'success', result: rawAuthors[1] } 181 | ])); 182 | 183 | readQueries.getFilteredReferenceData(post.views["ListView"].getReferences(), rawPosts) 184 | .then((referencedData) => { 185 | assert.equal(referencedData.author.length, 2); 186 | assert.equal(referencedData.author[0].id, 'abc'); 187 | assert.equal(referencedData.author[1].name, 'Ragna'); 188 | }); 189 | }); 190 | 191 | it('should return all references data for a View with one call', () => { 192 | let post = new Entity('posts'), 193 | author = new Entity('authors'), 194 | authorRef = new ReferenceField('author'); 195 | 196 | authorRef.singleApiCall((ids) => { 197 | return { 198 | id: ids 199 | }; 200 | }); 201 | 202 | let rawPosts = [ 203 | {id: 1, author: 'abc'}, 204 | {id: 2, author: '19DFE'} 205 | ]; 206 | 207 | let rawAuthors = [ 208 | {id: 'abc', name: 'Rollo'}, 209 | {id: '19DFE', name: 'Ragna'} 210 | ]; 211 | 212 | authorRef.targetEntity(author); 213 | authorRef.targetField(new Field('name')); 214 | post.views["ListView"] 215 | .addField(authorRef); 216 | 217 | restWrapper.getList = sinon.stub().returns(buildPromise({data: rawCats, headers: () => {}})); 218 | PromisesResolver.allEvenFailed = sinon.stub().returns(buildPromise([ 219 | {status: 'success', result: { data: rawAuthors }} 220 | ])); 221 | 222 | readQueries.getOptimizedReferenceData(post.views["ListView"].getReferences(), rawPosts) 223 | .then((referencedData) => { 224 | assert.equal(referencedData['author'].length, 2); 225 | assert.equal(referencedData['author'][0].id, 'abc'); 226 | assert.equal(referencedData['author'][1].name, 'Ragna'); 227 | }); 228 | }); 229 | }); 230 | 231 | describe('getReferencedListData', () => { 232 | it('should return all referenced list data for a View', () => { 233 | restWrapper.getList = sinon.stub().returns(buildPromise({data: rawCats, headers: () => {}})); 234 | PromisesResolver.allEvenFailed = sinon.stub().returns(buildPromise([ 235 | {status: 'success', result: { data: rawCats }} 236 | ])); 237 | 238 | readQueries.getReferencedListData(humanView.getReferencedLists(), null, null, 1) 239 | .then((referencedListEntries) => { 240 | assert.equal(referencedListEntries['cat_id'].length, 2); 241 | assert.equal(referencedListEntries['cat_id'][0].id, 1); 242 | assert.equal(referencedListEntries['cat_id'][1].name, 'Suna'); 243 | }); 244 | }); 245 | 246 | it('should send correct sort params to the API call', () => { 247 | let spy = sinon.spy(readQueries, 'getRawValues'); 248 | humanView.getReferencedLists()['cat_id'].sortField('name').sortDir('DESC'); 249 | let viewName = catView.name(); 250 | let perPage = humanView.getReferencedLists()['cat_id'].perPage(); 251 | let targetEntity = humanView.getReferencedLists()['cat_id'].targetEntity(); 252 | 253 | readQueries.getReferencedListData(humanView.getReferencedLists(), null, null, 1); 254 | readQueries.getReferencedListData(humanView.getReferencedLists(), 'unknow_ListView.name', 'ASC', 1); 255 | readQueries.getReferencedListData(humanView.getReferencedLists(), 'cat_ListView.id', 'ASC', 1); 256 | 257 | assert(spy.withArgs(catEntity, viewName, 'listView', 1, perPage, { 'human_id': 1 }, {}, viewName + '.name', 'DESC').calledTwice); 258 | assert(spy.withArgs(targetEntity, viewName, 'listView', 1, perPage, { 'human_id': 1 }, {}, viewName + '.id', 'ASC').calledOnce); 259 | }); 260 | 261 | it('should append permanentFilters to the queryString', () => { 262 | let readQueries = new ReadQueries(restWrapper, PromisesResolver, application); 263 | const commentEntity = new Entity('comment'); 264 | const postEntity = new Entity('post'); 265 | const postView = postEntity.showView() 266 | .fields([ 267 | new Field('name'), 268 | new ReferencedListField('comments') 269 | .targetEntity(commentEntity) 270 | .targetReferenceField('post_id') 271 | .permanentFilters({ foo: 'bar' }) 272 | ]); 273 | 274 | const comments = [ 275 | { id: 1, post_id: 1, summary: "A comment", foo: "bar" }, 276 | { id: 2, post_id: 1, summary: "Another comment" } 277 | ]; 278 | var post = { id: 1, title: "Hello, World" }; 279 | 280 | var spy = sinon.stub(readQueries, 'getRawValues', function(entity, viewType, identifierValue) { 281 | return buildPromise({ result: post }); 282 | }); 283 | PromisesResolver.allEvenFailed = sinon.stub().returns(buildPromise([ 284 | { status: 'success', result: { data: [comments[0]] } } 285 | ])); 286 | 287 | readQueries.getReferencedListData(postView.getReferencedLists(), null, null, 1) 288 | .then((referencedListEntries) => { 289 | let targetEntity = postView.getReferencedLists()['comments'].targetEntity(); 290 | assert(spy.withArgs(commentEntity, 'comment_ListView', 'listView', 1, 30, { post_id: 1, foo: 'bar' }, {}).calledOnce); 291 | }); 292 | }); 293 | }); 294 | 295 | describe('getAllReferencedData', () => { 296 | var getRawValuesMock; 297 | beforeEach(function() { 298 | getRawValuesMock = sinon.mock(readQueries); 299 | }); 300 | 301 | it('should execute permanentFilters function with current search parameter if filters is a function', () => { 302 | var searchParameter = null; 303 | var field = new ReferenceField('myField') 304 | .targetEntity(humanEntity) 305 | .targetField(new Field('name')) 306 | .permanentFilters((search) => { searchParameter = search; return { filter: search }; }); 307 | 308 | getRawValuesMock.expects('getRawValues').once(); 309 | readQueries.getAllReferencedData([field], 'foo'); 310 | 311 | getRawValuesMock.verify(); 312 | assert.equal(searchParameter, 'foo'); 313 | }); 314 | 315 | afterEach(function() { 316 | getRawValuesMock.restore(); 317 | }) 318 | }); 319 | 320 | 321 | describe('getRecordsByIds', function() { 322 | it('should return a promise with array of all retrieved records', function(done) { 323 | var spy = sinon.stub(readQueries, 'getOne', function(entity, viewType, identifierValue) { 324 | return buildPromise({ result: identifierValue }); 325 | }); 326 | 327 | readQueries.getRecordsByIds(humanEntity, [1, 2]).then(function(records) { 328 | assert.equal(spy.callCount, 2); 329 | assert.equal(spy.args[0][2], 1); 330 | assert.equal(spy.args[1][2], 2); 331 | done(); 332 | }); 333 | }); 334 | 335 | it('should return promise with empty result if no ids are passed', function(done) { 336 | var spy = sinon.spy(readQueries, 'getRawValues'); 337 | 338 | readQueries.getRecordsByIds(humanEntity, []).then(function(records) { 339 | assert.equal(spy.called, false); 340 | assert.equal(records, null); 341 | done(); 342 | }); 343 | }); 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /src/Queries/ReadQueries.js: -------------------------------------------------------------------------------- 1 | import Queries from './Queries'; 2 | import ReferenceExtractor from '../Utils/ReferenceExtractor'; 3 | 4 | class ReadQueries extends Queries { 5 | 6 | /** 7 | * Get one entity 8 | * 9 | * @param {Entity} entity 10 | * @param {String} viewType 11 | * @param {mixed} identifierValue 12 | * @param {String} identifierName 13 | * @param {String} url 14 | * 15 | * @returns {promise} (list of fields (with their values if set) & the entity name, label & id- 16 | */ 17 | getOne(entity, viewType, identifierValue, identifierName, url) { 18 | return this._restWrapper 19 | .getOne(entity.name(), this._application.getRouteFor(entity, url, viewType, identifierValue, identifierName), entity.retrieveMethod()); 20 | } 21 | 22 | /** 23 | * Return the list of all object of entityName type 24 | * Get all the object from the API 25 | * 26 | * @param {ListView} view the view associated to the entity 27 | * @param {Number} page the page number 28 | * @param {Object} filterValues searchQuery to filter elements 29 | * @param {String} sortField the field to be sorted ex: entity.fieldName 30 | * @param {String} sortDir the direction of the sort 31 | * 32 | * @returns {promise} the entity config & the list of objects 33 | */ 34 | getAll(view, page, filterValues, sortField, sortDir) { 35 | page = page || 1; 36 | filterValues = filterValues || {}; 37 | let url = view.getUrl(); 38 | 39 | if (sortField && sortField.split('.')[0] === view.name()) { 40 | sortField = sortField; 41 | sortDir = sortDir; 42 | } else { 43 | sortField = view.getSortFieldName(); 44 | sortDir = view.sortDir(); 45 | } 46 | 47 | let allFilterValues = {}; 48 | const permanentFilters = view.permanentFilters(); 49 | Object.keys(filterValues).forEach(key => { 50 | allFilterValues[key] = filterValues[key]; 51 | }); 52 | Object.keys(permanentFilters).forEach(key => { 53 | allFilterValues[key] = permanentFilters[key]; 54 | }); 55 | 56 | return this.getRawValues(view.entity, view.name(), view.type, page, view.perPage(), allFilterValues, view.filters(), sortField, sortDir, url) 57 | .then((values) => { 58 | return { 59 | data: values.data, 60 | totalItems: values.totalCount || values.headers('X-Total-Count') || values.data.length 61 | }; 62 | }); 63 | } 64 | 65 | /** 66 | * Return the list of all object of entityName type 67 | * Get all the object from the API 68 | * 69 | * @param {Entity} entity 70 | * @param {String} viewName 71 | * @param {String} viewType 72 | * @param {Number} page 73 | * @param {Number} perPage 74 | * @param {Object} filterValues 75 | * @param {Object} filterFields 76 | * @param {String} sortField 77 | * @param {String} sortDir 78 | * @param {String} url 79 | * 80 | * @returns {promise} the entity config & the list of objects 81 | */ 82 | getRawValues(entity, viewName, viewType, page, perPage, filterValues, filterFields, sortField, sortDir, url) { 83 | let params = {}; 84 | 85 | // Compute pagination 86 | if (page !== -1) { 87 | params._page = (typeof (page) === 'undefined') ? 1 : parseInt(page, 10); 88 | params._perPage = perPage; 89 | } 90 | 91 | // Compute sorting 92 | if (sortField && sortField.split('.')[0] === viewName) { 93 | params._sortField = sortField.substr(sortField.indexOf('.') + 1); 94 | params._sortDir = sortDir; 95 | } 96 | 97 | // Compute filtering 98 | if (filterValues && Object.keys(filterValues).length !== 0) { 99 | params._filters = {}; 100 | let filterName, mappedValue; 101 | for (filterName in filterValues) { 102 | if (filterFields.hasOwnProperty(filterName) && filterFields[filterName].hasMaps()) { 103 | mappedValue = filterFields[filterName].getMappedValue(filterValues[filterName]); 104 | Object.keys(mappedValue).forEach(key => { 105 | params._filters[key] = mappedValue[key]; 106 | }) 107 | continue; 108 | } 109 | 110 | // It's weird to not map, but why not. 111 | params._filters[filterName] = filterValues[filterName]; 112 | } 113 | } 114 | 115 | // Get grid data 116 | return this._restWrapper 117 | .getList(params, entity.name(), this._application.getRouteFor(entity, url, viewType), entity.retrieveMethod()); 118 | } 119 | 120 | getReferenceData(references, rawValues) { 121 | var nonOptimizedReferencedData = this.getFilteredReferenceData(ReferenceExtractor.getNonOptimizedReferences(references), rawValues); 122 | var optimizedReferencedData = this.getOptimizedReferenceData(ReferenceExtractor.getOptimizedReferences(references), rawValues); 123 | return Promise.all([nonOptimizedReferencedData, optimizedReferencedData]) 124 | .then((results) => { 125 | let data = {}; 126 | let name; 127 | for (name in results[0]) { 128 | data[name] = results[0][name]; 129 | } 130 | for (name in results[1]) { 131 | data[name] = results[1][name]; 132 | } 133 | return data; 134 | }) 135 | } 136 | 137 | /** 138 | * Returns all References for an entity with associated values [{targetEntity.identifier: targetLabel}, ...] 139 | * by calling the API for each entries 140 | * 141 | * @param {ReferenceField} references A hash of Reference and ReferenceMany objects 142 | * @param {Array} rawValues 143 | * 144 | * @returns {Promise} 145 | */ 146 | getFilteredReferenceData(references, rawValues) { 147 | if (!references || !Object.keys(references).length) { 148 | return this._promisesResolver.empty({}); 149 | } 150 | 151 | let getOne = this.getOne.bind(this), 152 | calls = []; 153 | 154 | for (let i in references) { 155 | let reference = references[i], 156 | targetEntity = reference.targetEntity(), 157 | identifiers = reference.getIdentifierValues(rawValues); 158 | 159 | for (let k in identifiers) { 160 | calls.push(getOne(targetEntity, 'listView', identifiers[k], reference.name())); 161 | } 162 | } 163 | 164 | return this.fillFilteredReferencedData(calls, references, rawValues); 165 | } 166 | 167 | /** 168 | * Returns all References for an entity with associated values [{targetEntity.identifier: targetLabel}, ...] 169 | * by calling the API once 170 | * 171 | * @param {[ReferenceField]} references A hash of Reference and ReferenceMany objects 172 | * @param {Array} rawValues 173 | * 174 | * @returns {Promise} 175 | */ 176 | getOptimizedReferenceData(references, rawValues) { 177 | if (!references || !Object.keys(references).length) { 178 | return this._promisesResolver.empty({}); 179 | } 180 | 181 | let getRawValues = this.getRawValues.bind(this), 182 | calls = []; 183 | 184 | for (let i in references) { 185 | let reference = references[i], 186 | targetEntity = reference.targetEntity(), 187 | identifiers = reference.getIdentifierValues(rawValues); 188 | 189 | // Check if we should retrieve values with 1 or multiple requests 190 | let singleCallFilters = reference.getSingleApiCall(identifiers); 191 | calls.push(getRawValues(targetEntity, targetEntity.name() + '_ListView', 'listView', 1, reference.perPage(), singleCallFilters, {}, reference.sortField(), reference.sortDir())); 192 | } 193 | 194 | return this.fillOptimizedReferencedData(calls, references); 195 | } 196 | 197 | /** 198 | * Returns all References for an entity with associated values [{targetEntity.identifier: targetLabel}, ...] 199 | * without filters on an entity 200 | * 201 | * @param {[ReferenceField]} references A hash of Reference and ReferenceMany objects 202 | * 203 | * @returns {Promise} 204 | */ 205 | getAllReferencedData(references, search) { 206 | if (!references || !Object.keys(references).length) { 207 | return this._promisesResolver.empty({}); 208 | } 209 | 210 | let calls = [], 211 | getRawValues = this.getRawValues.bind(this); 212 | 213 | for (let i in references) { 214 | let reference = references[i]; 215 | let targetEntity = reference.targetEntity(); 216 | 217 | const permanentFilters = reference.permanentFilters(); 218 | let filterValues = permanentFilters || {}; 219 | 220 | if (typeof(permanentFilters) === 'function') { 221 | console.warn('Reference.permanentFilters() called with a function is deprecated. Use the searchQuery option for remoteComplete() instead'); 222 | filterValues = permanentFilters(search); 223 | } 224 | 225 | if (search) { 226 | // remote complete situation 227 | let options = reference.remoteCompleteOptions(); 228 | if (options.searchQuery) { 229 | let filterValuesFromRemoteComplete = options.searchQuery(search); 230 | Object.keys(filterValuesFromRemoteComplete).forEach(key => { 231 | filterValues[key] = filterValuesFromRemoteComplete[key]; 232 | }) 233 | } else { 234 | // by default, filter the list by the referenceField name 235 | filterValues[reference.targetField().name()] = search; 236 | } 237 | } 238 | 239 | let filterFields = {}; 240 | filterFields[reference.name()] = reference; 241 | 242 | calls.push(getRawValues( 243 | targetEntity, 244 | targetEntity.name() + '_ListView', 245 | 'listView', 246 | 1, 247 | reference.perPage(), 248 | filterValues, 249 | filterFields, 250 | reference.getSortFieldName(), 251 | reference.sortDir() 252 | )); 253 | 254 | } 255 | 256 | return this.fillOptimizedReferencedData(calls, references); 257 | } 258 | 259 | /** 260 | * Fill all reference entries to return [{targetEntity.identifier: targetLabel}, ...] 261 | * 262 | * @param {[Promise]} apiCalls 263 | * @param {[Reference]} references 264 | * @returns {Promise} 265 | */ 266 | fillOptimizedReferencedData(apiCalls, references) { 267 | return this._promisesResolver.allEvenFailed(apiCalls) 268 | .then((responses) => { 269 | if (responses.length === 0) { 270 | return {}; 271 | } 272 | 273 | let referencedData = {}, 274 | i = 0; 275 | 276 | for (let j in references) { 277 | let reference = references[j], 278 | response = responses[i++]; 279 | 280 | // Retrieve entries depending on 1 or many request was done 281 | if (response.status == 'error') { 282 | // the response failed 283 | continue; 284 | } 285 | 286 | referencedData[reference.name()] = response.result.data; 287 | } 288 | 289 | return referencedData; 290 | }); 291 | } 292 | 293 | /** 294 | * Fill all reference entries to return [{targetEntity.identifier: targetLabel}, ...] 295 | * 296 | * @param {[Promise]} apiCalls 297 | * @param {[Reference]} references 298 | * @param {[Object]} rawValues 299 | * @returns {Promise} 300 | */ 301 | fillFilteredReferencedData(apiCalls, references, rawValues) { 302 | return this._promisesResolver.allEvenFailed(apiCalls) 303 | .then((responses) => { 304 | if (responses.length === 0) { 305 | return {}; 306 | } 307 | 308 | let referencedData = {}, 309 | response, 310 | i = 0; 311 | 312 | for (let j in references) { 313 | let data = [], 314 | reference = references[j], 315 | identifiers = reference.getIdentifierValues(rawValues); 316 | 317 | for (let k in identifiers) { 318 | response = responses[i++]; 319 | if (response.status == 'error') { 320 | // one of the responses failed 321 | continue; 322 | } 323 | data.push(response.result); 324 | } 325 | 326 | if (!data.length) { 327 | continue; 328 | } 329 | 330 | referencedData[reference.name()] = data; 331 | } 332 | 333 | return referencedData; 334 | }); 335 | } 336 | 337 | /** 338 | * Returns all ReferencedList for an entity for associated values [{targetEntity.identifier: [targetFields, ...]}} 339 | * 340 | * @param {View} referencedLists 341 | * @param {String} sortField 342 | * @param {String} sortDir 343 | * @param {*} entityId 344 | * 345 | * @returns {promise} 346 | */ 347 | getReferencedListData(referencedLists, sortField, sortDir, entityId) { 348 | let getRawValues = this.getRawValues.bind(this), 349 | calls = []; 350 | 351 | for (let i in referencedLists) { 352 | let referencedList = referencedLists[i], 353 | targetEntity = referencedList.targetEntity(), 354 | viewName = referencedList.datagridName(), 355 | currentSortField = referencedList.getSortFieldName(), 356 | currentSortDir = referencedList.sortDir(), 357 | filter = {}; 358 | 359 | if (sortField && sortField.split('.')[0] === viewName) { 360 | currentSortField = sortField; 361 | currentSortDir = sortDir || 'ASC'; 362 | } 363 | 364 | const permanentFilters = referencedList.permanentFilters() || {}; 365 | Object.keys(permanentFilters).forEach(key => { 366 | filter[key] = permanentFilters[key]; 367 | }); 368 | filter[referencedList.targetReferenceField()] = entityId; 369 | 370 | calls.push(getRawValues(targetEntity, viewName, 'listView', 1, referencedList.perPage(), filter, {}, currentSortField, currentSortDir)); 371 | } 372 | 373 | return this._promisesResolver.allEvenFailed(calls) 374 | .then((responses) => { 375 | let j = 0, 376 | entries = {}; 377 | 378 | for (let i in referencedLists) { 379 | let response = responses[j++]; 380 | if (response.status == 'error') { 381 | // If a response fail, skip it 382 | continue; 383 | } 384 | 385 | entries[i] = response.result.data; 386 | } 387 | 388 | return entries; 389 | }); 390 | } 391 | 392 | getRecordsByIds(entity, ids) { 393 | if (!ids || !ids.length) { 394 | return this._promisesResolver.empty(); 395 | } 396 | 397 | let calls = ids.map(id => this.getOne(entity, 'listView', id, entity.identifier().name())); 398 | 399 | return this._promisesResolver.allEvenFailed(calls) 400 | .then(responses => responses.filter(r => r.status != 'error').map(r => r.result)); 401 | } 402 | } 403 | 404 | export default ReadQueries; 405 | -------------------------------------------------------------------------------- /tests/lib/View/ViewTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | 3 | import Entity from "../../../lib/Entity/Entity"; 4 | import Entry from "../../../lib/Entry"; 5 | import Field from "../../../lib/Field/Field"; 6 | import ReferenceField from "../../../lib/Field/ReferenceField"; 7 | import ReferenceManyField from "../../../lib/Field/ReferenceManyField"; 8 | import View from "../../../lib/View/View"; 9 | import ListView from "../../../lib/View/ListView"; 10 | 11 | describe('View', function() { 12 | 13 | it('should be disabled by default', () => { 14 | const view = new ListView().setEntity(new Entity('foobar')); 15 | assert.isFalse(view.enabled); 16 | }); 17 | 18 | describe('name()', function() { 19 | it('should return a default name based on the entity name and view type', function() { 20 | var view = new ListView().setEntity(new Entity('foobar')); 21 | assert.equal(view.name(), 'foobar_ListView'); 22 | }); 23 | }); 24 | 25 | describe('title()', function() { 26 | it('should return false by default', function () { 27 | var view = new ListView(new Entity('foobar')); 28 | assert.isFalse(view.title()); 29 | }); 30 | 31 | it('should return the view title', function () { 32 | var view = new View(new Entity('foobar')).title('my-title'); 33 | assert.equal(view.title(), 'my-title'); 34 | }); 35 | }); 36 | 37 | describe('description()', function() { 38 | it('should return empty string by default', function () { 39 | var view = new View(new Entity('foobar')); 40 | assert.equal(view.description(), ''); 41 | }); 42 | 43 | it('should return the view description', function () { 44 | var view = new View(new Entity('foobar')).description('my description'); 45 | assert.equal(view.description(), 'my description'); 46 | }); 47 | }); 48 | 49 | describe('getReferences()', function() { 50 | it('should return only reference and reference_many fields', function() { 51 | var post = new Entity('post'); 52 | var category = new ReferenceField('category'); 53 | var tags = new ReferenceManyField('tags'); 54 | var view = new View(post).fields([ 55 | new Field('title'), 56 | category, 57 | tags 58 | ]); 59 | 60 | assert.deepEqual({category: category, tags: tags}, view.getReferences()); 61 | }); 62 | 63 | it('should return only reference with remote complete if withRemoteComplete is true', function() { 64 | var post = new Entity('post'); 65 | var category = new ReferenceField('category').remoteComplete(true); 66 | var tags = new ReferenceManyField('tags').remoteComplete(false); 67 | var view = new View(post).fields([ 68 | new Field('title'), 69 | category, 70 | tags 71 | ]); 72 | 73 | assert.deepEqual({ category: category }, view.getReferences(true)); 74 | }); 75 | 76 | it('should return only reference with no remote complete if withRemoteComplete is false', function() { 77 | var post = new Entity('post'); 78 | var category = new ReferenceField('category').remoteComplete(true); 79 | var tags = new ReferenceManyField('tags').remoteComplete(false); 80 | var view = new View(post).fields([ 81 | new Field('title'), 82 | category, 83 | tags 84 | ]); 85 | 86 | assert.deepEqual({ tags: tags }, view.getReferences(false)); 87 | }); 88 | }); 89 | 90 | describe('addField()', function() { 91 | it('should add fields and preserve the order', function () { 92 | var post = new Entity('post'); 93 | var view = new View(post); 94 | var refMany = new ReferenceManyField('refMany'); 95 | var ref = new ReferenceField('myRef'); 96 | 97 | var field = new Field('body'); 98 | view.addField(ref).addField(refMany).addField(field); 99 | 100 | assert.equal(view.getFieldsOfType('reference_many')[0].name(), 'refMany'); 101 | assert.equal(view.getReferences()['myRef'].name(), 'myRef'); 102 | assert.equal(view.getReferences()['refMany'].name(), 'refMany'); 103 | assert.equal(view.getFields()[2].name(), 'body'); 104 | }); 105 | }); 106 | 107 | describe('fields()', function() { 108 | it('should return the fields when called with no arguments', function() { 109 | var view = new View(new Entity('post')); 110 | var field = new Field('body'); 111 | view.addField(field); 112 | 113 | assert.deepEqual(view.fields(), [field]); 114 | }); 115 | 116 | it('should add fields when called with an array argument', function() { 117 | var view = new View(new Entity('post')); 118 | var field1 = new Field('foo'); 119 | var field2 = new Field('bar'); 120 | view.fields([field1, field2]); 121 | 122 | assert.deepEqual(view.fields(), [field1, field2]); 123 | }); 124 | 125 | it('should keep the default order of the given array to equal to the index even when more than 10 fields', function() { 126 | var view = new View(new Entity('post')); 127 | var fields = Array.from(new Array(11).keys()).map(function (i) { 128 | return new Field(i); 129 | }); 130 | view.fields(fields); 131 | 132 | assert.deepEqual(view.fields(), fields); 133 | 134 | fields.map(function (field, index) { 135 | assert.equal(field.order(), index); 136 | }); 137 | }); 138 | 139 | it('should add fields when called with a nested array argument', function() { 140 | var view = new View(new Entity('post')); 141 | var field1 = new Field('foo'); 142 | var field2 = new Field('bar'); 143 | view.fields([field1, [field2]]); 144 | 145 | assert.deepEqual(view.fields(), [field1, field2]); 146 | }); 147 | 148 | it('should add a single field when called with a non array argument', function() { 149 | var view = new View(new Entity('post')); 150 | var field1 = new Field('foo'); 151 | view.fields(field1); 152 | 153 | assert.deepEqual(view.fields(), [field1]); 154 | }); 155 | 156 | it('should add fields when called with several arguments', function() { 157 | var view = new View(new Entity('post')); 158 | var field1 = new Field('foo'); 159 | var field2 = new Field('bar'); 160 | view.fields(field1, field2); 161 | 162 | assert.deepEqual(view.fields(), [field1, field2]); 163 | }); 164 | 165 | it('should add field collections', function() { 166 | var view1 = new View(new Entity('post')); 167 | var view2 = new View(new Entity('category')); 168 | var field1 = new Field('foo'); 169 | var field2 = new Field('bar'); 170 | view1.fields(field1, field2); 171 | view2.fields(view1.fields()); 172 | 173 | assert.deepEqual(view2.fields(), [field1, field2]); 174 | }); 175 | 176 | it('should allow fields reuse', function() { 177 | var field1 = new Field('foo'), field2 = new Field('bar'); 178 | var view1 = new View().addField(field1); 179 | var view2 = new View().fields([ 180 | view1.fields(), 181 | field2 182 | ]); 183 | 184 | assert.deepEqual(view2.fields(), [field1, field2]); 185 | }); 186 | 187 | it('should append fields when multiple calls', function() { 188 | var view = new View(); 189 | var field1 = new Field('foo'), field2 = new Field('bar'); 190 | view 191 | .fields(field1) 192 | .fields(field2); 193 | 194 | assert.deepEqual(view.fields(), [field1, field2]); 195 | }); 196 | 197 | it('should enable the view when setting fields', () => { 198 | var view = new View(); 199 | 200 | assert.isFalse(view.enabled); 201 | 202 | view.fields([new Field('test')]); 203 | assert.isTrue(view.enabled); 204 | }); 205 | }); 206 | 207 | describe('disable()', () => { 208 | it('should disable the view', () => { 209 | const view = new View(); 210 | view.enable(); 211 | assert.isTrue(view.enabled); 212 | view.disable(); 213 | assert.isFalse(view.enabled); 214 | }); 215 | 216 | it('should disable the view even if the fields have been set', () => { 217 | const view = new View(); 218 | 219 | assert.isFalse(view.enabled); 220 | view.fields([new Field('test')]); 221 | assert.isTrue(view.enabled); 222 | 223 | view.disable(); 224 | assert.isFalse(view.enabled); 225 | }); 226 | }); 227 | 228 | describe("validate()", function () { 229 | it('should call validator on each fields.', function () { 230 | var entry = new Entry(), 231 | view = new View('myView'), 232 | field1 = new Field('notValidable').label('Complex'), 233 | field2 = new Field('simple').label('Simple'); 234 | 235 | entry.values = { 236 | notValidable: false, 237 | simple: 1 238 | }; 239 | 240 | view.addField(field1).addField(field2); 241 | 242 | field1.validation().validator = function () { 243 | throw new Error('Field "Complex" is not valid.'); 244 | }; 245 | field2.validation().validator = function () { 246 | return true; 247 | }; 248 | 249 | assert.throw(function () { view.validate(entry); }, Error, 'Field "Complex" is not valid.'); 250 | }); 251 | 252 | it('should call validator with the targeted field as first parameter', function (done) { 253 | var entry = new Entry(), 254 | view = new View('myView'), 255 | field1 = new Field('field1').label('field1'); 256 | 257 | entry.values = { 258 | field1: "field1_value", 259 | }; 260 | 261 | view.addField(field1); 262 | 263 | field1.validation().validator = function (value) { 264 | assert.equal("field1_value", value); 265 | done(); 266 | }; 267 | 268 | view.validate(entry); 269 | }); 270 | 271 | it('should call validator with the all other fields as second parameter', function (done) { 272 | var entry = new Entry(), 273 | view = new View('myView'), 274 | field1 = new Field('field1').label('field1'), 275 | field2 = new Field('field2').label('field2'); 276 | 277 | entry.values = { 278 | field1: "field1_value", 279 | field2: "field2_value", 280 | }; 281 | 282 | view.addField(field1).addField(field2); 283 | 284 | field1.validation().validator = function (value, all) { 285 | assert.deepEqual({ 286 | field1: "field1_value", 287 | field2: "field2_value", 288 | }, all); 289 | done(); 290 | }; 291 | 292 | view.validate(entry); 293 | }); 294 | }); 295 | 296 | describe('mapEntry()', () => { 297 | 298 | it('should return an entry', () => { 299 | let view = new View(); 300 | view.setEntity(new Entity().name('Foo')); 301 | assert.instanceOf(view.mapEntry(), Entry); 302 | assert.equal(view.mapEntry().entityName, 'Foo'); 303 | }); 304 | 305 | it('should return an empty entry when passed an empty object', () => { 306 | let view = new View(); 307 | view.setEntity(new Entity().name('Foo')); 308 | assert.deepEqual(view.mapEntry({}).values, {}); 309 | }); 310 | 311 | it('should use default values when passed an empty object', () => { 312 | let view = new View(); 313 | view.setEntity(new Entity().name('Foo')); 314 | view.fields([ 315 | new Field('foo').defaultValue('bar') 316 | ]); 317 | assert.equal(view.mapEntry({}).values.foo, 'bar'); 318 | }); 319 | 320 | it('should not use default values when passed a non-empty object', () => { 321 | let view = new View(); 322 | view.setEntity(new Entity().name('Foo')); 323 | view.fields([ 324 | new Field('foo').defaultValue('bar') 325 | ]); 326 | assert.notEqual(view.mapEntry({ hello: 1 }).values.foo, 'bar'); 327 | }); 328 | 329 | it('should populate the entry based on the values passed as argument', () => { 330 | let view = new View(); 331 | view.setEntity(new Entity().name('Foo')); 332 | let entry = view.mapEntry({ hello: 1, world: 2 }); 333 | assert.equal(entry.values.hello, 1); 334 | assert.equal(entry.values.world, 2); 335 | }); 336 | 337 | it('should set the entry identifier value by default', () => { 338 | let view = new View(); 339 | view.setEntity(new Entity().name('Foo')); 340 | assert.equal(view.mapEntry({ id: 1, bar: 2 }).identifierValue, 1); 341 | }); 342 | 343 | it('should set the entry identifier value according to the fields', () => { 344 | let view = new View(); 345 | view.setEntity(new Entity().name('Foo').identifier(new Field('bar'))); 346 | assert.equal(view.mapEntry({ id: 1, bar: 2 }).identifierValue, 2); 347 | }); 348 | 349 | it('should transform the object values using the fields map functions', () => { 350 | let view = new View(); 351 | view.setEntity(new Entity().name('Foo')); 352 | view.fields([ 353 | new Field('foo').map(v => v - 1) 354 | ]); 355 | assert.equal(view.mapEntry({ foo: 2 }).values.foo, 1); 356 | }) 357 | }); 358 | 359 | describe('mapEntries()', () => { 360 | it('should return entries based on an array of objects', function () { 361 | let view = new View(); 362 | view 363 | .addField(new Field('title')) 364 | .setEntity(new Entity().identifier(new Field('post_id'))); 365 | 366 | let entries = view.mapEntries([ 367 | { post_id: 1, title: 'Hello', published: true}, 368 | { post_id: 2, title: 'World', published: false}, 369 | { post_id: 3, title: 'How to use ng-admin', published: false} 370 | ]); 371 | 372 | assert.equal(entries.length, 3); 373 | assert.equal(entries[0].identifierValue, 1); 374 | assert.equal(entries[1].values.title, 'World'); 375 | assert.equal(entries[1].values.published, false); 376 | }); 377 | }); 378 | 379 | describe('transformEntry()', () => { 380 | 381 | it('should return an empty object for empty entries', () => { 382 | let view = new View(); 383 | let entry = new Entry(); 384 | assert.deepEqual(view.transformEntry(entry), {}); 385 | }); 386 | 387 | it('should return an object litteral based on the entry values', () => { 388 | let view = new View(); 389 | let entry = new Entry('foo', { id: 1, bar: 2 }, 1); 390 | assert.deepEqual(view.transformEntry(entry), { id: 1, bar: 2 }); 391 | }); 392 | 393 | it('should transform the entry values using the fields transform functions', () => { 394 | let view = new View(); 395 | view.setEntity(new Entity().name('Foo')); 396 | view.fields([ 397 | new Field('bar').transform(v => v - 1) 398 | ]); 399 | let entry = new Entry('foo', { id: 1, bar: 2 }, 1); 400 | assert.deepEqual(view.transformEntry(entry), { id: 1, bar: 1 }); 401 | 402 | }); 403 | }); 404 | 405 | describe('identifier()', function() { 406 | it('should return the identifier.', function () { 407 | var entity = new Entity('post').identifier(new Field('post_id')); 408 | var view = entity.listView(); 409 | view.addField(new Field('name')); 410 | 411 | assert.equal(view.identifier().name(), 'post_id'); 412 | }); 413 | }); 414 | }); 415 | --------------------------------------------------------------------------------