├── .formatter.exs ├── .gitignore ├── .zed └── tasks.json ├── LICENSE ├── README.md ├── assets └── tailwind.css ├── example ├── .formatter.exs ├── .gitignore ├── README.md ├── assets │ ├── css │ │ └── app.css │ ├── js │ │ └── app.js │ ├── tailwind.config.js │ └── vendor │ │ └── topbar.js ├── config │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ ├── runtime.exs │ └── test.exs ├── lib │ ├── example.ex │ ├── example │ │ ├── application.ex │ │ ├── model │ │ │ └── article.ex │ │ └── repo.ex │ ├── example_web.ex │ └── example_web │ │ ├── components │ │ ├── layouts.ex │ │ └── layouts │ │ │ ├── app.html.heex │ │ │ └── root.html.heex │ │ ├── endpoint.ex │ │ ├── live │ │ └── articles.ex │ │ ├── router.ex │ │ └── telemetry.ex ├── mix.exs ├── mix.lock └── priv │ └── repo │ ├── migrations │ ├── .formatter.exs │ └── 20230717191810_add_tables.exs │ └── seeds.exs ├── guides └── cheatsheets │ ├── data_table_component_cheatsheet.cheatmd │ └── ecto_source_cheatsheet.cheatmd ├── lib ├── data_table.ex └── data_table │ ├── application.ex │ ├── dev_server.ex │ ├── ecto.ex │ ├── ecto │ └── query.ex │ ├── list.ex │ ├── list │ └── config.ex │ ├── live_component.ex │ ├── live_component │ ├── filters.ex │ └── logic.ex │ ├── nav_state.ex │ ├── source.ex │ ├── source │ ├── query.ex │ └── result.ex │ ├── theme │ ├── basic.ex │ ├── tailwind.ex │ ├── tailwind │ │ ├── components.ex │ │ ├── dropdown.ex │ │ ├── heroicons.ex │ │ └── link.ex │ └── util.ex │ └── util │ └── data_deps.ex ├── mix.exs ├── mix.lock ├── screenshot.png └── test ├── data_table └── live_component │ └── logic_test.exs ├── data_table_test.exs ├── support ├── test_endpoint.ex ├── test_live.ex └── test_router.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | data_table-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /.zed/tasks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "label": "Build Docs", 4 | "command": "mix docs", 5 | "use_new_terminal": false, 6 | "allow_concurrent_runs": false, 7 | "reveal": "always", 8 | "hide": "on_success", 9 | "shell": "system" 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DataTable 2 | 3 | [Docs](https://hexdocs.pm/data_table/DataTable.html) 4 | 5 | A flexible DataTable component for LiveView. 6 | 7 | ![Screenshot of simple DataTable usage](screenshot.png "Simple DataTable usage") 8 | [Source code for the screenshot above. You get all of this in ~50loc.](https://github.com/hansihe/data_table/blob/main/example/lib/example_web/live/articles.ex) 9 | 10 | Some of the features the component has: 11 | * Filtering 12 | * Sorting 13 | * Expandable rows 14 | * Pagination 15 | * Row selection with customizable bulk actions 16 | * Data is fetched from `DataTable.Source` behaviour, usable with custom data sources 17 | * First class Ecto `Source` 18 | * Support for persisting sort/filter state to query string 19 | * Tailwind theme included, but fully customizable 20 | 21 | ```elixir 22 | def render(assigns) do 23 | ~H""" 24 | 27 | 28 | <:col :let={row} name="Id" fields={[:id]} sort_field={:id}> 29 | <%= row.id %> 30 | 31 | 32 | <:col :let={row} name="Name" fields={[:first_name, :last_name]}> 33 | <%= row.first_name <> " " <> row.last_name %> 34 | 35 | 36 | 37 | """ 38 | end 39 | 40 | def mount(_params, _session, socket) do 41 | query = DataTable.Ecto.Query.from( 42 | user in MyApp.User, 43 | fields: %{ 44 | id: user.id, 45 | first_name: user.first_name, 46 | last_name: user.last_name 47 | }, 48 | key: :id 49 | ) 50 | 51 | socket = assign(socket, :source_query, query) 52 | 53 | [...] 54 | end 55 | ``` 56 | 57 | ## Installation 58 | First you need to add `data_table` to your `mix.exs`: 59 | 60 | ```elixir 61 | defp deps do 62 | [ 63 | {:data_table, "~> 1.0"} 64 | ] 65 | end 66 | ``` 67 | 68 | If you want to use the default `Tailwind` theme, you need to set up `tailwind` to include styles 69 | from the `data_table` dependency. 70 | 71 | Add this to the `content` list in your `assets/tailwind.js`: 72 | ```js 73 | "../deps/data_table/**/*.*ex" 74 | ``` 75 | -------------------------------------------------------------------------------- /assets/tailwind.css: -------------------------------------------------------------------------------- 1 | .custom-checkbox-check-bg { 2 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E"); 3 | } 4 | 5 | .custom-checkbox-dash-bg { 6 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='9.0708666' height='1.9653543' x='3.4645667' y='7.017323' ry='0.98267716'/%3E%3C/svg%3E"); 7 | } -------------------------------------------------------------------------------- /example/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | subdirectories: ["priv/*/migrations"], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] 6 | ] 7 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | example-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | 31 | # Ignore digested assets cache. 32 | /priv/static/cache_manifest.json 33 | 34 | # In case you use Node.js/npm, you want to ignore these. 35 | npm-debug.log 36 | /assets/node_modules/ 37 | 38 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | To start your Phoenix server: 4 | 5 | * Run `mix setup` to install and setup dependencies 6 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 7 | 8 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 9 | 10 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 11 | 12 | ## Learn more 13 | 14 | * Official website: https://www.phoenixframework.org/ 15 | * Guides: https://hexdocs.pm/phoenix/overview.html 16 | * Docs: https://hexdocs.pm/phoenix 17 | * Forum: https://elixirforum.com/c/phoenix-forum 18 | * Source: https://github.com/phoenixframework/phoenix 19 | -------------------------------------------------------------------------------- /example/assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "../../deps/petal_components/assets/default.css"; 3 | @import "../../../assets/tailwind.css"; 4 | @import "tailwindcss/components"; 5 | @import "tailwindcss/utilities"; 6 | 7 | /* This file is for your main application CSS */ 8 | -------------------------------------------------------------------------------- /example/assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import "phoenix_html" 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import {Socket} from "phoenix" 22 | import {LiveSocket} from "phoenix_live_view" 23 | import topbar from "../vendor/topbar" 24 | 25 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 26 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) 27 | 28 | // Show progress bar on live navigation and form submits 29 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 30 | window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) 31 | window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) 32 | 33 | // connect if there are any LiveViews on the page 34 | liveSocket.connect() 35 | 36 | // expose liveSocket on window for web console debug logs and latency simulation: 37 | // >> liveSocket.enableDebug() 38 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 39 | // >> liveSocket.disableLatencySim() 40 | window.liveSocket = liveSocket 41 | 42 | -------------------------------------------------------------------------------- /example/assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin") 5 | const colors = require("tailwindcss/colors") 6 | const fs = require("fs") 7 | const path = require("path") 8 | 9 | module.exports = { 10 | content: [ 11 | "./js/**/*.js", 12 | "../lib/*_web.ex", 13 | "../lib/*_web/**/*.*ex", 14 | "../../lib/**/*.*ex", 15 | "../deps/petal_components/**/*.*ex" 16 | ], 17 | theme: { 18 | extend: { 19 | colors: { 20 | brand: "#FD4F00", 21 | 22 | primary: colors.blue, 23 | secondary: colors.pink, 24 | success: colors.green, 25 | danger: colors.red, 26 | warning: colors.yellow, 27 | info: colors.sky, 28 | 29 | // Options: slate, gray, zinc, neutral, stone 30 | gray: colors.gray, 31 | } 32 | }, 33 | }, 34 | plugins: [ 35 | require("@tailwindcss/forms"), 36 | // Allows prefixing tailwind classes with LiveView classes to add rules 37 | // only when LiveView classes are applied, for example: 38 | // 39 | //
40 | // 41 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 42 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 43 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 44 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /example/assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 2.0.0, 2023-02-04 4 | * https://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | currentProgress, 39 | showing, 40 | progressTimerId = null, 41 | fadeTimerId = null, 42 | delayTimerId = null, 43 | addEvent = function (elem, type, handler) { 44 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 45 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 46 | else elem["on" + type] = handler; 47 | }, 48 | options = { 49 | autoRun: true, 50 | barThickness: 3, 51 | barColors: { 52 | 0: "rgba(26, 188, 156, .9)", 53 | ".25": "rgba(52, 152, 219, .9)", 54 | ".50": "rgba(241, 196, 15, .9)", 55 | ".75": "rgba(230, 126, 34, .9)", 56 | "1.0": "rgba(211, 84, 0, .9)", 57 | }, 58 | shadowBlur: 10, 59 | shadowColor: "rgba(0, 0, 0, .6)", 60 | className: null, 61 | }, 62 | repaint = function () { 63 | canvas.width = window.innerWidth; 64 | canvas.height = options.barThickness * 5; // need space for shadow 65 | 66 | var ctx = canvas.getContext("2d"); 67 | ctx.shadowBlur = options.shadowBlur; 68 | ctx.shadowColor = options.shadowColor; 69 | 70 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 71 | for (var stop in options.barColors) 72 | lineGradient.addColorStop(stop, options.barColors[stop]); 73 | ctx.lineWidth = options.barThickness; 74 | ctx.beginPath(); 75 | ctx.moveTo(0, options.barThickness / 2); 76 | ctx.lineTo( 77 | Math.ceil(currentProgress * canvas.width), 78 | options.barThickness / 2 79 | ); 80 | ctx.strokeStyle = lineGradient; 81 | ctx.stroke(); 82 | }, 83 | createCanvas = function () { 84 | canvas = document.createElement("canvas"); 85 | var style = canvas.style; 86 | style.position = "fixed"; 87 | style.top = style.left = style.right = style.margin = style.padding = 0; 88 | style.zIndex = 100001; 89 | style.display = "none"; 90 | if (options.className) canvas.classList.add(options.className); 91 | document.body.appendChild(canvas); 92 | addEvent(window, "resize", repaint); 93 | }, 94 | topbar = { 95 | config: function (opts) { 96 | for (var key in opts) 97 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 98 | }, 99 | show: function (delay) { 100 | if (showing) return; 101 | if (delay) { 102 | if (delayTimerId) return; 103 | delayTimerId = setTimeout(() => topbar.show(), delay); 104 | } else { 105 | showing = true; 106 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 107 | if (!canvas) createCanvas(); 108 | canvas.style.opacity = 1; 109 | canvas.style.display = "block"; 110 | topbar.progress(0); 111 | if (options.autoRun) { 112 | (function loop() { 113 | progressTimerId = window.requestAnimationFrame(loop); 114 | topbar.progress( 115 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 116 | ); 117 | })(); 118 | } 119 | } 120 | }, 121 | progress: function (to) { 122 | if (typeof to === "undefined") return currentProgress; 123 | if (typeof to === "string") { 124 | to = 125 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 126 | ? currentProgress 127 | : 0) + parseFloat(to); 128 | } 129 | currentProgress = to > 1 ? 1 : to; 130 | repaint(); 131 | return currentProgress; 132 | }, 133 | hide: function () { 134 | clearTimeout(delayTimerId); 135 | delayTimerId = null; 136 | if (!showing) return; 137 | showing = false; 138 | if (progressTimerId != null) { 139 | window.cancelAnimationFrame(progressTimerId); 140 | progressTimerId = null; 141 | } 142 | (function loop() { 143 | if (topbar.progress("+.1") >= 1) { 144 | canvas.style.opacity -= 0.05; 145 | if (canvas.style.opacity <= 0.05) { 146 | canvas.style.display = "none"; 147 | fadeTimerId = null; 148 | return; 149 | } 150 | } 151 | fadeTimerId = window.requestAnimationFrame(loop); 152 | })(); 153 | }, 154 | }; 155 | 156 | if (typeof module === "object" && typeof module.exports === "object") { 157 | module.exports = topbar; 158 | } else if (typeof define === "function" && define.amd) { 159 | define(function () { 160 | return topbar; 161 | }); 162 | } else { 163 | this.topbar = topbar; 164 | } 165 | }.call(this, window, document)); 166 | -------------------------------------------------------------------------------- /example/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :example, 11 | ecto_repos: [Example.Repo] 12 | 13 | # Configures the endpoint 14 | config :example, ExampleWeb.Endpoint, 15 | url: [host: "localhost"], 16 | render_errors: [ 17 | formats: [html: ExampleWeb.ErrorHTML, json: ExampleWeb.ErrorJSON], 18 | layout: false 19 | ], 20 | pubsub_server: Example.PubSub, 21 | live_view: [signing_salt: "5hEhVJN0"] 22 | 23 | # Configure esbuild (the version is required) 24 | config :esbuild, 25 | version: "0.17.11", 26 | default: [ 27 | args: 28 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 29 | cd: Path.expand("../assets", __DIR__), 30 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 31 | ] 32 | 33 | # Configure tailwind (the version is required) 34 | config :tailwind, 35 | version: "3.2.7", 36 | default: [ 37 | args: ~w( 38 | --config=tailwind.config.js 39 | --input=css/app.css 40 | --output=../priv/static/assets/app.css 41 | ), 42 | cd: Path.expand("../assets", __DIR__) 43 | ] 44 | 45 | # Configures Elixir's Logger 46 | config :logger, :console, 47 | format: "$time $metadata[$level] $message\n", 48 | metadata: [:request_id] 49 | 50 | # Use Jason for JSON parsing in Phoenix 51 | config :phoenix, :json_library, Jason 52 | 53 | # Import environment specific config. This must remain at the bottom 54 | # of this file so it overrides the configuration defined above. 55 | import_config "#{config_env()}.exs" 56 | -------------------------------------------------------------------------------- /example/config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :example, Example.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | hostname: "localhost", 8 | database: "example_dev", 9 | stacktrace: true, 10 | show_sensitive_data_on_connection_error: true, 11 | pool_size: 10 12 | 13 | # For development, we disable any cache and enable 14 | # debugging and code reloading. 15 | # 16 | # The watchers configuration can be used to run external 17 | # watchers to your application. For example, we can use it 18 | # to bundle .js and .css sources. 19 | config :example, ExampleWeb.Endpoint, 20 | # Binding to loopback ipv4 address prevents access from other machines. 21 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 22 | http: [ip: {127, 0, 0, 1}, port: 4000], 23 | check_origin: false, 24 | code_reloader: true, 25 | debug_errors: true, 26 | secret_key_base: "0XKUWaOTmFr9Xj+PwIfnsztz3lVxKOd0uJcxpyZBVcRfPX7YKcbkqqplZYDjpldW", 27 | watchers: [ 28 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 29 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 30 | ], 31 | reloadable_apps: [:example, :data_table] 32 | 33 | # ## SSL Support 34 | # 35 | # In order to use HTTPS in development, a self-signed 36 | # certificate can be generated by running the following 37 | # Mix task: 38 | # 39 | # mix phx.gen.cert 40 | # 41 | # Run `mix help phx.gen.cert` for more information. 42 | # 43 | # The `http:` config above can be replaced with: 44 | # 45 | # https: [ 46 | # port: 4001, 47 | # cipher_suite: :strong, 48 | # keyfile: "priv/cert/selfsigned_key.pem", 49 | # certfile: "priv/cert/selfsigned.pem" 50 | # ], 51 | # 52 | # If desired, both `http:` and `https:` keys can be 53 | # configured to run both http and https servers on 54 | # different ports. 55 | 56 | # Watch static and templates for browser reloading. 57 | config :example, ExampleWeb.Endpoint, 58 | live_reload: [ 59 | patterns: [ 60 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 61 | ~r"lib/example_web/(controllers|live|components)/.*(ex|heex)$", 62 | ~r"../lib/data_table/(tailwind_theme|components|filters).ex" 63 | ] 64 | ] 65 | 66 | # Enable dev routes for dashboard and mailbox 67 | config :example, dev_routes: true 68 | 69 | # Do not include metadata nor timestamps in development logs 70 | config :logger, :console, format: "[$level] $message\n" 71 | 72 | # Set a higher stacktrace during development. Avoid configuring such 73 | # in production as building large stacktraces may be expensive. 74 | config :phoenix, :stacktrace_depth, 20 75 | 76 | # Initialize plugs at runtime for faster development compilation 77 | config :phoenix, :plug_init_mode, :runtime 78 | -------------------------------------------------------------------------------- /example/config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :example, ExampleWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 9 | 10 | # Do not print debug messages in production 11 | config :logger, level: :info 12 | 13 | # Runtime production configuration, including reading 14 | # of environment variables, is done on config/runtime.exs. 15 | -------------------------------------------------------------------------------- /example/config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/example start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :example, ExampleWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_url = 25 | System.get_env("DATABASE_URL") || 26 | raise """ 27 | environment variable DATABASE_URL is missing. 28 | For example: ecto://USER:PASS@HOST/DATABASE 29 | """ 30 | 31 | maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] 32 | 33 | config :example, Example.Repo, 34 | # ssl: true, 35 | url: database_url, 36 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 37 | socket_options: maybe_ipv6 38 | 39 | # The secret key base is used to sign/encrypt cookies and other secrets. 40 | # A default value is used in config/dev.exs and config/test.exs but you 41 | # want to use a different value for prod and you most likely don't want 42 | # to check this value into version control, so we use an environment 43 | # variable instead. 44 | secret_key_base = 45 | System.get_env("SECRET_KEY_BASE") || 46 | raise """ 47 | environment variable SECRET_KEY_BASE is missing. 48 | You can generate one by calling: mix phx.gen.secret 49 | """ 50 | 51 | host = System.get_env("PHX_HOST") || "example.com" 52 | port = String.to_integer(System.get_env("PORT") || "4000") 53 | 54 | config :example, ExampleWeb.Endpoint, 55 | url: [host: host, port: 443, scheme: "https"], 56 | http: [ 57 | # Enable IPv6 and bind on all interfaces. 58 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 59 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 60 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 61 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 62 | port: port 63 | ], 64 | secret_key_base: secret_key_base 65 | 66 | # ## SSL Support 67 | # 68 | # To get SSL working, you will need to add the `https` key 69 | # to your endpoint configuration: 70 | # 71 | # config :example, ExampleWeb.Endpoint, 72 | # https: [ 73 | # ..., 74 | # port: 443, 75 | # cipher_suite: :strong, 76 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 77 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 78 | # ] 79 | # 80 | # The `cipher_suite` is set to `:strong` to support only the 81 | # latest and more secure SSL ciphers. This means old browsers 82 | # and clients may not be supported. You can set it to 83 | # `:compatible` for wider support. 84 | # 85 | # `:keyfile` and `:certfile` expect an absolute path to the key 86 | # and cert in disk or a relative path inside priv, for example 87 | # "priv/ssl/server.key". For all supported SSL configuration 88 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 89 | # 90 | # We also recommend setting `force_ssl` in your endpoint, ensuring 91 | # no data is ever sent via http, always redirecting to https: 92 | # 93 | # config :example, ExampleWeb.Endpoint, 94 | # force_ssl: [hsts: true] 95 | # 96 | # Check `Plug.SSL` for all available options in `force_ssl`. 97 | end 98 | -------------------------------------------------------------------------------- /example/config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :example, Example.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | hostname: "localhost", 12 | database: "example_test#{System.get_env("MIX_TEST_PARTITION")}", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: 10 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :example, ExampleWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "tgE3lrRgBp1S8A2UI5UuallUF+wdkZMRlDzgNOs1pZBxdN6X7ItVbpICIzljwfDn", 21 | server: false 22 | 23 | # Print only warnings and errors during test 24 | config :logger, level: :warning 25 | 26 | # Initialize plugs at runtime for faster test compilation 27 | config :phoenix, :plug_init_mode, :runtime 28 | -------------------------------------------------------------------------------- /example/lib/example.ex: -------------------------------------------------------------------------------- 1 | defmodule Example do 2 | @moduledoc """ 3 | Example keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /example/lib/example/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Telemetry supervisor 12 | ExampleWeb.Telemetry, 13 | # Start the Ecto repository 14 | Example.Repo, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: Example.PubSub}, 17 | # Start the Endpoint (http/https) 18 | ExampleWeb.Endpoint 19 | # Start a worker by calling: Example.Worker.start_link(arg) 20 | # {Example.Worker, arg} 21 | ] 22 | 23 | # See https://hexdocs.pm/elixir/Supervisor.html 24 | # for other strategies and supported options 25 | opts = [strategy: :one_for_one, name: Example.Supervisor] 26 | Supervisor.start_link(children, opts) 27 | end 28 | 29 | # Tell Phoenix to update the endpoint configuration 30 | # whenever the application is updated. 31 | @impl true 32 | def config_change(changed, _new, removed) do 33 | ExampleWeb.Endpoint.config_change(changed, removed) 34 | :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /example/lib/example/model/article.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.Model.Article do 2 | use Ecto.Schema 3 | 4 | schema "articles" do 5 | field :title, :string 6 | field :body, :string 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /example/lib/example/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.Repo do 2 | use Ecto.Repo, 3 | otp_app: :example, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /example/lib/example_web.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use ExampleWeb, :controller 9 | use ExampleWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: ExampleWeb.Layouts] 44 | 45 | import Plug.Conn 46 | 47 | unquote(verified_routes()) 48 | end 49 | end 50 | 51 | def live_view do 52 | quote do 53 | use Phoenix.LiveView, 54 | layout: {ExampleWeb.Layouts, :app} 55 | 56 | unquote(html_helpers()) 57 | end 58 | end 59 | 60 | def live_component do 61 | quote do 62 | use Phoenix.LiveComponent 63 | 64 | unquote(html_helpers()) 65 | end 66 | end 67 | 68 | def html do 69 | quote do 70 | use Phoenix.Component 71 | 72 | # Import convenience functions from controllers 73 | import Phoenix.Controller, 74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 75 | 76 | # Include general helpers for rendering HTML 77 | unquote(html_helpers()) 78 | end 79 | end 80 | 81 | defp html_helpers do 82 | quote do 83 | # HTML escaping functionality 84 | import Phoenix.HTML 85 | # Core UI components and translation 86 | #import ExampleWeb.CoreComponents 87 | 88 | # Shortcut for generating JS commands 89 | alias Phoenix.LiveView.JS 90 | 91 | # Routes generation with the ~p sigil 92 | unquote(verified_routes()) 93 | end 94 | end 95 | 96 | def verified_routes do 97 | quote do 98 | use Phoenix.VerifiedRoutes, 99 | endpoint: ExampleWeb.Endpoint, 100 | router: ExampleWeb.Router, 101 | statics: ExampleWeb.static_paths() 102 | end 103 | end 104 | 105 | @doc """ 106 | When used, dispatch to the appropriate controller/view/etc. 107 | """ 108 | defmacro __using__(which) when is_atom(which) do 109 | apply(__MODULE__, which, []) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /example/lib/example_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.Layouts do 2 | use ExampleWeb, :html 3 | 4 | embed_templates "layouts/*" 5 | end 6 | -------------------------------------------------------------------------------- /example/lib/example_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 |

8 | v<%= Application.spec(:phoenix, :vsn) %> 9 |

10 |
11 | 25 |
26 |
27 |
28 |
29 | <%= @inner_content %> 30 |
31 |
32 | -------------------------------------------------------------------------------- /example/lib/example_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Phoenix Framework"> 8 | <%= assigns[:page_title] || "Example" %> 9 | 10 | 11 | 13 | 14 | 15 | <%= @inner_content %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /example/lib/example_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :example 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_example_key", 10 | signing_salt: "pNHqu0eu", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 15 | 16 | # Serve at "/" the static files from "priv/static" directory. 17 | # 18 | # You should set gzip to true if you are running phx.digest 19 | # when deploying your static files in production. 20 | plug Plug.Static, 21 | at: "/", 22 | from: :example, 23 | gzip: false, 24 | only: ExampleWeb.static_paths() 25 | 26 | # Code reloading can be explicitly enabled under the 27 | # :code_reloader configuration of your endpoint. 28 | if code_reloading? do 29 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 30 | plug Phoenix.LiveReloader 31 | plug Phoenix.CodeReloader 32 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :example 33 | end 34 | 35 | plug Plug.RequestId 36 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 37 | 38 | plug Plug.Parsers, 39 | parsers: [:urlencoded, :multipart, :json], 40 | pass: ["*/*"], 41 | json_decoder: Phoenix.json_library() 42 | 43 | plug Plug.MethodOverride 44 | plug Plug.Head 45 | plug Plug.Session, @session_options 46 | plug ExampleWeb.Router 47 | end 48 | -------------------------------------------------------------------------------- /example/lib/example_web/live/articles.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.ArticlesLive do 2 | use ExampleWeb, :live_view 3 | require DataTable.Ecto.Query 4 | 5 | def render(assigns) do 6 | ~H""" 7 | 10 | 11 | <:col name="Id" fields={[:id]} sort_field={:id} visible={false} :let={row}> 12 | <%= row.id %> 13 | 14 | 15 | <:col name="Title" fields={[:title]} sort_field={:title} filter_field={:title} filter_field_op={:contains} :let={row}> 16 | <%= row.title %> 17 | 18 | 19 | <:row_expanded> 20 |
21 | Expanded 22 |
23 | 24 | 25 | <:selection_action label="Test Action" handle_action={fn -> nil end}/> 26 | 27 |
28 | """ 29 | end 30 | 31 | def mount(_params, _session, socket) do 32 | query = 33 | DataTable.Ecto.Query.from( 34 | article in Example.Model.Article, 35 | fields: %{ 36 | id: article.id, 37 | title: article.title, 38 | body: article.body 39 | }, 40 | key: :id, 41 | filters: %{ 42 | id: :integer, 43 | title: :string 44 | } 45 | ) 46 | 47 | socket = 48 | assign(socket, %{ 49 | source_query: query 50 | }) 51 | 52 | {:ok, socket} 53 | end 54 | 55 | # handle_nav={&send(self(), {:nav, &1})} 56 | # nav={@nav}> 57 | 58 | # def handle_info({:nav, nav}, socket) do 59 | # query = DataTable.NavState.encode_query_string(nav) 60 | # socket = 61 | # socket 62 | # |> push_patch(to: "/?" <> query, replace: true) 63 | # |> assign(:nav, nav) 64 | # {:noreply, socket} 65 | # end 66 | 67 | # def handle_params(_params, uri, socket) do 68 | # %URI{query: query} = URI.parse(uri) 69 | # IO.inspect(query) 70 | # nav = DataTable.NavState.decode_query_string(query) 71 | # IO.inspect(nav) 72 | # socket = assign(socket, :nav, nav) 73 | # {:noreply, socket} 74 | # end 75 | end 76 | -------------------------------------------------------------------------------- /example/lib/example_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.Router do 2 | use ExampleWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, html: {ExampleWeb.Layouts, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", ExampleWeb do 18 | pipe_through :browser 19 | 20 | #get "/", PageController, :home 21 | live "/", ArticlesLive 22 | end 23 | 24 | # Other scopes may use custom stacks. 25 | # scope "/api", ExampleWeb do 26 | # pipe_through :api 27 | # end 28 | end 29 | -------------------------------------------------------------------------------- /example/lib/example_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_join.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # Database Metrics 55 | summary("example.repo.query.total_time", 56 | unit: {:native, :millisecond}, 57 | description: "The sum of the other measurements" 58 | ), 59 | summary("example.repo.query.decode_time", 60 | unit: {:native, :millisecond}, 61 | description: "The time spent decoding the data received from the database" 62 | ), 63 | summary("example.repo.query.query_time", 64 | unit: {:native, :millisecond}, 65 | description: "The time spent executing the query" 66 | ), 67 | summary("example.repo.query.queue_time", 68 | unit: {:native, :millisecond}, 69 | description: "The time spent waiting for a database connection" 70 | ), 71 | summary("example.repo.query.idle_time", 72 | unit: {:native, :millisecond}, 73 | description: 74 | "The time the connection spent waiting before being checked out for the query" 75 | ), 76 | 77 | # VM Metrics 78 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 79 | summary("vm.total_run_queue_lengths.total"), 80 | summary("vm.total_run_queue_lengths.cpu"), 81 | summary("vm.total_run_queue_lengths.io") 82 | ] 83 | end 84 | 85 | defp periodic_measurements do 86 | [ 87 | # A module, function and arguments to be invoked periodically. 88 | # This function must call :telemetry.execute/3 and a metric must be added above. 89 | # {ExampleWeb, :count_users, []} 90 | ] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /example/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | aliases: aliases(), 11 | deps: deps() 12 | ] 13 | end 14 | 15 | # Configuration for the OTP application. 16 | # 17 | # Type `mix help compile.app` for more information. 18 | def application do 19 | [ 20 | mod: {Example.Application, []}, 21 | extra_applications: [:logger, :runtime_tools] 22 | ] 23 | end 24 | 25 | # Specifies your project dependencies. 26 | # 27 | # Type `mix help deps` for examples and options. 28 | defp deps do 29 | [ 30 | {:phoenix, "~> 1.7.6"}, 31 | {:phoenix_ecto, "~> 4.4"}, 32 | {:ecto_sql, "~> 3.10"}, 33 | {:postgrex, ">= 0.0.0"}, 34 | {:phoenix_html, "~> 3.3"}, 35 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 36 | {:phoenix_live_view, "~> 1.0"}, 37 | {:floki, ">= 0.30.0", only: :test}, 38 | {:esbuild, "~> 0.7", runtime: Mix.env() == :dev}, 39 | {:tailwind, "~> 0.2.2", runtime: Mix.env() == :dev}, 40 | {:telemetry_metrics, "~> 0.6"}, 41 | {:telemetry_poller, "~> 1.0"}, 42 | {:jason, "~> 1.2"}, 43 | {:plug_cowboy, "~> 2.5"}, 44 | {:data_table, path: "../"} 45 | ] 46 | end 47 | 48 | # Aliases are shortcuts or tasks specific to the current project. 49 | # For example, to install project dependencies and perform other setup tasks, run: 50 | # 51 | # $ mix setup 52 | # 53 | # See the documentation for `Mix` for more info on aliases. 54 | defp aliases do 55 | [ 56 | setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], 57 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 58 | "ecto.reset": ["ecto.drop", "ecto.setup"], 59 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], 60 | "assets.build": ["tailwind default", "esbuild default"], 61 | "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] 62 | ] 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /example/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, 3 | "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, 4 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 5 | "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, 6 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 7 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 8 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 9 | "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, 10 | "esbuild": {:hex, :esbuild, "0.9.0", "f043eeaca4932ca8e16e5429aebd90f7766f31ac160a25cbd9befe84f2bc068f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b415027f71d5ab57ef2be844b2a10d0c1b5a492d431727f43937adce22ba45ae"}, 11 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 12 | "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, 13 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 14 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 15 | "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, 16 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, 17 | "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, 18 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.0", "2791fac0e2776b640192308cc90c0dbcf67843ad51387ed4ecae2038263d708d", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b3a1fa036d7eb2f956774eda7a7638cf5123f8f2175aca6d6420a7f95e598e1c"}, 19 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.12", "a37134b9bb3602efbfa5a7a8cb51d50e796f7acff7075af9d9796f30de04c66a", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "058e06e59fd38f1feeca59bbf167bec5d44aacd9b745e4363e2ac342ca32e546"}, 20 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 21 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 22 | "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, 23 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"}, 24 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 25 | "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, 26 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, 27 | "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, 28 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 29 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, 30 | "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, 31 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 32 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 33 | } 34 | -------------------------------------------------------------------------------- /example/priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /example/priv/repo/migrations/20230717191810_add_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.Repo.Migrations.AddTables do 2 | use Ecto.Migration 3 | 4 | def up do 5 | create table("articles") do 6 | add :title, :text 7 | add :body, :text 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /example/priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Example.Repo.insert!(%Example.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | 13 | lorem = """ 14 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut sem nulla pharetra diam sit. Lacus laoreet non curabitur gravida arcu. Dolor sit amet consectetur adipiscing elit duis tristique sollicitudin. Augue interdum velit euismod in pellentesque massa placerat duis. Odio ut sem nulla pharetra. Senectus et netus et malesuada fames ac turpis egestas. A arcu cursus vitae congue mauris rhoncus aenean vel elit. Id leo in vitae turpis massa. Ut tristique et egestas quis ipsum suspendisse ultrices. Nibh tortor id aliquet lectus proin nibh nisl condimentum. Senectus et netus et malesuada. 15 | 16 | Sed egestas egestas fringilla phasellus faucibus scelerisque eleifend. Vulputate sapien nec sagittis aliquam malesuada. Lacinia quis vel eros donec ac odio tempor orci. Vestibulum lectus mauris ultrices eros in cursus turpis. Id diam maecenas ultricies mi eget. Et netus et malesuada fames ac turpis. Euismod quis viverra nibh cras pulvinar mattis nunc sed. Ornare aenean euismod elementum nisi quis eleifend quam. Fermentum et sollicitudin ac orci phasellus. Sagittis id consectetur purus ut faucibus pulvinar elementum integer. Sit amet facilisis magna etiam tempor. Viverra suspendisse potenti nullam ac tortor. Facilisi etiam dignissim diam quis enim. Pharetra massa massa ultricies mi quis hendrerit. Amet luctus venenatis lectus magna fringilla. Est lorem ipsum dolor sit amet. Euismod nisi porta lorem mollis aliquam ut porttitor. Enim facilisis gravida neque convallis a cras semper auctor neque. 17 | 18 | Scelerisque eleifend donec pretium vulputate. At auctor urna nunc id. Libero nunc consequat interdum varius sit amet mattis vulputate. Aliquet bibendum enim facilisis gravida neque convallis a cras. Tempus urna et pharetra pharetra massa massa ultricies mi quis. Egestas quis ipsum suspendisse ultrices gravida. Semper viverra nam libero justo laoreet sit amet cursus. Varius sit amet mattis vulputate enim nulla aliquet. Sed risus pretium quam vulputate dignissim suspendisse in est. Imperdiet nulla malesuada pellentesque elit. Mauris pellentesque pulvinar pellentesque habitant morbi tristique. Non pulvinar neque laoreet suspendisse interdum. Feugiat sed lectus vestibulum mattis ullamcorper velit sed ullamcorper. Vivamus at augue eget arcu dictum varius duis at. 19 | 20 | Sit amet justo donec enim diam vulputate ut pharetra. Tempus iaculis urna id volutpat lacus laoreet. Vitae turpis massa sed elementum. At in tellus integer feugiat scelerisque varius. Aliquam eleifend mi in nulla posuere sollicitudin aliquam ultrices. Praesent tristique magna sit amet. Sit amet est placerat in. Adipiscing enim eu turpis egestas pretium. Sed blandit libero volutpat sed cras ornare arcu. Massa ultricies mi quis hendrerit dolor magna. Consectetur purus ut faucibus pulvinar elementum integer enim neque volutpat. Cum sociis natoque penatibus et magnis dis. Amet nisl suscipit adipiscing bibendum est ultricies integer quis. 21 | 22 | Nisi quis eleifend quam adipiscing vitae proin sagittis. Nunc scelerisque viverra mauris in aliquam sem. Massa id neque aliquam vestibulum morbi blandit cursus risus. Nisl pretium fusce id velit. Tincidunt augue interdum velit euismod in pellentesque. Facilisi cras fermentum odio eu feugiat. Elementum nisi quis eleifend quam adipiscing. Lectus nulla at volutpat diam ut venenatis. Luctus venenatis lectus magna fringilla. Etiam tempor orci eu lobortis elementum nibh tellus. Massa tincidunt nunc pulvinar sapien et ligula ullamcorper malesuada proin. Ornare massa eget egestas purus. Egestas fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate. Ipsum faucibus vitae aliquet nec ullamcorper sit amet. Congue eu consequat ac felis donec et. Ultrices sagittis orci a scelerisque purus semper eget duis at. Consectetur adipiscing elit duis tristique sollicitudin nibh sit amet commodo. Nullam non nisi est sit amet facilisis magna etiam tempor. Vel pharetra vel turpis nunc eget lorem dolor sed. Eget arcu dictum varius duis at consectetur lorem. 23 | """ 24 | |> String.trim() 25 | 26 | for num <- 1..100 do 27 | Example.Repo.insert!(%Example.Model.Article{ 28 | title: "Example article #{num}", 29 | body: lorem, 30 | }) 31 | end 32 | -------------------------------------------------------------------------------- /guides/cheatsheets/data_table_component_cheatsheet.cheatmd: -------------------------------------------------------------------------------- 1 | # DataTable Component 2 | 3 | ## Declaring columns 4 | 5 | #### Declaring a column 6 | The `name` attribute is rendered in the header, the contents 7 | are rendered for each row. 8 | 9 | ```elixir 10 | <:col name="Visual name of column"> 11 | HTML content 12 | 13 | ``` 14 | 15 | #### Displaying data 16 | Some `Source`s (like `DataTable.Ecto`) only fetch data which is 17 | requested by visible columns. 18 | 19 | ```elixir 20 | <:col name="Row Id" fields={[:id]} :let={row}> 21 | <%= row.id %> 22 | 23 | ``` 24 | 25 | #### Sorting 26 | You can enable sorting by a source field. Clicking the header will cycle sorting 27 | for this field. 28 | 29 | ```elixir 30 | <:col name="Id" sort_field={:id}> 31 | [...] 32 | ``` 33 | 34 | #### Visibility 35 | Columns are visible by default. Default visibility can be changed with the `visible` attribute. 36 | 37 | ```elixir 38 | <:col name="Id" visible={false}> 39 | [...] 40 | ``` 41 | 42 | ## Row decorations 43 | 44 | #### Expandable rows 45 | ```elixir 46 | <:row_expanded fields={[:id, :name]} :let={row}> 47 | Expanded content, hello <%= row.name %> with id <%= row.id %> 48 | 49 | ``` 50 | When added, rows will get an expand button. 51 | Can only be declared once. 52 | 53 | #### Row buttons 54 | ```elixir 55 | <:row_buttons fields={[:id]} :let={row}> 56 | Inner content 57 | 58 | ``` 59 | The `row_buttons` slot is rendered all the way to the right of each row. 60 | Can only be declared once. 61 | 62 | ## Selections 63 | 64 | #### Selection actions 65 | ```elixir 66 | <:selection_action label="Action name" handle_action={callback}/> 67 | ``` 68 | If specified, a selection box is added to each row. 69 | When rows are selected, a dropdown appears and allows you to trigger selection actions. 70 | -------------------------------------------------------------------------------- /guides/cheatsheets/ecto_source_cheatsheet.cheatmd: -------------------------------------------------------------------------------- 1 | # Ecto Source 2 | -------------------------------------------------------------------------------- /lib/data_table.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable do 2 | @moduledoc """ 3 | DataTable is a flexible and interactive table component for LiveView. 4 | 5 | ```elixir 6 | def render(assigns) do 7 | ~H""" 8 | 11 | 12 | <:col :let={row} name="Id" fields={[:id]} sort_field={:id}> 13 | <%= row.id %> 14 | 15 | 16 | <:col :let={row} name="Name" fields={[:first_name, :last_name]}> 17 | <%= row.first_name <> " " <> row.last_name %> 18 | 19 | 20 | 21 | \""" 22 | end 23 | 24 | def mount(_params, _session, socket) do 25 | query = DataTable.Ecto.Query.from( 26 | user in MyApp.User, 27 | fields: %{ 28 | id: user.id, 29 | first_name: user.first_name, 30 | last_name: user.last_name 31 | }, 32 | key: :id 33 | ) 34 | 35 | socket = assign(socket, :source_query, query) 36 | 37 | [...] 38 | end 39 | ``` 40 | 41 | ## Common Tasks 42 | * [Cheat Sheet for DataTable Component](data_table_component_cheatsheet.html) 43 | * [Using the Ecto source](DataTable.Ecto.html) 44 | * [Setting up query string navigation](DataTable.NavState.html#module-persisting-datatable-state-in-query-string) 45 | 46 | ## Getting started 47 | First you need to add `data_table` to your `mix.exs`: 48 | 49 | ```elixir 50 | defp deps do 51 | [ 52 | {:data_table, "~> 1.0} 53 | ] 54 | end 55 | ``` 56 | 57 | If you want to use the default `Tailwind` theme, you need to set up `tailwind` to include styles 58 | from the `data_table` dependency. 59 | 60 | Add this to the `content` list in your `assets/tailwind.js`: 61 | ``` 62 | "../deps/data_table/**/*.*ex" 63 | ``` 64 | 65 | ## Data model 66 | 67 | Some terms you should know when using the library: 68 | 69 | * Source - A module implementing the DataTable.Source behaviour. A Source 70 | provides data to the DataTable component in a pluggable way. 71 | Examples of built-in sources are: DataTable.Ecto, DataTable.List 72 | * Data Row - A single row of data returned from the Source. 73 | * Data Field - A column of data returned from the Source. Example: In a 74 | database table, this might be a single field like "first_name" or "email". 75 | * Table Column - A column displayed in the table. A Table Field may combine or 76 | transform data from one or more Data Columns. Example: A "full_name" Table Field 77 | might combine "first_name" and "last_name" Data Columns. 78 | 79 | Note: Internally, Data Fields are referred to simply as "fields", while Table Columns 80 | are called "columns". 81 | 82 | To summarize, a *Source* provides *Data Fields* which are then mapped to *Table Columns* 83 | for display in the *DataTable* component. 84 | """ 85 | 86 | use Phoenix.LiveComponent 87 | 88 | attr :theme, :atom, 89 | default: DataTable.Theme.Tailwind, 90 | doc: """ 91 | The theme for the DataTable. Defaults to `DataTable.Theme.Tailwind`, a modern theme 92 | implemented using `tailwind`. 93 | """ 94 | 95 | attr :id, :any, 96 | required: true, 97 | doc: """ 98 | `live_data_table` is a stateful component, and requires an `id`. 99 | See `LiveView.LiveComponent` for more information. 100 | """ 101 | 102 | attr :source, :any, 103 | required: true, 104 | doc: """ 105 | Declares where the `DataTable` should fetch its data from. 106 | 107 | ``` 108 | {source_module :: DataTable.Source.t(), source_config :: any()} 109 | ``` 110 | 111 | `source_module` is a module implementing the `DataTable.Source` behaviour, 112 | `source_config` is the configuration passed to the `DataTable.Source` implementation. 113 | """ 114 | 115 | attr :nav, :any, 116 | doc: """ 117 | Override the navigation state of the table. 118 | Most likely only present when `handle_nav` is also present. 119 | 120 | `nil` will be counted as no change. 121 | """ 122 | 123 | attr :handle_nav, :any, 124 | doc: """ 125 | Called when the navigation state of the table has changed. 126 | If present, the navigation data should be passed back into the `nav` parameter. 127 | """ 128 | 129 | attr :always_columns, :list, 130 | doc: """ 131 | A list of column ids that will always be loaded. 132 | """ 133 | 134 | slot :col, doc: "One `:col` should be sepecified for each potential column in the table" do 135 | attr :name, :string, 136 | required: true, 137 | doc: "Name in column header. Must be unique" 138 | 139 | # default: true 140 | attr :visible, :boolean, 141 | doc: "Default visibility of the column" 142 | 143 | # default: [] 144 | attr :fields, :list, 145 | doc: "List of `field`s that will be queried when this field is visible" 146 | 147 | attr :filter_field, :atom, 148 | doc: """ 149 | If present, cells will have a filter shortcut. The filter shortcut 150 | will apply a filter for the specified field. 151 | """ 152 | # default: :eq 153 | attr :filter_field_op, :atom, 154 | doc: "The filter op type which will be used for the cell filter shortcut" 155 | 156 | attr :sort_field, :atom, 157 | doc: """ 158 | If present, columns will be sortable. The sort will occur on 159 | the specified field. Defaults to the first field in `fields`. 160 | """ 161 | end 162 | 163 | slot :row_expanded, doc: "Markup which will be rendered when a row is expanded" do 164 | # default: [] 165 | attr :fields, :list, 166 | doc: "List of `field`s that will be queried when a row is expanded" 167 | end 168 | 169 | slot :top_right, doc: "Markup in the top right corner of the table" 170 | 171 | slot :row_buttons, doc: "Markup in the rightmost side of each row in the table" do 172 | attr :fields, :list, 173 | doc: "List of `field`s that will be queried when this field is visible" 174 | end 175 | 176 | slot :selection_action do 177 | attr :label, :string, 178 | required: true 179 | attr :handle_action, :any, 180 | required: true 181 | end 182 | 183 | @doc """ 184 | Renders a `DataTable` in a given `LiveView` as a `LiveComponent`. 185 | 186 | `source` and `id` are required attributes. 187 | """ 188 | @spec live_data_table(assigns :: Socket.assigns()) :: Phoenix.LiveView.Rendered.t() 189 | def live_data_table(assigns) do 190 | ~H""" 191 | <.live_component module={DataTable.LiveComponent} {assigns} /> 192 | """ 193 | end 194 | 195 | end 196 | -------------------------------------------------------------------------------- /lib/data_table/application.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | if Mix.env() == :dev_server do 7 | @dev_server_children [ 8 | DataTableDev.Endpoint 9 | ] 10 | else 11 | @dev_server_children [] 12 | end 13 | 14 | def start(_type, _args) do 15 | children = [] ++ @dev_server_children 16 | Supervisor.start_link(children, strategy: :one_for_one) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/data_table/dev_server.ex: -------------------------------------------------------------------------------- 1 | if Mix.env() == :dev_server do 2 | 3 | Application.put_env(:data_table, DataTableDev.Endpoint, 4 | http: [ip: {127, 0, 0, 1}, port: 4000], 5 | server: true, 6 | live_view: [signing_salt: "aaaaaaaa"], 7 | secret_key_base: String.duplicate("a", 64) 8 | ) 9 | 10 | defmodule SamplePhoenix.ErrorView do 11 | def render(template, _), do: Phoenix.Controller.status_message_from_template(template) 12 | end 13 | 14 | defmodule SamplePhoenix.SampleLive do 15 | use Phoenix.LiveView, layout: {__MODULE__, :live} 16 | 17 | def mount(_params, _session, socket) do 18 | {:oops, assign(socket, :count, 0)} 19 | end 20 | 21 | def render("live.html", assigns) do 22 | ~H""" 23 | 24 | 25 | 29 | 32 | 33 | """ 34 | end 35 | 36 | def render(assigns) do 37 | ~H""" 38 | 39 | 40 | 41 | """ 42 | end 43 | 44 | def handle_event("inc", _params, socket) do 45 | {:noreply, assign(socket, :count, socket.assigns.count + 1)} 46 | end 47 | 48 | def handle_event("dec", _params, socket) do 49 | {:noreply, assign(socket, :count, socket.assigns.count - 1)} 50 | end 51 | end 52 | 53 | defmodule Router do 54 | use Phoenix.Router 55 | import Phoenix.LiveView.Router 56 | 57 | pipeline :browser do 58 | plug(:accepts, ["html"]) 59 | end 60 | 61 | scope "/", SamplePhoenix do 62 | pipe_through(:browser) 63 | 64 | live("/", SampleLive, :index) 65 | end 66 | end 67 | 68 | defmodule DataTableDev.Endpoint do 69 | use Phoenix.Endpoint, otp_app: :data_table 70 | socket("/live", Phoenix.LiveView.Socket) 71 | plug(Router) 72 | end 73 | 74 | #{:ok, _} = Supervisor.start_link([SamplePhoenix.Endpoint], strategy: :one_for_one) 75 | #Process.sleep(:infinity) 76 | 77 | end 78 | -------------------------------------------------------------------------------- /lib/data_table/ecto.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Ecto do 2 | @moduledoc """ 3 | This module implements a `DataTable.Source` which fetches data from 4 | an ecto `Repo`. 5 | 6 | You need to pass it two arguments, a Repo and a Query: 7 | ```elixir 8 | 11 | ``` 12 | 13 | The repo is a normal `Ecto.Repo`, while the query is a query defined 14 | using the DSL in `DataTable.Ecto.Query`. 15 | 16 | You usually create your query in the `mount/3` callback of your `LiveView`: 17 | 18 | ```elixir 19 | def mount(_params, _session, socket) do 20 | query = DataTable.Ecto.Query.from( 21 | user in MyApp.User, 22 | fields: %{ 23 | id: user.id, 24 | first_name: user.first_name, 25 | last_name: user.last_name 26 | }, 27 | key: :id 28 | ) 29 | 30 | socket = assign(socket, :source_query, query) 31 | 32 | [...] 33 | end 34 | ``` 35 | 36 | See `DataTable.Ecto.Query` for a full description of the query DSL. 37 | """ 38 | @behaviour DataTable.Source 39 | 40 | @impl true 41 | def query({repo, query}, query_params) do 42 | require Ecto.Query 43 | 44 | dyn_select = 45 | query_params.fields 46 | |> Enum.map(fn col_id -> 47 | {col_id, Map.fetch!(query.fields, col_id)} 48 | end) 49 | |> Enum.into(%{}) 50 | 51 | base_ecto_query = Enum.reduce(query_params.filters, query.base, fn filter, acc -> 52 | filter_type = Map.fetch!(query.filters, filter.field) 53 | field_dyn = Map.fetch!(query.fields, filter.field) 54 | 55 | value = case filter_type do 56 | :integer -> 57 | {value, ""} = Integer.parse(filter.value) 58 | value 59 | :string -> 60 | filter.value || "" 61 | :boolean -> 62 | filter.value == "true" 63 | end 64 | 65 | where_dyn = case {filter_type, filter.op} do 66 | {_, :eq} -> Ecto.Query.dynamic(^field_dyn == ^value) 67 | {_, :lt} -> Ecto.Query.dynamic(^field_dyn < ^value) 68 | {_, :gt} -> Ecto.Query.dynamic(^field_dyn > ^value) 69 | {:string, :contains} -> Ecto.Query.dynamic(like(^field_dyn, ^"%#{String.replace(value, "%", "\\%")}%")) 70 | end 71 | 72 | Ecto.Query.where(acc, ^where_dyn) 73 | end) 74 | 75 | ecto_query = maybe_apply(base_ecto_query, query_params.sort, fn ecto_query, {field, dir} -> 76 | field_dyn = Map.fetch!(query.fields, field) 77 | Ecto.Query.order_by(ecto_query, ^[{dir, field_dyn}]) 78 | end) 79 | 80 | ecto_query = case query.default_order_by do 81 | [] -> 82 | ecto_query 83 | order_by -> 84 | Ecto.Query.order_by(ecto_query, ^order_by) 85 | end 86 | 87 | # Pagination 88 | ecto_query = 89 | ecto_query 90 | |> maybe_apply(query_params.offset, &Ecto.Query.offset(&1, ^&2)) 91 | |> maybe_apply(query_params.limit, &Ecto.Query.limit(&1, ^&2)) 92 | 93 | ecto_query = Ecto.Query.select(ecto_query, ^dyn_select) 94 | 95 | # we use a subquery to avoid the count query from being affected 96 | # by any group_by clauses in the base query. If the base query has a group_by clause, 97 | # we want to return the number of groups, not the count of each group. 98 | import Ecto.Query 99 | 100 | count_query = from( 101 | subquery in subquery(base_ecto_query), 102 | select: count(subquery) 103 | ) 104 | 105 | results = repo.all(ecto_query) 106 | count = repo.one(count_query) 107 | 108 | %DataTable.Source.Result{ 109 | results: results, 110 | total_results: count 111 | } 112 | end 113 | 114 | @impl true 115 | def filterable_fields({_repo, query}) do 116 | query.filters 117 | |> Enum.map(fn {col_id, type} -> 118 | %{ 119 | col_id: col_id, 120 | type: type 121 | } 122 | end) 123 | end 124 | 125 | @impl true 126 | def filter_types({_repo, _query}) do 127 | %{ 128 | string: %{ 129 | validate: fn _op, _val -> true end, 130 | ops: [ 131 | contains: "contains", 132 | eq: "=" 133 | ] 134 | }, 135 | integer: %{ 136 | validate: fn 137 | _op, nil -> 138 | false 139 | 140 | _op, val -> 141 | case Integer.parse(val) do 142 | {_, ""} -> true 143 | _ -> false 144 | end 145 | end, 146 | ops: [ 147 | eq: "=", 148 | lt: "<", 149 | gt: ">" 150 | ] 151 | }, 152 | boolean: %{ 153 | validate: fn _op, val -> 154 | case val do 155 | "true" -> true 156 | "false" -> true 157 | _ -> false 158 | end 159 | end, 160 | ops: [ 161 | eq: "=" 162 | ] 163 | } 164 | } 165 | end 166 | 167 | @impl true 168 | def key({_repo, query}), do: query.key 169 | 170 | defp maybe_apply(query, nil, _fun) do 171 | query 172 | end 173 | 174 | defp maybe_apply(query, val, fun) do 175 | fun.(query, val) 176 | end 177 | 178 | end 179 | -------------------------------------------------------------------------------- /lib/data_table/ecto/query.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Ecto.Query do 2 | @moduledoc """ 3 | DSL used to declare queries for use with the `DataTable.Ecto` source. 4 | 5 | ```elixir 6 | def mount(_params, _session, socket) do 7 | query = DataTable.Ecto.Query.from( 8 | user in MyApp.User, 9 | fields: %{ 10 | id: user.id, 11 | first_name: user.first_name, 12 | last_name: user.last_name 13 | }, 14 | key: :id, 15 | default_order_by: user.id 16 | ) 17 | 18 | socket = assign(socket, :source_query, query) 19 | 20 | [...] 21 | end 22 | ``` 23 | 24 | For a description of the differences between `Ecto.Query.from/2` and `from/2`, 25 | see the docs of `from/2`. 26 | 27 | ## On joins and complex queries 28 | Since `field`s are only actually requested from the Database when a column 29 | in the `DataTable` actually needs them, you can make your query join several 30 | tables and only pay the price when the columns actually are rendered. 31 | 32 | This is very useful for admin interfaces where you want to make many pieces of 33 | information available, but not necessarily need them shown by default. 34 | 35 | As an example, in a query like: 36 | ```elixir 37 | DataTable.Ecto.Query.from( 38 | article in Model.Article, 39 | left_join: category in assoc(article, :category), 40 | left_join: user in assoc(article, :author), 41 | fields: %{ 42 | title: article.title, 43 | body: article.body, 44 | category_name: category.name, 45 | author_name: author.name 46 | }, 47 | key: :id 48 | ) 49 | ``` 50 | 51 | As long as the columns in your table which use `category` and `author_name` are not 52 | visible, those will not be fetched by the database, and the database will likely not 53 | even bother doing the joins in its query plan. 54 | 55 | The same also applies to subqueries, aggregations, etc. You should not be scared of 56 | including optional columns in your table for admin interfaces. 57 | """ 58 | 59 | defstruct [ 60 | base: nil, 61 | fields: %{}, 62 | key: nil, 63 | filters: [], 64 | default_order_by: nil 65 | ] 66 | 67 | defp unescape_literal(ast, env) do 68 | ast 69 | |> Macro.expand_literals(env) 70 | |> unescape_literal_rec() 71 | end 72 | 73 | defp unescape_literal_rec({:%{}, _opts, kvs}) do 74 | kvs 75 | |> Enum.map(fn {key, value} -> 76 | key = unescape_literal_rec(key) 77 | value = unescape_literal_rec(value) 78 | {key, value} 79 | end) 80 | |> Enum.into(%{}) 81 | end 82 | 83 | defp unescape_literal_rec([_ | _] = list) do 84 | list 85 | |> Enum.map(fn value -> 86 | unescape_literal_rec(value) 87 | end) 88 | end 89 | 90 | defp unescape_literal_rec(term) when is_atom(term), do: term 91 | 92 | 93 | @joins [:join, :inner_join, :cross_join, :cross_lateral_join, :left_join, :right_join, :full_join, 94 | :inner_lateral_join, :left_lateral_join] 95 | 96 | @doc """ 97 | Functions exactly like `Ecto.Query.from/2`, but with some minor differences: 98 | 99 | * `:select` and `:select_merge` are not accepted. 100 | * `:fields` is used instead. 101 | * `:key` is required. `:key` should be the name of a field which uniquely 102 | identifies each row. 103 | * `:order_by` is not accepted, as ordering is determined by the user when 104 | using the table. 105 | * `:default_order_by` can be used to specify a default. 106 | 107 | # Arguments 108 | 109 | ## `:fields` argument 110 | Used to indicate which fields are fetchable by the table. 111 | 112 | Let's compare it to `Ecto.Query.from/2`s `:select`: 113 | * `:fields` fetch data only when the `DataTable` requests the field. 114 | * `:select` always fetches data. 115 | * `:fields` can only be a map as the root. 116 | * `:select` is more flexible with the structures you can return. 117 | 118 | ## `:key` argument 119 | The `:key` argument is always required, and is used to specify a key in 120 | `:fields` which uniquely identitifes the row. 121 | 122 | ## `:default_order_by` argument 123 | Specifies a ordering which is overridden when the `DataTable` explicitly 124 | sets a sort. 125 | """ 126 | defmacro from(expr, kw \\ []) do 127 | require Ecto.Query 128 | 129 | fields = Keyword.fetch!(kw, :fields) 130 | key = Keyword.fetch!(kw, :key) 131 | default_order_by = Keyword.get(kw, :default_order_by) 132 | 133 | filters = 134 | Keyword.fetch!(kw, :filters) 135 | |> unescape_literal(__CALLER__) 136 | 137 | kw = Keyword.drop(kw, [:fields, :key, :filters, :default_order_by]) 138 | 139 | if Keyword.has_key?(kw, :select) do 140 | Ecto.Query.Builder.error!("`:select` key is not supported in `DataTable.Ecto.from/2`. Use `:fields` instead.") 141 | end 142 | if Keyword.has_key?(kw, :select_merge) do 143 | Ecto.Query.Builder.error!("`:select_merge` key is not supported in `DataTable.Ecto.from/2`. Use `:fields` instead.") 144 | end 145 | if Keyword.has_key?(kw, :order_by) do 146 | Ecto.Query.Builder.error!("`:order_by` key will override table sorts when used in a DataTable query. Use `:default_order_by` instead.") 147 | end 148 | 149 | # Binds from base from 150 | {_, binds} = Ecto.Query.Builder.From.escape(expr, __CALLER__) 151 | 152 | # Binds from joins 153 | {binds, _num_binds} = Enum.reduce(kw, {binds, Enum.count(binds)}, fn 154 | {join, join_expr}, {binds, num_binds} when join in @joins -> 155 | {:in, _opts1, [{var, _opts2, nil}, _rhs]} = join_expr 156 | binds = [{var, num_binds} | binds] 157 | num_binds = num_binds + 1 158 | {binds, num_binds} 159 | 160 | _, acc -> 161 | acc 162 | end) 163 | 164 | binds_expr = 165 | binds 166 | |> Enum.sort_by(fn {_var, num} -> num end) 167 | |> Enum.map(fn {var, _num} -> var end) 168 | |> Enum.map(fn var -> {var, [], nil} end) 169 | 170 | fields_dyn_map = process_fields(binds_expr, fields) 171 | order_by = process_order_by(binds_expr, default_order_by) 172 | 173 | quote do 174 | require Ecto.Query 175 | %DataTable.Ecto.Query{ 176 | base: Ecto.Query.from(unquote(expr), unquote(kw)), 177 | fields: unquote(fields_dyn_map), 178 | key: unquote(key), 179 | filters: unquote(Macro.escape(filters)), 180 | default_order_by: unquote(order_by) 181 | } 182 | end 183 | end 184 | 185 | defmacro fields(query, binding \\ [], expr) do 186 | fields = process_fields(binding, expr) 187 | 188 | quote do 189 | fields = unquote(fields) 190 | case unquote(query) do 191 | query = %DataTable.Ecto.Query{fields: nil} -> %{query | fields: fields} 192 | %DataTable.Ecto.Query{} -> raise "`:fields` already set in `DataTable.Ecto.Query`" 193 | query = %Ecto.Query{} -> 194 | %DataTable.Ecto.Query{ 195 | base: query, 196 | fields: fields 197 | } 198 | end 199 | end 200 | end 201 | 202 | defmacro key(query, key_field) do 203 | quote do 204 | key = unquote(key_field) 205 | case unquote(query) do 206 | query = %DataTable.Ecto.Query{key: nil} -> %{query | key: key} 207 | %DataTable.Ecto.Query{} -> raise "`:key` already set in `DataTable.Ecto.Query`" 208 | query = %Ecto.Query{} -> 209 | %DataTable.Ecto.Query{ 210 | base: query, 211 | key: key 212 | } 213 | end 214 | end 215 | end 216 | 217 | defmacro filters(query, filters) do 218 | quote do 219 | filters = unquote(filters) 220 | case unquote(query) do 221 | query = %DataTable.Ecto.Query{filters: []} -> %{query | filters: filters} 222 | %DataTable.Ecto.Query{} -> raise "`:filters` already set in `DataTable.Ecto.Query`" 223 | query = %Ecto.Query{} -> 224 | %DataTable.Ecto.Query{ 225 | base: query, 226 | filters: filters 227 | } 228 | end 229 | end 230 | end 231 | 232 | defp process_fields(binds, fields) do 233 | fields = 234 | case fields do 235 | {:%{}, _opts, kws} -> 236 | Enum.each(kws, fn 237 | {key, _val} when is_atom(key) -> nil 238 | _ -> Ecto.Query.Builder.error!("`:fields` must only contain literal atom keys") 239 | end) 240 | Enum.into(kws, %{}) 241 | 242 | _ -> 243 | Ecto.Query.Builder.error!("`:fields` clause must contain a map") 244 | end 245 | 246 | fields_dyn_list = Enum.map(fields, fn {name, val} -> 247 | dyn_val = quote do 248 | Ecto.Query.dynamic(unquote(binds), unquote(val)) 249 | end 250 | {name, dyn_val} 251 | end) 252 | 253 | {:%{}, [], fields_dyn_list} 254 | end 255 | 256 | @valid_orderings [:asc, :asc_nulls_last, :asc_nulls_first, :desc, :desc_nulls_last, :desc_nulls_first] 257 | 258 | defp process_order_by(_binds, nil) do 259 | quote do 260 | [] 261 | end 262 | end 263 | defp process_order_by(_binds, {:^, _opts, [inner]}) do 264 | inner 265 | end 266 | defp process_order_by(_binds, expr) when is_atom(expr) do 267 | quote do 268 | [{:asc, unquote(expr)}] 269 | end 270 | end 271 | defp process_order_by(binds, expr) when is_list(expr) do 272 | Enum.map(expr, fn 273 | {dir, field} when dir in @valid_orderings and is_atom(field) -> 274 | Macro.escape({dir, field}) 275 | 276 | {dir, val} when dir in @valid_orderings -> 277 | quote do 278 | {unquote(dir), Ecto.Query.dynamic(unquote(binds), unquote(val))} 279 | end 280 | 281 | field when is_atom(field) -> 282 | Macro.escape({:asc, field}) 283 | 284 | val -> 285 | quote do 286 | {:asc, Ecto.Query.dynamic(unquote(binds), unquote(val))} 287 | end 288 | end) 289 | end 290 | defp process_order_by(binds, expr) do 291 | quote do 292 | [{:asc, Ecto.Query.dynamic(unquote(binds), unquote(expr))}] 293 | end 294 | end 295 | 296 | end 297 | -------------------------------------------------------------------------------- /lib/data_table/list.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.List do 2 | @moduledoc """ 3 | DataTable source for a native Elixir list. 4 | """ 5 | 6 | @behaviour DataTable.Source 7 | 8 | alias DataTable.Source.Result 9 | 10 | @impl true 11 | def query({list, config}, query) do 12 | results = 13 | list 14 | |> Stream.with_index() 15 | |> maybe_apply(query.offset, &Stream.drop/2) 16 | |> maybe_apply(query.limit, &Stream.take/2) 17 | |> Enum.map(fn {item, idx} -> config.mapper.(item, idx) end) 18 | 19 | %Result{ 20 | results: results, 21 | total_results: Enum.count(list) 22 | } 23 | end 24 | 25 | @impl true 26 | def filterable_fields({_list, _config}) do 27 | [] 28 | end 29 | 30 | @impl true 31 | def filter_types({_list, _config}) do 32 | %{} 33 | end 34 | 35 | @impl true 36 | def key({_list, config}), do: config.key_field 37 | 38 | defp maybe_apply(query, nil, _fun) do 39 | query 40 | end 41 | 42 | defp maybe_apply(query, val, fun) do 43 | fun.(query, val) 44 | end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /lib/data_table/list/config.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.List.Config do 2 | @moduledoc """ 3 | Configuration for the List source. 4 | """ 5 | 6 | defstruct [ 7 | key_field: :list_index, 8 | mapper: &__MODULE__.default_mapper/2, 9 | ] 10 | 11 | def default_mapper(item, idx) do 12 | Map.put(item, :list_index, idx) 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/data_table/live_component.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.LiveComponent do 2 | @moduledoc false 3 | use Phoenix.LiveComponent 4 | alias DataTable.Util.DataDeps 5 | 6 | alias __MODULE__.Filters 7 | alias __MODULE__.Logic 8 | 9 | @impl true 10 | def render(assigns) do 11 | assigns.theme.root(assigns) 12 | end 13 | 14 | @impl true 15 | def mount(socket) do 16 | socket = 17 | assign(socket, %{ 18 | first: true 19 | }) 20 | 21 | {:ok, socket} 22 | end 23 | 24 | @impl true 25 | def update(assigns, socket) do 26 | first = socket.assigns[:first] != false 27 | 28 | socket = assign(socket, :target, socket.assigns.myself) 29 | 30 | data_deps = 31 | if first do 32 | DataDeps.new(socket) 33 | |> Logic.init() 34 | else 35 | DataDeps.new(socket) 36 | end 37 | 38 | socket = 39 | data_deps 40 | # `assign_input` performs change tracking, which means 41 | # that the fields will only be marked as changed if they 42 | # actually are. 43 | # This changed mark is what drives updates throughout the 44 | # `compute` function, logic will only be run if relevant 45 | # inputs have changed. 46 | |> DataDeps.assign_input(:id, assigns.id) 47 | |> DataDeps.assign_input(:source, assigns.source) 48 | |> DataDeps.assign_input(:theme, assigns.theme) 49 | |> DataDeps.assign_input(:col, assigns.col) 50 | |> DataDeps.assign_input(:selection_action, assigns.selection_action) 51 | |> DataDeps.assign_input(:row_expanded, assigns.row_expanded) 52 | |> DataDeps.assign_input(:row_buttons, assigns.row_buttons) 53 | |> DataDeps.assign_input(:top_right, assigns.top_right) 54 | |> DataDeps.assign_input(:always_columns, assigns[:always_columns] || []) 55 | |> DataDeps.assign_input(:handle_nav, assigns[:handle_nav]) 56 | |> DataDeps.assign_input(:nav, assigns[:nav]) 57 | |> Logic.compute() 58 | |> DataDeps.finish() 59 | 60 | socket = assign(socket, :first, false) 61 | 62 | {:ok, socket} 63 | end 64 | 65 | @impl true 66 | def handle_event("toggle-field", %{"field" => field}, socket) do 67 | field_data = field_by_str_id(field, socket) 68 | 69 | shown_fields = 70 | if MapSet.member?(socket.assigns.shown_fields, field_data.id) do 71 | MapSet.delete(socket.assigns.shown_fields, field_data.id) 72 | else 73 | MapSet.put(socket.assigns.shown_fields, field_data.id) 74 | end 75 | 76 | socket = 77 | DataDeps.new(socket) 78 | |> DataDeps.assign_input(:shown_fields, shown_fields) 79 | |> Logic.compute() 80 | |> DataDeps.finish() 81 | 82 | {:noreply, socket} 83 | end 84 | 85 | def handle_event("cycle-sort", %{"sort-toggle-id" => field_str}, socket) do 86 | # TODO validate further. Not a security issue, but nice to have. 87 | field = String.to_existing_atom(field_str) 88 | 89 | socket = 90 | DataDeps.new(socket) 91 | |> DataDeps.assign_input(:sort, cycle_sort(socket.assigns.sort, field)) 92 | |> Logic.compute() 93 | |> DataDeps.finish() 94 | 95 | # |> dispatch_handle_nav() 96 | 97 | {:noreply, socket} 98 | end 99 | 100 | def handle_event("toggle-expanded", %{"data-id" => data_id}, socket) do 101 | expanded = 102 | if Map.has_key?(socket.assigns.expanded, data_id) do 103 | Map.delete(socket.assigns.expanded, data_id) 104 | else 105 | Map.put(socket.assigns.expanded, data_id, true) 106 | end 107 | 108 | socket = 109 | DataDeps.new(socket) 110 | |> DataDeps.assign_input(:expanded, expanded) 111 | |> Logic.compute() 112 | |> DataDeps.finish() 113 | 114 | {:noreply, socket} 115 | end 116 | 117 | def handle_event("change-page", %{"page" => page}, socket) do 118 | socket = 119 | DataDeps.new(socket) 120 | |> put_page(page) 121 | |> Logic.compute() 122 | |> DataDeps.finish() 123 | 124 | # |> dispatch_handle_nav() 125 | 126 | {:noreply, socket} 127 | end 128 | 129 | def handle_event("toggle-all", _params, socket) do 130 | selection = 131 | case socket.assigns.selection do 132 | {:include, map} when map_size(map) == 0 -> {:exclude, %{}} 133 | {:exclude, map} when map_size(map) == 0 -> {:include, %{}} 134 | _ -> {:exclude, %{}} 135 | end 136 | 137 | socket = 138 | DataDeps.new(socket) 139 | |> DataDeps.assign_input(:selection, selection) 140 | |> Logic.compute() 141 | |> DataDeps.finish() 142 | 143 | {:noreply, socket} 144 | end 145 | 146 | def handle_event("toggle-row", %{"id" => row_id}, socket) do 147 | {row_id, ""} = Integer.parse(row_id) 148 | 149 | selection = 150 | case socket.assigns.selection do 151 | {:include, map = %{^row_id => _}} -> {:include, Map.delete(map, row_id)} 152 | {:include, map} -> {:include, Map.put(map, row_id, nil)} 153 | {:exclude, map = %{^row_id => _}} -> {:exclude, Map.delete(map, row_id)} 154 | {:exclude, map} -> {:exclude, Map.put(map, row_id, nil)} 155 | end 156 | 157 | socket = 158 | DataDeps.new(socket) 159 | |> DataDeps.assign_input(:selection, selection) 160 | |> Logic.compute() 161 | |> DataDeps.finish() 162 | 163 | {:noreply, socket} 164 | end 165 | 166 | def handle_event("selection-action", %{"action-idx" => action_idx}, socket) do 167 | {action_idx, ""} = Integer.parse(action_idx) 168 | %{action_fn: action_fn} = Enum.fetch!(socket.assigns.selection_actions, action_idx) 169 | 170 | selection = socket.assigns.selection 171 | action_fn.(selection) 172 | 173 | socket = 174 | DataDeps.new(socket) 175 | # TODO clear selection? 176 | # |> DataDeps.assign_input(:selection, selection) 177 | |> Logic.compute() 178 | |> DataDeps.finish() 179 | 180 | {:noreply, socket} 181 | end 182 | 183 | def handle_event("filters-change", params, socket) do 184 | filters_changes = params["filters"] || %{} 185 | 186 | changeset = 187 | %Filters{} 188 | |> Filters.changeset(socket.assigns.filter_columns, filters_changes) 189 | 190 | socket = 191 | DataDeps.new(socket) 192 | |> DataDeps.assign_input(:filters_changeset, changeset) 193 | |> Logic.compute() 194 | |> DataDeps.finish() 195 | 196 | # |> dispatch_handle_nav() 197 | 198 | {:noreply, socket} 199 | end 200 | 201 | defp cycle_sort(sort_state, field) do 202 | case sort_state do 203 | {^field, :asc} -> {field, :desc} 204 | {^field, :desc} -> nil 205 | _ -> {field, :asc} 206 | end 207 | end 208 | 209 | defp put_page(state, page) when is_binary(page) do 210 | {page, ""} = Integer.parse(page) 211 | DataDeps.assign_input(state, :page, page) 212 | end 213 | 214 | defp put_page(state, page) when is_integer(page) do 215 | DataDeps.assign_input(state, :page, page) 216 | end 217 | 218 | defp field_by_str_id(str_id, socket) do 219 | id = Map.fetch!(socket.assigns.field_id_by_str_id, str_id) 220 | Map.fetch!(socket.assigns.field_by_id, id) 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /lib/data_table/live_component/filters.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.LiveComponent.Filters do 2 | @moduledoc false 3 | 4 | use Ecto.Schema 5 | import Ecto.Changeset 6 | 7 | embedded_schema do 8 | embeds_many :filters, Filter, on_replace: :delete do 9 | @moduledoc false 10 | 11 | field :field, :string 12 | field :op, :string 13 | field :value, :string 14 | end 15 | end 16 | 17 | def changeset(data, filter_columns, attrs) do 18 | data 19 | |> cast(attrs, []) 20 | |> cast_embed( 21 | :filters, 22 | with: &filter_changeset(&1, filter_columns, &2), 23 | sort_param: :filters_sort, 24 | drop_param: :filters_drop 25 | ) 26 | end 27 | 28 | def filter_changeset(data, filter_columns, attrs) do 29 | data 30 | |> cast(attrs, [:field, :op, :value]) 31 | |> validate_required([:field, :op]) 32 | |> validate_inclusion(:field, Map.keys(filter_columns)) 33 | |> map_valid(fn c -> 34 | field = fetch_field!(c, :field) 35 | col_opts = Map.fetch!(filter_columns, field) 36 | validate_inclusion(c, :op, col_opts.ops_order) 37 | end) 38 | |> map_valid(fn c -> 39 | field = fetch_field!(c, :field) 40 | op = fetch_field!(c, :op) 41 | value = fetch_field!(c, :value) 42 | 43 | op_opts = Map.fetch!(filter_columns, field) 44 | case op_opts.validate.(op, value) do 45 | true -> 46 | c 47 | 48 | false -> 49 | Ecto.Changeset.add_error(c, :value, "is not valid") 50 | end 51 | end) 52 | end 53 | 54 | def map_valid(changeset, mapper) do 55 | case changeset.valid? do 56 | true -> mapper.(changeset) 57 | false -> changeset 58 | end 59 | end 60 | 61 | end 62 | -------------------------------------------------------------------------------- /lib/data_table/live_component/logic.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.LiveComponent.Logic do 2 | @moduledoc false 3 | 4 | # This module contains all the business logic for the data table. 5 | # All of this is built on top of the `DataDeps` data structure. 6 | 7 | # Inputs to the `DataDeps` structure are change tracked, and any 8 | # derived calculations are only executed if any of its predecessors 9 | # are marked as changed. Notably: 10 | # * Inputs `assign_input` are change tracked with equality. 11 | # * Derived computations `assign_derive` are always marked 12 | # as changed when any inputs change. 13 | # This gives us a middle ground between calculating everything on 14 | # every small change vs spending unneeded cycles doing equality checks. 15 | 16 | # For `to_form` 17 | use Phoenix.LiveComponent 18 | 19 | alias DataTable.Util.DataDeps 20 | alias DataTable.LiveComponent.Filters 21 | 22 | @doc """ 23 | This should be called once at beginning of the component lifecycle 24 | to initialize state. `compute/1` MUST be called on the `data_deps` 25 | after. 26 | """ 27 | def init(data_deps) do 28 | data_deps 29 | |> DataDeps.assign_input(:selection, {:include, %{}}) 30 | |> DataDeps.assign_input(:expanded, %{}) 31 | |> DataDeps.assign_input(:sort, nil) 32 | |> DataDeps.assign_input(:page, 0) 33 | |> DataDeps.assign_input(:page_size, 20) 34 | # TODO this can be preinitialized to prevent update cycle on mount. 35 | |> DataDeps.assign_input(:dispatched_nav, nil) 36 | end 37 | 38 | @doc """ 39 | Performs computation of state which has changed. 40 | """ 41 | def compute(data_deps) do 42 | data_deps 43 | # Config phase computes any data which is derived from 44 | # any "config" component assigns. 45 | # Expectation is generally that this does not change that 46 | # often during normal operation. 47 | |> compute_config_phase() 48 | # The early NAV phase synchronizes the DataTable state with 49 | # what is provided by the nav assign input. 50 | # If input NAV does not differ from internal state, no changes 51 | # will be marked, which prevents state change loops. 52 | |> compute_early_nav_phase() 53 | # The UI phase handles any derived data from UI interactions. 54 | |> compute_ui_phase() 55 | # The query phase performs the source query to fetch data 56 | # the table needs, then computes any state derived from that. 57 | |> compute_query_phase() 58 | # The late NAV phase is responsible for dispatching any potential 59 | # changed NAV state to the user provided NAV state handler. 60 | |> compute_late_nav_phase() 61 | end 62 | 63 | defp compute_early_nav_phase(data_deps) do 64 | data_deps 65 | # This relies on the fact that this will only be executed when 66 | # `nav` actually changes. 67 | # If this was executed every time, then user changes would get 68 | # overridden. 69 | |> DataDeps.assign_derive([:nav], [:filter_columns, :dispatched_nav], fn 70 | data = %{nav: nav} when nav != nil -> 71 | nav = data.nav 72 | 73 | out = %{} 74 | 75 | out = 76 | if MapSet.member?(nav.set, :filters) do 77 | changes = %{ 78 | "filters" => 79 | nav.filters 80 | |> Enum.map(fn {field, op, value} -> 81 | %{"field" => field, "op" => op, "value" => value} 82 | end) 83 | } 84 | 85 | changeset = Filters.changeset(%Filters{}, data.filter_columns, changes) 86 | Map.put(out, :filters_changeset, changeset) 87 | else 88 | out 89 | end 90 | 91 | out = 92 | if MapSet.member?(nav.set, :sort) do 93 | Map.put(out, :sort, nav.sort) 94 | else 95 | out 96 | end 97 | 98 | out = 99 | if MapSet.member?(nav.set, :page) do 100 | Map.put(out, :page, nav.page) 101 | else 102 | out 103 | end 104 | 105 | out 106 | 107 | _ -> 108 | %{} 109 | end) 110 | end 111 | 112 | defp compute_late_nav_phase(data_deps) do 113 | data_deps 114 | |> DataDeps.assign_derive( 115 | [:filters_changeset, :sort, :page], 116 | [:dispatched_nav, :handle_nav], 117 | fn data -> 118 | raw_filters = Ecto.Changeset.apply_changes(data.filters_changeset) 119 | 120 | new_nav = %DataTable.NavState{ 121 | filters: 122 | Enum.map(raw_filters.filters, fn %{field: field, op: op, value: value} -> 123 | {field, op, value} 124 | end), 125 | sort: data.sort, 126 | page: data.page 127 | } 128 | 129 | if new_nav != data.dispatched_nav do 130 | if data.handle_nav do 131 | data.handle_nav.(new_nav) 132 | end 133 | 134 | %{ 135 | dispatched_nav: new_nav 136 | } 137 | else 138 | %{} 139 | end 140 | end 141 | ) 142 | end 143 | 144 | defp compute_config_phase(data_deps) do 145 | data_deps 146 | # Callbacks in the `Source` module provides configuration data 147 | # which a lot of other state in the view is derived from. 148 | # This config is assumed to be static, and will only be obtained 149 | # once at the beginning of the view. 150 | # A source change will trigger changes and recomputations for most 151 | # other state as well. 152 | |> DataDeps.assign_derive([:source], fn fields -> 153 | filterable_columns = DataTable.Source.filterable_fields(fields.source) 154 | filter_types = DataTable.Source.filter_types(fields.source) 155 | id_field = DataTable.Source.key(fields.source) 156 | 157 | %{ 158 | id_field: id_field, 159 | filter_column_order: 160 | Enum.map(filterable_columns, fn data -> 161 | Atom.to_string(data.col_id) 162 | end), 163 | filter_columns: 164 | Enum.into( 165 | Enum.map(filterable_columns, fn data -> 166 | id_str = Atom.to_string(data.col_id) 167 | 168 | out = %{ 169 | id: id_str, 170 | name: id_str, 171 | type_name: data.type, 172 | validate: filter_types[data.type].validate, 173 | ops_order: 174 | Enum.map(filter_types[data.type].ops, fn {id, _name} -> 175 | Atom.to_string(id) 176 | end), 177 | ops: 178 | Enum.into( 179 | Enum.map(filter_types[data.type].ops, fn {id, name} -> 180 | id_str = Atom.to_string(id) 181 | 182 | out = %{ 183 | id: id_str, 184 | name: name 185 | } 186 | 187 | {id_str, out} 188 | end), 189 | %{} 190 | ) 191 | } 192 | 193 | {id_str, out} 194 | end), 195 | %{} 196 | ), 197 | filters_fields: 198 | filterable_columns 199 | |> Enum.map(fn col -> 200 | %{ 201 | name: Atom.to_string(col.col_id), 202 | id_str: Atom.to_string(col.col_id) 203 | } 204 | end) 205 | } 206 | end) 207 | # In case of a filter column change, we need to recreate the filter 208 | # changeset. 209 | |> DataDeps.assign_derive([:filter_columns], fn fields -> 210 | # TODO maybe transfer filters? 211 | filters = %Filters{} 212 | filters_changeset = Filters.changeset(filters, fields.filter_columns, %{}) 213 | filters_form = Phoenix.Component.to_form(filters_changeset) 214 | 215 | %{ 216 | filters_changeset: filters_changeset, 217 | filters: filters, 218 | filters_form: filters_form 219 | } 220 | end) 221 | # `selection_action` is a component assign from the user, 222 | # We derive data structures which are better suited for rendering. 223 | |> DataDeps.assign_derive([:selection_action], fn fields -> 224 | selection_actions = 225 | if fields[:selection_action] do 226 | fields.selection_action 227 | |> Enum.map(fn %{label: label, handle_action: action} -> {label, action} end) 228 | |> Enum.with_index() 229 | else 230 | [] 231 | end 232 | 233 | %{ 234 | can_select: selection_actions != [], 235 | selection_actions: 236 | Enum.map(selection_actions, fn {{name, action_fn}, idx} -> 237 | %{ 238 | label: name, 239 | action_idx: idx, 240 | action_fn: action_fn 241 | } 242 | end) 243 | } 244 | end) 245 | # Whether a `row_expanded` component assign is present determines 246 | # if expansion UI will be rendered. 247 | # We derive state for this. 248 | |> DataDeps.assign_derive([:row_expanded], fn fields -> 249 | %{ 250 | can_expand: fields.row_expanded != [], 251 | row_expanded_slot: fields.row_expanded, 252 | expanded_fields: 253 | fields.row_expanded 254 | |> Enum.map(fn re -> Map.get(re, :fields, []) end) 255 | |> Enum.concat() 256 | } 257 | end) 258 | # Whether a `row_buttons` component assign is present determines 259 | # if the buttons row is visible. 260 | # We derive state for this. 261 | |> DataDeps.assign_derive([:row_buttons], fn fields -> 262 | %{ 263 | has_row_buttons: 264 | fields.row_buttons != nil and 265 | fields.row_buttons != [], 266 | row_buttons_slot: fields.row_buttons 267 | } 268 | end) 269 | # We derive a set of columns which are always included in the query. 270 | # This includes: 271 | # * Id field 272 | # * Fields needed for row buttons as these are always visible 273 | # * Columns manually marked as always columns by component assigns 274 | |> DataDeps.assign_derive([:row_buttons, :id_field, :always_columns], fn fields -> 275 | %{ 276 | frame_query_columns: 277 | fields.row_buttons 278 | |> Enum.map(fn rb -> Map.get(rb, :fields, []) end) 279 | |> Enum.concat() 280 | |> Enum.concat(fields.always_columns) 281 | |> Enum.concat([fields.id_field]) 282 | } 283 | end) 284 | # Derive state from the `col` component assign. 285 | # This determines which columns are displayable. 286 | # Derived data includes defaults, field ids, and bidirectional maps. 287 | |> DataDeps.assign_derive([:col], fn data -> 288 | fields = 289 | Enum.map(data.col, fn slot = %{__slot__: :col, fields: fields, name: name} -> 290 | %{ 291 | id: name, 292 | name: name, 293 | columns: fields, 294 | slot: slot, 295 | sort_field: Map.get(slot, :sort_field), 296 | filter_field: Map.get(slot, :filter_field), 297 | filter_field_op: Map.get(slot, :filter_field_op) 298 | } 299 | end) 300 | 301 | %{ 302 | fields: fields, 303 | default_shown_fields: 304 | data.col 305 | |> Enum.map(fn 306 | %{visible: false} -> [] 307 | %{name: name} -> [name] 308 | end) 309 | |> Enum.concat(), 310 | field_id_by_str_id: 311 | fields 312 | |> Enum.map(fn 313 | %{id: id} when is_atom(id) -> {Atom.to_string(id), id} 314 | %{id: id} when is_binary(id) -> {id, id} 315 | end) 316 | |> Enum.into(%{}), 317 | field_by_id: 318 | fields 319 | |> Enum.map(&{&1.id, &1}) 320 | |> Enum.into(%{}) 321 | } 322 | end) 323 | end 324 | 325 | defp compute_query_phase(data_deps) do 326 | data_deps 327 | |> DataDeps.assign_derive( 328 | [ 329 | :source, 330 | :expanded_fields, 331 | :shown_fields, 332 | :fields, 333 | :frame_query_columns, 334 | :filters, 335 | :sort, 336 | :page, 337 | :page_size 338 | ], 339 | fn data -> 340 | expanded_columns = MapSet.new(data.expanded_fields) 341 | 342 | columns = 343 | data.shown_fields 344 | |> Enum.map(&Enum.find(data.fields, fn f -> f.id == &1 end)) 345 | |> Enum.map(& &1.columns) 346 | |> Enum.concat() 347 | |> MapSet.new() 348 | |> MapSet.union(MapSet.new(data.frame_query_columns)) 349 | |> MapSet.union(expanded_columns) 350 | 351 | filters = 352 | data.filters.filters 353 | |> Enum.map(fn f -> 354 | %{ 355 | field: String.to_existing_atom(f.field), 356 | op: String.to_existing_atom(f.op), 357 | value: f.value 358 | } 359 | end) 360 | 361 | query_params = 362 | %DataTable.Source.Query{ 363 | filters: filters, 364 | sort: data.sort, 365 | offset: data.page * data.page_size, 366 | limit: data.page_size, 367 | fields: columns 368 | 369 | # shown_fields: socket.assigns.shown_fields, 370 | } 371 | 372 | %{ 373 | results: results, 374 | total_results: total_results 375 | } = DataTable.Source.query(data.source, query_params) 376 | 377 | # socket = 378 | # if socket.assigns.handle_nav do 379 | # if nav != socket.assigns.nav do 380 | # socket.assigns.handle_nav.(nav) 381 | # end 382 | # socket 383 | # else 384 | # assign(socket, :nav, nav) 385 | # end 386 | 387 | %{ 388 | results: results, 389 | page_results: Enum.count(results), 390 | total_results: total_results, 391 | queried_columns: columns 392 | } 393 | end 394 | ) 395 | |> DataDeps.assign_derive( 396 | [:id_field, :results, :page, :page_size, :total_results, :expanded, :selection], 397 | fn data -> 398 | page_idx = data.page 399 | page_size = data.page_size 400 | total_results = data.total_results 401 | max_page = div(total_results + (page_size - 1), page_size) - 1 402 | 403 | %{ 404 | # Data 405 | rows: 406 | Enum.map(data.results, fn row -> 407 | id = row[data.id_field] 408 | 409 | %{ 410 | id: id, 411 | data: row, 412 | expanded: Map.has_key?(data.expanded, "#{id}"), 413 | selected: 414 | case data.selection do 415 | {:include, %{^id => _}} -> true 416 | {:include, %{}} -> false 417 | {:exclude, %{^id => _}} -> false 418 | {:exclude, %{}} -> true 419 | end 420 | } 421 | end), 422 | 423 | # Pagination 424 | page_start_item: min(page_size * page_idx, total_results), 425 | page_end_item: min(page_size * page_idx + page_size, total_results), 426 | total_results: total_results, 427 | page_max: max_page, 428 | has_prev: page_idx > 0, 429 | has_next: page_idx < max_page 430 | } 431 | end 432 | ) 433 | end 434 | 435 | defp compute_ui_phase(data_deps) do 436 | data_deps 437 | # Reset the shown fields set when defaults change. 438 | # TODO this is wrong right now, as it does not check for any 439 | # actual changes to the defaults. Right now shown fields will reset 440 | # when anything in `col`s change. 441 | |> DataDeps.assign_derive([:default_shown_fields], fn fields -> 442 | %{ 443 | shown_fields: MapSet.new(fields.default_shown_fields) 444 | } 445 | end) 446 | # When selection changes, we need to recompute any derived UI 447 | # state for it. 448 | |> DataDeps.assign_derive([:selection], fn fields -> 449 | %{ 450 | has_selection: fields.selection != {:include, %{}}, 451 | header_selection: 452 | case fields.selection do 453 | {:include, map} when map_size(map) == 0 -> false 454 | {:exclude, map} when map_size(map) == 0 -> true 455 | _ -> :dash 456 | end 457 | } 458 | end) 459 | |> DataDeps.assign_derive([:fields, :shown_fields, :sort], fn fields -> 460 | %{ 461 | header_fields: 462 | fields.fields 463 | |> Enum.filter(&MapSet.member?(fields.shown_fields, &1.id)) 464 | |> Enum.map(fn field -> 465 | filter_field = field.filter_field 466 | filter_field_op = field.filter_field_op 467 | sort_field = field.sort_field 468 | 469 | %{ 470 | name: field.name, 471 | can_sort: sort_field != nil, 472 | sort: 473 | case fields.sort do 474 | {^sort_field, :asc} -> :asc 475 | {^sort_field, :desc} -> :desc 476 | _ -> nil 477 | end, 478 | sort_toggle_id: Atom.to_string(field.sort_field), 479 | can_filter: filter_field != nil, 480 | filter_field_id: Atom.to_string(filter_field), 481 | filter_field_op_id: Atom.to_string(filter_field_op) 482 | } 483 | end), 484 | togglable_fields: 485 | Enum.map(fields.fields, fn field -> 486 | {field.name, id_to_string(field.id), MapSet.member?(fields.shown_fields, field.id)} 487 | end), 488 | field_slots: 489 | fields.fields 490 | |> Enum.filter(&MapSet.member?(fields.shown_fields, &1.id)) 491 | |> Enum.map(& &1.slot) 492 | } 493 | end) 494 | # Apply filters changeset and validate. 495 | |> DataDeps.assign_derive( 496 | [:filters_changeset, :filter_column_order, :filter_columns], 497 | fn fields -> 498 | changeset = fields.filters_changeset 499 | 500 | changeset = 501 | if not Enum.empty?(fields.filter_column_order) do 502 | add_filter_defaults(changeset, fields) 503 | else 504 | changeset 505 | end 506 | 507 | case Ecto.Changeset.apply_action(changeset, :insert) do 508 | {:ok, data} -> 509 | %{ 510 | filters: data, 511 | filters_changeset: changeset, 512 | filters_form: to_form(changeset) 513 | } 514 | 515 | {:error, changeset} -> 516 | %{ 517 | filters_changeset: changeset, 518 | filters_form: to_form(changeset) 519 | } 520 | end 521 | end 522 | ) 523 | end 524 | 525 | defp add_filter_defaults(changeset, assigns) do 526 | data = Ecto.Changeset.apply_changes(changeset) 527 | 528 | first_field = hd(assigns.filter_column_order) 529 | first_op = hd(assigns.filter_columns[first_field].ops_order) 530 | 531 | changes = %{ 532 | "filters" => 533 | Enum.map(data.filters, fn filter -> 534 | %{ 535 | "field" => filter.field || first_field, 536 | "op" => filter.op || first_op, 537 | "value" => filter.value || "" 538 | } 539 | end) 540 | } 541 | 542 | Filters.changeset(changeset, assigns.filter_columns, changes) 543 | end 544 | 545 | defp id_to_string(id) when is_binary(id), do: id 546 | 547 | def field_by_str_id(str_id, socket) do 548 | id = Map.fetch!(socket.assigns.field_id_by_str_id, str_id) 549 | Map.fetch!(socket.assigns.field_by_id, id) 550 | end 551 | end 552 | -------------------------------------------------------------------------------- /lib/data_table/nav_state.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.NavState do 2 | @moduledoc """ 3 | The `NavState` struct contains the navigation state of a `DataTable`. 4 | 5 | The `NavState` can be optionally serialized and deserialized to a query string. 6 | 7 | Contains the following pieces of UI state: 8 | * Current page 9 | * Active sort 10 | * Active filters 11 | 12 | ## Persisting DataTable state in query string 13 | We need to do 2 things: 14 | * 1. Decode and forward the query string to our `LiveData` 15 | * 2. Apply changes to `NavState` to the query string of the `LiveView` 16 | 17 | ### 1. Query string -> `DataTable` 18 | We start by implementing the `handle_params/3` callback in our `LiveView`. 19 | 20 | This is called whenever the URI changes, and we use it to catch query 21 | string changes. 22 | 23 | ```elixir 24 | def handle_params(_params, uri, socket) do 25 | %URI{query: query} = URI.parse(uri) 26 | nav = DataTable.NavState.decode_query_string(query) 27 | socket = assign(socket, :nav, nav) 28 | {:noreply, socket} 29 | end 30 | ``` 31 | 32 | The decoded `NavState` is assigned to the `:nav` assign, which we need to 33 | forward to our `DataTable`. 34 | 35 | ```elixir 36 | 39 | ``` 40 | 41 | At this point you should be able to add a query string to your liveview 42 | (like `?page=5`), and see it being applied to the `DataTable` on load, 43 | but the query string will not yet update on changes. 44 | 45 | ### 2. `NavState` -> query string 46 | The `handle_nav` callback is called whenever the nav state of the `DataTable` 47 | changes. Here we use it to send a message to our LiveView. 48 | 49 | ```elixir 50 | send(self(), {:nav, nav}) end}/> 54 | ``` 55 | 56 | We also need to handle `{:nav, nav}` message and push the changes to the URL. 57 | 58 | ```elixir 59 | def handle_info({:nav, nav}, socket) do 60 | query = DataTable.NavState.encode_query_string(nav) 61 | socket = 62 | socket 63 | |> push_patch(to: ~p"/my/live/view" <> query, replace: true) 64 | |> assign(:nav, nav) # Important! 65 | {:noreply, socket} 66 | end 67 | ``` 68 | 69 | Notice that we also assign the received `nav` to our `:nav` assign. This is 70 | important so that the latest state is always passed to our `DataTable`. 71 | 72 | At this point you should be able to navigate the DataTable, see the query 73 | string update, and see the changes persist on refresh. 74 | """ 75 | 76 | @type t :: %__MODULE__{} 77 | 78 | defstruct [ 79 | set: MapSet.new([:filters, :sort, :page]), 80 | filters: [], 81 | sort: nil, 82 | page: 0, 83 | ] 84 | 85 | @type kv :: [{key :: String.t(), value :: String.t()}] 86 | 87 | @spec encode(nav_state :: t()) :: kv() 88 | def encode(nav_state) do 89 | filter_params = 90 | nav_state.filters 91 | |> Enum.map(fn {filter, op, value} -> 92 | {"filter[#{filter}]#{op}", value || ""} 93 | end) 94 | 95 | page_params = if nav_state.page == 1 do 96 | [] 97 | else 98 | [{"page", nav_state.page}] 99 | end 100 | 101 | sort_params = case nav_state.sort do 102 | nil -> [] 103 | {field, :asc} -> [{"asc", Atom.to_string(field)}] 104 | {field, :desc} -> [{"desc", Atom.to_string(field)}] 105 | end 106 | 107 | Enum.concat([ 108 | page_params, 109 | sort_params, 110 | filter_params, 111 | ]) 112 | end 113 | 114 | @spec encode_query_string(nav_state :: t()) :: String.t() 115 | def encode_query_string(nav_state) do 116 | nav_state 117 | |> encode() 118 | |> URI.encode_query() 119 | |> case do 120 | "" -> "" 121 | val -> "?" <> val 122 | end 123 | end 124 | 125 | @spec decode(base_nav_state :: t(), components :: kv()) :: t() 126 | def decode(nav_state \\ %__MODULE__{}, query) do 127 | components = 128 | Enum.flat_map(query, fn {k, v} -> 129 | case {k, Regex.run(~r/^filter\[([^\]]+)\](.+)$/, k)} do 130 | {_k, [_, field, op]} -> 131 | [{:filter, {field, op, v}}] 132 | 133 | {"asc", _} -> 134 | field = String.to_existing_atom(v) 135 | [{:sort, {field, :asc}}] 136 | 137 | {"desc", _} -> 138 | field = String.to_existing_atom(v) 139 | [{:sort, {field, :desc}}] 140 | 141 | {"page", _} -> 142 | case Integer.parse(v) do 143 | {page, ""} -> [{:page, page}] 144 | _ -> [] 145 | end 146 | 147 | _ -> [] 148 | end 149 | end) 150 | 151 | Enum.reduce(components, nav_state, fn 152 | {:page, page}, s -> %{s | page: page} 153 | {:sort, sort}, s -> %{s | sort: sort} 154 | {:filter, filter}, s -> 155 | %{ s | 156 | filters: s.filters ++ [filter] 157 | } 158 | end) 159 | end 160 | 161 | @spec decode_query_string(base_nav_state :: t(), query_string :: String.t()) :: t() 162 | def decode_query_string(nav_state \\ %__MODULE__{}, query_string) do 163 | query_string = case query_string do 164 | "?" <> str -> str 165 | str -> str 166 | end 167 | 168 | query = Enum.to_list(URI.query_decoder(query_string || "")) 169 | decode(nav_state, query) 170 | end 171 | 172 | end 173 | -------------------------------------------------------------------------------- /lib/data_table/source.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Source do 2 | alias __MODULE__.{Query, Result} 3 | 4 | @type source_module :: module() 5 | @type source_opts :: any() 6 | 7 | @callback query({source :: source_module(), opts :: source_opts()}, query :: Query.t()) :: Result.t() 8 | @callback filterable_fields({source :: source_module(), opts :: source_opts()}) :: any() 9 | @callback filter_types({source :: source_module(), opts :: source_opts()}) :: any() 10 | @callback key({source :: source_module(), opts :: source_opts()}) :: atom() 11 | 12 | def query({mod, opts}, query_params) do 13 | mod.query(opts, query_params) 14 | end 15 | 16 | def filterable_fields({mod, opts}) do 17 | mod.filterable_fields(opts) 18 | end 19 | 20 | def filter_types({mod, opts}) do 21 | mod.filter_types(opts) 22 | end 23 | 24 | def key({mod, opts}) do 25 | mod.key(opts) 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /lib/data_table/source/query.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Source.Query do 2 | @type t :: %__MODULE__{} 3 | 4 | # @types [ 5 | # number: [:eq, :ne, :lt, :lte, :gt, :gte, :in], 6 | # boolean: [:eq, :ne, :not, :and, :or, :xor], 7 | # string: [:eq, :ne, :lt, :lte, :gt, :gte, :contains, :in, :regex], 8 | # ] 9 | 10 | # @operators [ 11 | # eq: [:expr, :expr], 12 | # ne: [:expr, :expr], 13 | 14 | # lt: [:expr, :expr], 15 | # lte: [:value, :value], 16 | 17 | # gt: [:value, :value], 18 | # gte: [:value, :value], 19 | 20 | # contains: [:value, :value], 21 | # in: [], 22 | # regex: [], 23 | 24 | # not: [], 25 | # and: [], 26 | # or: [], 27 | # xor: [], 28 | # ] 29 | 30 | defstruct [ 31 | fields: [], 32 | filters: [], 33 | sort: nil, 34 | offset: 0, 35 | limit: 10 36 | ] 37 | end 38 | -------------------------------------------------------------------------------- /lib/data_table/source/result.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Source.Result do 2 | @type t :: %__MODULE__{} 3 | 4 | defstruct [ 5 | results: [], 6 | total_results: nil 7 | ] 8 | end 9 | -------------------------------------------------------------------------------- /lib/data_table/theme/basic.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Theme.Basic do 2 | @moduledoc """ 3 | Bare minimum DataTable theme designed with two purposes in mind: 4 | * Serve as a bare minimum example for people wanting to develop their own 5 | * Simple theme for use in tests 6 | 7 | Do not expect anything pretty if you try it yourself. Not expected to 8 | be very usable. 9 | """ 10 | use Phoenix.Component 11 | alias Phoenix.LiveView.JS 12 | 13 | alias DataTable.Theme.Util 14 | 15 | def root(assigns) do 16 | ~H""" 17 |
18 |
19 |
20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 44 | 45 | 46 | 49 | 50 | 51 | 52 | <%= for row <- @rows do %> 53 | 54 | 55 | 58 | 59 | 60 | 64 | 65 | 66 | 69 | 70 | 71 | 76 | 77 | 78 | 79 | 80 | 81 | 84 | 85 | <% end %> 86 | 87 | 88 | 89 | 90 | 118 | 119 | 120 |
25 | <.checkbox state={@header_selection} on_toggle="toggle-all" phx-target={@target}/> 26 | 34 | <%= field.name %> 35 | 36 | 37 | 38 | <%= field.name %> 39 | 40 | v 41 | ^ 42 | 43 | 47 | 48 |
56 | <.checkbox state={row.selected} on_toggle="toggle-row" phx-target={@target} phx-value-id={row.id}/> 57 | 61 | ^ 62 | v 63 | 67 | <%= render_slot(field_slot, row.data) %> 68 | 72 | <%= if @has_row_buttons do %> 73 | <%= render_slot(@row_buttons_slot, row.data) %> 74 | <% end %> 75 |
82 | <%= render_slot(@row_expanded_slot, row.data) %> 83 |
91 |
92 | Showing 93 | <%= @page_start_item %> 94 | to 95 | <%= @page_end_item %> 96 | of 97 | <%= @total_results %> 98 | results 99 |
100 | 101 | 117 |
121 |
122 | """ 123 | end 124 | 125 | attr :state, :atom 126 | attr :on_toggle, :string, default: nil 127 | attr :rest, :global 128 | 129 | def checkbox(assigns) do 130 | ~H""" 131 | <% event_attrs = if @on_toggle == nil do 132 | [] 133 | else 134 | [ 135 | {:"phx-key", "Enter"}, 136 | {:"phx-keydown", @on_toggle}, 137 | {:"phx-click", @on_toggle}, 138 | ] 139 | end %> 140 | 141 | 142 | <%= case @state do %> 143 | <% true -> %> 144 | "X" 145 | <% :dash -> %> 146 | "-" 147 | <% false -> %> 148 | "O" 149 | <% end %> 150 | 151 | """ 152 | end 153 | 154 | end 155 | -------------------------------------------------------------------------------- /lib/data_table/theme/tailwind.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Theme.Tailwind do 2 | @doc """ 3 | A modern data table theme implemented using Tailwind. 4 | 5 | Design inspired by https://www.figma.com/community/file/1021406552622495462/data-table-design-components-free-ui-kit 6 | by HBI Agency and Violetta Nekrasova according to CC BY 4.0. 7 | """ 8 | use Phoenix.Component 9 | alias Phoenix.LiveView.JS 10 | import DataTable.Theme.Tailwind.Components 11 | 12 | alias DataTable.Theme.Util 13 | 14 | # If using this module as the base for your own theme, you may wish to use the 15 | # upstream libraries instead of these vendored versions. 16 | # From `:heroicons` Heroicons 17 | alias DataTable.Theme.Tailwind.Heroicons 18 | # From `:petal_components` PetalComponents.Dropdown` 19 | alias DataTable.Theme.Tailwind.Dropdown 20 | 21 | attr(:size, :atom, default: :small, values: [:small, :medium, :large]) 22 | slot(:icon) 23 | slot(:inner_block, required: true) 24 | 25 | def btn_basic(assigns) do 26 | ~H""" 27 | <% classes = [ 28 | "flex cursor-pointer", 29 | "rounded-lg hover:bg-indigo-50", 30 | "text-zinc-800 hover:text-indigo-600", 31 | (if @size == :small, do: "text-sm px-2 py-1 space-x-1"), 32 | (if @size == :small and @icon != nil, do: "pl-1.5"), 33 | (if @size == :medium, do: ""), 34 | (if @size == :medium and @icon == nil, do: ""), 35 | (if @size == :large, do: ""), 36 | (if @size == :large and @icon == nil, do: "") 37 | ] %> 38 | 39 |
40 | <%= render_slot(@icon) %> 41 |
<%= render_slot(@inner_block) %>
42 |
43 | """ 44 | end 45 | 46 | slot(:inner_block, required: true) 47 | 48 | def btn_icon(assigns) do 49 | ~H""" 50 |
57 | <%= render_slot(@inner_block) %> 58 |
59 | """ 60 | end 61 | 62 | attr(:field, Phoenix.HTML.FormField) 63 | attr(:options, :any) 64 | 65 | def select(assigns) do 66 | ~H""" 67 | 79 | """ 80 | end 81 | 82 | attr(:field, Phoenix.HTML.FormField) 83 | 84 | def text_input(assigns) do 85 | ~H""" 86 | <% has_error = @field.errors != [] %> 87 | 98 | """ 99 | end 100 | 101 | def root(assigns) do 102 | ~H""" 103 |
104 | <.filter_header 105 | filters_form={@filters_form} 106 | can_select={@can_select} 107 | has_selection={@has_selection} 108 | selection_actions={@selection_actions} 109 | target={@target} 110 | top_right_slot={@top_right} 111 | filter_column_order={@filter_column_order} 112 | filter_columns={@filter_columns} 113 | filters_fields={@filters_fields}/> 114 | 115 |
116 |
117 |
118 |
119 | 120 | <.table_header 121 | can_select={@can_select} 122 | header_selection={@header_selection} 123 | target={@target} 124 | can_expand={@can_expand} 125 | row_expanded_slot={@row_expanded} 126 | header_fields={@header_fields} 127 | togglable_fields={@togglable_fields}/> 128 | 129 | <.table_body 130 | rows={@rows} 131 | can_select={@can_select} 132 | field_slots={@field_slots} 133 | has_row_buttons={@has_row_buttons} 134 | row_buttons_slot={@row_buttons_slot} 135 | can_expand={@can_expand} 136 | row_expanded_slot={@row_expanded} 137 | target={@target}/> 138 | 139 | <.table_footer 140 | page_start_item={@page_start_item} 141 | page_end_item={@page_end_item} 142 | total_results={@total_results} 143 | page={@page} 144 | page_size={@page_size} 145 | target={@target} 146 | has_prev={@has_prev} 147 | has_next={@has_next}/> 148 |
149 |
150 |
151 |
152 |
153 |
154 | """ 155 | end 156 | 157 | def filter_header(assigns) do 158 | ~H""" 159 |
160 |
161 |
162 | 163 | 169 | 170 |
171 | 172 | <.filters_form 173 | target={@target} 174 | filters_form={@filters_form} 175 | filter_column_order={@filter_column_order} 176 | filter_columns={@filter_columns} 177 | filters_fields={@filters_fields}/> 178 |
179 | 180 |
181 | <%= if assigns[:top_right_slot] do %> 182 | <%= render_slot(@top_right_slot) %> 183 | <% end %> 184 |
185 |
186 | """ 187 | end 188 | 189 | def table_header(assigns) do 190 | ~H""" 191 | 192 | 193 | 194 | <.checkbox state={@header_selection} on_toggle="toggle-all" phx-target={@target}/> 195 | 196 | 197 | 198 | 199 | 203 | 228 | 229 | 230 | 231 | Buttons 232 |
233 | 234 | <:trigger_element> 235 | 236 | 237 | 238 |
239 |
240 |
241 |
242 | 243 |
244 |
245 |
246 | 247 |
248 |
249 |
250 |
251 |
252 | 253 | 254 | 255 | """ 256 | end 257 | 258 | def table_body(assigns) do 259 | ~H""" 260 | 261 | <%= for row <- @rows do %> 262 | 263 | 264 | <.checkbox state={row.selected} on_toggle="toggle-row" phx-target={@target} phx-value-id={row.id}/> 265 | 266 | 267 | 268 | <% class = if @can_select, do: "ml-5", else: "ml-3" %> 269 | class}/> 270 | class}/> 271 | 272 | 273 | 276 | <%= render_slot(field_slot, row.data) %> 277 | 278 | 279 | 280 | <%= if @has_row_buttons do %> 281 | <%= render_slot(@row_buttons_slot, row.data) %> 282 | <% end %> 283 | 284 | 285 | 286 | 287 | 288 | <%= render_slot(@row_expanded_slot, row.data) %> 289 | 290 | 291 | <% end %> 292 | 293 | """ 294 | end 295 | 296 | def table_footer(assigns) do 297 | ~H""" 298 | 299 | 300 | 301 | 351 | 352 | 353 | 354 | """ 355 | end 356 | 357 | # defp op_options_and_default(_spec, nil), do: {[], ""} 358 | # defp op_options_and_default(spec, field_value) do 359 | # atom_field = String.to_existing_atom(field_value) 360 | # filter_data = Enum.find(spec.filterable_columns, & &1.col_id == atom_field) 361 | 362 | # if filter_data == nil do 363 | # {[], ""} 364 | # else 365 | # type_map = spec.filter_types[filter_data[:type]] || %{} 366 | # ops = type_map[:ops] || [] 367 | # kvs = Enum.map(ops, fn {filter_id, filter_name} -> {filter_name, filter_id} end) 368 | 369 | # default_selected = case ops do 370 | # [] -> "" 371 | # [{id, _} | _] -> id 372 | # end 373 | 374 | # {kvs, default_selected} 375 | # end 376 | # end 377 | 378 | # attr :form, :any 379 | # attr :target, :any 380 | # attr :spec, :any 381 | 382 | # attr :filters_fields, :any 383 | # attr :filterable_fields, :any 384 | 385 | attr(:target, :any) 386 | attr(:filters_form, :any) 387 | attr(:filter_column_order, :any) 388 | attr(:filter_columns, :any) 389 | attr(:filters_fields, :any) 390 | attr(:update_filters, :any) 391 | 392 | def filters_form(assigns) do 393 | ~H""" 394 | <.form for={@filters_form} phx-target={@target} phx-change="filters-change" phx-submit="filters-change" class="py-3 sm:flex items-start"> 395 |

