diff --git a/.eslintrc.js b/.eslintrc.js index ab627f4..74e37b8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -14,7 +14,6 @@ module.exports = { extends: [ 'eslint:recommended', 'plugin:ember/recommended', - 'plugin:prettier/recommended', ], env: { browser: true, diff --git a/README.md b/README.md index d8b9cf9..a89e7f2 100644 --- a/README.md +++ b/README.md @@ -2,243 +2,574 @@ [![Build Status](https://travis-ci.org/mu-semtech/ember-data-table.svg?branch=master)](https://travis-ci.org/mu-semtech/ember-data-table) [![npm version](https://badge.fury.io/js/ember-data-table.svg)](https://badge.fury.io/js/ember-data-table) -Data table for Ember based on a JSONAPI compliant backend. +Data table for EmberJS -Have a look at [ember-paper-data-table](https://github.com/mu-semtech/emper-paper-data-table) to get a data table styled with [ember-paper](https://github.com/miguelcobain/ember-paper). +## Tutorials + +### Add basic Ember Data Table + +Find an adaptation of Ember Data Table for the design framework of your choice or implement a custom variant for your application. This tutorial uses `RawDataTable`. + +Generate a route for products first: -## Installation -If you're using Ember > v3.8 ```bash -ember install ember-data-table +ember g route products/index ``` -For Ember < v3.8, use version 1.x of the addon +The tutorial assumes a model exists with `label` and `price` which you can generate using: + ```bash -ember install ember-data-table@1.2.2 +ember g model product label:string price:number ``` -## Getting started -Include the `DataTableRouteMixin` in the route which model you want to show in the data table. Configure the model name. +Next you'll fetch content from the back-end using standard model hooks and query parameters. Extending from the provided Route and Controller is the shortest form. + +For the route stored in `/app/routes/products/index.js` write: ```javascript -import Ember from 'ember'; -import DataTableRouteMixin from 'ember-data-table/mixins/route'; +import DataTableRoute from 'ember-data-table/route'; -export default Ember.Route.extend(DataTableRouteMixin, { - modelName: 'blogpost' -}); +export default class ProductsIndexRoute extends DataTableRoute { + modelName = 'product'; +} ``` -Next, include the data table in your template: - -```htmlbars -{{data-table - content=model - fields="firstName lastName age created modified" - isLoading=isLoadingModel - filter=filter - sort=sort - page=page - size=size -}} -``` +For the controller stored in `/app/controllers/product/index.js` write: -Note: the filtering, sorting and pagination isn't done at the frontend. By including the `DataTableRouteMixin` in the route each change to the `filter`, `sort`, `page` and `size` params will result in a new request to the backend. The `DataTableRouteMixin` also sets an isLoadingModel flag while the route's model is being loaded. - -Have a look at [Customizing the data table](https://github.com/erikap/ember-data-table#customizing-the-data-table) to learn how you can customize the data table's header and body. - -## Data table component - -### Specification - -The following parameters can be passed to the data-table component: - -| Parameter | Required | Default | Description | -|-----------|----------|---------|-------------| -| content | x | | a list of resources to be displayed in the table | -| fields | | | names of the model fields to show as columns (seperated by whitespace) | -| isLoading | | false | shows a spinner instead of the table content if true | -| filter | | | current value of the text search | -| sort | | | field by which the data is currently sorted | -| page | | | number of the page that is currently displayed | -| size | | | number of items shown on one page | -| enableSizes | | true | flag to enable page size options dropdown | -| sizes | | [5, 10, 25, 50, 100] | array of page size options (numbers) | -| link | | | name of the route the first column will link to. The selected row will be passed as a parameter. | -| onClickRow | | | action sent when a row is clicked. Takes the clicked item as a parameter. | -| autoSearch | | true | whether filter value is updated automatically while typing (with a debounce) or user must click a search button explicitly to set the filter value. -| noDataMessage | | No data | message to be shown when there is no content | -| lineNumbers | | false | display a line number per table row (default: false). Must be true or false. | -| searchDebounceTime | | 2000 | debounce time of the search action of the data table. Must be integer. | - -By default the data table will make each column sortable. The search text box is only shown if the `filter` parameter is bound. Pagination is only shown if the pagination metadata is set on the model (see the [Ember Data Table Serializer mixin](https://github.com/mu-semtech/ember-data-table#serializer)). - -### Customizing the data table -The way the data is shown in the table can be customized by defining a `content` block instead of a `fields` parameter. - -```htmlbars -{{#data-table content=model filter=filter sort=sort page=page size=size onClickRow=(action "clickRow") as |t|}} - {{#t.content as |c|}} - {{#c.header}} - {{th-sortable field='firstName' currentSorting=sort label='First name'}} - {{th-sortable field='lastName' currentSorting=sort label='Last name'}} - Age - {{th-sortable field='created' currentSorting=sort label='Created'}} - Modified - {{/c.header}} - {{#c.body as |row|}} - {{row.firstName}} - {{row.lastName}} - {{row.age}} - {{moment-format row.created}} - {{moment-format row.modified}} - {{/c.body}} - {{/t.content}} -{{/data-table}} -``` -Have a look at the [helper components](https://github.com/mu-semtech/ember-data-table#helper-components). - -### Adding actions to the data table -The user can add actions on top of the data table by providing a `menu` block. -```htmlbars -{{#data-table content=model filter=filter sort=sort page=page size=size isLoading=isLoadingModel as |t|}} - {{#t.menu as |menu|}} - {{#menu.general}} - {{#paper-button onClick=(action "export") accent=true noInk=true}}Export{{/paper-button}} - {{#paper-button onClick=(action "print") accent=true noInk=true}}Print{{/paper-button}} - {{/menu.general}} - {{#menu.selected as |selection datatable|}} - {{#paper-button onClick=(action "delete" selection table) accent=true noInk=true}}Delete{{/paper-button}} - {{/menu.selected}} - {{/t.menu}} - {{#t.content as |c|}} - ... - {{/t.content}} -{{/data-table}} -``` -The menu block consists of a `general` and a `selected` block. The `menu.general` is shown by default. The `menu.selected` is shown when one or more rows in the data table are selected. - -When applying an action on a selection, the currently selected rows can be provided to the action by the `selection` parameter. The user must reset the selection by calling `clearSelection()` on the data table. -E.g. ```javascript -actions: - myAction(selection, datatable) { - console.log("Hi, you reached my action for selection: " + JSON.stringify(selection)); - datatable.clearSelection(); - } -``` - -## Helper components -The following components may be helpful when customizing the data table: -* [Sortable header](https://github.com/mu-semtech/ember-data-table#sortable-header) +import DataTableController from 'ember-data-table/controller'; -### Sortable header -The `th-sortable` component makes a column in the data table sortable. It displays a table header `` element including an ascending/descending sorting icon in case the table is currently sorted by the given column. +export default class ProductsIndexController extends DataTableController {} +``` -```htmlbars -{{th-sortable field='firstName' currentSorting=sort label='First name'}} +These steps are the same for any Ember Data Table flavour, the following visualizes `RawDataTable`: + +```hbs + ``` -The following parameters are passed to the `th-sortable` component: +Visiting `http://localhost:4200/products` will now show the Raw Data Table. -| Parameter | Required | Description | -|-----------|----------|-------------| -| field | x | name of the model field in the column | -| label | x | label to be shown in the column's table header | -| currentSorting | x | current sorting (field and order) of the data according to [the JSONAPI specification](http://jsonapi.org/format/#fetching-sorting) | +## How-to guides -Note: the data table will update the `currentSorting` variable, but the user needs to handle the reloading of the data. The [Ember Data Table Route mixin](https://github.com/mu-semtech/ember-data-table#route) may be of use. +### Implementing a new style -## Mixins -The following mixins may be helpful to use with the data table: -* [Serializer mixin](https://github.com/mu-semtech/ember-data-table#serializer) -* [Route mixin](https://github.com/mu-semtech/ember-data-table#route) -* [Default Query Params mixin](https://github.com/mu-semtech/ember-data-table#default-query-params) +Adapt Ember Data Table to your application or design framework, or find a suitable adaptation. Some examples are listed below. The best approach to build a new style is to copy the file from `ember-data-table/addon/components/raw-data-table.hbs` and adapt it to your needs from top to bottom. -### Serializer -Upon installation, the `DataTableSerializerMixin` is automatically included in your application serializer to add parsing of the filter, sortig and pagination meta data from the links in the [JSONAPI](http://jsonapi.org) responses. The data is stored in [Ember's model metadata](https://guides.emberjs.com/v2.9.0/models/handling-metadata/). +The file is long, yet much can be left as is. Only the HTML parts of the file need to be overwritten to suit your needs. Liberally add wrapping tags and classes and use custom input components for your design framework (e.g.: a custom input component for searching). Feel free to move things around within the same nesting (e.g.: moving pagination to the top). -To include the `DataTableSerializerMixin` in your application, add the mixin to your application serializer: -```javascript -import DS from 'ember-data'; -import DataTableSerializerMixin from 'ember-data-table/mixins/serializer'; +### Overwriting the rendering of fields -export default DS.JSONAPISerializer.extend(DataTableSerializerMixin, { +Columns of Ember Data Table can receive custom rendering. Say you will render products and you want to render the Unit Price and product availability in a custom way. -}); -``` +Assume the initial Ember Data Table looks like: -E.g. -```javascript -meta: { - count: 42 -}, -links: { -  previous: '/posts?page[number]=1&page[size]=10' -  next: '/posts?page[number]=3&page[size]=10' -} -``` -will be parsed to -```javascript -meta: { - count: 42, - pagination: { - previous: { number: 1, size: 10 }, - next: { number: 3, size: 10 } - } -} +```hbs + + ``` -### Route -The route providing data for the `data-table` component often looks similar. The model hook needs to query a list of resources of a specific model from the server. This list needs to be reloaded when the sorting, page or page size changes. The `DataTableRouteMixin` provides a default implementation of this behaviour. Just include the mixin in your route and specify the model to be queried as `modelName`. +The `@customFields` property lists which fields which receive custom rendering. Use the `:data-cell` slot to implement the rendering aspect: + +```hbs + + <:data-cell as |cell|> + {{#if (eq cell.attribute "price")}} + + + + {{else if (eq cell.attribute "available")}} + + {{#if cell.value}}Available{{else}}Out of stock{{/if}} + + {{/if}} + + +``` -```javascript -import Ember from 'ember'; -import DataTableRouteMixin from 'ember-data-table/mixins/route'; +This configuration renders `label` as usual. `price` and `available` render through the named slot. Note that the order of the columns is still the order of `@fields`. -export default Ember.Route.extend(DataTableRouteMixin, { - modelName: 'post' -}); -``` +### Overwrite the header labels -The `DataTableRouteMixin` specifies the `filter`, `page`, `sort` and `size` variables as `queryParams` of the route with the `refreshModel` flag set to `true`. As such the data is reloaded when one of the variables changes. A user can add custom options to be passed in the query to the server by defining a `mergeQueryOptions(parms)` function in the route. The function must return an object with the options to be merged. +Supply column headers by adding extra properties to the fields attribute, split by a colon. A single `_` gets replaced by a space and two underscores get replaced by a single underscore -```javascript -import Ember from 'ember'; -import DataTableRouteMixin from 'ember-data-table/mixins/route'; - -export default Ember.Route.extend(DataTableRouteMixin, { - modelName: 'post', - mergeQueryOptions(params) { - return { included: 'author' }; - } -}); +``` + ``` -Note: if the `mergeQueryOptions` returns a filter option on a specific field (e.g. `title`), the nested key needs to be provided as a string. Otherwise the `filter` param across all fields will be overwritten breaking the general search. +## Discussions -E.g. -```javascript -mergeQueryOptions(params) { - return { - included: 'author', - 'filter[title]': params.title - }; -} -``` +### Why one big template file -The `DataTableRouteMixin` also sets the `isLoadingModel` flag on the controller while the route's model is being loaded. Passing this flag to the data table's `isLoading` property will show a spinner while data is loaded. +Named slots let users overwrite things deeply nested inside Ember Data Table when using a single template. -### Default Query Params -The `DefaultQueryParams` mixin provides sensible defaults for the `page` (default: 0), `size` (default: 25) and `filter` (default: '') query parameters. The mixin can be mixed in a controller that uses the `page` and `filter` query params. +Contextual components split logical processing in intermediate steps (e.g.: `DataTable::Row`) and get unified in one template. This keeps the logic contained and allows users to overwrite only the specifics. -```javascript -import Ember from 'ember'; -import DefaultQueryParamsMixin from 'ember-data-table/mixins/default-query-params'; +The template file itself contains a repeating pattern to check if a block is given and use that, or render the default implementation for your design framework. Eg. the `:menu` named slot is defined as follows in raw-data-table.hbs: -export default Ember.Controller.extend(DefaultQueryParamsMixin, { - ... -}); +```hbs + + {{#if (has-block "menu")}} + {{yield (hash General Selected) to="menu"}} + {{else}} + ... + {{/if}} + ``` -Note: if you want the search text field to be enabled on a data table, the filter parameter may not be `undefined`. Therefore you must initialize it on an empty query string (as done by the `DefaultQueryParams` mixin). +The `dt.Menu` component contains the logic and is supplied by the enclosing scope. First check if the `:menu` named block is given and dispatch processing to that block if it's available. Otherwise use an implementation suiting for your design framework in '...'. + +The downside of this approach is a large handlebars file, but with good reason. The dustbin here lets consuming applications of stay clean. We hope Ember Data Table design implementations get used in many applications so the heavy template outweighs the clean usage. + +The default implementation will be used most often, but the end-user receives an escape hatch on every level to overwrite exactly the piece they need. The focus is placed on what diverges from the default where we use Ember Data Table. This makes maintenance and upgrades easier and lets apps better express the intended diversion. + +## Reference + +### Arguments to Ember Data Table + +These arguments should be supported by specific design implementations too. + +#### Common information from route and controller + +The passing of data from route and controller, and moving data back up. + +- `@content` :: Data to be rendered. In case this has a `meta` + property, this is used as default to derive amount of results and + back-end pagination offset. +- `@page` and `@updatePage` :: Indicates the current page number and the + function called to update the current page. +- `@size` and `@updatePageSize` :: Indicates the current page size and + the function called to update the current page size. +- `@sort` and `@updateSort` :: Returns current sorting for data table + and a function to update the sorting. +- `@filter` and `@updateFilter` :: Supplies the user filter string and + the function to call for updating that string. +- `@total` :: The total amount of results across all pages. If not set, + `@meta.count` or `@content.meta.count` is tried. +- `@isLoading` :: Truthy if the Data Table is currently loading data. + +- `@meta` :: Meta may be provided in `@content.meta` or it may be + provided in a separate property. If supplied, it may be used to + determine the back-end pagination offset from + `@meta.links.first.number` (often `0` but sometimes `1`) and + amount of results as alternative to `@total` from `@meta.count`. + + +#### Ember Data Table visualization configuration + +How to show different things in Ember Data Table + +- `@fields` :: List of fields to render with extra options. The fields are split by spaces. Splitting a field with a colon (`:`) makes the first element be the attribute and the second be the label. Use an `_` to render a space in the label. E.g.: `@fields="label:Name priceInEuros:Euro_price"`. +- `@sortableFields` :: List of fields by which the user may sort. + Fields should use the attribute names of `@fields` and are split by + spaces. By default all fields are sortable. Set to an empty list to + disable sorting. +- `@noDataMessage` :: Custom message to show when no data is available. + The `:no-data-message` block can be used as an alternative to provide + styling. +- `@enableLineNumbers` :: Set to truthy to show line numbers in the + table. +- `@links` :: Each row may contain a number of links. Different links + are split by a space in the configuration. Each link consists of one + to three parts split by a colon. The first part is the route, the + second is the label, the third is an icon to use instead of the label + if supported (screen readers should see the label still). E.g.: + `@links="products.edit:edit:pencil products.show:open:file-earmark-richtext"`. + Note that only the route is required in which case the label is + derived and no icon is shown. The link by default receives the `id` + of the item but this is configurable using the `@linksModelProperty` + attribute (see below). +- `@customHeaders` :: List of attributes for which a custom header will + be rendered through the `:data-header` named block. Each of the + attributes mentioned here won't render the default header but will + instead dispatch to the named block. Check which attribute is being + rendered in the named block to render the right label. Verify in the + implementation you override which actions are set on the columns how + to support sorting if needed. + + ```hbs + + <:data-header as |header|> + {{#if (eq header.attribute "label")}} + Here is my label + {{else if (eq header.attribute "priceInEuros")}} + Here is my price! + {{/if}} + + + ``` + +- `@customFields` :: List of attributes for which the fields will + receive a custom rendering. This will render the individual cell + values based on the `:data-cell` custom block. You may use the + attribute name to verify which attribute the custom block is rendering + for. + + ```hbs + + <:data-cell as |cell|> + {{#if (eq cell.attribute "label")}} + {{cell.value}} + {{else if (eq cell.attribute "priceInEuros")}} + €{{cell.value}},- + {{/if}} + + + ``` + +#### Ember Data Table functional configuration + +- `@autoSearch` :: If truthy, search is automatically triggered + without explicitly pressing search. If a number is provided, this is + the time in milliseconds before sending the request. If no number is + supplied a default is used. +- `@hasMenu` :: If not truthy, the component will show the supplied + menu. This allows controlling whether the menu should be shown + dynamically. The menu may contain actions which act on the current + selection. +- `@enableSelection` :: Whether items should be selectable. Items are + selectable across pages and may be acted on using the + `:selection-menu-actions` or `:selection-menu` named blocks. +- `@linksModelProperty` :: When a link is clicked the row must supply + information to the link to indicate which item was clicked. By + default the `id` property is used but another attribute may be + supplied if desired (such as `uuid` when using mu-search). An empty + string will provide the full object. +- `@attributeToSortParams` :: Function which translates an attribute to + its sort parameters. The sort parameters is currently a hash which + contains a key (default `'asc'` and `'desc'` to indicate sorting up + and down) and the corresponding sort key which should be sent out of + Ember Data Table (and used in the sort hash to the back-end). More + options than `'asc'` and `'desc'` can be provided if the back-end + understands different sorting strategies. +- `@onClickRow` :: Action to be triggered when the row is clicked. This + is an alternative for the row link but it triggers an action rather + than following a route. +- `@rowLink` :: Link to be used when users click on the full row. This + is an easier click target for users than an icon on the side. Ideally + the that target is provided too. `@onClickRow` may be provided to + call a function instead but this is less accessible. +- `@rowLinkModelProperty` :: When `@rowLink` is used, the `id` property + of the model rendered in the row will be supplied to the link. The + property may be overridden by this property. Set to `uuid` when using + mu-search for instance, or set to empty string to supply the full + model. + +#### Overriding Ember Data Table parts using named blocks + +Various named blocks are offered, check your Ember Data Table design implementation to see which part needs to be overridden. A list is provided here for reference. + +- `search` :: Overrides the full search component. Receives a search hash with properties: + - `filter` :: User's filter + - `placeholder` :: Placeholder for the text search + - `autoSearch` :: Value for autoSearch as supplied by the user + (subject to change) + - `submitForm` :: Action which can be used to submit the search form + and trigger search update + - `handleAutoInput` :: Action which can handle auto input by + debouncing and updating the search string + - `handleDirectInput` :: Action which handles the event where a user + types, gets value from `event.target.value`. + +- `menu` :: Overrides the full menu rendering. Receives three positional arguments: + - `General` :: Component with information about the General menu which + is rendered when nothing is selected. The block given to General + receives an argument which should be passed to `:general-menu`. + - `Selected` :: Component with information on handling selected items. + The block given to Selected receives `selected` which should be + passed to `:selection-menu`. + +- `general-menu` :: Implements the menu with actions which is shown when + no items are selected. Receives a hash with two items: + - `dataTable` :: The main DataTable object on which actions can be + called. + - `selectionIsEmpty` :: Whether items are currently selected or not. + +- `selection-menu` :: This menu is rendered only when items have been + selected. It's the main wrapper which contains + `:selection-menu-actions` (which you'd likely want to override + instead) as well as some visual information on the selected items. It + receives a hash with four elements: + - `selectionIsEmpty` :: Whether the selection is currently empty. + - `selectionCount` :: The amount of items which are selected at this point. + - `clearSelection` :: An action to clear the whole selection. + - `selection` :: Copy of the selected items which can be passed to other functions. + - `dataTable` :: The DataTable object. + +- `selection-menu-actions` :: Contains the actions which can be applied + to a selection. This is likely custom for each use of the Ember Data + Table (versus the template). Receives the same argument as + `:selection-menu`. + +- `content` :: This block is the full table but without search, actions + or pagination. It must render the table tag and everything in it. It + receives a hash with three elements. + - `Header` :: The Header logical component which contains information + to render the header row. Supplying a block to Header will yield + with the content for the `:header` named block. + - `Body` :: The Body logical component which contains information to + render each of the body rows. Supplying a block to Body will yield + with the content for the `:body` named block. + - `dataTable` :: The DataTable object. + +- `full-header` :: This block should render the `` with the header row + inside of it. Receives a hash with the following items: + - `enableSelection` :: Whether or not selection is enabled. + - `enableLineNumbers` :: Whether or not line numbers are enabled. + - `sort` :: Sort parameter. + - `updateSort` :: Function to update sorting. + - `hasLinks` :: Whether custom links are provided for this table (as + per the `@links` argument to DataTable). + - `customHeaders` :: Headers which should be rendered in a custom way + as an array or strings. + - `fields` :: A complex fields object containing the information about + each column to be rendered: + - `attribute` :: the attribute to be rendered + - `label` :: the label of the header + - `isSortable` :: whether this column is sortable or not + - `sortParameters` :: hash which indicates in which ways this field + can be sorted (ascending, descending, something else). See + `@attributeToSortParams`. + - `hasCustomHeader` :: whether this column has a custom header or + not (meaning we should render it through the `:data-header` + named block) + - `isCustom` :: whether the field rendering should be custom or not + (meaning data cells should be rendered through `:data-cell`). + - `dataHeadersInfo` :: information for the data headers. Supplied to + `:data-headers` named block. + - `ThSortable` :: Contextual component. When calling this component + `@field` must be supplied (to generate info for a given field when + looping over `header.fields`) and `@hasCustomBlock` which should + indicate whether a `:data-header` is given. Supplying a block to + ThSortable will yield with the content for the `:data-header` + named block. The aforementioned content also has a + `renderCustomBlock` which can be used to detect whether a custom + block should be rendered for this block or not. +- `data-headers` :: This is inside the `` of the `` and + should render all headers for the attributes. Thus ignoring the + headers for selection, numbers and actions. It receives a hash + containing the following elements: + - `fields` :: The fields to be rendered (see `fields` above for all + the attributes). + - `customHeaders` :: Headers which should be rendered in a custom way + as an array or strings. + - `sort` :: Sort parameter. + - `updateSort` :: Function to update sorting. +- `data-header` :: Renders a custom header which should handle sorting + etc. Receives a hash with the following elements: + - `label` :: Label of the header. + - `attribute` :: Attribute which will be rendered in this column. + - `isSortable` :: Whether this column is sortable or not. + - `isSorted` :: Whether sorting is applied to this header or not. + - `toggleSort` :: Action which switches to the next sorting method + (e.g.: from `'asc'` to `'desc'` or from `'desc'` to nothing). + - `nextSort` :: Next way of sorting. This is clear for + `["asc","desc",""]` but users may have provided other sorting + methods through `@attributeToSortParams`. + - `isAscending` :: Are we sorting ascending now? + - `isDescending` :: Are we sorting descending now? + - `sortDirection` :: What's the key on which we're sorting now (e.g.: `"desc"`) + - `renderCustomBlock` :: Should a custom block be rendered for this data header? + - `isCustom` :: Is the header explicitly marked to render custom? + - `hasCustomHeaders` :: Are there any custom headers to be rendered? + +- `actions-header` :: Header which will contain all actions. Receives no arguments. + +- `body` :: Renders the full body of the table, including the `` + tag. Receives a hash containing: + - `isLoading` :: Is the data being loaded at this point? Probably + need to render `:body-loading` named block then. + - `content` :: The actual content of this Data Table. + - `offset` :: The index of the first element in this data table. + - `wrappedItems` :: Rows of the data table in a way through which they + can be selected. + - `enableLineNumbers` :: Whether line numbers are enabled or not. + - `hasClickRowAction` :: Whether something needs to happen when the row + is clicked. Either because there is an `@onClickRow` or because + there is a `@rowLink`. + - `onClickRow` :: Action to be called when user clicked on a row, if + supplied by user of this Data Table. + - `toggleSelected` :: Action which allows to toggle the selection + state of the current row. Should receive the an element from + `wrappedItems` as first element and the event that caused it (will + check `event.target.fetched`) as second argument. + - `selection` :: Currently selected items. + - `enableSelection` :: Whether selection of items is enabled. + - `linkedRoutes` :: Array of objects describing each of the routes + which should be linked as custom links per row. Each item is a hash + with the following elements: + - `route` :: The route to which we should link. + - `label` :: The human-readable label for the route if supplied. + - `icon` :: The icon which should be rendered for the link, if supplied. + - `linksModelProperty` :: The property of the model which should be + supplied to the route (e.g.: `id` for the id or `""` if the whole + object should be supplied). + - `rowLink` :: The route which should be used when users click on the + row itself. + - `rowLinkModelProperty` :: The property of the model which should be + supplied to the `rowLink` route (e.g.: `id` for the id or `""` if + the whole object should be supplied). + - `noDataMessage` :: String message which the user asked to render + when no data was supplied. + - `fields` :: Array of objects describing each of the fields to be + rendered. See `fields` higher up. + - `Row` :: Contextual component handling the logic of an individual + row. This has to be called for each row in the visible table and it + should receive `@wrapper` for the element of `wrappedItems` we're + rendering here, as well as the `@index` for the index we're looping + over here. The `@index` is a local index for this rendering + regardless of the page, so you can use `{{#each body.wrappedItems as + |wrapper index|}}...{{/each}}`. +- `body-loading` :: Renders a custom body loading message supplied in + this invocation of Ember Data Table. +- `row` :: Renders an individual row, including the `` tag. This is + the row with both the data elements as well as with the meta elements + such as selection of items and links. Receives a hash with the + following elements: + - `wrapper` :: An object containing the item and the selection status. + - `item` :: Actual item to be rendered in this row. + - `enableLineNumbers` :: See above. + - `lineNumber` :: See above. + - `enableSelection` :: See above. + - `selected` :: Whether this row is selected or not. + - `isSelected` :: Whether this item is selected or not (same as + selected). + - `toggleSelected` :: See above. + - `hasClickRowAction` :: See above. + - `onClickRow` :: See above. + - `linkedRoutes` :: A copy of `linkedRoutes` as mentioned above but + adding the `model` key which contains the specific model to supply + to the linked route for this row (e.g.: the `id`, `uuid` or the full + `item`) + - `fields` :: See above. + - `DataCells` :: Contextual component which provides information for + rendering the data cells of a row. Supplying a block to DataCells + will yield a block which is used for rendering the `:dataCells` named + block. +- `data-cells` :: Renders all the cells containing real data in a row. + This includes selection of the row and links. Receives a hash with + the following elements: + - `fields` :: See above. + - `firstColumn` :: The field of the first column to be rendered. Good + for designs where the first column should receive different styling. + - `otherColumns` :: The fields of all columns but the first one to be + rendered. Good for designs where the first column should receive + different styling. + - `wrapper` :: See above. + - `item` :: See above. + - `rowLink` :: See above. + - `rowLinkModel` :: Model to supply to the route specified by `rowLink` for this specific row. # =@wrapper.rowLinkModel + - `fields` :: See above. + - `DataCell` :: Contextual component which provides information for + rendering an individual cell. Should receive `@column` with the + field to render and `@hasCustomBlock` with `{{has-block + "data-cell"}}` so we know whether a custom block was provided for + the `data-cell` named slot. +- `data-cell` :: Renders a custom data cell regardless of whether it's + first or any other. Receives a hash with the following elements: + - `firstColumn` :: See above. + - `otherColumns` :: See above. + - `item` :: See above. + - `rowLink` :: See above. + - `rowLinkModel` :: See above. + - `label` :: See above. + - `fields` :: See above. + - `isCustom` :: Is the cell explicitly marked to render custom? + - `hasCustomFields` :: Whether there are custom fields to be + rendered. + - `attribute` :: The attribute which will be rendered. + - `renderCustomBlock` :: Whether a custom block should be rendered + for this field. This is the named slot `:data-cell`. + - `value` :: The value which should be rendered. +- `first-data-cell` :: In designs which care about the first data cell + versus the others, this will render a custom design for the first data + column of the table. Receives the same arguments as `data-cell`. +- `rest-data-cell` :: In designs which care about the first data cell + versus the others, this will render a custom design for the other data + columns of the table. Receives the same arguments as `data-cell`. +- `actions` :: Renders the links next to each row specified through + `@links`. Receives the same arguments as `row`. +- `no-data-message` :: Rendered when no data was available in the data + cell. When no styling is needed, `@noDataMessage` can be used + instead. +- `pagination` :: Renders everything needed to handle pagination. + Receives a hash with the following elements: + - `startItem` :: Number of the first item rendered on this page. + - `endItem` :: Number of the last item rendered on this page. + - `total` :: Total amount of items on all pages of this table. + - `hasTotal` :: Whether the total amount of items is known. + - `pageSize` :: Amount of items per page (though the last page may have fewer items). + - `pageNumber` :: The page number as seen by a human (first page is 1 + regardless of the back-end using 0 for the first page or not). + - `numberOfPages` :: Total number of pages available. + - `pageOptions` :: Array containing a number for each page available + in the data table in human form (can be used for rendering buttons). + - `summarizedPageOptions` :: A smart way of showing pages. Yields a list of page numbers with: + - the leftmost being the first page number, + - followed by the string 'more' if empty spots follow, + - followed by up to three pages less than the current page, + - followed by the current page number, + - followed by up to three pages after the current page number, + - followed by 'more' if empty spots follow, + - followed by the last page number. + - `sizeOptions` :: The different sizes (as an array) for pages of this Data Table. + - `firstPage` :: The first page number in this Data Table. + - `lastPage` :: The last page number in this Data Table. + - `nextPage` :: The next page number in this view, `undefined` if this + is the last page. + - `previousPage` :: The previous page number in this view, `undefined` + if this is the first page. + - `updatePage` :: Function which takes a back-end page number and + updates it (this is the raw function supplied to `DataTable`. + - `humanPage` :: Thu current page in human form. + - `updateHumanPage` :: Updates the human page number (this will call + `updatePage` after mapping the human page number through the back-end + page number offset). + - `selectSizeOption` :: Selects a new size option, takes `event` as + input and gets the new value from `event.target.value`. + - `setSizeOption` :: Selects a new size, takes the `size` as either + string or as number and calls the `@updateSize` function supplied to + Data Table. + - `hasMultiplePages` :: Whether this Data Table has multiple pages or + not. + - `isFirstPage` :: Whether we're now rendering the first page or + not. + - `isLastPage` :: Whether we're rendering the last page or not. + - `hasPreviousPage` :: Whether there is a previous page or not. + - `hasNextPage` :: Whether there is a next page or not. + - `meta` :: If meta is available, it will be stored here. This may + contain page links. + - `backendPageOffset` :: The current back-end page offset (either + calculated or guessed). diff --git a/addon/components/data-table-content-body.js b/addon/components/data-table-content-body.js deleted file mode 100644 index f143fac..0000000 --- a/addon/components/data-table-content-body.js +++ /dev/null @@ -1,47 +0,0 @@ -import { set } from '@ember/object'; -import { computed } from '@ember/object'; -import Component from '@ember/component'; -import layout from '../templates/components/data-table-content-body'; - -export default Component.extend({ - tagName: 'tbody', - init() { - this._super(...arguments); - if (!this['data-table']) this.set('data-table', {}); - if (!this['content']) this.set('content', []); - }, - layout, - offset: computed('data-table.{page,size}', function () { - var offset = 1; //to avoid having 0. row - var page = this.get('data-table.page'); - var size = this.get('data-table.size'); - if (page && size) { - offset += page * size; - } - return offset; - }), - wrappedItems: computed( - 'content', - 'content.[]', - 'data-table.selection.[]', - function () { - const selection = this.get('data-table.selection') || []; - const content = this.content || []; - return content.map(function (item) { - return { item: item, isSelected: selection.includes(item) }; - }); - } - ), - actions: { - updateSelection(selectedWrapper, event) { - set(selectedWrapper, 'isSelected', event.target.checked); - this.wrappedItems.forEach((wrapper) => { - if (wrapper.isSelected) { - this.get('data-table').addItemToSelection(wrapper.item); - } else { - this.get('data-table').removeItemFromSelection(wrapper.item); - } - }); - }, - }, -}); diff --git a/addon/components/data-table-content-header.js b/addon/components/data-table-content-header.js deleted file mode 100644 index 7fd2dae..0000000 --- a/addon/components/data-table-content-header.js +++ /dev/null @@ -1,11 +0,0 @@ -import { oneWay } from '@ember/object/computed'; -import { alias } from '@ember/object/computed'; -import Component from '@ember/component'; -import layout from '../templates/components/data-table-content-header'; - -export default Component.extend({ - layout, - tagName: 'thead', - sort: alias('data-table.sort'), - fields: oneWay('data-table.parsedFields'), -}); diff --git a/addon/components/data-table-content.js b/addon/components/data-table-content.js deleted file mode 100644 index 7236c2d..0000000 --- a/addon/components/data-table-content.js +++ /dev/null @@ -1,9 +0,0 @@ -import Component from '@ember/component'; -import { alias } from '@ember/object/computed'; -import layout from '../templates/components/data-table-content'; - -export default Component.extend({ - layout, - classNames: ['data-table-content'], - tableClass: alias('data-table.tableClass'), -}); diff --git a/addon/components/data-table-menu-general.js b/addon/components/data-table-menu-general.js deleted file mode 100644 index d843ece..0000000 --- a/addon/components/data-table-menu-general.js +++ /dev/null @@ -1,6 +0,0 @@ -import Component from '@ember/component'; -import layout from '../templates/components/data-table-menu-general'; - -export default Component.extend({ - layout, -}); diff --git a/addon/components/data-table-menu-selected.js b/addon/components/data-table-menu-selected.js deleted file mode 100644 index 5854bcb..0000000 --- a/addon/components/data-table-menu-selected.js +++ /dev/null @@ -1,17 +0,0 @@ -import { computed } from '@ember/object'; -import Component from '@ember/component'; -import layout from '../templates/components/data-table-menu-selected'; - -export default Component.extend({ - layout, - init: function () { - this._super(...arguments); - this.set('data-table.enableSelection', true); - }, - selectionCount: computed.reads('data-table.selection.length'), - actions: { - clearSelection() { - this.get('data-table').clearSelection(); - }, - }, -}); diff --git a/addon/components/data-table-menu.js b/addon/components/data-table-menu.js deleted file mode 100644 index 298f2ba..0000000 --- a/addon/components/data-table-menu.js +++ /dev/null @@ -1,7 +0,0 @@ -import Component from '@ember/component'; -import layout from '../templates/components/data-table-menu'; - -export default Component.extend({ - layout, - classNames: ['data-table-menu'], -}); diff --git a/addon/components/data-table.hbs b/addon/components/data-table.hbs new file mode 100644 index 0000000..c401c1e --- /dev/null +++ b/addon/components/data-table.hbs @@ -0,0 +1,41 @@ +{{!-- TODO: supply both meta and @content.meta or supply @content.meta only when @meta is not supplied to be in line with readme --}} + +{{yield + (hash + Search=(component "data-table/text-search" + filter=this.filter + placeholder=this.searchPlaceholder + autoSearch=this.autoSearch + updateFilter=this.updateFilter + searchDebounceTime=this.searchDebounceTime) + Content=(component "data-table/data-table-content" + content=@content + noDataMessage=this.noDataMessage + enableSelection=@enableSelection + enableLineNumbers=@enableLineNumbers + onClickRow=@onClickRow + sort=this.sort + updateSort=this.updateSort + customHeaders=this.customHeaders + fields=this.fields + links=@links + linksModelProperty=this.linksModelProperty + rowLink=@rowLink + rowLinkModelProperty=this.rowLinkModelProperty + dataTable=this) + Pagination=(component "data-table/number-pagination" + page=this.page + size=this.size + itemsOnCurrentPage=@content.length + sizeOptions=this.sizeOptions + total=@total + meta=@content.meta + updatePage=this.updatePage + updateSize=this.updatePageSize + backendPageOffset=@backendPageOffset) + Menu=(component "data-table/data-table-menu" + enableSelection=@enableSelection + dataTable=this) + content=@content + enableSearch=this.enableSearch + dataTable=this)}} diff --git a/addon/components/data-table.js b/addon/components/data-table.js index 743f4d6..056c7fa 100644 --- a/addon/components/data-table.js +++ b/addon/components/data-table.js @@ -1,64 +1,249 @@ +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import Component from '@glimmer/component'; import { typeOf } from '@ember/utils'; -import { observer } from '@ember/object'; -import { computed } from '@ember/object'; -import { oneWay } from '@ember/object/computed'; -import { bool } from '@ember/object/computed'; -import Component from '@ember/component'; -import layout from '../templates/components/data-table'; - -export default Component.extend({ - init() { - this._super(...arguments); - if (this.selection === undefined) this.set('selection', []); - }, - layout, - noDataMessage: 'No data', - isLoading: false, - lineNumbers: false, - searchDebounceTime: 2000, - enableLineNumbers: bool('lineNumbers'), - enableSelection: oneWay('hasMenu'), - selectionIsEmpty: computed.equal('selection.length', 0), - enableSizes: true, - size: 5, - sizeOptions: computed('size', 'sizes', 'enableSizes', function () { +import { toComponentSpecifications, splitDefinitions } from "../utils/string-specification-helpers"; +import attributeToSortParams from "../utils/attribute-to-sort-params"; + +export default class DataTable extends Component { + @tracked _selection = undefined; + + get filter() { + return this.args.filter !== undefined + ? this.args.filter + : this.args.view?.filter; + } + + get sort() { + return this.args.sort !== undefined + ? this.args.sort + : this.args.view?.sort; + } + + get selection() { + if (this._selection === undefined && this.args.selection === undefined) + return []; + else if (this._selection !== undefined) + return this._selection; + else + return this.args.selection; + } + + set selection(newSelection) { + this._selection = newSelection; // also trigers dependent properties + } + + get noDataMessage() { + return this.args.noDataMessage === undefined + ? 'No data' + : this.args.noDataMessage; + } + + get isLoading() { + return this.args.isLoading !== undefined + ? this.args.isLoading + : this.args.view?.isLoading; + } + + /** + * Calculates the search debounce time. + * + * If the user supplies searchDebounceTime, that is what we should + * use. A shorthand form is supported in which the user supplies a + * number to autoSearch in which case we use that. This would not + * work with 0 (which is a strange debounce time in itself) so this + * option exists for now. + */ + get searchDebounceTime() { + return this.args.searchDebounceTime === undefined + ? isNaN(this.args.autoSearch) ? 2000 : this.args.autoSearch + : this.args.searchDebounceTime; + } + + get enableSelection() { + return this.args.enableSelection; + } + + get selectionIsEmpty() { + return this.selection.length === 0; + } + + get enableSizes() { + return this.args.enableSizes === undefined ? true : this.args.enableSizes; + } + + get page() { + const page = this.args.page !== undefined + ? this.args.page + : this.args.view?.page; + return page || 0; + } + + get size() { + if ( this.args.size ) + return this.args.size; + else if ( this.args.view?.size ) + return this.args.view.size; + else + return 5; + } + + get sizeOptions() { if (!this.enableSizes) { return null; } else { - const sizeOptions = this.sizes || [5, 10, 25, 50, 100]; - if (!sizeOptions.includes(this.size)) { + const sizeOptions = this.args.sizes || [5, 10, 25, 50, 100]; + if (!sizeOptions.includes(this.size) && this.size) { sizeOptions.push(this.size); } sizeOptions.sort((a, b) => a - b); return sizeOptions; } - }), - hasMenu: false, // set from inner component, migth fail with nested if - enableSearch: computed('filter', function () { - return this.filter || this.filter === ''; - }), - autoSearch: true, - filterChanged: observer('filter', function () { - this.set('page', 0); - }), - sizeChanged: observer('size', function () { - this.set('page', 0); - }), - parsedFields: computed('fields', function () { - const fields = this.fields; + } + + get enableSearch() { + return this.filter !== undefined; + } + + get autoSearch() { + return this.args.autoSearch === undefined ? true : this.args.autoSearch; + } + + get linksModelProperty() { + return this.args.linksModelProperty === undefined + ? 'id' + : this.args.linksModelProperty; + } + + get rowLinkModelProperty() { + return this.args.rowLinkModelProperty === undefined + ? 'id' + : this.args.rowLinkModelProperty; + } + + get fieldsWithMeta() { + const fields = this.args.fields; + if (typeOf(fields) === 'string') { - return fields.split(' '); + return toComponentSpecifications(fields, [{raw: "attribute"},{name: "label", default: "attribute"}]); } else { return fields || []; } - }), + } + + attributeToSortParams(attribute) { + if( this.args.attributeToSortParams ) { + return this.args.attributeToSortParams(attribute); + } else { + return attributeToSortParams(attribute); + } + } + + get fields() { + return this + .fieldsWithMeta + .map( ({ attribute, label, isSortable, hasCustomHeader, isCustom, sortParameters }) => ({ + attribute, + label, + sortParameters: sortParameters // custom format says it's sortable + || ( ( isSortable // custom format says it's sortable + || this.sortableFields === null // default: all fields are sortable + || this.sortableFields?.includes(attribute) ) // @sortableFields + && this.attributeToSortParams(attribute) ), + get isSortable() { return Object.keys( this.sortParameters || {} ).length >= 1; }, + hasCustomHeader: hasCustomHeader + || this.customHeaders.includes(attribute), + isCustom: isCustom + || this.customFields.includes(attribute) + })); + } + + get customHeaders() { + return splitDefinitions(this.args.customHeaders); + } + + get customFields() { + return splitDefinitions(this.args.customFields); + } + + get sortableFields() { + const sortableFields = this.args.sortableFields; + if (sortableFields || sortableFields === "") + return splitDefinitions(sortableFields); + else + // default: all fields are sortable + return null; + } + + get searchPlaceholder() { + return this.args.searchPlaceholder === undefined + ? 'Search input' + : this.args.searchPlaceholder; + } + + @action + updatePageSize(size) { + const updater = this.args.updatePageSize !== undefined + ? this.args.updatePageSize + : this.args.view?.updatePageSize; + + if( !updater ) { + console.error(`Could not update page size to ${size} because @updatePageSize was not supplied to data table`); + } else { + this.updatePage(0); + updater(size); + } + } + + @action + updateFilter(filter) { + const updater = this.args.updateFilter || this.args.view?.updateFilter; + + if( !updater ) { + console.error(`Could not update filter to '${filter}' because @updateFilter was not supplied to data table`); + } else { + this.updatePage(0); + updater(filter); + } + } + + @action + updateSort(sort) { + const updater = this.args.updateSort !== undefined + ? this.args.updateSort + : this.args.view?.updateSort; + + if( !updater ) { + console.error(`Could not update sorting to '${sort}' because @updateSort was not supplied to data table`); + } else { + this.updatePage(0); + updater(sort); + } + } + + @action + updatePage(page) { + const updater = this.args.updatePage !== undefined + ? this.args.updatePage + : this.args.view?.updatePage; + + if( !updater ) { + console.error(`Could not update page to ${page} because @updatePage was not supplied to data table`); + } else { + updater(page); + } + } + + @action addItemToSelection(item) { - this.selection.addObject(item); - }, + this.selection = [...new Set([item, ...this.selection])]; + } + @action removeItemFromSelection(item) { - this.selection.removeObject(item); - }, + this.selection = this.selection.filter((x) => x !== item); + } + @action clearSelection() { - this.selection.clear(); - }, -}); + this.selection = []; + } +} diff --git a/addon/components/data-table/data-cell.hbs b/addon/components/data-table/data-cell.hbs new file mode 100644 index 0000000..877b297 --- /dev/null +++ b/addon/components/data-table/data-cell.hbs @@ -0,0 +1,14 @@ +{{!-- Used in: data-table/data-cells --}} +{{yield (hash + firstColumn=@firstColumn + otherColumns=@otherColumns + item=@wrapper.item + rowLink=@wrapper.rowLink + rowLinkModel=@wrapper.rowLinkModel + label=@column.label + fields=@fields + isCustom=this.isCustom + hasCustomFields=this.hasCustomFields + attribute=@column.attribute + renderCustomBlock=this.renderCustomBlock + value=(get @wrapper.item @column.attribute))}} \ No newline at end of file diff --git a/addon/components/data-table/data-cell.js b/addon/components/data-table/data-cell.js new file mode 100644 index 0000000..136c5da --- /dev/null +++ b/addon/components/data-table/data-cell.js @@ -0,0 +1,15 @@ +import Component from '@glimmer/component'; + +export default class DataTableDataCellComponent extends Component { + get isCustom() { + return this.args.column.isCustom; + } + + get hasCustomFields() { + return this.args.fields.find( ({isCustom}) => isCustom) || false; + } + + get renderCustomBlock() { + return this.args.hasCustomBlock && ( this.isCustom || !this.hasCustomFields ); + } +} diff --git a/addon/components/data-table/data-cells.hbs b/addon/components/data-table/data-cells.hbs new file mode 100644 index 0000000..a0bfebb --- /dev/null +++ b/addon/components/data-table/data-cells.hbs @@ -0,0 +1,16 @@ +{{!-- Used in: data-table/row --}} +{{yield (hash + fields=@dataTable.fields + firstColumn=this.firstColumn + otherColumns=this.otherColumns + wrapper=@wrapper + item=@wrapper.item + rowLink=@wrapper.rowLink + rowLinkModel=@wrapper.rowLinkModel + fields=@fields + DataCell=(component + "data-table/data-cell" + firstColumn=this.firstColumn + otherColumns=this.otherColumns + wrapper=@wrapper + fields=@fields))}} diff --git a/addon/components/data-table/data-cells.js b/addon/components/data-table/data-cells.js new file mode 100644 index 0000000..3b607d1 --- /dev/null +++ b/addon/components/data-table/data-cells.js @@ -0,0 +1,16 @@ +import Component from '@glimmer/component'; + +export default class DataTableDataCellsComponent extends Component { + get firstColumn() { + return this.args.fields?.[0] || null; + } + + get otherColumns() { + if (this.args.fields?.length) { + let [, ...fields] = this.args.fields; + return fields; + } else { + return []; + } + } +} diff --git a/addon/components/data-table/data-table-content-body.hbs b/addon/components/data-table/data-table-content-body.hbs new file mode 100644 index 0000000..5891161 --- /dev/null +++ b/addon/components/data-table/data-table-content-body.hbs @@ -0,0 +1,28 @@ +{{!-- Used in: data-table/data-table-content --}} +{{yield (hash + isLoading=@dataTable.isLoading + content=@content + offset=this.offset + wrappedItems=this.wrappedItems + enableLineNumbers=@enableLineNumbers + hasClickRowAction=(and (or @onClickRow @rowLink) true) + onClickRow=@onClickRow + toggleSelected=this.updateSelection + selection=@dataTable.selection + enableSelection=@enableSelection + linkedRoutes=@linkedRoutes + rowLink=@rowLink + rowLinkModelProperty=@rowLinkModelProperty + noDataMessage=@noDataMessage + fields=@fields + Row=(component "data-table/row" + dataTable=@dataTable + enableLineNumbers=@enableLineNumbers + enableSelection=@enableSelection + selection=@dataTable.selection + offset=this.offset + hasClickRowAction=(and (or @onClickRow @rowLink) true) + onClickRow=this.onClickRow + linkedRoutes=@linkedRoutes + fields=@fields + toggleSelected=this.updateSelection))}} diff --git a/addon/components/data-table/data-table-content-body.js b/addon/components/data-table/data-table-content-body.js new file mode 100644 index 0000000..3b27459 --- /dev/null +++ b/addon/components/data-table/data-table-content-body.js @@ -0,0 +1,56 @@ +import { cached } from '@glimmer/tracking'; +import { get } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import Component from '@glimmer/component'; + +export default class DataTableContentBodyComponent extends Component { + @service router; + + get offset() { + var offset = 1; //to avoid having 0. row + var page = this.args.dataTable.page; // TODO: pass on page directly? + var size = this.args.dataTable.size; // TODO: pass on size directly? + if (page && size) { + offset += page * size; + } + return offset; + } + + @cached + get wrappedItems() { + const selection = this.args.dataTable.selection || []; // TODO: should the dataTable ensure this is an array? + const content = this.args.content; + return content.map((item) => { + return { + item: item, + isSelected: selection.includes(item), + rowLink: this.args.rowLink, + rowLinkModel: this.rowLinkModel(item) + }; + }); + } + + rowLinkModel(row) { + return this.args.rowLinkModelProperty + ? get(row, this.args.rowLinkModelProperty) + : row; + } + + @action + updateSelection(selectedWrapper, event) { + if( event.target.checked ) + this.args.dataTable.addItemToSelection(selectedWrapper.item); + else + this.args.dataTable.removeItemFromSelection(selectedWrapper.item); + } + + @action + onClickRow(row) { + if ( this.args.onClickRow ) { + this.args.onClickRow(...arguments); + } else if ( this.args.rowLink ) { + this.router.transitionTo( this.args.rowLink, this.rowLinkModel(row) ); + } + } +} diff --git a/addon/components/data-table/data-table-content-header.hbs b/addon/components/data-table/data-table-content-header.hbs new file mode 100644 index 0000000..d634aab --- /dev/null +++ b/addon/components/data-table/data-table-content-header.hbs @@ -0,0 +1,19 @@ +{{!-- Used in: data-table/data-table-content --}} +{{yield (hash + enableSelection=@enableSelection + enableLineNumbers=@enableLineNumbers + sort=@sort + updateSort=@updateSort + hasLinks=@hasLinks + customHeaders=@customHeaders + fields=@fields + dataHeadersInfo=(hash + fields=@fields + customHeaders=@customHeaders + sort=@sort + updateSort=@updateSort) + ThSortable=(component + "data-table/th-sortable" + fields=@fields + sort=@sort + updateSort=@updateSort))}} diff --git a/addon/components/data-table/data-table-content-header.js b/addon/components/data-table/data-table-content-header.js new file mode 100644 index 0000000..e84986c --- /dev/null +++ b/addon/components/data-table/data-table-content-header.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class DataTableContentHeaderComponent extends Component {} diff --git a/addon/components/data-table/data-table-content.hbs b/addon/components/data-table/data-table-content.hbs new file mode 100644 index 0000000..38570f9 --- /dev/null +++ b/addon/components/data-table/data-table-content.hbs @@ -0,0 +1,24 @@ +{{!-- Used in: data-table.hbs --}} +{{yield + (hash + Header=(component "data-table/data-table-content-header" + enableSelection=@enableSelection + enableLineNumbers=@enableLineNumbers + sort=@sort + updateSort=@updateSort + hasLinks=this.hasLinks + customHeaders=@customHeaders + dataTable=@dataTable + fields=@fields) + Body=(component "data-table/data-table-content-body" + content=@content + enableSelection=@enableSelection + enableLineNumbers=@enableLineNumbers + noDataMessage=@noDataMessage + onClickRow=@onClickRow + linkedRoutes=this.linkedRoutes + rowLink=@rowLink + rowLinkModelProperty=@rowLinkModelProperty + dataTable=@dataTable + fields=@fields) + dataTable=@dataTable)}} diff --git a/addon/components/data-table/data-table-content.js b/addon/components/data-table/data-table-content.js new file mode 100644 index 0000000..b197317 --- /dev/null +++ b/addon/components/data-table/data-table-content.js @@ -0,0 +1,32 @@ +import Component from '@glimmer/component'; +import { toComponentSpecifications } from '../../utils/string-specification-helpers'; + +export default class DataTableContentComponent extends Component { + get hasLinks() { + return this.linkedRoutes.length > 0; + } + + /** + * Accepts and transforms definitions for linked routes. + * + * Implementations may transform this at will. The default + * transformation splits on `:` assuming the first part is the route + * and the second part is the label. If no label is given, it is + * passed as null. If a label is given, all underscores are + * transformed to spaces and double underscores are left as a single + * _. We split again on a third `:`, transforming in the same way for + * the suggested icon. + * + * Behaviour for `___` is undefined. + * + * Yields an array of objects to represent the linked routes. + * [ { route: "products.show", label: "Show product", icon: "show-icon" } ] + */ + get linkedRoutes() { + return toComponentSpecifications(this.args.links || "", [{ raw: "route" }, "label", "icon"]) + .map( (spec) => { + spec.linksModelProperty = this.args.linksModelProperty; + return spec; + } ); + } +} diff --git a/addon/components/data-table/data-table-menu-general.hbs b/addon/components/data-table/data-table-menu-general.hbs new file mode 100644 index 0000000..aa4bd58 --- /dev/null +++ b/addon/components/data-table/data-table-menu-general.hbs @@ -0,0 +1,5 @@ +{{!-- Used in: data-table/data-table-menu --}} +{{yield (hash + dataTable=@dataTable + selectionIsEmpty=@dataTable.selectionIsEmpty) +}} diff --git a/addon/components/data-table/data-table-menu-general.js b/addon/components/data-table/data-table-menu-general.js new file mode 100644 index 0000000..c985733 --- /dev/null +++ b/addon/components/data-table/data-table-menu-general.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class DataTableMenuGeneralComponent extends Component {} diff --git a/addon/components/data-table/data-table-menu-selected.hbs b/addon/components/data-table/data-table-menu-selected.hbs new file mode 100644 index 0000000..f232183 --- /dev/null +++ b/addon/components/data-table/data-table-menu-selected.hbs @@ -0,0 +1,9 @@ +{{!-- Used in: data-table/data-table-menu --}} +{{yield (hash + selectionIsEmpty=@dataTable.selectionIsEmpty + selectionCount=@dataTable.selection.length + clearSelection=@dataTable.clearSelection + selection=this.copiedSelection + dataTable=@dataTable)}} + +{{!-- TODO: must we pass the data table itself? It is shared with the consumers. --}} \ No newline at end of file diff --git a/addon/components/data-table/data-table-menu-selected.js b/addon/components/data-table/data-table-menu-selected.js new file mode 100644 index 0000000..29c343a --- /dev/null +++ b/addon/components/data-table/data-table-menu-selected.js @@ -0,0 +1,12 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; + +export default class DataTableMenuSelectedComponent extends Component { + get selectionCount() { + return this.args.dataTable.selection.length; + } + + get copiedSelection() { + return [...this.args.dataTable.selection]; + } +} diff --git a/addon/components/data-table/data-table-menu.hbs b/addon/components/data-table/data-table-menu.hbs new file mode 100644 index 0000000..635a3ff --- /dev/null +++ b/addon/components/data-table/data-table-menu.hbs @@ -0,0 +1,7 @@ +{{!-- Used in: data-table.hbs --}} +{{#let + (component "data-table/data-table-menu-general" dataTable=@dataTable) + (component "data-table/data-table-menu-selected" dataTable=@dataTable) + as |general selected|}} + {{yield general selected @dataTable.enableSelection}} +{{/let}} \ No newline at end of file diff --git a/addon/components/data-table/data-table-menu.js b/addon/components/data-table/data-table-menu.js new file mode 100644 index 0000000..9d407b4 --- /dev/null +++ b/addon/components/data-table/data-table-menu.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class DataTableMenuComponent extends Component {} diff --git a/addon/components/data-table/number-pagination.hbs b/addon/components/data-table/number-pagination.hbs new file mode 100644 index 0000000..769b7e3 --- /dev/null +++ b/addon/components/data-table/number-pagination.hbs @@ -0,0 +1,28 @@ +{{!-- Used in: data-table.hbs --}} +{{yield (hash + startItem=this.startItem + endItem=this.endItem + total=this.total + hasTotal=this.hasTotal + pageSize=@size + pageNumber=this.humanPage + numberOfPages=this.numberOfPages + pageOptions=this.pageOptions + summarizedPageOptions=this.summarizedPageOptions + sizeOptions=@sizeOptions + firstPage=this.firstPage + lastPage=this.lastPage + nextPage=this.nextPage + previousPage=this.previousPage + updatePage=this.updatePage + humanPage=this.humanPage + updateHumanPage=this.updateHumanPage + selectSizeOption=this.selectSizeOption + setSizeOption=this.setSizeOption + hasMultiplePages=this.hasMultiplePages + isFirstPage=this.isFirstPage + isLastPage=this.isLastPage + hasPreviousPage=this.hasPreviousPage + hasNextPage=this.hasNextPage + meta=@meta + backendPageOffset=this.backendPageOffset)}} \ No newline at end of file diff --git a/addon/components/data-table/number-pagination.js b/addon/components/data-table/number-pagination.js new file mode 100644 index 0000000..61516b9 --- /dev/null +++ b/addon/components/data-table/number-pagination.js @@ -0,0 +1,240 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; + +const humanPageOffset = 1; // humans assume the first page has number 1 + +/** + * Converts from the human based page number (eg: first = 1) to the + * backend-based offset. + */ +function humanToBackend(number, backendPageOffset) { + return number - humanPageOffset + backendPageOffset; +} + +/** + * Converts from a backend page number to (eg: often first = 0) to the + * human-based offset (eg: first = 1) + */ +function backendToHuman(number, backendPageOffset) { + return number + humanPageOffset - backendPageOffset; +} + +/** + * Converts a human-based number to a zero-based number. + */ +function humanToZeroBased(number) { + return number - humanPageOffset; +} + +/** + * Converts a zero-based number to a human-based number. + */ +function zeroToHumanBased(number) { + return number + humanPageOffset; +} + +/** + * Helpers for pagination buttons. + * + * This component does not assume a backend offset. If the backend's + * first page has a number, we will assume that is the offset for the + * first page. This component then uses the numbers as a user would see + * them, as that's likely the easiest to construct the template. + * + * The inputs to this component from its wrapping component are what the + * backend understands in terms of page numbers, the outputs to the + * yielded block are what humans would understand. + */ +export default class NumberPaginationComponent extends Component { + get currentBackendPage() { + return this.args.page + ? parseInt(this.args.page) + : this.backendPageOffset; + } + + /** + * Yields 0 for 0-based offset from the backend, and 1 for 1-based + * offset. Also works for n-based offset but no one does that, we + * hope. + */ + get backendPageOffset() { + if( this.args.backendPageOffset !== undefined ) { + // users may supply this + return this.args.backendPageOffset; + } else if( this.args.meta?.links?.first?.number !== undefined ) { + // or we could derive from the backend + return this.args.meta.links.first.number; + } else { + // and else it's just 0 + return 0; + } + } + + /** + * The human page is as the user would view the page, what we supply + * as functions and parameters outside of this component is based on + * what the API supplies. + */ + get humanPage() { + return backendToHuman(this.args.page || this.backendPageOffset, this.backendPageOffset); + } + set humanPage(number) { + this.updatePage(humanToBackend(number || 0, this.backendPageOffset)); + } + @action + updateHumanPage(number) { + this.humanPage = number; + } + + get firstPage() { + return humanPageOffset; + } + + get lastPage() { + return Math.ceil( (0.0 + this.total) / this.args.size); + } + + get previousPage() { + return this.isFirstPage + ? undefined + : this.humanPage - 1; + } + + get nextPage() { + return this.isLastPage + ? undefined + : this.humanPage + 1; + } + + get isFirstPage() { + return this.humanPage == this.firstPage; + } + + get isLastPage() { + return this.humanPage == this.lastPage; + } + + get hasPreviousPage() { + return this.humanPage > this.firstPage; + } + + get hasNextPage() { + return this.humanPage < this.lastPage; + } + + get hasMultiplePages() { + return this.lastPage > this.firstPage; + } + + get startItem() { + // note, you might want to use this.args.page instead, but given + // that comes from the backend, it's *not* guaranteed to be + // zero-based either. + if( this.args.itemsOnCurrentPage == 0 && this.isFirstPage ) + // human probably expects to see 0-0 when no items exist. + return 0; + else + return zeroToHumanBased(this.args.size * humanToZeroBased( this.humanPage )); + } + + get endItem() { + // this one is exactly the same number as humanPageOffset yet it has + // a different meaning. When summing up lists, it's effectively + // removing one regardless of the offset. + if( this.args.itemsOnCurrentPage == 0 && this.isFirstPage ) + // human probably expects to see 0-0 when no items exist. + return 0; + else + return this.startItem - 1 + this.args.itemsOnCurrentPage; + } + + get numberOfPages() { + return this.lastPage - this.firstPage + 1; + } + + /** + * Supplies an array with all available pages. + */ + get pageOptions() { + return Array.from( + new Array(this.numberOfPages), + (_val, index) => this.firstPage + index + ); + } + + /** + * Page selectors to show + * Examples: (~x~ indicates current page, more indicates ellipsis) + * [~1~, 2, 3, more, 8, 9, 10] + * [1, 2, 3, more, 8, 9, ~10~] + * [1, more, 5, 6, ~7~, 8, 9, 10] + * [1, more, 3, 4, ~5~, 6, 7, more, 10] + * [1, more, 7, 8, ~9~, 10] + */ + get summarizedPageOptions() { + const more = 'more'; + + if (this.numberOfPages > 0) { + if (this.isFirstPage || this.isLastPage) { + const x = this.firstPage; + const leftWindow = [x, x + 1, x + 2].filter((i) => i <= this.lastPage); + const y = this.lastPage; + const rightWindow = [y - 2, y - 1, y].filter((i) => i >= this.firstPage); + const pages = [...new Set([...leftWindow, ...rightWindow])].sort((a, b) => a - b); + if (pages.length == 6 && pages[2] < pages[3] - 1) { + return [...leftWindow, more, ...rightWindow]; + } else { + return pages; + } + } else { + const x = this.humanPage; + const currentPageWindow = [x - 2, x - 1, x, x + 1, x + 2].filter( + (i) => i >= this.firstPage && i <= this.lastPage + ); + let prepend = []; + let append = []; + if (currentPageWindow.length) { + const first = currentPageWindow[0]; + if (first > this.firstPage) { + prepend = first == this.firstPage + 1 ? [this.firstPage] : [this.firstPage, more]; + } + const last = currentPageWindow[currentPageWindow.length - 1]; + if (last < this.lastPage) { + append = last == this.lastPage - 1 ? [this.lastPage] : [more, this.lastPage]; + } + } + return [...prepend, ...currentPageWindow, ...append]; + } + } else { + return [this.firstPage]; + } + } + + get total() { + if( this.args.total !== undefined ) + return this.args.total; + else if( this.args.meta?.count !== undefined ) + return this.args.meta.count; + else + return undefined; + } + + get hasTotal() { + return this.total || this.total === 0; + } + + @action + updatePage(number) { + this.args.updatePage(number || this.backendPageOffset); + } + + @action + selectSizeOption(event) { + this.args.updateSize(parseInt(event.target.value)); + } + + @action + setSizeOption(size) { + this.args.updateSize(parseInt(size)); + } +} diff --git a/addon/components/data-table/row.hbs b/addon/components/data-table/row.hbs new file mode 100644 index 0000000..60c2bc8 --- /dev/null +++ b/addon/components/data-table/row.hbs @@ -0,0 +1,21 @@ +{{!-- Used in: data-table/data-table-content-body --}} +{{!-- TODO: do we want both selected and isSelected? --}} +{{yield (hash + wrapper=@wrapper + item=@wrapper.item + enableLineNumbers=@enableLineNumbers + lineNumber=(add @index @offset) + enableSelection=@enableSelection + hasClickRowAction=@hasClickRowAction + onClickRow=(fn @onClickRow @wrapper.item) + isSelected=(includes @wrapper.item @selection) + selected=(includes @wrapper.item @selection) + toggleSelected=(fn @toggleSelected @wrapper) + linkedRoutes=this.linkedRoutes + fields=@fields + DataCells=(component + "data-table/data-cells" + fields=@fields + wrapper=@wrapper + linkedRoutes=this.linkedRoutes + dataTable=@dataTable))}} \ No newline at end of file diff --git a/addon/components/data-table/row.js b/addon/components/data-table/row.js new file mode 100644 index 0000000..6428da7 --- /dev/null +++ b/addon/components/data-table/row.js @@ -0,0 +1,15 @@ +import { get } from '@ember/object'; +import Component from '@glimmer/component'; + +export default class DataTableRowComponent extends Component { + get linkedRoutes() { + return this.args.linkedRoutes.map( (linkedRoute) => { + const model = this.args.wrapper.item; + return Object.assign( { + model: linkedRoute.linksModelProperty + ? get(model, linkedRoute.linksModelProperty) + : model + }, linkedRoute ); + } ); + } +} diff --git a/addon/components/data-table/text-search.hbs b/addon/components/data-table/text-search.hbs new file mode 100644 index 0000000..a204a12 --- /dev/null +++ b/addon/components/data-table/text-search.hbs @@ -0,0 +1,8 @@ +{{!-- Used in data-table.hbs --}} +{{yield (hash + filter=@filter + placeholder=@placeholder + autoSearch=@autoSearch + submitForm=this.submitForm + handleAutoInput=this.handleAutoInput + handleDirectInput=this.handleDirectInput)}} diff --git a/addon/components/data-table/text-search.js b/addon/components/data-table/text-search.js new file mode 100644 index 0000000..8000fa0 --- /dev/null +++ b/addon/components/data-table/text-search.js @@ -0,0 +1,38 @@ +import { action } from '@ember/object'; +import { cancel, debounce } from '@ember/runloop'; +import Component from '@glimmer/component'; + +export default class TextSearchComponent extends Component { + enteredValue = undefined; + + autoDebouncePid = undefined; + + @action + handleAutoInput(event) { + this.enteredValue = event.target.value; + this.autoDebouncePid = debounce(this, this.submitCurrent, this.args.searchDebounceTime); + } + + submitCurrent() { + if (!this.isDestroying && !this.isDestroyed) { + this.args.updateFilter(this.enteredValue); + this.autoDebouncePid = undefined; + } + } + + willDestroy() { + super.willDestroy(...arguments); + cancel(this.autoDebouncePid); + } + + @action + handleDirectInput(event) { + this.enteredValue = event.target.value; + } + + @action + submitForm(event) { + event.preventDefault(); + this.submitCurrent(); + } +} diff --git a/addon/components/data-table/th-sortable.hbs b/addon/components/data-table/th-sortable.hbs new file mode 100644 index 0000000..15792f2 --- /dev/null +++ b/addon/components/data-table/th-sortable.hbs @@ -0,0 +1,17 @@ +{{!-- Used in: data-table/data-table-content-header --}} +{{yield (hash + label=@field.label + attribute=@field.attribute + + isSortable=@field.isSortable + isSorted=this.isSorted + toggleSort=this.toggleSort + nextSort=this.nextSort + + isAscending=this.isAscending + isDescending=this.isDescending + sortDirection=this.sortDirection + + renderCustomBlock=this.renderCustomBlock + isCustom=this.isCustom + hasCustom=this.hasCustom)}} \ No newline at end of file diff --git a/addon/components/data-table/th-sortable.js b/addon/components/data-table/th-sortable.js new file mode 100644 index 0000000..abc00a3 --- /dev/null +++ b/addon/components/data-table/th-sortable.js @@ -0,0 +1,70 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; + +export default class ThSortableComponent extends Component { + get sortParameters() { + return this.args.field.sortParameters; + } + + get sortDirection() { + for ( const key in this.sortParameters ) + if( this.args.sort == this.sortParameters[key] ) + return key; + + return ''; + } + + get isAscending() { + return this.sortDirection === "asc"; + } + + get isDescending() { + return this.sortDirection === "desc"; + } + + get isSorted() { + return this.sortDirection !== ''; + } + + get renderCustomBlock() { + // render the custom block when this header is custom or when a + // custom block was given and no specific headers were supplied to + // be custom. + // + // Note: data table can't make this decision because it doesn't know + // whether a custom block was supplied. + return this.args.hasCustomBlock && (this.isCustom || !this.hasCustomHeaders); + } + + get isCustom() { + return this.args.field.hasCustomHeader; + } + + get hasCustomHeaders() { + return this.args.fields.find(({hasCustomHeader}) => hasCustomHeader) || false; + } + + get availableSortOptions() { + const options = []; + Object + .keys( this.sortParameters ) + .sort() // for asc and desc, asc first then desc, the rest also sorted for now + .map( (key) => options.push(key) ); + options.push(''); // no sorting + return options; + } + + get nextSort() { + // wrapping loop over availableSortOptions + const opts = this.availableSortOptions; + return opts[(opts.indexOf(this.sortDirection) + 1) % opts.length]; + } + + /** + * Wraps around possible sorting directions. + */ + @action + toggleSort() { + this.args.updateSort(this.sortParameters[this.nextSort]); + } +} diff --git a/addon/components/default-data-table-content-body.js b/addon/components/default-data-table-content-body.js deleted file mode 100644 index 30d0242..0000000 --- a/addon/components/default-data-table-content-body.js +++ /dev/null @@ -1,21 +0,0 @@ -import { A } from '@ember/array'; -import { computed } from '@ember/object'; -import { oneWay } from '@ember/object/computed'; -import Component from '@ember/component'; -import layout from '../templates/components/default-data-table-content-body'; - -export default Component.extend({ - layout, - tagName: '', - allFields: oneWay('data-table.parsedFields'), - firstColumn: computed('data-table.parsedFields', function () { - const parsedFields = A(this.get('data-table.parsedFields')); - return parsedFields.get('firstObject'); - }), - otherColumns: computed('data-table.parsedFields', function () { - let fields; - [, ...fields] = this.get('data-table.parsedFields'); - return fields; - }), - linkedRoute: oneWay('data-table.link'), -}); diff --git a/addon/components/number-pagination.js b/addon/components/number-pagination.js deleted file mode 100644 index 901ade2..0000000 --- a/addon/components/number-pagination.js +++ /dev/null @@ -1,49 +0,0 @@ -import { computed } from '@ember/object'; -import Component from '@ember/component'; -import layout from '../templates/components/number-pagination'; - -export default Component.extend({ - layout, - classNames: ['data-table-pagination'], - currentPage: computed('page', { - get() { - return this.page ? parseInt(this.page) + 1 : 1; - }, - set(key, value) { - this.set('page', value - 1); - return value; - }, - }), - firstPage: computed('links.first.number', function () { - return this.get('links.first.number') || 1; - }), - lastPage: computed('links.last.number', function () { - const max = this.get('links.last.number') || -1; - return max ? max + 1 : max; - }), - isFirstPage: computed('firstPage', 'currentPage', function () { - return this.firstPage == this.currentPage; - }), - isLastPage: computed('lastPage', 'currentPage', function () { - return this.lastPage == this.currentPage; - }), - hasMultiplePages: computed.gt('lastPage', 0), - startItem: computed('size', 'currentPage', function () { - return this.size * (this.currentPage - 1) + 1; - }), - endItem: computed('startItem', 'nbOfItems', function () { - return this.startItem + this.nbOfItems - 1; - }), - pageOptions: computed('firstPage', 'lastPage', function () { - const nbOfPages = this.lastPage - this.firstPage + 1; - return Array.from( - new Array(nbOfPages), - (val, index) => this.firstPage + index - ); - }), - actions: { - changePage(link) { - this.set('page', link['number'] || 0); - }, - }, -}); diff --git a/addon/components/raw-data-table.hbs b/addon/components/raw-data-table.hbs new file mode 100644 index 0000000..e4c1249 --- /dev/null +++ b/addon/components/raw-data-table.hbs @@ -0,0 +1,303 @@ +{{!-- template-lint-disable no-inline-styles --}} + + {{!-- START: search --}} +
+ {{#if dt.enableSearch}} + + {{#if (has-block "search")}} + {{yield search to="search"}} + {{else}} +
+
+ +
+
+ {{/if}} +
+ {{/if}} + {{!-- END: search --}} + + {{!-- START: menu --}} + + {{#if (has-block "menu")}} + {{yield (hash General Selected enableSelection) to="menu"}} + {{else}} +
+ {{!-- either we have a general block or we have to have a menu --}} + + {{!-- TODO: shouldn't this be rendered when the result is empty too? Update docs! --}} + {{#if general.selectionIsEmpty}} + {{yield general to="general-menu"}} + {{/if}} + + {{#if enableSelection}} + + {{#unless selected.selectionIsEmpty}} + {{#if (has-block "selection-menu")}} + {{yield selected to="selection-menu"}} + {{else}} + {{#if (has-block "selection-menu-actions")}} + {{selected.selectionCount}} item(s) selected + + {{yield selected to="selection-menu-actions"}} + {{/if}} + {{/if}} + {{/unless}} + + {{/if}} +
+ {{/if}} +
+ {{!-- END: menu --}} + + {{!-- START: content --}} + + {{#if (has-block "content")}} + {{yield content to="content"}} + {{else}} +
+ + {{!-- START: headers --}} + + {{#if (has-block "full-header")}} + {{yield header to="full-header"}} + {{else}} + + + {{#if header.enableSelection}} + + {{/if}} + {{#if header.enableLineNumbers}} + + {{/if}} + {{#if (has-block "data-headers")}} + {{yield header.dataHeadersInfo to="data-headers"}} + {{else}} + {{#each header.fields as |field|}} + + {{#if dataHeader.renderCustomBlock}} + {{yield dataHeader to="data-header"}} + {{else}} + {{#if dataHeader.isSortable}} + + {{else}} + + {{/if}} + {{/if}} + + {{/each}} + {{/if}} + {{#if (has-block "actions-header")}} + {{yield to="actions-header"}} + {{else}} + {{#if (or (has-block "actions") header.hasLinks)}} + + {{/if}} + {{/if}} + + + {{/if}} + + {{!-- END: headers --}} + + {{!-- START: body --}} + + {{#if (has-block "body")}} + {{yield body to="body"}} + {{else}} + + {{#if body.isLoading}} + {{#if (has-block "body-loading")}} + {{yield to="body-loading"}} + {{else}} + + {{/if}} + {{else}} + {{#if body.content}} + {{#each body.wrappedItems as |wrapper index|}} + + {{#if (has-block "row")}} + {{yield row to="row"}} + {{else}} + + {{#if row.enableSelection}} + + {{/if}} + {{#if row.enableLineNumbers}} + + {{/if}} + + {{#if (has-block "data-cells")}} + {{yield dataCells to="data-cells"}} + {{else}} + {{!-- NOTE: you may drop this {{#if dataCells.firstColumn}}...{{/if}} when no custom first column styling is needed --}} + {{#if dataCells.firstColumn}} + + {{#if (has-block "first-data-cell")}} + {{yield cell to="first-data-cell"}} + {{else if cell.renderCustomBlock}} + {{yield cell to="data-cell"}} + {{else}} + {{!-- TODO: This should be based on the type of the field --}} + {{#if cell.rowLink}} + + {{else}} + + {{/if}} + {{/if}} + + {{/if}} + {{!-- NOTE: if you dropped custom styling for dataCells.firstColumn then use {{#each dataCells.fields as |column|}}...{{/each}} --}} + {{#each dataCells.otherColumns as |column|}} + + {{#if (has-block "rest-data-cell")}} + {{yield cell to="rest-data-cell"}} + {{else if cell.renderCustomBlock}} + {{yield cell to="data-cell"}} + {{else}} + {{!-- TODO: This should be based on the type of the field --}} + {{#if cell.rowLink}} + + {{else}} + + {{/if}} + {{/if}} + + {{/each}} + {{/if}} + + {{#if (has-block "actions")}} + {{yield row to="actions"}} + {{else}} + {{#if row.linkedRoutes}} + + {{/if}} + {{/if}} + + {{/if}} + + {{/each}} + {{else}} + {{#if (has-block "no-data-message")}} + {{yield to="no-data-message"}} + {{else}} + + {{/if}} + {{/if}} + {{/if}} + + {{/if}} + + {{!-- END: body --}} +
{{!-- Checkbox --}}{{!-- Linenumbers --}} + + {{#if dataHeader.isSorted}}[{{dataHeader.sortDirection}}]{{/if}} + {{dataHeader.label}} + + {{dataHeader.label}}
Loading...
+ {{input type="checkbox" checked=row.isSelected click=row.toggleSelected}} + {{row.lineNumber}} + + {{cell.value}} + + {{cell.value}} + + {{cell.value}} + + {{cell.value}} + {{#each row.linkedRoutes as |linkedRoute|}} + + {{or linkedRoute.label linkedRoute.route}} + + {{/each}} +

{{@noDataMessage}}

+
+ {{/if}} +
+ {{!-- END: content --}} + + {{!-- START: pagination --}} + + {{#if (has-block "pagination")}} + {{yield pagination to="pagination"}} + {{else}} +
+
+ Displaying {{pagination.startItem}}-{{pagination.endItem}} + {{#if pagination.hasTotal}} of {{pagination.total}}{{/if}} + {{#if pagination.sizeOptions}} + | + + {{/if}} +
+ {{#if pagination.hasMultiplePages}} +
+ + + + + +
+ {{/if}} +
+ {{/if}} +
+ {{!-- END: pagination --}} +
+
\ No newline at end of file diff --git a/addon/components/text-search.js b/addon/components/text-search.js deleted file mode 100644 index c7c09bb..0000000 --- a/addon/components/text-search.js +++ /dev/null @@ -1,41 +0,0 @@ -import { isEqual } from '@ember/utils'; -import { cancel, debounce } from '@ember/runloop'; -import { observer } from '@ember/object'; -import { oneWay } from '@ember/object/computed'; -import Component from '@ember/component'; -import layout from '../templates/components/text-search'; - -export default Component.extend({ - layout, - filter: '', - classNames: ['data-table-search'], - internalValue: oneWay('filter'), - auto: true, - placeholder: 'Search', - init() { - this._super(...arguments); - this.set('value', this.filter); - }, - onValueChange: observer('value', function () { - this._valuePid = debounce(this, this._setFilter, this.wait); - }), - onFilterChange: observer('filter', function () { - // update value if filter is update manually outsite this component - if ( - !this.isDestroying && - !this.isDestroyed && - !isEqual(this.filter, this.value) - ) { - this.set('value', this.filter); - } - }), - _setFilter() { - if (!this.isDestroying && !this.isDestroyed) { - this.set('filter', this.value); - } - }, - willDestroy() { - this._super(...arguments); - cancel(this._valuePid); - }, -}); diff --git a/addon/components/th-sortable.js b/addon/components/th-sortable.js deleted file mode 100644 index 41ede1f..0000000 --- a/addon/components/th-sortable.js +++ /dev/null @@ -1,60 +0,0 @@ -import { computed } from '@ember/object'; -import Component from '@ember/component'; -import layout from '../templates/components/th-sortable'; - -export default Component.extend({ - layout: layout, - tagName: 'th', - classNames: ['sortable'], - classNameBindings: ['isSorted:sorted'], - dasherizedField: computed('field', function () { - return this.field.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); - }), - /** - Inverses the sorting parameter - E.g. inverseSorting('title') returns '-title' - inverseSorting('-title') returns 'title' - */ - _inverseSorting(sorting) { - if (sorting.substring(0, 1) === '-') { - return sorting.substring(1); - } else { - return '-' + sorting; - } - }, - isSorted: computed('dasherizedField', 'currentSorting', function () { - return ( - this.currentSorting === this.dasherizedField || - this.currentSorting === this._inverseSorting(this.dasherizedField) - ); - }), - order: computed('dasherizedField', 'currentSorting', function () { - if (this.currentSorting === this.dasherizedField) { - return 'asc'; - } else if (this.currentSorting === `-${this.dasherizedField}`) { - return 'desc'; - } else { - return ''; - } - }), - - actions: { - /** - Sets the current sorting parameter. - Note: the current sorting parameter may contain another field than the given field. - In case the given field is currently sorted ascending, change to descending. - In case the given field is currently sorted descending, clean the sorting. - Else, set the sorting to ascending on the given field. - */ - inverseSorting() { - if (this.order === 'asc') { - this.set('currentSorting', this._inverseSorting(this.currentSorting)); - } else if (this.order === 'desc') { - this.set('currentSorting', ''); - } else { - // if currentSorting is not set to this field - this.set('currentSorting', this.dasherizedField); - } - }, - }, -}); diff --git a/addon/controller.js b/addon/controller.js new file mode 100644 index 0000000..a8063f4 --- /dev/null +++ b/addon/controller.js @@ -0,0 +1,26 @@ +import { tracked } from '@glimmer/tracking'; +import Controller from '@ember/controller'; + +export default class DataTableController extends Controller { + queryParams = ['size', 'page', 'filter', 'sort']; + + @tracked size = 10; + @tracked page = 0; + @tracked filter = ''; + @tracked sort = ''; // TODO: perhaps undefined would be a nicer default for consumers + @tracked isLoadingModel = false; + + get view() { + return { + size: this.size, + page: this.page, + filter: this.filter, + sort: this.sort, + isLoading: this.isLoadingModel, + updatePage: (page) => this.page = page, + updatePageSize: (size) => this.size = size, + updateFilter: (filter) => this.filter = filter, + updateSort: (sort) => this.sort = sort + } + } +} diff --git a/addon/mixins/serializer.js b/addon/mixins/serializer.js deleted file mode 100644 index f36939b..0000000 --- a/addon/mixins/serializer.js +++ /dev/null @@ -1,61 +0,0 @@ -import Mixin from '@ember/object/mixin'; - -export default Mixin.create({ - /** - Parse the links in the JSONAPI response and convert to a meta-object - */ - normalizeQueryResponse(store, clazz, payload) { - const result = this._super(...arguments); - result.meta = result.meta || {}; - - if (payload.links) { - result.meta.pagination = this.createPageMeta(payload.links); - } - if (payload.meta) { - result.meta.count = payload.meta.count; - } - - return result; - }, - - /** - Transforms link URLs to objects containing metadata - E.g. - { - previous: '/streets?page[number]=1&page[size]=10&sort=name - next: '/streets?page[number]=3&page[size]=10&sort=name - } - - will be converted to - - { - previous: { number: 1, size: 10 }, - next: { number: 3, size: 10 } - } - */ - createPageMeta(data) { - let meta = {}; - - Object.keys(data).forEach((type) => { - const link = data[type]; - meta[type] = {}; - - if (link) { - //extracts from '/path?foo=bar&baz=foo' the string: foo=bar&baz=foo - const query = link.split(/\?(.+)/)[1] || ''; - - query.split('&').forEach((pairs) => { - const [param, value] = pairs.split('='); - - if (decodeURIComponent(param) === 'page[number]') { - meta[type].number = parseInt(value); - } else if (decodeURIComponent(param) === 'page[size]') { - meta[type].size = parseInt(value); - } - }); - } - }); - - return meta; - }, -}); diff --git a/addon/route.js b/addon/route.js new file mode 100644 index 0000000..d670568 --- /dev/null +++ b/addon/route.js @@ -0,0 +1,47 @@ +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import Route from '@ember/routing/route'; +import merge from 'lodash/merge'; + +export default class DataTableRoute extends Route { + @service store; + + queryParams = { + filter: { refreshModel: true }, + page: { refreshModel: true }, + size: { refreshModel: true }, + sort: { refreshModel: true }, + }; + + mergeQueryOptions() { + return {}; + } + + model(params) { + const options = { + sort: params.sort, + page: { + number: params.page, + size: params.size, + }, + }; + // TODO: sending an empty filter param to backend returns [] + if (params.filter) { + options['filter'] = params.filter; + } + merge(options, this.mergeQueryOptions(params)); + return this.store.query(this.modelName, options); + } + + @action + loading(transition) { + let controller = this.controllerFor(this.routeName); + controller.isLoadingModel = true; + + transition.promise.finally(function () { + controller.isLoadingModel = false; + }); + + return true; // bubble the loading event + } +} diff --git a/addon/serializer.js b/addon/serializer.js new file mode 100644 index 0000000..2567f13 --- /dev/null +++ b/addon/serializer.js @@ -0,0 +1,101 @@ +import JSONAPISerializer from '@ember-data/serializer/json-api'; + +/** + * Transforms link URLs to objects containing metadata + * E.g. + * { + * previous: '/streets?page[number]=1&page[size]=10&sort=name + * next: '/streets?page[number]=3&page[size]=10&sort=name + * } + * will be converted to + * { + * previous: { number: 1, size: 10 }, + * next: { number: 3, size: 10 } + * } + */ +function createPageMeta(data) { + let meta = {}; + + Object.keys(data).forEach((type) => { + const link = data[type]; + meta[type] = {}; + + if (link) { + //extracts from '/path?foo=bar&baz=foo' the string: foo=bar&baz=foo + const query = link.split(/\?(.+)/)[1] || ''; + + query.split('&').forEach((pairs) => { + const [param, value] = pairs.split('='); + + if (decodeURIComponent(param) === 'page[number]') { + meta[type].number = parseInt(value); + } else if (decodeURIComponent(param) === 'page[size]') { + meta[type].size = parseInt(value); + } + }); + } + }); + + return meta; +} + +/** + * Adds the meta content to the query result. + * + * This function can be used if you need to manually append the changes. + * For instance, if you also have other overrides in the serializer. + * + * @param result The result from normalizeQueryResponse. + * @param payload The payload supplied to normalizeQueryResponse. + * @return The manipulated result object. + */ +export function appendMetaToQueryResponse(result, payload) { + result.meta = result.meta || {}; + + if (payload.links) { + result.meta.pagination = createPageMeta(payload.links); + } + if (payload.meta) { + result.meta.count = payload.meta.count; + } + + return result; +} + +/** + * Decorator for the normalizeQueryResponse serializer method. + * + * Augments the call to the normalizeQueryResponse method with parsing + * of the payload to extract the page meta. This decorator can be used + * if the serializer itself could not be used directly. Alternatively, + * you can combine the calls yourself with appendMetaToQueryResponse + * (also exported from here) directly. + */ +export function withPageMeta(_target, _name, descriptor) { + const original = descriptor.value; + + descriptor.value = function (_store, _clazz, payload) { + const result = original.apply(this, arguments); + return appendMetaToQueryResponse(result, payload); + }; + + return descriptor; +} + +/** + * Serializer to be used for DataTable requests. + * + * By extending this, the query repsonses are parsed correctly. If you + * need to adapt further, or need to combine with other libraries, also + * take a peek at the withPageMeta decorator exported from here, as well + * as the appendMetaToQueryResponse function. + */ +export default class ApplicationSerializer extends JSONAPISerializer { + /** + * Parse the links in the JSONAPI response and convert to a meta-object + */ + @withPageMeta + normalizeQueryResponse() { + return super.normalizeQueryResponse(...arguments); + } +} diff --git a/addon/templates/components/data-table-content-body.hbs b/addon/templates/components/data-table-content-body.hbs deleted file mode 100644 index 775ede4..0000000 --- a/addon/templates/components/data-table-content-body.hbs +++ /dev/null @@ -1,25 +0,0 @@ -{{#if data-table.isLoading}} - Loading... -{{else}} - {{#if content}} - {{#each wrappedItems as |wrapper index|}} - - {{#if enableSelection}} - - {{input type="checkbox" checked=wrapper.isSelected click=(action "updateSelection" wrapper)}} - - {{/if}} - {{#if enableLineNumbers}} - {{add index offset}} - {{/if}} - {{#if (has-block)}} - {{yield wrapper.item}} - {{else}} - {{default-data-table-content-body item=wrapper.item data-table=data-table}} - {{/if}} - - {{/each}} - {{else}} -

{{noDataMessage}}

- {{/if}} -{{/if}} diff --git a/addon/templates/components/data-table-content-header.hbs b/addon/templates/components/data-table-content-header.hbs deleted file mode 100644 index f075637..0000000 --- a/addon/templates/components/data-table-content-header.hbs +++ /dev/null @@ -1,15 +0,0 @@ - - {{#if enableSelection}} - {{!-- Checkbox --}} - {{/if}} - {{#if enableLineNumbers}} - {{!-- Linenumbers --}} - {{/if}} - {{#if (has-block)}} - {{yield}} - {{else}} - {{#each fields as |field|}} - {{th-sortable field=field label=field currentSorting=sort}} - {{/each}} - {{/if}} - diff --git a/addon/templates/components/data-table-content.hbs b/addon/templates/components/data-table-content.hbs deleted file mode 100644 index 782f366..0000000 --- a/addon/templates/components/data-table-content.hbs +++ /dev/null @@ -1,12 +0,0 @@ -{{! template-lint-disable table-groups }} - - {{#if (has-block)}} - {{yield (hash - header=(component "data-table-content-header" enableSelection=enableSelection enableLineNumbers=enableLineNumbers data-table=data-table) - body=(component "data-table-content-body" content=content enableSelection=enableSelection enableLineNumbers=enableLineNumbers noDataMessage=noDataMessage onClickRow=(optional onClickRow) data-table=data-table) - )}} - {{else}} - {{component "data-table-content-header" enableSelection=enableSelection enableLineNumbers=enableLineNumbers data-table=data-table}} - {{component "data-table-content-body" content=content enableSelection=enableSelection enableLineNumbers=enableLineNumbers noDataMessage=noDataMessage onClickRow=(optional onClickRow) data-table=data-table}} - {{/if}} -
diff --git a/addon/templates/components/data-table-menu-general.hbs b/addon/templates/components/data-table-menu-general.hbs deleted file mode 100644 index 452c9a5..0000000 --- a/addon/templates/components/data-table-menu-general.hbs +++ /dev/null @@ -1,3 +0,0 @@ -{{#if data-table.selectionIsEmpty}} - {{yield}} -{{/if}} diff --git a/addon/templates/components/data-table-menu-selected.hbs b/addon/templates/components/data-table-menu-selected.hbs deleted file mode 100644 index b8ac2b2..0000000 --- a/addon/templates/components/data-table-menu-selected.hbs +++ /dev/null @@ -1,5 +0,0 @@ -{{#unless data-table.selectionIsEmpty}} - {{selectionCount}} item(s) selected - - {{yield (slice 0 selectionCount data-table.selection) data-table}} -{{/unless}} diff --git a/addon/templates/components/data-table-menu.hbs b/addon/templates/components/data-table-menu.hbs deleted file mode 100644 index db4ab7a..0000000 --- a/addon/templates/components/data-table-menu.hbs +++ /dev/null @@ -1,4 +0,0 @@ -{{yield (hash - general=(component "data-table-menu-general" data-table=data-table) - selected=(component "data-table-menu-selected" data-table=data-table) -)}} diff --git a/addon/templates/components/data-table.hbs b/addon/templates/components/data-table.hbs deleted file mode 100644 index d530ed1..0000000 --- a/addon/templates/components/data-table.hbs +++ /dev/null @@ -1,30 +0,0 @@ -{{#if (has-block)}} -
- {{#if enableSearch}} - {{text-search filter=filter auto=autoSearch wait=searchDebounceTime}} - {{/if}} - {{yield (hash - menu=(component "data-table-menu" data-table=this) - ) - this}} -
- {{yield (hash - content=(component "data-table-content" content=content noDataMessage=noDataMessage enableSelection=enableSelection enableLineNumbers=enableLineNumbers onClickRow=(optional onClickRow) data-table=this) - ) - this}} -{{else}} - {{#if enableSearch}} -
-
- {{text-search filter=filter auto=autoSearch}} -
-
- {{/if}} - {{component "data-table-content" content=content noDataMessage=noDataMessage enableSelection=enableSelection enableLineNumbers=enableLineNumbers onClickRow=(optional onClickRow) data-table=this}} -{{/if}} - -{{#if content}} - {{number-pagination - page=page size=size nbOfItems=content.length sizeOptions=sizeOptions - total=content.meta.count links=content.meta.pagination}} -{{/if}} diff --git a/addon/templates/components/default-data-table-content-body.hbs b/addon/templates/components/default-data-table-content-body.hbs deleted file mode 100644 index f13a9c0..0000000 --- a/addon/templates/components/default-data-table-content-body.hbs +++ /dev/null @@ -1,15 +0,0 @@ -{{#if firstColumn}} - {{#if linkedRoute}} - {{#link-to linkedRoute item tagName="td"}}{{get item firstColumn}}{{/link-to}} - {{else}} - {{get item firstColumn}} - {{/if}} -{{/if}} -{{#each otherColumns as |field|}} - - {{!-- This should be based on the type of the field --}} - {{get item field}} - -{{/each}} -{{yield}} - diff --git a/addon/templates/components/number-pagination.hbs b/addon/templates/components/number-pagination.hbs deleted file mode 100644 index fd93b80..0000000 --- a/addon/templates/components/number-pagination.hbs +++ /dev/null @@ -1,27 +0,0 @@ -
-
- Displaying {{startItem}}-{{endItem}} - {{#if total}} of {{total}}{{/if}} - {{#if sizeOptions}} - | - per page - {{/if}} -
- {{#if hasMultiplePages}} -
- - - - - -
- {{/if}} -
diff --git a/addon/templates/components/text-search.hbs b/addon/templates/components/text-search.hbs deleted file mode 100644 index 0ae311f..0000000 --- a/addon/templates/components/text-search.hbs +++ /dev/null @@ -1,6 +0,0 @@ -{{#if auto}} - {{input value=value placeholder=placeholder}} -{{else}} - - -{{/if}} diff --git a/addon/templates/components/th-sortable.hbs b/addon/templates/components/th-sortable.hbs deleted file mode 100644 index b17b6f1..0000000 --- a/addon/templates/components/th-sortable.hbs +++ /dev/null @@ -1,4 +0,0 @@ - - {{#if order}}[{{order}}]{{/if}} - {{label}} - diff --git a/addon/utils/attribute-to-sort-params.js b/addon/utils/attribute-to-sort-params.js new file mode 100644 index 0000000..a77df92 --- /dev/null +++ b/addon/utils/attribute-to-sort-params.js @@ -0,0 +1,4 @@ +export default function attributeToSortParams(attribute) { + const attr = attribute.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + return { asc: attr, desc: `-${attr}` }; +} diff --git a/addon/utils/string-specification-helpers.js b/addon/utils/string-specification-helpers.js new file mode 100644 index 0000000..0818b45 --- /dev/null +++ b/addon/utils/string-specification-helpers.js @@ -0,0 +1,88 @@ +import { upperFirst } from "lodash"; + +/** + * Splits a string of defitinions by space. + */ +export function splitDefinitions(string) { + return (string || "") + .split(" ") + .filter((x) => x !== ""); +} + +/** + * Transforms __ to _ and _ to space. + */ +export function deUnderscoreString(string) { + const arrString = []; + + // executing this with a regex turned out to be less clear + let idx = 0; + while( idx < string.length ) { + let current = string[idx]; + let next = string[idx+1]; + + if( current === "_" && next === "_") { + arrString.push("_"); + idx = idx + 2; + } else if( current === "_" ) { + arrString.push(" "); + idx = idx + 1; + } else { + arrString.push(current); + idx = idx + 1; + } + } + return arrString.join(""); +} + +/** + * Unpacks the components of a series of name/label specifications split + * by spaces (top-level) and : lower-level, including the unpacking of + * _. + * + * configuration is an array of components to be recognized. In case of + * a simple string, the item is placed under that key in the returned + * object and rawLabel is used to provide the unparsed value (without + * clearing _). An object may be supplied for further unpacking which + * may contain the following key/values: + * + * - raw: label : Store the raw value as label, do not process further. + * - default: label : Use the previously parsed value for label as the * + * default value if no value was supplied or if an empty value was + * supplied, must also supply name as key. + * + * toComponentSpecifications( "number:Nr. location:Gemeente_en_straat land", [{raw: "attribute"},{name: "label", default: "attribute"}]) + * -> [{attribute:"number", label: "Nr."},{attribute:"location",label:"Gemeente en straat",rawLabel:"Gemeente_en_straat"},{attribute:"land",label:"land"}] + */ +export function toComponentSpecifications(string, configuration) { + return splitDefinitions(string) + .map( (specification) => { + let obj = {}; + let components = specification.split(":"); + for ( let i = 0; i < configuration.length; i++ ) { + const spec = configuration[i]; + const component = components[i]; + if ( typeof spec === "string" ) { + obj[`raw${upperFirst(spec)}`] = component; + obj[spec] = deUnderscoreString(component || ""); + } else { + // object specification + if (spec.raw) { + obj[spec.raw] = component; + } + if (spec.name) { + if (spec.default && !component) { + obj[spec.name] = obj[spec.default]; + } else { + obj[`raw${upperFirst(spec.name)}`] = component; + obj[spec.name] = deUnderscoreString(component || ""); + } + } + if (!spec.raw && !spec.default) { + throw `Specification ${JSON.stringify(spec)} not understood`; + } + } + } + return obj; + } ); +} diff --git a/app/components/data-table-content-body.js b/app/components/data-table-content-body.js deleted file mode 100644 index 8f7fe73..0000000 --- a/app/components/data-table-content-body.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-data-table/components/data-table-content-body'; diff --git a/app/components/data-table-content-header.js b/app/components/data-table-content-header.js deleted file mode 100644 index 44b4a48..0000000 --- a/app/components/data-table-content-header.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-data-table/components/data-table-content-header'; diff --git a/app/components/data-table-content.js b/app/components/data-table-content.js deleted file mode 100644 index d1f2b66..0000000 --- a/app/components/data-table-content.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-data-table/components/data-table-content'; diff --git a/app/components/data-table-menu-general.js b/app/components/data-table-menu-general.js deleted file mode 100644 index 0b647fd..0000000 --- a/app/components/data-table-menu-general.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-data-table/components/data-table-menu-general'; diff --git a/app/components/data-table-menu-selected.js b/app/components/data-table-menu-selected.js deleted file mode 100644 index 0e2877e..0000000 --- a/app/components/data-table-menu-selected.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-data-table/components/data-table-menu-selected'; diff --git a/app/components/data-table-menu.js b/app/components/data-table-menu.js deleted file mode 100644 index 423d082..0000000 --- a/app/components/data-table-menu.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-data-table/components/data-table-menu'; diff --git a/app/components/data-table/data-cell.js b/app/components/data-table/data-cell.js new file mode 100644 index 0000000..11d7dae --- /dev/null +++ b/app/components/data-table/data-cell.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/data-cell'; diff --git a/app/components/data-table/data-cells.js b/app/components/data-table/data-cells.js new file mode 100644 index 0000000..e68d66a --- /dev/null +++ b/app/components/data-table/data-cells.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/data-cells'; \ No newline at end of file diff --git a/app/components/data-table/data-table-content-body.js b/app/components/data-table/data-table-content-body.js new file mode 100644 index 0000000..8ce1b62 --- /dev/null +++ b/app/components/data-table/data-table-content-body.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/data-table-content-body'; diff --git a/app/components/data-table/data-table-content-header.js b/app/components/data-table/data-table-content-header.js new file mode 100644 index 0000000..ad8e187 --- /dev/null +++ b/app/components/data-table/data-table-content-header.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/data-table-content-header'; diff --git a/app/components/data-table/data-table-content.js b/app/components/data-table/data-table-content.js new file mode 100644 index 0000000..672dc51 --- /dev/null +++ b/app/components/data-table/data-table-content.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/data-table-content'; diff --git a/app/components/data-table/data-table-menu-general.js b/app/components/data-table/data-table-menu-general.js new file mode 100644 index 0000000..ecb0364 --- /dev/null +++ b/app/components/data-table/data-table-menu-general.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/data-table-menu-general'; diff --git a/app/components/data-table/data-table-menu-selected.js b/app/components/data-table/data-table-menu-selected.js new file mode 100644 index 0000000..d216170 --- /dev/null +++ b/app/components/data-table/data-table-menu-selected.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/data-table-menu-selected'; diff --git a/app/components/data-table/data-table-menu.js b/app/components/data-table/data-table-menu.js new file mode 100644 index 0000000..84a697d --- /dev/null +++ b/app/components/data-table/data-table-menu.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/data-table-menu'; diff --git a/app/components/data-table/number-pagination.js b/app/components/data-table/number-pagination.js new file mode 100644 index 0000000..3232643 --- /dev/null +++ b/app/components/data-table/number-pagination.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/number-pagination'; diff --git a/app/components/data-table/row.js b/app/components/data-table/row.js new file mode 100644 index 0000000..2731d43 --- /dev/null +++ b/app/components/data-table/row.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/row'; diff --git a/app/components/data-table/text-search.js b/app/components/data-table/text-search.js new file mode 100644 index 0000000..26875e3 --- /dev/null +++ b/app/components/data-table/text-search.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/text-search'; diff --git a/app/components/data-table/th-sortable.js b/app/components/data-table/th-sortable.js new file mode 100644 index 0000000..057b751 --- /dev/null +++ b/app/components/data-table/th-sortable.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/data-table/th-sortable'; diff --git a/app/components/default-data-table-content-body.js b/app/components/default-data-table-content-body.js deleted file mode 100644 index d9892cf..0000000 --- a/app/components/default-data-table-content-body.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-data-table/components/default-data-table-content-body'; diff --git a/app/components/number-pagination.js b/app/components/number-pagination.js deleted file mode 100644 index 236d179..0000000 --- a/app/components/number-pagination.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-data-table/components/number-pagination'; diff --git a/app/components/raw-data-table.js b/app/components/raw-data-table.js new file mode 100644 index 0000000..ea25044 --- /dev/null +++ b/app/components/raw-data-table.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/components/raw-data-table'; diff --git a/app/components/text-search.js b/app/components/text-search.js deleted file mode 100644 index 43016fd..0000000 --- a/app/components/text-search.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-data-table/components/text-search'; diff --git a/app/components/th-sortable.js b/app/components/th-sortable.js deleted file mode 100644 index 54c6aec..0000000 --- a/app/components/th-sortable.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from 'ember-data-table/components/th-sortable'; diff --git a/app/utils/attribute-to-sort-params.js b/app/utils/attribute-to-sort-params.js new file mode 100644 index 0000000..d9701d0 --- /dev/null +++ b/app/utils/attribute-to-sort-params.js @@ -0,0 +1 @@ +export { default } from 'ember-data-table/utils/attribute-to-sort-params'; diff --git a/package.json b/package.json index e4c7514..e5f4664 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,11 @@ "test:ember-compatibility": "ember try:each" }, "dependencies": { - "ember-auto-import": "^1.12.0", + "ember-auto-import": "^2.0.0", "ember-cli-babel": "^7.26.10", "ember-cli-htmlbars": "^5.7.2", "ember-composable-helpers": "^5.0.0", + "ember-data": ">=3.28.0", "ember-math-helpers": "^2.18.0", "ember-truth-helpers": "^3.0.0", "lodash": "^4.17.21" diff --git a/tests/integration/components/data-table-content-body-test.js b/tests/integration/components/data-table-content-body-test.no-js similarity index 74% rename from tests/integration/components/data-table-content-body-test.js rename to tests/integration/components/data-table-content-body-test.no-js index a72beee..e02ae58 100644 --- a/tests/integration/components/data-table-content-body-test.js +++ b/tests/integration/components/data-table-content-body-test.no-js @@ -9,7 +9,7 @@ module('Integration | Component | data table content body', function (hooks) { test('it renders', async function (assert) { // Set any properties with this.set('myProperty', 'value'); // Handle any actions with this.on('myAction', function(val) { ... }); - await render(hbs`{{data-table-content-body}}`); + await render(hbs`{{data-table/data-table-content-body}}`); assert.dom('tbody').exists({ count: 1 }); }); @@ -23,7 +23,7 @@ module('Integration | Component | data table content body', function (hooks) { this.set('dataTable.selection', []); await render( - hbs`{{data-table-content-body content=content data-table=dataTable}}` + hbs`{{data-table/data-table-content-body content=content dataTable=dataTable}}` ); assert.dom('tr').exists({ count: 2 }, 'displays 2 rows'); @@ -50,12 +50,12 @@ module('Integration | Component | data table content body', function (hooks) { const jane = { firstName: 'Jane', lastName: 'Doe', age: 21 }; const jeff = { firstName: 'Jeff', lastName: 'Doe', age: 22 }; this.set('content', [john, jane, jeff]); - this.set('data-table', {}); - this.set('data-table.parsedFields', ['firstName', 'lastName', 'age']); - this.set('data-table.selection', [jane]); + this.set('dataTable', {}); + this.set('dataTable.parsedFields', ['firstName', 'lastName', 'age']); + this.set('dataTable.selection', [jane]); await render( - hbs`{{data-table-content-body content=content data-table=data-table enableSelection=true}}` + hbs`{{data-table/data-table-content-body content=content dataTable=dataTable enableSelection=true}}` ); assert.equal(this.$('tr:first td').length, 4, 'displays 4 columns'); @@ -73,16 +73,16 @@ module('Integration | Component | data table content body', function (hooks) { const jane = { firstName: 'Jane', lastName: 'Doe', age: 21 }; const jeff = { firstName: 'Jeff', lastName: 'Doe', age: 22 }; this.set('content', [john, jane, jeff]); - this.set('data-table', {}); - this.set('data-table.parsedFields', ['firstName', 'lastName', 'age']); - this.set('data-table.selection', [jane]); - this.set('data-table.addItemToSelection', () => - this.set('data-table.selection', [john, jane]) + this.set('dataTable', {}); + this.set('dataTable.parsedFields', ['firstName', 'lastName', 'age']); + this.set('dataTable.selection', [jane]); + this.set('dataTable.addItemToSelection', () => + this.set('dataTable.selection', [john, jane]) ); // mock function - this.set('data-table.removeItemFromSelection', function () {}); // mock function + this.set('dataTable.removeItemFromSelection', function () {}); // mock function await render( - hbs`{{data-table-content-body content=content data-table=data-table enableSelection=true}}` + hbs`{{data-table/data-table-content-body content=content dataTable=dataTable enableSelection=true}}` ); assert @@ -99,12 +99,12 @@ module('Integration | Component | data table content body', function (hooks) { const jane = { firstName: 'Jane', lastName: 'Doe', age: 21 }; const jeff = { firstName: 'Jeff', lastName: 'Doe', age: 22 }; this.set('content', [john, jane, jeff]); - this.set('data-table', {}); - this.set('data-table.parsedFields', ['firstName', 'lastName', 'age']); - this.set('data-table.selection', []); + this.set('dataTable', {}); + this.set('dataTable.parsedFields', ['firstName', 'lastName', 'age']); + this.set('dataTable.selection', []); await render( - hbs`{{data-table-content-body content=content data-table=data-table enableLineNumbers=true}}` + hbs`{{data-table/data-table-content-body content=content dataTable=dataTable enableLineNumbers=true}}` ); assert.equal(this.$('tr:first td').length, 4, 'displays 4 columns'); @@ -124,10 +124,10 @@ module('Integration | Component | data table content body', function (hooks) { 'displays offset 3 on the third row' ); - this.set('data-table.page', 2); - this.set('data-table.size', 5); + this.set('dataTable.page', 2); + this.set('dataTable.size', 5); await render( - hbs`{{data-table-content-body content=content data-table=data-table enableLineNumbers=true}}` + hbs`{{data-table/data-table-content-body content=content dataTable=dataTable enableLineNumbers=true}}` ); assert.equal( @@ -156,12 +156,12 @@ module('Integration | Component | data table content body', function (hooks) { // Set any properties with this.set('myProperty', 'value'); // Handle any actions with this.on('myAction', function(val) { ... }); this.set('noDataMessage', 'No data'); - this.set('data-table', {}); - this.set('data-table.parsedFields', ['firstName', 'lastName', 'age']); - this.set('data-table.selection', []); + this.set('dataTable', {}); + this.set('dataTable.parsedFields', ['firstName', 'lastName', 'age']); + this.set('dataTable.selection', []); await render( - hbs`{{data-table-content-body noDataMessage=noDataMessage data-table=data-table}}` + hbs`{{data-table/data-table-content-body noDataMessage=noDataMessage dataTable=dataTable}}` ); assert .dom('td.no-data-message') @@ -172,7 +172,7 @@ module('Integration | Component | data table content body', function (hooks) { this.set('content', []); await render( - hbs`{{data-table-content-body content=content noDataMessage=noDataMessage data-table=data-table}}` + hbs`{{data-table/data-table-content-body content=content noDataMessage=noDataMessage dataTable=dataTable}}` ); assert .dom('td.no-data-message') @@ -183,7 +183,7 @@ module('Integration | Component | data table content body', function (hooks) { this.set('content', ['foo', 'bar']); await render( - hbs`{{data-table-content-body content=content noDataMessage=noDataMessage data-table=data-table}}` + hbs`{{data-table/data-table-content-body content=content noDataMessage=noDataMessage dataTable=dataTable}}` ); assert .dom('td.no-data-message') diff --git a/tests/integration/components/data-table-content-header-test.js b/tests/integration/components/data-table-content-header-test.no-js similarity index 79% rename from tests/integration/components/data-table-content-header-test.js rename to tests/integration/components/data-table-content-header-test.no-js index be3368c..6ff2ee3 100644 --- a/tests/integration/components/data-table-content-header-test.js +++ b/tests/integration/components/data-table-content-header-test.no-js @@ -26,10 +26,10 @@ module('Integration | Component | data table content header', function (hooks) { }); test('display column headers', async function (assert) { - this.set('data-table', {}); - this.set('data-table.parsedFields', ['firstName', 'lastName', 'age']); + this.set('dataTable', {}); + this.set('dataTable.parsedFields', ['firstName', 'lastName', 'age']); - await render(hbs`{{data-table-content-header data-table=data-table}}`); + await render(hbs`{{data-table-content-header dataTable=dataTable}}`); assert.dom('tr').exists({ count: 1 }, 'displays 1 header row'); assert.equal(this.$('tr:first th').length, 3, 'displays 3 column headers'); @@ -51,11 +51,11 @@ module('Integration | Component | data table content header', function (hooks) { }); test('add selection column header if enabled', async function (assert) { - this.set('data-table', {}); - this.set('data-table.parsedFields', ['firstName', 'lastName', 'age']); + this.set('dataTable', {}); + this.set('dataTable.parsedFields', ['firstName', 'lastName', 'age']); await render( - hbs`{{data-table-content-header data-table=data-table enableSelection=true}}` + hbs`{{data-table-content-header dataTable=dataTable enableSelection=true}}` ); assert.dom('tr').exists({ count: 1 }, 'displays 1 header row'); @@ -68,11 +68,11 @@ module('Integration | Component | data table content header', function (hooks) { }); test('add line number column header if enabled', async function (assert) { - this.set('data-table', {}); - this.set('data-table.parsedFields', ['firstName', 'lastName', 'age']); + this.set('dataTable', {}); + this.set('dataTable.parsedFields', ['firstName', 'lastName', 'age']); await render( - hbs`{{data-table-content-header data-table=data-table enableLineNumbers=true}}` + hbs`{{data-table-content-header dataTable=dataTable enableLineNumbers=true}}` ); assert.dom('tr').exists({ count: 1 }, 'displays 1 header row'); diff --git a/tests/integration/components/data-table-content-test.js b/tests/integration/components/data-table-content-test.no-js similarity index 100% rename from tests/integration/components/data-table-content-test.js rename to tests/integration/components/data-table-content-test.no-js diff --git a/tests/integration/components/data-table-menu-general-test.js b/tests/integration/components/data-table-menu-general-test.no-js similarity index 87% rename from tests/integration/components/data-table-menu-general-test.js rename to tests/integration/components/data-table-menu-general-test.no-js index 57e1ad7..60c9fad 100644 --- a/tests/integration/components/data-table-menu-general-test.js +++ b/tests/integration/components/data-table-menu-general-test.no-js @@ -22,16 +22,16 @@ module('Integration | Component | data table menu general', function (hooks) { this.set('data-table', { selectionIsEmpty: true }); // Template block usage: await render(hbs` - {{#data-table-menu-general data-table=data-table}} + {{#data-table-menu-general dataTable=dataTable}} template block text {{/data-table-menu-general}} `); assert.dom('*').hasText('template block text'); - this.set('data-table', { selectionIsEmpty: false }); + this.set('dataTable', { selectionIsEmpty: false }); // Template block usage: await render(hbs` - {{#data-table-menu-general data-table=data-table}} + {{#data-table-menu-general dataTable=dataTable}} template block text {{/data-table-menu-general}} `); diff --git a/tests/integration/components/data-table-menu-selected-test.js b/tests/integration/components/data-table-menu-selected-test.no-js similarity index 71% rename from tests/integration/components/data-table-menu-selected-test.js rename to tests/integration/components/data-table-menu-selected-test.no-js index ec3157c..5ab4e5d 100644 --- a/tests/integration/components/data-table-menu-selected-test.js +++ b/tests/integration/components/data-table-menu-selected-test.no-js @@ -7,10 +7,10 @@ module('Integration | Component | data table menu selected', function (hooks) { setupRenderingTest(hooks); test('it renders block only if data table selection is not empty', async function (assert) { - this.set('data-table', { selectionIsEmpty: true }); + this.set('dataTable', { selectionIsEmpty: true }); // Template block usage: await render(hbs` - {{#data-table-menu-selected data-table=data-table}} + {{#data-table-menu-selected dataTable=dataTable}} template block text {{/data-table-menu-selected}} `); @@ -18,23 +18,23 @@ module('Integration | Component | data table menu selected', function (hooks) { }); test('it renders selection count', async function (assert) { - this.set('data-table', { selectionIsEmpty: false, selection: ['foo'] }); + this.set('dataTable', { selectionIsEmpty: false, selection: ['foo'] }); // Template block usage: await render(hbs` - {{#data-table-menu-selected data-table=data-table}} + {{#data-table-menu-selected dataTable=dataTable}} template block text {{/data-table-menu-selected}} `); assert.dom('span.item-count').hasText('1 item(s) selected', 'item count 1'); - this.set('data-table', { + this.set('dataTable', { selectionIsEmpty: false, selection: ['foo', 'bar'], }); // Template block usage: await render(hbs` - {{#data-table-menu-selected data-table=data-table}} + {{#data-table-menu-selected dataTable=dataTable}} template block text {{/data-table-menu-selected}} `); @@ -45,13 +45,13 @@ module('Integration | Component | data table menu selected', function (hooks) { test('calls clearSelection on cancel button click', async function (assert) { assert.expect(2); // 2 asserts in this test - this.set('data-table', { selectionIsEmpty: false, selection: ['foo'] }); - this.set('data-table.clearSelection', function () { - assert.ok(true, 'data-table.clearSelection gets called'); + this.set('dataTable', { selectionIsEmpty: false, selection: ['foo'] }); + this.set('dataTable.clearSelection', function () { + assert.ok(true, 'dataTable.clearSelection gets called'); }); // Template block usage: await render(hbs` - {{#data-table-menu-selected data-table=data-table}} + {{#data-table-menu-selected dataTable=dataTable}} template block text {{/data-table-menu-selected}} `); diff --git a/tests/integration/components/data-table-menu-test.js b/tests/integration/components/data-table-menu-test.no-js similarity index 100% rename from tests/integration/components/data-table-menu-test.js rename to tests/integration/components/data-table-menu-test.no-js diff --git a/tests/integration/components/data-table-test.js b/tests/integration/components/data-table-test.no-js similarity index 100% rename from tests/integration/components/data-table-test.js rename to tests/integration/components/data-table-test.no-js diff --git a/tests/integration/components/data-table/data-cell-test.js b/tests/integration/components/data-table/data-cell-test.js new file mode 100644 index 0000000..ff4306f --- /dev/null +++ b/tests/integration/components/data-table/data-cell-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | data-table/data-cell', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs``); + + assert.dom(this.element).hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); diff --git a/tests/integration/components/data-table/data-cells-test.js b/tests/integration/components/data-table/data-cells-test.js new file mode 100644 index 0000000..bced827 --- /dev/null +++ b/tests/integration/components/data-table/data-cells-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | data-table/data-cells', function(hooks) { + setupRenderingTest(hooks); + + test('it renders', async function(assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs``); + + assert.dom(this.element).hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); diff --git a/tests/integration/components/data-table/row-test.js b/tests/integration/components/data-table/row-test.js new file mode 100644 index 0000000..4ff68d1 --- /dev/null +++ b/tests/integration/components/data-table/row-test.js @@ -0,0 +1,29 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | data-table/row', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + this.set('onClickRow', () => undefined ); + this.set('toggleSelected', () => undefined ); + + await render(hbs``); + + assert.dom(this.element).hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); diff --git a/tests/integration/components/default-data-table-content-body-test.js b/tests/integration/components/default-data-table-content-body-test.no-js similarity index 83% rename from tests/integration/components/default-data-table-content-body-test.js rename to tests/integration/components/default-data-table-content-body-test.no-js index b4c2644..5966f01 100644 --- a/tests/integration/components/default-data-table-content-body-test.js +++ b/tests/integration/components/default-data-table-content-body-test.no-js @@ -12,19 +12,19 @@ module( // Set any properties with this.set('myProperty', 'value'); // Handle any actions with this.on('myAction', function(val) { ... }); - this.set('data-table', { + this.set('dataTable', { parsedFields: ['firstName', 'lastName', 'age'], }); await render( - hbs`{{default-data-table-content-body data-table=data-table}}` + hbs`{{default-data-table-content-body dataTable=dataTable}}` ); assert.dom().hasText(''); // Template block usage: await render(hbs` - + template block text `); diff --git a/tests/integration/components/number-pagination-test.js b/tests/integration/components/number-pagination-test.no-js similarity index 100% rename from tests/integration/components/number-pagination-test.js rename to tests/integration/components/number-pagination-test.no-js diff --git a/tests/integration/components/raw-data-table-test.no-js b/tests/integration/components/raw-data-table-test.no-js new file mode 100644 index 0000000..6c72a99 --- /dev/null +++ b/tests/integration/components/raw-data-table-test.no-js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | raw-data-table', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs``); + + assert.dom(this.element).hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); diff --git a/tests/integration/components/text-search-test.js b/tests/integration/components/text-search-test.no-js similarity index 100% rename from tests/integration/components/text-search-test.js rename to tests/integration/components/text-search-test.no-js diff --git a/tests/integration/components/th-sortable-test.js b/tests/integration/components/th-sortable-test.no-js similarity index 100% rename from tests/integration/components/th-sortable-test.js rename to tests/integration/components/th-sortable-test.no-js diff --git a/tests/unit/components/data-table/data-table-content-test.js b/tests/unit/components/data-table/data-table-content-test.js new file mode 100644 index 0000000..99cf300 --- /dev/null +++ b/tests/unit/components/data-table/data-table-content-test.js @@ -0,0 +1,58 @@ +import { module, test } from 'qunit'; +import { deUnderscoreString, splitDefinitions, toComponentSpecifications } from 'ember-data-table/utils/string-specification-helpers'; + +function convertDefinition(string) { + return toComponentSpecifications(string || "", [{ raw: "route" }, "label", "icon"]); +} + +module('Unit | Component | data-table-content', function() { + test('it strips underscores', function(assert) { + const checks = [["one", "one"], + ["one_two", "one two"], + ["one_two_three", "one two three"], + ["one__two", "one_two"], + ["one__two_three", "one_two three"], + ["__hello__", "_hello_"]]; + + assert.expect(checks.length); + + checks.forEach(([input, output]) => { + assert.strictEqual(deUnderscoreString(input), output); + }); + }); + + test('it splits definitions', function(assert) { + assert.deepEqual(splitDefinitions("hello world"), ["hello", "world"]); + assert.deepEqual(splitDefinitions(null), []); + assert.deepEqual(splitDefinitions(undefined), []); + }); + + test('it creates definition objects', function(assert) { + const checks = [ + ["hello", { + route: "hello", + label: null, + icon: null, + rawLabel: null, + rawIcon: null + }], + ["hello.world:Hello_World", { + route: "hello.world", + label: "Hello World", + icon: null, + rawLabel: "Hello_World", + rawIcon: null + }], + ["hello.world:Hello_World:add-icon-thing", { + route: "hello.world", + label: "Hello World", + icon: "add-icon-thing", + rawLabel: "Hello_World", + rawIcon: "add-icon-thing" + }]]; + + assert.expect(checks.length); + + checks.forEach(([input, output]) => assert.deepEqual(convertDefinition(input), output)); + }); +}); diff --git a/tests/unit/mixins/default-query-params-test.js b/tests/unit/mixins/default-query-params-test.js deleted file mode 100644 index 958487e..0000000 --- a/tests/unit/mixins/default-query-params-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import EmberObject from '@ember/object'; -import DefaultQueryParamsMixin from 'ember-data-table/mixins/default-query-params'; -import { module, test } from 'qunit'; - -module('Unit | Mixin | default query params', function () { - // Replace this with your real tests. - test('it works', function (assert) { - let DefaultQueryParamsObject = EmberObject.extend(DefaultQueryParamsMixin); - let subject = DefaultQueryParamsObject.create(); - assert.ok(subject); - }); -}); diff --git a/tests/unit/mixins/route-test.js b/tests/unit/mixins/route-test.js deleted file mode 100644 index 1c7ec25..0000000 --- a/tests/unit/mixins/route-test.js +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable ember/no-new-mixins,ember/no-mixins */ - -import EmberObject from '@ember/object'; -import RouteMixin from 'ember-data-table/mixins/route'; -import { module, test } from 'qunit'; - -module('Unit | Mixin | route', function () { - test('it (deep) merges the response of mergeQueryOptions method with the query param options', function (assert) { - assert.expect(2); - - let RouteObject = EmberObject.extend(RouteMixin, { - modelName: 'test', - mergeQueryOptions() { - return { - foo: 'bar', - page: { - size: 5, - }, - }; - }, - }); - - let mockStore = { - query: (modelName, queryOptions) => { - assert.strictEqual(modelName, 'test'); - assert.deepEqual(queryOptions, { - sort: 'name', - page: { - size: 5, - number: 0, - }, - foo: 'bar', - }); - }, - }; - - let mockRoute = RouteObject.create(); - mockRoute.store = mockStore; - mockRoute.model({ sort: 'name', page: 0, size: 20 }); - }); -}); diff --git a/tests/unit/mixins/serializer-test.js b/tests/unit/mixins/serializer-test.js deleted file mode 100644 index 55973c2..0000000 --- a/tests/unit/mixins/serializer-test.js +++ /dev/null @@ -1,12 +0,0 @@ -import EmberObject from '@ember/object'; -import SerializerMixin from 'ember-data-table/mixins/serializer'; -import { module, test } from 'qunit'; - -module('Unit | Mixin | serializer', function () { - // Replace this with your real tests. - test('it works', function (assert) { - let SerializerObject = EmberObject.extend(SerializerMixin); - let subject = SerializerObject.create(); - assert.ok(subject); - }); -}); diff --git a/tests/unit/utils/string-specification-helpers-test.js b/tests/unit/utils/string-specification-helpers-test.js new file mode 100644 index 0000000..e158f30 --- /dev/null +++ b/tests/unit/utils/string-specification-helpers-test.js @@ -0,0 +1,10 @@ +import stringSpecificationHelpers from 'dummy/utils/string-specification-helpers'; +import { module, test } from 'qunit'; + +module('Unit | Utility | string-specification-helpers', function () { + // TODO: Replace this with your real tests. + test('it works', function (assert) { + let result = stringSpecificationHelpers(); + assert.ok(result); + }); +});