├── .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 | 
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 |
20 |
21 |
22 |
23 |
24 |
25 | <.checkbox state={@header_selection} on_toggle="toggle-all" phx-target={@target}/>
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | <%= field.name %>
35 |
36 |
37 |
38 | <%= field.name %>
39 |
40 | v
41 | ^
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | <%= for row <- @rows do %>
53 |
54 |
55 |
56 | <.checkbox state={row.selected} on_toggle="toggle-row" phx-target={@target} phx-value-id={row.id}/>
57 |
58 |
59 |
60 |
61 | ^
62 | v
63 |
64 |
65 |
66 |
67 | <%= render_slot(field_slot, row.data) %>
68 |
69 |
70 |
71 |
72 | <%= if @has_row_buttons do %>
73 | <%= render_slot(@row_buttons_slot, row.data) %>
74 | <% end %>
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | <%= render_slot(@row_expanded_slot, row.data) %>
83 |
84 |
85 | <% end %>
86 |
87 |
88 |
89 |
90 |
91 |
100 |
101 |
117 |
118 |
119 |
120 |
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 |
75 | <%= for {id, name} <- @options do %>
76 | <%= name %>
77 | <% end %>
78 |
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 |
245 |
246 | <%= name %>
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 |
302 |
303 |
304 | Showing
305 | <%= @page_start_item %>
306 | to
307 | <%= @page_end_item %>
308 | of
309 | <%= @total_results %>
310 | results
311 |
312 |
313 |
350 |
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 |
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 |
96 |
97 |
106 | <%= @label %>
107 |
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 |
124 | <%= @prompt %>
125 | <%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
126 |
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 |
184 | <%= render_slot(@inner_block) %>
185 |
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 |
67 | Open options
68 |
69 | <%= if @label do %>
70 | <%= @label %>
71 |
72 | <% end %>
73 |
74 | <%= if @trigger_element do %>
75 | <%= render_slot(@trigger_element) %>
76 | <% end %>
77 |
78 | <%= if !@label && @trigger_element == [] do %>
79 |
80 | <% end %>
81 |
82 |
83 |
95 |
96 | <%= render_slot(@inner_block) %>
97 |
98 |
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 ( tags can't be disabled)"
14 |
15 | attr :rest, :global, include: ~w(method download)
16 | slot :inner_block, required: false
17 |
18 | def a(%{link_type: "button", disabled: true} = assigns) do
19 | assigns = update_in(assigns.rest, &Map.drop(&1, [:"phx-click"]))
20 |
21 | ~H"""
22 |
23 | <%= if @label, do: @label, else: render_slot(@inner_block) %>
24 |
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 |
60 | <%= if @label, do: @label, else: render_slot(@inner_block) %>
61 |
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 |
--------------------------------------------------------------------------------