├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets └── dumper.gif ├── config └── config.exs ├── lib ├── dumper.ex └── dumper │ ├── components │ ├── pagination.html.heex │ ├── show_record.html.heex │ ├── show_table.html.heex │ ├── show_table_names.html.heex │ └── table_records.html.heex │ ├── config.ex │ └── live_dashboard_page.ex ├── mix.exs ├── mix.lock └── test ├── dumper └── config_test.exs ├── dumper_test.exs ├── support ├── conn_case.ex ├── migrations │ ├── 01_create_tables.exs │ └── 02_seed_data.exs ├── nav_helpers.ex ├── phoenix_setup.ex ├── repo.ex └── schemas │ ├── author.ex │ ├── book.ex │ ├── book_review.ex │ ├── loan.ex │ └── patron.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | [ 12 | import_deps: [:ecto, :ecto_sql, :phoenix], 13 | plugins: [Phoenix.LiveView.HTMLFormatter, Styler], 14 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] 15 | ] 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | on: 3 | push: 4 | branches: [ "main"] 5 | pull_request: 6 | branches: [ "main"] 7 | env: 8 | MIX_ENV: test 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | name: Ex${{matrix.elixir}}/OTP${{matrix.otp}} 14 | strategy: 15 | matrix: 16 | elixir: ['1.15.7', '1.16.0', '1.17.0'] 17 | otp: ['25.1.2'] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: erlef/setup-beam@v1 21 | with: 22 | otp-version: ${{matrix.otp}} 23 | elixir-version: ${{matrix.elixir}} 24 | - run: mix deps.get 25 | - run: mix compile --warnings-as-errors 26 | - run: mix test 27 | -------------------------------------------------------------------------------- /.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 | dumper-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | test.db* -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.2.7 4 | 5 | ### Improvements 6 | 7 | * Added `large_tables/0` optional config to handle performance issues for tables with large row counts. [https://github.com/adobe/elixir-dumper/issues/7](Issue #7) showed that large tables would time out when rendering the records page due to the expensive count query. It was also discovered the order by inserted_at could also contribute to timeouts. 8 | 9 | ### Bug Fix 10 | 11 | * Addressed [https://github.com/adobe/elixir-dumper/issues/7](Issue #7) by removing total number of entries altogether. 12 | 13 | ## v0.2.6 14 | 15 | ### Improvements 16 | 17 | * Removed unnecessary `action` url parameter. The correct page to render can be derived from the presence of the module and id parameters. 18 | 19 | ## v0.2.5 20 | 21 | ### Improvements 22 | 23 | * Was previously loading all associations, which caused timeouts for records with large amounts of associated records. 24 | * Tests are now run on PRs instead of only pushes against main. 25 | 26 | ## v0.2.4 27 | 28 | ### Improvements 29 | 30 | * Correct the `Dumper.Config` callback specs. 31 | 32 | ## v0.2.3 33 | 34 | ### Improvements 35 | 36 | * Allow override of the auto-discovery of the `otp_app`. 37 | 38 | ## v0.2.2 39 | 40 | ### Improvements 41 | 42 | * Updated the package description to reflect that it is now a LiveDashboard page and not a mix generator. 43 | 44 | ## v0.2.1 45 | 46 | ### Improvements 47 | 48 | * Search for a record by id 49 | * Use router config instead of app config 50 | * Update links to use `<.link navigate={}>` instead of firing events 51 | 52 | ## v0.2.0 53 | 54 | ### Improvements 55 | 56 | * Changed from mix task to a LiveDashboard plugin 57 | 58 | ## v0.1.1 59 | 60 | ### Improvements 61 | 62 | * Fix phoenix heex warning where name attribute was set to "id". Renamed to "search_id". 63 | 64 | ## v0.1.0 65 | 66 | ### Improvements 67 | 68 | * Initial release of Dumper 69 | -------------------------------------------------------------------------------- /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 | [![Hex.pm](https://img.shields.io/hexpm/v/dumper)](https://hex.pm/packages/dumper) 2 | [![Hexdocs.pm](https://img.shields.io/badge/docs-hexdocs.pm-purple)](https://hexdocs.pm/dumper) 3 | [![Github.com](https://github.com/adobe/elixir-dumper/actions/workflows/ci.yml/badge.svg)](https://github.com/adobe/elixir-dumper/actions) 4 | 5 | # Dumper 6 | 7 | _Takes your data and dumps it to the screen!_ 8 | 9 | Dumper uses reflection to find all your app's ecto schemas and provide routes to browse their data. It's packaged as a Live Dashboard plugin for easy navigation. 10 | 11 | ![dumper](assets/dumper.gif) 12 | 13 | ## Requirements 14 | Dumper only works with Phoenix 1.7+ applications that use Ecto. 15 | 16 | ## About 17 | Dumper aims to make it as easy as possible for everyone on a project to access and understand its data. 18 | 19 | - All ids can be linked, so it's an incredibly fast way to explore a data model against real data. 20 | - Because it's implemented with reflection, it automatically covers every schema module in your project. 21 | - Styling is kept consistent with the LiveDashboard theme so that data is front and center. 22 | - Associations are also shown for a given record, all on the same page. 23 | - Non-intrusive. No changes necessary to existing modules/files. 24 | - Read-only. No accidentally deleting or editing data while browsing. 25 | - Shareable URLs. Having a shareable link to every record in your database is very useful for debugging. It gives everyone including non-technical teammates the ability to get a better understanding of your data model. 26 | 27 | ## Installation and Usage 28 | Add `dumper` to your list of dependencies in `mix.exs`: 29 | 30 | ```elixir 31 | def deps do 32 | [ 33 | {:dumper, "~> 0.2.6"} 34 | ] 35 | end 36 | ``` 37 | 38 | Install and configure [Phoenix Live Dashboard](https://hexdocs.pm/phoenix_live_dashboard) if you haven't already. Then modify `router.ex` to include the `dumper` as a plugin: 39 | 40 | ``` elixir 41 | live_dashboard "/dashboard", additional_pages: [dumper: {Dumper.LiveDashboardPage, repo: MyApp.Repo}] 42 | ``` 43 | 44 | By default, Dumper will auto-discover all Ecto schemas associated with the provided repo's `otp_app`. You can override this behavior by explicitly passing an app as an option with `otp_app: :my_app`. 45 | 46 | You can now run your web app, navigate to dumper tab within the live dashboard, and view all your data. 47 | 48 | ## Customization 49 | 50 | ### Config Module 51 | It is *highly recommended* to customize the `dumper`. To do so, you can optionally define a module that implements the `Dumper.Config` behavior. Add it to the `live_dashboard` entry in your `router.ex` file: 52 | 53 | ``` elixir 54 | live_dashboard "/dashboard", additional_pages: 55 | [dumper: {Dumper.LiveDashboardPage, repo: MyApp.Repo, config_module: MyApp.DumperConfig}] 56 | ``` 57 | 58 | Here's an example config module: 59 | 60 | ``` elixir 61 | defmodule MyApp.DumperConfig do 62 | use Dumper.Config 63 | 64 | @impl Dumper.Config 65 | def ids_to_schema() do 66 | %{ 67 | book_id: Library.Book, 68 | author_id: Library.Author 69 | } 70 | end 71 | 72 | def excluded_fields() do 73 | %{ 74 | Library.Employee => [:salary, :email_address], 75 | Library.Book => [:price] 76 | } 77 | end 78 | 79 | def display(%{field: :last_name} = assigns) do 80 | ~H|<%= @value %>| 81 | end 82 | 83 | def custom_record_links(%Library.Book{} = book) do 84 | [ 85 | {"https://goodreads.com/search?q=#{book.title}", "Goodreads"}, 86 | {~p"/logging/#{book.id}", "Logs"} 87 | ] 88 | end 89 | end 90 | 91 | ``` 92 | 93 | Common customizations are using the `c:Dumper.Config.ids_to_schema/0` to turn id values into clickable links and using `c:Dumper.Config.custom_record_links/1` to provide extra links to useful information when viewing a specific record. 94 | 95 | Take a look a `c:Dumper.Config.ids_to_schema/0`, `c:Dumper.Config.allowed_fields/0`, `c:Dumper.Config.excluded_fields/0`, `c:Dumper.Config.display/1`, `c:Dumper.Config.additional_associations/1`, and `c:Dumper.Config.custom_record_links/1` for more information on how each optional callback lets you customize how your data is rendered. 96 | 97 | 98 | ## Other notes 99 | 100 | ### Rendering Embeds 101 | The index page and association tables on the show page by default omit columns that are embeds. This is purely for display purposes, as those values tend to take up a lot of vertical space. This is currently not configurable, but may be in the future. 102 | 103 | ### Redactions 104 | By default, schema fields with `redact: true` are hidden and replaced with the text `redacted`. If you're running the Dumper in a non-production environment or against dummy data, you may want to disregard the redacted fields. To do that, you can add a `display/1` function head like the following: 105 | 106 | ``` elixir 107 | def display(%{redacted: true} = assigns), do: ~H|<%= @value %>| 108 | ``` 109 | 110 | You can refine that down to a specific schema and/or field as well by pattern matching the assigns. 111 | 112 | ### Security 113 | As with LiveDashboard more broadly, it is highly recommended that you put the route behind some sort of [admin authentication](https://hexdocs.pm/phoenix_live_dashboard/Phoenix.LiveDashboard.html#module-extra-add-dashboard-access-on-all-environments-including-production) if you want to use `dumper` in production. 114 | 115 | The `c:Dumper.Config.allowed_fields/0` and `c:Dumper.Config.excluded_fields/0`callbacks are another way to be explicit about what data is shown or hidden altogether. 116 | 117 | You could also hide the plugin altogether by modifying the live_dashboard route. For example, this would require a `:dumper` `enabled: true` config to be set in order to display the `dumper` tab in the live dashboard: 118 | 119 | ``` elixir 120 | live_dashboard "/dashboard", 121 | additional_pages: [] ++ (if Application.get_env(:dumper, :enabled, false), do: [dumper: {Dumper.LiveDashboardPage, repo: MyApp.Repo}], else: []) 122 | ``` 123 | 124 | This would allow you to configure showing or hiding the `dumper` based on environments. 125 | -------------------------------------------------------------------------------- /assets/dumper.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adobe/elixir-dumper/a2b8e21d65c833e97c3c55904084db5837e09625/assets/dumper.gif -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env() == :test do 4 | config :dumper, DumperTest.Endpoint, 5 | url: [host: "localhost", port: 4000], 6 | secret_key_base: "Hu4qQN3iKzTV4fJxhorPQlA/osH9fAMtbtjVS58PFgfw3ja5Z18Q/WSNR9wP4OfW", 7 | live_view: [signing_salt: "hMegieSe"], 8 | check_origin: false 9 | 10 | config :dumper, Repo, database: "test.db" 11 | 12 | config :logger, level: :warning 13 | end 14 | -------------------------------------------------------------------------------- /lib/dumper.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Dumper do 12 | @moduledoc false 13 | use Phoenix.Component 14 | 15 | import Ecto.Query 16 | 17 | alias Phoenix.LiveDashboard.PageBuilder 18 | 19 | embed_templates "dumper/components/*" 20 | 21 | def docs(assigns) do 22 | doctext = 23 | case Code.fetch_docs(assigns.module) do 24 | {_, _, _, _, %{"en" => doctext} = _module_doc, _, _} -> String.trim(doctext) 25 | _ -> "" 26 | end 27 | 28 | markdown_html = 29 | doctext 30 | |> Earmark.as_html!(code_class_prefix: "lang- language-") 31 | |> Phoenix.HTML.raw() 32 | 33 | assigns = assign(assigns, markdown: markdown_html, doctext: doctext) 34 | 35 | ~H""" 36 |
37 | Documentation 38 |
<%= @markdown %>
39 |
40 | """ 41 | end 42 | 43 | def module_name(module), do: module |> to_string() |> String.replace(~r/^Elixir\./, "") 44 | 45 | def humanize_association_name(module) when is_atom(module) do 46 | module |> Atom.to_string() |> String.split("_") |> Enum.map_join(" ", &String.capitalize/1) 47 | end 48 | 49 | def fields(module, config_module) do 50 | all = module.__schema__(:fields) 51 | excluded = config_module.excluded_fields() 52 | 53 | {allowed_map, allowed_strict?} = 54 | case config_module.allowed_fields() do 55 | nil -> {%{}, false} 56 | %{} = m -> {m, true} 57 | {m, :lenient} -> {m, false} 58 | {m, _strict} -> {m, true} 59 | end 60 | 61 | cond do 62 | allowed_strict? || Map.has_key?(allowed_map, module) -> 63 | allowed_map |> Map.get(module, []) |> Enum.filter(fn f -> f in all end) 64 | 65 | Map.has_key?(excluded, module) -> 66 | Enum.reduce(excluded[module], all, fn f, acc -> List.delete(acc, f) end) 67 | 68 | :all_fields -> 69 | all 70 | end 71 | end 72 | 73 | def embeds(module), do: module.__schema__(:embeds) 74 | def redacted_fields(module), do: module.__schema__(:redact_fields) 75 | def custom_record_links(record, config_module), do: config_module.custom_record_links(record) 76 | def additional_associations(record, config_module), do: config_module.additional_associations(record) 77 | 78 | def value(assigns) do 79 | assigns.config_module.display(assigns) 80 | end 81 | 82 | def paginate(query, %{"pagenum" => page, "page_size" => page_size}, repo) do 83 | page = if is_binary(page), do: String.to_integer(page), else: page 84 | page = max(1, page) 85 | 86 | page_size = if is_binary(page_size), do: String.to_integer(page_size), else: page_size 87 | page_size = max(1, page_size) 88 | page_size_plus_one = page_size + 1 89 | 90 | entries = 91 | query 92 | |> limit(^page_size_plus_one) 93 | |> offset(^(page_size * (page - 1))) 94 | |> repo.all() 95 | 96 | %{ 97 | entries: Enum.take(entries, page_size), 98 | has_prev?: page > 1, 99 | has_next?: Enum.count(entries) == page_size_plus_one, 100 | page: page, 101 | page_size: page_size 102 | } 103 | end 104 | 105 | defp fetch_rows(otp_app) do 106 | fn params, _node -> 107 | %{search: search, sort_by: _sort_by, sort_dir: sort_dir, limit: limit} = params 108 | search = String.downcase(search || "") 109 | 110 | {:ok, modules} = :application.get_key(otp_app, :modules) 111 | 112 | modules = 113 | modules 114 | |> Enum.filter(&is_ecto_schema?/1) 115 | |> Enum.map(fn module -> %{name: module |> Module.split() |> Enum.join(".")} end) 116 | 117 | # apply search 118 | modules = Enum.filter(modules, fn %{name: name} -> String.downcase(name) =~ search end) 119 | 120 | # sort 121 | modules = Enum.sort(modules, sort_dir) 122 | 123 | {Enum.take(modules, limit), Enum.count(modules)} 124 | end 125 | end 126 | 127 | defp row_attrs(%{name: module}) do 128 | [ 129 | {"phx-click", "show_table"}, 130 | {"phx-value-module", module}, 131 | {"phx-page-loading", true} 132 | ] 133 | end 134 | 135 | defp is_ecto_schema?(module) do 136 | # true iff it 1. is a module 2. has a __schema__ function 3. is not an Embedded Schema 137 | match?({:module, _}, Code.ensure_loaded(module)) && 138 | {:__schema__, 1} in module.__info__(:functions) && 139 | module.__schema__(:source) != nil 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /lib/dumper/components/pagination.html.heex: -------------------------------------------------------------------------------- 1 | <%!-- 2 | Copyright 2024 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | --%> 12 | 13 |
14 | <%= if @records.has_prev? do %> 15 | 16 | Prev 17 | 18 | <% else %> 19 | Prev 20 | <% end %> 21 | 22 | <%= if @records.has_next? do %> 23 | 24 | Next 25 | 26 | <% else %> 27 | Next 28 | <% end %> 29 |
30 | -------------------------------------------------------------------------------- /lib/dumper/components/show_record.html.heex: -------------------------------------------------------------------------------- 1 | <%!-- 2 | Copyright 2024 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | --%> 12 | 13 |
14 |
15 |
16 |
<%= module_name(@module) %>
17 | 18 | <.link navigate={"#{@dumper_home}?module=#{@module}"}>See all 19 | 20 |
21 | 22 | 32 |
33 | 34 | <.docs module={@module} /> 35 |
36 | 37 |
38 |
39 |
40 | 41 | 42 | 43 | 55 | 56 |
<%= field %> 44 | <.value 45 | module={@module} 46 | field={field} 47 | resource={@record} 48 | value={Map.get(@record, field)} 49 | type={@module.__schema__(:type, field)} 50 | redacted={field in redacted_fields(@module)} 51 | config_module={@config_module} 52 | dumper_home={@dumper_home} 53 | /> 54 |
57 |
58 |
59 |
60 | 61 |
66 |
67 | 68 | <%= humanize_association_name(assoc) %> 69 | 70 |
71 | <.table_records 72 | records={result.entries} 73 | config_module={@config_module} 74 | dumper_home={@dumper_home} 75 | /> 76 | <.pagination records={result} assoc={assoc} /> 77 |
78 |
79 |
80 | 81 | <%!-- Additional associations are defined by the user, so we do not have pagination --%> 82 |
87 |
88 | 89 | <%= humanize_association_name(assoc) %> 90 | 91 |
92 | <.table_records records={records} config_module={@config_module} dumper_home={@dumper_home} /> 93 |
94 |
95 |
96 | -------------------------------------------------------------------------------- /lib/dumper/components/show_table.html.heex: -------------------------------------------------------------------------------- 1 | <%!-- 2 | Copyright 2024 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | --%> 12 | 13 |
14 |
15 |
16 |
<%= module_name(@module) %>
17 |
18 |
19 | 20 | <.docs module={@module} /> 21 |
22 | 23 |
24 |
25 |
26 |
27 | <%= if @records.page_size do %> 28 |
Showing at most
29 |
30 |
31 | 37 |
38 |
39 |
out of <%= @total_entries %>
40 | <% else %> 41 |
Showing <%= @total_entries %>
42 | <% end %> 43 |
44 |
45 |
46 | 47 | 61 |
62 | 63 |
64 | <.table_records 65 | records={@records.entries} 66 | config_module={@config_module} 67 | dumper_home={@dumper_home} 68 | /> 69 |
70 | 71 | <.pagination records={@records} assoc={nil} /> 72 | -------------------------------------------------------------------------------- /lib/dumper/components/show_table_names.html.heex: -------------------------------------------------------------------------------- 1 | <%!-- 2 | Copyright 2024 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | --%> 12 | 13 |
14 | 23 | <:col field={:name} sortable={:desc} /> 24 | 25 |
26 | -------------------------------------------------------------------------------- /lib/dumper/components/table_records.html.heex: -------------------------------------------------------------------------------- 1 | <%!-- 2 | Copyright 2024 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | --%> 12 | 13 |

14 | No records 15 |

16 | 17 |
18 | <% first_record = List.first(@records) %> 19 | <% module = Map.get(first_record, :__struct__) %> 20 | <% fields = if module, do: fields(module, @config_module), else: Map.keys(first_record) %> 21 | <% embeds = if module, do: embeds(module), else: [] %> 22 | <% redacted_fields = if module, do: redacted_fields(module), else: [] %> 23 | 24 |
25 |
26 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 57 | 58 | 59 |
33 | <%= field %> 34 |
40 | <%= if field == :id do %> 41 | <.link navigate={"#{@dumper_home}?module=#{module}&id=#{record.id}"}> 42 | <%= record.id %> 43 | 44 | <% else %> 45 | <.value 46 | module={module} 47 | field={field} 48 | resource={record} 49 | value={Map.get(record, field)} 50 | type={if module, do: module.__schema__(:type, field), else: nil} 51 | redacted={field in redacted_fields} 52 | config_module={@config_module} 53 | dumper_home={@dumper_home} 54 | /> 55 | <% end %> 56 |
60 |
61 |
62 |
63 | -------------------------------------------------------------------------------- /lib/dumper/config.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Dumper.Config do 12 | @moduledoc ~S""" 13 | Provides sensible defaults for how data should be rendered. 14 | 15 | If you'd like your own customizations, define a module that implements 16 | the `m:Dumper.Config` behaviour: 17 | 18 | # An example dumper config module 19 | defmodule MyApp.DumperConfig do 20 | use Dumper.Config 21 | 22 | def ids_to_schema() do 23 | %{ 24 | book_id: Library.Book, 25 | author_id: Library.Author 26 | } 27 | end 28 | 29 | def display(%{field: :last_name} = assigns) do 30 | ~H|<%= @value %>| 31 | end 32 | 33 | def custom_record_links(%Library.Book{} = book) do 34 | [ 35 | {~p"/logging/#{book.id}", "Logs"}, 36 | {"https://goodreads.com/search?q=#{book.title}", "Goodreads"}, 37 | ] 38 | end 39 | end 40 | 41 | Then update the `live_dashboard` entry in the `router.ex` file to add the config module: 42 | 43 | live_dashboard "/dashboard", additional_pages: 44 | [dumper: {Dumper.LiveDashboardPage, repo: MyApp.Repo, config_module: MyApp.DumperConfig}] 45 | 46 | Implementing each callback provides a different way to control how the data is rendered: 47 | - `c:ids_to_schema/0`: turn id values into clickable links 48 | - `c:display/1`: define a functional component for complete control over how fields are rendered 49 | - `c:custom_record_links/1`: display custom links when viewing an indivisual record 50 | - `c:Dumper.Config.additional_associations/1`: display custom associations not defined in the Ecto schema 51 | - `c:Dumper.Config.allowed_fields/0`: any fields not included will be ignored 52 | - `c:Dumper.Config.excluded_fields/0`: any fields included will be ignored 53 | - `c:Dumper.Config.large_tables/0`: tables included will not render the total record count and won't sort by inserted_at 54 | 55 | The `use Dumper.Config` brings in the default definitions of behaviour, so you can 56 | choose to define one, all, or none of them. As such, even this is a valid implementation 57 | (although it would be functionally the same as not defining a config module at all): 58 | 59 | defmodule MyApp.DumperConfig do 60 | use Dumper.Config 61 | end 62 | """ 63 | 64 | use Phoenix.Component 65 | 66 | @doc """ 67 | A map of ids (as atoms) to the schema module they should link to. 68 | 69 | Each key/value pair in the map will automatically render as a clickable link that 70 | navigates the user to the dumper page for that specific record. 71 | 72 | By default this map is empty. 73 | 74 | Example: 75 | 76 | def ids_to_schema() do 77 | %{ 78 | book_id: Library.Book, 79 | author_id: Library.Author 80 | } 81 | end 82 | 83 | Here, any field in any schema named `book_id` would render as a link to the `Book` record, 84 | via a `Repo.get!(id)` call under the hood, instead of just printing the value. This allows 85 | you to easily navigate through your data by clicking connected links. 86 | """ 87 | @callback ids_to_schema() :: map() 88 | 89 | @doc """ 90 | Fine-grained control over how any field is rendered. This is a functional component that takes 91 | in an `assigns` map and returns a valid `heex` expression. 92 | 93 | By default, Dumper has some sensible defaults for how redacted fields, values like `true`, `false`, `nil`, and data types like dates and datetimes are rendered. 94 | 95 | It is useful to define as many `c:display/1` function heads as you want, pattern matching on specific values to pick out the specific ones you'd like to customize. 96 | 97 | The `assigns` that is passed in is a map of the following form: 98 | 99 | # assigns 100 | %{ 101 | module: Library.Author, 102 | field: :last_name, 103 | resource: %Library.Author{ ... }, # the entire ecto struct 104 | value: "Smith", 105 | type: :binary, # the Ecto data type 106 | redacted: false 107 | %} 108 | 109 | So for example, if you wanted every last name to be red except for the Author table, which should have blue last names, you could do the following: 110 | 111 | @impl Dumper.Config 112 | def display(%{field: :last_name, module: Library.Author} = assigns) do 113 | ~H|<%= @value %>| 114 | end 115 | 116 | def display(%{field: :last_name} = assigns) do 117 | ~H|<%= @value %>| 118 | end 119 | 120 | In this way, you can have near complete control over how a particular field, data type, or entire module is displayed. 121 | 122 | Note that LiveDashboard ships with [Bootstrap 4.6](https://getbootstrap.com/docs/4.6), so you are free to use Bootstrap classes in your styling to help achieve a consistent look and feel. 123 | """ 124 | @callback display(assigns :: map) :: Phoenix.LiveView.Rendered.t() 125 | 126 | @doc ~S""" 127 | Custom links rendered when viewing a specific record. 128 | 129 | This function takes a record you can pattern match on, and must return a list of `{route, text}` 130 | tuples. 131 | 132 | @impl Dumper.Config 133 | def custom_record_links(%Book{} = book) do 134 | [ 135 | {~p"/logging/#{book.id}", "Logs"}, 136 | {"https://goodreads.com/search?q=#{book.title}", "Goodreads"}, 137 | ] 138 | end 139 | 140 | def custom_record_links(%Ticket{} = ticket), 141 | do: [{"https://jira.com/#{ticket.project}/#{ticket.id}", "Jira"}] 142 | 143 | In the above example, any `Book` record you visit in the Dumper will display two links labelled 144 | "Logs" and "Goodreads" at the top of the page. Any `Ticket` record will likewise display one 145 | link, "Jira", in that spot. Routes can be internal or external, verified routes, or plain strings. 146 | 147 | Logs, dashboards, traces, support portals, etc are all common use cases, but any `{route, text}` 148 | pair is possible. 149 | """ 150 | @callback custom_record_links(record :: map) :: [{route :: binary, display_text :: binary}] 151 | 152 | @doc """ 153 | Tables so large they timeout when querying the row count. 154 | 155 | By including schema modules in this list, Dumper will omit querying and displaying of the 156 | total number of entries and order by `id` instead of `inserted_at`. A clue you may need 157 | to add a table to this list is if the page errors out when rendering the list of records. 158 | 159 | @impl Dumper.Config 160 | def large_tables, do: [Book, Patrons] 161 | """ 162 | @callback large_tables() :: [atom] 163 | 164 | @doc """ 165 | Additional records to be rendered on the given record's page. 166 | 167 | @impl Dumper.Config 168 | def additional_associations(%Book{id: book_id}) do 169 | # look up reviews on goodreads 170 | [goodreads_reviews: [top_review, lowest_review]] 171 | end 172 | 173 | For a given record, return a keyword list of association name to list of records. Overriding 174 | this callback allows you to render more data at the bottom of the page. This is useful 175 | when the given record doesn't explicitly define an association, or the data you want to 176 | display doesn't live in the database. 177 | 178 | Note that while regular associations are paginated, since these are custom we can't 179 | automatically paginate them. It's recommended to cap the number of records returned. 180 | """ 181 | @callback additional_associations(record :: map) :: Keyword.t() 182 | 183 | @doc """ 184 | A mapping from schema module => list of its fields that will be rendered. 185 | 186 | Can return 187 | - `nil`: all fields in all tables are shown 188 | - a map: a field is only displayed if the exact Schema + field pairing exists in the map 189 | - `{map, :strict}`: a field is only displayed if the exact Schema + field pairing exists in the map 190 | - `{map, :lenient}`: a field is displayed if the exact Schema + field pairing exists in the map **or** the schema module is not present in the map 191 | 192 | Here's an example where we return a mapping of schema modules to the fields we want to allow displayed: 193 | 194 | @impl Dumper.Config 195 | def allowed_fields() do 196 | %{ 197 | Patron => [:id, :last_name], 198 | Book => [:title] 199 | } 200 | end 201 | 202 | In the above example, the returned map defaults to strict mode. Rendered fields would 203 | include `patron.last_name` and `book.title`. Hidden fields would include fields like 204 | `patron.first_name` and `author.last_name`. 205 | 206 | @impl Dumper.Config 207 | def allowed_fields() do 208 | map = %{ 209 | Patron => [:id, :last_name], 210 | Book => [:title] 211 | } 212 | {map, :lenient} 213 | end 214 | 215 | 216 | In the above example, `lenient` strictness means that `author.last_name` would now be rendered even though the `Author` key not explicitly defined in the returned mapping. 217 | 218 | It's recommended to at least include the primary key field in the list so that there is at least 219 | one field to display. 220 | 221 | `c:excluded_fields/0` is ignored if: 222 | - this callback is implemented and returns strict mode (or returns only a map) 223 | - this callback is implemented and returns lenient mode, but the schema key is present in the map 224 | """ 225 | @callback allowed_fields() :: nil | map() | {map(), :strict | :lenient} 226 | 227 | @doc """ 228 | A mapping from schema module => list of its fields that will be excluded from being rendered. 229 | 230 | @impl Dumper.Config 231 | def excluded_fields() do 232 | %{ 233 | Library.Patron => [:email_address, :date_of_birth], 234 | Library.Book => [:purchase_price] 235 | } 236 | end 237 | 238 | In the above example, when displaying any patron record, the `email_address` and `date_of_birth` 239 | fields will not be rendered. All others fields will be displayed. The `email_address` and 240 | `date_of_birth` fields will not even be sent down through the `c:display/1` callback. The 241 | same for any book record; the `purchase_price` field will never be rendered. All other 242 | schemas are unaffected - for example a `Author` schema would display all of its fields, even 243 | though it is not included in the returned map. 244 | 245 | The excluded fields will only hide fields for a module if the module exists as a key in the 246 | returned map. 247 | 248 | It's recommended to at least include the primary key field in the list so that there is at least 249 | one field to display. 250 | 251 | See `c:allowed_fields/0` for cases where `c:excluded_fields/0` is ignored. 252 | """ 253 | @callback excluded_fields() :: map() 254 | 255 | defmacro __using__(_opts) do 256 | quote do 257 | @behaviour Dumper.Config 258 | 259 | use Phoenix.Component 260 | 261 | @impl Dumper.Config 262 | def ids_to_schema, do: %{} 263 | 264 | @impl Dumper.Config 265 | def large_tables, do: [] 266 | 267 | @impl Dumper.Config 268 | def allowed_fields, do: nil 269 | 270 | @impl Dumper.Config 271 | def excluded_fields, do: %{} 272 | 273 | @before_compile {Dumper.Config, :add_display_fallback} 274 | @before_compile {Dumper.Config, :add_custom_record_links_fallback} 275 | @before_compile {Dumper.Config, :add_additional_associations_fallback} 276 | 277 | defoverridable ids_to_schema: 0 278 | defoverridable large_tables: 0 279 | defoverridable allowed_fields: 0 280 | defoverridable excluded_fields: 0 281 | end 282 | end 283 | 284 | @doc false 285 | defmacro add_additional_associations_fallback(_env) do 286 | quote do 287 | @impl true 288 | def additional_associations(_), do: [] 289 | end 290 | end 291 | 292 | @doc false 293 | defmacro add_custom_record_links_fallback(_env) do 294 | quote do 295 | @impl true 296 | def custom_record_links(_), do: [] 297 | end 298 | end 299 | 300 | @doc false 301 | defmacro add_display_fallback(_env) do 302 | quote do 303 | @impl true 304 | def display(assigns) do 305 | Dumper.Config.default_display(assigns) 306 | end 307 | end 308 | end 309 | 310 | @doc false 311 | def ids_to_schema, do: %{} 312 | 313 | @doc false 314 | def large_tables, do: [] 315 | 316 | @doc false 317 | def allowed_fields, do: nil 318 | 319 | @doc false 320 | def excluded_fields, do: %{} 321 | 322 | @doc false 323 | def additional_associations(_), do: [] 324 | 325 | @doc false 326 | def custom_record_links(_), do: [] 327 | 328 | @doc false 329 | def display(assigns), do: default_display(assigns) 330 | 331 | @doc false 332 | def default_display(assigns) do 333 | assigns = assign(assigns, id_link_schema: assigns.config_module.ids_to_schema()[assigns.field]) 334 | default_style_value(assigns) 335 | end 336 | 337 | defp default_style_value(%{id_link_schema: schema} = assigns) when not is_nil(schema) do 338 | ~H|<.link navigate={"#{@dumper_home}?module=#{@id_link_schema}&id=#{@value}"}> 339 | <%= @value %> 340 | | 341 | end 342 | 343 | defp default_style_value(%{redacted: true} = assigns), do: ~H|redacted| 344 | defp default_style_value(%{value: nil} = assigns), do: ~H|nil| 345 | defp default_style_value(%{value: true} = assigns), do: ~H|true| 346 | defp default_style_value(%{value: false} = assigns), do: ~H|false| 347 | defp default_style_value(%{type: :binary_id} = assigns), do: ~H|
<%= @value %>
| 348 | defp default_style_value(%{type: :date} = assigns), do: ~H/<%= @value |> Calendar.strftime("%b %d, %Y") %>/ 349 | 350 | defp default_style_value(%{type: type} = assigns) 351 | when type in ~w/utc_datetime_usec naive_datetime_usec utc_datetime naive_datetime/a do 352 | ~H""" 353 | <%= Calendar.strftime(@value, "%b %d, %Y") %> 354 |   <%= Calendar.strftime(@value, "%I:%M:%S.%f %p") %> 355 | """ 356 | end 357 | 358 | defp default_style_value(assigns), do: ~H|
<%= inspect(@value, pretty: true) %>
| 359 | end 360 | -------------------------------------------------------------------------------- /lib/dumper/live_dashboard_page.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Dumper.LiveDashboardPage do 12 | @moduledoc false 13 | 14 | use Phoenix.LiveDashboard.PageBuilder, refresher?: false 15 | 16 | import Ecto.Query 17 | 18 | alias Phoenix.LiveDashboard.PageBuilder 19 | 20 | @assoc_page_size 5 21 | 22 | @impl true 23 | def menu_link(_, _), do: {:ok, "Dumper"} 24 | 25 | @impl true 26 | def init(term), do: {:ok, Map.new(term)} 27 | 28 | @impl true 29 | def mount(params, %{repo: _} = session, socket) do 30 | # config module via params is a hack to enable testing 31 | # it will not override a config module defined in the router options 32 | config_module = Map.get(params, "config_module", Dumper.Config) 33 | config_module = if is_binary(config_module), do: String.to_existing_atom(config_module), else: config_module 34 | session = Map.put_new(session, :config_module, config_module) 35 | session = Map.put_new(session, :otp_app, session.repo.config()[:otp_app]) 36 | 37 | dumper_home = PageBuilder.live_dashboard_path(socket, %{socket.assigns.page | params: %{}}) 38 | {:ok, socket |> assign(session) |> assign(dumper_home: dumper_home)} 39 | end 40 | 41 | defp clear_assigns(socket) do 42 | assign(socket, 43 | module: nil, 44 | records: nil, 45 | record: nil, 46 | associations: nil, 47 | additional_associations: nil 48 | ) 49 | end 50 | 51 | @impl true 52 | def handle_params(%{"module" => module, "id" => id} = params, _uri, socket) do 53 | repo = socket.assigns.repo 54 | module = to_module(module) 55 | record = repo.get!(module, id) 56 | 57 | associations = 58 | Enum.map(module.__schema__(:associations), fn assoc -> 59 | pagenum = Map.get(params, to_string(assoc), 1) 60 | page_params = %{"pagenum" => pagenum, "page_size" => @assoc_page_size} 61 | {assoc, Dumper.paginate(from(u in Ecto.assoc(record, assoc)), page_params, socket.assigns.repo)} 62 | end) 63 | 64 | {:noreply, 65 | socket 66 | |> clear_assigns() 67 | |> assign( 68 | module: module, 69 | record: record, 70 | associations: associations, 71 | additional_associations: Dumper.additional_associations(record, socket.assigns.config_module) 72 | )} 73 | end 74 | 75 | def handle_params(%{"module" => module} = params, _uri, socket) do 76 | page = Map.merge(%{"pagenum" => 1, "page_size" => 25}, params) 77 | module = to_module(module) 78 | fields = module.__schema__(:fields) 79 | large_table? = module in socket.assigns.config_module.large_tables() 80 | 81 | query = Ecto.Queryable.to_query(module) 82 | query = if :inserted_at in fields && !large_table?, do: order_by(query, desc: :inserted_at), else: query 83 | query = if :id in fields, do: order_by(query, desc: :id), else: query 84 | total_entries = if large_table?, do: nil, else: socket.assigns.repo.aggregate(query, :count) 85 | 86 | {:noreply, 87 | socket 88 | |> clear_assigns() 89 | |> assign( 90 | module: module, 91 | records: Dumper.paginate(query, page, socket.assigns.repo), 92 | total_entries: total_entries 93 | )} 94 | end 95 | 96 | def handle_params(_params, _uri, socket) do 97 | {:noreply, clear_assigns(socket)} 98 | end 99 | 100 | @impl true 101 | def render(assigns) do 102 | ~H""" 103 |
104 |
<.link navigate={@dumper_home}>Dumper Home
105 | 106 | 107 | 108 |
109 | """ 110 | end 111 | 112 | @impl true 113 | def handle_event("show_table", %{"module" => module}, socket) do 114 | to = 115 | PageBuilder.live_dashboard_path(socket, %{ 116 | socket.assigns.page 117 | | params: %{"module" => module} 118 | }) 119 | 120 | {:noreply, push_navigate(socket, to: to)} 121 | end 122 | 123 | def handle_event("show_record", %{"module" => module, "id" => record_id}, socket) do 124 | to = record_path(module, record_id, socket) 125 | {:noreply, push_navigate(socket, to: to)} 126 | end 127 | 128 | def handle_event("to-page", %{"pagenum" => pagenum} = params, socket) do 129 | param = if params["assoc"], do: String.to_atom(params["assoc"]), else: :pagenum 130 | to = PageBuilder.live_dashboard_path(socket, socket.assigns.page, %{param => pagenum}) 131 | {:noreply, push_patch(socket, to: to)} 132 | end 133 | 134 | def handle_event("select_limit", %{"limit" => limit}, socket) do 135 | to = 136 | PageBuilder.live_dashboard_path(socket, socket.assigns.page, pagenum: 1, page_size: limit) 137 | 138 | {:noreply, push_patch(socket, to: to)} 139 | end 140 | 141 | def handle_event("id-search", %{"search" => search}, socket) do 142 | to = record_path(socket.assigns.module, search, socket) 143 | {:noreply, push_patch(socket, to: to)} 144 | end 145 | 146 | defp to_module(nil), do: nil 147 | 148 | defp to_module(module_param) do 149 | module = module_param |> String.split(".") |> Module.safe_concat() 150 | if is_ecto_schema?(module), do: module 151 | rescue 152 | # Module.safe_concat will raise if the resource string doesn't map to an existing atom 153 | ArgumentError -> nil 154 | end 155 | 156 | defp is_ecto_schema?(module) do 157 | # true iff it 1. is a module 2. has a __schema__ function 3. is not an Embedded Schema 158 | match?({:module, _}, Code.ensure_loaded(module)) && 159 | {:__schema__, 1} in module.__info__(:functions) && 160 | module.__schema__(:source) != nil 161 | end 162 | 163 | defp record_path(module, record_id, socket) do 164 | PageBuilder.live_dashboard_path(socket, %{ 165 | socket.assigns.page 166 | | params: %{"module" => module, "id" => record_id} 167 | }) 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Dumper.MixProject do 12 | use Mix.Project 13 | 14 | @version "0.2.7" 15 | @url "https://github.com/adobe/elixir-dumper" 16 | 17 | def project do 18 | [ 19 | app: :dumper, 20 | version: @version, 21 | elixir: "~> 1.15", 22 | elixirc_paths: elixirc_paths(Mix.env()), 23 | start_permanent: Mix.env() == :prod, 24 | deps: deps(), 25 | 26 | ## Hex 27 | package: package(), 28 | description: "A LiveDashboard page for browsing the data in your Ecto Repo", 29 | 30 | # Docs 31 | name: "Dumper", 32 | docs: docs() 33 | ] 34 | end 35 | 36 | # Run "mix help compile.app" to learn about applications. 37 | def application do 38 | [ 39 | extra_applications: [:logger] 40 | ] 41 | end 42 | 43 | defp elixirc_paths(:test), do: ["lib", "test/support"] 44 | defp elixirc_paths(_), do: ["lib"] 45 | 46 | # Run "mix help deps" to learn about dependencies. 47 | defp deps do 48 | [ 49 | {:earmark, ">= 1.4.0"}, 50 | {:ecto, ">= 3.7.0"}, 51 | {:phoenix_ecto, ">= 4.4.0"}, 52 | {:phoenix_live_dashboard, ">= 0.8.3"}, 53 | {:phoenix_live_view, ">= 0.19.0"}, 54 | {:phoenix_html, ">= 3.3.0"}, 55 | {:ex_doc, "~> 0.33", runtime: false, only: :dev}, 56 | {:ecto_sql, "~> 3.5", only: [:dev, :test]}, 57 | {:ecto_sqlite3, "~> 0.7", only: :test}, 58 | {:floki, "~> 0.36.0", only: :test}, 59 | {:faker, "~> 0.17", only: :test}, 60 | {:styler, "~> 1.1", only: [:dev, :test], runtime: false} 61 | ] 62 | end 63 | 64 | defp package do 65 | [ 66 | maintainers: ["Ryan Young"], 67 | licenses: ["Apache-2.0"], 68 | links: %{"GitHub" => @url} 69 | ] 70 | end 71 | 72 | defp docs do 73 | [ 74 | main: "readme", 75 | assets: %{"assets" => "assets"}, 76 | source_ref: "v#{@version}", 77 | source_url: @url, 78 | extras: [ 79 | "CHANGELOG.md": [title: "Changelog"], 80 | "README.md": [title: "Dumper"] 81 | ], 82 | filter_modules: "Dumper.Config" 83 | ] 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, 3 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, 4 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 5 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 6 | "earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"}, 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 | "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 | "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.17.4", "48dd9c6d0fc10875a64545d04f0478b142898b6f0e73ae969becf5726f834d22", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "f67372e0eae5e5cbdd1d145e78e670fc5064d5810adf99d104d364cb920e306a"}, 11 | "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, 12 | "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"}, 13 | "exqlite": {:hex, :exqlite, "0.27.0", "2ef6021862e74c6253d1fb1f5701bd47e4e779b035d34daf2a13ec83945a05ba", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "b947b9db15bb7aad11da6cd18a0d8b78f7fcce89508a27a5b9be18350fe12c59"}, 14 | "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, 15 | "floki": {:hex, :floki, "0.36.3", "1102f93b16a55bc5383b85ae3ec470f82dee056eaeff9195e8afdf0ef2a43c30", [:mix], [], "hexpm", "fe0158bff509e407735f6d40b3ee0d7deb47f3f3ee7c6c182ad28599f9f6b27a"}, 16 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 17 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.0", "74bb8348c9b3a51d5c589bf5aebb0466a84b33274150e3b6ece1da45584afc82", [: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", "49159b7d7d999e836bedaf09dcf35ca18b312230cf901b725a64f3f42e407983"}, 18 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 19 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 21 | "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [: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", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, 22 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [: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", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, 23 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 24 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, 25 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [: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", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"}, 26 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 27 | "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"}, 28 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [: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", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 29 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 30 | "styler": {:hex, :styler, "1.1.2", "d5b14cd4f8f7cc45624d9485cd0edb277ec92583b118409cfcbcb7c78efa5f4b", [:mix], [], "hexpm", "b46edab1f129d0c839d426755e172cf92118e5fac877456d074156b335f1f80b"}, 31 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 32 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, 33 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 34 | "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"}, 35 | } 36 | -------------------------------------------------------------------------------- /test/dumper/config_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Dumper.ConfigTest do 12 | use Dumper.ConnCase, async: true 13 | 14 | describe "custom linked ids config" do 15 | defmodule LinkedIdsConfig do 16 | @moduledoc false 17 | use Dumper.Config 18 | 19 | def ids_to_schema, do: %{book_id: Book, author_id: Author} 20 | end 21 | 22 | test "links work on book id=100 page", %{conn: conn} do 23 | {:ok, view, _html} = navigate_to_book_100(conn, LinkedIdsConfig) 24 | assert has_element?(view, ~s(td[data-field="author_id"] a)) 25 | end 26 | 27 | test "links work on all books page", %{conn: conn} do 28 | {:ok, view, _html} = navigate_to_books_table(conn, LinkedIdsConfig) 29 | assert has_element?(view, ~s(tr:first-child td[data-field="author_id"] a)) 30 | end 31 | end 32 | 33 | describe "custom display config" do 34 | defmodule DisplayConfig do 35 | @moduledoc false 36 | use Dumper.Config 37 | 38 | def display(%{field: :title} = assigns), do: ~H|MY_UNIQUE_VALUE| 39 | end 40 | 41 | test "title replaced on book id=100 page", %{conn: conn} do 42 | {:ok, view, _html} = navigate_to_book_100(conn, DisplayConfig) 43 | title_text = view |> element(~s(td[data-field="title"])) |> render() 44 | assert title_text =~ "MY_UNIQUE_VALUE" 45 | end 46 | 47 | test "title replaced on all books page", %{conn: conn} do 48 | {:ok, _view, html} = navigate_to_books_table(conn, DisplayConfig) 49 | 50 | html 51 | |> Floki.parse_fragment!() 52 | |> Floki.find(~s(td[data-field="title"])) 53 | |> Enum.all?(fn td -> 54 | assert Floki.text(td) =~ "MY_UNIQUE_VALUE" 55 | end) 56 | end 57 | end 58 | 59 | describe "allowed fields config" do 60 | defmodule AllowedFieldsConfig do 61 | @moduledoc false 62 | use Dumper.Config 63 | 64 | def allowed_fields, do: %{Book => [:id, :title]} 65 | end 66 | 67 | test "books show only id and title on the book id=100 page", %{conn: conn} do 68 | {:ok, view, _html} = navigate_to_book_100(conn, AllowedFieldsConfig) 69 | assert has_element?(view, ~s(#dumper td[data-field="title"])) 70 | refute has_element?(view, ~s(#dumper td[data-field="author_id"])) 71 | end 72 | 73 | test "books show only id and title on the all books page", %{conn: conn} do 74 | {:ok, view, _html} = navigate_to_books_table(conn, AllowedFieldsConfig) 75 | assert has_element?(view, ~s(#dumper td[data-field="title"])) 76 | refute has_element?(view, ~s(#dumper td[data-field="author_id"])) 77 | end 78 | 79 | test "unspecified tables are completely hidden by default (strict)", %{conn: conn} do 80 | defmodule Strict do 81 | @moduledoc false 82 | use Dumper.Config 83 | 84 | def allowed_fields, do: %{Book => [:id, :title]} 85 | end 86 | 87 | # The book_reviews association is normally shown on this page. 88 | # Test that it is not shown since 89 | {:ok, view, _html} = navigate_to_book_100(conn, Strict) 90 | refute has_element?(view, ~s(div[data-association="reviews"] td[data-field="review_text"])) 91 | 92 | defmodule StrictExplicit do 93 | @moduledoc false 94 | use Dumper.Config 95 | 96 | def allowed_fields, do: {%{Book => [:id, :title]}, :strict} 97 | end 98 | 99 | {:ok, view, _html} = navigate_to_book_100(conn, StrictExplicit) 100 | refute has_element?(view, ~s(div[data-association="reviews"] td[data-field="review_text"])) 101 | end 102 | 103 | test "unspecified tables are shown when :lenient", %{conn: conn} do 104 | defmodule Lenient do 105 | @moduledoc false 106 | use Dumper.Config 107 | 108 | def allowed_fields, do: {%{Book => [:id, :title]}, :lenient} 109 | end 110 | 111 | {:ok, view, _html} = navigate_to_book_100(conn, Lenient) 112 | assert has_element?(view, ~s(div[data-association="reviews"] td[data-field="review_text"])) 113 | end 114 | end 115 | 116 | describe "excluded fields config" do 117 | defmodule ExcludedFieldsConfig do 118 | @moduledoc false 119 | use Dumper.Config 120 | 121 | def excluded_fields, do: %{Book => [:title]} 122 | end 123 | 124 | test "books show only id and title on the book id=100 page", %{conn: conn} do 125 | {:ok, view, _html} = navigate_to_book_100(conn, ExcludedFieldsConfig) 126 | refute has_element?(view, ~s(#dumper td[data-field="title"])) 127 | assert has_element?(view, ~s(#dumper td[data-field="author_id"])) 128 | end 129 | 130 | test "books show only id and title on the all books page", %{conn: conn} do 131 | {:ok, view, _html} = navigate_to_books_table(conn, ExcludedFieldsConfig) 132 | refute has_element?(view, ~s(#dumper td[data-field="title"])) 133 | assert has_element?(view, ~s(#dumper td[data-field="author_id"])) 134 | end 135 | 136 | test "excluded is ignored when allowed is specified", %{conn: conn} do 137 | defmodule ExcludedIgnored do 138 | @moduledoc false 139 | use Dumper.Config 140 | 141 | def allowed_fields, do: %{Book => [:id, :title]} 142 | def excluded_fields, do: %{Book => [:title, :author_id]} 143 | end 144 | 145 | {:ok, view, _html} = navigate_to_book_100(conn, ExcludedIgnored) 146 | assert has_element?(view, ~s(#dumper td[data-field="title"])) 147 | refute has_element?(view, ~s(#dumper td[data-field="author_id"])) 148 | # strict by default, so other schemas not specified in allowed_fields hide all fields 149 | refute has_element?(view, ~s(div[data-association="reviews"] td[data-field="review_text"])) 150 | 151 | defmodule ExcludedIgnoredLenient do 152 | @moduledoc false 153 | use Dumper.Config 154 | 155 | def allowed_fields, do: {%{Book => [:id, :title]}, :lenient} 156 | def excluded_fields, do: %{Book => [:title], BookReview => [:rating]} 157 | end 158 | 159 | {:ok, view, _html} = navigate_to_book_100(conn, ExcludedIgnoredLenient) 160 | assert has_element?(view, ~s(#dumper td[data-field="title"])) 161 | refute has_element?(view, ~s(#dumper td[data-field="author_id"])) 162 | # when lenient, schemas not specified in allowed_fields are allowed 163 | # so the excluded_fields map is checked 164 | assert has_element?(view, ~s(div[data-association="reviews"] td[data-field="review_text"])) 165 | refute has_element?(view, ~s(div[data-association="reviews"] td[data-field="rating"])) 166 | end 167 | end 168 | 169 | describe "custom record links config" do 170 | defmodule RecordLinksConfig do 171 | @moduledoc false 172 | use Dumper.Config 173 | 174 | def custom_record_links(%Book{}), do: [{"http://example.com", "example link"}] 175 | end 176 | 177 | test "displays links on the book id=100 page", %{conn: conn} do 178 | {:ok, _view, html} = navigate_to_book_100(conn, RecordLinksConfig) 179 | assert html =~ "example link" 180 | end 181 | end 182 | 183 | describe "additional associations config" do 184 | defmodule AdditionalAssociationsConfig do 185 | @moduledoc false 186 | use Dumper.Config 187 | 188 | def additional_associations(%Book{id: 100}) do 189 | [ 190 | foo: [%{id: 1, baz: "quux"}], 191 | my_unique_association_name: [%{a: 2, b: 2, c: 2}, %{a: 3, b: 3, c: 3}], 192 | more_authors: Repo.all(Author), 193 | empty_association: [] 194 | ] 195 | end 196 | end 197 | 198 | test "displays links on the book id=100 page", %{conn: conn} do 199 | {:ok, _view, html} = navigate_to_book_100(conn, AdditionalAssociationsConfig) 200 | assert html =~ "baz" 201 | assert html =~ "My Unique Association Name" 202 | end 203 | end 204 | 205 | describe "large tables config" do 206 | defmodule LargeTablesConfig do 207 | @moduledoc false 208 | use Dumper.Config 209 | 210 | def large_tables, do: [Book] 211 | end 212 | 213 | test "doesn't show page count for large tables", %{conn: conn} do 214 | {:ok, _view, html} = navigate_to_books_table(conn, LargeTablesConfig) 215 | refute html =~ "out of" 216 | assert html =~ "Showing at most" 217 | 218 | {:ok, _view, html} = navigate_to_authors_table(conn, LargeTablesConfig) 219 | assert html =~ "out of" 220 | assert html =~ "Showing at most" 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /test/dumper_test.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Dumper.DumperTest do 12 | use Dumper.ConnCase, async: true 13 | 14 | describe "The show all schemas page" do 15 | test "shows 5 entries in the table", %{conn: conn} do 16 | {:ok, view, _html} = live(conn, ~p"/dashboard/dumper") 17 | assert has_element?(view, "tr", "Book") 18 | assert has_element?(view, "tr", "BookReview") 19 | assert has_element?(view, "tr", "Loan") 20 | assert has_element?(view, "tr", "Patron") 21 | assert has_element?(view, "tr", "Author") 22 | end 23 | end 24 | 25 | describe "The Dumper Home header link" do 26 | test "works from the show table page", %{conn: conn} do 27 | {:ok, view, _html} = navigate_to_authors_table(conn) 28 | {:ok, _view, html} = navigate_to_dumper_home(view, conn) 29 | assert html =~ "schemas out of 5" 30 | end 31 | 32 | test "works from the show record page", %{conn: conn} do 33 | {:ok, view, _html} = navigate_to_author_100(conn) 34 | {:ok, _view, html} = navigate_to_dumper_home(view, conn) 35 | assert html =~ "schemas out of 5" 36 | end 37 | end 38 | 39 | describe "The show all records in a table page" do 40 | test "displays sorted records in the table", %{conn: conn} do 41 | {:ok, _view, html} = navigate_to_authors_table(conn) 42 | assert {100, 76} = results_between(html) 43 | end 44 | 45 | test "respects the page size limit dropdown", %{conn: conn} do 46 | # There are exactly 100 authors in the DB 47 | {:ok, view, _html} = navigate_to_authors_table(conn) 48 | 49 | # Set the limit to 1000, we should see all 100 rows 50 | assert {100, 1} = view |> change_page_size(1_000) |> results_between() 51 | 52 | # Set the limit back to 25, we should only see 25 rows again 53 | assert {100, 76} = view |> change_page_size(25) |> results_between() 54 | end 55 | 56 | test "can navigate to the next and prev pages", %{conn: conn} do 57 | # There are exactly 100 authors in the DB, so 4 pages of 25 58 | {:ok, view, _html} = navigate_to_authors_table(conn) 59 | 60 | # We're at the beginning, so there shouldn't be a Prev link 61 | refute has_element?(view, "#dumper a", "Prev") 62 | 63 | assert {75, 51} = view |> next_page() |> results_between() 64 | assert {50, 26} = view |> next_page() |> results_between() 65 | assert {25, 1} = view |> next_page() |> results_between() 66 | 67 | # We're at the end, so there shouldn't be a Next link 68 | refute has_element?(view, "#dumper a", "Next") 69 | 70 | # Click the "Prev" link and verify that we go backwards by 25 71 | assert {50, 26} = view |> prev_page() |> results_between() 72 | end 73 | 74 | test "Displays the Book schema moduledoc", %{conn: conn} do 75 | {:ok, view, _html} = navigate_to_books_table(conn) 76 | assert has_element?(view, "summary", "Documentation") 77 | assert has_element?(view, "details", "Representation of a Book for demo purposes") 78 | end 79 | end 80 | 81 | describe "The Book id 100 show table record page" do 82 | test "Displays the Book schema moduledoc", %{conn: conn} do 83 | {:ok, view, _html} = navigate_to_book_100(conn) 84 | assert has_element?(view, "summary", "Documentation") 85 | end 86 | 87 | test "Displays the author and reviews associations", %{conn: conn} do 88 | {:ok, view, _html} = navigate_to_book_100(conn) 89 | assert has_element?(view, "summary", "Author") 90 | assert has_element?(view, "summary", "Reviews") 91 | 92 | # assert there's only 1 author record, as a book only has 1 author 93 | assert {id, id} = 94 | view 95 | |> element("div[data-association=\"author\"]") 96 | |> render() 97 | |> results_between() 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Dumper.ConnCase do 12 | @moduledoc """ 13 | This module defines the test case to be used by 14 | tests that require setting up a connection. 15 | 16 | Such tests rely on `Phoenix.ConnTest` and also 17 | import other functionality to make it easier 18 | to build common data structures and query the data layer. 19 | 20 | Finally, if the test case interacts with the database, 21 | we enable the SQL sandbox, so changes done to the database 22 | are reverted at the end of every test. If you are using 23 | PostgreSQL, you can even run database tests asynchronously 24 | by setting `use LibraryWeb.ConnCase, async: true`, although 25 | this option is not recommended for other databases. 26 | """ 27 | 28 | use ExUnit.CaseTemplate 29 | 30 | using do 31 | quote do 32 | use Phoenix.VerifiedRoutes, 33 | endpoint: DumperTest.Endpoint, 34 | router: DumperTest.Router 35 | 36 | import Dumper.ConnCase 37 | import NavHelpers 38 | import Phoenix.ConnTest 39 | import Phoenix.LiveViewTest 40 | 41 | # Import conveniences for testing with connections 42 | import Plug.Conn 43 | 44 | @endpoint DumperTest.Endpoint 45 | end 46 | end 47 | 48 | setup _tags do 49 | {:ok, conn: Phoenix.ConnTest.build_conn()} 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/support/migrations/01_create_tables.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Migration.CreateTables do 12 | @moduledoc false 13 | use Ecto.Migration 14 | 15 | def change do 16 | create table(:books) do 17 | add :title, :string 18 | add :author_id, :integer 19 | add :published_at, :date 20 | 21 | timestamps() 22 | end 23 | 24 | create table(:authors) do 25 | add :first_name, :string 26 | add :last_name, :string 27 | add :date_of_birth, :date 28 | 29 | timestamps() 30 | end 31 | 32 | create table(:patrons) do 33 | add :first_name, :string 34 | add :last_name, :string 35 | add :date_of_birth, :date 36 | add :email_address, :string 37 | add :late_fees_balance, :integer 38 | 39 | timestamps() 40 | end 41 | 42 | create table(:loans) do 43 | add :patron_id, :integer 44 | add :book_id, :integer 45 | add :borrowed_at, :utc_datetime 46 | add :returned_at, :utc_datetime 47 | add :due_at, :utc_datetime 48 | 49 | timestamps() 50 | end 51 | 52 | create table(:book_reviews) do 53 | add :patron_id, :integer 54 | add :book_id, :integer 55 | add :rating, :integer 56 | add :review_text, :text 57 | 58 | timestamps() 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/support/migrations/02_seed_data.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Migration.SeedData do 12 | @moduledoc false 13 | use Ecto.Migration 14 | 15 | import Ecto.Query 16 | 17 | def up do 18 | Enum.each(1..100, fn _ -> 19 | author = 20 | Repo.insert!(%Author{ 21 | first_name: Faker.Person.first_name(), 22 | last_name: Faker.Person.last_name(), 23 | date_of_birth: Faker.Date.date_of_birth() 24 | }) 25 | 26 | # create books written by author 27 | Enum.each(1..Enum.random(3..8), fn _ -> 28 | Repo.insert!(%Book{ 29 | title: 1..4 |> Faker.Lorem.words() |> Enum.join(" "), 30 | published_at: Faker.Date.between(~D[1800-01-01], Date.utc_today()), 31 | author_id: author.id 32 | }) 33 | end) 34 | end) 35 | 36 | ############################################################################# 37 | ## Patrons, Loans, and BookReviews 38 | 39 | Enum.each(1..1000, fn _ -> 40 | late_fees = if Enum.random(1..100) > 75, do: Enum.random(1..10) * 10, else: 0 41 | 42 | patron = 43 | Repo.insert!(%Patron{ 44 | first_name: Faker.Person.first_name(), 45 | last_name: Faker.Person.last_name(), 46 | date_of_birth: Faker.Date.date_of_birth(), 47 | email_address: Faker.Internet.free_email(), 48 | late_fees_balance: late_fees 49 | }) 50 | 51 | # Loans 52 | Enum.each(0..4, fn _ -> 53 | borrowed_at = 54 | ~D[1970-01-01] |> Faker.Date.between(~D[2023-12-01]) |> DateTime.new!(~T[00:00:00]) 55 | 56 | returned_at = 57 | if Enum.random(1..100) > 92, do: DateTime.add(borrowed_at, Enum.random(3..37), :day) 58 | 59 | loan = 60 | Repo.insert!(%Loan{ 61 | borrowed_at: borrowed_at, 62 | returned_at: returned_at, 63 | due_at: DateTime.add(borrowed_at, 21, :day), 64 | patron_id: patron.id, 65 | book_id: random_book_id() 66 | }) 67 | 68 | Repo.insert!(%BookReview{ 69 | rating: Enum.random(1..5), 70 | review_text: Faker.Lorem.paragraph(), 71 | patron_id: loan.patron_id, 72 | book_id: loan.book_id 73 | }) 74 | end) 75 | end) 76 | end 77 | 78 | def down do 79 | for schema <- [Book, BookReview, Author, Patron, Loan], 80 | do: Repo.delete_all(schema) 81 | end 82 | 83 | defp random_book_id do 84 | x = 85 | Book 86 | |> select([:id]) 87 | |> order_by(fragment("RANDOM()")) 88 | |> limit(1) 89 | |> Repo.one() 90 | 91 | x.id 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/support/nav_helpers.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule NavHelpers do 12 | @moduledoc false 13 | use Phoenix.VerifiedRoutes, 14 | endpoint: DumperTest.Endpoint, 15 | router: DumperTest.Router 16 | 17 | import Phoenix.ConnTest 18 | import Phoenix.LiveViewTest 19 | 20 | @endpoint DumperTest.Endpoint 21 | 22 | defp add_config_to_path(path, config) do 23 | if URI.new!(path).query == nil, 24 | do: path <> "?config_module=#{config}", 25 | else: path <> "&config_module=#{config}" 26 | end 27 | 28 | defp href(href_element) do 29 | [href] = href_element |> render() |> Floki.parse_fragment!() |> Floki.attribute("href") 30 | href 31 | end 32 | 33 | def navigate_to_dumper_home(view, conn, config \\ Dumper.Config) do 34 | path = view |> element("a", "Dumper Home") |> href() 35 | live(conn, add_config_to_path(path, config)) 36 | end 37 | 38 | def navigate_to_books_table(conn, config \\ Dumper.Config), do: navigate_to_table(conn, "Book", config) 39 | def navigate_to_authors_table(conn, config \\ Dumper.Config), do: navigate_to_table(conn, "Author", config) 40 | 41 | defp navigate_to_table(conn, schema, config) do 42 | path = ~p"/dashboard/dumper?module=#{schema}" 43 | live(conn, add_config_to_path(path, config)) 44 | end 45 | 46 | def navigate_to_author_100(conn, config \\ Dumper.Config) do 47 | {:ok, view, _html} = navigate_to_authors_table(conn, config) 48 | change_page_size(view, 1_000) 49 | book_100_path = view |> element(~s(#dumper td[data-field="id"] a), ~r/100\s*/) |> href() 50 | live(conn, add_config_to_path(book_100_path, config)) 51 | end 52 | 53 | def navigate_to_book_100(conn, config \\ Dumper.Config) do 54 | {:ok, view, _html} = navigate_to_books_table(conn, config) 55 | change_page_size(view, 1_000) 56 | book_100_path = view |> element(~s(#dumper td[data-field="id"] a), ~r/100\s*/) |> href() 57 | live(conn, add_config_to_path(book_100_path, config)) 58 | end 59 | 60 | def change_page_size(view, limit) do 61 | view |> element("#dumper form", "Showing at most") |> render_change(%{"limit" => limit}) 62 | end 63 | 64 | def next_page(view), do: view |> element("#dumper a", "Next") |> render_click() 65 | def prev_page(view), do: view |> element("#dumper a", "Prev") |> render_click() 66 | 67 | def results_between(html) do 68 | rows = html |> Floki.parse_document!() |> Floki.find("tbody tr") 69 | 70 | to_int = fn tr -> 71 | tr 72 | |> Floki.find("td") 73 | |> List.first() 74 | |> Floki.text() 75 | |> String.trim() 76 | |> String.to_integer() 77 | end 78 | 79 | {rows |> List.first() |> to_int.(), rows |> List.last() |> to_int.()} 80 | end 81 | 82 | def is_author_page?(view), do: has_element?(view, "h5", "Author") 83 | end 84 | -------------------------------------------------------------------------------- /test/support/phoenix_setup.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule DumperTest.Router do 12 | use Phoenix.Router 13 | 14 | import Phoenix.LiveDashboard.Router 15 | 16 | live_dashboard "/dashboard", 17 | additional_pages: [dumper: {Dumper.LiveDashboardPage, repo: Repo}] 18 | end 19 | 20 | defmodule DumperTest.Endpoint do 21 | use Phoenix.Endpoint, otp_app: :dumper 22 | 23 | plug DumperTest.Router 24 | end 25 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Repo do 12 | use Ecto.Repo, otp_app: :dumper, adapter: Ecto.Adapters.SQLite3 13 | end 14 | -------------------------------------------------------------------------------- /test/support/schemas/author.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Author do 12 | @moduledoc false 13 | use Ecto.Schema 14 | 15 | schema "authors" do 16 | field(:first_name, :string) 17 | field(:last_name, :string) 18 | field(:date_of_birth, :date) 19 | 20 | has_many(:books, Book) 21 | 22 | timestamps() 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/support/schemas/book.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Book do 12 | @moduledoc """ 13 | Representation of a Book for demo purposes. 14 | 15 | Markdown is rendered in earmark. 16 | * ~strike text~ 17 | * **bold text** 18 | """ 19 | use Ecto.Schema 20 | 21 | schema "books" do 22 | field(:title, :string) 23 | field(:published_at, :date) 24 | 25 | belongs_to(:author, Author) 26 | has_many(:reviews, BookReview) 27 | 28 | timestamps() 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/support/schemas/book_review.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule BookReview do 12 | @moduledoc false 13 | use Ecto.Schema 14 | 15 | schema "book_reviews" do 16 | field(:rating, :integer) 17 | field(:review_text, :string) 18 | 19 | belongs_to(:patron, Patron) 20 | belongs_to(:book, Book) 21 | 22 | timestamps() 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/support/schemas/loan.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Loan do 12 | @moduledoc false 13 | use Ecto.Schema 14 | 15 | schema "loans" do 16 | field(:borrowed_at, :utc_datetime) 17 | field(:returned_at, :utc_datetime) 18 | field(:due_at, :utc_datetime) 19 | 20 | belongs_to(:patron, Patron) 21 | belongs_to(:book, Book) 22 | 23 | timestamps() 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/schemas/patron.ex: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | defmodule Patron do 12 | @moduledoc false 13 | use Ecto.Schema 14 | 15 | schema "patrons" do 16 | field(:first_name, :string) 17 | field(:last_name, :string) 18 | field(:date_of_birth, :date) 19 | field(:email_address, :string, redact: true) 20 | field(:late_fees_balance, :integer) 21 | 22 | has_many(:loans, Loan) 23 | has_many(:reviews, BookReview) 24 | 25 | timestamps() 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Adobe. All rights reserved. 2 | # This file is licensed to you under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. You may obtain a copy 4 | # of the License at http://www.apache.org/licenses/LICENSE-2.0 5 | 6 | # Unless required by applicable law or agreed to in writing, software distributed under 7 | # the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 8 | # OF ANY KIND, either express or implied. See the License for the specific language 9 | # governing permissions and limitations under the License. 10 | 11 | ################################################## 12 | # Test phoenix web server setup 13 | 14 | Supervisor.start_link([DumperTest.Endpoint], strategy: :one_for_one) 15 | 16 | ################################################## 17 | # DB setup and seeding 18 | 19 | _ = Repo.__adapter__().storage_up(Repo.config()) 20 | {:ok, _} = Supervisor.start_link([Repo], strategy: :one_for_one) 21 | Ecto.Migrator.run(Repo, "test/support/migrations", :up, all: true) 22 | 23 | ExUnit.start(exclude: :integration) 24 | --------------------------------------------------------------------------------