├── .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 | [](https://hex.pm/packages/dumper)
2 | [](https://hexdocs.pm/dumper)
3 | [](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 | 
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 | <%= field %> |
43 |
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 | |
55 |
56 |
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 |
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 |
33 | <%= field %>
34 | |
35 |
36 |
37 |
38 |
39 |
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 | |
57 |
58 |
59 |
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 |
--------------------------------------------------------------------------------