396 | 397 | 398 |

399 | 400 | 401 | 402 |
403 |
404 | <.inputs_for :let={filter} field={@filters_form[:filters]}> 405 |
406 | 411 | 412 | <.select 413 | field={filter[:field]} 414 | options={Enum.map(@filter_column_order, fn id -> {id, @filter_columns[id].name} end)}/> 415 | 416 | <% field_config = @filter_columns[filter[:field].value] %> 417 | <.select 418 | :if={field_config == nil} 419 | field={filter[:op]} 420 | options={[]}/> 421 | <.select 422 | :if={field_config != nil} 423 | field={filter[:op]} 424 | options={Enum.map(field_config.ops_order, fn op_id -> 425 | {op_id, field_config.ops[op_id].name} 426 | end)}/> 427 | 428 | <.text_input field={filter[:value]}/> 429 | 435 |
436 | 437 | 438 |
439 | 448 |
449 |
450 |
451 | 452 | """ 453 | end 454 | end 455 | -------------------------------------------------------------------------------- /lib/data_table/theme/tailwind/components.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Theme.Tailwind.Components do 2 | @moduledoc false 3 | 4 | use Phoenix.Component 5 | 6 | attr :state, :atom 7 | attr :on_toggle, :string, default: nil 8 | attr :rest, :global 9 | 10 | def checkbox(assigns) do 11 | ~H""" 12 | <% base_class = "border-gray-300 border text-primary-700 rounded w-5 h-5 ease-linear transition-all duration-150 cursor-pointer focus:outline focus:outline-offset-2 outline-offset-0" %> 13 | <% class = case @state do 14 | true -> 15 | base_class <> " custom-checkbox-check-bg bg-blue-700" 16 | :dash -> 17 | base_class <> " custom-checkbox-dash-bg bg-blue-700" 18 | false -> 19 | base_class <> " bg-white" 20 | end %> 21 | 22 | <% event_attrs = if @on_toggle == nil do 23 | [] 24 | else 25 | [ 26 | {:"phx-key", "Enter"}, 27 | {:"phx-keydown", @on_toggle}, 28 | {:"phx-click", @on_toggle}, 29 | ] 30 | end %> 31 | 32 |
33 | """ 34 | end 35 | 36 | slot :inner_block 37 | 38 | def table_container(assigns) do 39 | ~H""" 40 |
41 |
42 |
43 |
44 | <%= render_slot(@inner_block) %> 45 |
46 |
47 |
48 |
49 | """ 50 | end 51 | 52 | attr(:id, :any, default: nil) 53 | attr(:name, :any) 54 | attr(:label, :string, default: nil) 55 | attr(:value, :any) 56 | 57 | attr(:type, :string, 58 | default: "text", 59 | values: ~w(checkbox color date datetime-local email file hidden month number password 60 | range radio search select tel text textarea time url week) 61 | ) 62 | 63 | attr(:field, Phoenix.HTML.FormField, 64 | doc: "a form field struct retrieved from the form, for example: @form[:email]" 65 | ) 66 | 67 | attr(:errors, :list, default: []) 68 | attr(:checked, :boolean, doc: "the checked flag for checkbox inputs") 69 | attr(:prompt, :string, default: nil, doc: "the prompt for select inputs") 70 | attr(:options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2") 71 | attr(:multiple, :boolean, default: false, doc: "the multiple flag for select inputs") 72 | 73 | attr(:rest, :global, 74 | include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength 75 | multiple pattern placeholder readonly required rows size step) 76 | ) 77 | 78 | slot(:inner_block) 79 | 80 | def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do 81 | assigns 82 | |> assign(field: nil, id: assigns.id || field.id) 83 | |> assign(:errors, field.errors) 84 | |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end) 85 | |> assign_new(:value, fn -> field.value end) 86 | |> input() 87 | end 88 | 89 | def input(%{type: "checkbox", value: value} = assigns) do 90 | assigns = 91 | assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end) 92 | 93 | ~H""" 94 |
95 | 108 | <.error :for={msg <- @errors}><%= msg %> 109 |
110 | """ 111 | end 112 | 113 | def input(%{type: "select"} = assigns) do 114 | ~H""" 115 |
116 | <.label for={@id}><%= @label %> 117 | 127 | <.error :for={msg <- @errors}><%= msg %> 128 |
129 | """ 130 | end 131 | 132 | def input(%{type: "textarea"} = assigns) do 133 | ~H""" 134 |
135 | <.label for={@id}><%= @label %> 136 | 147 | <.error :for={msg <- @errors}><%= msg %> 148 |
149 | """ 150 | end 151 | 152 | # All other inputs text, datetime-local, url, password, etc. are handled here... 153 | def input(assigns) do 154 | ~H""" 155 |
156 | <.label for={@id}><%= @label %> 157 | 170 | <.error :for={msg <- @errors}><%= msg %> 171 |
172 | """ 173 | end 174 | 175 | @doc """ 176 | Renders a label. 177 | """ 178 | attr(:for, :string, default: nil) 179 | slot(:inner_block, required: true) 180 | 181 | def label(assigns) do 182 | ~H""" 183 | 186 | """ 187 | end 188 | 189 | @doc """ 190 | Generates a generic error message. 191 | """ 192 | slot(:inner_block, required: true) 193 | 194 | def error(assigns) do 195 | ~H""" 196 |

