diff --git a/.jshintrc b/.jshintrc index 8fe58ca6..a9a9e7ee 100644 --- a/.jshintrc +++ b/.jshintrc @@ -4,7 +4,7 @@ "esnext": true, "bitwise": true, "camelcase": true, - "curly": true, + "curly": false, "eqeqeq": true, "immed": true, "indent": 2, @@ -14,7 +14,7 @@ "regexp": true, "undef": true, "unused": true, - "strict": true, + "strict": false, "trailing": true, "smarttabs": true, "globals": { diff --git a/doc/Configuration-reference.md b/doc/Configuration-reference.md index ba12ab07..282ccc2f 100644 --- a/doc/Configuration-reference.md +++ b/doc/Configuration-reference.md @@ -379,6 +379,7 @@ A field is the representation of a property of an entity. * [`file` Field Type](#file-field-type) * [`reference` Field Type](#reference-field-type) * [`referenced_list` Field Type](#referenced_list-field-type) +* [`embedded_list` Field Type](#embedded_list-field-type) * [`reference_many` Field Type](#reference_many-field-type) ### General Field Settings @@ -608,42 +609,73 @@ Some other properties are allowed, see https://github.com/danialfarid/ng-file-up ### `reference` Field Type -The `reference` type also defines `label`, `order`, `map`, `list` & `validation` options like the `Field` type. +The `reference` type maps a many-to-one relationship where the entity contains a foreign key to another entity. For instance, if the REST API behaves as follows: + +``` +GET /comments/123 +{ + "id": 123, + "body": "Lorem ipsum sic dolor amet...", + "post_id": 456 // foreign key to post of id 456 +} + +GET /posts/456 +{ + "id": "456", + "title": "Consectetur adipisicing elit", + "body": "Sed do eiusmod..." +} +``` + +Then mapping the `post_id` property of the `comment` entity to a `reference` will tell ng-admin to fetch the related `post` entity, and to display the `targetField`. + +```js +var post = nga.entity('posts'); +var comment = nga.entity('comments'); +comment.listView().fields([ + nga.field('post_id', 'reference') + .targetEntity(post) // Select a target Entity + .targetField(nga.field('title')) // Select the field to be displayed +]); +``` + +In a read context (`listView` and `showView`), `reference` fields render the targetField as text. In a write context (`creationView` and `editionView`), ` reference` fields render as a dropdown, allowing to select the related entity among a list using the ` targetField` as a representation. In that case, ng-admin fetches the possible values on the related entity, so a `reference` field makes an additional query in a write context: + +``` +GET /comments/123 <= get the main entity +{ "id": 123, "post_id": 456, ... }, +GET /posts/456 <= get the referenced entity itself +GET /posts?_perPage=30 <= get the possible values for the referenced entity +``` + +The `reference` type specializes the `Field` type, so it supports the same `label`, `order`, `map`, `list` & `validation` options. Additional options are: * `targetEntity(Entity)` Define the referenced entity. * `targetField(string)` -Define the target field name used to retrieve the label of the referenced element. +Define the target field used to retrieve the label of the referenced element. myView.fields([ nga.field('post_id', 'reference') - .label('Post title') - .map(truncate) // Allows to truncate values in the select - .targetEntity(post) // Select a target Entity - .targetField(nga.field('title')) // Select a label Field + .label('Post content') + .targetEntity(post) + .targetField(nga.field('body')) // display the body instead of the title + .map(truncate) // truncate the long body ]); -* `singleApiCall(function(entityIds) {}` -Define a function that returns parameters for filtering API calls. You can use it if you API support filter for multiple values. - - // Will call /posts?post_id[]=1&post_id[]=2&post_id%[]=5... - commentList.fields([ - nga.field('post_id', 'reference') - .singleApiCall(function (postIds) { - return { 'post_id[]': postIds }; - }) - ]); +* `perPage(integer)` +Define the maximum number of related entities fetched and displayed in the dropdown of possible values in a write context. Defaults to 30. * `sortField(String)` -Set the default field for list sorting. Defaults to 'id' +Set the field used to sort the list displayed in the dropdown in a write context. Defaults to 'id'. * `sortDir(String)` -Set the default direction for list sorting. Defaults to 'DESC' +Set the direction used to sort the list displayed in the dropdown in a write context. Defaults to 'DESC'. * `remoteComplete([true|false], options = {})` -Enable autocompletion by fetching remote results (disabled by default). When enabled, the `reference` widget fetches the results matching the string typed in the autocomplete input from the REST API. -If set to false, all references (in the limit of `perPage` parameter) would be retrieved at view initialization. +In write context, enable autocompletion by fetching remote results as the user types (disabled by default). When enabled, the `reference` widget fetches the results matching the string typed in the autocomplete input from the REST API. +When set to false, all references (in the limit of `perPage` parameter) are retrieved at view initialization. comments.editionView().fields([ nga.field('id'), @@ -656,7 +688,7 @@ If set to false, all references (in the limit of `perPage` parameter) would be r Available options are: * `refreshDelay`: minimal delay between two API calls in milliseconds. By default: 500. - * `searchQuery`: a function returning the parameters to add to the query string basd on the input string. + * `searchQuery`: a function returning the parameters to add to the query string based on the input string. comments.editionView().fields([ nga.field('id'), @@ -671,8 +703,56 @@ If set to false, all references (in the limit of `perPage` parameter) would be r .perPage(10) // limit the number of results to 10 ]); +* `singleApiCall(function(entityIds) {}` +Group queries for the related entities in a `listView`. If that option isn't defined, adding a `reference` field in a `listView` triggers a query to the API for the related entity *for each row*. + + commentList.fields([ + nga.field('post_id', 'reference') + .targetEntity(post) + .targetField(nga.field('title')) + ]); + + // will trigger the following queries + GET /comments?_page=1 + [ + { "id": 123, "post_id": 456, ... }, + { "id": 124, "post_id": 457, ... }, + { "id": 125, "post_id": 458, ... }, + ] + GET /posts/456 + { "id": 456, ... } + GET /posts/457 + { "id": 457, ... } + GET /posts/458 + { "id": 458, ... } + + On most configurations, multiplying the requests to the REST API like that will slow down the rendering of the list a great deal. To speed things up, you can group the calls to the related entities - provided the REST API supports filters for multiple values ("WHERE ... IN"). To do so, use `singleApiCall()` to format the request based on an array of ids. + + commentList.fields([ + nga.field('post_id', 'reference') + .targetEntity(post) + .targetField(nga.field('title')) + .singleApiCall(function (postIds) { + return { 'post_id[]': postIds }; + }) + ]); + + // will trigger the following queries + GET /comments?_page=1 + [ + { "id": 123, "post_id": 456, ... }, + { "id": 124, "post_id": 457, ... }, + { "id": 125, "post_id": 458, ... }, + ] + GET /posts?post_id[]=456&post_id[]=457&post_id[]=458 + [ + { "id": 456, ... }, + { "id": 457, ... }, + { "id": 458, ... }, + ] + * `permanentFilters({ field1: value, field2: value, ...})` -Add filters to the referenced results list. This can be very useful to restrict the list of possible values displayed in a dropdown list: +Add filters to the referenced results list. This can be very useful to restrict the list of possible values displayed in a dropdown list. As such, it is only used in write context. comments.editionView().fields([ nga.field('id'), @@ -680,16 +760,72 @@ Add filters to the referenced results list. This can be very useful to restrict .targetEntity(post) .targetField(nga.field('title')) .permanentFilters({ - published: true + published: true // display only the published posts }); ]); -* `perPage(integer)` -Define the maximum number of elements fetched and displayed in the list. + // will trigger the following queries + GET /comments/123 <= get the main entity + { "id": 123, "post_id": 456, ... } + GET /posts/456 <= get the referenced entity itself + GET /posts?_filters={"published":true} <= get the possible values for the referenced entity + [ + { "id": 456, ... }, + { "id": 458, ... }, + ] ### `referenced_list` Field Type -The `referenced_list` type also defines `label`, `order`, `map`, `list` & `validation` options like the `Field` type. +The `referenced_list` type maps a one-to-many relationship where the foreign key is located in another entity. For instance, if the REST API behaves as follows: + +``` +GET /posts/456 +{ + "id": "456", + "title": "Consectetur adipisicing elit", + "body": "Sed do eiusmod..." +} + +GET /comments/123 +{ + "id": 123, + "author": "Alice", + "body": "Lorem ipsum sic dolor amet...", + "post_id": 456 // foreign key to post of id 456 +} +GET /comments/124 +{ + "id": 124, + "author": "Bob", + "body": "Lorem ipsum sic dolor amet...", + "post_id": 456 // foreign key to post of id 456 +} +``` + +Then mapping a `comments` property of the `post` entity to a `referenced_list` will tell ng-admin to fetch the related `comment` entities, and to display the result in a datagrid. + +```js +var post = nga.entity('posts'); +var comment = nga.entity('comments'); +post.editionView().fields([ + nga.field('comments', 'referenced_list') // Define a 1-N relationship with the comment entity + .targetEntity(comment) // Target the comment Entity + .targetReferenceField('post_id') // Each comment with post_id = post.id (the identifier) will be displayed + .targetFields([ // which comment fields to display in the datagrid + nga.field('id').label('ID'), + nga.field('body').label('Comment') + ]) +]); +``` + +As such, a `referenced_lists` field is the opposite of a `reference` field. `referenced_lists` fields are not editable (because the relationship is the other entity's responsibility), so they render the same in all contexts: as a datagrid. However, they are only useful in `showView` and `editionView` (you can't display a datagrid in a datagrid, so this excludes the `listView`, and you can't fetch related entities to a non-existent entity, so this excludes the `creationView`). For that field, ng-admin fetches the related entities in a single query with a filter: + +``` +GET /posts/456 <= get the main entity +GET /comments?_filters={"post_id":456}&_page=1 <= get the values for the referenced entity +``` + +The `referenced_list` type specializes the `Field` type, so it supports the same `label`, `order`, `map`, `list` & `validation` options. Additional options are: * `targetEntity(Entity)` Define the referenced entity. @@ -698,33 +834,182 @@ Define the referenced entity. Define the field name used to link the referenced entity. * `targetFields(Array(Field))` -Define an array of fields that will be displayed in the list of the form. +Define the list of fields of the target entity to be displayed in the datagrid. myEditionView.fields([ - nga.field('comments', 'referenced_list') // Define a N-1 relationship with the comment entity - .label('Comments') - .targetEntity(comment) // Target the comment Entity - .targetReferenceField('post_id') // Each comment with post_id = post.id (the identifier) will be displayed - .targetFields([ // Display comment field to display + nga.field('comments', 'referenced_list') + .targetEntity(comment) + .targetReferenceField('post_id') + .targetFields([ // choose another set of fields + nga.field('author'), + nga.field('body') + ]) + ]); + +* `perPage(integer)` +Define the maximum number of related entities fetched and displayed in the datagrid. Defaults to 30. + +* `sortField(String)` +Set the field used to sort the datagrid. Defaults to 'id'. + +* `sortDir(String)` +Set the direction used to sort the datagrid. Defaults to 'DESC'. + +* `permanentFilters({ field1: value, field2: value, ...})` +Filter the list of referenced entities list. This can be very useful to restrict the list of possible values displayed in the datagrid: + + post.editionView().fields([ + nga.field('comments', 'referenced_list') + .targetEntity(comment) + .targetReferenceField('post_id') + .targetFields([ nga.field('id').label('ID'), nga.field('body').label('Comment') ]) + .permanentFilters({ + published: true // display only the published comments + }) + ]); + + // will trigger the following queries + GET /post/456 <= get the main entity + { "id": 456, ... } + GET /posts?_filters={"post_id":456,"published":true}&_page=1 <= get the possible values for the referenced entity + [ + { "id": 123, "post_id": 456, ... }, + { "id": 124, "post_id": 456, ... }, + ] + +### `embedded_list` Field Type + +The `embedded_list` type maps a one-to-many relationship where the related entities are embedded in the main response. For instance, if the REST API behaves as follows: + +``` +GET /posts/1 +{ + "id": "1", + "title": "Consectetur adipisicing elit", + "body": "Sed do eiusmod...", + "comments": [ + { + "author": "Alice", + "body": "Lorem ipsum sic dolor amet...", + }, + { + "author": "Bob", + "body": "Lorem ipsum sic dolor amet...", + } + ] +} +``` + +Then mapping a `comments` property of the `post` entity to an `embedded_list` will tell ng-admin to use the embedded `comment` entities. + +```js +post.showView().fields([ + nga.field('comments', 'embedded_list') // Define a 1-N relationship with the (embedded) comment entity + .targetFields([ // which comment fields to display in the datagrid / form + nga.field('body') + ]) +]); +``` + +Ng-admin renders`embedded_list` fields as a datagrid in read context (`showView`), and as a list of embedded forms in write context (`creationView` and `editionView`). This won't issue any additional query to the REST API, since the related entities are already embedded. + +The `embedded_list` type specializes the `Field` type, so it supports the same `label`, `order`, `map`, `list` & `validation` options. Additional options are: + +* `targetFields(Array(Field))` +Define the list of fields of the target entity to be displayed in the datagrid. + + myEditionView.fields([ + nga.field('comments', 'embedded_list') + .targetFields([ // choose another set of fields + nga.field('author'), + nga.field('body') + ]) ]); * `sortField(String)` -Set the default field for list sorting. Defaults to 'id' +Set the field used to sort the datagrid. Defaults to 'id'. * `sortDir(String)` -Set the default direction for list sorting. Defaults to 'DESC' +Set the direction used to sort the datagrid. Defaults to 'DESC'. + +* `targetEntity(Entity)` +Define the referenced entity (optional). When set, if the embedded entities have an identifier field, the `embedded_list` datagrid will be able to display links to the detail view of the entity. It is not used in write context. + + var post = nga.entity('posts'); + var comment = nga.entity('comments'); + post.showView().fields([ + nga.field('comments', 'embedded_list') // Define a 1-N relationship with the (embedded) comment entity + .targetEntity(comment) + .targetFields([ // which comment fields to display in the datagrid / form + nga.field('id') // will have a link to comment edition view + nga.field('body') + ]) + ]); * `permanentFilters({ field1: value, field2: value, ...})` -Add filters to the referenced results list. +Filter the list of referenced entities list. This can be very useful to restrict the list of possible values displayed in the datagrid: -* `perPage(integer)` -Define the maximum number of elements fetched and displayed in the list. + post.editionView().fields([ + nga.field('comments', 'embedded_list') + .targetFields([ + nga.field('body') + ]) + .permanentFilters({ + published: true // display only the published comments + }) + ]); ### `reference_many` Field Type +The `reference_many` type maps a one-to-many relationship where the identifiers of the related entities are embedded in the main response. For instance, if the REST API behaves as follows: + +``` +GET /posts/456 +{ + "id": "456", + "title": "Consectetur adipisicing elit", + "body": "Sed do eiusmod...", + "comments": [123, 124] +} + +GET /comments/123 +{ + "id": 123, + "author": "Alice", + "body": "Lorem ipsum sic dolor amet...", +} +GET /comments/124 +{ + "id": 124, + "author": "Bob", + "body": "Lorem ipsum sic dolor amet...", +} +``` + +Then mapping a `comments` property of the `post` entity to a `reference_many` will tell ng-admin to fetch the related `comment` entities. + +```js +var post = nga.entity('posts'); +var comment = nga.entity('comments'); +post.editionView().fields([ + nga.field('comments', 'reference_many') // Define a 1-N relationship with the comment entity + .targetEntity(comment) // Target the comment Entity + .targetFieldField('body') // the field of the comment entity to use as representation +]); +``` + +` reference_many` fields render as a list of labels in rerad context (`listView` and `showView`), and as a select multiple in write context (`creationView` and `editionView`). For that field, ng-admin fetches the related entities one by one: + +``` +GET /posts/456 <= get the main entity +{ "id": "456", "comments": [123, 124], ... } +GET /comments/123 +GET /comments/124 +``` + The `reference_many` field type also defines `label`, `order`, `map` & `validation` options like the `Field` type. * `targetEntity(Entity)` @@ -742,7 +1027,7 @@ Define the field name used to link the referenced entity. ]) * `singleApiCall(function(entityIds) {}` -Define a function that returns parameters for filtering API calls. You can use it if you API support filter for multiple values. +Define a function that returns parameters for filtering API calls. You can use it if you API supports filter for multiple values. // Will call /tags?tag_id[]=1&tag_id[]=2&tag_id%[]=5... postList.fields([ diff --git a/examples/blog/config.js b/examples/blog/config.js index 11488953..da501180 100644 --- a/examples/blog/config.js +++ b/examples/blog/config.js @@ -97,6 +97,10 @@ .cssClasses('hidden-xs'), nga.field('views', 'number') .cssClasses('hidden-xs'), + nga.field('backlinks', 'embedded_list') // display list of related comments + .label('Links') + .map(links => links ? links.length : '') + .template('{{ value }}'), nga.field('tags', 'reference_many') // a Reference is a particular type of field that references another entity .targetEntity(tag) // the tag entity is defined later in this file .targetField(nga.field('name')) // the field to be displayed in this list @@ -152,6 +156,14 @@ .cssClasses('col-sm-4'), nga.field('average_note', 'float') .cssClasses('col-sm-4'), + nga.field('backlinks', 'embedded_list') // display embedded list + .targetFields([ + nga.field('date', 'datetime'), + nga.field('url') + .cssClasses('col-lg-10') + ]) + .sortField('date') + .sortDir('DESC'), nga.field('comments', 'referenced_list') // display list of related comments .targetEntity(nga.entity('comments')) .targetReferenceField('post_id') @@ -170,7 +182,39 @@ post.showView() // a showView displays one entry in full page - allows to display more data than in a a list .fields([ nga.field('id'), - post.editionView().fields(), // reuse fields from another view in another order + nga.field('category', 'choice') // a choice field is rendered as a dropdown in the edition view + .choices([ // List the choice as object literals + { label: 'Tech', value: 'tech' }, + { label: 'Lifestyle', value: 'lifestyle' } + ]), + nga.field('subcategory', 'choice') + .choices(subCategories), + nga.field('tags', 'reference_many') // ReferenceMany translates to a select multiple + .targetEntity(tag) + .targetField(nga.field('name')), + nga.field('pictures', 'json'), + nga.field('views', 'number'), + nga.field('average_note', 'float'), + nga.field('backlinks', 'embedded_list') // display embedded list + .targetFields([ + nga.field('date', 'datetime'), + nga.field('url') + ]) + .sortField('date') + .sortDir('DESC'), + nga.field('comments', 'referenced_list') // display list of related comments + .targetEntity(nga.entity('comments')) + .targetReferenceField('post_id') + .targetFields([ + nga.field('id').isDetailLink(true), + nga.field('created_at').label('Posted'), + nga.field('body').label('Comment') + ]) + .sortField('created_at') + .sortDir('DESC') + .listActions(['edit']), + nga.field('').label('') + .template(''), nga.field('custom_action').label('') .template('') ]); diff --git a/examples/blog/data.js b/examples/blog/data.js index 66abc58b..f5483595 100644 --- a/examples/blog/data.js +++ b/examples/blog/data.js @@ -35,12 +35,12 @@ var apiData = { } }, "published_at": "2012-08-06", - "tags": [ - 1, - 3 - ], + "tags": [1, 3], "category": "tech", - "subcategory": "computers" + "subcategory": "computers", + "backlinks": [ + { "date": "2012-08-09T00:00:00.000Z", "url": "http://example.com/bar/baz.html" }, + ] }, { "id": 2, @@ -50,10 +50,8 @@ var apiData = { "views": 563, "average_note": 3.48121, "published_at": "2012-08-08", - "tags": [ - 3, - 5 - ] + "tags": [3, 5], + "backlinks": [] }, { "id": 3, @@ -63,9 +61,12 @@ var apiData = { "views": 467, "average_note": 4.12319, "published_at": "2012-08-08", - "tags": [ - 1, - 2 + "tags": [1,2], + "backlinks": [ + { "date": "2012-08-10T00:00:00.000Z", "url": "http://example.com/foo/bar.html" }, + { "date": "2012-08-14T00:00:00.000Z", "url": "https://blog.johndoe.com/2012/08/12/foobar.html" }, + { "date": "2012-08-22T00:00:00.000Z", "url": "https://foo.bar.com/lorem/ipsum" }, + { "date": "2012-08-29T00:00:00.000Z", "url": "http://dicta.es/nam_doloremque" } ] }, { @@ -164,7 +165,10 @@ var apiData = { ], "category": "tech", "subcategory": "computers", - "pictures": null + "pictures": null, + "backlinks": [ + { "date": "2012-10-29T00:00:00.000Z", "url": "http://dicta.es/similique_pariatur" } + ] }, { "id": 12, @@ -177,7 +181,11 @@ var apiData = { "tags": [], "category": "lifestyle", "subcategory": "fitness", - "pictures": null + "pictures": { "first": {}, "second": {} }, + "backlinks": [ + { "date": "2012-08-07T00:00:00.000Z", "url": "http://example.com/foo/bar.html" }, + { "date": "2012-08-12T00:00:00.000Z", "url": "https://blog.johndoe.com/2012/08/12/foobar.html" } + ] } ], "comments": [ diff --git a/examples/blog/fakerest-init.js b/examples/blog/fakerest-init.js index 570456b3..42424f90 100644 --- a/examples/blog/fakerest-init.js +++ b/examples/blog/fakerest-init.js @@ -5,6 +5,10 @@ var restServer = new FakeRest.Server('http://localhost:3000'); var testEnv = window.location.pathname.indexOf('test.html') !== -1; restServer.init(apiData); + restServer.setDefaultQuery(function(resourceName) { + if (resourceName == 'posts') return { embed: ['comments'] } + return {}; + }); restServer.toggleLogging(); // logging is off by default, enable it // use sinon.js to monkey-patch XmlHttpRequest diff --git a/examples/blog/index.html b/examples/blog/index.html index 53fb2193..7266b8eb 100644 --- a/examples/blog/index.html +++ b/examples/blog/index.html @@ -5,6 +5,11 @@ Angular admin +
diff --git a/package.json b/package.json index f4526ec3..3fd82a46 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "url": "git://github.com/marmelab/ng-admin.git" }, "devDependencies": { - "admin-config": "^0.4.0", + "admin-config": "^0.5.1", "angular": "~1.3.15", "angular-bootstrap": "^0.12.0", "angular-mocks": "1.3.14", @@ -29,7 +29,7 @@ "es6-promise": "^2.3.0", "exports-loader": "^0.6.2", "extract-text-webpack-plugin": "^0.8.0", - "fakerest": "^1.0.10", + "fakerest": "^1.1.4", "file-loader": "^0.8.1", "font-awesome": "^4.3.0", "grunt": "~0.4.4", diff --git a/src/javascripts/ng-admin/Crud/CrudModule.js b/src/javascripts/ng-admin/Crud/CrudModule.js index 1f4db9b9..be2534a4 100644 --- a/src/javascripts/ng-admin/Crud/CrudModule.js +++ b/src/javascripts/ng-admin/Crud/CrudModule.js @@ -27,6 +27,7 @@ CrudModule.directive('maButtonField', require('./field/maButtonField')); CrudModule.directive('maChoiceField', require('./field/maChoiceField')); CrudModule.directive('maChoicesField', require('./field/maChoicesField')); CrudModule.directive('maDateField', require('./field/maDateField')); +CrudModule.directive('maEmbeddedListField', require('./field/maEmbeddedListField')); CrudModule.directive('maInputField', require('./field/maInputField')); CrudModule.directive('maJsonField', require('./field/maJsonField')); CrudModule.directive('maFileField', require('./field/maFileField')); @@ -54,6 +55,7 @@ CrudModule.directive('maColumn', require('./column/maColumn')); CrudModule.directive('maBooleanColumn', require('./column/maBooleanColumn')); CrudModule.directive('maChoicesColumn', require('./column/maChoicesColumn')); CrudModule.directive('maDateColumn', require('./column/maDateColumn')); +CrudModule.directive('maEmbeddedListColumn', require('./column/maEmbeddedListColumn')); CrudModule.directive('maJsonColumn', require('./column/maJsonColumn')); CrudModule.directive('maNumberColumn', require('./column/maNumberColumn')); CrudModule.directive('maReferenceColumn', require('./column/maReferenceColumn')); diff --git a/src/javascripts/ng-admin/Crud/column/maEmbeddedListColumn.js b/src/javascripts/ng-admin/Crud/column/maEmbeddedListColumn.js new file mode 100644 index 00000000..1721bda4 --- /dev/null +++ b/src/javascripts/ng-admin/Crud/column/maEmbeddedListColumn.js @@ -0,0 +1,85 @@ +import Entry from 'admin-config/lib/Entry'; + +function sorter(sortField, sortDir) { + return (entry1, entry2) => { + // use < and > instead of substraction to sort strings properly + const sortFactor = sortDir === 'DESC' ? -1 : 1; + if (entry1.values[sortField] > entry2.values[sortField]) return sortFactor; + if (entry1.values[sortField] < entry2.values[sortField]) return -1 * sortFactor; + return 0; + }; +} + +function maEmbeddedListColumn(NgAdminConfiguration) { + const application = NgAdminConfiguration(); // jshint ignore:line + return { + scope: { + 'field': '&', + 'value': '&', + 'datastore': '&' + }, + restrict: 'E', + link: { + pre: function(scope) { + const field = scope.field(); + const targetEntity = field.targetEntity(); + const targetEntityName = targetEntity.name(); + const targetFields = field.targetFields(); + const sortField = field.sortField(); + const sortDir = field.sortDir(); + var filterFunc; + if (field.permanentFilters()) { + const filters = field.permanentFilters(); + const filterKeys = Object.keys(filters); + filterFunc = (entry) => filterKeys.reduce((isFiltered, key) => isFiltered && entry.values[key] === filters[key], true); + } else { + filterFunc = () => true; + } + let entries = Entry + .createArrayFromRest(scope.value() || [], targetFields, targetEntityName, targetEntity.identifier().name()) + .sort(sorter(sortField, sortDir)) + .filter(filterFunc); + if (!targetEntityName) { + let index = 0; + entries = entries.map(e => { + e._identifierValue = index++; + return e; + }); + } + scope.field = field; + scope.targetFields = targetFields; + scope.entries = entries; + scope.entity = targetEntityName ? application.getEntity(targetEntityName) : targetEntity; + scope.sortField = sortField; + scope.sortDir = sortDir; + scope.sort = field => { + let sortDir = 'ASC'; + const sortField = field.name(); + if (scope.sortField === sortField) { + // inverse sort dir + sortDir = scope.sortDir === 'ASC' ? 'DESC' : 'ASC'; + } + scope.entries = scope.entries.sort(sorter(sortField, sortDir)); + scope.sortField = sortField; + scope.sortDir = sortDir; + }; + } + }, + template: ` + +` + }; +} + +maEmbeddedListColumn.$inject = ['NgAdminConfiguration']; + +module.exports = maEmbeddedListColumn; + diff --git a/src/javascripts/ng-admin/Crud/column/maReferencedListColumn.js b/src/javascripts/ng-admin/Crud/column/maReferencedListColumn.js index 69820eff..eb50c1f0 100644 --- a/src/javascripts/ng-admin/Crud/column/maReferencedListColumn.js +++ b/src/javascripts/ng-admin/Crud/column/maReferencedListColumn.js @@ -14,7 +14,7 @@ function maReferencedListColumn(NgAdminConfiguration) { } }, template: ` - { + return filterKeys.reduce((isFiltered, key) => isFiltered && entry.values[key] === filters[key], true); + }; + } else { + filterFunc = () => true; + } + scope.fields = targetFields; + scope.entries = Entry + .createArrayFromRest(scope.value || [], targetFields, targetEntityName, targetEntity.identifier().name()) + .sort((entry1, entry2) => { + // use < and > instead of substraction to sort strings properly + if (entry1.values[sortField] > entry2.values[sortField]) return sortDir; + if (entry1.values[sortField] < entry2.values[sortField]) return -1 * sortDir; + return 0; + }) + .filter(filterFunc); + scope.addNew = () => scope.entries.push(Entry.createForFields(targetFields)); + scope.remove = entry => { + scope.entries = scope.entries.filter(e => e !== entry); + }; + scope.$watch('entries', (newEntries, oldEntries) => { + if (newEntries === oldEntries) return; + scope.value = newEntries.map(e => e.transformToRest(targetFields)); + }, true); + } + }, + template: ` +
+ +
+  Remove +
+
+ +
+
+
+ +
` + }; +} + +maEmbeddedListField.$inject = []; + +module.exports = maEmbeddedListField; diff --git a/src/javascripts/ng-admin/Crud/fieldView/DateFieldView.js b/src/javascripts/ng-admin/Crud/fieldView/DateFieldView.js index 5b1932db..1301ec87 100644 --- a/src/javascripts/ng-admin/Crud/fieldView/DateFieldView.js +++ b/src/javascripts/ng-admin/Crud/fieldView/DateFieldView.js @@ -2,5 +2,5 @@ module.exports = { getReadWidget: () => '', getLinkWidget: () => '' + module.exports.getReadWidget() + '', getFilterWidget: () => '', - getWriteWidget: () => '
' + getWriteWidget: () => '
' }; diff --git a/src/javascripts/ng-admin/Crud/fieldView/DateTimeFieldView.js b/src/javascripts/ng-admin/Crud/fieldView/DateTimeFieldView.js index 552367f6..69866085 100644 --- a/src/javascripts/ng-admin/Crud/fieldView/DateTimeFieldView.js +++ b/src/javascripts/ng-admin/Crud/fieldView/DateTimeFieldView.js @@ -2,5 +2,5 @@ module.exports = { getReadWidget: () => '', getLinkWidget: () => '' + module.exports.getReadWidget() + '', getFilterWidget: () => '', - getWriteWidget: () => '
' + getWriteWidget: () => '
' }; diff --git a/src/javascripts/ng-admin/Crud/fieldView/EmbeddedListFieldView.js b/src/javascripts/ng-admin/Crud/fieldView/EmbeddedListFieldView.js new file mode 100644 index 00000000..d5308384 --- /dev/null +++ b/src/javascripts/ng-admin/Crud/fieldView/EmbeddedListFieldView.js @@ -0,0 +1,6 @@ +module.exports = { + getReadWidget: () => '', + getLinkWidget: () => 'error: cannot display referenced_list field as linkable', + getFilterWidget: () => 'error: cannot display referenced_list field as filter', + getWriteWidget: () => '' +}; diff --git a/src/javascripts/ng-admin/Crud/list/maDatagrid.js b/src/javascripts/ng-admin/Crud/list/maDatagrid.js index eb7c6259..56a13a21 100644 --- a/src/javascripts/ng-admin/Crud/list/maDatagrid.js +++ b/src/javascripts/ng-admin/Crud/list/maDatagrid.js @@ -15,7 +15,10 @@ define(function (require) { fields: '&', listActions: '&', entity: '&', - datastore: '&' + datastore: '&', + sortField: '@', + sortDir: '@', + sort: '&' }, controllerAs: 'datagrid', controller: maDatagridController, @@ -27,7 +30,7 @@ define(function (require) { - + {{ field.label() }} diff --git a/src/javascripts/ng-admin/Crud/list/maDatagridController.js b/src/javascripts/ng-admin/Crud/list/maDatagridController.js index 04020fbf..59b96032 100644 --- a/src/javascripts/ng-admin/Crud/list/maDatagridController.js +++ b/src/javascripts/ng-admin/Crud/list/maDatagridController.js @@ -12,7 +12,7 @@ define(function () { * * @constructor */ - function DatagridController($scope, $location, $stateParams, $anchorScroll) { + function DatagridController($scope, $location, $stateParams, $anchorScroll, $attrs) { $scope.entity = $scope.entity(); this.$scope = $scope; this.$location = $location; @@ -20,12 +20,15 @@ define(function () { this.datastore = this.$scope.datastore(); this.filters = {}; this.shouldDisplayActions = this.$scope.listActions() && this.$scope.listActions().length > 0; - $scope.toggleSelect = this.toggleSelect.bind(this); $scope.toggleSelectAll = this.toggleSelectAll.bind(this); - - this.sortField = 'sortField' in $stateParams ? $stateParams.sortField : null; - this.sortDir = 'sortDir' in $stateParams ? $stateParams.sortDir : null; + $scope.sortField = $attrs.sortField; + $scope.sortDir = $attrs.sortDir; + this.sortField = 'sortField' in $stateParams ? $stateParams.sortField : $attrs.sortField; + this.sortDir = 'sortDir' in $stateParams ? $stateParams.sortDir : $attrs.sortDir; + $attrs.$observe('sortDir', sortDir => this.sortDir = sortDir); + $attrs.$observe('sortField', sortField => this.sortField = sortField); + this.sortCallback = $scope.sort() ? $scope.sort() : this.sort.bind(this); } /** @@ -73,7 +76,7 @@ define(function () { * @returns {String} */ DatagridController.prototype.getSortName = function (field) { - return this.$scope.name + '.' + field.name(); + return this.$scope.name ? this.$scope.name + '.' + field.name() : field.name(); }; DatagridController.prototype.toggleSelect = function (entry) { @@ -99,7 +102,7 @@ define(function () { this.$scope.selection = []; }; - DatagridController.$inject = ['$scope', '$location', '$stateParams', '$anchorScroll']; + DatagridController.$inject = ['$scope', '$location', '$stateParams', '$anchorScroll', '$attrs']; return DatagridController; }); diff --git a/src/javascripts/test/e2e/EditionViewSpec.js b/src/javascripts/test/e2e/EditionViewSpec.js index 67829889..fb7b1d45 100644 --- a/src/javascripts/test/e2e/EditionViewSpec.js +++ b/src/javascripts/test/e2e/EditionViewSpec.js @@ -80,6 +80,25 @@ describe('EditionView', function () { }); }) + describe('EmbeddedListField', function() { + beforeEach(function() { + browser.get(browser.baseUrl + '#/posts/edit/1'); + }); + + it('should render as a list of subforms', function () { + $$('.ng-admin-field-backlinks ng-form') + .then(function subFormsExist(subforms) { + expect(subforms.length).toBe(1); + }) + .then(function getUrlInput() { + return $$('.ng-admin-field-backlinks ng-form input#url').first(); + }) + .then(function urlInputContainsUrl(input) { + expect(input.getAttribute('value')).toBe('http://example.com/bar/baz.html'); + }); + }); + }) + describe('DetailLink', function() { beforeEach(function() { browser.get(browser.baseUrl + '#/posts/edit/1'); diff --git a/src/javascripts/test/e2e/ShowViewSpec.js b/src/javascripts/test/e2e/ShowViewSpec.js index 0b16dd54..f7620ac1 100644 --- a/src/javascripts/test/e2e/ShowViewSpec.js +++ b/src/javascripts/test/e2e/ShowViewSpec.js @@ -25,12 +25,34 @@ describe('ShowView', function () { describe('ReferencedListField', function() { it('should render as a datagrid', function () { - $$('.ng-admin-field-comments th').then(function (inputs) { + $$('.ng-admin-field-comments th') + .then(function (inputs) { expect(inputs.length).toBe(4); - expect(inputs[0].getAttribute('class')).toBe('ng-admin-column-id ng-admin-type-string'); expect(inputs[1].getAttribute('class')).toBe('ng-admin-column-created_at ng-admin-type-string'); expect(inputs[2].getAttribute('class')).toBe('ng-admin-column-body ng-admin-type-string'); + }) + .then(function() { + return $$('.ng-admin-field-comments tbody tr'); + }) + .then(function (lines) { + expect(lines.length).toBe(2); + }); + }); + }); + + describe('EmbeddedListField', function() { + it('should render as a datagrid', function () { + $$('.ng-admin-field-backlinks th').then(function (inputs) { + expect(inputs.length).toBe(2); + expect(inputs[0].getAttribute('class')).toBe('ng-admin-column-date ng-admin-type-datetime'); + expect(inputs[1].getAttribute('class')).toBe('ng-admin-column-url ng-admin-type-string'); + }) + .then(function() { + return $$('.ng-admin-field-backlinks tbody tr'); + }) + .then(function (lines) { + expect(lines.length).toBe(1); }); }); }); diff --git a/src/javascripts/test/unit/Crud/column/maEmbeddedListColumnSpec.js b/src/javascripts/test/unit/Crud/column/maEmbeddedListColumnSpec.js new file mode 100644 index 00000000..a692b063 --- /dev/null +++ b/src/javascripts/test/unit/Crud/column/maEmbeddedListColumnSpec.js @@ -0,0 +1,68 @@ +/*global angular,inject,describe,it,expect,beforeEach*/ +describe('directive: ma-embedded-list-column', function () { + 'use strict'; + + var directive = require('../../../../ng-admin/Crud/column/maEmbeddedListColumn'); + var Field = require('admin-config/lib/Field/Field'); + var EmbeddedListField = require('admin-config/lib/Field/EmbeddedListField'); + + angular.module('testapp_EmbeddedListColumn', []) + .directive('maEmbeddedListColumn', directive) + .service('NgAdminConfiguration', () => () => ({})); + + var $compile, + scope, + directiveUsage = ''; + + beforeEach(angular.mock.module('testapp_EmbeddedListColumn')); + + beforeEach(inject(function (_$compile_, _$rootScope_) { + $compile = _$compile_; + scope = _$rootScope_; + })); + + it('should contain a datagrid', function () { + scope.field = new EmbeddedListField() + .targetFields([new Field('num'), new Field('name')]); + scope.value = [{ num: 1, name: 'foo', dummy: 0 }, { num: 2, name: 'bar', dummy: 1 }]; + var element = $compile(directiveUsage)(scope); + scope.$digest(); + expect(element.children()[0].nodeName).toBe('MA-DATAGRID'); + const datagridScope = element.children().eq(0).scope(); + expect(datagridScope.entries.length).toBe(2); + expect(datagridScope.entries[0].values).toEqual({ num: 1, name: 'foo', dummy: 0 }); + expect(datagridScope.entries[1].values).toEqual({ num: 2, name: 'bar', dummy: 1 }); + expect(datagridScope.targetFields.length).toBe(2); + expect(datagridScope.targetFields[0].name()).toBe('num'); + expect(datagridScope.targetFields[1].name()).toBe('name'); + }); + + it('should sort the list according to the sortField', function () { + scope.field = new EmbeddedListField() + .targetFields([new Field('num'), new Field('name')]) + .sortField('num') + .sortDir('DESC'); + scope.value = [{ num: 1, name: 'foo', dummy: 0 }, { num: 2, name: 'bar', dummy: 1 }]; + var element = $compile(directiveUsage)(scope); + scope.$digest(); + const datagridScope = angular.element(element.children()[0]).scope(); + expect(datagridScope.entries.length).toBe(2); + expect(datagridScope.entries[0].values).toEqual({ num: 2, name: 'bar', dummy: 1 }); + expect(datagridScope.entries[1].values).toEqual({ num: 1, name: 'foo', dummy: 0 }); + expect(datagridScope.sortField).toEqual('num'); + expect(datagridScope.sortDir).toEqual('DESC'); + }); + + it('should filter the list according to the permanentFilters', function () { + scope.field = new EmbeddedListField() + .targetFields([new Field('num'), new Field('name')]) + .permanentFilters({ name: 'foo' }); + scope.value = [{ num: 1, name: 'foo', dummy: 0 }, { num: 2, name: 'bar', dummy: 1 }]; + var element = $compile(directiveUsage)(scope); + scope.$digest(); + const datagridScope = angular.element(element.children()[0]).scope(); + expect(datagridScope.entries.length).toBe(1); + expect(datagridScope.entries[0].values).toEqual({ num: 1, name: 'foo', dummy: 0 }); + }); + +}); diff --git a/src/javascripts/test/unit/Crud/field/maEmbeddedListFieldSpec.js b/src/javascripts/test/unit/Crud/field/maEmbeddedListFieldSpec.js new file mode 100644 index 00000000..dfea5064 --- /dev/null +++ b/src/javascripts/test/unit/Crud/field/maEmbeddedListFieldSpec.js @@ -0,0 +1,45 @@ +/*global angular,inject,describe,it,expect,beforeEach*/ +describe('directive: ma-embedded-list-field', function () { + 'use strict'; + + var directive = require('../../../../ng-admin/Crud/field/maEmbeddedListField'); + var Field = require('admin-config/lib/Field/Field'); + var EmbeddedListField = require('admin-config/lib/Field/EmbeddedListField'); + + angular.module('testapp_EmbeddedListField', []) + .directive('maEmbeddedListField', directive) + .directive('maField', function() { + return { + scope: { value: '=' }, + template: '' + }; + }); + + var $compile, + scope, + directiveUsage = ''; + + beforeEach(angular.mock.module('testapp_EmbeddedListField')); + + beforeEach(inject(function (_$compile_, _$rootScope_) { + $compile = _$compile_; + scope = _$rootScope_; + })); + + it('should contain a list of subforms with bounded inputs', function () { + scope.field = new EmbeddedListField('dummy') + .targetFields([new Field('num'), new Field('name')]); + scope.value = [{ num: 1, name: 'foo', dummy: 0 }, { num: 2, name: 'bar', dummy: 1 }]; + var element = $compile(directiveUsage)(scope); + scope.$digest(); + expect(element.find('ng-form').length).toBe(2); + expect(element.find('input').length).toBe(4); + expect(element.find('a').eq(0).text().trim()).toBe('Remove'); + expect(element.find('input').eq(0).scope().value).toBe(1); + expect(element.find('input').eq(1).scope().value).toBe('foo'); + expect(element.find('a').eq(1).text().trim()).toBe('Remove'); + expect(element.find('input').eq(2).scope().value).toBe(2); + expect(element.find('input').eq(3).scope().value).toBe('bar'); + expect(element.find('a').eq(2).text().trim()).toBe('Add new dummy'); + }); +}); diff --git a/src/javascripts/test/unit/Crud/list/DatagridControllerSpec.js b/src/javascripts/test/unit/Crud/list/DatagridControllerSpec.js index 98143f61..1b675c04 100644 --- a/src/javascripts/test/unit/Crud/list/DatagridControllerSpec.js +++ b/src/javascripts/test/unit/Crud/list/DatagridControllerSpec.js @@ -18,12 +18,13 @@ describe('controller: ma-datagrid', function () { entity: () => new Entity('my_entity'), entries: entries, selection: [], - datastore: () => { return {}; } + datastore: () => { return {}; }, + sort: () => null }, { search: () => { return {}; } - }, {}); + }, {}, {}, { $observe: () => null }); }); describe('toggleSelect', function () { diff --git a/src/sass/ng-admin.scss b/src/sass/ng-admin.scss index 5615ae0c..1f70a095 100644 --- a/src/sass/ng-admin.scss +++ b/src/sass/ng-admin.scss @@ -240,6 +240,29 @@ div.bottom-loader { width: 100%; } + .date_widget { + width: 10em; + @media (max-width: 768px) { + width: 100% + } + } + + .datetime_widget { + width: 15em; + @media (max-width: 768px) { + width: 100% + } + } + + .remove_button_container { + float: right; + @media (max-width: 992px) { + float: none; + display: block; + text-align: right; + } + } + .ta-toolbar button { font-size: 12px; padding: 5px 8px; @@ -270,6 +293,12 @@ div.bottom-loader { } } } + + .ng-admin-type-embedded_list, .ng-admin-type-referenced_list { + .table-condensed > thead > tr > th { + padding-top: 0; + } + } } .CodeMirror { @@ -277,3 +306,4 @@ div.bottom-loader { border-radius: 4px; } } +