From b6177e025df96702598df3287254b6fd086a8f8c Mon Sep 17 00:00:00 2001 From: Jonathan <3713684+gsmlg@users.noreply.github.com> Date: Thu, 22 Sep 2022 00:28:34 +0800 Subject: [PATCH] feat: BREAKING CHANGE: Project architecture to umbrella project and remove third party web component. (#4) BREAKING CHANGE: * feat: Change architecture to umbrella project. * fix: Fix base dev. * feat: Add live storybook. * fix: fix test. * fix: Fix names. * fix: Fix filename to exs. * fix: Fix component. * feat: Update storybook. * fix: Add class. * feat: Add tailwind css. * feat: Add appbar. * fix: Fix config. * feat: Add markdown add appbar. * feat: Add pagination. * fix: Fix pagination. * chore: Add CI. * chore: Fix format. * fix: Fix test. * chore: Fix. * feat: Add use :alias. * docs: Add README.md Co-authored-by: Jonathan Gao --- .formatter.exs | 3 +- .github/workflows/test-and-release.yml | 3 + .gitignore | 32 +- README.md | 47 +- apps/phoenix_webcomponent/.formatter.exs | 3 + apps/phoenix_webcomponent/.gitignore | 11 + .../phoenix_webcomponent/.releaserc.yaml | 1 + .../phoenix_webcomponent/CHANGELOG.md | 0 LICENSE => apps/phoenix_webcomponent/LICENSE | 0 apps/phoenix_webcomponent/README.md | 81 ++ .../assets/css/phoenix_webcomponent.css | 5 + .../assets/js/phoenix_webcomponent.js | 1 + .../assets/tailwind.config.js | 22 + .../phoenix_webcomponent/babel.config.json | 0 .../lib/phoenix_webcomponent.ex | 91 ++ .../lib/phoenix_webcomponent/appbar.ex | 48 + .../lib/phoenix_webcomponent/helpers}/link.ex | 136 ++- .../lib}/phoenix_webcomponent/markdown.ex | 35 +- .../lib/phoenix_webcomponent/pagination.ex | 109 +++ .../lib/phoenix_webcomponent/table.ex | 44 + apps/phoenix_webcomponent/mix.exs | 68 ++ apps/phoenix_webcomponent/mix.lock | 21 + apps/phoenix_webcomponent/package.json | 31 + .../phoenix_webcomponent/markdown_test.exs | 12 + .../test/phoenix_webcomponent_test.exs | 11 + .../test}/test_helper.exs | 0 apps/phx_wc_storybook/.formatter.exs | 3 + apps/phx_wc_storybook/.gitignore | 23 + apps/phx_wc_storybook/README.md | 3 + apps/phx_wc_storybook/lib/phx_wc_storybook.ex | 9 + .../lib/phx_wc_storybook/application.ex | 19 + apps/phx_wc_storybook/mix.exs | 51 + apps/phx_wc_storybook/test/test_helper.exs | 1 + apps/phx_wc_storybook_web/.formatter.exs | 4 + apps/phx_wc_storybook_web/.gitignore | 34 + apps/phx_wc_storybook_web/README.md | 18 + apps/phx_wc_storybook_web/assets/css/app.css | 155 +++ apps/phx_wc_storybook_web/assets/js/app.js | 45 + .../assets/tailwind.config.js | 22 + .../assets/vendor/topbar.js | 157 +++ .../lib/phx_wc_storybook_web.ex | 115 +++ .../lib/phx_wc_storybook_web/application.ex | 32 + .../controllers/page_controller.ex | 11 + .../lib/phx_wc_storybook_web/endpoint.ex | 49 + .../lib/phx_wc_storybook_web/gettext.ex | 24 + .../lib/phx_wc_storybook_web/router.ex | 28 + .../lib/phx_wc_storybook_web/storybook.ex | 4 + .../storybook/components/markdown.exs | 54 ++ .../lib/phx_wc_storybook_web/telemetry.ex | 48 + .../templates/layout/app.html.heex | 5 + .../templates/layout/live.html.heex | 11 + .../templates/layout/root.html.heex | 30 + .../templates/page/index.html.heex | 19 + .../views/error_helpers.ex | 47 + .../phx_wc_storybook_web/views/error_view.ex | 16 + .../phx_wc_storybook_web/views/layout_view.ex | 7 + .../phx_wc_storybook_web/views/page_view.ex | 3 + apps/phx_wc_storybook_web/lib/table.exs | 18 + apps/phx_wc_storybook_web/mix.exs | 68 ++ .../priv/gettext/en/LC_MESSAGES/errors.po | 11 + .../priv/gettext/errors.pot | 10 + .../priv/static/favicon.ico | Bin 0 -> 1258 bytes .../priv/static/images/phoenix.png | Bin 0 -> 13900 bytes .../priv/static/robots.txt | 5 + .../controllers/page_controller_test.exs | 8 + .../views/error_view_test.exs | 15 + .../views/layout_view_test.exs | 8 + .../views/page_view_test.exs | 3 + .../test/support/conn_case.ex | 37 + .../phx_wc_storybook_web/test/test_helper.exs | 1 + config/config.exs | 77 +- config/dev.exs | 45 + config/prod.exs | 17 + config/runtime.exs | 40 + config/test.exs | 14 + lib/phoenix_webcomponent.ex | 153 --- lib/phoenix_webcomponent/form_helper.ex | 898 ------------------ lib/phoenix_webcomponent/table.ex | 96 -- lib/phoenix_webcomponent/top_app_bar.ex | 99 -- mix.exs | 77 +- mix.lock | 33 +- package.json | 50 - priv/src/phoenix_html.js | 80 -- priv/src/phoenix_webcomponent.js | 34 - .../phoenix_webcomponent/form_helper_test.exs | 456 --------- test/phoenix_webcomponent/link_test.exs | 170 ---- test/phoenix_webcomponent/markdown_test.exs | 29 - test/phoenix_webcomponent/table_test.exs | 11 - .../phoenix_webcomponent/top_app_bar_test.exs | 21 - test/phoenix_webcomponent_test.exs | 13 - 90 files changed, 2203 insertions(+), 2256 deletions(-) create mode 100644 apps/phoenix_webcomponent/.formatter.exs create mode 100644 apps/phoenix_webcomponent/.gitignore rename .releaserc.yaml => apps/phoenix_webcomponent/.releaserc.yaml (96%) rename CHANGELOG.md => apps/phoenix_webcomponent/CHANGELOG.md (100%) rename LICENSE => apps/phoenix_webcomponent/LICENSE (100%) create mode 100644 apps/phoenix_webcomponent/README.md create mode 100644 apps/phoenix_webcomponent/assets/css/phoenix_webcomponent.css create mode 100644 apps/phoenix_webcomponent/assets/js/phoenix_webcomponent.js create mode 100644 apps/phoenix_webcomponent/assets/tailwind.config.js rename babel.config.json => apps/phoenix_webcomponent/babel.config.json (100%) create mode 100644 apps/phoenix_webcomponent/lib/phoenix_webcomponent.ex create mode 100644 apps/phoenix_webcomponent/lib/phoenix_webcomponent/appbar.ex rename {lib/phoenix_webcomponent => apps/phoenix_webcomponent/lib/phoenix_webcomponent/helpers}/link.ex (62%) rename {lib => apps/phoenix_webcomponent/lib}/phoenix_webcomponent/markdown.ex (53%) create mode 100644 apps/phoenix_webcomponent/lib/phoenix_webcomponent/pagination.ex create mode 100644 apps/phoenix_webcomponent/lib/phoenix_webcomponent/table.ex create mode 100644 apps/phoenix_webcomponent/mix.exs create mode 100644 apps/phoenix_webcomponent/mix.lock create mode 100644 apps/phoenix_webcomponent/package.json create mode 100644 apps/phoenix_webcomponent/test/phoenix_webcomponent/markdown_test.exs create mode 100644 apps/phoenix_webcomponent/test/phoenix_webcomponent_test.exs rename {test => apps/phoenix_webcomponent/test}/test_helper.exs (100%) create mode 100644 apps/phx_wc_storybook/.formatter.exs create mode 100644 apps/phx_wc_storybook/.gitignore create mode 100644 apps/phx_wc_storybook/README.md create mode 100644 apps/phx_wc_storybook/lib/phx_wc_storybook.ex create mode 100644 apps/phx_wc_storybook/lib/phx_wc_storybook/application.ex create mode 100644 apps/phx_wc_storybook/mix.exs create mode 100644 apps/phx_wc_storybook/test/test_helper.exs create mode 100644 apps/phx_wc_storybook_web/.formatter.exs create mode 100644 apps/phx_wc_storybook_web/.gitignore create mode 100644 apps/phx_wc_storybook_web/README.md create mode 100644 apps/phx_wc_storybook_web/assets/css/app.css create mode 100644 apps/phx_wc_storybook_web/assets/js/app.js create mode 100644 apps/phx_wc_storybook_web/assets/tailwind.config.js create mode 100644 apps/phx_wc_storybook_web/assets/vendor/topbar.js create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/application.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/controllers/page_controller.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/endpoint.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/gettext.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/router.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/storybook.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/storybook/components/markdown.exs create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/telemetry.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/app.html.heex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/live.html.heex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/root.html.heex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/page/index.html.heex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/error_helpers.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/error_view.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/layout_view.ex create mode 100644 apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/page_view.ex create mode 100644 apps/phx_wc_storybook_web/lib/table.exs create mode 100644 apps/phx_wc_storybook_web/mix.exs create mode 100644 apps/phx_wc_storybook_web/priv/gettext/en/LC_MESSAGES/errors.po create mode 100644 apps/phx_wc_storybook_web/priv/gettext/errors.pot create mode 100644 apps/phx_wc_storybook_web/priv/static/favicon.ico create mode 100644 apps/phx_wc_storybook_web/priv/static/images/phoenix.png create mode 100644 apps/phx_wc_storybook_web/priv/static/robots.txt create mode 100644 apps/phx_wc_storybook_web/test/phx_wc_storybook_web/controllers/page_controller_test.exs create mode 100644 apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/error_view_test.exs create mode 100644 apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/layout_view_test.exs create mode 100644 apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/page_view_test.exs create mode 100644 apps/phx_wc_storybook_web/test/support/conn_case.ex create mode 100644 apps/phx_wc_storybook_web/test/test_helper.exs create mode 100644 config/dev.exs create mode 100644 config/prod.exs create mode 100644 config/runtime.exs create mode 100644 config/test.exs delete mode 100644 lib/phoenix_webcomponent.ex delete mode 100644 lib/phoenix_webcomponent/form_helper.ex delete mode 100644 lib/phoenix_webcomponent/table.ex delete mode 100644 lib/phoenix_webcomponent/top_app_bar.ex delete mode 100644 package.json delete mode 100644 priv/src/phoenix_html.js delete mode 100644 priv/src/phoenix_webcomponent.js delete mode 100644 test/phoenix_webcomponent/form_helper_test.exs delete mode 100644 test/phoenix_webcomponent/link_test.exs delete mode 100644 test/phoenix_webcomponent/markdown_test.exs delete mode 100644 test/phoenix_webcomponent/table_test.exs delete mode 100644 test/phoenix_webcomponent/top_app_bar_test.exs delete mode 100644 test/phoenix_webcomponent_test.exs diff --git a/.formatter.exs b/.formatter.exs index d304ff32..6915976e 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,3 +1,4 @@ [ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["mix.exs", "config/*.exs"], + subdirectories: ["apps/*"] ] diff --git a/.github/workflows/test-and-release.yml b/.github/workflows/test-and-release.yml index cfd3eb43..6af076b6 100644 --- a/.github/workflows/test-and-release.yml +++ b/.github/workflows/test-and-release.yml @@ -8,6 +8,7 @@ on: - beta - next - next-major + - umbrella jobs: tests: name: Run tests (Elixir ${{matrix.elixir}}, OTP ${{matrix.otp}}) @@ -104,6 +105,8 @@ jobs: @semantic-release/changelog @semantic-release/exec @semantic-release/git + dry_run: true + working_directory: apps/phoenix_webcomponent env: MIX_ENV: prod GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 79f1c8fe..232e94c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,25 @@ -/_build -/cover -/node_modules -/package-lock.json -/deps -/doc -/priv/static/phoenix_webcomponent.js -/priv/static/ -/.elixir_ls +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). *.ez + +# Temporary files, for example, from tests. +/tmp + + diff --git a/README.md b/README.md index 4e30c8f5..a95239ba 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ About at [Web Component](https://developer.mozilla.org/en-US/docs/Web/Web_Compon Add deps in `mix.exs` ```elixir - {:phoenix_webcomponent, "~> 1.0"}, + {:phoenix_webcomponent, "~> 2.0"}, ``` Include in phoenix view helpers @@ -24,10 +24,11 @@ Include in phoenix view helpers ```elixir defp view_helpers do quote do - # Use all HTML functionality (forms, tags, etc) - use Phoenix.HTML + # import all helper functions use Phoenix.WebComponent + # or + use Phoenix.WebComponent, :alias ... end end @@ -49,33 +50,13 @@ npm install phoenix_webcomponent ### All helpers -- wc_button -- wc_checkbox -- wc_color_input -- wc_date_input -- wc_date_select -- wc_datetime_local_input -- wc_datetime_select -- wc_email_input -- wc_file_input -- wc_link -- wc_live_patch -- wc_live_redirect -- wc_multiple_select -- wc_number_input -- wc_password_input -- wc_radio_button -- wc_range_input -- wc_remark -- wc_reset -- wc_search_input -- wc_select -- wc_submit -- wc_telephone_input -- wc_textarea -- wc_text_input -- wc_time_input -- wc_time_select -- wc_url_input -- wc_switch -- wc_top_app_bar +- wc_appbar +- wc_markdown +- wc_pagination +- wc_table + +## Live Storybook + +[Live Storybook](https://phoenix-webcomponent.gsmlg.org) + + diff --git a/apps/phoenix_webcomponent/.formatter.exs b/apps/phoenix_webcomponent/.formatter.exs new file mode 100644 index 00000000..d304ff32 --- /dev/null +++ b/apps/phoenix_webcomponent/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/phoenix_webcomponent/.gitignore b/apps/phoenix_webcomponent/.gitignore new file mode 100644 index 00000000..79f1c8fe --- /dev/null +++ b/apps/phoenix_webcomponent/.gitignore @@ -0,0 +1,11 @@ +/_build +/cover +/node_modules +/package-lock.json +/deps +/doc +/priv/static/phoenix_webcomponent.js +/priv/static/ +/.elixir_ls +erl_crash.dump +*.ez diff --git a/.releaserc.yaml b/apps/phoenix_webcomponent/.releaserc.yaml similarity index 96% rename from .releaserc.yaml rename to apps/phoenix_webcomponent/.releaserc.yaml index 4670b57f..7f3b3a4f 100644 --- a/.releaserc.yaml +++ b/apps/phoenix_webcomponent/.releaserc.yaml @@ -3,6 +3,7 @@ branches: - main - next - next-major + - {name: 'umbrella', prerelease: true} - {name: 'beta', prerelease: true} - {name: 'alpha', prerelease: true} plugins: diff --git a/CHANGELOG.md b/apps/phoenix_webcomponent/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to apps/phoenix_webcomponent/CHANGELOG.md diff --git a/LICENSE b/apps/phoenix_webcomponent/LICENSE similarity index 100% rename from LICENSE rename to apps/phoenix_webcomponent/LICENSE diff --git a/apps/phoenix_webcomponent/README.md b/apps/phoenix_webcomponent/README.md new file mode 100644 index 00000000..4e30c8f5 --- /dev/null +++ b/apps/phoenix_webcomponent/README.md @@ -0,0 +1,81 @@ +# Phoenix.WebComponent + +[![release](https://github.com/gsmlg-dev/phoenix_webcomponent/actions/workflows/test-and-release.yml/badge.svg)](https://github.com/gsmlg-dev/phoenix_webcomponent/actions/workflows/test-and-release.yml) + +Collection of helpers to generate and manipulate Web Component. + +Although this project was originally extracted from Phoenix, +it does not depend on Phoenix and can be used with any Plug +application (or even without Plug). + +See the [docs](https://hexdocs.pm/phoenix_webcomponent/) for more information. + +About at [Web Component](https://developer.mozilla.org/en-US/docs/Web/Web_Components) + +## Install + +Add deps in `mix.exs` +```elixir + {:phoenix_webcomponent, "~> 1.0"}, +``` + +Include in phoenix view helpers + +```elixir + defp view_helpers do + quote do + # Use all HTML functionality (forms, tags, etc) + use Phoenix.HTML + use Phoenix.WebComponent + + ... + end +end +``` + +Include javascript + +```javascript +import 'phoenix_webcomponent'; +``` + +By default, javascirpt is at `priv/static/phoenix_webcomponent.js` and bundle all packages. + +If you wannt better javascript bundle, you can use npm version. + +```bash +npm install phoenix_webcomponent +``` + +### All helpers + +- wc_button +- wc_checkbox +- wc_color_input +- wc_date_input +- wc_date_select +- wc_datetime_local_input +- wc_datetime_select +- wc_email_input +- wc_file_input +- wc_link +- wc_live_patch +- wc_live_redirect +- wc_multiple_select +- wc_number_input +- wc_password_input +- wc_radio_button +- wc_range_input +- wc_remark +- wc_reset +- wc_search_input +- wc_select +- wc_submit +- wc_telephone_input +- wc_textarea +- wc_text_input +- wc_time_input +- wc_time_select +- wc_url_input +- wc_switch +- wc_top_app_bar diff --git a/apps/phoenix_webcomponent/assets/css/phoenix_webcomponent.css b/apps/phoenix_webcomponent/assets/css/phoenix_webcomponent.css new file mode 100644 index 00000000..1f8afc35 --- /dev/null +++ b/apps/phoenix_webcomponent/assets/css/phoenix_webcomponent.css @@ -0,0 +1,5 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + + diff --git a/apps/phoenix_webcomponent/assets/js/phoenix_webcomponent.js b/apps/phoenix_webcomponent/assets/js/phoenix_webcomponent.js new file mode 100644 index 00000000..1d1256e4 --- /dev/null +++ b/apps/phoenix_webcomponent/assets/js/phoenix_webcomponent.js @@ -0,0 +1 @@ +import '@gsmlg/lit'; diff --git a/apps/phoenix_webcomponent/assets/tailwind.config.js b/apps/phoenix_webcomponent/assets/tailwind.config.js new file mode 100644 index 00000000..ba0b19e7 --- /dev/null +++ b/apps/phoenix_webcomponent/assets/tailwind.config.js @@ -0,0 +1,22 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +let plugin = require('tailwindcss/plugin') + +module.exports = { + content: [ + './js/**/*.js', + '../lib/phoenix_webcomponent/*.ex', + '../lib/phoenix_webcomponent/**/*.*ex' + ], + theme: { + extend: {}, + }, + plugins: [ + require('@tailwindcss/forms'), + plugin(({addVariant}) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])), + plugin(({addVariant}) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])), + plugin(({addVariant}) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])), + plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])) + ] +} diff --git a/babel.config.json b/apps/phoenix_webcomponent/babel.config.json similarity index 100% rename from babel.config.json rename to apps/phoenix_webcomponent/babel.config.json diff --git a/apps/phoenix_webcomponent/lib/phoenix_webcomponent.ex b/apps/phoenix_webcomponent/lib/phoenix_webcomponent.ex new file mode 100644 index 00000000..4b3e0006 --- /dev/null +++ b/apps/phoenix_webcomponent/lib/phoenix_webcomponent.ex @@ -0,0 +1,91 @@ +defmodule Phoenix.WebComponent do + @moduledoc """ + Provides a suit of html custom component for phoenix. + + This library provides three main functionalities: + + * Enhance form helper with manterial web componet + * Enhance link helper with manterial web componet + * Markdown render helper with `@gsmlg/lit/remark-element` + * TopAppBar render top app bar with custom element. + + ## Form helper + + See `Phoenix.WebComponent.FormHelper`. + + ## JavaScript library + + This project provides javascript that define custom elements. + + To use the web component, you must load `priv/static/phoenix_webcomponent.js` + into your build tool. Or through npm by install `phoenix_webcomponent`. + The difference is npm version is not bundled. + + """ + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(view_helpers()) + end + end + + def component do + quote do + use Phoenix.Component + + unquote(view_helpers()) + end + end + + def alias do + quote do + alias Phoenix.WebComponent.Appbar + alias Phoenix.WebComponent.Markdown + alias Phoenix.WebComponent.Table + alias Phoenix.WebComponent.Pagination + end + end + + defp view_helpers do + quote do + # Use all HTML functionality (forms, tags, etc) + use Phoenix.HTML + + # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) + import Phoenix.LiveView.Helpers + + # Import basic rendering functionality (render, render_layout, etc) + import Phoenix.View + end + end + + defp components do + quote do + import Phoenix.WebComponent.Appbar + import Phoenix.WebComponent.Markdown + import Phoenix.WebComponent.Table + import Phoenix.WebComponent.Pagination + end + end + + @doc """ + Import helpers for internal usage. + + Support: + + - `use Phoenix.WebComponent, :component` + - `use Phoenix.WebComponent, :live_component` + + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end + + defmacro __using__(_) do + quote do + unquote(components()) + end + end +end diff --git a/apps/phoenix_webcomponent/lib/phoenix_webcomponent/appbar.ex b/apps/phoenix_webcomponent/lib/phoenix_webcomponent/appbar.ex new file mode 100644 index 00000000..f1434cdb --- /dev/null +++ b/apps/phoenix_webcomponent/lib/phoenix_webcomponent/appbar.ex @@ -0,0 +1,48 @@ +defmodule Phoenix.WebComponent.Appbar do + @moduledoc """ + render appbar + + """ + use Phoenix.WebComponent, :component + + import Phoenix.WebComponent.Helpers.Link + + @doc """ + Generates a html customElement appbar. + + ## Attributes + + - `title` binary + example: "App Title" + - `menus` List + example: [ %{ label: "Menu Name", to: Routes.index_path(@conn, :index) } ] + + ## Slots + + - `logo` + - `user_profile` + + """ + def wc_appbar(assigns) do + assigns = + assigns + |> assign_new(:logo, fn -> [] end) + |> assign_new(:title, fn -> "GSMLG Title" end) + |> assign_new(:menus, fn -> [] end) + |> assign_new(:user_profile, fn -> [] end) + + ~H""" + + + <%= for menu <- @menus do %> + <%= wc_link menu.label, to: menu.to, slot: "nav" %> + <% end %> + + <%= render_slot(@user_profile) %> + + + """ + end +end diff --git a/lib/phoenix_webcomponent/link.ex b/apps/phoenix_webcomponent/lib/phoenix_webcomponent/helpers/link.ex similarity index 62% rename from lib/phoenix_webcomponent/link.ex rename to apps/phoenix_webcomponent/lib/phoenix_webcomponent/helpers/link.ex index 343915e5..e491c145 100644 --- a/lib/phoenix_webcomponent/link.ex +++ b/apps/phoenix_webcomponent/lib/phoenix_webcomponent/helpers/link.ex @@ -1,4 +1,4 @@ -defmodule Phoenix.WebComponent.Link do +defmodule Phoenix.WebComponent.Helpers.Link do @moduledoc """ Conveniences for working with links and URLs in HTML. """ @@ -6,35 +6,102 @@ defmodule Phoenix.WebComponent.Link do import Phoenix.HTML.Tag alias Phoenix.LiveView.Socket + @doc """ + Returns a list of attributes that make an element behave like a link. + For example, to make a button work like a link: + + However, this function is more often used to create buttons that + must invoke an action on the server, such as deleting an entity, + using the relevant HTTP protocol: + + The `to` argument may be a string, a URI, or a tuple `{scheme, value}`. + See the examples below. + Note: using this function requires loading the JavaScript library + at `priv/static/phoenix_html.js`. See the `Phoenix.HTML` module + documentation for more information. + ## Options + * `:method` - the HTTP method for the link. Defaults to `:get`. + * `:csrf_token` - a custom token to use when method is not `:get`. + This is used to ensure the request was sent by the user who + rendered the page. By default, CSRF tokens are generated through + `Plug.CSRFProtection`. You can set this option to `false`, to + disable token generation, or set it to your own token. + When the `:method` is set to `:get` and the `:to` URL contains query + parameters the generated form element will strip the parameters in + accordance with the [W3C](https://www.w3.org/TR/html401/interact/forms.html#h-17.13.3.4) + form specification. + ## Data attributes + The following data attributes can also be manually set in the element: + * `data-confirm` - shows a confirmation prompt before generating and + submitting the form. + ## Examples + iex> link_attributes("/world") + [data: [method: :get, to: "/world"]] + iex> link_attributes(URI.parse("https://elixir-lang.org")) + [data: [method: :get, to: "https://elixir-lang.org"]] + iex> link_attributes("/product/1", method: :delete) + [data: [csrf: Plug.CSRFProtection.get_csrf_token(), method: :delete, to: "/product/1"]] + ## If the URL is absolute, only certain schemas are allowed to avoid JavaScript injection. + For example, the following will fail + iex> link_attributes("javascript:alert('hacked!')") + ** (ArgumentError) unsupported scheme given as link. In case you want to link to an + unknown or unsafe scheme, such as javascript, use a tuple: {:javascript, rest} + You can however explicitly render those unsafe schemes by using a tuple: + iex> link_attributes({:javascript, "alert('my alert!')"}) + [data: [method: :get, to: ["javascript", 58, "alert('my alert!')"]]] + """ + def link_attributes(to, opts \\ []) do + to = valid_destination!(to) + method = Keyword.get(opts, :method, :get) + data = [method: method, to: to] + + data = + if method == :get do + data + else + case Keyword.get(opts, :csrf_token, true) do + true -> [csrf: Phoenix.HTML.Tag.csrf_token_value(to)] ++ data + false -> data + csrf when is_binary(csrf) -> [csrf: csrf] ++ data + end + end + + [data: data] + end + @doc """ Generates a link to the given URL. ## Examples wc_link("hello", to: "/world") - #=> hello + #=> hello wc_link("hello", to: URI.parse("https://elixir-lang.org")) - #=> hello + #=> hello wc_link("", to: "/world") - #=> <hello> + #=> <hello> wc_link("", to: "/world", class: "btn") - #=> <hello> + #=> <hello> wc_link("delete", to: "/the_world", data: [confirm: "Really?"]) - #=> delete + #=> delete # If you supply a method other than `:get`: wc_link("delete", to: "/everything", method: :delete) - #=> delete + #=> delete # You can use a `do ... end` block too: link to: "/hello" do "world" end - #=> world + #=> world ## Options @@ -79,24 +146,19 @@ defmodule Phoenix.WebComponent.Link do if method == :get do # Call link attributes to validate `to` - [data: data] = Phoenix.WebComponent.link_attributes(to, []) + [data: data] = link_attributes(to, []) {linkOpts, opts} = pop_link_attr(Keyword.delete(opts, :csrf_token)) - content_tag(:a, [href: data[:to]] ++ linkOpts) do - content_tag(:"bx-link", text, opts) - end + content_tag(:"wc-button", text, [href: data[:to]] ++ linkOpts ++ opts) else {csrf_token, opts} = Keyword.pop(opts, :csrf_token, true) opts = Keyword.put_new(opts, :rel, "nofollow") - [data: data] = - Phoenix.WebComponent.link_attributes(to, method: method, csrf_token: csrf_token) + [data: data] = link_attributes(to, method: method, csrf_token: csrf_token) {linkOpts, opts} = pop_link_attr(opts) - content_tag(:a, [data: data, href: data[:to]] ++ linkOpts) do - content_tag(:"bx-link", text, opts) - end + content_tag(:"wc-button", text, [data: data, href: data[:to]] ++ linkOpts ++ opts) end end @@ -148,8 +210,8 @@ defmodule Phoenix.WebComponent.Link do |> Keyword.put_new(:method, :post) |> Keyword.split([:method, :csrf_token]) - link_attributes = Phoenix.WebComponent.link_attributes(to, link_opts) - content_tag(:"bx-link", text, link_attributes ++ opts) + link_attributes = link_attributes(to, link_opts) + content_tag(:"wc-button", text, link_attributes ++ opts) end defp pop_required_option!(opts, key, error_message) do @@ -289,8 +351,40 @@ defmodule Phoenix.WebComponent.Link do {linkOpts, opts} = pop_link_attr(opts) - content_tag(:a, linkOpts) do - content_tag(:"bx-link", opts, do: block_or_text) + content_tag(:"wc-button", linkOpts ++ opts, do: block_or_text) + end + + defp valid_destination!(%URI{} = uri) do + valid_destination!(URI.to_string(uri)) + end + + defp valid_destination!({:safe, to}) do + {:safe, valid_string_destination!(IO.iodata_to_binary(to))} + end + + defp valid_destination!({other, to}) when is_atom(other) do + [Atom.to_string(other), ?:, to] + end + + defp valid_destination!(to) do + valid_string_destination!(IO.iodata_to_binary(to)) + end + + @valid_uri_schemes ~w(http: https: ftp: ftps: mailto: news: irc: gopher:) ++ + ~w(nntp: feed: telnet: mms: rtsp: svn: tel: fax: xmpp:) + + for scheme <- @valid_uri_schemes do + defp valid_string_destination!(unquote(scheme) <> _ = string), do: string + end + + defp valid_string_destination!(to) do + if not match?("/" <> _, to) and String.contains?(to, ":") do + raise ArgumentError, """ + unsupported scheme given as link. In case you want to link to an + unknown or unsafe scheme, such as javascript, use a tuple: {:javascript, rest}\ + """ + else + to end end end diff --git a/lib/phoenix_webcomponent/markdown.ex b/apps/phoenix_webcomponent/lib/phoenix_webcomponent/markdown.ex similarity index 53% rename from lib/phoenix_webcomponent/markdown.ex rename to apps/phoenix_webcomponent/lib/phoenix_webcomponent/markdown.ex index 98922a2a..ca16797b 100644 --- a/lib/phoenix_webcomponent/markdown.ex +++ b/apps/phoenix_webcomponent/lib/phoenix_webcomponent/markdown.ex @@ -8,23 +8,19 @@ defmodule Phoenix.WebComponent.Markdown do * charts render by mermaid.js """ - - import Phoenix.HTML.Tag + use Phoenix.WebComponent, :component @doc """ Generates a html customElement remark-element to preview markdown. Docs of remark-element (See https://gsmlg-dev.github.io/lit/?path=/story/gsmlg-remark-element--basic). - Useful to ensure that links that change data are not triggered by - search engines and other spidering software. - ## Examples - wc_remark("# Hello", class: "dark") + wc_markdown("# Hello", class: "dark") #=> - wc_remark(content: "# Hello", class: "btn") + wc_markdown(content: "# Hello", class: "btn") #=> ## Options @@ -33,21 +29,16 @@ defmodule Phoenix.WebComponent.Markdown do * `:content` - The content of markdown, replace innerHTML. - All other options are forwarded to the underlying button input. - """ - def wc_remark(text, opts) when is_binary(text) and is_list(opts) do - opts = Keyword.put(opts, :content, text) - wc_remark(opts) - end - - def wc_remark(text) when is_binary(text) do - opts = Keyword.put([], :content, text) - wc_remark(opts) - end - - def wc_remark(opts) when is_list(opts) do - {text, opts} = Keyword.pop(opts, :content, "") - content_tag(:"remark-element", text, opts) + def wc_markdown(assigns) do + assigns = + assigns + |> assign_new(:id, fn -> false end) + |> assign_new(:debug, fn -> false end) + |> assign_new(:class, fn -> false end) + + ~H""" + <%= @content %> + """ end end diff --git a/apps/phoenix_webcomponent/lib/phoenix_webcomponent/pagination.ex b/apps/phoenix_webcomponent/lib/phoenix_webcomponent/pagination.ex new file mode 100644 index 00000000..d6d0c1c2 --- /dev/null +++ b/apps/phoenix_webcomponent/lib/phoenix_webcomponent/pagination.ex @@ -0,0 +1,109 @@ +defmodule Phoenix.WebComponent.Pagination do + @moduledoc """ + Render table. + + """ + use Phoenix.WebComponent, :component + + @doc """ + Generates a table. + + ## Examples + + ## Options + + """ + def wc_pagination(assigns) do + assigns = + assigns + |> assign_new(:id, fn -> false end) + |> assign_new(:page_size, fn -> 30 end) + |> assign_new(:page_num, fn -> 1 end) + |> assign_new(:total, fn -> 0 end) + |> assign_new(:url_base, fn -> "\#{p}" end) + + IO.inspect(assigns) + + max_page = + if assigns.total > 0 do + (assigns.total / assigns.page_size) |> ceil + else + 1 + end + + assigns = assigns |> Map.put(:max_page, max_page) + page_num = assigns.page_num + + pages = + cond do + max_page == 1 -> + [1] + + max_page < 7 -> + 1..max_page |> Enum.map(& &1) + + page_num < 3 -> + [1, 2, 3] ++ ['...'] ++ [max_page - 2, max_page - 1, max_page] + + page_num == 3 -> + [1, 2, 3, 4] ++ ['...'] ++ [max_page - 2, max_page - 1, max_page] + + page_num > 3 && page_num < max_page - 2 -> + [1] ++ ['...', page_num - 1, page_num, page_num + 1, '...'] ++ [max_page] + + page_num == max_page - 2 -> + [1, 2, 3] ++ ['...', max_page - 3, max_page - 2, max_page - 1, max_page] + + page_num > max_page - 2 -> + [1, 2, 3] ++ ['...', max_page - 2, max_page - 1, max_page] + end + + assigns = assigns |> Map.put(:pages, pages) + + ~H""" +
+ + +
+ """ + end +end diff --git a/apps/phoenix_webcomponent/lib/phoenix_webcomponent/table.ex b/apps/phoenix_webcomponent/lib/phoenix_webcomponent/table.ex new file mode 100644 index 00000000..d50ee248 --- /dev/null +++ b/apps/phoenix_webcomponent/lib/phoenix_webcomponent/table.ex @@ -0,0 +1,44 @@ +defmodule Phoenix.WebComponent.Table do + @moduledoc """ + Render table. + + """ + use Phoenix.WebComponent, :component + + @doc """ + Generates a table. + + ## Examples + + ## Options + + """ + def wc_table(assigns) do + assigns = + assigns + |> assign_new(:id, fn -> false end) + |> assign_new(:col, fn -> [] end) + |> assign_new(:rows, fn -> [] end) + + ~H""" + + + + <%= for col <- @col do %> + + <% end %> + + + + <%= for row <- @rows do %> + + <%= for col <- @col do %> + + <% end %> + + <% end %> + +
<%= col.label %>
<%= render_slot(col, row) %>
+ """ + end +end diff --git a/apps/phoenix_webcomponent/mix.exs b/apps/phoenix_webcomponent/mix.exs new file mode 100644 index 00000000..664e8c77 --- /dev/null +++ b/apps/phoenix_webcomponent/mix.exs @@ -0,0 +1,68 @@ +defmodule PhoenixWebComponent.Mixfile do + use Mix.Project + + # Also change package.json version + @source_url "https://github.com/gsmlg-dev/phoenix_webcomponent.git" + @version "0.0.0" + + def project do + [ + app: :phoenix_webcomponent, + version: @version, + elixir: "~> 1.12", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + deps: deps(), + name: "Phoenix.WebComponent", + description: "Phoenix view functions for working with WebComponent", + package: package(), + aliases: aliases(), + docs: [ + extras: ["CHANGELOG.md"], + source_url: @source_url, + source_ref: "v#{@version}", + main: "Phoenix.WebComponent", + skip_undefined_reference_warnings_on: ["CHANGELOG.md"] + ] + ] + end + + def application do + [ + extra_applications: [:eex, :logger], + env: [csrf_token_reader: {Plug.CSRFProtection, :get_csrf_token_for, []}] + ] + end + + defp deps do + [ + {:phoenix_html, "~> 3.0"}, + {:phoenix_live_view, "~> 0.17.0"}, + {:plug, "~> 1.5", optional: true}, + {:jason, "~> 1.0"}, + {:esbuild, "~> 0.2", runtime: true}, + {:tailwind, "~> 0.1.6", runtime: Mix.env() == :dev}, + {:ex_doc, ">= 0.0.0", only: :prod, runtime: false} + ] + end + + defp package do + [ + maintainers: ["Jonathan Gao"], + licenses: ["MIT"], + files: ~w(lib priv CHANGELOG.md LICENSE mix.exs package.json README.md), + links: %{ + Changelog: "https://hexdocs.pm/phoenix_webcomponent/changelog.html", + GitHub: @source_url + } + ] + end + + defp aliases do + [ + prepublish: ["tailwind default --minify", "esbuild default --minify"] + ] + end +end diff --git a/apps/phoenix_webcomponent/mix.lock b/apps/phoenix_webcomponent/mix.lock new file mode 100644 index 00000000..4325d097 --- /dev/null +++ b/apps/phoenix_webcomponent/mix.lock @@ -0,0 +1,21 @@ +%{ + "castore": {:hex, :castore, "0.1.16", "2675f717adc700475345c5512c381ef9273eb5df26bdd3f8c13e2636cf4cc175", [:mix], [], "hexpm", "28ed2c43d83b5c25d35c51bc0abf229ac51359c170cba76171a462ced2e4b651"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, + "esbuild": {:hex, :esbuild, "0.4.0", "9f17db148aead4cf1e6e6a584214357287a93407b5fb51a031f122b61385d4c2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "b61e4e6b92ffe45e4ee4755a22de6211a67c67987dc02afb35a425a0add1d447"}, + "ex_doc": {:hex, :ex_doc, "0.28.3", "6eea2f69995f5fba94cd6dd398df369fe4e777a47cd887714a0976930615c9e6", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "05387a6a2655b5f9820f3f627450ed20b4325c25977b2ee69bed90af6688e718"}, + "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "phoenix": {:hex, :phoenix, "1.6.7", "f1de32418bbbcd471f4fe74d3860ee9c8e8c6c36a0ec173be8ff468a5d72ac90", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b354a4f11d9a2f3a380fb731042dae064f22d7aed8c7e7c024a2459f12994aad"}, + "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.9", "36b5aa812bc3ccd64c9630f6b3234d9ea21105493237e927aae19d0ba758f0db", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f7ebc3e0ba0c5f6b6996ed6c901ddbfdaba59a6d09b569e7cb2f2f7d693b4455"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, + "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, + "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, + "tailwind": {:hex, :tailwind, "0.1.9", "25ba09d42f7bfabe170eb67683a76d6ec2061952dc9bd263a52a99ba3d24bd4d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "9213f87709c458aaec313bb5f2df2b4d2cedc2b630e4ae821bf3c54c47a56d0b"}, + "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, +} diff --git a/apps/phoenix_webcomponent/package.json b/apps/phoenix_webcomponent/package.json new file mode 100644 index 00000000..aea64faf --- /dev/null +++ b/apps/phoenix_webcomponent/package.json @@ -0,0 +1,31 @@ +{ + "name": "phoenix_webcomponent", + "version": "0.0.0", + "main": "./priv/static/phoenix_webcomponent.js", + "repository": {}, + "scripts": { + "prepublish": "mix tailwind default && mix esbuild default" + }, + "files": [ + "README.md", + "CHANGELOG.md", + "LICENSE", + "package.json", + "priv/static/" + ], + "dependencies": { + "@gsmlg/lit": "^1.25.1", + "lit": "^2.3.1", + "lit-element": "^3.2.2", + "lit-html": "^2.3.1" + }, + "devDependencies": { + "@babel/core": "^7.19.0", + "@babel/preset-env": "^7.19.0" + }, + "peerDependencies": { + "lit": "^2.3.1", + "lit-element": "^3.2.2", + "lit-html": "^2.3.1" + } +} diff --git a/apps/phoenix_webcomponent/test/phoenix_webcomponent/markdown_test.exs b/apps/phoenix_webcomponent/test/phoenix_webcomponent/markdown_test.exs new file mode 100644 index 00000000..22aefc91 --- /dev/null +++ b/apps/phoenix_webcomponent/test/phoenix_webcomponent/markdown_test.exs @@ -0,0 +1,12 @@ +defmodule Phoenix.WebComponent.MarkdownTest do + use ExUnit.Case, async: true + + require Phoenix.LiveViewTest + import Phoenix.LiveViewTest + import Phoenix.WebComponent.Markdown + + test "remark empty" do + assert render_component(&wc_markdown/1, content: "value") == + ~s[value] + end +end diff --git a/apps/phoenix_webcomponent/test/phoenix_webcomponent_test.exs b/apps/phoenix_webcomponent/test/phoenix_webcomponent_test.exs new file mode 100644 index 00000000..f2a8ae97 --- /dev/null +++ b/apps/phoenix_webcomponent/test/phoenix_webcomponent_test.exs @@ -0,0 +1,11 @@ +defmodule Phoenix.WebComponentTest do + use ExUnit.Case, async: true + + # use Phoenix.WebComponent + # import Phoenix.WebComponent + doctest Phoenix.WebComponent + + test "link_attributes" do + assert "a" == "a" + end +end diff --git a/test/test_helper.exs b/apps/phoenix_webcomponent/test/test_helper.exs similarity index 100% rename from test/test_helper.exs rename to apps/phoenix_webcomponent/test/test_helper.exs diff --git a/apps/phx_wc_storybook/.formatter.exs b/apps/phx_wc_storybook/.formatter.exs new file mode 100644 index 00000000..3d8ce11a --- /dev/null +++ b/apps/phx_wc_storybook/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/phx_wc_storybook/.gitignore b/apps/phx_wc_storybook/.gitignore new file mode 100644 index 00000000..33a86d2b --- /dev/null +++ b/apps/phx_wc_storybook/.gitignore @@ -0,0 +1,23 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +phx_wc_storybook-*.tar diff --git a/apps/phx_wc_storybook/README.md b/apps/phx_wc_storybook/README.md new file mode 100644 index 00000000..9e8fda73 --- /dev/null +++ b/apps/phx_wc_storybook/README.md @@ -0,0 +1,3 @@ +# PhxWCStorybook + +**TODO: Add description** diff --git a/apps/phx_wc_storybook/lib/phx_wc_storybook.ex b/apps/phx_wc_storybook/lib/phx_wc_storybook.ex new file mode 100644 index 00000000..70d86363 --- /dev/null +++ b/apps/phx_wc_storybook/lib/phx_wc_storybook.ex @@ -0,0 +1,9 @@ +defmodule PhxWCStorybook do + @moduledoc """ + PhxWCStorybook keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/apps/phx_wc_storybook/lib/phx_wc_storybook/application.ex b/apps/phx_wc_storybook/lib/phx_wc_storybook/application.ex new file mode 100644 index 00000000..8821079e --- /dev/null +++ b/apps/phx_wc_storybook/lib/phx_wc_storybook/application.ex @@ -0,0 +1,19 @@ +defmodule PhxWCStorybook.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + # Start the PubSub system + {Phoenix.PubSub, name: PhxWCStorybook.PubSub} + # Start a worker by calling: PhxWCStorybook.Worker.start_link(arg) + # {PhxWCStorybook.Worker, arg} + ] + + Supervisor.start_link(children, strategy: :one_for_one, name: PhxWCStorybook.Supervisor) + end +end diff --git a/apps/phx_wc_storybook/mix.exs b/apps/phx_wc_storybook/mix.exs new file mode 100644 index 00000000..02ff7968 --- /dev/null +++ b/apps/phx_wc_storybook/mix.exs @@ -0,0 +1,51 @@ +defmodule PhxWCStorybook.MixProject do + use Mix.Project + + def project do + [ + app: :phx_wc_storybook, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.12", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {PhxWCStorybook.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix_pubsub, "~> 2.0"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get"] + ] + end +end diff --git a/apps/phx_wc_storybook/test/test_helper.exs b/apps/phx_wc_storybook/test/test_helper.exs new file mode 100644 index 00000000..869559e7 --- /dev/null +++ b/apps/phx_wc_storybook/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/apps/phx_wc_storybook_web/.formatter.exs b/apps/phx_wc_storybook_web/.formatter.exs new file mode 100644 index 00000000..47616780 --- /dev/null +++ b/apps/phx_wc_storybook_web/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:phoenix], + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/apps/phx_wc_storybook_web/.gitignore b/apps/phx_wc_storybook_web/.gitignore new file mode 100644 index 00000000..cad58b8a --- /dev/null +++ b/apps/phx_wc_storybook_web/.gitignore @@ -0,0 +1,34 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +phx_wc_storybook_web-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/apps/phx_wc_storybook_web/README.md b/apps/phx_wc_storybook_web/README.md new file mode 100644 index 00000000..77d24e88 --- /dev/null +++ b/apps/phx_wc_storybook_web/README.md @@ -0,0 +1,18 @@ +# PhxWCStorybookWeb + +To start your Phoenix server: + + * Install dependencies with `mix deps.get` + * Start Phoenix endpoint with `mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + + * Official website: https://www.phoenixframework.org/ + * Guides: https://hexdocs.pm/phoenix/overview.html + * Docs: https://hexdocs.pm/phoenix + * Forum: https://elixirforum.com/c/phoenix-forum + * Source: https://github.com/phoenixframework/phoenix diff --git a/apps/phx_wc_storybook_web/assets/css/app.css b/apps/phx_wc_storybook_web/assets/css/app.css new file mode 100644 index 00000000..4f0db667 --- /dev/null +++ b/apps/phx_wc_storybook_web/assets/css/app.css @@ -0,0 +1,155 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ +@import "../../../phoenix_webcomponent/priv/static/phoenix_webcomponent.css"; + +.rainbow { + z-index: -1; + width: 100vw; + height: 100vh; + position: fixed; + --opt: 0.2; + background: linear-gradient( + -55.62deg, + rgba(255, 0, 0, var(--opt)) 0%, + rgba(255, 154, 0, var(--opt)) 10%, + rgba(208, 222, 33, var(--opt)) 20%, + rgba(79, 220, 74, var(--opt)) 30%, + rgba(63, 218, 216, var(--opt)) 40%, + rgba(47, 201, 226, var(--opt)) 50%, + rgba(28, 127, 238, var(--opt)) 60%, + rgba(95, 21, 242, var(--opt)) 70%, + rgba(186, 12, 248, var(--opt)) 80%, + rgba(251, 7, 217, var(--opt)) 90%, + rgba(255, 0, 0, var(--opt)) 100% + ); +} + +app-bar { + --btn-link: rgb(238, 249, 238); +} + +/* Alerts and form errors used by phx.new */ +.alert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; +} +.alert-info { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} +.alert-warning { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} +.alert-danger { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} +.alert p { + margin-bottom: 0; +} +.alert:empty { + display: none; +} +.invalid-feedback { + color: #a94442; + display: block; + margin: -1rem 0 2rem; +} + +/* LiveView specific classes for your customization */ +.phx-no-feedback.invalid-feedback, +.phx-no-feedback .invalid-feedback { + display: none; +} + +.phx-click-loading { + opacity: 0.5; + transition: opacity 1s ease-out; +} + +.phx-loading{ + cursor: wait; +} + +.phx-modal { + opacity: 1!important; + position: fixed; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0,0,0,0.4); +} + +.phx-modal-content { + background-color: #fefefe; + margin: 15vh auto; + padding: 20px; + border: 1px solid #888; + width: 80%; +} + +.phx-modal-close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; +} + +.phx-modal-close:hover, +.phx-modal-close:focus { + color: black; + text-decoration: none; + cursor: pointer; +} + +.fade-in-scale { + animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; +} + +.fade-out-scale { + animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; +} + +.fade-in { + animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; +} +.fade-out { + animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; +} + +@keyframes fade-in-scale-keys{ + 0% { scale: 0.95; opacity: 0; } + 100% { scale: 1.0; opacity: 1; } +} + +@keyframes fade-out-scale-keys{ + 0% { scale: 1.0; opacity: 1; } + 100% { scale: 0.95; opacity: 0; } +} + +@keyframes fade-in-keys{ + 0% { opacity: 0; } + 100% { opacity: 1; } +} + +@keyframes fade-out-keys{ + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +.lsb-sandbox { + width: 100%; + height: fit-content; +} diff --git a/apps/phx_wc_storybook_web/assets/js/app.js b/apps/phx_wc_storybook_web/assets/js/app.js new file mode 100644 index 00000000..2a16f615 --- /dev/null +++ b/apps/phx_wc_storybook_web/assets/js/app.js @@ -0,0 +1,45 @@ +// We import the CSS which is extracted to its own file by esbuild. +// Remove this line if you add a your own CSS build pipeline (e.g postcss). + +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", info => topbar.show()) +window.addEventListener("phx:page-loading-stop", info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + +import 'phoenix_webcomponent'; \ No newline at end of file diff --git a/apps/phx_wc_storybook_web/assets/tailwind.config.js b/apps/phx_wc_storybook_web/assets/tailwind.config.js new file mode 100644 index 00000000..76fe4511 --- /dev/null +++ b/apps/phx_wc_storybook_web/assets/tailwind.config.js @@ -0,0 +1,22 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +let plugin = require('tailwindcss/plugin') + +module.exports = { + content: [ + './js/**/*.js', + '../lib/*_web.ex', + '../lib/*_web/**/*.*ex' + ], + theme: { + extend: {}, + }, + plugins: [ + require('@tailwindcss/forms'), + plugin(({addVariant}) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])), + plugin(({addVariant}) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])), + plugin(({addVariant}) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])), + plugin(({addVariant}) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])) + ] +} diff --git a/apps/phx_wc_storybook_web/assets/vendor/topbar.js b/apps/phx_wc_storybook_web/assets/vendor/topbar.js new file mode 100644 index 00000000..1f622097 --- /dev/null +++ b/apps/phx_wc_storybook_web/assets/vendor/topbar.js @@ -0,0 +1,157 @@ +/** + * @license MIT + * topbar 1.0.0, 2021-01-06 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + progressTimerId, + fadeTimerId, + currentProgress, + showing, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function () { + if (showing) return; + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web.ex new file mode 100644 index 00000000..bdb6f7b5 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web.ex @@ -0,0 +1,115 @@ +defmodule PhxWCStorybookWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, views, channels and so on. + + This can be used in your application as: + + use PhxWCStorybookWeb, :controller + use PhxWCStorybookWeb, :view + + The definitions below will be executed for every view, + controller, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define any helper function in modules + and import those modules here. + """ + + def controller do + quote do + use Phoenix.Controller, namespace: PhxWCStorybookWeb + + import Plug.Conn + alias PhxWCStorybookWeb.Router.Helpers, as: Routes + end + end + + def view do + quote do + use Phoenix.View, + root: "lib/phx_wc_storybook_web/templates", + namespace: PhxWCStorybookWeb + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] + + # Include shared imports and aliases for views + unquote(view_helpers()) + unquote(components()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {PhxWCStorybookWeb.LayoutView, "live.html"} + + unquote(view_helpers()) + unquote(components()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(view_helpers()) + end + end + + def component do + quote do + use Phoenix.Component + + unquote(view_helpers()) + end + end + + def router do + quote do + use Phoenix.Router + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + import PhxWCStorybookWeb.Gettext + end + end + + defp view_helpers do + quote do + # Use all HTML functionality (forms, tags, etc) + use Phoenix.HTML + + # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) + import Phoenix.LiveView.Helpers + + # Import basic rendering functionality (render, render_layout, etc) + import Phoenix.View + + alias PhxWCStorybookWeb.Router.Helpers, as: Routes + end + end + + defp components do + quote do + use Phoenix.WebComponent + end + end + + @doc """ + When used, dispatch to the appropriate controller/view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/application.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/application.ex new file mode 100644 index 00000000..b406c8c7 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/application.ex @@ -0,0 +1,32 @@ +defmodule PhxWCStorybookWeb.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + # Start the Telemetry supervisor + PhxWCStorybookWeb.Telemetry, + # Start the Endpoint (http/https) + PhxWCStorybookWeb.Endpoint + # Start a worker by calling: PhxWCStorybookWeb.Worker.start_link(arg) + # {PhxWCStorybookWeb.Worker, arg} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: PhxWCStorybookWeb.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + PhxWCStorybookWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/controllers/page_controller.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/controllers/page_controller.ex new file mode 100644 index 00000000..39ea63eb --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/controllers/page_controller.ex @@ -0,0 +1,11 @@ +defmodule PhxWCStorybookWeb.PageController do + use PhxWCStorybookWeb, :controller + + def index(conn, %{"p" => page} = _params) do + render(conn, "index.html", page: String.to_integer(page)) + end + + def index(conn, _params) do + render(conn, "index.html", page: 1) + end +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/endpoint.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/endpoint.ex new file mode 100644 index 00000000..da13a195 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/endpoint.ex @@ -0,0 +1,49 @@ +defmodule PhxWCStorybookWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :phx_wc_storybook_web + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_phx_wc_storybook_web_key", + signing_salt: "+Si2ZHDD" + ] + + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :phx_wc_storybook_web, + gzip: false, + only: ~w(assets fonts images favicon.ico robots.txt) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug PhxWCStorybookWeb.Router +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/gettext.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/gettext.ex new file mode 100644 index 00000000..ed8fab76 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/gettext.ex @@ -0,0 +1,24 @@ +defmodule PhxWCStorybookWeb.Gettext do + @moduledoc """ + A module providing Internationalization with a gettext-based API. + + By using [Gettext](https://hexdocs.pm/gettext), + your module gains a set of macros for translations, for example: + + import PhxWCStorybookWeb.Gettext + + # Simple translation + gettext("Here is the string to translate") + + # Plural translation + ngettext("Here is the string to translate", + "Here are the strings to translate", + 3) + + # Domain-based translation + dgettext("errors", "Here is the error message to translate") + + See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. + """ + use Gettext, otp_app: :phx_wc_storybook_web +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/router.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/router.ex new file mode 100644 index 00000000..4b8114b3 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/router.ex @@ -0,0 +1,28 @@ +defmodule PhxWCStorybookWeb.Router do + use PhxWCStorybookWeb, :router + import PhxLiveStorybook.Router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, {PhxWCStorybookWeb.LayoutView, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", PhxWCStorybookWeb do + pipe_through :browser + + get "/", PageController, :index + end + + live_storybook("/storybook", + otp_app: :phx_wc_storybook_web, + backend_module: PhxWCStorybookWeb.Storybook + ) +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/storybook.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/storybook.ex new file mode 100644 index 00000000..baae4fa5 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/storybook.ex @@ -0,0 +1,4 @@ +defmodule PhxWCStorybookWeb.Storybook do + @moduledoc false + use PhxLiveStorybook, otp_app: :phx_wc_storybook_web +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/storybook/components/markdown.exs b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/storybook/components/markdown.exs new file mode 100644 index 00000000..af746056 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/storybook/components/markdown.exs @@ -0,0 +1,54 @@ +defmodule PhxWCStorybookWeb.Storybook.Components.Markdown do + # :live_component or :page are also available + use PhxLiveStorybook.Entry, :component + + def function, do: &Phoenix.WebComponent.Markdown.wc_markdown/1 + def description, do: "A markdown render element." + + def stories do + [ + %Story{ + id: :default, + attributes: %{ + content: """ + # Page title + """ + } + }, + %Story{ + id: :with_code, + attributes: %{ + content: """ + # Code + ```elixir + IO.inspect :math.pow(2, 4) + ``` + """ + } + }, + %Story{ + id: :with_mermaid, + attributes: %{ + content: """ + ### Mermaid Chart + ```mermaid + graph LR; + A-->B; + A-->C; + B-->D; + C-->D; + ``` + + ```mermaid + graph TD; + A-->B; + A-->C; + B-->D; + C-->D; + ``` + """ + } + } + ] + end +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/telemetry.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/telemetry.ex new file mode 100644 index 00000000..975e9a91 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/telemetry.ex @@ -0,0 +1,48 @@ +defmodule PhxWCStorybookWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {PhxWCStorybookWeb, :count_users, []} + ] + end +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/app.html.heex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/app.html.heex new file mode 100644 index 00000000..da36c254 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/app.html.heex @@ -0,0 +1,5 @@ +
+ + + <%= @inner_content %> +
diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/live.html.heex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/live.html.heex new file mode 100644 index 00000000..a29d6044 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/live.html.heex @@ -0,0 +1,11 @@ +
+ + + + + <%= @inner_content %> +
diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/root.html.heex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/root.html.heex new file mode 100644 index 00000000..c52c51bd --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/layout/root.html.heex @@ -0,0 +1,30 @@ + + + + + + + + <%= live_title_tag assigns[:page_title] || "PhxWCStorybook", suffix: " · Phoenix Framework" %> + + + + +
+ <.wc_appbar + title={"Phoenix WebComponent"} + menus={[ + %{ label: "Component Storybook", to: Routes.live_storybook_path(@conn, :root) } + ]} + > + <:logo> + + + <:user_profile> + (^_^) + + + + <%= @inner_content %> + + diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/page/index.html.heex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/page/index.html.heex new file mode 100644 index 00000000..eee06a71 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/templates/page/index.html.heex @@ -0,0 +1,19 @@ +
+<.wc_pagination url_func={&(Routes.page_path(@conn, :index, [p: &1]))} total={10834} page_size={30} page_num={@page}> +
+ +
+ <.wc_table rows={Process.list |> Enum.map(&Process.info/1)}> + <:col let={process} label="Name"> + <%= process |> Keyword.get(:registered_name) %> + + + <:col let={process} label="Priority"> + <%=process |> Keyword.get(:priority) %> + + + <:col let={process} label="Status"> + <%=process |> Keyword.get(:status) %> + + +
diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/error_helpers.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/error_helpers.ex new file mode 100644 index 00000000..3a19855d --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/error_helpers.ex @@ -0,0 +1,47 @@ +defmodule PhxWCStorybookWeb.ErrorHelpers do + @moduledoc """ + Conveniences for translating and building error messages. + """ + + use Phoenix.HTML + + @doc """ + Generates tag for inlined form input errors. + """ + def error_tag(form, field) do + Enum.map(Keyword.get_values(form.errors, field), fn error -> + content_tag(:span, translate_error(error), + class: "invalid-feedback", + phx_feedback_for: input_name(form, field) + ) + end) + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate "is invalid" in the "errors" domain + # dgettext("errors", "is invalid") + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # Because the error messages we show in our forms and APIs + # are defined inside Ecto, we need to translate them dynamically. + # This requires us to call the Gettext module passing our gettext + # backend as first argument. + # + # Note we use the "errors" domain, which means translations + # should be written to the errors.po file. The :count option is + # set by Ecto and indicates we should also apply plural rules. + if count = opts[:count] do + Gettext.dngettext(PhxWCStorybookWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(PhxWCStorybookWeb.Gettext, "errors", msg, opts) + end + end +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/error_view.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/error_view.ex new file mode 100644 index 00000000..85445bb8 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/error_view.ex @@ -0,0 +1,16 @@ +defmodule PhxWCStorybookWeb.ErrorView do + use PhxWCStorybookWeb, :view + + # If you want to customize a particular status code + # for a certain format, you may uncomment below. + # def render("500.html", _assigns) do + # "Internal Server Error" + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.html" becomes + # "Not Found". + def template_not_found(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/layout_view.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/layout_view.ex new file mode 100644 index 00000000..4bc6e953 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/layout_view.ex @@ -0,0 +1,7 @@ +defmodule PhxWCStorybookWeb.LayoutView do + use PhxWCStorybookWeb, :view + + # Phoenix LiveDashboard is available only in development by default, + # so we instruct Elixir to not warn if the dashboard route is missing. + @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} +end diff --git a/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/page_view.ex b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/page_view.ex new file mode 100644 index 00000000..53724485 --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/views/page_view.ex @@ -0,0 +1,3 @@ +defmodule PhxWCStorybookWeb.PageView do + use PhxWCStorybookWeb, :view +end diff --git a/apps/phx_wc_storybook_web/lib/table.exs b/apps/phx_wc_storybook_web/lib/table.exs new file mode 100644 index 00000000..31eabdce --- /dev/null +++ b/apps/phx_wc_storybook_web/lib/table.exs @@ -0,0 +1,18 @@ +defmodule PhxWCStorybookWeb.Storybook.Components.Table do + # :live_component or :page are also available + use PhxLiveStorybook.Entry, :component + + def function, do: &Phoenix.WebComponent.Table.wc_table/1 + def description, do: "A markdown render element." + + def stories do + [ + %Story{ + id: :default, + attributes: %{ + cols: [%{label: "Name"}] + } + } + ] + end +end diff --git a/apps/phx_wc_storybook_web/mix.exs b/apps/phx_wc_storybook_web/mix.exs new file mode 100644 index 00000000..11a5ad90 --- /dev/null +++ b/apps/phx_wc_storybook_web/mix.exs @@ -0,0 +1,68 @@ +defmodule PhxWCStorybookWeb.MixProject do + use Mix.Project + + def project do + [ + app: :phx_wc_storybook_web, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.12", + elixirc_paths: elixirc_paths(Mix.env()), + compilers: Mix.compilers(), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {PhxWCStorybookWeb.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.6.8"}, + {:phoenix_html, "~> 3.0"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 0.17.5"}, + {:phx_live_storybook, "~> 0.3.0"}, + {:floki, ">= 0.30.0", only: :test}, + {:phoenix_live_dashboard, "~> 0.6"}, + {:tailwind, "~> 0.1.6", runtime: Mix.env() == :dev}, + {:esbuild, "~> 0.4", runtime: Mix.env() == :dev}, + {:telemetry_metrics, "~> 0.6"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 0.18"}, + {:phx_wc_storybook, in_umbrella: true}, + {:phoenix_webcomponent, in_umbrella: true}, + {:jason, "~> 1.2"}, + {:plug_cowboy, "~> 2.5"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get"], + "assets.deploy": ["tailwind storybook --minify", "esbuild storybook --minify", "phx.digest"] + ] + end +end diff --git a/apps/phx_wc_storybook_web/priv/gettext/en/LC_MESSAGES/errors.po b/apps/phx_wc_storybook_web/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 00000000..cdec3a11 --- /dev/null +++ b/apps/phx_wc_storybook_web/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,11 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" diff --git a/apps/phx_wc_storybook_web/priv/gettext/errors.pot b/apps/phx_wc_storybook_web/priv/gettext/errors.pot new file mode 100644 index 00000000..d6f47fa8 --- /dev/null +++ b/apps/phx_wc_storybook_web/priv/gettext/errors.pot @@ -0,0 +1,10 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. + diff --git a/apps/phx_wc_storybook_web/priv/static/favicon.ico b/apps/phx_wc_storybook_web/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..73de524aaadcf60fbe9d32881db0aa86b58b5cb9 GIT binary patch literal 1258 zcmbtUO>fgM7{=qN=;Mz_82;lvPEdVaxv-<-&=sZLwab?3I zBP>U*&(Hv<5n@9ZQ$vhg#|u$Zmtq8BV;+W*7(?jOx-{r?#TE&$Sdq77MbdJjD5`-q zMm_z(jLv3t>5NhzK{%aG(Yudfpjd3AFdKe2U7&zdepTe>^s(@!&0X8TJ`h+-I?84Ml# literal 0 HcmV?d00001 diff --git a/apps/phx_wc_storybook_web/priv/static/images/phoenix.png b/apps/phx_wc_storybook_web/priv/static/images/phoenix.png new file mode 100644 index 0000000000000000000000000000000000000000..9c81075f63d2151e6f40e9aa66f665749a87cc6a GIT binary patch literal 13900 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 UWxWXg`G1#+f~I_}taaG`2PLnS&Hw-a literal 0 HcmV?d00001 diff --git a/apps/phx_wc_storybook_web/priv/static/robots.txt b/apps/phx_wc_storybook_web/priv/static/robots.txt new file mode 100644 index 00000000..26e06b5f --- /dev/null +++ b/apps/phx_wc_storybook_web/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/controllers/page_controller_test.exs b/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/controllers/page_controller_test.exs new file mode 100644 index 00000000..93ccaf87 --- /dev/null +++ b/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/controllers/page_controller_test.exs @@ -0,0 +1,8 @@ +defmodule PhxWCStorybookWeb.PageControllerTest do + use PhxWCStorybookWeb.ConnCase + + test "GET /", %{conn: conn} do + conn = get(conn, "/") + assert html_response(conn, 200) =~ "Phoenix WebComponent" + end +end diff --git a/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/error_view_test.exs b/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/error_view_test.exs new file mode 100644 index 00000000..da5d4e88 --- /dev/null +++ b/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/error_view_test.exs @@ -0,0 +1,15 @@ +defmodule PhxWCStorybookWeb.ErrorViewTest do + use PhxWCStorybookWeb.ConnCase, async: true + + # Bring render/3 and render_to_string/3 for testing custom views + import Phoenix.View + + test "renders 404.html" do + assert render_to_string(PhxWCStorybookWeb.ErrorView, "404.html", []) == "Not Found" + end + + test "renders 500.html" do + assert render_to_string(PhxWCStorybookWeb.ErrorView, "500.html", []) == + "Internal Server Error" + end +end diff --git a/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/layout_view_test.exs b/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/layout_view_test.exs new file mode 100644 index 00000000..de38f20f --- /dev/null +++ b/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/layout_view_test.exs @@ -0,0 +1,8 @@ +defmodule PhxWCStorybookWeb.LayoutViewTest do + use PhxWCStorybookWeb.ConnCase, async: true + + # When testing helpers, you may want to import Phoenix.HTML and + # use functions such as safe_to_string() to convert the helper + # result into an HTML string. + # import Phoenix.HTML +end diff --git a/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/page_view_test.exs b/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/page_view_test.exs new file mode 100644 index 00000000..069edec3 --- /dev/null +++ b/apps/phx_wc_storybook_web/test/phx_wc_storybook_web/views/page_view_test.exs @@ -0,0 +1,3 @@ +defmodule PhxWCStorybookWeb.PageViewTest do + use PhxWCStorybookWeb.ConnCase, async: true +end diff --git a/apps/phx_wc_storybook_web/test/support/conn_case.ex b/apps/phx_wc_storybook_web/test/support/conn_case.ex new file mode 100644 index 00000000..54de26ab --- /dev/null +++ b/apps/phx_wc_storybook_web/test/support/conn_case.ex @@ -0,0 +1,37 @@ +defmodule PhxWCStorybookWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use PhxWCStorybookWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import PhxWCStorybookWeb.ConnCase + + alias PhxWCStorybookWeb.Router.Helpers, as: Routes + + # The default endpoint for testing + @endpoint PhxWCStorybookWeb.Endpoint + end + end + + setup _tags do + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/apps/phx_wc_storybook_web/test/test_helper.exs b/apps/phx_wc_storybook_web/test/test_helper.exs new file mode 100644 index 00000000..869559e7 --- /dev/null +++ b/apps/phx_wc_storybook_web/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/config/config.exs b/config/config.exs index f54bdc5d..e17eef9a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,11 +1,80 @@ +# This file is responsible for configuring your umbrella +# and **all applications** and their dependencies with the +# help of the Config module. +# +# Note that all applications in your umbrella share the +# same configuration and dependencies, which is why they +# all use the same configuration file. If you want different +# configurations or dependencies per app, it is best to +# move said applications out of the umbrella. import Config -config :phoenix, :json_library, Jason +# Configure Mix tasks and generators +config :phx_wc_storybook, + namespace: PhxWCStorybook + +config :phx_wc_storybook_web, + namespace: PhxWCStorybookWeb, + generators: [context_app: :phx_wc_storybook] + +# Configures the endpoint +config :phx_wc_storybook_web, PhxWCStorybookWeb.Endpoint, + url: [host: "localhost"], + render_errors: [view: PhxWCStorybookWeb.ErrorView, accepts: ~w(html json), layout: false], + pubsub_server: PhxWCStorybook.PubSub, + live_view: [signing_salt: "HkF5qV0r"] + +config :phx_wc_storybook_web, PhxWCStorybookWeb.Storybook, + content_path: + Path.expand("../apps/phx_wc_storybook_web/lib/phx_wc_storybook_web/storybook", __DIR__), + js_path: "/assets/app.js", + css_path: "/assets/app.css" + +config :tailwind, + version: "3.1.6", + default: [ + args: ~w( + --config=tailwind.config.js + --input=css/phoenix_webcomponent.css + --output=../priv/static/phoenix_webcomponent.css + ), + cd: Path.expand("../apps/phoenix_webcomponent/assets", __DIR__), + env: %{"NODE_PATH" => "#{Path.expand("../deps", __DIR__)}:#{Path.expand("../apps", __DIR__)}"} + ], + storybook: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../apps/phx_wc_storybook_web/assets", __DIR__), + env: %{"NODE_PATH" => "#{Path.expand("../deps", __DIR__)}:#{Path.expand("../apps", __DIR__)}"} + ] +# Configure esbuild (the version is required) config :esbuild, - version: "0.14.0", + version: "0.14.29", default: [ args: - ~w(priv/src/phoenix_webcomponent.js --bundle --minify --target=esnext --outdir=priv/static/), - cd: Path.expand("../", __DIR__) + ~w(js/phoenix_webcomponent.js --bundle --target=es2021 --outdir=../priv/static/ --external:/fonts/* --external:/images/*), + cd: Path.expand("../apps/phoenix_webcomponent/assets", __DIR__), + env: %{"NODE_PATH" => "#{Path.expand("../deps", __DIR__)}:#{Path.expand("../apps", __DIR__)}"} + ], + storybook: [ + args: + ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), + cd: Path.expand("../apps/phx_wc_storybook_web/assets", __DIR__), + env: %{"NODE_PATH" => "#{Path.expand("../deps", __DIR__)}:#{Path.expand("../apps", __DIR__)}"} ] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 00000000..76232592 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,45 @@ +import Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we use it +# with esbuild to bundle .js and .css sources. +config :phx_wc_storybook_web, PhxWCStorybookWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {0, 0, 0, 0}, port: 4600], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "BM3gjYo7YUKjr9Ye7kqOjj4t4c4dAkezwSbPFN1AJE1Tqi/aw1Kt/fNszzGoSGi9", + watchers: [ + # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) + tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}, + esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:storybook, ~w(--watch)]}, + esbuild: {Esbuild, :install_and_run, [:storybook, ~w(--sourcemap=inline --watch)]} + ] + +# Watch static and templates for browser reloading. +config :phx_wc_storybook_web, PhxWCStorybookWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"priv/gettext/.*(po)$", + ~r"lib/phoenix_webcomponent/.*(ex)$", + ~r"lib/phx_wc_storybook_web/(live|views)/.*(ex)$", + ~r"lib/phx_wc_storybook_web/templates/.*(eex)$" + ] + ] + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 00000000..5f8555d9 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,17 @@ +import Config + +# For production, don't forget to configure the url host +# to something meaningful, Phoenix uses this information +# when generating URLs. +# +# Note we also include the path to a cache manifest +# containing the digested version of static files. This +# manifest is generated by the `mix phx.digest` task, +# which you should run after static files are built and +# before starting your production server. +config :phx_wc_storybook_web, PhxWCStorybookWeb.Endpoint, + url: [host: "phoenix-webcomponent.gsmlg.org", port: 80], + cache_static_manifest: "priv/static/cache_manifest.json" + +# Do not print debug messages in production +config :logger, level: :info diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 00000000..e9eae020 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,40 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. +if config_env() == :prod do + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + config :phx_wc_storybook_web, PhxWCStorybookWeb.Endpoint, + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: String.to_integer(System.get_env("PORT") || "4000") + ], + secret_key_base: secret_key_base + + # ## Using releases + # + # If you are doing OTP releases, you need to instruct Phoenix + # to start each relevant endpoint: + # + # config :phx_wc_storybook_web, PhxWCStorybookWeb.Endpoint, server: true + # + # Then you can assemble a release by calling `mix release`. + # See `mix help release` for more information. +end diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 00000000..5d4d1e4b --- /dev/null +++ b/config/test.exs @@ -0,0 +1,14 @@ +import Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :phx_wc_storybook_web, PhxWCStorybookWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "u4CGMLvZGj1B4C7in/ai2kZWRpAPYjbpWB5kNiGeYVbPuZsv2wO3DTR71X6QiJ6l", + server: false + +# Print only warnings and errors during test +config :logger, level: :warn + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/lib/phoenix_webcomponent.ex b/lib/phoenix_webcomponent.ex deleted file mode 100644 index d3801f2d..00000000 --- a/lib/phoenix_webcomponent.ex +++ /dev/null @@ -1,153 +0,0 @@ -defmodule Phoenix.WebComponent do - @moduledoc """ - Provides a suit of html custom component for phoenix. - - This library provides three main functionalities: - - * Enhance form helper with manterial web componet - * Enhance link helper with manterial web componet - * Markdown render helper with `@gsmlg/lit/remark-element` - * TopAppBar render top app bar with custom element. - - ## Form helper - - See `Phoenix.WebComponent.FormHelper`. - - ## JavaScript library - - This project provides javascript that define custom elements. - - To use the web component, you must load `priv/static/phoenix_webcomponent.js` - into your build tool. Or through npm by install `phoenix_webcomponent`. - The difference is npm version is not bundled. - - """ - - @doc false - defmacro __using__(_) do - quote do - import Phoenix.WebComponent.FormHelper - import Phoenix.WebComponent.Link - import Phoenix.WebComponent.Markdown - import Phoenix.WebComponent.TopAppBar - import Phoenix.WebComponent.Table - end - end - - @doc """ - Returns a list of attributes that make an element behave like a link. - For example, to make a button work like a link: - - - - However, this function is more often used to create buttons that - must invoke an action on the server, such as deleting an entity, - using the relevant HTTP protocol: - - - - The `to` argument may be a string, a URI, or a tuple `{scheme, value}`. - See the examples below. - Note: using this function requires loading the JavaScript library - at `priv/static/phoenix_html.js`. See the `Phoenix.HTML` module - documentation for more information. - - ## Options - - * `:method` - the HTTP method for the link. Defaults to `:get`. - * `:csrf_token` - a custom token to use when method is not `:get`. - This is used to ensure the request was sent by the user who - rendered the page. By default, CSRF tokens are generated through - `Plug.CSRFProtection`. You can set this option to `false`, to - disable token generation, or set it to your own token. - - When the `:method` is set to `:get` and the `:to` URL contains query - parameters the generated form element will strip the parameters in - accordance with the [W3C](https://www.w3.org/TR/html401/interact/forms.html#h-17.13.3.4) - form specification. - - ## Data attributes - - The following data attributes can also be manually set in the element: - * `data-confirm` - shows a confirmation prompt before generating and - submitting the form. - - ## Examples - - iex> link_attributes("/world") - [data: [method: :get, to: "/world"]] - iex> link_attributes(URI.parse("https://elixir-lang.org")) - [data: [method: :get, to: "https://elixir-lang.org"]] - iex> link_attributes("/product/1", method: :delete) - [data: [csrf: Plug.CSRFProtection.get_csrf_token(), method: :delete, to: "/product/1"]] - - ## If the URL is absolute, only certain schemas are allowed to avoid JavaScript injection. - For example, the following will fail - - iex> link_attributes("javascript:alert('hacked!')") - ** (ArgumentError) unsupported scheme given as link. In case you want to link to an - unknown or unsafe scheme, such as javascript, use a tuple: {:javascript, rest} - - You can however explicitly render those unsafe schemes by using a tuple: - - iex> link_attributes({:javascript, "alert('my alert!')"}) - [data: [method: :get, to: ["javascript", 58, "alert('my alert!')"]]] - - """ - def link_attributes(to, opts \\ []) do - to = valid_destination!(to) - method = Keyword.get(opts, :method, :get) - data = [method: method, to: to] - - data = - if method == :get do - data - else - case Keyword.get(opts, :csrf_token, true) do - true -> [csrf: Phoenix.HTML.Tag.csrf_token_value(to)] ++ data - false -> data - csrf when is_binary(csrf) -> [csrf: csrf] ++ data - end - end - - [data: data] - end - - defp valid_destination!(%URI{} = uri) do - valid_destination!(URI.to_string(uri)) - end - - defp valid_destination!({:safe, to}) do - {:safe, valid_string_destination!(IO.iodata_to_binary(to))} - end - - defp valid_destination!({other, to}) when is_atom(other) do - [Atom.to_string(other), ?:, to] - end - - defp valid_destination!(to) do - valid_string_destination!(IO.iodata_to_binary(to)) - end - - @valid_uri_schemes ~w(http: https: ftp: ftps: mailto: news: irc: gopher:) ++ - ~w(nntp: feed: telnet: mms: rtsp: svn: tel: fax: xmpp:) - - for scheme <- @valid_uri_schemes do - defp valid_string_destination!(unquote(scheme) <> _ = string), do: string - end - - defp valid_string_destination!(to) do - if not match?("/" <> _, to) and String.contains?(to, ":") do - raise ArgumentError, """ - unsupported scheme given as link. In case you want to link to an - unknown or unsafe scheme, such as javascript, use a tuple: {:javascript, rest}\ - """ - else - to - end - end -end diff --git a/lib/phoenix_webcomponent/form_helper.ex b/lib/phoenix_webcomponent/form_helper.ex deleted file mode 100644 index 67d5304a..00000000 --- a/lib/phoenix_webcomponent/form_helper.ex +++ /dev/null @@ -1,898 +0,0 @@ -defmodule Phoenix.WebComponent.FormHelper do - @moduledoc ~S""" - Helpers related to producing HTML forms. - - The functions in this module can be used in three - distinct scenarios: - - * with changeset data - when information to populate - the form comes from a changeset - - * with limited data - when a form is created without - an underlying data layer. In this scenario, you can - use the connection information (aka Plug.Conn.params) - or pass the form values by hand - - * outside of a form - when the functions are used directly, - outside of `form_for` - - We will explore all three scenarios below. - - ## With changeset data - - The entry point for defining forms in Phoenix is with - the `form_for/4` function. For this example, we will - use `Ecto.Changeset`, which integrates nicely with Phoenix - forms via the `phoenix_ecto` package. - - Imagine you have the following action in your controller: - - def new(conn, _params) do - changeset = User.changeset(%User{}) - render conn, "new.html", changeset: changeset - end - - where `User.changeset/2` is defined as follows: - - def changeset(user, params \\ %{}) do - Ecto.Changeset.cast(user, params, [:name, :age]) - end - - Now a `@changeset` assign is available in views which we - can pass to the form: - - <%= form_for @changeset, Routes.user_path(@conn, :create), fn f -> %> - - - - - <%= wc_submit "Submit" %> - <% end %> - - `form_for/4` receives the `Ecto.Changeset` and converts it - to a form, which is passed to the function as the argument - `f`. All the remaining functions in this module receive - the form and automatically generate the input fields, often - by extracting information from the given changeset. For example, - if the user had a default value for age set, it will - automatically show up as selected in the form. - - ### A note on `:errors` - - If no action has been applied to the changeset or action was set to `:ignore`, - no errors are shown on the form object even if the changeset has a non-empty - `:errors` value. - - This is useful for things like validation hints on form fields, e.g. an empty - changeset for a new form. That changeset isn't valid, but we don't want to - show errors until an actual user action has been performed. - - Ecto automatically applies the action for you when you call - Repo.insert/update/delete, but if you want to show errors manually you can - also set the action yourself, either directly on the `Ecto.Changeset` struct - field or by using `Ecto.Changeset.apply_action/2`. - - ## With limited data - - `form_for/4` expects as first argument any data structure that - implements the `Phoenix.WebComponent.FormData` protocol. By default, - Phoenix implements this protocol for `Plug.Conn` and `Atom`. - - This is useful when you are creating forms that are not backed - by any kind of data layer. Let's assume that we're submitting a - form to the `:new` action in the `FooController`: - - <%= form_for @conn, Routes.foo_path(@conn, :new), [as: :foo], fn f -> %> - <%= wc_text_input f, :for %> - <%= wc_submit "Search" %> - <% end %> - - `form_for/4` uses the `Plug.Conn` to set input values from the - request parameters. - - Alternatively, if you don't have a connection, you can pass `:foo` - as the form data source and explicitly pass the value for every input: - - <%= form_for :foo, Routes.foo_path(MyApp.Endpoint, :new), fn f -> %> - <%= wc_text_input f, :for, value: "current value" %> - <%= wc_submit "Search" %> - <% end %> - - ## Without form data - - Sometimes we may want to generate a `text_input/3` or any other - tag outside of a form. The functions in this module also support - such usage by simply passing an atom as first argument instead - of the form. - - <%= wc_text_input :user, :name, value: "This is a prepopulated value" %> - - - """ - - import Phoenix.HTML - import Phoenix.HTML.Tag - import Phoenix.HTML.Form, except: [options_for_select: 2] - - ## Form helpers - - @doc """ - Generates a text input. - - The form should either be a `Phoenix.WebComponent.Form` emitted - by `form_for` or an atom. - - All given options are forwarded to the underlying input, - default values are provided for id, name and value if - possible. - - ## Examples - - # Assuming form contains a User schema - wc_text_input(form, :name) - #=> - - wc_text_input(:user, :name) - #=> - - """ - def wc_text_input(form, field, opts \\ []) do - generic_input(:text, form, field, opts) - end - - @doc """ - Generates an email input. - - Auto add pattern="[^@]+@[^@]+" to check format - - See `text_input/3` for example and docs. - """ - def wc_email_input(form, field, opts \\ []) do - opts = opts |> Keyword.put_new(:pattern, "[^@]+@[^@]+") - generic_input(:email, form, field, opts) - end - - @spec wc_number_input(atom | Phoenix.HTML.Form.t(), atom | binary, keyword) :: - {:safe, [binary | list | 60 | 62, ...]} - @doc """ - Generates a number input. - - See `text_input/3` for example and docs. - """ - def wc_number_input(form, field, opts \\ []) do - generic_input(:number, form, field, opts) - end - - @doc """ - Generates a password input. - - For security reasons, the form data and parameter values - are never re-used in `password_input/3`. Pass the value - explicitly if you would like to set one. - - See `text_input/3` for example and docs. - """ - def wc_password_input(form, field, opts \\ []) do - opts = - opts - |> Keyword.put_new(:"label-text", humanize(field)) - |> Keyword.put_new(:type, "password") - |> Keyword.put_new(:id, input_id(form, field)) - |> Keyword.put_new(:name, input_name(form, field)) - - errors = - case form do - %{errors: errors} -> errors |> Keyword.get_values(field) - _ -> [] - end - - {translate_error, opts} = opts |> Keyword.pop(:translate_error) - - opts = - unless Enum.empty?(errors) do - opts = opts |> Keyword.put_new(:invalid, true) - - errorString = - Enum.map(errors, fn {msg, opts} -> - if is_function(translate_error) do - translate_error.({msg, opts}) - else - msg - end - end) - |> Enum.join(" ") - - opts |> Keyword.put(:"validity-message", errorString) - else - opts - end - - tag(:"bx-input", opts) - end - - @doc """ - Generates an url input. - - See `text_input/3` for example and docs. - """ - def wc_url_input(form, field, opts \\ []) do - generic_input(:url, form, field, opts) - end - - @doc """ - Generates a search input. - - See `text_input/3` for example and docs. - """ - def wc_search_input(form, field, opts \\ []) do - generic_input(:search, form, field, opts) - end - - @doc """ - Generates a telephone input. - - See `text_input/3` for example and docs. - """ - def wc_telephone_input(form, field, opts \\ []) do - generic_input(:tel, form, field, opts) - end - - @doc """ - Generates a color input. - - Warning: this feature isn't available in all browsers. - Check `http://caniuse.com/#feat=input-color` for further information. - - See `text_input/3` for example and docs. - """ - def wc_color_input(form, field, opts \\ []) do - generic_input(:color, form, field, opts) - end - - @doc """ - Generates a range input. - - See `text_input/3` for example and docs. - """ - def wc_range_input(form, field, opts \\ []) do - generic_input(:range, form, field, opts) - end - - @doc """ - Generates a date input. - - Warning: this feature isn't available in all browsers. - Check `http://caniuse.com/#feat=input-datetime` for further information. - - See `text_input/3` for example and docs. - """ - def wc_date_input(form, field, opts \\ []) do - opts = - opts - |> Keyword.put_new(:"label-text", humanize(field)) - |> Keyword.put_new(:id, input_id(form, field)) - |> Keyword.put_new(:name, input_name(form, field)) - |> Keyword.put_new(:value, input_value(form, field)) - |> Keyword.update!(:value, &maybe_html_escape/1) - |> Keyword.put_new(:"date-format", "Y-m-d") - - errors = - case form do - %{errors: errors} -> errors |> Keyword.get_values(field) - _ -> [] - end - - {translate_error, opts} = opts |> Keyword.pop(:translate_error) - - opts = - unless Enum.empty?(errors) do - opts = opts |> Keyword.put_new(:invalid, true) - - errorString = - Enum.map(errors, fn {msg, opts} -> - if is_function(translate_error) do - translate_error.({msg, opts}) - else - msg - end - end) - |> Enum.join(" ") - - opts |> Keyword.put(:"validity-message", errorString) - else - opts - end - - {format, opts} = Keyword.pop(opts, :"date-format") - {name, opts} = Keyword.pop(opts, :name) - {value, opts} = Keyword.pop(opts, :value) - - content_tag(:"bx-date-picker", "date-format": format, name: name, value: value) do - content_tag(:"bx-date-picker-input", "", opts ++ [kind: "single", value: value]) - end - end - - defp generic_input(type, form, field, opts) - when is_list(opts) and (is_atom(field) or is_binary(field)) do - opts = - opts - |> Keyword.put_new(:"label-text", humanize(field)) - |> Keyword.put_new(:type, type) - |> Keyword.put_new(:id, input_id(form, field)) - |> Keyword.put_new(:name, input_name(form, field)) - |> Keyword.put_new(:value, input_value(form, field)) - |> Keyword.update!(:value, &maybe_html_escape/1) - - errors = - case form do - %{errors: errors} -> errors |> Keyword.get_values(field) - _ -> [] - end - - {translate_error, opts} = opts |> Keyword.pop(:translate_error) - - opts = - unless Enum.empty?(errors) do - opts = opts |> Keyword.put_new(:invalid, true) - - errorString = - Enum.map(errors, fn {msg, opts} -> - if is_function(translate_error) do - translate_error.({msg, opts}) - else - msg - end - end) - |> Enum.join(" ") - - opts |> Keyword.put(:"validity-message", errorString) - else - opts - end - - tag(:"bx-input", opts) - end - - defp maybe_html_escape(nil), do: nil - defp maybe_html_escape(value), do: html_escape(value) - - @doc """ - Generates a textarea input. - - All given options are forwarded to the underlying input, - default values are provided for id, name and textarea - content if possible. - - ## Examples - - # Assuming form contains a User schema - textarea(form, :description) - #=> - - ## New lines - - Notice the generated textarea includes a new line after - the opening tag. This is because the HTML spec says new - lines after tags must be ignored and all major browser - implementations do that. - - So in order to avoid new lines provided by the user - from being ignored when the form is resubmitted, we - automatically add a new line before the text area - value. - """ - def wc_textarea(form, field, opts \\ []) do - {value, opts} = Keyword.pop(opts, :value, input_value(form, field)) - - opts = - opts - |> Keyword.put_new(:"label-text", humanize(field)) - |> Keyword.put_new(:id, input_id(form, field)) - |> Keyword.put_new(:name, input_name(form, field)) - |> Keyword.put_new(:value, value) - - errors = - case form do - %{errors: errors} -> errors |> Keyword.get_values(field) - _ -> [] - end - - {translate_error, opts} = opts |> Keyword.pop(:translate_error) - - opts = - unless Enum.empty?(errors) do - opts = opts |> Keyword.put_new(:invalid, true) - - errorString = - Enum.map(errors, fn {msg, opts} -> - if is_function(translate_error) do - translate_error.({msg, opts}) - else - msg - end - end) - |> Enum.join(" ") - - opts |> Keyword.put(:"validity-message", errorString) - else - opts - end - - content_tag(:"bx-textarea", "", opts) - end - - @doc """ - Generates a file input. - - It requires the given form to be configured with `multipart: true` - when invoking `form_for/4`, otherwise it fails with `ArgumentError`. - - See `wc_text_input/3` for example and docs. - """ - def wc_file_input(form, field, opts \\ []) do - if match?(%Phoenix.HTML.Form{}, form) and !form.options[:multipart] do - raise ArgumentError, - "file_input/3 requires the enclosing form_for/4 " <> - "to be configured with multipart: true" - end - - opts = - opts - |> Keyword.put_new(:type, :file) - |> Keyword.put_new(:id, input_id(form, field)) - |> Keyword.put_new(:name, input_name(form, field)) - - opts = - if opts[:multiple] do - Keyword.update!(opts, :name, &"#{&1}[]") - else - opts - end - - tag(:input, opts) - end - - @doc """ - Generates a wc_submit button to send the form. - - ## Examples - - wc_submit do: "Submit" - #=> - - """ - def wc_submit([do: _] = block_option), do: wc_submit([], block_option) - - @doc """ - Generates a wc_submit button to send the form. - - All options are forwarded to the underlying button tag. - When called with a `do:` block, the button tag options - come first. - - ## Examples - - wc_submit "Submit" - #=> - - wc_submit "Submit", class: "btn" - #=> - - wc_submit [class: "btn"], do: "Submit" - #=> - - """ - def wc_submit(value, opts \\ []) - - def wc_submit(opts, [do: _] = block_option) do - opts = Keyword.put_new(opts, :type, "submit") - opts = Keyword.put_new(opts, :unelevated, true) - - content_tag(:"bx-btn", opts, block_option) - end - - def wc_submit(value, opts) do - opts = Keyword.put_new(opts, :type, "submit") - opts = Keyword.put_new(opts, :unelevated, true) - - content_tag(:"bx-btn", value, opts) - end - - @doc """ - Generates a reset input to reset all the form fields to - their original state. - - All options are forwarded to the underlying input tag. - - ## Examples - - wc_reset "Reset" - #=> - - wc_reset "Reset", class: "btn" - #=> - - """ - def wc_reset(value, opts \\ []) do - opts = - opts - |> Keyword.put_new(:type, "reset") - |> Keyword.put_new(:value, value) - - tag(:"bx-btn", opts) - end - - @doc """ - Generates a radio button. - - Invoke this function for each possible value you want - to be sent to the server. - - ## Examples - - # Assuming form contains a User schema - wc_radio_button(form, :role, "admin") - #=> - - ## Options - - All options are simply forwarded to the underlying HTML tag. - """ - def wc_radio_button(form, field, value, opts \\ []) do - escaped_value = html_escape(value) - - opts = - opts - |> Keyword.put_new(:type, "radio") - |> Keyword.put_new(:id, input_id(form, field, escaped_value)) - |> Keyword.put_new(:name, input_name(form, field)) - - opts = - if escaped_value == html_escape(input_value(form, field)) do - Keyword.put_new(opts, :checked, true) - else - opts - end - - tag(:input, [value: escaped_value] ++ opts) - end - - @doc """ - Generates a wc_checkbox. - - This function is useful for sending boolean values to the server. - - ## Examples - - # Assuming form contains a User schema - wc_checkbox(form, :famous) - #=> - #=> - - ## Options - - * `:checked_value` - the value to be sent when the wc_checkbox is checked. - Defaults to "true" - - * `:hidden_input` - controls if this function will generate a hidden input - to wc_submit the unchecked value or not. Defaults to "true" - - * `:unchecked_value` - the value to be sent when the wc_checkbox is unchecked, - Defaults to "false" - - * `:value` - the value used to check if a wc_checkbox is checked or unchecked. - The default value is extracted from the form data if available - - All other options are forwarded to the underlying HTML tag. - - ## Hidden fields - - Because an unchecked wc_checkbox is not sent to the server, Phoenix - automatically generates a hidden field with the unchecked_value - *before* the wc_checkbox field to ensure the `unchecked_value` is sent - when the wc_checkbox is not marked. Set `hidden_input` to false If you - don't want to send values from unchecked wc_checkbox to the server. - """ - def wc_checkbox(form, field, opts \\ []) do - opts = - opts - |> Keyword.put_new(:type, "checkbox") - |> Keyword.put_new(:id, input_id(form, field)) - |> Keyword.put_new(:name, input_name(form, field)) - - {value, opts} = Keyword.pop(opts, :value, input_value(form, field)) - {checked_value, opts} = Keyword.pop(opts, :checked_value, true) - {unchecked_value, opts} = Keyword.pop(opts, :unchecked_value, false) - {hidden_input, opts} = Keyword.pop(opts, :hidden_input, true) - - # We html escape all values to be sure we are comparing - # apples to apples. After all we may have true in the data - # but "true" in the params and both need to match. - checked_value = html_escape(checked_value) - unchecked_value = html_escape(unchecked_value) - - opts = - Keyword.put_new_lazy(opts, :checked, fn -> - value = html_escape(value) - value == checked_value - end) - - if hidden_input do - hidden_opts = [type: "hidden", value: unchecked_value] - - html_escape([ - tag(:input, hidden_opts ++ Keyword.take(opts, [:name, :disabled, :form])), - tag(:input, [value: checked_value] ++ opts) - ]) - else - html_escape([ - tag(:input, [value: checked_value] ++ opts) - ]) - end - end - - @doc """ - Generates a select tag with the given `options`. - - `options` are expected to be an enumerable which will be used to - generate each respective `option`. The enumerable may have: - - * keyword lists - each keyword list is expected to have the keys - `:key` and `:value`. Additional keys such as `:disabled` may - be given to customize the option - - * two-item tuples - where the first element is an atom, string or - integer to be used as the option label and the second element is - an atom, string or integer to be used as the option value - - * atom, string or integer - which will be used as both label and value - for the generated select - - ## Optgroups - - If `options` is map or keyword list where the first element is a string, - atom or integer and the second element is a list or a map, it is assumed - the key will be wrapped in an `` and the value will be used to - generate `` nested under the group. - - ## Examples - - # Assuming form contains a User schema - select(form, :age, 0..120) - #=> - #=> - #=> ... - #=> - #=> - - select(form, :role, ["Admin": "admin", "User": "user"]) - #=> - #=> - #=> - #=> - - select(form, :role, [[key: "Admin", value: "admin", disabled: true], - [key: "User", value: "user"]]) - #=> - #=> - #=> - #=> - - You can also pass a prompt: - - select(form, :role, ["Admin": "admin", "User": "user"], prompt: "Choose your role") - #=> - #=> - #=> - #=> - #=> - - And customize the prompt as any other entry: - - select(form, :role, ["Admin": "admin", "User": "user"], prompt: [key: "Choose your role", disabled: true]) - #=> - #=> - #=> - #=> - #=> - - If you want to select an option that comes from the database, - such as a manager for a given project, you may write: - - select(form, :manager_id, Enum.map(@managers, &{&1.name, &1.id})) - #=> - #=> - #=> - #=> - - Finally, if the values are a list or a map, we use the keys for - grouping: - - select(form, :country, ["Europe": ["UK", "Sweden", "France"]], ...) - #=> - #=> - #=> - #=> - #=> - #=> - #=> ... - #=> - - ## Options - - * `:prompt` - an option to include at the top of the options. It may be - a string or a keyword list of attributes and the `:key` - - * `:selected` - the default value to use when none was sent as parameter - - Be aware that a `:multiple` option will not generate a correctly - functioning multiple select element. Use `wc_multiple_select/4` instead. - - All other options are forwarded to the underlying HTML tag. - """ - def wc_select(form, field, options, opts \\ []) when is_atom(field) or is_binary(field) do - options_html = options_for_select(options) - - opts = - opts - |> Keyword.put_new(:"label-text", humanize(field)) - |> Keyword.put_new(:id, input_id(form, field)) - |> Keyword.put_new(:name, input_name(form, field)) - |> Keyword.put_new(:value, input_value(form, field)) - |> Keyword.update!(:value, &maybe_html_escape/1) - - content_tag(:"bx-select", options_html, opts) - end - - @doc """ - Returns options to be used inside a select. - - This is useful when building the select by hand. - It expects all options and one or more select values. - - ## Examples - - options_for_select(["Admin": "admin", "User": "user"], "admin") - #=> - #=> - - Groups are also supported: - - options_for_select(["Europe": ["UK", "Sweden", "France"], ...], nil) - #=> - #=> - #=> - #=> - #=> - - """ - def options_for_select(options) do - {:safe, escaped_options_for_select(options)} - end - - defp escaped_options_for_select(options) do - Enum.reduce(options, [], fn - {option_key, option_value}, acc -> - [acc | option(option_key, option_value, [])] - - options, acc when is_list(options) -> - {option_key, options} = Keyword.pop(options, :key) - - option_key || - raise ArgumentError, - "expected :key key when building