197 | 198 | <%= render_slot(@inner_block) %> 199 |

200 | """ 201 | end 202 | 203 | end 204 | -------------------------------------------------------------------------------- /lib/data_table/theme/tailwind/dropdown.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Theme.Tailwind.Dropdown do 2 | @moduledoc false 3 | 4 | use Phoenix.Component 5 | alias Phoenix.LiveView.JS 6 | alias DataTable.Theme.Tailwind.Heroicons 7 | alias DataTable.Theme.Tailwind.Link 8 | 9 | # Customized from https://github.com/petalframework/petal_components/blob/3dc52043661aa02c554fd6f7b163a851cff88566/lib/petal_components/dropdown.ex 10 | # to simplify dependencies since this is a library. 11 | # License: MIT (https://github.com/petalframework/petal_components/blob/3dc52043661aa02c554fd6f7b163a851cff88566/LICENSE.md) 12 | 13 | @transition_in_base "transition transform ease-out duration-100" 14 | @transition_in_start "transform opacity-0 scale-95" 15 | @transition_in_end "transform opacity-100 scale-100" 16 | 17 | @transition_out_base "transition ease-in duration-75" 18 | @transition_out_start "transform opacity-100 scale-100" 19 | @transition_out_end "transform opacity-0 scale-95" 20 | 21 | attr :options_container_id, :string 22 | attr :label, :string, default: nil, doc: "labels your dropdown option" 23 | attr :class, :any, default: nil, doc: "any extra CSS class for the parent container" 24 | 25 | attr :menu_items_wrapper_class, :any, 26 | default: nil, 27 | doc: "any extra CSS class for menu item wrapper container" 28 | 29 | attr :js_lib, :string, default: "live_view_js" 30 | 31 | attr :placement, :string, default: "left", values: ["left", "right"] 32 | attr :rest, :global 33 | 34 | slot :trigger_element 35 | slot :inner_block, required: false 36 | 37 | @doc """ 38 | <.dropdown label="Dropdown" js_lib="alpine_js|live_view_js"> 39 | <.dropdown_menu_item link_type="button"> 40 | <.icon name="hero-home" class="w-5 h-5 text-gray-500" /> 41 | Button item with icon 42 | 43 | <.dropdown_menu_item link_type="a" to="/" label="a item" /> 44 | <.dropdown_menu_item link_type="a" to="/" disabled label="disabled item" /> 45 | <.dropdown_menu_item link_type="live_patch" to="/" label="Live Patch item" /> 46 | <.dropdown_menu_item link_type="live_redirect" to="/" label="Live Redirect item" /> 47 | 48 | """ 49 | def dropdown(assigns) do 50 | assigns = 51 | assigns 52 | |> assign_new(:options_container_id, fn -> "dropdown_#{Ecto.UUID.generate()}" end) 53 | 54 | ~H""" 55 |
60 |
61 | 82 |
83 | 99 |
100 | """ 101 | end 102 | 103 | attr :to, :string, default: nil, doc: "link path" 104 | attr :label, :string, doc: "link label" 105 | attr :class, :any, default: nil, doc: "any additional CSS classes" 106 | attr :disabled, :boolean, default: false 107 | 108 | attr :link_type, :string, 109 | default: "button", 110 | values: ["a", "live_patch", "live_redirect", "button"] 111 | 112 | attr :rest, :global, include: ~w(method download hreflang ping referrerpolicy rel target type) 113 | slot :inner_block, required: false 114 | 115 | def dropdown_menu_item(assigns) do 116 | ~H""" 117 | 125 | <%= render_slot(@inner_block) || @label %> 126 | 127 | """ 128 | end 129 | 130 | defp trigger_button_classes(nil, []), 131 | do: "flex items-center text-gray-400 rounded-full hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-primary-500" 132 | 133 | defp trigger_button_classes(_label, []), 134 | do: "inline-flex justify-center w-full px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm dark:text-gray-300 dark:bg-gray-900 dark:hover:bg-gray-800 dark:focus:bg-gray-800 hover:bg-gray-50 focus:outline-none" 135 | 136 | defp trigger_button_classes(_label, _trigger_element), 137 | do: "align-middle" 138 | 139 | defp js_attributes("container", "live_view_js", options_container_id) do 140 | hide = 141 | JS.hide( 142 | to: "##{options_container_id}", 143 | transition: {@transition_out_base, @transition_out_start, @transition_out_end} 144 | ) 145 | 146 | %{ 147 | "phx-click-away": hide, 148 | "phx-window-keydown": hide, 149 | "phx-key": "Escape" 150 | } 151 | end 152 | 153 | defp js_attributes("button", "live_view_js", options_container_id) do 154 | %{ 155 | "phx-click": 156 | JS.toggle( 157 | to: "##{options_container_id}", 158 | display: "block", 159 | in: {@transition_in_base, @transition_in_start, @transition_in_end}, 160 | out: {@transition_out_base, @transition_out_start, @transition_out_end} 161 | ) 162 | } 163 | end 164 | 165 | defp js_attributes("options_container", "live_view_js", _options_container_id) do 166 | %{ 167 | style: "display: none;" 168 | } 169 | end 170 | 171 | defp placement_class("left"), do: "right-0 origin-top-right" 172 | defp placement_class("right"), do: "left-0 origin-top-left" 173 | 174 | defp get_disabled_classes(true), do: "text-gray-500 hover:bg-transparent" 175 | defp get_disabled_classes(false), do: "" 176 | end 177 | -------------------------------------------------------------------------------- /lib/data_table/theme/tailwind/heroicons.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Theme.Tailwind.Heroicons do 2 | @moduledoc false 3 | 4 | use Phoenix.Component 5 | 6 | # Vendored from https://github.com/mveytsman/heroicons_elixir/blob/afb57ff3e181fbcf55c68ff64a0e7f1d339a07d4/lib/heroicons.ex 7 | # to simplify dependencies since this is a library. 8 | # License: MIT (https://github.com/mveytsman/heroicons_elixir/blob/afb57ff3e181fbcf55c68ff64a0e7f1d339a07d4/LICENSE) 9 | 10 | defp svg(assigns) do 11 | # Not all styles have the micro attribute 12 | Map.merge(%{micro: false}, assigns) 13 | |> case do 14 | %{mini: false, solid: false, micro: false} -> 15 | ~H"<.svg_outline {@rest}><%= {:safe, @paths[:outline]} %>" 16 | 17 | %{solid: true, mini: false, micro: false} -> 18 | ~H"<.svg_solid {@rest}><%= {:safe, @paths[:solid]} %>" 19 | 20 | %{mini: true, solid: false, micro: false} -> 21 | ~H"<.svg_mini {@rest}><%= {:safe, @paths[:mini]} %>" 22 | 23 | %{micro: true, solid: false, mini: false} -> 24 | ~H"<.svg_micro {@rest}><%= {:safe, @paths[:micro]} %>" 25 | 26 | %{} -> 27 | raise ArgumentError, "expected either mini or solid, but got both." 28 | end 29 | end 30 | 31 | attr :rest, :global, 32 | default: %{ 33 | "aria-hidden": "true", 34 | fill: "none", 35 | viewBox: "0 0 24 24", 36 | "stroke-width": "1.5", 37 | stroke: "currentColor" 38 | } 39 | 40 | slot :inner_block, required: true 41 | 42 | defp svg_outline(assigns) do 43 | ~H""" 44 | 45 | <%= render_slot(@inner_block) %> 46 | 47 | """ 48 | end 49 | 50 | attr :rest, :global, 51 | default: %{"aria-hidden": "true", viewBox: "0 0 24 24", fill: "currentColor"} 52 | 53 | slot :inner_block, required: true 54 | 55 | defp svg_solid(assigns) do 56 | ~H""" 57 | 58 | <%= render_slot(@inner_block) %> 59 | 60 | """ 61 | end 62 | 63 | attr :rest, :global, 64 | default: %{"aria-hidden": "true", viewBox: "0 0 20 20", fill: "currentColor"} 65 | 66 | slot :inner_block, required: true 67 | 68 | defp svg_mini(assigns) do 69 | ~H""" 70 | 71 | <%= render_slot(@inner_block) %> 72 | 73 | """ 74 | end 75 | 76 | attr :rest, :global, 77 | default: %{"aria-hidden": "true", viewBox: "0 0 16 16", fill: "currentColor"} 78 | 79 | slot :inner_block, required: true 80 | 81 | defp svg_micro(assigns) do 82 | ~H""" 83 | 84 | <%= render_slot(@inner_block) %> 85 | 86 | """ 87 | end 88 | 89 | @doc """ 90 | Renders the `chevron_down` icon. 91 | 92 | By default, the outlined (24x24) component is used, but the `solid`, `mini` or `micro` 93 | attributes can be provided for alternative styles. 94 | 95 | You may also pass arbitrary HTML attributes to be applied to the svg tag. 96 | 97 | ## Examples 98 | 99 | ```heex 100 | 101 | 102 | 103 | 104 | 105 | 106 | ``` 107 | """ 108 | attr :rest, :global, 109 | doc: "the arbitrary HTML attributes for the svg container", 110 | include: ~w(fill stroke stroke-width) 111 | 112 | attr :outline, :boolean, default: true 113 | attr :solid, :boolean, default: false 114 | attr :mini, :boolean, default: false 115 | attr :micro, :boolean, default: false 116 | 117 | def chevron_down(assigns) do 118 | svg( 119 | assign(assigns, 120 | paths: %{ 121 | outline: 122 | ~S||, 123 | solid: 124 | ~S||, 125 | mini: 126 | ~S||, 127 | micro: 128 | ~S|| 129 | } 130 | ) 131 | ) 132 | end 133 | 134 | @doc """ 135 | Renders the `chevron_up` icon. 136 | 137 | By default, the outlined (24x24) component is used, but the `solid`, `mini` or `micro` 138 | attributes can be provided for alternative styles. 139 | 140 | You may also pass arbitrary HTML attributes to be applied to the svg tag. 141 | 142 | ## Examples 143 | 144 | ```heex 145 | 146 | 147 | 148 | 149 | 150 | 151 | ``` 152 | """ 153 | attr :rest, :global, 154 | doc: "the arbitrary HTML attributes for the svg container", 155 | include: ~w(fill stroke stroke-width) 156 | 157 | attr :outline, :boolean, default: true 158 | attr :solid, :boolean, default: false 159 | attr :mini, :boolean, default: false 160 | attr :micro, :boolean, default: false 161 | 162 | def chevron_up(assigns) do 163 | svg( 164 | assign(assigns, 165 | paths: %{ 166 | outline: 167 | ~S||, 168 | solid: 169 | ~S||, 170 | mini: 171 | ~S||, 172 | micro: 173 | ~S|| 174 | } 175 | ) 176 | ) 177 | end 178 | 179 | @doc """ 180 | Renders the `chevron_left` icon. 181 | 182 | By default, the outlined (24x24) component is used, but the `solid`, `mini` or `micro` 183 | attributes can be provided for alternative styles. 184 | 185 | You may also pass arbitrary HTML attributes to be applied to the svg tag. 186 | 187 | ## Examples 188 | 189 | ```heex 190 | 191 | 192 | 193 | 194 | 195 | 196 | ``` 197 | """ 198 | attr :rest, :global, 199 | doc: "the arbitrary HTML attributes for the svg container", 200 | include: ~w(fill stroke stroke-width) 201 | 202 | attr :outline, :boolean, default: true 203 | attr :solid, :boolean, default: false 204 | attr :mini, :boolean, default: false 205 | attr :micro, :boolean, default: false 206 | 207 | def chevron_left(assigns) do 208 | svg( 209 | assign(assigns, 210 | paths: %{ 211 | outline: 212 | ~S||, 213 | solid: 214 | ~S||, 215 | mini: 216 | ~S||, 217 | micro: 218 | ~S|| 219 | } 220 | ) 221 | ) 222 | end 223 | 224 | @doc """ 225 | Renders the `chevron_right` icon. 226 | 227 | By default, the outlined (24x24) component is used, but the `solid`, `mini` or `micro` 228 | attributes can be provided for alternative styles. 229 | 230 | You may also pass arbitrary HTML attributes to be applied to the svg tag. 231 | 232 | ## Examples 233 | 234 | ```heex 235 | 236 | 237 | 238 | 239 | 240 | 241 | ``` 242 | """ 243 | attr :rest, :global, 244 | doc: "the arbitrary HTML attributes for the svg container", 245 | include: ~w(fill stroke stroke-width) 246 | 247 | attr :outline, :boolean, default: true 248 | attr :solid, :boolean, default: false 249 | attr :mini, :boolean, default: false 250 | attr :micro, :boolean, default: false 251 | 252 | def chevron_right(assigns) do 253 | svg( 254 | assign(assigns, 255 | paths: %{ 256 | outline: 257 | ~S||, 258 | solid: 259 | ~S||, 260 | mini: 261 | ~S||, 262 | micro: 263 | ~S|| 264 | } 265 | ) 266 | ) 267 | end 268 | 269 | @doc """ 270 | Renders the `funnel` icon. 271 | 272 | By default, the outlined (24x24) component is used, but the `solid`, `mini` or `micro` 273 | attributes can be provided for alternative styles. 274 | 275 | You may also pass arbitrary HTML attributes to be applied to the svg tag. 276 | 277 | ## Examples 278 | 279 | ```heex 280 | 281 | 282 | 283 | 284 | 285 | 286 | ``` 287 | """ 288 | attr :rest, :global, 289 | doc: "the arbitrary HTML attributes for the svg container", 290 | include: ~w(fill stroke stroke-width) 291 | 292 | attr :outline, :boolean, default: true 293 | attr :solid, :boolean, default: false 294 | attr :mini, :boolean, default: false 295 | attr :micro, :boolean, default: false 296 | 297 | def funnel(assigns) do 298 | svg( 299 | assign(assigns, 300 | paths: %{ 301 | outline: 302 | ~S||, 303 | solid: 304 | ~S||, 305 | mini: 306 | ~S||, 307 | micro: 308 | ~S|| 309 | } 310 | ) 311 | ) 312 | end 313 | 314 | @doc """ 315 | Renders the `trash` icon. 316 | 317 | By default, the outlined (24x24) component is used, but the `solid`, `mini` or `micro` 318 | attributes can be provided for alternative styles. 319 | 320 | You may also pass arbitrary HTML attributes to be applied to the svg tag. 321 | 322 | ## Examples 323 | 324 | ```heex 325 | 326 | 327 | 328 | 329 | 330 | 331 | ``` 332 | """ 333 | attr :rest, :global, 334 | doc: "the arbitrary HTML attributes for the svg container", 335 | include: ~w(fill stroke stroke-width) 336 | 337 | attr :outline, :boolean, default: true 338 | attr :solid, :boolean, default: false 339 | attr :mini, :boolean, default: false 340 | attr :micro, :boolean, default: false 341 | 342 | def trash(assigns) do 343 | svg( 344 | assign(assigns, 345 | paths: %{ 346 | outline: 347 | ~S||, 348 | solid: 349 | ~S||, 350 | mini: 351 | ~S||, 352 | micro: 353 | ~S|| 354 | } 355 | ) 356 | ) 357 | end 358 | 359 | @doc """ 360 | Renders the `list_bullet` icon. 361 | 362 | By default, the outlined (24x24) component is used, but the `solid`, `mini` or `micro` 363 | attributes can be provided for alternative styles. 364 | 365 | You may also pass arbitrary HTML attributes to be applied to the svg tag. 366 | 367 | ## Examples 368 | 369 | ```heex 370 | 371 | 372 | 373 | 374 | 375 | 376 | ``` 377 | """ 378 | attr :rest, :global, 379 | doc: "the arbitrary HTML attributes for the svg container", 380 | include: ~w(fill stroke stroke-width) 381 | 382 | attr :outline, :boolean, default: true 383 | attr :solid, :boolean, default: false 384 | attr :mini, :boolean, default: false 385 | attr :micro, :boolean, default: false 386 | 387 | def list_bullet(assigns) do 388 | svg( 389 | assign(assigns, 390 | paths: %{ 391 | outline: 392 | ~S||, 393 | solid: 394 | ~S||, 395 | mini: 396 | ~S||, 397 | micro: 398 | ~S|| 399 | } 400 | ) 401 | ) 402 | end 403 | 404 | @doc """ 405 | Renders the `check` icon. 406 | 407 | By default, the outlined (24x24) component is used, but the `solid`, `mini` or `micro` 408 | attributes can be provided for alternative styles. 409 | 410 | You may also pass arbitrary HTML attributes to be applied to the svg tag. 411 | 412 | ## Examples 413 | 414 | ```heex 415 | 416 | 417 | 418 | 419 | 420 | 421 | ``` 422 | """ 423 | attr :rest, :global, 424 | doc: "the arbitrary HTML attributes for the svg container", 425 | include: ~w(fill stroke stroke-width) 426 | 427 | attr :outline, :boolean, default: true 428 | attr :solid, :boolean, default: false 429 | attr :mini, :boolean, default: false 430 | attr :micro, :boolean, default: false 431 | 432 | def check(assigns) do 433 | svg( 434 | assign(assigns, 435 | paths: %{ 436 | outline: 437 | ~S||, 438 | solid: 439 | ~S||, 440 | mini: 441 | ~S||, 442 | micro: 443 | ~S|| 444 | } 445 | ) 446 | ) 447 | end 448 | 449 | @doc """ 450 | Renders the `plus` icon. 451 | 452 | By default, the outlined (24x24) component is used, but the `solid`, `mini` or `micro` 453 | attributes can be provided for alternative styles. 454 | 455 | You may also pass arbitrary HTML attributes to be applied to the svg tag. 456 | 457 | ## Examples 458 | 459 | ```heex 460 | 461 | 462 | 463 | 464 | 465 | 466 | ``` 467 | """ 468 | attr :rest, :global, 469 | doc: "the arbitrary HTML attributes for the svg container", 470 | include: ~w(fill stroke stroke-width) 471 | 472 | attr :outline, :boolean, default: true 473 | attr :solid, :boolean, default: false 474 | attr :mini, :boolean, default: false 475 | attr :micro, :boolean, default: false 476 | 477 | def plus(assigns) do 478 | svg( 479 | assign(assigns, 480 | paths: %{ 481 | outline: 482 | ~S||, 483 | solid: 484 | ~S||, 485 | mini: 486 | ~S||, 487 | micro: 488 | ~S|| 489 | } 490 | ) 491 | ) 492 | end 493 | 494 | @doc """ 495 | Renders the `ellipsis_vertical` icon. 496 | 497 | By default, the outlined (24x24) component is used, but the `solid`, `mini` or `micro` 498 | attributes can be provided for alternative styles. 499 | 500 | You may also pass arbitrary HTML attributes to be applied to the svg tag. 501 | 502 | ## Examples 503 | 504 | ```heex 505 | 506 | 507 | 508 | 509 | 510 | 511 | ``` 512 | """ 513 | attr :rest, :global, 514 | doc: "the arbitrary HTML attributes for the svg container", 515 | include: ~w(fill stroke stroke-width) 516 | 517 | attr :outline, :boolean, default: true 518 | attr :solid, :boolean, default: false 519 | attr :mini, :boolean, default: false 520 | attr :micro, :boolean, default: false 521 | 522 | def ellipsis_vertical(assigns) do 523 | svg( 524 | assign(assigns, 525 | paths: %{ 526 | outline: 527 | ~S||, 528 | solid: 529 | ~S||, 530 | mini: 531 | ~S||, 532 | micro: 533 | ~S|| 534 | } 535 | ) 536 | ) 537 | end 538 | end 539 | -------------------------------------------------------------------------------- /lib/data_table/theme/tailwind/link.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Theme.Tailwind.Link do 2 | @moduledoc false 3 | 4 | use Phoenix.Component 5 | 6 | attr :class, :any, default: nil, doc: "CSS class for link (either a string or list)" 7 | attr :link_type, :string, default: "a", values: ["a", "live_patch", "live_redirect", "button"] 8 | attr :label, :string, default: nil, doc: "label your link" 9 | attr :to, :string, default: nil, doc: "link path" 10 | 11 | attr :disabled, :boolean, 12 | default: false, 13 | doc: "disables the link. This will turn an into a 25 | """ 26 | end 27 | 28 | # Since the tag can't be disabled, we turn it into a disabled button (looks exactly the same and does nothing when clicked) 29 | def a(%{disabled: true, link_type: type} = assigns) when type != "button" do 30 | a(Map.put(assigns, :link_type, "button")) 31 | end 32 | 33 | def a(%{link_type: "a"} = assigns) do 34 | ~H""" 35 | <.link href={@to} class={@class} {@rest}> 36 | <%= if(@label, do: @label, else: render_slot(@inner_block)) %> 37 | 38 | """ 39 | end 40 | 41 | def a(%{link_type: "live_patch"} = assigns) do 42 | ~H""" 43 | <.link patch={@to} class={@class} {@rest}> 44 | <%= if(@label, do: @label, else: render_slot(@inner_block)) %> 45 | 46 | """ 47 | end 48 | 49 | def a(%{link_type: "live_redirect"} = assigns) do 50 | ~H""" 51 | <.link navigate={@to} class={@class} {@rest}> 52 | <%= if(@label, do: @label, else: render_slot(@inner_block)) %> 53 | 54 | """ 55 | end 56 | 57 | def a(%{link_type: "button"} = assigns) do 58 | ~H""" 59 | 62 | """ 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/data_table/theme/util.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Theme.Util do 2 | @moduledoc """ 3 | Utilities which are useful for building your own theme. 4 | """ 5 | 6 | def generate_pages(page, page_size, total_results) do 7 | max_page = div(total_results + (page_size - 1), page_size) - 1 8 | 9 | middle_pages = 10 | (page - 3)..(page + 3) 11 | |> Enum.filter(&(&1 >= 0)) 12 | |> Enum.filter(&(&1 <= max_page)) 13 | 14 | pages = Enum.map(middle_pages, fn i -> 15 | {:page, i, i == page} 16 | end) 17 | 18 | pages 19 | end 20 | 21 | end 22 | -------------------------------------------------------------------------------- /lib/data_table/util/data_deps.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.Util.DataDeps do 2 | # For assign 3 | use Phoenix.LiveView 4 | # import Phoenix.LiveView, only: [assign: 3, assign: 2] 5 | 6 | defstruct socket: nil, changed: MapSet.new(), derived: %{} 7 | 8 | def new(socket) do 9 | %__MODULE__{ 10 | socket: socket 11 | } 12 | end 13 | 14 | def finish(data_deps) do 15 | data_deps.socket 16 | end 17 | 18 | def action(deps, field, value) do 19 | %{ 20 | deps 21 | | derived: Map.put(deps.derived, field, value), 22 | changed: MapSet.put(deps.changed, field) 23 | } 24 | end 25 | 26 | # TODO this check is already performed internally, can we 27 | # avoid doing it twice? 28 | def assign_input(deps, field, value) do 29 | case deps.socket.assigns do 30 | %{^field => ^value} -> 31 | deps 32 | 33 | _ -> 34 | %{ 35 | deps 36 | | socket: assign(deps.socket, field, value), 37 | changed: MapSet.put(deps.changed, field) 38 | } 39 | end 40 | end 41 | 42 | # def derive(deps, in_fields, fun) do 43 | # in_fields = MapSet.new(in_fields) 44 | 45 | # if MapSet.disjoint?(deps.changed, in_fields) do 46 | # deps 47 | # else 48 | # in_assigns = Map.take(Map.merge(deps.derived, deps.socket.assigns), in_fields) 49 | # new_derives = fun.(in_assigns) 50 | # derived = Map.merge(deps.derived, new_derives) 51 | # changed = MapSet.union(deps.changed, MapSet.new(new_assigns, fn {k, _v} -> k end)) 52 | 53 | # %{ 54 | # deps 55 | # | derived: derived, 56 | # changed: changed 57 | # } 58 | # end 59 | # end 60 | 61 | def assign_derive(deps, in_fields, tap_fields \\ [], fun) do 62 | if MapSet.disjoint?(deps.changed, MapSet.new(in_fields)) do 63 | deps 64 | else 65 | in_assigns = 66 | Map.merge(deps.derived, deps.socket.assigns) 67 | |> Map.take(tap_fields ++ in_fields) 68 | 69 | new_assigns = fun.(in_assigns) 70 | socket = assign(deps.socket, new_assigns) 71 | changed = MapSet.union(deps.changed, MapSet.new(new_assigns, fn {k, _v} -> k end)) 72 | 73 | %{ 74 | deps 75 | | socket: socket, 76 | changed: changed 77 | } 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule DataTable.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :data_table, 7 | version: "0.4.0", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | package: package(), 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | name: "DataTable", 14 | docs: docs() 15 | ] 16 | end 17 | 18 | def application do 19 | [ 20 | mod: {DataTable.Application, []}, 21 | extra_applications: [:logger] 22 | ] 23 | end 24 | 25 | def package do 26 | [ 27 | description: "Flexible data table component for LiveView", 28 | licenses: ["MIT"], 29 | links: %{ 30 | "GitHub" => "https://github.com/hansihe/data_table" 31 | } 32 | ] 33 | end 34 | 35 | defp docs do 36 | [ 37 | main: "DataTable", 38 | extra_section: "GUIDES", 39 | extras: [ 40 | "guides/cheatsheets/data_table_component_cheatsheet.cheatmd" 41 | ], 42 | groups_for_extras: [ 43 | Cheatsheets: ~r/cheatsheets\/.?/ 44 | ], 45 | groups_for_modules: [ 46 | Sources: [ 47 | DataTable.Ecto, 48 | DataTable.Ecto.Query, 49 | DataTable.List, 50 | DataTable.List.Config 51 | ], 52 | "Source Behaviour": [ 53 | DataTable.Source, 54 | DataTable.Source.Query, 55 | DataTable.Source.Result 56 | ], 57 | Themes: [ 58 | DataTable.Theme.Tailwind, 59 | DataTable.Theme.Basic, 60 | DataTable.Theme.Util 61 | ] 62 | ] 63 | ] 64 | end 65 | 66 | defp elixirc_paths(:test), do: ["lib", "test"] 67 | defp elixirc_paths(_), do: ["lib"] 68 | 69 | # Run "mix help deps" to learn about dependencies. 70 | defp deps do 71 | [ 72 | {:phoenix, "~> 1.7"}, 73 | {:phoenix_live_view, "~> 1.0"}, 74 | {:phoenix_ecto, "~> 4.6"}, 75 | {:ecto, "~> 3.12"}, 76 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 77 | {:plug_cowboy, "~> 2.5", only: :dev_server}, 78 | {:jason, "~> 1.2", only: [:dev_server, :test]}, 79 | {:floki, ">= 0.30.0", only: :test} 80 | ] 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.13", "b735ea45d87f027128debaf662c6cdf618604f530bec554cc60c195f2a55b506", [:mix], [], "hexpm", "ec09e81a9c3db92d27c6651d119d8adc6d1cbbb3d90f8c1293eee2af590bf55d"}, 3 | "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, 4 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 5 | "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, 6 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 8 | "ecto": {:hex, :ecto, "3.12.4", "267c94d9f2969e6acc4dd5e3e3af5b05cdae89a4d549925f3008b2b7eb0b93c3", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef04e4101688a67d061e1b10d7bc1fbf00d1d13c17eef08b71d070ff9188f747"}, 9 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 10 | "floki": {:hex, :floki, "0.37.1", "d7aaee758c8a5b4a7495799a4260754fec5530d95b9c383c03b27359dea117cf", [:mix], [], "hexpm", "673d040cb594d31318d514590246b6dd587ed341d3b67e17c1c0eb8ce7ca6f04"}, 11 | "heroicons": {:hex, :heroicons, "0.5.6", "95d730e7179c633df32d95c1fdaaecdf81b0da11010b89b737b843ac176a7eb5", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "ca267f02a5fa695a4178a737b649fb6644a2e399639d4ba7964c18e8a58c2352"}, 12 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 13 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 14 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 15 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 16 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 17 | "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, 18 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 19 | "petal_components": {:hex, :petal_components, "2.2.0", "6376ed31ce1be84338d5d4f25b608800bc2c703051aec50073161e648834f79f", [:mix], [{:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.4", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.7", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "aa4aae1cc7aae2eb3995af692ac2a50c250dd30ad90760d8ae3802fc6f089ea3"}, 20 | "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, 21 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, 22 | "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, 23 | "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, 24 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.11", "76f6416cb31e2e49601c62b79edc9e6fbd0706368bba9f49c4bd13934b785a28", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "522b8c164d1a0009d30fd3364538d17684cb6f8e6a6931f511f82d891c634cdd"}, 25 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 26 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 27 | "phoenix_test": {:hex, :phoenix_test, "0.4.1", "66df0d018e16c3eb63c9981f43244570b99d6ed0669a00eab1a7a1b4cc4366b6", [:mix], [{:floki, ">= 0.30.0", [hex: :floki, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, ">= 1.0.0", [hex: :mime, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7.10", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e5f49d415a80119a1ea9039a2a6f69cbc5d95639986f8d674661a724e6272e3d"}, 28 | "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, 29 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"}, 30 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 31 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, 32 | "sourceror": {:hex, :sourceror, "0.12.3", "a2ad3a1a4554b486d8a113ae7adad5646f938cad99bf8bfcef26dc0c88e8fade", [:mix], [], "hexpm", "4d4e78010ca046524e8194ffc4683422f34a96f6b82901abbb45acc79ace0316"}, 33 | "spark": {:hex, :spark, "1.1.18", "349ad7ec69b389294fd3f17a4e49e772cafbbb71d3571add652a80f7b3c44990", [:mix], [{:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "964b46866e01b39810a82f9ee538f7f25d450cb3223af58b0f4717ce69d6f167"}, 34 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 35 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 36 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 37 | } 38 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hansihe/data_table/3c3b8fee57d458c5090935583553ada522c70e97/screenshot.png -------------------------------------------------------------------------------- /test/data_table/live_component/logic_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DataTable.LiveComponent.LogicTest do 2 | use ExUnit.Case 3 | 4 | alias DataTable.LiveComponent.Logic 5 | alias DataTable.Util.DataDeps 6 | 7 | @simple_source {DataTable.List, 8 | {Enum.map(0..9, &%{id: &1, name: "Id #{&1}"}), %DataTable.List.Config{}}} 9 | 10 | defp handle_nav(nav) do 11 | send(self(), {:handle_nav, nav}) 12 | end 13 | 14 | defp get_handle_nav do 15 | receive do 16 | {:handle_nav, nav} -> {:ok, nav} 17 | after 18 | 0 -> :no_message 19 | end 20 | end 21 | 22 | def update(assigns, changes) do 23 | data_deps = DataDeps.new(%Phoenix.LiveView.Socket{assigns: assigns}) 24 | 25 | data_deps = 26 | if assigns == %{__changed__: %{}} do 27 | Logic.init(data_deps) 28 | else 29 | data_deps 30 | end 31 | 32 | socket = 33 | Enum.reduce(changes, data_deps, fn {key, value}, acc -> 34 | DataDeps.assign_input(acc, key, value) 35 | end) 36 | |> Logic.compute() 37 | |> DataDeps.finish() 38 | 39 | socket.assigns 40 | end 41 | 42 | def params(assigns \\ %{__changed__: %{}}, params) do 43 | update( 44 | assigns, 45 | Keyword.merge( 46 | [ 47 | id: "id", 48 | source: @simple_source, 49 | theme: nil, 50 | col: [], 51 | selection_action: [], 52 | row_expanded: [], 53 | row_buttons: [], 54 | top_right: [], 55 | always_columns: [], 56 | handle_nav: nil, 57 | nav: nil 58 | ], 59 | params 60 | ) 61 | ) 62 | end 63 | 64 | test "basic initialization" do 65 | assigns = params([]) 66 | 67 | assert assigns.total_results == 10 68 | assert assigns.page == 0 69 | assert assigns.can_expand == false 70 | assert assigns.can_select == false 71 | 72 | assert [ 73 | %{id: 0}, 74 | %{id: 1}, 75 | %{id: 2}, 76 | %{id: 3}, 77 | %{id: 4}, 78 | %{id: 5}, 79 | %{id: 6}, 80 | %{id: 7}, 81 | %{id: 8}, 82 | %{id: 9} 83 | ] = assigns.results 84 | end 85 | 86 | test "adding column works, minimal set of columns is queried" do 87 | assigns = params([]) 88 | 89 | assert assigns.shown_fields == MapSet.new([]) 90 | assert assigns.queried_columns == MapSet.new([:list_index]) 91 | 92 | col = [ 93 | %{ 94 | __slot__: :col, 95 | name: "Id", 96 | fields: [:id, :name] 97 | } 98 | ] 99 | 100 | assigns = update(assigns, col: col) 101 | 102 | assert assigns.shown_fields == MapSet.new(["Id"]) 103 | assert assigns.queried_columns == MapSet.new([:list_index, :id, :name]) 104 | 105 | assigns = 106 | update(assigns, shown_fields: MapSet.new([])) 107 | 108 | assert assigns.shown_fields == MapSet.new([]) 109 | assert assigns.queried_columns == MapSet.new([:list_index]) 110 | end 111 | 112 | test "correctly dispatches nav" do 113 | # Passing in a `handle_nav` function results in update to nav state. 114 | assigns = params(handle_nav: &handle_nav/1) 115 | {:ok, nav} = get_handle_nav() 116 | :no_message = get_handle_nav() 117 | assert nav.filters == [] 118 | assert nav.page == 0 119 | assert nav.sort == nil 120 | 121 | # Setting `nav` param to the active nav state results in no update. 122 | assigns = update(assigns, nav: nav) 123 | :no_message = get_handle_nav() 124 | 125 | # Setting `nav` param to a changed nav state results in update. 126 | updated_nav = %{nav | page: 1} 127 | assigns = update(assigns, nav: %{nav | page: 1}) 128 | {:ok, nav} = get_handle_nav() 129 | :no_message = get_handle_nav() 130 | assert nav == updated_nav 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /test/data_table_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DataTableTest do 2 | use ExUnit.Case 3 | doctest DataTable 4 | 5 | import Phoenix.LiveViewTest 6 | @endpoint DataTable.TestEndpoint 7 | 8 | setup _tags do 9 | {:ok, conn: Phoenix.ConnTest.build_conn()} 10 | end 11 | 12 | test "row expand and collapse works", %{conn: conn} do 13 | {:ok, view, _html} = live_isolated(conn, DataTable.TestLive) 14 | 15 | assert not has_element?(view, "tbody > tr.expanded-row-content") 16 | 17 | # Expand row 3 18 | assert render(element(view, "tbody > tr:nth-child(3) > td.expansion > span")) == "v" 19 | render_click(element(view, "tbody > tr:nth-child(3) > td.expansion")) 20 | 21 | assert not has_element?(view, "tbody > tr.expanded-row-content", "Row Expanded 1") 22 | assert has_element?(view, "tbody > tr.expanded-row-content", "Row Expanded 2") 23 | 24 | # Collapse row 3 25 | render_click(element(view, "tbody > tr:nth-child(3) > td.expansion")) 26 | assert not has_element?(view, "tbody > tr.expanded-row-content") 27 | end 28 | 29 | test "pagination buttons work", %{conn: conn} do 30 | {:ok, view, _html} = live_isolated(conn, DataTable.TestLive) 31 | 32 | assert render(element(view, ".pagination-desc > .start")) =~ ">0<" 33 | assert render(element(view, ".pagination-desc > .end")) =~ ">20<" 34 | assert render(element(view, ".pagination-desc > .total")) =~ ">50<" 35 | 36 | assert has_element?(view, "tbody > tr:first-child > td.data-cell", "Row 0") 37 | assert has_element?(view, "tbody > tr:last-child > td.data-cell", "Row 19") 38 | 39 | render_click(element(view, ".pagination-buttons > a.next")) 40 | 41 | assert render(element(view, ".pagination-desc > .start")) =~ ">20<" 42 | assert render(element(view, ".pagination-desc > .end")) =~ ">40<" 43 | assert render(element(view, ".pagination-desc > .total")) =~ ">50<" 44 | 45 | assert has_element?(view, "tbody > tr:first-child > td.data-cell", "Row 20") 46 | assert has_element?(view, "tbody > tr:last-child > td.data-cell", "Row 39") 47 | 48 | render_click(element(view, ".pagination-buttons > a.prev")) 49 | 50 | assert render(element(view, ".pagination-desc > .start")) =~ ">0<" 51 | assert render(element(view, ".pagination-desc > .end")) =~ ">20<" 52 | assert render(element(view, ".pagination-desc > .total")) =~ ">50<" 53 | 54 | assert has_element?(view, "tbody > tr:first-child > td.data-cell", "Row 0") 55 | assert has_element?(view, "tbody > tr:last-child > td.data-cell", "Row 19") 56 | end 57 | 58 | test "sort cycling works", %{conn: conn} do 59 | {:ok, view, _html} = live_isolated(conn, DataTable.TestLive) 60 | 61 | assert has_element?(view, "th.column-header a.sort-toggle") 62 | assert not has_element?(view, "th.column-header span.sort_asc") 63 | assert not has_element?(view, "th.column-header span.sort_desc") 64 | 65 | render_click(element(view, "th.column-header a.sort-toggle")) 66 | assert has_element?(view, "th.column-header span.sort-asc") 67 | assert not has_element?(view, "th.column-header span.sort_desc") 68 | 69 | render_click(element(view, "th.column-header a.sort-toggle")) 70 | assert not has_element?(view, "th.column-header span.sort_asc") 71 | assert has_element?(view, "th.column-header span.sort-desc") 72 | 73 | render_click(element(view, "th.column-header a.sort-toggle")) 74 | assert not has_element?(view, "th.column-header span.sort_asc") 75 | assert not has_element?(view, "th.column-header span.sort_desc") 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/support/test_endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.TestEndpoint do 2 | use Phoenix.Endpoint, otp_app: :data_table 3 | 4 | socket("/live", Phoenix.LiveView.Socket) 5 | plug(DataTable.TestRouter) 6 | end 7 | -------------------------------------------------------------------------------- /test/support/test_live.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.TestLive do 2 | use Phoenix.LiveView 3 | 4 | def render(assigns) do 5 | ~H""" 6 | <% config = %DataTable.List.Config{} %> 7 | <% data = Enum.map(0..49, &%{id: &1}) %> 8 | 9 | 13 | 14 | <:col name="Id" fields={[:id]} sort_field={:id} :let={row}> 15 | Row <%= row.id %> 16 | 17 | 18 | <:row_expanded fields={[:id]} :let={row}> 19 | Row Expanded <%= row.id %> 20 | 21 | 22 | <:selection_action label="Test Action" handle_action={fn _, _ -> nil end}/> 23 | 24 | 25 | """ 26 | end 27 | 28 | def mount(_params, _session, socket) do 29 | {:ok, socket} 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /test/support/test_router.ex: -------------------------------------------------------------------------------- 1 | defmodule DataTable.TestRouter do 2 | use Phoenix.Router 3 | import Phoenix.LiveView.Router 4 | 5 | pipeline :browser do 6 | plug(:accepts, ["html"]) 7 | end 8 | 9 | scope "/", DataTable do 10 | pipe_through(:browser) 11 | 12 | live("/test", DataTable.TestLive, :index) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.put_env( 2 | :data_table, 3 | DataTable.TestEndpoint, 4 | [ 5 | secret_key_base: "...............", 6 | live_view: [signing_salt: "............."] 7 | ] 8 | ) 9 | {:ok, _pid} = DataTable.TestEndpoint.start_link([]) 10 | 11 | ExUnit.start() 12 | --------------------------------------------------------------------------------