├── .credo.exs ├── .formatter.exs ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── assets ├── biome.json ├── css │ ├── app.css │ └── fonts.css ├── js │ ├── app.js │ ├── iframe.js │ └── lib │ │ ├── color_mode_hook.js │ │ ├── search_hook.js │ │ ├── sidebar_hook.js │ │ └── story_hook.js ├── package-lock.json ├── package.json └── tailwind.config.js ├── biome.json ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── coveralls.json ├── guides ├── color_modes.md ├── components.md ├── icons.md ├── sandboxing.md ├── setup.md ├── testing.md └── theming.md ├── lib ├── mix │ └── tasks │ │ ├── dev.storybook.ex │ │ └── phx.gen.storybook.ex ├── phoenix_storybook.ex └── phoenix_storybook │ ├── application.ex │ ├── components │ └── icon.ex │ ├── controllers │ └── asset_not_found_controller.ex │ ├── dbg.ex │ ├── entries.ex │ ├── events │ ├── event_log.ex │ └── instrumenter.ex │ ├── exs_compiler.ex │ ├── guides │ ├── guide_macros.ex │ └── guides.ex │ ├── helpers │ ├── asset_helpers.ex │ ├── example_helpers.ex │ ├── extra_assigns_helpers.ex │ ├── navigation_helpers.ex │ ├── search_helpers.ex │ ├── template_helpers.ex │ ├── theme_helpers.ex │ └── validation_helpers.ex │ ├── live │ ├── error_view.ex │ ├── search.ex │ ├── sidebar.ex │ ├── story │ │ ├── component_doc.ex │ │ ├── component_iframe_live.ex │ │ ├── playground.ex │ │ ├── playground_preview_live.ex │ │ └── variations.ex │ ├── story_live.ex │ └── visual_test_live.ex │ ├── mount.ex │ ├── rendering │ ├── code_renderer.ex │ ├── component_renderer.ex │ ├── rendering_context.ex │ └── rendering_variation.ex │ ├── router.ex │ ├── stories │ ├── attr.ex │ ├── doc.ex │ ├── index.ex │ ├── index_validator.ex │ ├── slot.ex │ ├── story.ex │ ├── story_source.ex │ ├── story_validator.ex │ └── variation.ex │ ├── templates │ └── layout │ │ ├── _favicon.html.heex │ │ ├── live.html.heex │ │ ├── live_iframe.html.heex │ │ ├── root.html.heex │ │ └── root_iframe.html.heex │ ├── views │ └── layout_view.ex │ └── web.ex ├── mix.exs ├── mix.lock ├── priv ├── static │ ├── favicon │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── mstile-150x150.png │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest │ ├── fonts │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff2 │ │ ├── fa-solid-900.ttf │ │ └── fa-solid-900.woff2 │ └── images │ │ └── background.png └── templates │ └── phx.gen.storybook │ ├── _root.index.exs │ ├── core_components │ ├── _core_components.index.exs.eex │ ├── back.story.exs.eex │ ├── button.story.exs.eex │ ├── error.story.exs.eex │ ├── flash.story.exs.eex │ ├── header.story.exs.eex │ ├── icon.story.exs.eex │ ├── input.story.exs.eex │ ├── list.story.exs.eex │ └── table.story.exs.eex │ ├── examples │ └── core_components.story.exs.eex │ ├── storybook.css.eex │ ├── storybook.ex.eex │ ├── storybook.js │ ├── storybook.tailwind.css │ └── welcome.story.exs ├── screenshots ├── screenshot-01.jpg └── screenshot-02.jpg └── test ├── fixtures ├── asset_manifests │ ├── cache_manifest.json │ └── corrupted_manifest.json ├── components │ ├── all_types_component.ex │ ├── component.ex │ ├── event_component.ex │ ├── event_live_component.ex │ ├── let_component.ex │ ├── let_live_component.ex │ ├── live_component.ex │ ├── nested_component.ex │ ├── template_component.ex │ └── template_live_component.ex ├── exs │ ├── bad_script.exs │ └── script.exs ├── indexes │ ├── bad_entry.index.exs │ ├── bad_entry_icon_provider.index.exs │ ├── bad_folder_icon.index.exs │ ├── bad_folder_name.index.exs │ ├── bad_local_icon_class.index.exs │ ├── bad_local_icon_tuple.index.exs │ ├── empty.index.exs │ └── valid.index.exs ├── storybook_content │ ├── empty_files │ │ ├── not_a_story │ │ └── not_a_story.exs │ ├── empty_folders │ │ ├── empty_a │ │ │ └── .gitkeep │ │ └── empty_b │ │ │ └── .gitkeep │ ├── flat_list │ │ ├── a_component.story.exs │ │ └── b_component.story.exs │ ├── render_page_crash │ │ └── a_page.story.exs │ ├── tree │ │ ├── _root.index.exs │ │ ├── a_folder │ │ │ ├── _a_folder.index.exs │ │ │ ├── component.story.exs │ │ │ └── live_component.story.exs │ │ ├── a_page.story.exs │ │ ├── b_folder │ │ │ ├── _b_folder.index.exs │ │ │ ├── all_types_component.story.exs │ │ │ ├── component.story.exs │ │ │ ├── nested_component.story.exs │ │ │ └── with_id_component.story.exs │ │ ├── b_page.story.exs │ │ ├── component.story.exs │ │ ├── containers │ │ │ ├── components │ │ │ │ ├── iframe.story.exs │ │ │ │ └── iframe_with_opts.story.exs │ │ │ └── live_components │ │ │ │ └── iframe.story.exs │ │ ├── event │ │ │ ├── _event.index.exs │ │ │ ├── event_component.story.exs │ │ │ └── event_live_component.story.exs │ │ ├── examples │ │ │ ├── example.story.exs │ │ │ ├── example_html.ex │ │ │ └── templates │ │ │ │ └── example.html.heex │ │ ├── let │ │ │ ├── let_component.story.exs │ │ │ └── let_live_component.story.exs │ │ ├── live_component.story.exs │ │ └── templates │ │ │ ├── invalid_template_component.story.exs │ │ │ ├── template_component.story.exs │ │ │ ├── template_iframe_component.story.exs │ │ │ └── template_live_component.story.exs │ └── tree_b │ │ ├── a_folder │ │ └── .gitkeep │ │ └── b_folder │ │ ├── ba_folder │ │ └── .gitkeep │ │ ├── bb_folder │ │ ├── b_ba_component.story.exs │ │ └── b_bb_component.story.exs │ │ └── bc_folder │ │ └── .gitkeep └── stubs │ ├── component_stub.ex │ ├── example_stub.ex │ ├── live_component_stub.ex │ └── page_stub.ex ├── mix └── tasks │ ├── dev.storybook_test.exs │ └── phx.gen.storybook_test.exs ├── mix_helper.exs ├── phoenix_storybook ├── components │ └── icon_test.exs ├── controllers │ └── asset_not_found_controller_test.exs ├── exs_compiler_test.exs ├── guides │ └── guides_test.exs ├── helpers │ ├── asset_helpers_test.exs │ ├── example_helpers_test.exs │ ├── extra_assigns_helpers_test.exs │ ├── search_helpers_test.exs │ └── template_helpers_test.exs ├── live │ ├── component_iframe_live_test.exs │ ├── playground_live_test.exs │ ├── search_test.exs │ ├── sidebar_test.exs │ ├── story_live_test.exs │ └── visual_test_live_test.exs ├── rendering │ ├── code_renderer_test.exs │ └── component_renderer_test.exs ├── router_test.exs └── stories │ ├── doc_test.exs │ ├── index_validator_test.exs │ ├── story_source_test.exs │ ├── story_test.exs │ └── story_validator_test.exs ├── phoenix_storybook_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | locals_without_parens = [ 2 | live_storybook: 2 3 | ] 4 | 5 | [ 6 | import_deps: [:phoenix], 7 | plugins: [Phoenix.LiveView.HTMLFormatter], 8 | inputs: [ 9 | "*.{ex,exs}", 10 | "{config,lib,priv}/**/*.{ex,exs,eex,heex}", 11 | "test/phoenix_storybook/**/*.{ex,exs}", 12 | "test/*.{ex,exs}" 13 | ], 14 | locals_without_parens: locals_without_parens, 15 | export: [locals_without_parens: locals_without_parens] 16 | ] 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [cblavier] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "mix" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | 10 | - package-ecosystem: "npm" 11 | directory: "/assets" 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | name: Build and test 12 | runs-on: ubuntu-22.04 13 | 14 | env: 15 | MIX_ENV: test 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up Elixir 23 | uses: erlef/setup-beam@v1 24 | id: beam 25 | with: 26 | otp-version: "27.3" 27 | elixir-version: "1.18.3-otp-27" 28 | 29 | - name: Restore dependencies cache 30 | uses: actions/cache@v3 31 | with: 32 | path: | 33 | deps 34 | _build 35 | key: ${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-mix-${{ hashFiles('**/mix.lock') }} 36 | restore-keys: ${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-mix- 37 | 38 | - name: Restore PLT cache 39 | id: plt_cache 40 | uses: actions/cache/restore@v3 41 | with: 42 | path: .plts 43 | key: | 44 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} 45 | restore-keys: | 46 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}- 47 | 48 | - name: Install dependencies 49 | run: mix deps.get 50 | 51 | - name: Compilation 52 | run: mix compile --warnings-as-errors 53 | 54 | - name: Create PLTs 55 | if: steps.plt_cache.outputs.cache-hit != 'true' 56 | run: mix dialyzer --plt 57 | 58 | # By default, the GitHub Cache action will only save the cache if all steps in the job succeed, 59 | # so we separate the cache restore and save steps in case running dialyzer fails. 60 | - name: Save PLT cache 61 | id: plt_cache_save 62 | uses: actions/cache/save@v3 63 | if: steps.plt_cache.outputs.cache-hit != 'true' 64 | with: 65 | path: .plts 66 | key: | 67 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} 68 | 69 | - name: Check formatting 70 | run: mix format --check-formatted 71 | 72 | - name: Credo 73 | run: mix credo 74 | 75 | - name: Setup Biome 76 | uses: biomejs/setup-biome@v2 77 | with: 78 | version: latest 79 | 80 | - name: Run Biome 81 | run: biome ci ./assets 82 | 83 | - name: Dialyzer 84 | run: mix dialyzer --format github 85 | 86 | - name: Run tests 87 | run: mix coveralls.json --warnings-as-errors --all-warnings 88 | 89 | - uses: codecov/codecov-action@v4 90 | with: 91 | fail_ci_if_error: true 92 | files: coverage/excoveralls.json 93 | token: ${{ secrets.CODECOV_TOKEN }} 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | /coverage/ 7 | 8 | # The directory Mix downloads your dependencies sources to. 9 | /deps/ 10 | 11 | # Where third-party dependencies like ExDoc output generated docs. 12 | /doc/ 13 | 14 | # Ignore .fetch files in case you like to edit your project deps locally. 15 | /.fetch 16 | 17 | # If the VM crashes, it generates a dump, let's ignore it too. 18 | erl_crash.dump 19 | 20 | # Also ignore archive artifacts (built via "mix archive.build"). 21 | *.ez 22 | 23 | # Ignore package tarball (built via "mix hex.build"). 24 | phoenix_storybook-*.tar 25 | 26 | /priv/static/**/*.gz 27 | /priv/static/**/*-????????????????????????????????.* 28 | /priv/static/css 29 | /priv/static/js 30 | /priv/static/cache_manifest.json 31 | 32 | # Temporary files, for example, from tests. 33 | /tmp/ 34 | 35 | /assets/node_modules 36 | 37 | .vscode 38 | .tool-versions 39 | .plts 40 | .elixir_ls -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2022 Christian Blavier. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /assets/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 2, 16 | "lineWidth": 100 17 | }, 18 | "organizeImports": { 19 | "enabled": true 20 | }, 21 | "linter": { 22 | "enabled": true, 23 | "rules": { 24 | "recommended": true, 25 | "a11y": { 26 | "useGenericFontNames": "off" 27 | }, 28 | "correctness": { 29 | "noUnknownFunction": "off" 30 | } 31 | } 32 | }, 33 | "javascript": { 34 | "formatter": { 35 | "quoteStyle": "double" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | import { Socket } from "phoenix"; 2 | import { LiveSocket } from "phoenix_live_view"; 3 | import { ColorModeHook } from "./lib/color_mode_hook"; 4 | import { SearchHook } from "./lib/search_hook"; 5 | import { SidebarHook } from "./lib/sidebar_hook"; 6 | import { StoryHook } from "./lib/story_hook"; 7 | 8 | if (window.storybook === undefined) { 9 | console.warn("No storybook configuration detected."); 10 | console.warn( 11 | "If you need to use custom hooks or uploaders, please define them in JS file and declare this \ 12 | file in your Elixir backend module options (:js_path key).", 13 | ); 14 | window.storybook = {}; 15 | } 16 | 17 | const socketPath = document.querySelector("html").getAttribute("phx-socket") || "/live"; 18 | 19 | const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content"); 20 | 21 | const selectedColorMode = ColorModeHook.selectedColorMode(); 22 | const actualColorMode = ColorModeHook.actualColorMode(selectedColorMode); 23 | 24 | const liveSocket = new LiveSocket(socketPath, Socket, { 25 | hooks: { 26 | ...window.storybook.Hooks, 27 | StoryHook, 28 | SearchHook, 29 | SidebarHook, 30 | ColorModeHook, 31 | }, 32 | uploaders: window.storybook.Uploaders, 33 | params: (liveViewName) => { 34 | return { 35 | _csrf_token: csrfToken, 36 | extra: window.storybook.Params, 37 | selected_color_mode: selectedColorMode, 38 | color_mode: actualColorMode, 39 | }; 40 | }, 41 | ...window.storybook.LiveSocketOptions, 42 | }); 43 | 44 | liveSocket.connect(); 45 | window.liveSocket = liveSocket; 46 | -------------------------------------------------------------------------------- /assets/js/iframe.js: -------------------------------------------------------------------------------- 1 | import { Socket } from "phoenix"; 2 | import { LiveSocket } from "phoenix_live_view"; 3 | import { ColorModeHook } from "./lib/color_mode_hook"; 4 | 5 | if (window.storybook === undefined) { 6 | console.warn("No storybook configuration detected."); 7 | console.warn( 8 | "If you need to use custom hooks or uploaders, please define them in JS file and declare this \ 9 | file in your in your Elixir backend module options (:js_path key).", 10 | ); 11 | window.storybook = {}; 12 | } 13 | 14 | const socketPath = document.querySelector("html").getAttribute("phx-socket") || "/live"; 15 | 16 | const csrfToken = window.parent.document 17 | .querySelector("meta[name='csrf-token']") 18 | ?.getAttribute("content"); 19 | 20 | const liveSocket = new LiveSocket(socketPath, Socket, { 21 | hooks: { ...window.storybook.Hooks, ColorModeHook }, 22 | uploaders: window.storybook.Uploaders, 23 | params: (liveViewName) => { 24 | return { 25 | _csrf_token: csrfToken, 26 | }; 27 | }, 28 | ...window.storybook.LiveSocketOptions, 29 | }); 30 | 31 | liveSocket.connect(); 32 | window.liveSocket = liveSocket; 33 | -------------------------------------------------------------------------------- /assets/js/lib/color_mode_hook.js: -------------------------------------------------------------------------------- 1 | /* 2 | This hook is meant to: 3 | - remember, with local storage, which color mode has been selected by the user 4 | - toggle psb color_mode on the HTML root element (which will change colors of the storybook itself, 5 | not the component's colors) 6 | - push to the storybook the selected color mode, and the actual color mode (when selected value 7 | is system, it will send dark or light depending on the browser current prefers-color-scheme). 8 | */ 9 | 10 | let self; 11 | 12 | export const ColorModeHook = { 13 | mounted() { 14 | self = this; 15 | window.addEventListener("psb:set-color-mode", this.onSetColorMode); 16 | 17 | window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { 18 | const selectedMode = this.selectedColorMode(); 19 | const actualMode = this.actualColorMode(selectedMode); 20 | this.pushEvent("psb-set-color-mode", { 21 | selected_mode: selectedMode, 22 | mode: actualMode, 23 | }); 24 | this.toggleColorModeClass(actualMode); 25 | }); 26 | }, 27 | 28 | destroyed() { 29 | window.removeEventListener("psb:set-color-mode", this.onSetColorMode); 30 | }, 31 | 32 | selectedColorMode() { 33 | return localStorage.getItem("psb_selected_color_mode") || "system"; 34 | }, 35 | 36 | actualColorMode(selectedMode) { 37 | if (selectedMode === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches) { 38 | return "dark"; 39 | } 40 | if (selectedMode === "dark") { 41 | return "dark"; 42 | } 43 | return "light"; 44 | }, 45 | toggleColorModeClass: (mode) => { 46 | if ("colorMode" in document.documentElement.dataset) { 47 | if (mode === "dark") { 48 | document.documentElement.classList.add("psb-dark"); 49 | } else { 50 | document.documentElement.classList.remove("psb-dark"); 51 | } 52 | } 53 | }, 54 | onSetColorMode: (e) => { 55 | const selectedMode = e.detail.mode || "system"; 56 | localStorage.setItem("psb_selected_color_mode", selectedMode); 57 | const actualMode = self.actualColorMode(selectedMode); 58 | self.pushEvent("psb-set-color-mode", { 59 | selected_mode: selectedMode, 60 | mode: actualMode, 61 | }); 62 | self.toggleColorModeClass(actualMode); 63 | }, 64 | }; 65 | 66 | const selectedMode = ColorModeHook.selectedColorMode(); 67 | const actualMode = ColorModeHook.actualColorMode(selectedMode); 68 | ColorModeHook.toggleColorModeClass(actualMode); 69 | -------------------------------------------------------------------------------- /assets/js/lib/search_hook.js: -------------------------------------------------------------------------------- 1 | export const SearchHook = { 2 | execJS: (el, attr) => { 3 | el && liveSocket.execJS(el, el.getAttribute(attr)); 4 | }, 5 | 6 | mounted() { 7 | const searchContainer = document.querySelector("#search-container"); 8 | const searchModal = document.querySelector("#search-modal"); 9 | const searchList = document.querySelector("#search-list"); 10 | const searchInput = document.querySelector("#search-input"); 11 | 12 | let allStories = searchList.children; 13 | let firstStory = searchList.firstElementChild; 14 | let lastStory = searchList.lastElementChild; 15 | let activeStory = firstStory; 16 | 17 | const observer = new MutationObserver((mutations) => { 18 | allStories = searchList.children; 19 | firstStory = searchList.firstElementChild; 20 | lastStory = searchList.lastElementChild; 21 | 22 | if (allStories.length > 0) { 23 | this.execJS(activeStory, "phx-baseline"); 24 | activeStory = firstStory; 25 | this.execJS(activeStory, "phx-highlight"); 26 | } 27 | }); 28 | 29 | observer.observe(searchList, { 30 | childList: true, 31 | }); 32 | 33 | window.addEventListener("psb:open-search", () => { 34 | this.execJS(searchContainer, "phx-show"); 35 | this.execJS(searchModal, "phx-show"); 36 | setTimeout(() => searchInput.focus(), 50); 37 | this.execJS(activeStory, "phx-highlight"); 38 | }); 39 | 40 | window.addEventListener("psb:close-search", () => { 41 | this.execJS(searchModal, "phx-hide"); 42 | this.execJS(searchContainer, "phx-hide"); 43 | }); 44 | 45 | window.addEventListener("keydown", (e) => { 46 | if (e.metaKey && (e.key === "k" || e.key === "K")) { 47 | e.preventDefault(); 48 | this.dispatchOpenSearch(); 49 | } 50 | }); 51 | 52 | for (const story of allStories) { 53 | story.addEventListener("mouseover", (e) => { 54 | if (e.movementX !== 0 && e.movementY !== 0 && e.target === story) { 55 | // This prevents clipping when switching back and forth 56 | // between mouse navigation and keyboard navigation 57 | 58 | this.execJS(activeStory, "phx-baseline"); 59 | activeStory = e.target; 60 | this.execJS(activeStory, "phx-highlight"); 61 | } 62 | }); 63 | } 64 | 65 | searchContainer.addEventListener("keydown", (e) => { 66 | if (e.key === "Enter") { 67 | e.preventDefault(); 68 | const link = activeStory.firstElementChild; 69 | 70 | this.resetInput(searchInput); 71 | this.pushEventTo("#search-container", "navigate", { 72 | path: link.pathname, 73 | }); 74 | this.dispatchCloseSearch(); 75 | } 76 | 77 | if (e.key === "Escape") { 78 | this.dispatchCloseSearch(); 79 | } 80 | 81 | if (e.key === "Tab") { 82 | // This prevents the use of tab within the search modal 83 | // to keep the focus in the search input. 84 | e.preventDefault(); 85 | } 86 | 87 | if (e.key === "ArrowUp") { 88 | this.execJS(activeStory, "phx-baseline"); 89 | 90 | if (activeStory === firstStory) { 91 | activeStory = lastStory; 92 | } else { 93 | activeStory = activeStory.previousElementSibling; 94 | } 95 | 96 | this.execJS(activeStory, "phx-highlight"); 97 | activeStory?.scrollIntoView({ block: "nearest", inline: "nearest" }); 98 | } 99 | 100 | if (e.key === "ArrowDown") { 101 | this.execJS(activeStory, "phx-baseline"); 102 | 103 | if (activeStory === lastStory) { 104 | activeStory = firstStory; 105 | } else { 106 | activeStory = activeStory.nextElementSibling; 107 | } 108 | 109 | this.execJS(activeStory, "phx-highlight"); 110 | activeStory?.scrollIntoView({ block: "nearest", inline: "nearest" }); 111 | } 112 | }); 113 | 114 | searchList.addEventListener("click", (e) => { 115 | const link = activeStory.firstElementChild; 116 | 117 | this.resetInput(searchInput); 118 | this.pushEventTo("#search-container", "navigate", { 119 | path: link.pathname, 120 | }); 121 | this.dispatchCloseSearch(); 122 | }); 123 | }, 124 | 125 | resetInput(searchInput) { 126 | searchInput.value = ""; 127 | this.pushEventTo("#search-container", "search", { search: { input: "" } }); 128 | }, 129 | 130 | dispatchOpenSearch() { 131 | const event = new Event("psb:open-search"); 132 | window.dispatchEvent(event); 133 | }, 134 | 135 | dispatchCloseSearch() { 136 | const event = new Event("psb:close-search"); 137 | window.dispatchEvent(event); 138 | }, 139 | }; 140 | -------------------------------------------------------------------------------- /assets/js/lib/sidebar_hook.js: -------------------------------------------------------------------------------- 1 | export const SidebarHook = { 2 | mounted() { 3 | const sidebarContainer = document.querySelector("#sidebar-container"); 4 | const overlay = document.querySelector("#sidebar-overlay"); 5 | 6 | const openSidebar = () => { 7 | sidebarContainer.classList.remove("psb-hidden"); 8 | overlay.classList.remove("psb-hidden"); 9 | }; 10 | 11 | const closeSidebar = () => { 12 | sidebarContainer.classList.add("psb-hidden"); 13 | overlay.classList.add("psb-hidden"); 14 | }; 15 | 16 | this.handleEvent("psb:open-sidebar", openSidebar); 17 | this.handleEvent("psb:close-sidebar", closeSidebar); 18 | 19 | window.addEventListener("psb:open-sidebar", openSidebar); 20 | window.addEventListener("psb:close-sidebar", closeSidebar); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /assets/js/lib/story_hook.js: -------------------------------------------------------------------------------- 1 | export const StoryHook = { 2 | mounted() { 3 | // scrolling to matching anchor if present in location hash 4 | if (window.location.hash) { 5 | const el = document.querySelector(window.location.hash); 6 | if (el) { 7 | const liveContainer = document.querySelector("#live-container"); 8 | setTimeout(() => { 9 | liveContainer.scrollTop = el.offsetTop - 115; 10 | }, 100); 11 | } 12 | } 13 | 14 | this.bindAnchorLinks(); 15 | this.bindCopyCodeLinks(); 16 | }, 17 | 18 | updated() { 19 | this.bindAnchorLinks(); 20 | }, 21 | bindAnchorLinks() { 22 | for (const link of document.querySelectorAll(".variation-anchor-link")) { 23 | link.addEventListener("click", (event) => { 24 | event.preventDefault(); 25 | window.history.replaceState({}, "", link.hash); 26 | }); 27 | } 28 | }, 29 | bindCopyCodeLinks() { 30 | const buttonClasses = ["psb-text-slate-500", "hover:psb-text-slate-100"]; 31 | const buttonActiveClasses = ["psb-text-green-400", "hover:psb-text-green-400"]; 32 | const iconClass = "fa-copy"; 33 | const iconActiveClass = "fa-check"; 34 | 35 | window.addEventListener("psb:copy-code", (e) => { 36 | const button = e.target; 37 | const icon = button.querySelector(".svg-inline--fa") || button.querySelector(".fa-copy"); 38 | button.classList.add(...buttonActiveClasses); 39 | button.classList.remove(...buttonClasses); 40 | icon.classList.add(iconActiveClass); 41 | icon.classList.remove(iconClass); 42 | 43 | this.copyToClipboard(button.nextElementSibling.textContent); 44 | 45 | setTimeout(() => { 46 | const icon = button.querySelector(".svg-inline--fa") || button.querySelector(".fa-copy"); 47 | icon.classList.add(iconClass); 48 | icon.classList.remove(iconActiveClass); 49 | button.classList.add(...buttonClasses); 50 | button.classList.remove(...buttonActiveClasses); 51 | }, 1000); 52 | }); 53 | }, 54 | copyToClipboard(text) { 55 | const textarea = document.createElement("textarea"); 56 | textarea.textContent = text; 57 | textarea.style.position = "fixed"; // Prevent scrolling to bottom of page in Microsoft Edge. 58 | document.body.appendChild(textarea); 59 | textarea.select(); 60 | try { 61 | return document.execCommand("copy"); // Security exception may be thrown by some browsers. 62 | } catch (ex) { 63 | console.warn("Copy to clipboard failed.", ex); 64 | return prompt("Copy to clipboard: Ctrl+C, Enter", text); 65 | } finally { 66 | document.body.removeChild(textarea); 67 | } 68 | }, 69 | }; 70 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phoenix_storybook", 3 | "version": "1.0.0", 4 | "description": "PhoenixStorybook assets", 5 | "main": "./assets/js/phoenix_storybook.js", 6 | "scripts": { 7 | "build:css": "tailwindcss --minify -i css/app.css -o ../priv/static/css/app.css", 8 | "build:fonts_css": "tailwindcss --minify -i css/fonts.css -o ../priv/static/css/fonts.css", 9 | "build:js": "esbuild js/*.js --minify --bundle --outdir=../priv/static/js", 10 | "build": "npm-run-all build:*", 11 | "watch:css": "tailwindcss -i css/app.css -o ../priv/static/css/app.css --watch", 12 | "watch:fonts_css": "tailwindcss -i css/fonts.css -o ../priv/static/css/fonts.css --watch", 13 | "watch:js": "esbuild js/*.js --bundle --outdir=../priv/static/js --watch", 14 | "watch": "npm-run-all --parallel watch:*" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@biomejs/biome": "1.9.4", 20 | "@tailwindcss/forms": "^0.5.10", 21 | "esbuild": "^0.25.4", 22 | "npm-run-all": "^4.1.5", 23 | "phoenix": "file:../deps/phoenix", 24 | "phoenix_html": "file:../deps/phoenix_html", 25 | "phoenix_live_view": "file:../deps/phoenix_live_view", 26 | "tailwindcss": "^3.4.17", 27 | "tailwindcss-font-inter": "^3.1.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./css/**/*.css", 4 | "./js/**/*.js", 5 | "../lib/**/*.{ex,heex}", 6 | "../priv/templates/**/*.eex", 7 | ], 8 | safelist: [ 9 | { pattern: /^\!?psb-(w|h|m|p)\w?-.+/ }, 10 | { 11 | pattern: 12 | /^psb-text-(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)(-\d\d\d?)?$/, 13 | }, 14 | ], 15 | theme: { 16 | extend: { 17 | minHeight: (theme) => ({ 18 | ...theme("spacing"), 19 | }), 20 | }, 21 | }, 22 | 23 | plugins: [ 24 | require("@tailwindcss/forms")({ 25 | strategy: "class", 26 | }), 27 | ], 28 | corePlugins: { 29 | preflight: false, 30 | }, 31 | important: ".psb", 32 | prefix: "psb-", 33 | darkMode: "selector", 34 | }; 35 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["./assets/biome.json"] 3 | } 4 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :phoenix, :json_library, Jason 4 | 5 | config :phoenix_storybook, :env, config_env() 6 | config :phoenix_storybook, :gzip_assets, false 7 | config :elixir, :dbg_callback, {PhoenixStorybook.Dbg, :debug_fun, [:stdio]} 8 | 9 | config :logger, 10 | backends: [:console], 11 | compile_time_purge_matching: [ 12 | [module: Earmark.Parser.LineScanner] 13 | ] 14 | 15 | import_config "#{config_env()}.exs" 16 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | config :phoenix_storybook, :gzip_assets, true 3 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :logger, :level, :info 4 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverage_options": { 3 | "output_dir": "coverage/", 4 | "treat_no_relevant_lines_as_covered": true 5 | }, 6 | "skip_files": [ 7 | "test/fixtures", 8 | "lib/phoenix_storybook/web.ex", 9 | "lib/phoenix_storybook/dbg.ex" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /guides/color_modes.md: -------------------------------------------------------------------------------- 1 | # Color modes 2 | 3 | The storybook supports three color modes: _dark_, _light_ and _system_. 4 | 5 | - The Storybook's styling adapts based on the selected color mode. 6 | - Your components are wrapped in a `
` with a custom dark class. 7 | 8 | The different modes are handled as follows: 9 | 10 | - `dark`: the `dark` class (or custom dark class) is applied to your component's sandbox 11 | - `light`: no class is applied 12 | - `system`: The `dark` class is added if your system prefers dark mode (as determined by the `prefers-color-scheme` media query). 13 | 14 | ## Setup 15 | 16 | To enable color mode support, you need to configure it in your Storybook setup: 17 | 18 | ```elixir 19 | use PhoenixStorybook, 20 | # ... 21 | color_mode: true 22 | ``` 23 | 24 | This configuration adds a color theme picker to the Storybook header, allowing you to render the Storybook with the selected mode. 25 | 26 | ## Component rendering 27 | 28 | When your components are rendered in Storybook, they are wrapped in a sandbox element (read [sandboxing guide](sandboxing.md)). 29 | 30 | - If the current color mode is dark (or system mode with dark preference), the sandbox will have a `dark` CSS class. 31 | - In light mode, no class is applied. 32 | 33 | You can customize the default dark class by specifying it in your configuration: 34 | 35 | ```elixir 36 | use PhoenixStorybook, 37 | # ... 38 | color_mode_sandbox_dark_class: "my-dark", 39 | ``` 40 | 41 | ## Tailwind setup 42 | 43 | If you use Tailwind for your components, update your `tailwind.config.js` file as follows: 44 | 45 | ```js 46 | module.exports = { 47 | // ... 48 | darkMode: "selector", 49 | }; 50 | ``` 51 | 52 | To use a custom dark class, modify the configuration like this: 53 | 54 | ```js 55 | module.exports = { 56 | // ... 57 | darkMode: ["selector", ".my-dark"], 58 | }; 59 | ``` 60 | 61 | In your application, ensure that the dark mode class is applied to your DOM element, particularly on or under your sandbox element: 62 | 63 | ```html 64 | 65 | ... 66 | 67 | ``` 68 | -------------------------------------------------------------------------------- /guides/setup.md: -------------------------------------------------------------------------------- 1 | # Manual setup 2 | 3 | To start using `PhoenixStorybook` in your Phoenix application you will need to follow these steps: 4 | 5 | 1. Add the `phoenix_storybook` dependency 6 | 2. Create your storybook backend module 7 | 3. Add storybook access to your router 8 | 4. Make your components' assets available 9 | 5. Update your Docker image 10 | 6. Create some content 11 | 12 | ## 1. Add the `phoenix_storybook` dependency 13 | 14 | Add the following to your mix.exs and run mix deps.get: 15 | 16 | ```elixir 17 | def deps do 18 | [ 19 | {:phoenix_storybook, "~> 0.8.0"} 20 | ] 21 | end 22 | ``` 23 | 24 | ## 2. Create your storybook backend module 25 | 26 | Create a new module under your application lib folder: 27 | 28 | ```elixir 29 | # lib/my_app_web/storybook.ex 30 | defmodule MyAppWeb.Storybook do 31 | use PhoenixStorybook, 32 | otp_app: :my_app, 33 | content_path: Path.expand("../storybook", __DIR__), 34 | # assets path are remote path, not local file-system paths 35 | css_path: "/assets/my_components.css", 36 | js_path: "/assets/my_components.js" 37 | end 38 | ``` 39 | 40 | ## 3. Add storybook access to your router 41 | 42 | Once installed, update your router's configuration to forward requests to a `PhoenixStorybook` 43 | with a unique name of your choice: 44 | 45 | ```elixir 46 | # lib/my_app_web/router.ex 47 | use MyAppWeb, :router 48 | import PhoenixStorybook.Router 49 | ... 50 | scope "/" do 51 | storybook_assets() 52 | end 53 | 54 | scope "/", PhoenixStorybookSampleWeb do 55 | pipe_through(:browser) 56 | ... 57 | live_storybook "/storybook", backend_module: MyAppWeb.Storybook 58 | end 59 | ``` 60 | 61 | ## 4. Make your components' assets available 62 | 63 | Build a new CSS bundle dedicated to your live_view components: this bundle will be used both by your 64 | app and the storybook. 65 | 66 | In this README, we use `assets/css/storybook.css` as an example. 67 | 68 | If your components require any hooks or custom uploaders, or if your pages require connect parameters, 69 | declare them as such in a new JS bundle: 70 | 71 | ```javascript 72 | // assets/js/storybook.js 73 | 74 | import * as Hooks from "./hooks"; 75 | import * as Params from "./params"; 76 | import * as Uploaders from "./uploaders"; 77 | 78 | (function () { 79 | window.storybook = { Hooks, Params, Uploaders }; 80 | })(); 81 | ``` 82 | 83 | Your application must bundle these assets and serve them. Our custom `mix phx.gen.storybook` 84 | generator may guide you through these steps. 85 | 86 | ℹ️ Learn more on this topic in the [sandboxing guide](guides/sandboxing.md). 87 | 88 | ## 5. Update your Docker image 89 | 90 | If you are deploying your app with Docker, then you need to copy the storybook content into your 91 | Docker image. 92 | 93 | Add this to your `Dockerfile`: 94 | 95 | ```docker 96 | COPY storybook storybook 97 | ``` 98 | 99 | ## 6. Create some content 100 | 101 | Then you can start creating some content for your storybook. Storybook can contain different kinds 102 | of _stories_: 103 | 104 | - **component stories**: to document and showcase your components across different variations. 105 | - **pages**: to publish some UI guidelines, framework with regular HTML content. 106 | - **examples**: to show how your components can be used and mixed in real UI pages. 107 | 108 | Stories are described as Elixir scripts (`.story.exs`) created under your `:content_path` folder. 109 | Feel free to organize them in sub-folders, as the hierarchy will be respected in your storybook 110 | sidebar. 111 | 112 | Here is an example of a stateless (function) component story: 113 | 114 | ```elixir 115 | # storybook/components/button.story.exs 116 | defmodule MyAppWeb.Storybook.Components.Button do 117 | alias MyAppWeb.Components.Button 118 | 119 | # :live_component or :page are also available 120 | use PhoenixStorybook.Story, :component 121 | 122 | def function, do: &Button.button/1 123 | 124 | def variations do [ 125 | %Variation{ 126 | id: :default, 127 | attributes: %{ 128 | label: "A button" 129 | } 130 | }, 131 | %Variation{ 132 | id: :green_button, 133 | attributes: %{ 134 | label: "Still a button", 135 | color: :green 136 | } 137 | } 138 | ] 139 | end 140 | end 141 | ``` 142 | -------------------------------------------------------------------------------- /guides/testing.md: -------------------------------------------------------------------------------- 1 | # Visual Regression Testing 2 | 3 | While we still encourage you writing regular unit test for your components, this doesn't protect 4 | you against visual regressions. 5 | 6 | Visual Regression Testing consists in taking automated screenshots of your components and compare 7 | them pixel-per-pixel to notice any unwanted change. 8 | 9 | For this we recommend using a dedicated tool such as [percy.io](https://percy.io/). 10 | 11 | This library provides you a dedicated endpoint to output your stories (only components' stories) 12 | without the storybook main UI: 13 | 14 | - single story endpoint: `https://localhost:4000/storybook/visual_tests/buttons/button` 15 | - range story endpoint: `https://localhost:4000/storybook/visual_tests?start=a&end=e` 16 | 17 | _The last one renders all stories whose name starting between letter 'a' and letter 'e')_ 18 | -------------------------------------------------------------------------------- /guides/theming.md: -------------------------------------------------------------------------------- 1 | # Theming components 2 | 3 | ## Theming Strategies 4 | 5 | The storybook gives you different possibilities to apply a theme to your components. These 6 | possibilities are named _strategies_. 7 | 8 | The following strategies are available: 9 | 10 | 1. _sandbox class_: set your theme as a CSS class, on the sandbox container, with a custom prefix 11 | 2. _assign_: pass the theme as an assign to your components, with a custom key. 12 | 3. _function_: call a custom module/function along with the current theme. 13 | 14 | Here is how you can use these strategies. In your `storybook.ex`: 15 | 16 | ```elixir 17 | use PhoenixStorybook, 18 | themes_strategies: [ 19 | sandbox_class: "prefix", # will set a class prefixed by `prefix-` on the sandbox container 20 | assign: :theme, 21 | function: {MyApp.ThemeHelper, :register_theme} 22 | ] 23 | ``` 24 | 25 | If the `themes_strategies` key is undefined, the default `sandbox_class: "theme"` strategy is applied. 26 | 27 | ## CSS theming 28 | 29 | By default, the storybook is applying a `theme-*` CSS class to your components/page containers and 30 | you should do as well to your application HTML body element. 31 | 32 | It will allow you to style raw HTML elements 33 | 34 | ```css 35 | body.theme-colorful { 36 | font-family: // ... 37 | } 38 | 39 | .theme-colorful h1 { 40 | font-family: // ... 41 | font-size: // ... 42 | } 43 | ``` 44 | 45 | ## Using a Registry 46 | 47 | This chapter explain how you can leverage on a `Registry` with the _function_ theming strategy. 48 | 49 | An effective way to store the current theme setting so that it can be available to all your 50 | components, but still have different values for different (concurrent) users is to associate it to 51 | the current LiveView pid. 52 | 53 | `Registry` is a native Elixir module that handles decentralized storage, linked to specific 54 | processes. We will leverage on this to associate a theme to the current LiveView pid. 55 | 56 | First start a `Registry` from your `Application` module. 57 | 58 | ```elixir 59 | defmodule PhenixStorybook.Application do 60 | def start(_type, _args) do 61 | children = [ 62 | {Registry, keys: :duplicate, name: ThemeRegistry} 63 | ] 64 | end 65 | end 66 | ``` 67 | 68 | Then create a **LiveView Hook** that will fetch the theme from wherever it is relevant for your 69 | application: database, user session, URL params... and store it in the `Registry` (it's working 70 | because the Hook is running under the same pid than the Liveview). 71 | 72 | ```elixir 73 | defmodule ThemeHook do 74 | def on_mount(:default, params, _session, socket) do 75 | theme = current_user_theme(socket, params) 76 | Registry.register(ThemeRegistry, :theme, theme) 77 | {:cont, socket} 78 | end 79 | end 80 | ``` 81 | 82 | Mount the hook in your `router`. 83 | 84 | ```elixir 85 | defmodule Router do 86 | live_session :default, on_mount: [ThemeHook] do 87 | scope "/" do 88 | # ... 89 | end 90 | end 91 | end 92 | ``` 93 | 94 | Write a helper module, to be used from your components to fetch the current theme from the 95 | `Registry` and merge it in the component's assigns. 96 | 97 | ```elixir 98 | defmodule ThemeHelpers do 99 | def set_theme(assigns) do 100 | pid_and_themes = Registry.lookup(ThemeRegistry, :theme) 101 | 102 | case find_by_pid(pid_and_themes, self()) do 103 | {_pid, theme} -> Map.put_new(assigns, :theme, theme) 104 | _ -> raise("theme not found in registry") 105 | end 106 | end 107 | 108 | defp find_by_pid(pid_and_themes, current_pid) do 109 | Enum.find(pid_and_themes, fn {pid, _} -> pid == current_pid end) 110 | end 111 | end 112 | ``` 113 | -------------------------------------------------------------------------------- /lib/mix/tasks/dev.storybook.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Dev.Storybook do 2 | @shortdoc "Make sure storybook is properly setup as a local dependency." 3 | @moduledoc """ 4 | Make sure your storybook local dependency has all its assets packaged in priv. 5 | 6 | ```bash 7 | $> mix dev.storybook 8 | ``` 9 | """ 10 | 11 | use Mix.Task 12 | 13 | @doc false 14 | def run(_args) do 15 | Mix.shell().info("Setting up storybook for local development.") 16 | storybook_app = :phoenix_storybook 17 | current_app = Mix.Project.config()[:app] 18 | 19 | if storybook_app == current_app do 20 | setup_storybook(File.cwd!(), storybook_app) 21 | else 22 | case Mix.Project.deps_paths() |> Map.get(storybook_app) do 23 | nil -> Mix.raise("#{storybook_app} not found in your mix dependencies") 24 | dep_path -> setup_storybook(dep_path, storybook_app) 25 | end 26 | end 27 | 28 | :ok 29 | end 30 | 31 | defp setup_storybook(path, storybook_app) do 32 | Mix.shell().info("#{storybook_app} installed in #{path}") 33 | 34 | Mix.shell().info("* Running mix deps.get for #{storybook_app} dependency") 35 | cmd_unless_test("mix deps.get > /dev/null", cd: path) 36 | 37 | Mix.shell().info("* Running npm ci for #{storybook_app} dependency") 38 | cmd_unless_test("npm ci --prefix assets > /dev/null", cd: path) 39 | 40 | Mix.shell().info("* Running mix assets.build for #{storybook_app} dependency") 41 | cmd_unless_test("mix assets.build", cd: path) 42 | end 43 | 44 | defp cmd_unless_test(cmd, opts) do 45 | unless Mix.env() == :test do 46 | Mix.shell().cmd(cmd, opts) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/application.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | alias PhoenixStorybook.Events.Instrumenter 6 | 7 | @impl true 8 | def start(_type, _args) do 9 | Instrumenter.setup() 10 | 11 | Supervisor.start_link( 12 | [ 13 | {Phoenix.PubSub, name: PhoenixStorybook.PubSub}, 14 | {PhoenixStorybook.ExsCompiler, []} 15 | ], 16 | strategy: :one_for_one, 17 | name: PhoenixStorybook.Supervisor 18 | ) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/controllers/asset_not_found_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.AssetNotFoundController do 2 | @moduledoc false 3 | # Dummy controller that only exists because `Plug.Static` requires 4 | # that a route matches an actual path to be executed. 5 | # 6 | # Any valid asset request will halt before this controller is executed. 7 | # Only bad requests (404) will reach to here. 8 | 9 | use PhoenixStorybook.Web, :controller 10 | 11 | def asset(_conn, path) do 12 | raise PhoenixStorybook.AssetNotFound, "unknown asset #{inspect(path)}" 13 | end 14 | end 15 | 16 | defmodule PhoenixStorybook.AssetNotFound do 17 | @moduledoc false 18 | defexception [:message, plug_status: 404] 19 | end 20 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/dbg.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Dbg do 2 | @moduledoc false 3 | 4 | def debug_fun(code, options, caller, device) do 5 | case Keyword.pop(options, :debug, false) do 6 | {true, options} -> Macro.dbg(code, options, caller) 7 | {_, options} -> custom_debug(code, options, caller, device) 8 | end 9 | end 10 | 11 | defp custom_debug(code, options, caller, device) do 12 | alias PhoenixStorybook.Dbg 13 | 14 | quote do 15 | result = unquote(code) 16 | 17 | IO.puts( 18 | unquote(device), 19 | Dbg.light_green( 20 | "\n#{unquote(Path.relative_to(caller.file, File.cwd!()))}:#{unquote(caller.line)}" 21 | ) 22 | ) 23 | 24 | IO.puts( 25 | unquote(device), 26 | "#{unquote(Macro.to_string(code))} #{Dbg.light_green("=>")} #{result |> inspect(unquote(options)) |> Dbg.bright()}" 27 | ) 28 | 29 | result 30 | end 31 | end 32 | 33 | def bright(s), do: IO.ANSI.bright() <> s <> IO.ANSI.reset() 34 | def light_green(s), do: IO.ANSI.light_green() <> s <> IO.ANSI.reset() 35 | end 36 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/events/event_log.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Events.EventLog do 2 | @moduledoc false 3 | # Data structure for event logs displayed in each story's playground 4 | 5 | @type t :: %__MODULE__{ 6 | type: :live_view | :component, 7 | parent_pid: pid(), 8 | view: atom(), 9 | event: binary(), 10 | params: map(), 11 | assigns: map(), 12 | time: Time.t() 13 | } 14 | 15 | defstruct [:type, :parent_pid, :view, :event, :params, :assigns, :time] 16 | end 17 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/events/instrumenter.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Events.Instrumenter do 2 | @moduledoc false 3 | # Event handlers for LiveView exposed telemetry events 4 | alias Phoenix.PubSub 5 | alias PhoenixStorybook.Events.EventLog 6 | 7 | def setup do 8 | events = [ 9 | [:phoenix, :live_view, :handle_event, :stop], 10 | [:phoenix, :live_component, :handle_event, :stop] 11 | ] 12 | 13 | :telemetry.attach_many("psb-instrumenter", events, &__MODULE__.handle_event/4, nil) 14 | end 15 | 16 | def handle_event([:phoenix, :live_view, :handle_event, :stop], _measurements, metadata, _config) do 17 | PubSub.broadcast!( 18 | PhoenixStorybook.PubSub, 19 | "event_logs:#{inspect(metadata.socket.root_pid)}", 20 | %{metadata_to_event_log(metadata) | type: :live_view} 21 | ) 22 | end 23 | 24 | def handle_event( 25 | [:phoenix, :live_component, :handle_event, :stop], 26 | _measurements, 27 | metadata, 28 | _config 29 | ) do 30 | PubSub.broadcast!( 31 | PhoenixStorybook.PubSub, 32 | "event_logs:#{inspect(metadata.socket.root_pid)}", 33 | %{metadata_to_event_log(metadata) | type: :component} 34 | ) 35 | end 36 | 37 | defp metadata_to_event_log(metadata) do 38 | %EventLog{ 39 | parent_pid: metadata.socket.parent_pid, 40 | view: metadata.socket.view, 41 | event: metadata.event, 42 | params: metadata.params, 43 | assigns: metadata.socket.assigns, 44 | time: Time.utc_now() 45 | } 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/exs_compiler.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.ExsCompiler do 2 | @moduledoc false 3 | 4 | # This module is intended to compile exs files non concurrently. 5 | # We indeed use `Code.put_compiler_option/2` which can lead to race conditions. 6 | 7 | use GenServer 8 | require Logger 9 | 10 | def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) 11 | def init(opts), do: {:ok, opts} 12 | 13 | def compile_exs!(path, relative_to, opts \\ []) do 14 | do_compile_exs!(path, relative_to, opts) 15 | end 16 | 17 | def compile_exs(path, relative_to, opts \\ []) do 18 | GenServer.call(__MODULE__, {:compile_exs, path, relative_to, opts}) 19 | end 20 | 21 | def handle_call({:compile_exs, path, relative_to, opts}, _from, state) do 22 | module = do_compile_exs(path, relative_to, opts) 23 | {:reply, module, state} 24 | end 25 | 26 | defp do_compile_exs!(path, relative_to, opts) do 27 | original_ignore_module_conflict = Code.get_compiler_option(:ignore_module_conflict) 28 | 29 | try do 30 | if opts[:compilation_debug] do 31 | Logger.debug("compiling storybook file: #{path}") 32 | end 33 | 34 | Code.put_compiler_option(:ignore_module_conflict, true) 35 | modules = Code.compile_file(path, relative_to) |> Enum.map(&elem(&1, 0)) 36 | 37 | Enum.find( 38 | modules, 39 | Enum.at(modules, 0), 40 | &function_exported?(&1, :storybook_type, 0) 41 | ) 42 | after 43 | Code.put_compiler_option(:ignore_module_conflict, original_ignore_module_conflict) 44 | end 45 | end 46 | 47 | defp do_compile_exs(path, relative_to, opts) do 48 | module = do_compile_exs!(path, relative_to, opts) 49 | {:ok, module} 50 | rescue 51 | e -> 52 | message = "Could not compile #{inspect(path)}" 53 | exception = Exception.format(:error, e, __STACKTRACE__) 54 | Logger.error(message <> "\n\n" <> exception) 55 | {:error, message, exception} 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/guides/guide_macros.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Guides.Macros do 2 | @moduledoc false 3 | 4 | defmacro __using__(__opts \\ []) do 5 | if PhoenixStorybook.enabled?() do 6 | for path <- Path.wildcard(Path.expand("../../../guides/*.md", __DIR__)), 7 | guide = Path.basename(path), 8 | markdown = File.read!(path), 9 | {:ok, html_guide, _} = Earmark.as_html(markdown) do 10 | quote do 11 | @external_resource unquote(path) 12 | def markup(unquote(guide)) do 13 | unquote(html_guide) 14 | end 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/guides/guides.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Guides do 2 | @moduledoc """ 3 | This module is meant to be used from generated `welcome.story.exs` page. 4 | It renders HTML markup from markdown guides located in the guides/folder. 5 | 6 | Markup is precompiled because: 7 | - we don't want to force user application to embed Earmark 8 | - we don't want to put markdown guides in priv 9 | 10 | ## Examples 11 | 12 | ```elixir 13 | Guides.markup("components.md") 14 | Guides.markup("icons.md") 15 | ``` 16 | """ 17 | 18 | use PhoenixStorybook.Guides.Macros 19 | end 20 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/helpers/asset_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.AssetHelpers do 2 | @moduledoc false 3 | 4 | def parse_manifest(manifest_path) do 5 | with {:ok, manifest_json} <- File.read(manifest_path), 6 | {:ok, manifest_body} <- Jason.decode(manifest_json) do 7 | manifest_body 8 | else 9 | _ -> raise "cannot read manifest #{manifest_path}" 10 | end 11 | end 12 | 13 | def asset_file_name(manifest, asset, :prod) do 14 | case manifest |> Map.get("latest", %{}) |> Map.get(asset) do 15 | nil -> raise "cannot find asset #{asset} in manifest" 16 | asset -> asset 17 | end 18 | end 19 | 20 | def asset_file_name(_manifest, _asset, _env), do: nil 21 | end 22 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/helpers/example_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Helpers.ExampleHelpers do 2 | @moduledoc false 3 | 4 | def strip_example_source(code) do 5 | with {:ok, ast, comments} <- 6 | Code.string_to_quoted_with_comments(code, 7 | literal_encoder: &{:ok, {:__block__, &2, [&1]}}, 8 | token_metadata: true, 9 | unescape: false 10 | ), 11 | {:defmodule, m1, [aliases, [{{:__block__, m2, [:do]}, {:__block__, m3, block}}]]} <- ast do 12 | new_block = 13 | block 14 | # drop doc and extra_sources functions from the source code 15 | |> Enum.reject(fn 16 | {:def, _, [{:doc, _, _} | _]} -> true 17 | {:def, _, [{:extra_sources, _, _} | _]} -> true 18 | _ -> false 19 | end) 20 | # replace `use PhoenixStorybook.Story, :example` with `use Phoenix.LiveView` 21 | |> Enum.map(fn 22 | {:use, m4, [{:__aliases__, _, [:PhoenixStorybook, :Story]}, _example]} -> 23 | {:use, m4, [{:__aliases__, [], [:Phoenix, :LiveView]}]} 24 | 25 | other -> 26 | other 27 | end) 28 | 29 | new_ast = 30 | {:defmodule, m1, [aliases, [{{:__block__, m2, [:do]}, {:__block__, m3, new_block}}]]} 31 | 32 | algebra = Code.quoted_to_algebra(new_ast, comments: comments) 33 | doc = Inspect.Algebra.format(algebra, 98) 34 | IO.iodata_to_binary(doc) 35 | else 36 | _ -> code 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/helpers/navigation_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.NavigationHelpers do 2 | @moduledoc false 3 | 4 | alias Phoenix.LiveView 5 | 6 | def patch_to(socket, root_path, story_path, params \\ %{}) do 7 | path = path_to(socket, root_path, story_path, params) 8 | LiveView.push_patch(socket, to: path) 9 | end 10 | 11 | def navigate_to(socket, root_path, story_path, params \\ %{}) do 12 | path = path_to(socket, root_path, story_path, params) 13 | LiveView.push_navigate(socket, to: path) 14 | end 15 | 16 | def path_to(%{assigns: assigns}, root_path, story_path, params) do 17 | query = build_query(assigns, params) 18 | build_path(root_path, story_path, query) 19 | end 20 | 21 | def path_to_iframe(%{assigns: assigns}, root_path, story_path, params) do 22 | query = build_query(assigns, params) 23 | 24 | root_path 25 | |> Path.join("iframe") 26 | |> build_path(story_path, query) 27 | end 28 | 29 | defp build_path(root_path, story_path, query) do 30 | path = Path.join(root_path, story_path) 31 | 32 | if Enum.any?(query) do 33 | query = query |> Enum.to_list() |> Enum.sort_by(&elem(&1, 0)) 34 | path <> "?" <> URI.encode_query(query) 35 | else 36 | path 37 | end 38 | end 39 | 40 | defp build_query(assigns, params) do 41 | params = Map.new(params) 42 | 43 | assigns 44 | |> Map.take([:theme, :tab, :variation_id]) 45 | |> Map.merge(params) 46 | |> Enum.reject(fn {_key, value} -> is_nil(value) end) 47 | |> Enum.to_list() 48 | |> Enum.sort_by(fn {key, _value} -> to_string(key) end) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/helpers/template_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.TemplateHelpers do 2 | @moduledoc false 3 | 4 | @variation_regex ~r{(<\.psb-variation\/>)|(<\.psb-variation\s[^(\>)]*\/>)} 5 | @variation_group_regex ~r{<\.psb-variation-group[^(\>)]*\/>} 6 | @html_attributes_regex ~r{(\w+)=((?:.(?!["']?\s+(?:\S+)=|\s*\/?[>]))+.["']?)?} 7 | @js_push_regex ~r[(JS\.push\("(?:psb-assign|psb-toggle)".*value:\s+)(%{.*})(.*\))] 8 | 9 | def default_template, do: "<.psb-variation/>" 10 | 11 | def set_variation_dom_id(template, unique_variation_id) do 12 | String.replace(template, ":variation_id", unique_variation_id) 13 | end 14 | 15 | def set_js_push_variation_id(template, variation_id) do 16 | Regex.replace(@js_push_regex, template, fn _, open, match, close -> 17 | match = 18 | match 19 | |> Code.eval_string() 20 | |> elem(0) 21 | |> Map.put(:variation_id, unique_variation_id_serializable(variation_id)) 22 | |> inspect(custom_options: [sort_maps: true]) 23 | 24 | open <> match <> close 25 | end) 26 | end 27 | 28 | def unique_variation_id(story, {group_id, variation_id}) do 29 | unique_variation_id(story, "#{group_id}-#{variation_id}") 30 | end 31 | 32 | def unique_variation_id(story, variation_id) do 33 | "#{story_module_name(story)}-#{variation_id}" 34 | |> Macro.underscore() 35 | |> String.replace("_", "-") 36 | end 37 | 38 | defp story_module_name(story) do 39 | story |> to_string() |> String.split(".") |> Enum.at(-1) 40 | end 41 | 42 | defp unique_variation_id_serializable({group_id, variation_id}), do: [group_id, variation_id] 43 | 44 | def variation_template?(template) do 45 | Regex.match?(@variation_regex, template) 46 | end 47 | 48 | def variation_group_template?(template) do 49 | Regex.match?(@variation_group_regex, template) 50 | end 51 | 52 | def code_hidden?(template) do 53 | String.contains?(template, "psb-code-hidden") 54 | end 55 | 56 | def replace_template_variation(template, variation_markup, indent? \\ false) do 57 | replace_in_template(template, @variation_regex, variation_markup, indent?) 58 | end 59 | 60 | def replace_template_variation_group(template, variation_group_markup, indent? \\ false) do 61 | replace_in_template(template, @variation_group_regex, variation_group_markup, indent?) 62 | end 63 | 64 | def get_template(template, :unset), do: template 65 | def get_template(_template, template) when template in [nil, false], do: default_template() 66 | def get_template(_template, template), do: template 67 | 68 | def extract_placeholder_attributes(template, inspect \\ nil) do 69 | cond do 70 | variation_template?(template) -> 71 | extract_placeholder_attributes(template, @variation_regex, inspect) 72 | 73 | variation_group_template?(template) -> 74 | extract_placeholder_attributes(template, @variation_group_regex, inspect) 75 | 76 | true -> 77 | [] 78 | end 79 | end 80 | 81 | defp extract_placeholder_attributes(template, regex, _inspect = nil) do 82 | [placeholder | _] = Regex.run(regex, template) 83 | 84 | @html_attributes_regex 85 | |> Regex.scan(placeholder) 86 | |> Enum.map(fn [match, _, _] -> match end) 87 | end 88 | 89 | # When rendering a variation from the component Playground, the playground will pass some context 90 | # (topic and variation_id). 91 | # We use this context to wrap template examples, unknown from the Playground, within a 92 | # `psb_inspect/4` call that will broadcast examples to the Playground. 93 | defp extract_placeholder_attributes(template, regex, {topic, variation_id}) do 94 | [placeholder | _] = Regex.run(regex, template) 95 | 96 | @html_attributes_regex 97 | |> Regex.scan(placeholder) 98 | |> Enum.map(fn [_, term1, term2] -> 99 | "#{term1}={psb_inspect(#{inspect(topic)}, #{inspect(variation_id)}, :#{term1}, #{inspect_val(term2)})}" 100 | end) 101 | end 102 | 103 | defp inspect_val(var) do 104 | Regex.replace(~r|{(.*)}|, var, "\\1") 105 | end 106 | 107 | defp replace_in_template(template, regex, markup, _indent? = true) do 108 | template 109 | |> String.split("\n") 110 | |> Enum.map_join("\n", fn line -> 111 | if Regex.match?(regex, line) do 112 | indent_size = indent_size(line) 113 | indent(markup, indent_size) 114 | else 115 | line 116 | end 117 | end) 118 | end 119 | 120 | defp replace_in_template(template, regex, markup, _indent? = false) do 121 | String.replace(template, regex, markup) 122 | end 123 | 124 | defp indent_size(line) do 125 | if String.starts_with?(line, " ") do 126 | [indent | _] = line |> String.codepoints() |> Enum.chunk_by(&(&1 == " ")) 127 | length(indent) 128 | else 129 | 0 130 | end 131 | end 132 | 133 | defp indent(markup, indent_size) do 134 | indent = indent(indent_size) 135 | 136 | markup 137 | |> String.split("\n") 138 | |> Enum.reject(&(&1 == "")) 139 | |> Enum.map_join("\n", &(indent <> &1)) 140 | end 141 | 142 | defp indent(0), do: "" 143 | defp indent(size), do: Enum.map_join(1..size, fn _ -> " " end) 144 | end 145 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/helpers/theme_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.ThemeHelpers do 2 | @moduledoc false 3 | 4 | def theme_sandbox_class(backend_module, theme) do 5 | case theme_strategy(backend_module, :sandbox_class) do 6 | nil -> nil 7 | prefix -> "#{prefix}-#{theme}" 8 | end 9 | end 10 | 11 | def theme_assign(backend_module, theme) do 12 | case theme_strategy(backend_module, :assign) do 13 | nil -> nil 14 | assign_key when is_binary(assign_key) -> {String.to_atom(assign_key), theme} 15 | assign_key -> {assign_key, theme} 16 | end 17 | end 18 | 19 | def call_theme_function(backend_module, theme) do 20 | case theme_strategy(backend_module, :function) do 21 | nil -> nil 22 | {module, fun} -> apply(module, fun, [theme]) 23 | end 24 | end 25 | 26 | def theme_strategy(backend_module, strategy) do 27 | backend_module.config(:themes_strategies, sandbox_class: "theme") 28 | |> Keyword.get(strategy) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/helpers/validation_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.ValidationHelpers do 2 | @moduledoc false 3 | 4 | def validate_type!(file, term, types, message) when is_list(types) do 5 | unless Enum.any?(types, &match_attr_type?(term, &1)), do: compile_error!(file, message) 6 | end 7 | 8 | def validate_type!(file, term, type, message) do 9 | unless match_attr_type?(term, type), do: compile_error!(file, message) 10 | end 11 | 12 | def validate_icon!(file, term, message \\ "") 13 | def validate_icon!(_file, nil, _message), do: :ok 14 | 15 | def validate_icon!(file, term, message_prefix) do 16 | cond do 17 | match_attr_type?(term, {:tuple, 2}) -> 18 | validate_icon_provider!(file, term) 19 | 20 | validate_type!( 21 | file, 22 | elem(term, 1), 23 | :string, 24 | message_prefix <> "icon name must be a binary" 25 | ) 26 | 27 | match_attr_type?(term, {:tuple, 3}) -> 28 | validate_icon_provider!(file, term) 29 | icon_provider = elem(term, 0) 30 | 31 | validate_type!( 32 | file, 33 | elem(term, 1), 34 | :string, 35 | message_prefix <> "icon name must be a binary" 36 | ) 37 | 38 | case icon_provider do 39 | :local -> 40 | validate_type!( 41 | file, 42 | elem(term, 2), 43 | :string, 44 | message_prefix <> "icon class must be a binary" 45 | ) 46 | 47 | _ -> 48 | validate_type!( 49 | file, 50 | elem(term, 2), 51 | :atom, 52 | message_prefix <> "icon style must be an atom" 53 | ) 54 | end 55 | 56 | match_attr_type?(term, {:tuple, 4}) -> 57 | validate_icon_provider!(file, term) 58 | 59 | if elem(term, 0) == :local do 60 | compile_error!(file, "local icons only support 2 or 3 elem tuples") 61 | end 62 | 63 | validate_type!( 64 | file, 65 | elem(term, 1), 66 | :string, 67 | message_prefix <> "icon name must be a binary" 68 | ) 69 | 70 | validate_type!(file, elem(term, 2), :atom, message_prefix <> "icon style must be an atom") 71 | 72 | validate_type!( 73 | file, 74 | elem(term, 3), 75 | :string, 76 | message_prefix <> "icon class must be a binary" 77 | ) 78 | 79 | true -> 80 | compile_error!( 81 | file, 82 | message_prefix <> 83 | "icon must be a tuple 2, 3 or 4 items ({provider, name, style, class})" 84 | ) 85 | end 86 | end 87 | 88 | defp validate_icon_provider!(file, term) do 89 | unless elem(term, 0) in [:fa, :hero, :local], 90 | do: compile_error!(file, "icon provider must be either :fa, :hero, or :local") 91 | end 92 | 93 | def match_attr_type?(nil, _type), do: true 94 | def match_attr_type?({:eval, _term}, _type), do: true 95 | def match_attr_type?(_term, :any), do: true 96 | def match_attr_type?(term, {:tuple, s}) when is_tuple(term) and tuple_size(term) == s, do: true 97 | def match_attr_type?(term, :string) when is_binary(term), do: true 98 | def match_attr_type?(term, :atom) when is_atom(term), do: true 99 | def match_attr_type?(term, :integer) when is_integer(term), do: true 100 | def match_attr_type?(term, :float) when is_float(term), do: true 101 | def match_attr_type?(term, :boolean) when is_boolean(term), do: true 102 | def match_attr_type?(term, :list) when is_list(term), do: true 103 | def match_attr_type?(_min.._max//_, :range), do: true 104 | def match_attr_type?(term, :global) when is_map(term), do: true 105 | def match_attr_type?(term, :map) when is_map(term), do: true 106 | def match_attr_type?(term, :function) when is_function(term), do: true 107 | def match_attr_type?(term, struct) when is_struct(term, struct), do: true 108 | def match_attr_type?(_term, _type), do: false 109 | 110 | def compile_error!(file_path, msg) do 111 | raise CompileError, file: file_path, description: msg 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/live/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.ErrorView do 2 | use PhoenixStorybook.Web, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/live/story/component_doc.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Story.ComponentDoc do 2 | @moduledoc false 3 | 4 | use PhoenixStorybook.Web, :component 5 | import PhoenixStorybook.Components.Icon 6 | 7 | alias PhoenixStorybook.Stories.Doc 8 | 9 | @doc """ 10 | Renders story documentation. 11 | 12 | `story.doc()` may return: 13 | - `nil` 14 | - a string, when `doc/0` has been implemented in the story 15 | - a `[header]` array, when a single line of doc has been fetched from component `@doc` 16 | - a `[header, body]` array, when `@doc` contains more than a single line of documentation 17 | """ 18 | 19 | attr :story, PhoenixStorybook.Story 20 | attr :fa_plan, :atom 21 | attr :backend_module, :any 22 | 23 | def render_documentation(assigns = %{story: story}) do 24 | strip_doc_attributes? = assigns.backend_module.config(:strip_doc_attributes, true) 25 | 26 | doc = 27 | cond do 28 | story.storybook_type() in [:page, :example] -> 29 | story.doc() |> render_page_doc() |> read_doc() 30 | 31 | story.storybook_type() == :component and not strip_doc_attributes? -> 32 | story.unstripped_doc() |> read_doc() 33 | 34 | true -> 35 | story.doc() |> read_doc() 36 | end 37 | 38 | assigns = assign(assigns, :doc, doc) 39 | 40 | ~H""" 41 |
45 | {raw(@doc.header)} 46 |
47 |
48 | JS.hide() |> JS.show(to: "#read-less")} 50 | id="read-more" 51 | class="psb psb-py-2 psb-inline-block psb-text-slate-400 hover:psb-text-indigo-700 dark:hover:psb-text-sky-400 psb-cursor-pointer" 52 | > 53 | <.fa_icon 54 | name="caret-right" 55 | style={:thin} 56 | plan={@fa_plan} 57 | class="psb-relative psb-top-px psb-mr-1" 58 | /> Read more 59 | 60 | JS.hide() |> JS.show(to: "#read-more")} 62 | id="read-less" 63 | class="psb psb-pt-2 psb-pb-4 psb-hidden psb-inline-block psb-text-slate-400 hover:psb-text-indigo-700 dark:hover:psb-text-sky-400 psb-cursor-pointer" 64 | > 65 | <.fa_icon name="caret-down" style={:thin} plan={@fa_plan} class="psb-mr-1" /> Read less 66 | 67 |
68 |
69 | {raw(@doc.body)} 70 |
71 |
72 |
73 | """ 74 | end 75 | 76 | defp render_page_doc(nil), do: nil 77 | 78 | defp render_page_doc(doc) do 79 | case String.split(doc, "\n\n", parts: 2) do 80 | [header] -> %Doc{header: format(header)} 81 | [header, body] -> %Doc{header: format(header), body: format(body)} 82 | end 83 | end 84 | 85 | defp format(doc) do 86 | doc |> String.trim() |> Earmark.as_html() |> elem(1) 87 | end 88 | 89 | defp read_doc(nil), do: nil 90 | defp read_doc(doc), do: doc 91 | end 92 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/mount.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Mount do 2 | @moduledoc false 3 | 4 | import Phoenix.Component, only: [assign: 2] 5 | 6 | def on_mount(:default, _params, session, socket) do 7 | socket = 8 | assign(socket, 9 | backend_module: Map.fetch!(session, "backend_module"), 10 | root_path: Map.fetch!(session, "root_path"), 11 | assets_path: Map.fetch!(session, "assets_path"), 12 | csp_nonces: Map.fetch!(session, "csp_nonces"), 13 | csrf: Map.fetch!(session, "csrf"), 14 | live_socket_path: Map.fetch!(session, "live_socket_path") 15 | ) 16 | 17 | {:cont, socket} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/rendering/rendering_context.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Rendering.RenderingContext do 2 | @moduledoc """ 3 | A struct holding all data needed by `ComponentRenderer` and `CodeRenderer` to render story 4 | variations. 5 | """ 6 | 7 | alias PhoenixStorybook.Rendering.{RenderingContext, RenderingVariation} 8 | alias PhoenixStorybook.Stories.{Variation, VariationGroup} 9 | alias PhoenixStorybook.TemplateHelpers 10 | 11 | @enforce_keys [:group_id, :dom_id, :type, :story] 12 | defstruct [ 13 | :group_id, 14 | :dom_id, 15 | :type, 16 | :function, 17 | :component, 18 | :story, 19 | :variations, 20 | :template, 21 | :backend_module, 22 | options: [] 23 | ] 24 | 25 | def build(backend_module, story, variation_or_group, extra_attributes, options \\ []) 26 | 27 | def build(backend_module, story, variation = %Variation{}, extra_attributes, options) do 28 | group_id = :single 29 | dom_id = dom_id(story, group_id) 30 | 31 | %RenderingContext{ 32 | group_id: group_id, 33 | dom_id: dom_id, 34 | story: story, 35 | type: story.storybook_type(), 36 | function: function(story.storybook_type(), story), 37 | component: component(story.storybook_type(), story), 38 | variations: [ 39 | %RenderingVariation{ 40 | id: variation.id, 41 | dom_id: variation_dom_id(dom_id, variation.id), 42 | attributes: attributes(variation, group_id, dom_id, extra_attributes), 43 | slots: variation.slots, 44 | let: variation.let 45 | } 46 | ], 47 | template: TemplateHelpers.get_template(story.template(), variation.template), 48 | options: options(story, options), 49 | backend_module: backend_module 50 | } 51 | end 52 | 53 | def build( 54 | backend_module, 55 | story, 56 | group = %VariationGroup{variations: variations}, 57 | extra_attributes, 58 | options 59 | ) do 60 | dom_id = dom_id(story, group.id) 61 | 62 | %RenderingContext{ 63 | group_id: group.id, 64 | dom_id: dom_id, 65 | story: story, 66 | type: story.storybook_type(), 67 | function: function(story.storybook_type(), story), 68 | component: component(story.storybook_type(), story), 69 | variations: 70 | for variation <- variations do 71 | %RenderingVariation{ 72 | id: variation.id, 73 | dom_id: variation_dom_id(dom_id, variation.id), 74 | attributes: attributes(variation, group.id, dom_id, extra_attributes), 75 | slots: variation.slots, 76 | let: variation.let 77 | } 78 | end, 79 | template: TemplateHelpers.get_template(story.template(), group.template), 80 | options: options(story, options), 81 | backend_module: backend_module 82 | } 83 | end 84 | 85 | defp component(:component, _story), do: nil 86 | defp component(:live_component, story), do: story.component() 87 | 88 | defp function(:component, story), do: story.function() 89 | defp function(:live_component, _story), do: nil 90 | 91 | defp dom_id(story, group_id) do 92 | story_module_name = story |> to_string() |> String.split(".") |> Enum.at(-1) 93 | "#{story_module_name}-#{group_id}" |> Macro.underscore() |> String.replace("_", "-") 94 | end 95 | 96 | defp variation_dom_id(dom_id, variation_id) do 97 | "#{dom_id}-#{variation_id}" |> Macro.underscore() |> String.replace("_", "-") 98 | end 99 | 100 | defp attributes(variation, group_id, dom_id, extra_attributes) do 101 | extra_attributes = 102 | Map.get_lazy(extra_attributes, variation.id, fn -> 103 | Map.get(extra_attributes, {group_id, variation.id}, %{}) 104 | end) 105 | 106 | variation.attributes 107 | |> Map.put(:id, variation_dom_id(dom_id, variation.id)) 108 | |> Map.merge(extra_attributes) 109 | end 110 | 111 | defp options(story, options) do 112 | Keyword.merge(options, [imports: story.imports(), aliases: story.aliases()], fn 113 | _key, v1, nil -> v1 114 | _key, nil, v2 -> v2 115 | _key, v1, v2 -> v1 ++ v2 116 | end) 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/rendering/rendering_variation.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Rendering.RenderingVariation do 2 | @moduledoc false 3 | 4 | @enforce_keys [:id, :dom_id] 5 | defstruct [:id, :dom_id, attributes: [], slots: [], let: nil] 6 | end 7 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/stories/attr.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Stories.Attr do 2 | @moduledoc """ 3 | An attr is one of your component attributes. Its structure mimics the LiveView 0.18.0 declarative 4 | assigns. 5 | 6 | Attributes declaration will populate the Playground tab of your storybook, for each of your 7 | components. 8 | """ 9 | 10 | alias PhoenixStorybook.Stories.Attr 11 | 12 | require Logger 13 | 14 | @typedoc """ 15 | - `id`: the attribute id (required). Should match your component assign. 16 | - `type`: the attribute type (required). Must be one of: 17 | * `:any` - any term 18 | * `:string` - any binary string 19 | * `:atom` - any atom 20 | * `:boolean` - any boolean 21 | * `:integer` - any integer 22 | * `:float` - any float 23 | * `:map` - any map 24 | * `:list` - a List of any arbitrary types 25 | * `:global`- any common HTML attributes, 26 | * Any struct module 27 | - `required`: `true` if the attribute is mandatory. 28 | - `default`: attribute default value. 29 | - `examples` the list or range of examples suggested for the attribute 30 | - `values` the list or range of all possible examples for the attribute. Unlike examples, this 31 | option enforces validation of the default value against the given list. 32 | - `doc`: a text documentation for this attribute. 33 | """ 34 | @type t :: %__MODULE__{ 35 | id: atom, 36 | type: 37 | :any 38 | | :string 39 | | :atom 40 | | :boolean 41 | | :integer 42 | | :float 43 | | :map 44 | | :list 45 | | :global 46 | | module, 47 | required: boolean, 48 | default: any, 49 | examples: [any] | nil, 50 | values: [any] | nil, 51 | doc: String.t() | nil 52 | } 53 | 54 | @enforce_keys [:id, :type] 55 | defstruct [:id, :type, :doc, :default, :examples, :values, required: false] 56 | 57 | @doc false 58 | def merge_attributes(mod_or_fun, story_attrs) do 59 | component_attrs = read_attributes(mod_or_fun) 60 | component_attrs_map = mod_or_fun |> read_attributes() |> attributes_map(:name) 61 | story_attrs_map = attributes_map(story_attrs, :id) 62 | attr_keys = Enum.uniq(Enum.map(component_attrs, & &1.name) ++ Enum.map(story_attrs, & &1.id)) 63 | 64 | for attr_id <- attr_keys do 65 | component_attr = Map.get(component_attrs_map, attr_id) 66 | story_attr = Map.get(story_attrs_map, attr_id) 67 | build_attr(component_attr, story_attr) 68 | end 69 | end 70 | 71 | defp read_attributes(fun_or_mod) 72 | 73 | defp read_attributes(module) when is_atom(module) do 74 | attrs = get_in(module.__components__(), [:render, :attrs]) || [] 75 | Enum.sort_by(attrs, & &1.line) 76 | rescue 77 | _ -> 78 | Logger.warning("cannot load attributes for component #{inspect(module)}") 79 | [] 80 | end 81 | 82 | defp read_attributes(function) when is_function(function) do 83 | [module: module, name: name] = function |> Function.info() |> Keyword.take([:module, :name]) 84 | attrs = get_in(module.__components__(), [name, :attrs]) || [] 85 | Enum.sort_by(attrs, & &1.line) 86 | rescue 87 | _ -> 88 | Logger.warning("cannot load attributes for component #{inspect(function)}") 89 | [] 90 | end 91 | 92 | defp attributes_map(attrs, key) do 93 | for attr <- attrs, into: %{}, do: {Map.get(attr, key), attr} 94 | end 95 | 96 | defp build_attr(nil, story_attribute = %Attr{}), do: story_attribute 97 | 98 | defp build_attr(attr, nil) do 99 | %Attr{ 100 | id: attr.name, 101 | type: attr.type, 102 | required: attr[:required], 103 | default: get_in(attr, [:opts, :default]), 104 | values: get_in(attr, [:opts, :values]), 105 | examples: get_in(attr, [:opts, :examples]), 106 | doc: attr.doc 107 | } 108 | end 109 | 110 | defp build_attr(attr, story_attribute = %Attr{}) do 111 | %Attr{ 112 | id: attr.name, 113 | type: merge_attr_key(story_attribute, attr, :type, nil), 114 | required: merge_attr_key(story_attribute, attr, :required, false), 115 | default: merge_attr_key(story_attribute, attr, :default, [:opts, :default], nil), 116 | values: merge_attr_key(story_attribute, attr, :values, [:opts, :values], nil), 117 | examples: merge_attr_key(story_attribute, attr, :examples, [:opts, :examples], nil), 118 | doc: merge_attr_key(story_attribute, attr, :doc, nil) 119 | } 120 | end 121 | 122 | defp merge_attr_key(story_attribute = %Attr{}, attr, key, attr_keys \\ nil, default) do 123 | attr_keys = if is_nil(attr_keys), do: [key], else: attr_keys 124 | 125 | case Map.get(story_attribute, key) do 126 | falsy when falsy in [nil, false] -> get_in(attr, attr_keys) || default 127 | val -> val 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/phoenix_storybook/stories/doc.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Stories.Doc do 2 | @moduledoc """ 3 | Functions to fetch component documentation and render it at HTML. 4 | """ 5 | 6 | alias Phoenix.HTML.Safe, as: HTMLSafe 7 | alias PhoenixStorybook.Rendering.CodeRenderer 8 | alias PhoenixStorybook.Stories.Doc 9 | 10 | require Logger 11 | 12 | defstruct [:header, :body] 13 | 14 | @doc """ 15 | Fetch component documentation from component source and format it as HTML. 16 | - For a live_component, fetches @moduledoc content 17 | - For a function component, fetches @doc content of the relevant function 18 | 19 | Output HTML is split in paragraphs and returned as a list of paragraphs. 20 | """ 21 | def fetch_doc_as_html(story, stripped? \\ true) do 22 | case fetch_component_doc(story.storybook_type(), story) do 23 | :error -> 24 | nil 25 | 26 | doc -> 27 | case split_header(doc, stripped?) do 28 | [] -> nil 29 | [header] -> %Doc{header: format(header)} 30 | [header, body] -> %Doc{header: format(header), body: format(body)} 31 | end 32 | end 33 | end 34 | 35 | def fetch_component_doc(:component, module) do 36 | info = Function.info(module.function()) 37 | fetch_function_doc(info[:module], {info[:name], info[:arity]}) 38 | end 39 | 40 | def fetch_component_doc(:live_component, module) do 41 | fetch_module_doc(module.component()) 42 | end 43 | 44 | defp fetch_function_doc(module, {fun, arity}) do 45 | case Code.fetch_docs(module) do 46 | {_, _, _, _, _, _, function_docs} -> 47 | case find_function_doc(function_docs, fun, arity) do 48 | map when is_map(map) -> map |> Map.values() |> Enum.at(0) 49 | _ -> nil 50 | end 51 | 52 | _ -> 53 | Logger.warning("could not fetch function docs from #{inspect(module)}") 54 | :error 55 | end 56 | end 57 | 58 | defp find_function_doc(docs, fun, arity) do 59 | Enum.find_value( 60 | docs, 61 | %{}, 62 | fn 63 | {{:function, item_fun, item_arity}, _, _, doc, _} -> 64 | if fun == item_fun && arity == item_arity, do: doc, else: false 65 | 66 | _ -> 67 | false 68 | end 69 | ) 70 | end 71 | 72 | defp fetch_module_doc(module) do 73 | case Code.fetch_docs(module) do 74 | {_, _, _, _, module_doc, _, _} -> 75 | case module_doc do 76 | map when is_map(map) -> map |> Map.values() |> Enum.at(0) 77 | _ -> nil 78 | end 79 | 80 | _ -> 81 | Logger.warning("could not fetch module doc from #{inspect(module)}") 82 | :error 83 | end 84 | end 85 | 86 | def strip_lv_attributes_doc(doc), 87 | do: (" " <> doc) |> String.split("## Attributes\n\n", trim: true) |> hd() 88 | 89 | def strip_lv_slots_doc(doc), 90 | do: (" " <> doc) |> String.split("## Slots\n\n", trim: true) |> hd() 91 | 92 | defp split_header(nil, _stripped?), do: [] 93 | defp split_header(doc, false), do: String.split(doc, "\n\n", parts: 2, trim: true) 94 | 95 | defp split_header(doc, true) do 96 | doc |> strip_lv_attributes_doc() |> strip_lv_slots_doc() |> split_header(false) 97 | end 98 | 99 | defp format(doc) do 100 | doc |> Earmark.as_html!() |> highlight_code_blocks() 101 | end 102 | 103 | defp highlight_code_blocks(html) do 104 | regex = ~r/
([^<]*)<\/code><\/pre>/
105 |     Regex.replace(regex, html, &highlight_code_block/3)
106 |   end
107 | 
108 |   defp highlight_code_block(_full_match, lang, escaped_code) do
109 |     code = escaped_code |> unescape_html() |> IO.iodata_to_binary()
110 | 
111 |     lang =
112 |       case lang do
113 |         "elixir" -> :elixir
114 |         "heex" -> :heex
115 |         "" -> code |> String.trim_leading() |> guess_lang()
116 |         _ -> :unknown
117 |       end
118 | 
119 |     CodeRenderer.render_code_block(code, lang, trim: false)
120 |     |> HTMLSafe.to_iodata()
121 |   end
122 | 
123 |   defp guess_lang("<" <> _), do: :heex
124 |   defp guess_lang(_code), do: :elixir
125 | 
126 |   entities = [{"&", ?&}, {"<", ?<}, {">", ?>}, {""", ?"}, {"'", ?'}]
127 | 
128 |   for {encoded, decoded} <- entities do
129 |     defp unescape_html(unquote(encoded) <> rest), do: [unquote(decoded) | unescape_html(rest)]
130 |   end
131 | 
132 |   defp unescape_html(<>), do: [c | unescape_html(rest)]
133 |   defp unescape_html(<<>>), do: []
134 | end
135 | 


--------------------------------------------------------------------------------
/lib/phoenix_storybook/stories/index.ex:
--------------------------------------------------------------------------------
 1 | defmodule PhoenixStorybook.Index do
 2 |   @moduledoc """
 3 |   An index is an optional file you can create in every folder of your storybook content tree to
 4 |   improve rendering of the storybook sidebar.
 5 | 
 6 |   The index files can be used:
 7 |     - to customize the folder itself: name, icon and opening status.
 8 |     - to customize folder direct children (only stories): name and icon.
 9 | 
10 |   Indexes must be created as `index.exs` files.
11 | 
12 |   Read the [icons](guides/icons.md) guide for more information on custom icon usage.
13 | 
14 |   ## Usage
15 | 
16 |   ```elixir
17 |   # storybook/_components.index.exs
18 |   defmodule MyAppWeb.Storybook.Components do
19 |     use PhoenixStorybook.Index
20 | 
21 |     def folder_name, do: "My Components"
22 |     def folder_icon, do: {:fa, "icon"}
23 |     def folder_open?, do: true
24 | 
25 |     def entry("a_component"), do: [name: "My Component"]
26 |     def entry("other_component"), do: [name: "Another Component", icon: {:fa, "icon", :thin}]
27 |   end
28 |   ```
29 |   """
30 | 
31 |   defmodule IndexBehaviour do
32 |     @moduledoc false
33 |     alias PhoenixStorybook.Components.Icon
34 | 
35 |     @callback folder_name() :: nil | String.t()
36 |     @callback folder_icon() :: nil | Icon.t()
37 |     @callback folder_open?() :: boolean()
38 |     @callback entry(String.t()) :: keyword(String.t() | Icon.t())
39 |   end
40 | 
41 |   @doc """
42 |   Convenience helper for using the functions above.
43 |   """
44 |   defmacro __using__(_) do
45 |     quote do
46 |       @behaviour IndexBehaviour
47 | 
48 |       @on_definition {PhoenixStorybook.Stories.IndexValidator, :on_definition}
49 | 
50 |       @impl IndexBehaviour
51 |       def folder_name, do: nil
52 | 
53 |       @impl IndexBehaviour
54 |       def folder_icon, do: nil
55 | 
56 |       @impl IndexBehaviour
57 |       def folder_open?, do: false
58 | 
59 |       @impl IndexBehaviour
60 |       def entry(_), do: []
61 | 
62 |       defoverridable folder_name: 0, folder_icon: 0, folder_open?: 0, entry: 1
63 |     end
64 |   end
65 | end
66 | 


--------------------------------------------------------------------------------
/lib/phoenix_storybook/stories/index_validator.ex:
--------------------------------------------------------------------------------
 1 | defmodule PhoenixStorybook.Stories.IndexValidator do
 2 |   @moduledoc false
 3 | 
 4 |   import PhoenixStorybook.ValidationHelpers
 5 | 
 6 |   def on_definition(env, :def, :folder_name, [], _guards, body) do
 7 |     {[do: term], _} = Code.eval_quoted(body, [], env)
 8 |     validate_type!(env.file, term, :string, "folder_name must return a binary")
 9 |   end
10 | 
11 |   def on_definition(env, :def, :folder_icon, [], _guards, body) do
12 |     {[do: term], _} = Code.eval_quoted(body, [], env)
13 |     validate_icon!(env.file, term, "folder_icon is invalid: ")
14 |   end
15 | 
16 |   def on_definition(env, :def, :entry, [entry_name], _guards, body) do
17 |     {[do: term], _} = Code.eval_quoted(body, [], env)
18 |     msg = "entry(#{inspect(entry_name)}) must return a keyword list with keys :icon and :name"
19 |     validate_type!(env.file, term, :list, msg)
20 | 
21 |     Enum.each(term, fn
22 |       {:name, name} ->
23 |         validate_type!(env.file, name, :string, "entry(#{inspect(entry_name)}) must be a string")
24 | 
25 |       {:icon, icon} ->
26 |         validate_icon!(env.file, icon, "entry(#{inspect(entry_name)}) icon is invalid: ")
27 | 
28 |       _ ->
29 |         compile_error!(env.file, msg)
30 |     end)
31 |   end
32 | 
33 |   def on_definition(_env, _kind, _name, _args, _guards, _body), do: :ok
34 | end
35 | 


--------------------------------------------------------------------------------
/lib/phoenix_storybook/stories/slot.ex:
--------------------------------------------------------------------------------
  1 | defmodule PhoenixStorybook.Stories.Slot do
  2 |   @moduledoc """
  3 |   A slot is one of your component slots. Its structure mimics the LiveView 0.18.0 declarative
  4 |   assigns.
  5 | 
  6 |   Slots declaration will populate the Playground tab of your storybook, for each of your
  7 |   components.
  8 | 
  9 |   Supported keys:
 10 |   - `id`: the slot id (required). Should match your component slot name.
 11 |     Use the id `:inner_block` for your component default slot.
 12 |   - `doc`: a text documentation for this slot.
 13 |   - `required`: `true` if the attribute is mandatory.
 14 |   """
 15 | 
 16 |   alias PhoenixStorybook.Stories.{Attr, Slot}
 17 |   require Logger
 18 | 
 19 |   @type t :: %__MODULE__{
 20 |           id: atom(),
 21 |           doc: String.t(),
 22 |           attrs: [Attr.t()],
 23 |           required: boolean
 24 |         }
 25 | 
 26 |   @enforce_keys [:id]
 27 |   defstruct [:id, :doc, attrs: [], required: false]
 28 | 
 29 |   @doc false
 30 |   def merge_slots(mod_or_fun, story_slots) do
 31 |     component_slots = read_slots(mod_or_fun)
 32 |     component_slots_map = slots_map(component_slots, :name)
 33 |     story_slots_map = slots_map(story_slots, :id)
 34 |     slot_keys = Enum.uniq(Enum.map(component_slots, & &1.name) ++ Enum.map(story_slots, & &1.id))
 35 | 
 36 |     for slot_id <- slot_keys do
 37 |       component_slot = Map.get(component_slots_map, slot_id)
 38 |       story_slot = Map.get(story_slots_map, slot_id)
 39 |       build_slot(component_slot, story_slot)
 40 |     end
 41 |   end
 42 | 
 43 |   defp read_slots(fun_or_mod)
 44 | 
 45 |   defp read_slots(module) when is_atom(module) do
 46 |     slots = get_in(module.__components__(), [:render, :slots]) || []
 47 |     Enum.sort_by(slots, & &1.line)
 48 |   rescue
 49 |     _ ->
 50 |       Logger.warning("cannot load slots for component #{inspect(module)}")
 51 |       []
 52 |   end
 53 | 
 54 |   defp read_slots(function) when is_function(function) do
 55 |     [module: module, name: name] =
 56 |       function |> Function.info() |> Keyword.take([:module, :name])
 57 | 
 58 |     slots = get_in(module.__components__(), [name, :slots]) || []
 59 |     Enum.sort_by(slots, & &1.line)
 60 |   rescue
 61 |     _ ->
 62 |       Logger.warning("cannot load slots for component #{inspect(function)}")
 63 |       []
 64 |   end
 65 | 
 66 |   defp slots_map(slots, key) do
 67 |     for slot <- slots, into: %{}, do: {Map.get(slot, key), slot}
 68 |   end
 69 | 
 70 |   defp build_slot(nil, story_slot = %Slot{}), do: story_slot
 71 | 
 72 |   defp build_slot(slot, nil) do
 73 |     %Slot{
 74 |       id: slot.name,
 75 |       required: slot[:required],
 76 |       doc: slot.doc,
 77 |       attrs: Enum.map(slot.attrs, &build_attr/1)
 78 |     }
 79 |   end
 80 | 
 81 |   defp build_slot(slot, story_slot = %Slot{}) do
 82 |     %Slot{
 83 |       id: slot.name,
 84 |       required: merge_slot_key(story_slot, slot, :required, false),
 85 |       doc: merge_slot_key(story_slot, slot, :doc, nil)
 86 |     }
 87 |   end
 88 | 
 89 |   defp merge_slot_key(story_slot = %Slot{}, slot, key, default) do
 90 |     case Map.get(story_slot, key) do
 91 |       falsy when falsy in [nil, false] -> get_in(slot, [key]) || default
 92 |       val -> val
 93 |     end
 94 |   end
 95 | 
 96 |   defp build_attr(attr) do
 97 |     %Attr{
 98 |       id: attr.name,
 99 |       type: attr.type,
100 |       required: attr[:required],
101 |       values: get_in(attr, [:opts, :values]),
102 |       examples: get_in(attr, [:opts, :examples]),
103 |       doc: attr.doc
104 |     }
105 |   end
106 | end
107 | 


--------------------------------------------------------------------------------
/lib/phoenix_storybook/stories/variation.ex:
--------------------------------------------------------------------------------
 1 | defmodule PhoenixStorybook.Stories.Variation do
 2 |   @moduledoc """
 3 |   A variation captures the rendered state of a UI component. Developers write multiple variations
 4 |   per component that describe all the “interesting” states a component can support.
 5 | 
 6 |   Each variation will be displayed in the storybook as a code snippet alongside with the
 7 |   component preview.
 8 | 
 9 |   Variations attributes type are checked against their matching attribute (if any) and will raise
10 |   a compilation an error in case of mismatch.
11 | 
12 |   Advanced component & variation documentation is available in the
13 |   [components guide](guides/components.md).
14 | 
15 |   ## Usage
16 |   ```elixir
17 |     def variations do
18 |       [
19 |         %Variation{
20 |           id: :default,
21 |           description: "Default dropdown",
22 |           attributes: %{
23 |             label: "A dropdown",
24 |           },
25 |           slots: [
26 |             ~s|<:entry path="#" label="Account settings"/>|,
27 |             ~s|<:entry path="#" label="Support"/>|,
28 |             ~s|<:entry path="#" label="License"/>|
29 |           ]
30 |         }
31 |       ]
32 |     end
33 |   ```
34 |   """
35 | 
36 |   @type t :: %__MODULE__{
37 |           id: atom,
38 |           description: String.t() | nil,
39 |           let: atom | nil,
40 |           slots: [String.t()],
41 |           attributes: map,
42 |           template: :unset | String.t() | nil | false
43 |         }
44 | 
45 |   @enforce_keys [:id]
46 |   defstruct [:id, :description, :let, slots: [], attributes: %{}, template: :unset]
47 | end
48 | 
49 | defmodule PhoenixStorybook.Stories.VariationGroup do
50 |   @moduledoc """
51 |   A variation group is a set of similar variations that will be rendered together in a single
52 |   preview 
 block.
53 | 
54 |   ## Usage
55 |   ```elixir
56 |     def variations do
57 |       [
58 |         %VariationGroup{
59 |           id: :colors,
60 |           description: "Different color buttons",
61 |           variations: [
62 |             %Variation{
63 |               id: :blue_button,
64 |               attributes: %{label: "A button", color: :blue }
65 |             },
66 |             %Variation{
67 |               id: :red_button,
68 |               attributes: %{label: "A button", color: :red }
69 |             },
70 |             %Variation{
71 |               id: :green_button,
72 |               attributes: %{label: "A button", color: :green }
73 |             }
74 |           ]
75 |         }
76 |       ]
77 |     end
78 |   ```
79 |   """
80 | 
81 |   alias PhoenixStorybook.Stories.Variation
82 | 
83 |   @type t :: %__MODULE__{
84 |           id: atom,
85 |           description: String.t() | nil,
86 |           variations: [Variation.t()],
87 |           template: :unset | String.t() | nil | false
88 |         }
89 | 
90 |   @enforce_keys [:id, :variations]
91 |   defstruct [:id, :description, :variations, template: :unset]
92 | end
93 | 


--------------------------------------------------------------------------------
/lib/phoenix_storybook/templates/layout/_favicon.html.heex:
--------------------------------------------------------------------------------
 1 | 
 4 | 
10 | 
17 | 
24 | 
25 | 
31 | 
32 | 
33 | 


--------------------------------------------------------------------------------
/lib/phoenix_storybook/templates/layout/live_iframe.html.heex:
--------------------------------------------------------------------------------
1 | {@inner_content}
2 | 


--------------------------------------------------------------------------------
/lib/phoenix_storybook/templates/layout/root.html.heex:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 8 |   
 9 |     
10 |     
11 |     
15 |     <%= if csrf?(@conn) do %>
16 |       {csrf_meta_tag()}
17 |     <% end %>
18 | 
19 |     {render("_favicon.html", conn: @conn)}
20 |     <.live_title prefix={title_prefix(@conn)}>
21 |       {assigns[:page_title]}
22 |     
23 | 
24 |     <%= if fa_kit_id = fa_kit_id(@conn) do %>
25 |       
30 |     <% else %>
31 |       
36 |     <% end %>
37 |     <%= if path = storybook_js_path(@conn) do %>
38 |       
46 |     <% end %>
47 |     
54 | 
55 |     
60 |     <%= if path = storybook_css_path(@conn) do %>
61 |       
67 |     <% end %>
68 |     
71 |   
72 | 
73 |   
74 |     {@inner_content}
75 |   
76 | 
77 | 


--------------------------------------------------------------------------------
/lib/phoenix_storybook/templates/layout/root_iframe.html.heex:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |     <%= if csrf?(@conn) do %>
 5 |       {csrf_meta_tag()}
 6 |     <% end %>
 7 |     <%= if path = storybook_js_path(@conn) do %>
 8 |       
15 |     <% end %>
16 |     
22 | 
23 |     <%= if fa_kit_id = fa_kit_id(@conn) do %>
24 |       
29 |     <% else %>
30 |       
35 |     <% end %>
36 | 
37 |     
42 |     <%= if path = storybook_css_path(@conn) do %>
43 |       
49 |     <% end %>
50 |   
51 | 
52 |   <% container =
53 |     if assigns[:story],
54 |       do: normalize_story_container(assigns[:story].container()),
55 |       else: {:div, []} %>
56 |   
57 |     {@inner_content}
58 |   
59 | 
60 | 


--------------------------------------------------------------------------------
/lib/phoenix_storybook/web.ex:
--------------------------------------------------------------------------------
 1 | defmodule PhoenixStorybook.Web do
 2 |   @moduledoc false
 3 | 
 4 |   @doc false
 5 |   def controller do
 6 |     quote do
 7 |       @moduledoc false
 8 | 
 9 |       use Phoenix.Controller, namespace: PhoenixStorybook
10 |       import Plug.Conn
11 |       unquote(view_helpers())
12 |     end
13 |   end
14 | 
15 |   @doc false
16 |   def view do
17 |     quote do
18 |       @moduledoc false
19 | 
20 |       use Phoenix.View,
21 |         namespace: PhoenixStorybook,
22 |         root: "lib/phoenix_storybook/templates"
23 | 
24 |       import PhoenixStorybook.Components.Icon
25 | 
26 |       unquote(view_helpers())
27 |     end
28 |   end
29 | 
30 |   @doc false
31 |   def live_view do
32 |     quote do
33 |       @moduledoc false
34 |       use Phoenix.LiveView,
35 |         layout: {PhoenixStorybook.LayoutView, :live}
36 | 
37 |       import PhoenixStorybook.Components.Icon
38 | 
39 |       unquote(view_helpers())
40 |     end
41 |   end
42 | 
43 |   @doc false
44 |   def component do
45 |     quote do
46 |       @moduledoc false
47 |       use Phoenix.Component
48 |       alias Phoenix.LiveView.JS
49 |       unquote(view_helpers())
50 |     end
51 |   end
52 | 
53 |   @doc false
54 |   def live_component do
55 |     quote do
56 |       @moduledoc false
57 |       use Phoenix.LiveComponent
58 |       import PhoenixStorybook.Components.Icon
59 |       unquote(view_helpers())
60 |     end
61 |   end
62 | 
63 |   defp view_helpers do
64 |     quote do
65 |       # Use all HTML functionality (forms, tags, etc)
66 |       import Phoenix.HTML
67 |       import Phoenix.HTML.Form
68 |       use PhoenixHTMLHelpers
69 | 
70 |       # Import convenience functions for LiveView rendering
71 |       import Phoenix.Component
72 | 
73 |       alias PhoenixStorybook.Router.Helpers, as: Routes
74 |     end
75 |   end
76 | 
77 |   @doc """
78 |   Convenience helper for using the functions above.
79 |   """
80 |   defmacro __using__(which) when is_atom(which) do
81 |     apply(__MODULE__, which, [])
82 |   end
83 | end
84 | 


--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
  1 | defmodule PhoenixStorybook.MixProject do
  2 |   use Mix.Project
  3 | 
  4 |   @version "0.8.2"
  5 | 
  6 |   def project do
  7 |     [
  8 |       app: :phoenix_storybook,
  9 |       version: @version,
 10 |       elixir: "~> 1.13",
 11 |       start_permanent: Mix.env() == :prod,
 12 |       elixirc_paths: elixirc_paths(Mix.env()),
 13 |       name: "phoenix_storybook",
 14 |       description: "A pluggable storybook for your Phoenix components.",
 15 |       source_url: "https://github.com/phenixdigital/phoenix_storybook",
 16 |       aliases: aliases(),
 17 |       deps: deps(),
 18 |       package: package(),
 19 |       docs: docs(),
 20 |       test_coverage: [tool: ExCoveralls, export: "excoveralls"],
 21 |       preferred_cli_env: [
 22 |         coveralls: :test,
 23 |         "coveralls.lcov": :test,
 24 |         coverage: :test
 25 |       ],
 26 |       dialyzer: [
 27 |         plt_add_apps: [:mix],
 28 |         plt_local_path: ".plts",
 29 |         plt_core_path: ".plts",
 30 |         plt_file: {:no_warn, ".plts/storybook.plt"}
 31 |       ],
 32 |       prune_code_paths: false
 33 |     ]
 34 |   end
 35 | 
 36 |   # Run "mix help compile.app" to learn about applications.
 37 |   def application do
 38 |     [
 39 |       mod: {PhoenixStorybook.Application, []},
 40 |       extra_applications: [:logger]
 41 |     ]
 42 |   end
 43 | 
 44 |   defp elixirc_paths(:test), do: ["lib", "test/fixtures"]
 45 |   defp elixirc_paths(_), do: ["lib"]
 46 | 
 47 |   # Run "mix help deps" to learn about dependencies.
 48 |   defp deps do
 49 |     [
 50 |       {:phoenix, "~> 1.7.0"},
 51 |       {:phoenix_live_view, "~> 1.0"},
 52 |       {:phoenix_html_helpers, "~> 1.0"},
 53 |       {:phoenix_view, "~> 2.0"},
 54 |       {:makeup_eex, "~> 2.0.2"},
 55 |       {:makeup_html, "~> 0.2.0"},
 56 |       {:heroicons, "~> 0.5", only: [:test]},
 57 |       {:jason, "~> 1.3", optional: true},
 58 |       {:earmark, "~> 1.4"},
 59 |       {:credo, "~> 1.6", only: [:dev, :test], runtime: false},
 60 |       {:ex_doc, "~> 0.30", only: :dev, runtime: false},
 61 |       {:excoveralls, "~> 0.10", only: :test},
 62 |       {:floki, "~> 0.37.0", only: :test},
 63 |       {:mox, "~> 1.0", only: :test},
 64 |       {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}
 65 |     ]
 66 |   end
 67 | 
 68 |   defp docs do
 69 |     [
 70 |       main: "PhoenixStorybook",
 71 |       source_ref: "v#{@version}",
 72 |       source_url: "https://github.com/phenixdigital/phoenix_storybook",
 73 |       extra_section: "GUIDES",
 74 |       extras: extras(),
 75 |       nest_modules_by_prefix: [PhoenixStorybook]
 76 |     ]
 77 |   end
 78 | 
 79 |   defp extras do
 80 |     [
 81 |       "guides/color_modes.md",
 82 |       "guides/components.md",
 83 |       "guides/icons.md",
 84 |       "guides/sandboxing.md",
 85 |       "guides/setup.md",
 86 |       "guides/testing.md",
 87 |       "guides/theming.md"
 88 |     ]
 89 |   end
 90 | 
 91 |   defp package do
 92 |     [
 93 |       maintainers: ["Christian Blavier"],
 94 |       files:
 95 |         ~w(mix.exs priv lib guides README.md LICENSE.md CHANGELOG.md CONTRIBUTING.md .formatter.exs),
 96 |       licenses: ["MIT"],
 97 |       links: %{"GitHub" => "https://github.com/phenixdigital/phoenix_storybook"}
 98 |     ]
 99 |   end
100 | 
101 |   defp aliases do
102 |     [
103 |       coverage: "coveralls.lcov",
104 |       "assets.watch": "cmd npm run watch --prefix assets",
105 |       "assets.build": [
106 |         "cmd npm run build --prefix assets",
107 |         "phx.digest",
108 |         "phx.digest.clean"
109 |       ],
110 |       publish: [
111 |         "assets.build",
112 |         "hex.publish"
113 |       ]
114 |     ]
115 |   end
116 | end
117 | 


--------------------------------------------------------------------------------
/priv/static/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/favicon/android-chrome-192x192.png


--------------------------------------------------------------------------------
/priv/static/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/favicon/android-chrome-512x512.png


--------------------------------------------------------------------------------
/priv/static/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/favicon/apple-touch-icon.png


--------------------------------------------------------------------------------
/priv/static/favicon/browserconfig.xml:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |     
 4 |         
 5 |             
 6 |             #da532c
 7 |         
 8 |     
 9 | 
10 | 


--------------------------------------------------------------------------------
/priv/static/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/favicon/favicon-16x16.png


--------------------------------------------------------------------------------
/priv/static/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/favicon/favicon-32x32.png


--------------------------------------------------------------------------------
/priv/static/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/favicon/favicon.ico


--------------------------------------------------------------------------------
/priv/static/favicon/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/favicon/mstile-150x150.png


--------------------------------------------------------------------------------
/priv/static/favicon/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 4 | 
 7 | 
 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017
 9 | 
10 | 
12 | 
25 | 
36 | 
37 | 
38 | 


--------------------------------------------------------------------------------
/priv/static/favicon/site.webmanifest:
--------------------------------------------------------------------------------
 1 | {
 2 |     "name": "",
 3 |     "short_name": "",
 4 |     "icons": [
 5 |         {
 6 |             "src": "/android-chrome-192x192.png",
 7 |             "sizes": "192x192",
 8 |             "type": "image/png"
 9 |         },
10 |         {
11 |             "src": "/android-chrome-512x512.png",
12 |             "sizes": "512x512",
13 |             "type": "image/png"
14 |         }
15 |     ],
16 |     "theme_color": "#ffffff",
17 |     "background_color": "#ffffff",
18 |     "display": "standalone"
19 | }
20 | 


--------------------------------------------------------------------------------
/priv/static/fonts/fa-brands-400.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/fonts/fa-brands-400.ttf


--------------------------------------------------------------------------------
/priv/static/fonts/fa-brands-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/fonts/fa-brands-400.woff2


--------------------------------------------------------------------------------
/priv/static/fonts/fa-solid-900.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/fonts/fa-solid-900.ttf


--------------------------------------------------------------------------------
/priv/static/fonts/fa-solid-900.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/fonts/fa-solid-900.woff2


--------------------------------------------------------------------------------
/priv/static/images/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/priv/static/images/background.png


--------------------------------------------------------------------------------
/priv/templates/phx.gen.storybook/_root.index.exs:
--------------------------------------------------------------------------------
 1 | defmodule Storybook.Root do
 2 |   # See https://hexdocs.pm/phoenix_storybook/PhoenixStorybook.Index.html for full index
 3 |   # documentation.
 4 | 
 5 |   use PhoenixStorybook.Index
 6 | 
 7 |   def folder_icon, do: {:fa, "book-open", :light, "psb-mr-1"}
 8 |   def folder_name, do: "Storybook"
 9 | 
10 |   def entry("welcome") do
11 |     [
12 |       name: "Welcome Page",
13 |       icon: {:fa, "hand-wave", :thin}
14 |     ]
15 |   end
16 | end
17 | 


--------------------------------------------------------------------------------
/priv/templates/phx.gen.storybook/core_components/_core_components.index.exs.eex:
--------------------------------------------------------------------------------
 1 | defmodule Storybook.CoreComponents do
 2 |   use PhoenixStorybook.Index
 3 | 
 4 |   def folder_open?, do: true
 5 | 
 6 |   def entry("back"), do: [icon: {:fa, "circle-left", :thin}]
 7 |   def entry("button"), do: [icon: {:fa, "rectangle-ad", :thin}]
 8 |   def entry("error"), do: [icon: {:fa, "circle-exclamation", :thin}]
 9 |   def entry("flash"), do: [icon: {:fa, "bolt", :thin}]
10 |   def entry("header"), do: [icon: {:fa, "heading", :thin}]
11 |   def entry("icon"), do: [icon: {:fa, "icons", :thin}]
12 |   def entry("input"), do: [icon: {:fa, "input-text", :thin}]
13 |   def entry("list"), do: [icon: {:fa, "list", :thin}]
14 |   def entry("table"), do: [icon: {:fa, "table", :thin}]
15 | end
16 | 


--------------------------------------------------------------------------------
/priv/templates/phx.gen.storybook/core_components/back.story.exs.eex:
--------------------------------------------------------------------------------
 1 | defmodule Storybook.Components.CoreComponents.Back do
 2 |   use PhoenixStorybook.Story, :component
 3 | 
 4 |   def function, do: &<%= schema.core_components_module_name %>.back/1
 5 |   def render_source, do: :function
 6 | 
 7 |   def template do
 8 |     """
 9 |     
10 | <.psb-variation/> 11 |
12 | """ 13 | end 14 | 15 | def variations do 16 | [ 17 | %Variation{ 18 | id: :default, 19 | attributes: %{ 20 | navigate: "/storybook" 21 | }, 22 | slots: [ 23 | "Back to home page" 24 | ] 25 | } 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/core_components/button.story.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Components.CoreComponents.Button do 2 | use PhoenixStorybook.Story, :component 3 | 4 | def function, do: &<%= schema.core_components_module_name %>.button/1 5 | def render_source, do: :function 6 | 7 | def variations do 8 | [ 9 | %Variation{ 10 | id: :default, 11 | attributes: %{ 12 | type: "button", 13 | class: "bg-emerald-400 hover:bg-emerald-500 text-emerald-800" 14 | }, 15 | slots: [ 16 | "Click me!" 17 | ] 18 | }, 19 | %Variation{ 20 | id: :disabled, 21 | attributes: %{ 22 | type: "button", 23 | disabled: true 24 | }, 25 | slots: [ 26 | "Click me!" 27 | ] 28 | } 29 | ] 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/core_components/error.story.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Components.CoreComponents.Error do 2 | use PhoenixStorybook.Story, :component 3 | 4 | def function, do: &<%= schema.core_components_module_name %>.error/1 5 | def imports, do: [{<%= schema.core_components_module_name %>, button: 1}] 6 | 7 | def render_source, do: :function 8 | 9 | def variations do 10 | [ 11 | %Variation{ 12 | id: :default, 13 | description: "Typical error message", 14 | slots: [ 15 | """ 16 | Obviously, something went wrong ... 17 | """ 18 | ] 19 | }, 20 | %Variation{ 21 | id: :try_again, 22 | slots: [ 23 | """ 24 | Obviously, something went wrong ... 25 |
26 | <.button class="bg-rose-600 hover:bg-rose-700">Try again 27 |
28 | """ 29 | ] 30 | } 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/core_components/flash.story.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Components.CoreComponents.Flash do 2 | use PhoenixStorybook.Story, :component 3 | 4 | def function, do: &<%= schema.core_components_module_name %>.flash/1 5 | def imports, do: [{<%= schema.core_components_module_name %>, show: 1, button: 1}] 6 | def render_source, do: :function 7 | 8 | def template do 9 | """ 10 |
11 | <.button phx-click={show("#:variation_id")}> 12 | Trigger flash 13 | 14 | <.psb-variation/> 15 |
16 | """ 17 | end 18 | 19 | def variations do 20 | [ 21 | %Variation{ 22 | id: :info, 23 | description: "Info message", 24 | attributes: %{ 25 | kind: :info, 26 | hidden: true, 27 | title: "Did you know?" 28 | }, 29 | slots: ["Flash message"] 30 | }, 31 | %Variation{ 32 | id: :error, 33 | description: "Error message", 34 | attributes: %{ 35 | kind: :error, 36 | hidden: true, 37 | title: "Oops!" 38 | }, 39 | slots: ["Sorry, it just crashed"] 40 | } 41 | ] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/core_components/header.story.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Components.CoreComponents.Header do 2 | use PhoenixStorybook.Story, :component 3 | 4 | def function, do: &<%= schema.core_components_module_name %>.header/1 5 | def imports, do: [{<%= schema.core_components_module_name %>, button: 1}] 6 | def render_source, do: :function 7 | 8 | def template do 9 | """ 10 |
11 | <.psb-variation/> 12 |
13 | """ 14 | end 15 | 16 | def variations do 17 | [ 18 | %Variation{ 19 | id: :default, 20 | description: "With a title", 21 | slots: [ 22 | "Section title" 23 | ] 24 | }, 25 | %Variation{ 26 | id: :subtitle, 27 | description: "With a subtitle", 28 | slots: [ 29 | "Section title", 30 | """ 31 | <:subtitle> 32 | Here a subtitle 33 | 34 | """ 35 | ] 36 | }, 37 | %Variation{ 38 | id: :actions, 39 | description: "With a subtitle and actions", 40 | slots: [ 41 | "Section title", 42 | """ 43 | <:subtitle> 44 | Here a subtitle 45 | 46 | """, 47 | """ 48 | <:actions> 49 | <.button>Action! 50 | 51 | """ 52 | ] 53 | } 54 | ] 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/core_components/icon.story.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Components.CoreComponents.Icon do 2 | use PhoenixStorybook.Story, :component 3 | 4 | def function, do: &<%= schema.core_components_module_name %>.icon/1 5 | def render_source, do: :function 6 | 7 | def variations do 8 | [ 9 | %VariationGroup{ 10 | id: :sizes, 11 | variations: [ 12 | %Variation{ 13 | id: :outline, 14 | attributes: %{ 15 | name: "hero-book-open", 16 | class: "dark:text-zinc-300" 17 | } 18 | }, 19 | %Variation{ 20 | id: :solid, 21 | attributes: %{ 22 | name: "hero-book-open-solid", 23 | class: "dark:text-zinc-300" 24 | } 25 | }, 26 | %Variation{ 27 | id: :mini, 28 | attributes: %{ 29 | name: "hero-book-open-mini", 30 | class: "dark:text-zinc-300" 31 | } 32 | }, 33 | %Variation{ 34 | id: :micro, 35 | attributes: %{ 36 | name: "hero-book-open-micro", 37 | class: "dark:text-zinc-300" 38 | } 39 | } 40 | ] 41 | }, 42 | %VariationGroup{ 43 | id: :colors, 44 | variations: [ 45 | %Variation{ 46 | id: :indigo, 47 | attributes: %{ 48 | name: "hero-academic-cap", 49 | class: "text-indigo-400" 50 | } 51 | }, 52 | %Variation{ 53 | id: :pink, 54 | attributes: %{ 55 | name: "hero-academic-cap", 56 | class: "text-pink-400" 57 | } 58 | }, 59 | %Variation{ 60 | id: :teal, 61 | attributes: %{ 62 | name: "hero-academic-cap", 63 | class: "text-teal-400" 64 | } 65 | }, 66 | %Variation{ 67 | id: :amber, 68 | attributes: %{ 69 | name: "hero-academic-cap", 70 | class: "text-amber-400" 71 | } 72 | } 73 | ] 74 | }, 75 | %VariationGroup{ 76 | id: :motion, 77 | template: """ 78 |
79 | <.psb-variation/> 80 |
81 | """, 82 | variations: [ 83 | %Variation{ 84 | id: :spin, 85 | attributes: %{ 86 | name: "hero-arrow-path", 87 | class: "motion-safe:animate-spin dark:text-zinc-300" 88 | } 89 | }, 90 | %Variation{ 91 | id: :bounce, 92 | attributes: %{ 93 | name: "hero-arrow-down-circle", 94 | class: "motion-safe:animate-bounce dark:text-zinc-300" 95 | } 96 | }, 97 | %Variation{ 98 | id: :pulse, 99 | attributes: %{ 100 | name: "hero-information-circle", 101 | class: "motion-safe:animate-pulse dark:text-zinc-300" 102 | } 103 | }, 104 | %Variation{ 105 | id: :ping, 106 | attributes: %{ 107 | name: "hero-arrows-pointing-out", 108 | class: "motion-safe:animate-ping dark:text-zinc-300" 109 | } 110 | } 111 | ] 112 | } 113 | ] 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/core_components/input.story.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Components.CoreComponents.Input do 2 | use PhoenixStorybook.Story, :component 3 | 4 | def function, do: &<%= schema.core_components_module_name %>.input/1 5 | def render_source, do: :function 6 | def layout, do: :one_column 7 | 8 | def template do 9 | """ 10 | <.form for={%{}} class="w-full space-y-6" psb-code-hidden> 11 | <.psb-variation-group /> 12 | 13 | """ 14 | end 15 | 16 | def variations do 17 | [ 18 | %VariationGroup{ 19 | id: :text, 20 | variations: [ 21 | %Variation{ 22 | id: :default, 23 | attributes: %{ 24 | label: "Text input", 25 | name: "default", 26 | value: "some text", 27 | type: "text" 28 | } 29 | }, 30 | %Variation{ 31 | id: :errors, 32 | attributes: %{ 33 | label: "Input with errors", 34 | name: "text_errors", 35 | value: "invalid value", 36 | errors: ["This field is invalid"] 37 | } 38 | } 39 | ] 40 | }, 41 | %Variation{ 42 | id: :select, 43 | attributes: %{ 44 | label: "Select list", 45 | name: "checkbox", 46 | type: "select", 47 | value: "user", 48 | options: [Admin: "admin", User: "user"] 49 | } 50 | }, 51 | %VariationGroup{ 52 | id: :checkbox, 53 | variations: [ 54 | %Variation{ 55 | id: :opt1, 56 | attributes: %{ 57 | label: "Option 1", 58 | name: "checkbox", 59 | type: "checkbox", 60 | checked: true 61 | } 62 | }, 63 | %Variation{ 64 | id: :opt2, 65 | attributes: %{ 66 | label: "Option 2", 67 | name: "checkbox", 68 | type: "checkbox", 69 | checked: false 70 | } 71 | } 72 | ] 73 | }, 74 | %Variation{ 75 | id: :area, 76 | attributes: %{ 77 | label: "Text area", 78 | name: "textarea", 79 | type: "textarea", 80 | value: "user" 81 | } 82 | }, 83 | %VariationGroup{ 84 | id: :type, 85 | description: "Various input types", 86 | variations: 87 | for type <- ~w(number range email password tel search month week date time color file) do 88 | %Variation{ 89 | id: String.to_atom(type), 90 | attributes: %{ 91 | type: type, 92 | name: type, 93 | label: String.capitalize(type), 94 | value: type 95 | } 96 | } 97 | end 98 | } 99 | ] 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/core_components/list.story.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Components.CoreComponents.List do 2 | use PhoenixStorybook.Story, :component 3 | 4 | def function, do: &<%= schema.core_components_module_name %>.list/1 5 | def render_source, do: :function 6 | 7 | def template do 8 | """ 9 |
10 | <.psb-variation/> 11 |
12 | """ 13 | end 14 | 15 | def variations do 16 | [ 17 | %Variation{ 18 | id: :default, 19 | slots: [ 20 | ~s|<:item title="Apples">two|, 21 | ~s|<:item title="Bananas">five|, 22 | ~s|<:item title="Carrots">a lot|, 23 | ~s|<:item title="Potatoes">even more| 24 | ] 25 | } 26 | ] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/core_components/table.story.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Components.CoreComponents.Table do 2 | use PhoenixStorybook.Story, :component 3 | 4 | def function, do: &<%= schema.core_components_module_name %>.table/1 5 | def imports, do: [{<%= schema.core_components_module_name %>, button: 1}] 6 | def render_source, do: :function 7 | def layout, do: :one_column 8 | 9 | def template do 10 | """ 11 |
12 | <.psb-variation/> 13 |
14 | """ 15 | end 16 | 17 | def variations do 18 | [ 19 | %Variation{ 20 | id: :default, 21 | attributes: %{ 22 | rows: table_rows() 23 | }, 24 | slots: table_slots() 25 | }, 26 | %Variation{ 27 | id: :with_function, 28 | description: "Applying functions to row items", 29 | attributes: %{ 30 | rows: table_rows(), 31 | row_id: {:eval, ~S|&"user-#{&1.id}"|}, 32 | row_item: {:eval, ~S"&%{&1 | last_name: String.upcase(&1.last_name)}"} 33 | }, 34 | slots: table_slots() 35 | }, 36 | %Variation{ 37 | id: :with_actions, 38 | description: "With an action slot", 39 | attributes: %{ 40 | rows: table_rows() 41 | }, 42 | slots: [ 43 | """ 44 | <:action> 45 | <.button>Show 46 | 47 | """ 48 | | table_slots() 49 | ] 50 | } 51 | ] 52 | end 53 | 54 | defp table_rows do 55 | [ 56 | %{id: 1, first_name: "Jean", last_name: "Dupont", city: "Paris"}, 57 | %{id: 2, first_name: "Sam", last_name: "Smith", city: "NY"} 58 | ] 59 | end 60 | 61 | defp table_slots do 62 | [ 63 | """ 64 | <:col :let={user} label="ID"> 65 | <%%= user.id %> 66 | 67 | """, 68 | """ 69 | <:col :let={user} label="First name"> 70 | <%%= user.first_name %> 71 | 72 | """, 73 | """ 74 | <:col :let={user} label="Last name"> 75 | <%%= user.last_name %> 76 | 77 | """, 78 | """ 79 | <:col :let={user} label="City"> 80 | <%%= user.city %> 81 | 82 | """ 83 | ] 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/examples/core_components.story.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Examples.CoreComponents do 2 | use PhoenixStorybook.Story, :example 3 | import <%= schema.core_components_module_name %> 4 | 5 | alias Phoenix.LiveView.JS 6 | 7 | def doc do 8 | "An example of what you can achieve with Phoenix core components." 9 | end 10 | 11 | defstruct [:id, :first_name, :last_name] 12 | 13 | @impl true 14 | def mount(_params, _session, socket) do 15 | {:ok, 16 | assign(socket, 17 | current_id: 2, 18 | users: [ 19 | %__MODULE__{id: 1, first_name: "Jose", last_name: "Valim"}, 20 | %__MODULE__{id: 2, first_name: "Chris", last_name: "McCord"} 21 | ] 22 | )} 23 | end 24 | 25 | @impl true 26 | def render(assigns) do 27 | ~H""" 28 | <.table id="user-table" rows={@users}> 29 | <:col :let={user} label="Id"> 30 | {user.id} 31 | 32 | <:col :let={user} label="First name"> 33 | {user.first_name} 34 | 35 | <:col :let={user} label="Last name"> 36 | {user.last_name} 37 | 38 | 39 | <.header class="mt-16"> 40 | Feel free to add any missing user! 41 | <:subtitle>Please fill-in their first and last names 42 | 43 | <.simple_form :let={f} for={%{}} as={:user} phx-submit={JS.push("save_user")}> 44 | <.input field={f[:first_name]} label="First name" /> 45 | <.input field={f[:last_name]} label="Last name" /> 46 | <:actions> 47 | <.button>Save user 48 | 49 | 50 | """ 51 | end 52 | 53 | @impl true 54 | def handle_event("save_user", %{"user" => params}, socket) do 55 | user = %__MODULE__{ 56 | first_name: params["first_name"], 57 | last_name: params["last_name"], 58 | id: socket.assigns.current_id + 1 59 | } 60 | 61 | {:noreply, 62 | socket 63 | |> update(:users, &(&1 ++ [user])) 64 | |> update(:current_id, &(&1 + 1))} 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/storybook.css.eex: -------------------------------------------------------------------------------- 1 | /* 2 | * This is your custom storybook stylesheet. 3 | * Put your component styling under .<%= inspect schema.sandbox_class %> scope. 4 | * See the https://hexdocs.pm/phoenix_storybook/sandboxing.html guide for more info. 5 | */ 6 | 7 | .psb-sandbox, .<%= schema.sandbox_class %> { 8 | font-family: system-ui; 9 | } 10 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/storybook.ex.eex: -------------------------------------------------------------------------------- 1 | defmodule <%= inspect schema.web_module %>.Storybook do 2 | use PhoenixStorybook, 3 | otp_app: <%= inspect schema.app_name %>, 4 | content_path: Path.expand("../../storybook", __DIR__), 5 | # assets path are remote path, not local file-system paths 6 | css_path: "/assets/storybook.css", 7 | js_path: "/assets/storybook.js", 8 | sandbox_class: <%= inspect schema.sandbox_class %> 9 | end 10 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/storybook.js: -------------------------------------------------------------------------------- 1 | // If your components require any hooks or custom uploaders, or if your pages 2 | // require connect parameters, uncomment the following lines and declare them as 3 | // such: 4 | // 5 | // import * as Hooks from "./hooks"; 6 | // import * as Params from "./params"; 7 | // import * as Uploaders from "./uploaders"; 8 | 9 | // (function () { 10 | // window.storybook = { Hooks, Params, Uploaders }; 11 | // })(); 12 | 13 | 14 | // If your components require alpinejs, you'll need to start 15 | // alpine after the DOM is loaded and pass in an onBeforeElUpdated 16 | // 17 | // import Alpine from 'alpinejs' 18 | // window.Alpine = Alpine 19 | // document.addEventListener('DOMContentLoaded', () => { 20 | // window.Alpine.start(); 21 | // }); 22 | 23 | // (function () { 24 | // window.storybook = { 25 | // LiveSocketOptions: { 26 | // dom: { 27 | // onBeforeElUpdated(from, to) { 28 | // if (from._x_dataStack) { 29 | // window.Alpine.clone(from, to) 30 | // } 31 | // } 32 | // } 33 | // } 34 | // }; 35 | // })(); 36 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/storybook.tailwind.css: -------------------------------------------------------------------------------- 1 | /* This is your custom storybook stylesheet. */ 2 | @import "tailwindcss/base"; 3 | @import "tailwindcss/components"; 4 | @import "tailwindcss/utilities"; 5 | 6 | /* 7 | * Put your component styling within the Tailwind utilities layer. 8 | * See the https://hexdocs.pm/phoenix_storybook/sandboxing.html guide for more info. 9 | */ 10 | 11 | @layer utilities { 12 | * { 13 | font-family: system-ui; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /priv/templates/phx.gen.storybook/welcome.story.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.MyPage do 2 | # See https://hexdocs.pm/phoenix_storybook/PhoenixStorybook.Story.html for full story 3 | # documentation. 4 | use PhoenixStorybook.Story, :page 5 | 6 | def doc, do: "Your very first steps into using Phoenix Storybook" 7 | 8 | # Declare an optional tab-based navigation in your page: 9 | def navigation do 10 | [ 11 | {:welcome, "Welcome", {:fa, "hand-wave", :thin}}, 12 | {:components, "Components", {:fa, "toolbox", :thin}}, 13 | {:sandboxing, "Sandboxing", {:fa, "box-check", :thin}}, 14 | {:icons, "Icons", {:fa, "icons", :thin}} 15 | ] 16 | end 17 | 18 | # This is a dummy function that you should replace with your own HEEx content. 19 | def render(assigns = %{tab: :welcome}) do 20 | ~H""" 21 |
22 |

23 | We generated your storybook with an example of a page and a component. 24 | Explore the generated *.story.exs 25 | files in your /storybook 26 | directory. When you're ready to add your own, just drop your new story & index files into the same directory and refresh your storybook. 27 |

28 | 29 |

30 | Here are a few docs you might be interested in: 31 |

32 | 33 | <.description_list items={[ 34 | {"Create a new Story", doc_link("Story")}, 35 | {"Display components using Variations", doc_link("Stories.Variation")}, 36 | {"Group components using VariationGroups", doc_link("Stories.VariationGroup")}, 37 | {"Organize the sidebar with Index files", doc_link("Index")} 38 | ]} /> 39 | 40 |

41 | This should be enough to get you started, but you can use the tabs in the upper-right corner of this page to check out advanced usage guides. 42 |

43 |
44 | """ 45 | end 46 | 47 | def render(assigns = %{tab: guide}) when guide in ~w(components sandboxing icons)a do 48 | assigns = 49 | assign(assigns, 50 | guide: guide, 51 | guide_content: PhoenixStorybook.Guides.markup("#{guide}.md") 52 | ) 53 | 54 | ~H""" 55 |

56 | 61 | This and other guides are also available on HexDocs. 62 | 63 |

64 |
65 | {Phoenix.HTML.raw(@guide_content)} 66 |
67 | """ 68 | end 69 | 70 | defp description_list(assigns) do 71 | ~H""" 72 |
73 |
74 |
75 | <%= for {dt, link} <- @items do %> 76 |
77 |
78 | {dt} 79 |
80 |
81 | 86 | {link} 87 | 88 |
89 |
90 | <% end %> 91 |
92 |
93 |
94 | """ 95 | end 96 | 97 | defp doc_link(page) do 98 | "https://hexdocs.pm/phoenix_storybook/PhoenixStorybook.#{page}.html" 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /screenshots/screenshot-01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/screenshots/screenshot-01.jpg -------------------------------------------------------------------------------- /screenshots/screenshot-02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/screenshots/screenshot-02.jpg -------------------------------------------------------------------------------- /test/fixtures/asset_manifests/cache_manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "!comment!":"This is file was auto-generated by `mix phx.digest`. Remove it and all generated artefacts with `mix phx.digest.clean --all`", 3 | "version":1, 4 | "latest":{"css/app.css":"css/app-e77ac4f3bca48ee29802c2f9347f0fc9.css","images/background.png":"images/background-b86648c393a3ab5dbb24671388a48878.png","js/app.js":"js/app-95f46e7cf239d376ab8ff27958ffab1a.js","js/iframe.js":"js/iframe-b4155bdc2453b2d0c8b0a17537a0e2c1.js"}, 5 | "digests":{"css/app-e77ac4f3bca48ee29802c2f9347f0fc9.css":{"digest":"e77ac4f3bca48ee29802c2f9347f0fc9","logical_path":"css/app.css","mtime":63827944551,"sha512":"rXwmX/jQMjQ0aoDiBL/PFkGPRhSV/SUK1ZHz83QMDoJT1xmWhsmMLH7iCmzmgMEQekhUINMa9Zqf1RDgWqjGQQ==","size":73508},"images/background-b86648c393a3ab5dbb24671388a48878.png":{"digest":"b86648c393a3ab5dbb24671388a48878","logical_path":"images/background.png","mtime":63827944551,"sha512":"wAATA2Tba7MnlpXREX9RdQmZlQShVxHzelzN6dGQJCitEOKmaxUpzRp4lNLp/NTBcL5/6TcfEwIDgIoDh4YCXA==","size":123453},"js/app-95f46e7cf239d376ab8ff27958ffab1a.js":{"digest":"95f46e7cf239d376ab8ff27958ffab1a","logical_path":"js/app.js","mtime":63827944551,"sha512":"NuIAu1WpahmCnt6esxze4OIJidL3oN6qmt3k2fOxNxgyubOtgnySZfrm66/dvkMYIbMwHCQ2zdrv/Gm9yJZAmg==","size":88796},"js/iframe-b4155bdc2453b2d0c8b0a17537a0e2c1.js":{"digest":"b4155bdc2453b2d0c8b0a17537a0e2c1","logical_path":"js/iframe.js","mtime":63827944551,"sha512":"8fEJFOtsgVn6E1Sove5dbJc2sRGIAplTCe06ptcpfcGqXyZ2s0861AmjSkXOyV5tQnheZthl68K/4mfergAE4Q==","size":87055}} 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/asset_manifests/corrupted_manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "!comment!":"This is file was auto-generated by `mix phx.digest`. Remove it and all generated artefacts with `mix phx.digest.clean --all`", 3 | "version":1, 4 | "latest":{"css/app.css":"css/app-e77ac4f3bca48ee29802c2f9347f0fc9.css","images/background.png":"images/background-b86648c393a3ab5dbb24671388a48878.png","js/app.js":"js/app-95f46e7cf239d376ab8ff27958ffab1a.js","js/iframe.js":"js/iframe-b4155bdc2453b2d0c8b0a17537a0e2c1.js"}, 5 | -------------------------------------------------------------------------------- /test/fixtures/components/all_types_component.ex: -------------------------------------------------------------------------------- 1 | defmodule AllTypesComponent do 2 | use Phoenix.Component 3 | 4 | @doc """ 5 | Component mixing any attribute possible types. 6 | """ 7 | 8 | attr :event, Phoenix.LiveView.JS 9 | 10 | defmodule Struct do 11 | defstruct [:name] 12 | end 13 | 14 | def all_types_component(assigns) do 15 | assigns = 16 | assigns 17 | |> assign_new(:label, fn -> "" end) 18 | |> assign_new(:option, fn -> nil end) 19 | |> assign_new(:index_i, fn -> 42 end) 20 | |> assign_new(:index_i_with_range, fn -> 5 end) 21 | |> assign_new(:index_f, fn -> 37.2 end) 22 | |> assign_new(:toggle, fn -> false end) 23 | |> assign_new(:things, fn -> [] end) 24 | |> assign_new(:slot_thing, fn -> [] end) 25 | |> assign_new(:map, fn -> %{} end) 26 | |> assign_new(:rest, fn -> %{} end) 27 | 28 | if assigns[:label] == "raise" do 29 | raise "booooom!" 30 | end 31 | 32 | ~H""" 33 |
34 |

all_types_component: <%= @label %>

35 |

option: <%= @option %>

36 |

index_i: <%= @index_i %>

37 |

index_i_with_range: <%= @index_i_with_range %>

38 |

index_f: <%= @index_f %>

39 |

toggle: <%= @toggle %>

40 |

things: <%= inspect(@things) %>

41 |

map: <%= inspect(@map) %>

42 | <%= render_slot(@inner_block) %> 43 |
    44 | <%= for thing <- @slot_thing do %> 45 |
  • <%= render_slot(thing) %>
  • 46 | <% end %> 47 |
48 |
49 | """ 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/fixtures/components/component.ex: -------------------------------------------------------------------------------- 1 | defmodule Component do 2 | use Phoenix.Component 3 | 4 | @doc """ 5 | Component first doc paragraph. 6 | Still first paragraph. 7 | 8 | Second paragraph. 9 | 10 | ## Examples 11 | 12 | <.component label="hello" /> 13 | 14 | and 15 | 16 | iex> Component.component(%{label: "hello"}) 17 | %Phoenix.LiveView.Rendered{} 18 | 19 | and 20 | 21 | ```heex 22 | <.component theme={:cool} /> 23 | ``` 24 | 25 | and 26 | 27 | ```elixir 28 | iex> Component.component(%{theme: :boring}) 29 | %Phoenix.LiveView.Rendered{} 30 | ``` 31 | """ 32 | 33 | attr :theme, :atom, default: nil 34 | attr :label, :string, default: "", doc: "Set your component label" 35 | 36 | attr :index, :integer, 37 | default: 42, 38 | doc: """ 39 | This is a multi-line 40 | 41 | attr documentation. 42 | """ 43 | 44 | def component(assigns) do 45 | ~H""" 46 | 47 | component: <%= @label %> 48 | <%= if @theme do %> 49 | <%= @theme %> 50 | <% end %> 51 | 52 | """ 53 | end 54 | 55 | # In tests, we use 56 | # "should not appear" keyphrase in comments and docs to 57 | # check that the source code is not extracted 58 | 59 | # attrs, comments, and docs that should not appear in the source code 60 | attr :index2, :integer, default: 42 61 | attr :label2, :string, default: "", doc: "Set your component label" 62 | 63 | @doc """ 64 | This should not appear in Component.component/1 source code. 65 | """ 66 | def another_component(assigns) do 67 | ~H""" 68 | 69 | another_component: <%= @label2 %> 70 | 71 | """ 72 | end 73 | 74 | @doc """ 75 | Should not be extracted in Component.component/1 source code. 76 | """ 77 | def unrelated_function, do: nil 78 | end 79 | -------------------------------------------------------------------------------- /test/fixtures/components/event_component.ex: -------------------------------------------------------------------------------- 1 | defmodule EventComponent do 2 | use Phoenix.Component 3 | 4 | @doc """ 5 | Component doc 6 | 7 | ``` 8 | Some code 9 | ``` 10 | 11 | ```css 12 | .my-class { 13 | margin: 0; 14 | } 15 | ``` 16 | """ 17 | def component(assigns) do 18 | assigns = 19 | assigns 20 | |> assign_new(:theme, fn -> nil end) 21 | |> assign_new(:label, fn -> "" end) 22 | 23 | ~H""" 24 | 25 | """ 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/fixtures/components/event_live_component.ex: -------------------------------------------------------------------------------- 1 | defmodule EventLiveComponent do 2 | use Phoenix.LiveComponent 3 | 4 | def render(assigns) do 5 | ~H""" 6 | 7 | 8 | 9 | 10 | """ 11 | end 12 | 13 | def handle_event(_, _, socket) do 14 | {:noreply, socket} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/fixtures/components/let_component.ex: -------------------------------------------------------------------------------- 1 | defmodule LetComponent do 2 | use Phoenix.Component 3 | 4 | attr :stories, :list, doc: "list of stories" 5 | 6 | slot :my_slot, 7 | doc: """ 8 | slot documentation 9 | 10 | is working multiline 11 | """ do 12 | attr :optional_attr, :string, doc: "Optional attr" 13 | end 14 | 15 | def let_component(assigns) do 16 | ~H""" 17 |
    18 | <%= for story <- @stories do %> 19 |
  • 20 | <%= render_slot(@my_slot, story) %> 21 |
  • 22 | <% end %> 23 |
24 | """ 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/fixtures/components/let_live_component.ex: -------------------------------------------------------------------------------- 1 | defmodule LetLiveComponent do 2 | use Phoenix.LiveComponent 3 | 4 | def render(assigns) do 5 | ~H""" 6 |
    7 | <%= for story <- @stories do %> 8 |
  • 9 | <%= render_slot(@inner_block, story) %> 10 |
  • 11 | <% end %> 12 |
13 | """ 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/components/live_component.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveComponent do 2 | @moduledoc """ 3 | LiveComponent first doc paragraph. 4 | Still first paragraph. 5 | 6 | Second paragraph. 7 | """ 8 | use Phoenix.LiveComponent 9 | 10 | def render(assigns) do 11 | ~H""" 12 | 13 | b component: <%= @label %> 14 | <%= if assigns[:inner_block] do %> 15 | <%= render_slot(@inner_block) %> 16 | <% end %> 17 | 18 | """ 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/fixtures/components/nested_component.ex: -------------------------------------------------------------------------------- 1 | defmodule NestedComponent do 2 | use Phoenix.Component 3 | 4 | def nested_component(assigns) do 5 | ~H""" 6 |
7 | <%= if assigns[:inner_block] do %> 8 | <%= render_slot(@inner_block) %> 9 | <% end %> 10 |
11 | """ 12 | end 13 | 14 | def nested(assigns) do 15 | assigns = assign_new(assigns, :label, fn -> "" end) 16 | 17 | ~H""" 18 | I'm nested: <%= @label %> 19 | """ 20 | end 21 | 22 | def other_nested(assigns), do: nested(assigns) 23 | end 24 | -------------------------------------------------------------------------------- /test/fixtures/components/template_component.ex: -------------------------------------------------------------------------------- 1 | defmodule TemplateComponent do 2 | use Phoenix.Component 3 | 4 | def template_component(assigns) do 5 | assigns = 6 | assigns 7 | |> assign_new(:label, fn -> "" end) 8 | |> assign_new(:status, fn -> false end) 9 | 10 | ~H""" 11 | template_component: <%= @label %> / status: <%= @status %> 12 | """ 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/fixtures/components/template_live_component.ex: -------------------------------------------------------------------------------- 1 | defmodule TemplateLiveComponent do 2 | use Phoenix.LiveComponent 3 | 4 | def render(assigns) do 5 | assigns = 6 | assigns 7 | |> assign_new(:label, fn -> "" end) 8 | |> assign_new(:status, fn -> false end) 9 | 10 | ~H""" 11 | template_live_component: <%= @label %> / status: <%= @status %> 12 | """ 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/fixtures/exs/bad_script.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.BadScript do 2 | -------------------------------------------------------------------------------- /test/fixtures/exs/script.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Script do 2 | end 3 | -------------------------------------------------------------------------------- /test/fixtures/indexes/bad_entry.index.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.BadEntry do 2 | use PhoenixStorybook.Index 3 | def folder_icon, do: {:fa, "book-open"} 4 | def folder_name, do: "Storybook" 5 | 6 | def entry("colors"), do: [icon: "fat fa-swatchbook"] 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/indexes/bad_entry_icon_provider.index.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.BadEntryIconProvider do 2 | use PhoenixStorybook.Index 3 | def folder_icon, do: {:fa, "book-open"} 4 | def folder_name, do: "Storybook" 5 | 6 | def entry("colors"), do: [icon: {:unknown, "ufo"}] 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/indexes/bad_folder_icon.index.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.BadFolderIcon do 2 | use PhoenixStorybook.Index 3 | def folder_icon, do: :icon 4 | def folder_name, do: "Storybook" 5 | 6 | def entry("colors"), do: [icon: {:fa, "swatchbook", :thin}] 7 | def entry("typography"), do: [icon: {:fa, "text-size", :duotone}] 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/indexes/bad_folder_name.index.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.BadFolderName do 2 | use PhoenixStorybook.Index 3 | def folder_icon, do: {:fa, "book-open", :light} 4 | def folder_name, do: :storybook 5 | 6 | def entry("colors"), do: [icon: {:fa, "swatchbook", :thin}] 7 | def entry("typography"), do: [icon: {:fa, "text-size", :duotone}] 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/indexes/bad_local_icon_class.index.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.BadLocalIconClass do 2 | use PhoenixStorybook.Index 3 | def folder_icon, do: {:fa, "book-open"} 4 | def folder_name, do: "Storybook" 5 | 6 | def entry("colors"), do: [icon: {:local, "hero-ufo", :mini}] 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/indexes/bad_local_icon_tuple.index.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.BadLocalIconTuple do 2 | use PhoenixStorybook.Index 3 | def folder_icon, do: {:fa, "book-open"} 4 | def folder_name, do: "Storybook" 5 | 6 | def entry("colors"), do: [icon: {:local, "hero-ufo", :mini, "h-2 w-2"}] 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/indexes/empty.index.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Empty do 2 | use PhoenixStorybook.Index 3 | end 4 | -------------------------------------------------------------------------------- /test/fixtures/indexes/valid.index.exs: -------------------------------------------------------------------------------- 1 | defmodule Storybook.Valid do 2 | use PhoenixStorybook.Index 3 | def folder_icon, do: {:fa, "book-open", :light} 4 | def folder_name, do: "Storybook" 5 | 6 | def entry("colors"), do: [icon: {:fa, "swatchbook", :thin}] 7 | def entry("typography"), do: [icon: {:fa, "text-size", :duotone}] 8 | def entry("hero"), do: [icon: {:local, "hero-book-open", "h-2 w-2"}] 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/empty_files/not_a_story: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/test/fixtures/storybook_content/empty_files/not_a_story -------------------------------------------------------------------------------- /test/fixtures/storybook_content/empty_files/not_a_story.exs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/test/fixtures/storybook_content/empty_files/not_a_story.exs -------------------------------------------------------------------------------- /test/fixtures/storybook_content/empty_folders/empty_a/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/test/fixtures/storybook_content/empty_folders/empty_a/.gitkeep -------------------------------------------------------------------------------- /test/fixtures/storybook_content/empty_folders/empty_b/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/test/fixtures/storybook_content/empty_folders/empty_b/.gitkeep -------------------------------------------------------------------------------- /test/fixtures/storybook_content/flat_list/a_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule FlatListStorybook.AComponent do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: nil 4 | end 5 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/flat_list/b_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule FlatListStorybook.BComponent do 2 | use PhoenixStorybook.Story, :live_component 3 | def component, do: nil 4 | end 5 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/render_page_crash/a_page.story.exs: -------------------------------------------------------------------------------- 1 | defmodule RenderPageCrashStorybook.APage do 2 | use PhoenixStorybook.Story, :page 3 | 4 | def render(_assigns) do 5 | raise "crash" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/_root.index.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Root do 2 | use PhoenixStorybook.Index 3 | 4 | def folder_name, do: "Root" 5 | 6 | def entry("a_page"), do: [icon: {:fa, "page"}] 7 | def entry("live_component"), do: [name: "Live Component (root)"] 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/a_folder/_a_folder.index.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.AFolder do 2 | use PhoenixStorybook.Index 3 | 4 | def folder_name, do: "A Folder" 5 | def folder_icon, do: {:fa, "icon"} 6 | 7 | def entry("component"), do: [name: "Component (a_folder)", icon: {:fa, "icon"}] 8 | def entry("live_component"), do: [name: "Live Component (a_folder)"] 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/a_folder/component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.AFolder.Component do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &Component.component/1 4 | def render_source, do: :function 5 | def container, do: {:div, class: "block", "data-foo": "bar"} 6 | 7 | def variations do 8 | [ 9 | %VariationGroup{ 10 | id: :group, 11 | variations: [ 12 | %Variation{ 13 | id: :hello, 14 | description: "Hello variation", 15 | attributes: %{label: "hello"} 16 | }, 17 | %Variation{ 18 | id: :world, 19 | description: "World variation", 20 | attributes: %{label: "world", index: 37} 21 | } 22 | ] 23 | }, 24 | %Variation{ 25 | id: :no_attributes 26 | } 27 | ] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/a_folder/live_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.AFolder.LiveComponent do 2 | use PhoenixStorybook.Story, :live_component 3 | def component, do: LiveComponent 4 | def render_source, do: false 5 | 6 | def attributes do 7 | [ 8 | %Attr{id: :label, type: :string, required: true} 9 | ] 10 | end 11 | 12 | def slots do 13 | [ 14 | %Slot{id: :inner_block} 15 | ] 16 | end 17 | 18 | def variations do 19 | [ 20 | %VariationGroup{ 21 | id: :group, 22 | variations: [ 23 | %Variation{ 24 | id: :hello, 25 | description: "Hello variation", 26 | attributes: %{label: "hello"}, 27 | slots: ["inner block"] 28 | }, 29 | %Variation{ 30 | id: :world, 31 | attributes: %{label: "world"} 32 | } 33 | ] 34 | }, 35 | %Variation{ 36 | id: :default, 37 | attributes: %{label: "hello"}, 38 | slots: ["inner block"] 39 | } 40 | ] 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/a_page.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.APage do 2 | use PhoenixStorybook.Story, :page 3 | 4 | def doc, 5 | do: """ 6 | a page 7 | 8 | multiline doc 9 | """ 10 | 11 | def render(assigns) do 12 | ~H""" 13 | A Page 14 | """ 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/b_folder/_b_folder.index.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.BFolder do 2 | use PhoenixStorybook.Index 3 | 4 | def folder_name, do: "Config Name" 5 | def folder_open?, do: true 6 | 7 | def entry("all_types_component"), do: [name: "AllTypesComponent (b_folder)"] 8 | def entry("component"), do: [name: "Component (b_folder)"] 9 | end 10 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/b_folder/all_types_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.BFolder.AllTypesComponent do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &AllTypesComponent.all_types_component/1 4 | 5 | def attributes do 6 | [ 7 | %Attr{id: :label, type: :string, doc: "A label", required: true}, 8 | %Attr{id: :option, type: :atom, doc: "An option", values: [:opt1, :opt2, :opt3]}, 9 | %Attr{id: :enforced_option, type: :atom, doc: "An option", values: [:opt1, :opt2, :opt3]}, 10 | %Attr{id: :index_i, type: :integer, default: 42}, 11 | %Attr{id: :index_i_with_range, type: :integer, values: 1..10, default: 5}, 12 | %Attr{id: :index_i_with_enforced_range, type: :integer, values: 1..10, default: 5}, 13 | %Attr{id: :index_f, type: :float}, 14 | %Attr{id: :toggle, type: :boolean, default: false}, 15 | %Attr{id: :things, type: :list}, 16 | %Attr{id: :struct, type: AllTypesComponent.Struct}, 17 | %Attr{id: :map, type: :map}, 18 | %Attr{id: :rest, type: :global} 19 | ] 20 | end 21 | 22 | def slots do 23 | [ 24 | %Slot{id: :inner_block, doc: "Your inner block", required: true}, 25 | %Slot{id: :slot_thing, doc: "Some slots"} 26 | ] 27 | end 28 | 29 | def variations do 30 | [ 31 | %Variation{ 32 | id: :default, 33 | attributes: %{ 34 | label: "default label", 35 | toggle: false, 36 | rest: %{:foo => "bar", "data-bar" => 42} 37 | }, 38 | slots: [ 39 | "

will be displayed in inner block

", 40 | "<:slot_thing>slot 1", 41 | "<:slot_thing>slot 2", 42 | "<:other_slot>not displayed" 43 | ] 44 | }, 45 | %Variation{ 46 | id: :with_struct, 47 | attributes: %{ 48 | label: "foo", 49 | struct: %AllTypesComponent.Struct{name: "bar"} 50 | }, 51 | slots: [ 52 | "

inner block

" 53 | ] 54 | }, 55 | %Variation{ 56 | id: :with_eval, 57 | attributes: %{ 58 | label: "with eval", 59 | index_i: {:eval, "10 + 15"} 60 | }, 61 | slots: [ 62 | "

inner block

" 63 | ] 64 | }, 65 | %Variation{ 66 | id: :toggle_true, 67 | attributes: %{ 68 | label: "toggle true", 69 | toggle: true 70 | }, 71 | slots: [ 72 | "

inner block

" 73 | ] 74 | }, 75 | ] 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/b_folder/component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.BFolder.Component do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &Component.component/1 4 | 5 | defmodule NestedStruct do 6 | defstruct [:id, :name] 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/b_folder/nested_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.BFolder.NestedComponent do 2 | use PhoenixStorybook.Story, :component 3 | 4 | def function, do: &NestedComponent.nested_component/1 5 | 6 | def imports do 7 | [{NestedComponent, nested: 1}] 8 | end 9 | 10 | def variations do 11 | [ 12 | %Variation{ 13 | id: :default, 14 | slots: [ 15 | """ 16 | <.nested>hello 17 | <.nested>world 18 | """ 19 | ] 20 | } 21 | ] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/b_folder/with_id_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.BFolder.WithIdComponent do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &Component.component/1 4 | 5 | def attributes do 6 | [ 7 | %Attr{id: :id, type: :string, required: true}, 8 | ] 9 | end 10 | 11 | def variations do 12 | [ 13 | %Variation{id: :default} 14 | ] 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/b_page.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.BPage do 2 | use PhoenixStorybook.Story, :page 3 | 4 | def navigation do 5 | [{:tab_1, "Tab 1", nil}, {:tab_2, "Tab 2", nil}] 6 | end 7 | 8 | def render(assigns) do 9 | ~H""" 10 | B Page: <%= @tab %> 11 | """ 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Component do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &Component.component/1 4 | 5 | def attributes do 6 | [ 7 | %Attr{ 8 | id: :id, 9 | type: :string 10 | }, 11 | %Attr{ 12 | id: :label, 13 | type: :string, 14 | doc: "component label", 15 | required: true 16 | }, 17 | %Attr{ 18 | id: :theme, 19 | type: :atom 20 | } 21 | ] 22 | end 23 | 24 | def variations do 25 | [ 26 | %Variation{ 27 | id: :hello, 28 | description: "Hello variation", 29 | attributes: %{label: "hello"} 30 | }, 31 | %Variation{ 32 | id: :world, 33 | description: "World variation", 34 | attributes: %{label: "world", index: 37} 35 | }, 36 | %Variation{ 37 | id: :lengthy, 38 | description: "Lengthy variation", 39 | attributes: %{ 40 | label: "Omnis rerum facere aspernatur ipsum velit et illum in earum quia modi molestias qui sunt.", 41 | index: 37 42 | } 43 | }, 44 | 45 | %Variation{ 46 | id: :themed, 47 | description: "With a theme attribute", 48 | attributes: %{label: "world", theme: :blue} 49 | } 50 | ] 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/containers/components/iframe.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Containers.Components.Iframe do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &Component.component/1 4 | def container, do: :iframe 5 | 6 | def variations do 7 | [ 8 | %Variation{ 9 | id: :hello 10 | } 11 | ] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/containers/components/iframe_with_opts.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Containers.Components.IframeWithOpts do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &Component.component/1 4 | def container, do: {:iframe, "data-foo": "bar"} 5 | 6 | def variations do 7 | [ 8 | %Variation{ 9 | id: :hello 10 | } 11 | ] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/containers/live_components/iframe.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Containers.LiveComponents.Iframe do 2 | use PhoenixStorybook.Story, :live_component 3 | def component, do: LiveComponent 4 | def container, do: :iframe 5 | 6 | def variations do 7 | [ 8 | %Variation{ 9 | id: :hello 10 | } 11 | ] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/event/_event.index.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Event do 2 | use PhoenixStorybook.Index 3 | 4 | def entry("event_live_component"), do: [name: "Live Event Component (root)"] 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/event/event_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Event.EventComponent do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &EventComponent.component/1 4 | 5 | def attributes do 6 | [ 7 | %Attr{ 8 | id: :label, 9 | type: :string, 10 | doc: "event component label", 11 | required: true 12 | } 13 | ] 14 | end 15 | 16 | def variations do 17 | [ 18 | %Variation{ 19 | id: :hello, 20 | description: "Hello variation", 21 | attributes: %{label: "hello"} 22 | } 23 | ] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/event/event_live_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Event.EventLiveComponent do 2 | use PhoenixStorybook.Story, :live_component 3 | def component, do: EventLiveComponent 4 | 5 | def variations do 6 | [ 7 | %Variation{ 8 | id: :hello, 9 | description: "Hello variation", 10 | attributes: %{label: "hello"} 11 | } 12 | ] 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/examples/example.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Examples.Example do 2 | use PhoenixStorybook.Story, :example 3 | 4 | def doc, do: "Example story" 5 | 6 | def extra_sources do 7 | [ 8 | "./example_html.ex", 9 | "./templates/example.html.heex" 10 | ] 11 | end 12 | 13 | @impl true 14 | def render(assigns) do 15 | TreeStorybook.Examples.ExampleHTML.example(assigns) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/examples/example_html.ex: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Examples.ExampleHTML do 2 | use Phoenix.Component 3 | embed_templates("./templates/*") 4 | end 5 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/examples/templates/example.html.heex: -------------------------------------------------------------------------------- 1 |

2 | Example template 3 |

-------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/let/let_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Let.LetComponent do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &LetComponent.let_component/1 4 | 5 | def variations do 6 | [ 7 | %Variation{ 8 | id: :default, 9 | attributes: %{stories: ~w(foo bar qix)}, 10 | slots: ["<:my_slot :let={entry}>**<%= entry %>**"] 11 | } 12 | ] 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/let/let_live_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.Let.LetLiveComponent do 2 | use PhoenixStorybook.Story, :live_component 3 | def component, do: LetLiveComponent 4 | 5 | def variations do 6 | [ 7 | %Variation{ 8 | id: :default, 9 | attributes: %{stories: ~w(foo bar qix)}, 10 | let: :entry, 11 | slots: ["**<%= entry %>**"] 12 | } 13 | ] 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/live_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.LiveComponent do 2 | use PhoenixStorybook.Story, :live_component 3 | def component, do: LiveComponent 4 | def layout, do: :one_column 5 | 6 | def container, do: :iframe 7 | 8 | def variations do 9 | [ 10 | %Variation{ 11 | id: :hello, 12 | description: "Hello variation", 13 | attributes: %{label: "hello"} 14 | }, 15 | %Variation{ 16 | id: :world, 17 | attributes: %{label: "world"}, 18 | slots: ["inner block"] 19 | }, 20 | %Variation{ 21 | id: :lengthy, 22 | description: "Lengthy variation", 23 | attributes: %{ 24 | label: "Omnis rerum facere aspernatur ipsum velit et illum in earum quia modi molestias qui sunt." 25 | } 26 | }, 27 | ] 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/templates/invalid_template_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.InvalidTemplateComponent do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &TemplateComponent.template_component/1 4 | 5 | def template do 6 | """ 7 |
8 | <.psb-variation/> 9 |
10 | """ 11 | end 12 | 13 | def attributes do 14 | [ 15 | %Attr{ 16 | id: :label, 17 | type: :string, 18 | doc: "component label", 19 | required: true 20 | } 21 | ] 22 | end 23 | 24 | def variations do 25 | [ 26 | %Variation{ 27 | id: :invalid_template_placeholder, 28 | template: ~s|
<.psb-variation-group/>
|, 29 | attributes: %{label: "invalid template"} 30 | } 31 | ] 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/templates/template_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.TemplateComponent do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &TemplateComponent.template_component/1 4 | 5 | def template do 6 | """ 7 |
8 | 9 | 10 | 11 | 12 | 13 | <.psb-variation/> 14 |
15 | """ 16 | end 17 | 18 | def attributes do 19 | [ 20 | %Attr{ 21 | id: :label, 22 | type: :string, 23 | doc: "component label" 24 | }, 25 | %Attr{ 26 | id: :status, 27 | type: :boolean, 28 | doc: "component status", 29 | default: false 30 | } 31 | ] 32 | end 33 | 34 | def variations do 35 | [ 36 | %Variation{ 37 | id: :hello, 38 | description: "Hello variation", 39 | attributes: %{label: "hello"} 40 | }, 41 | %Variation{ 42 | id: :world, 43 | description: "World variation", 44 | attributes: %{label: "world"} 45 | }, 46 | %VariationGroup{ 47 | id: :group, 48 | variations: [ 49 | %Variation{ 50 | id: :one, 51 | attributes: %{label: "one"} 52 | }, 53 | %Variation{ 54 | id: :two, 55 | attributes: %{label: "two"} 56 | } 57 | ] 58 | }, 59 | %Variation{ 60 | id: :variation_template, 61 | template: ~s|
<.psb-variation/>
|, 62 | attributes: %{label: "variation template"} 63 | }, 64 | %Variation{ 65 | id: :no_template, 66 | template: false, 67 | attributes: %{label: "variation without template"} 68 | }, 69 | %Variation{ 70 | id: :hidden_template, 71 | template: ~s|
<.psb-variation/>
|, 72 | attributes: %{label: "variation hidden template"} 73 | }, 74 | %Variation{ 75 | id: :no_placeholder, 76 | template: "
", 77 | attributes: %{label: ""} 78 | }, 79 | %VariationGroup{ 80 | id: :group_template, 81 | template: """ 82 |
83 | <.psb-variation/> 84 |
85 | """, 86 | variations: [ 87 | %Variation{ 88 | id: :one, 89 | attributes: %{label: "one"} 90 | }, 91 | %Variation{ 92 | id: :two, 93 | attributes: %{label: "two"} 94 | } 95 | ] 96 | }, 97 | %VariationGroup{ 98 | id: :group_template_single, 99 | template: """ 100 |
101 | <.psb-variation-group/> 102 |
103 | """, 104 | variations: [ 105 | %Variation{ 106 | id: :one, 107 | attributes: %{label: "one"} 108 | }, 109 | %Variation{ 110 | id: :two, 111 | attributes: %{label: "two"} 112 | } 113 | ] 114 | }, 115 | %VariationGroup{ 116 | id: :group_template_hidden, 117 | template: """ 118 |
119 | <.psb-variation-group/> 120 |
121 | """, 122 | variations: [ 123 | %Variation{ 124 | id: :one, 125 | attributes: %{label: "one"} 126 | }, 127 | %Variation{ 128 | id: :two, 129 | attributes: %{label: "two"} 130 | } 131 | ] 132 | }, 133 | %VariationGroup{ 134 | id: :no_placeholder_group, 135 | template: "
", 136 | variations: [ 137 | %Variation{ 138 | id: :one, 139 | attributes: %{label: "one"} 140 | }, 141 | %Variation{ 142 | id: :two, 143 | attributes: %{label: "two"} 144 | } 145 | ] 146 | }, 147 | %Variation{ 148 | id: :template_attributes, 149 | template: ~s(<.psb-variation label="from_template" status={true}/>) 150 | } 151 | ] 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/templates/template_iframe_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.TemplateIframeComponent do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: &TemplateComponent.template_component/1 4 | def container, do: :iframe 5 | 6 | def template do 7 | """ 8 |
9 | 10 | 11 | 12 | 13 | 14 | <.psb-variation/> 15 |
16 | """ 17 | end 18 | 19 | def attributes do 20 | [ 21 | %Attr{ 22 | id: :label, 23 | type: :string, 24 | doc: "component label", 25 | required: true 26 | }, 27 | %Attr{ 28 | id: :status, 29 | type: :boolean, 30 | doc: "component status", 31 | default: false 32 | } 33 | ] 34 | end 35 | 36 | def variations do 37 | [ 38 | %Variation{ 39 | id: :hello, 40 | description: "Hello variation", 41 | attributes: %{label: "hello"} 42 | }, 43 | %Variation{ 44 | id: :world, 45 | description: "World variation", 46 | attributes: %{label: "world"} 47 | } 48 | ] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree/templates/template_live_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeStorybook.TemplateLiveComponent do 2 | use PhoenixStorybook.Story, :live_component 3 | def component, do: TemplateLiveComponent 4 | 5 | def template do 6 | """ 7 |
8 | 9 | 10 | 11 | 12 | 13 | <.psb-variation/> 14 |
15 | """ 16 | end 17 | 18 | def attributes do 19 | [ 20 | %Attr{ 21 | id: :label, 22 | type: :string, 23 | doc: "component label", 24 | required: true 25 | }, 26 | %Attr{ 27 | id: :status, 28 | type: :boolean, 29 | doc: "component status", 30 | default: false 31 | } 32 | ] 33 | end 34 | 35 | def variations do 36 | [ 37 | %Variation{ 38 | id: :hello, 39 | description: "Hello variation", 40 | attributes: %{label: "hello"} 41 | }, 42 | %Variation{ 43 | id: :world, 44 | description: "World variation", 45 | attributes: %{label: "world"} 46 | } 47 | ] 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree_b/a_folder/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/test/fixtures/storybook_content/tree_b/a_folder/.gitkeep -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree_b/b_folder/ba_folder/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/test/fixtures/storybook_content/tree_b/b_folder/ba_folder/.gitkeep -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree_b/b_folder/bb_folder/b_ba_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeBStorybook.BFolder.BBFolder.BBaComponent do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: nil 4 | end 5 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree_b/b_folder/bb_folder/b_bb_component.story.exs: -------------------------------------------------------------------------------- 1 | defmodule TreeBStorybook.BFolder.BbFolder.BbbComponent do 2 | use PhoenixStorybook.Story, :component 3 | def function, do: nil 4 | end 5 | -------------------------------------------------------------------------------- /test/fixtures/storybook_content/tree_b/b_folder/bc_folder/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phenixdigital/phoenix_storybook/a7d9ee69de69e8b1fd3aba4842ce82f1c038d027/test/fixtures/storybook_content/tree_b/b_folder/bc_folder/.gitkeep -------------------------------------------------------------------------------- /test/fixtures/stubs/component_stub.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.ComponentStub do 2 | alias PhoenixStorybook.Story.{ComponentBehaviour, StoryBehaviour} 3 | 4 | @behaviour StoryBehaviour 5 | @behaviour ComponentBehaviour 6 | 7 | @impl StoryBehaviour 8 | def storybook_type, do: :component 9 | 10 | @impl StoryBehaviour 11 | def doc, do: ["description"] 12 | 13 | @impl ComponentBehaviour 14 | def unstripped_doc, do: ["description"] 15 | 16 | @impl ComponentBehaviour 17 | def function, do: fn -> "" end 18 | 19 | @impl ComponentBehaviour 20 | def container, do: :div 21 | 22 | @impl ComponentBehaviour 23 | def imports, do: [] 24 | 25 | @impl ComponentBehaviour 26 | def aliases, do: [] 27 | 28 | @impl ComponentBehaviour 29 | def attributes, do: [] 30 | 31 | @impl ComponentBehaviour 32 | def slots, do: [] 33 | 34 | @impl ComponentBehaviour 35 | def variations, do: [] 36 | 37 | @impl ComponentBehaviour 38 | def template, do: PhoenixStorybook.TemplateHelpers.default_template() 39 | 40 | @impl ComponentBehaviour 41 | def layout, do: :two_columns 42 | 43 | @impl ComponentBehaviour 44 | def render_source, do: :module 45 | 46 | end 47 | -------------------------------------------------------------------------------- /test/fixtures/stubs/example_stub.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.ExampleStub do 2 | alias PhoenixStorybook.Story.{ExampleBehaviour, StoryBehaviour} 3 | 4 | @behaviour StoryBehaviour 5 | @behaviour ExampleBehaviour 6 | 7 | @impl StoryBehaviour 8 | def storybook_type, do: :example 9 | 10 | @impl StoryBehaviour 11 | def doc, do: "description" 12 | 13 | @impl ExampleBehaviour 14 | def extra_sources, do: [] 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/stubs/live_component_stub.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.LiveComponentStub do 2 | alias PhoenixStorybook.Story.{LiveComponentBehaviour, StoryBehaviour} 3 | 4 | @behaviour StoryBehaviour 5 | @behaviour LiveComponentBehaviour 6 | 7 | @impl StoryBehaviour 8 | def storybook_type, do: :live_component 9 | 10 | @impl StoryBehaviour 11 | def doc, do: nil 12 | 13 | @impl LiveComponentBehaviour 14 | def component, do: nil 15 | 16 | @impl LiveComponentBehaviour 17 | def container, do: :div 18 | 19 | @impl LiveComponentBehaviour 20 | def imports, do: [] 21 | 22 | @impl LiveComponentBehaviour 23 | def aliases, do: [] 24 | 25 | @impl LiveComponentBehaviour 26 | def attributes, do: [] 27 | 28 | @impl LiveComponentBehaviour 29 | def slots, do: [] 30 | 31 | @impl LiveComponentBehaviour 32 | def variations, do: [] 33 | 34 | @impl LiveComponentBehaviour 35 | def template, do: PhoenixStorybook.TemplateHelpers.default_template() 36 | 37 | @impl LiveComponentBehaviour 38 | def layout, do: :two_columns 39 | 40 | @impl LiveComponentBehaviour 41 | def render_source, do: :module 42 | end 43 | -------------------------------------------------------------------------------- /test/fixtures/stubs/page_stub.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.PageStub do 2 | import Phoenix.Component 3 | alias PhoenixStorybook.Story.{PageBehaviour, StoryBehaviour} 4 | 5 | @behaviour StoryBehaviour 6 | @behaviour PageBehaviour 7 | 8 | @impl StoryBehaviour 9 | def storybook_type, do: :page 10 | 11 | @impl StoryBehaviour 12 | def doc, do: "description" 13 | 14 | @impl PageBehaviour 15 | def navigation, do: [] 16 | 17 | @impl PageBehaviour 18 | def render(assigns), do: ~H"" 19 | end 20 | -------------------------------------------------------------------------------- /test/mix/tasks/dev.storybook_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Dev.StorybookTest do 2 | use ExUnit.Case 3 | alias Mix.Tasks.Dev.Storybook 4 | 5 | setup do 6 | Mix.Task.clear() 7 | :ok 8 | end 9 | 10 | test "mix dev.storybook" do 11 | Storybook.run([]) 12 | assert_receive {:mix_shell, :info, ["* Running mix deps.get for phoenix_storybook dependency"]} 13 | assert_receive {:mix_shell, :info, ["* Running npm ci for phoenix_storybook dependency"]} 14 | assert_receive {:mix_shell, :info, ["* Running mix assets.build for phoenix_storybook dependency"]} 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /test/mix_helper.exs: -------------------------------------------------------------------------------- 1 | # https://github.com/phoenixframework/phoenix/blob/master/installer/test/mix_helper.exs 2 | Mix.shell(Mix.Shell.Process) 3 | 4 | defmodule PhoenixStorybook.MixHelper do 5 | import ExUnit.Assertions 6 | 7 | def tmp_path, do: Path.expand("../tmp", __DIR__) 8 | 9 | def in_tmp_project(which, function) do 10 | conf_before = Application.get_env(:phoenix, :generators) || [] 11 | tmp_dir = Path.join([tmp_path(), random_string(10)]) 12 | path = Path.join([tmp_dir, to_string(which)]) 13 | 14 | try do 15 | File.rm_rf!(path) 16 | File.mkdir_p!(path) 17 | File.cd!(path, function) 18 | after 19 | File.rm_rf!(tmp_dir) 20 | Application.put_env(:phoenix, :generators, conf_before) 21 | end 22 | end 23 | 24 | defp random_string(len) do 25 | len |> :crypto.strong_rand_bytes() |> Base.encode64() |> binary_part(0, len) 26 | end 27 | 28 | def assert_file(file) do 29 | assert File.regular?(file), "Expected #{file} to exist, but does not" 30 | end 31 | 32 | def assert_file(file, match) do 33 | cond do 34 | is_list(match) -> 35 | assert_file(file, &Enum.each(match, fn m -> assert &1 =~ m end)) 36 | 37 | is_binary(match) or is_struct(match, Regex) -> 38 | assert_file(file, &assert(&1 =~ match)) 39 | 40 | is_function(match, 1) -> 41 | assert_file(file) 42 | match.(File.read!(file)) 43 | 44 | true -> 45 | raise inspect({file, match}) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/phoenix_storybook/controllers/asset_not_found_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.AssetNotFoundControllerTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Phoenix.ConnTest, only: [build_conn: 0, get: 2] 5 | alias PhoenixStorybook.TestRouter.Helpers, as: Routes 6 | @endpoint PhoenixStorybook.AssetNotFoundControllerEndpoint 7 | @moduletag :capture_log 8 | 9 | setup_all do 10 | start_supervised!(@endpoint) 11 | {:ok, conn: build_conn()} 12 | end 13 | 14 | test "it raises, whatever the path", %{conn: conn} do 15 | assert_raise PhoenixStorybook.AssetNotFound, fn -> 16 | get(conn, Routes.storybook_asset_path(conn, :asset, ["foo"])) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/phoenix_storybook/exs_compiler_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.ExsCompilerTest do 2 | use ExUnit.Case, async: true 3 | import ExUnit.CaptureLog 4 | alias PhoenixStorybook.ExsCompiler 5 | 6 | setup do 7 | [ 8 | path: Path.expand("../fixtures/exs", __DIR__), 9 | exs: "script.exs", 10 | bad_exs: "bad_script.exs" 11 | ] 12 | end 13 | 14 | describe "compile_exs/2" do 15 | test "can load an exs", %{exs: exs, path: path} do 16 | assert ExsCompiler.compile_exs(exs, path) == {:ok, PhoenixStorybook.Script} 17 | end 18 | 19 | test "can load same exs twice", %{exs: exs, path: path} do 20 | assert ExsCompiler.compile_exs(exs, path) == {:ok, PhoenixStorybook.Script} 21 | assert ExsCompiler.compile_exs(exs, path) == {:ok, PhoenixStorybook.Script} 22 | end 23 | 24 | test "returns an error tuple with bad script", %{bad_exs: exs, path: path} do 25 | log = capture_log(fn -> assert {:error, _, _} = ExsCompiler.compile_exs(exs, path) end) 26 | assert log =~ ~s|Could not compile "#{exs}"| 27 | end 28 | end 29 | 30 | describe "compile_exs!/2" do 31 | test "can load a valid exs, logs nothing by default", %{exs: exs, path: path} do 32 | log = 33 | capture_log(fn -> 34 | assert ExsCompiler.compile_exs!(exs, path) == PhoenixStorybook.Script 35 | end) 36 | 37 | refute log =~ "compiling" 38 | end 39 | 40 | test "it raises with bad script", %{bad_exs: exs, path: path} do 41 | assert_raise TokenMissingError, fn -> 42 | ExsCompiler.compile_exs!(exs, path) 43 | end 44 | end 45 | 46 | test "it logs when compilation_debug is set to true", %{ 47 | exs: exs, 48 | path: path 49 | } do 50 | previous_logger_level = Logger.level() 51 | Logger.configure(level: :debug) 52 | 53 | log = 54 | capture_log(fn -> 55 | assert ExsCompiler.compile_exs!(exs, path, compilation_debug: true) == 56 | PhoenixStorybook.Script 57 | end) 58 | 59 | Logger.configure(level: previous_logger_level) 60 | 61 | assert log =~ "compiling storybook file: #{exs}" 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/phoenix_storybook/guides/guides_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Guides.GuidesTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule Guides do 5 | use PhoenixStorybook.Guides.Macros 6 | end 7 | 8 | test "components guide" do 9 | guide = Guides.markup("components.md") 10 | assert guide =~ "

\nComponent stories

" 11 | end 12 | 13 | test "sandboxing guide" do 14 | guide = Guides.markup("sandboxing.md") 15 | assert guide =~ "

\nSandboxing components

" 16 | end 17 | 18 | test "icons guide" do 19 | guide = Guides.markup("icons.md") 20 | assert guide =~ "

\nCustom Icons

" 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/phoenix_storybook/helpers/asset_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.AssetHelpersTest do 2 | use ExUnit.Case, async: true 3 | 4 | import PhoenixStorybook.AssetHelpers 5 | 6 | describe "parse_manifest/2" do 7 | test "it parses a valid manifest" do 8 | path = manifest_path("cache_manifest.json") 9 | assert is_map(parse_manifest(path)) 10 | end 11 | 12 | test "it raises when path is invalid" do 13 | path = manifest_path("unknown.json") 14 | 15 | assert_raise RuntimeError, "cannot read manifest #{path}", fn -> 16 | parse_manifest(path) 17 | end 18 | end 19 | 20 | test "it raises when manifest is corrupted" do 21 | path = manifest_path("corrupted_manifest.json") 22 | 23 | assert_raise RuntimeError, "cannot read manifest #{path}", fn -> 24 | parse_manifest(path) 25 | end 26 | end 27 | end 28 | 29 | describe "asset_file_name/3" do 30 | setup do 31 | {:ok, manifest: manifest_path("cache_manifest.json") |> parse_manifest()} 32 | end 33 | 34 | test "it returns fingerprinted asset name", %{manifest: manifest} do 35 | assert asset_file_name(manifest, "js/app.js", :prod) == 36 | "js/app-95f46e7cf239d376ab8ff27958ffab1a.js" 37 | end 38 | 39 | test "it raises nil with wrong asset", %{manifest: manifest} do 40 | assert_raise RuntimeError, "cannot find asset js/wrong.js in manifest", fn -> 41 | asset_file_name(manifest, "js/wrong.js", :prod) 42 | end 43 | end 44 | 45 | test "it returns nil when not in production", %{manifest: manifest} do 46 | assert is_nil(asset_file_name(manifest, "js/app.js", :dev)) 47 | end 48 | end 49 | 50 | defp manifest_path(manifest) do 51 | ["..", "..", "fixtures", "asset_manifests", manifest] |> Path.join() |> Path.expand(__DIR__) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/phoenix_storybook/helpers/search_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.SearchHelpersTest do 2 | use ExUnit.Case, async: true 3 | 4 | import PhoenixStorybook.SearchHelpers 5 | 6 | describe "search/2" do 7 | test "simple search" do 8 | assert {true, _, _} = search("a", "abc") 9 | assert {true, _, _} = search("b", "abc") 10 | assert {true, _, _} = search("ab", "abc") 11 | assert {false, _, _} = search("d", "abc") 12 | assert {false, _, _} = search("ba", "abc") 13 | assert {false, _, _} = search("", "abc") 14 | end 15 | 16 | test "fuzzy search" do 17 | assert {true, _, _} = search("LCnt", "LiveComponent") 18 | assert {true, _, _} = search("lcnt", "LiveComponent") 19 | assert {true, _, _} = search("lcnt", "LiveComponent") 20 | assert {false, _, _} = search("lcZnt", "LiveComponent") 21 | end 22 | end 23 | 24 | describe "search_by/3" do 25 | test "simple search" do 26 | assert [%{t: "abc"}, %{t: "addbc"}] = 27 | search_by("ab", [%{t: "addbc"}, %{t: "abc"}, %{t: "xy"}], [:t]) 28 | end 29 | 30 | test "multi-key search" do 31 | assert [%{t: "abc", n: "Wahou"}, %{t: "awaha", n: "bar"}] = 32 | search_by( 33 | "wah", 34 | [%{t: "abc", n: "Wahou"}, %{t: "addbc", n: "foo"}, %{t: "awaha", n: "bar"}], 35 | [:t, :n] 36 | ) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/phoenix_storybook/live/component_iframe_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.ComponentIframeLiveTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.ConnTest 4 | import Phoenix.LiveViewTest 5 | 6 | @endpoint PhoenixStorybook.ComponentIframeLiveEndpoint 7 | @moduletag :capture_log 8 | 9 | setup_all do 10 | start_supervised!(@endpoint) 11 | {:ok, conn: build_conn()} 12 | end 13 | 14 | describe "variation rendering" do 15 | test "it renders an story with a variation", %{conn: conn} do 16 | {:ok, _view, html} = 17 | live_with_params( 18 | conn, 19 | "/storybook/iframe/component", 20 | %{"variation_id" => "hello", "theme" => "default"} 21 | ) 22 | 23 | assert html =~ "component: hello" 24 | end 25 | 26 | test "it renders an story with a variation group", %{conn: conn} do 27 | {:ok, _view, html} = 28 | live_with_params( 29 | conn, 30 | "/storybook/iframe/a_folder/component", 31 | %{"variation_id" => "group", "theme" => "colorful"} 32 | ) 33 | 34 | assert html =~ "component: hello" 35 | assert html =~ "component: world" 36 | end 37 | 38 | test "variation with a template", %{conn: conn} do 39 | {:ok, view, html} = 40 | live_with_params(conn, "/storybook/iframe/templates/template_iframe_component", %{ 41 | "variation_id" => "hello", 42 | "theme" => "default" 43 | }) 44 | 45 | assert html =~ "template_component: hello / status: false" 46 | 47 | view |> element("#set-foo-template-iframe-component-single-hello") |> render_click() 48 | assert render(view) =~ "template_component: foo / status: false" 49 | 50 | view |> element("#set-bar-template-iframe-component-single-hello") |> render_click() 51 | assert render(view) =~ "template_component: bar / status: false" 52 | 53 | view |> element("#toggle-status-template-iframe-component-single-hello") |> render_click() 54 | assert render(view) =~ "template_component: bar / status: true" 55 | 56 | view |> element("#toggle-status-template-iframe-component-single-hello") |> render_click() 57 | assert render(view) =~ "template_component: bar / status: false" 58 | 59 | view |> element("#set-status-true-template-iframe-component-single-hello") |> render_click() 60 | assert render(view) =~ "template_component: bar / status: true" 61 | 62 | view 63 | |> element("#set-status-false-template-iframe-component-single-hello") 64 | |> render_click() 65 | 66 | assert render(view) =~ "template_component: bar / status: false" 67 | end 68 | 69 | test "it renders an story with a color theme", %{conn: conn} do 70 | {:ok, _view, html} = 71 | live_with_params( 72 | conn, 73 | "/storybook/iframe/component", 74 | %{"variation_id" => "hello", "theme" => "default", "color_mode" => "dark"} 75 | ) 76 | 77 | assert html =~ ~s|class="dark"| 78 | assert html =~ "component: hello" 79 | end 80 | end 81 | 82 | describe "playground" do 83 | test "it renders a playground with a variation", %{conn: conn} do 84 | {:ok, _view, html} = 85 | live_with_params( 86 | conn, 87 | "/storybook/iframe/component", 88 | %{"variation_id" => "hello", "playground" => true} 89 | ) 90 | 91 | assert html =~ "component: hello" 92 | end 93 | 94 | test "it renders a playground with a color_mode", %{conn: conn} do 95 | {:ok, view, _html} = 96 | live_with_params( 97 | conn, 98 | "/storybook/iframe/component", 99 | %{"variation_id" => "hello", "playground" => true, "color_mode" => "dark"} 100 | ) 101 | 102 | html = view |> element(".psb-sandbox") |> render() 103 | [class] = html |> Floki.parse_fragment!() |> Floki.attribute("class") 104 | assert class |> String.split(" ") |> Enum.member?("dark") 105 | assert html =~ "component: hello" 106 | end 107 | 108 | test "it renders a playground with a variation group", %{conn: conn} do 109 | {:ok, _view, html} = 110 | live_with_params( 111 | conn, 112 | "/storybook/iframe/a_folder/component", 113 | %{"variation_id" => "group", "playground" => true} 114 | ) 115 | 116 | assert html =~ "component: hello" 117 | assert html =~ "component: world" 118 | end 119 | end 120 | 121 | test "it raises with an unknown story", %{conn: conn} do 122 | assert_raise RuntimeError, fn -> 123 | live_with_params(conn, "/storybook/iframe/unknown", %{"variation_id" => "default"}) 124 | end 125 | end 126 | 127 | defp live_with_params(conn, path, params) do 128 | live(conn, "#{path}?#{URI.encode_query(params)}") 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /test/phoenix_storybook/live/search_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.SearchTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.LiveViewTest 4 | import Floki, only: [find: 2] 5 | 6 | alias PhoenixStorybook.Search 7 | alias PhoenixStorybook.{EmptyFilesStorybook, FlatListStorybook} 8 | 9 | describe "search list stories" do 10 | test "has no story" do 11 | {_document, html} = render_search(EmptyFilesStorybook) 12 | assert String.contains?(html, "No stories found") 13 | end 14 | 15 | test "contains all stories" do 16 | {document, html} = render_search(FlatListStorybook) 17 | 18 | assert find(document, "ul>li") |> length() == 2 19 | assert String.contains?(html, "a_component") 20 | assert String.contains?(html, "b_component") 21 | end 22 | end 23 | 24 | defp render_search(backend_module) do 25 | html = 26 | render_component(Search, 27 | id: "search", 28 | root_path: "/storybook", 29 | backend_module: backend_module 30 | ) 31 | 32 | {:ok, document} = Floki.parse_document(html) 33 | {document, html} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/phoenix_storybook/live/sidebar_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.SidebarTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.LiveViewTest 4 | import Floki, only: [find: 2] 5 | 6 | alias PhoenixStorybook.Sidebar 7 | alias PhoenixStorybook.{FlatListStorybook, TreeStorybook} 8 | 9 | describe "storybook with flat list of stories" do 10 | test "sidebar contains those 2 stories" do 11 | {document, _html} = render_sidebar(FlatListStorybook) 12 | # test sidebar has 1 root story 13 | assert find(document, "nav>ul>li") |> length() == 1 14 | 15 | # test sidebar has 2 folders beneath root 16 | assert find(document, "nav>ul>li>ul>li") |> length() == 2 17 | 18 | # test those 2 stories are links (ie. not folders) 19 | assert find(document, "nav>ul>li>ul>li>div>a") |> length() == 2 20 | end 21 | end 22 | 23 | describe "storybook with a tree of stories" do 24 | test "sidebar contains all stories, with one open folder" do 25 | {document, _html} = render_sidebar(TreeStorybook) 26 | # test sidebar has 1 root story 27 | assert find(document, "nav>ul>li") |> length() == 1 28 | 29 | # test sidebar has 9 stories 30 | assert find(document, "nav>ul>li>ul>li") |> length() == 11 31 | 32 | # test 4 of them are links (ie. not folders) 33 | assert find(document, "nav>ul>li>ul>li>div>a") |> length() == 4 34 | 35 | # fifth node (which is 1st folder) is closed 36 | assert find(document, "nav>ul>li>ul>li:nth-child(5)>ul>li") |> length() == 0 37 | 38 | # sixth node (which is 2nd folder) is open (by config) 39 | assert find(document, "nav>ul>li>ul>li:nth-child(6)>ul>li") |> length() == 4 40 | end 41 | 42 | test "sidebar with a path contains all stories, with 2 open folders" do 43 | {document, _html} = render_sidebar(TreeStorybook, "/a_folder/aa_component") 44 | # test sidebar has 1 root story 45 | assert find(document, "nav>ul>li") |> length() == 1 46 | 47 | # test sidebar has 9 stories 48 | assert find(document, "nav>ul>li>ul>li") |> length() == 11 49 | 50 | # test 4 of them are links (ie. not folders) 51 | assert find(document, "nav>ul>li>ul>li>div>a") |> length() == 4 52 | 53 | # fifth node (which is 1st folder) is open (by path) 54 | assert find(document, "nav>ul>li>ul>li:nth-child(5)>ul>li") |> length() == 2 55 | 56 | # sixth node (which is 2nd folder) is open (by config) 57 | assert find(document, "nav>ul>li>ul>li:nth-child(6)>ul>li") |> length() == 4 58 | end 59 | 60 | test "sidebar with a path has active story marked as active" do 61 | {document, _html} = render_sidebar(TreeStorybook, "a_folder/component") 62 | 63 | # test 1th story in 1st folder is active (font-bold class) 64 | [{"div", [{"class", link_class} | _], _}] = 65 | find(document, "nav>ul>li>ul>li:nth-child(5)>ul>li:nth-child(1)>div") 66 | 67 | assert String.contains?(link_class, "psb-font-bold") 68 | end 69 | 70 | test "sidebar with an icon folder is well displayed" do 71 | {document, _html} = render_sidebar(TreeStorybook, "a_folder/component") 72 | 73 | [ 74 | {"i", [{"class", first_icon_classes} | _], _}, 75 | {"i", [{"class", second_icon_classes} | _], _} 76 | ] = find(document, "nav>ul>li>ul>li:nth-child(5)>div>i") 77 | 78 | assert String.contains?(first_icon_classes, "fa-caret-down") 79 | assert String.contains?(second_icon_classes, "fa-icon") 80 | end 81 | 82 | test "sidebar folder names are well displayed" do 83 | {document, _html} = render_sidebar(TreeStorybook, "a_folder/component") 84 | 85 | # test default folder name (properly humanized) 86 | [{"span", [_], [html]}] = 87 | find(document, "nav>ul>li>ul>li:nth-child(5)>div>span:nth-child(3)") 88 | 89 | assert String.contains?(html, "A Folder") 90 | 91 | # test config folder name 92 | [{"span", [_], [html]}] = find(document, "nav>ul>li>ul>li:nth-child(6)>div>span") 93 | assert String.contains?(html, "Config Name") 94 | end 95 | end 96 | 97 | defp render_sidebar(backend_module, path \\ "/") do 98 | html = 99 | render_component(Sidebar, 100 | id: "sidebar", 101 | backend_module: backend_module, 102 | root_path: "/storybook", 103 | current_path: path, 104 | fa_plan: :pro, 105 | sandbox_class: "sandbox" 106 | ) 107 | 108 | {:ok, document} = Floki.parse_document(html) 109 | {document, html} 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/phoenix_storybook/live/visual_test_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.VisualTestLiveTest do 2 | use ExUnit.Case, async: true 3 | import Phoenix.ConnTest 4 | import Phoenix.LiveViewTest 5 | 6 | @endpoint PhoenixStorybook.VisualTestLiveEndpoint 7 | 8 | setup_all do 9 | start_supervised!(@endpoint) 10 | {:ok, conn: build_conn()} 11 | end 12 | 13 | test "renders a component", %{conn: conn} do 14 | {:ok, _view, html} = live(conn, "/storybook/visual_tests/component") 15 | assert html =~ ~r|component:\s*hello\s*default| 16 | end 17 | 18 | test "renders an iframe component", %{conn: conn} do 19 | {:ok, _view, html} = live(conn, "/storybook/visual_tests/live_component") 20 | assert html =~ " get("/storybook/visual_tests", start: "a", end: "e") |> live() 25 | assert html =~ "component: hello default" 26 | assert html =~ "inner block" 27 | refute html =~ "A Page" 28 | assert html |> Floki.parse_document!() |> Floki.find("h1") |> length() == 5 29 | end 30 | 31 | test "renders another component range", %{conn: conn} do 32 | {:ok, _view, html} = conn |> get("/storybook/visual_tests", start: "a", end: "z") |> live() 33 | assert html |> Floki.parse_document!() |> Floki.find("h1") |> length() == 19 34 | end 35 | 36 | test "exclude components from the range", %{conn: conn} do 37 | {:ok, _view, html} = 38 | conn 39 | |> get("/storybook/visual_tests", 40 | start: "a", 41 | end: "z", 42 | excludes: "Nested Component,With Id Component" 43 | ) 44 | |> live() 45 | 46 | assert html |> Floki.parse_document!() |> Floki.find("h1") |> length() == 17 47 | end 48 | 49 | @tag :capture_log 50 | test "404 on unknown story path", %{conn: conn} do 51 | assert_raise PhoenixStorybook.StoryNotFound, fn -> 52 | live(conn, "/storybook/visual_tests/unknown") 53 | end 54 | end 55 | 56 | @tag :capture_log 57 | test "404 on page story path", %{conn: conn} do 58 | assert_raise PhoenixStorybook.StoryNotFound, fn -> 59 | live(conn, "/storybook/visual_tests/a_page") 60 | end 61 | end 62 | 63 | @tag :capture_log 64 | test "404 on example story path", %{conn: conn} do 65 | assert_raise PhoenixStorybook.StoryNotFound, fn -> 66 | live(conn, "/storybook/visual_tests/examples/example") 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/phoenix_storybook/router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.RouterTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Phoenix.ConnTest, only: [build_conn: 0] 5 | 6 | alias PhoenixStorybook.TestRouter.Helpers, as: Routes 7 | 8 | describe "live_storybook_path/2" do 9 | test "generates helper for home" do 10 | assert Routes.live_storybook_path(build_conn(), :root) == "/storybook" 11 | end 12 | 13 | test "generates helper for story" do 14 | assert Routes.live_storybook_path(build_conn(), :story, ["components", "button"]) == 15 | "/storybook/components/button" 16 | end 17 | 18 | test "generates helper for story iframe" do 19 | assert Routes.live_storybook_path(build_conn(), :story_iframe, ["components", "button"]) == 20 | "/storybook/iframe/components/button" 21 | end 22 | 23 | test "generates helper for home when :as option is passed" do 24 | assert Routes.admin_live_storybook_path(build_conn(), :root) == "/admin/storybook" 25 | end 26 | 27 | test "generates helper for story when :as option is passed" do 28 | assert Routes.admin_live_storybook_path(build_conn(), :story, ["components", "button"]) == 29 | "/admin/storybook/components/button" 30 | end 31 | 32 | test "generates helper for story iframe when :as option is passed" do 33 | assert Routes.admin_live_storybook_path(build_conn(), :story_iframe, [ 34 | "components", 35 | "button" 36 | ]) == "/admin/storybook/iframe/components/button" 37 | end 38 | 39 | test "raises when backend_module is missing" do 40 | assert_raise(RuntimeError, fn -> 41 | defmodule NoBackendModuleRouter do 42 | use Phoenix.Router 43 | import PhoenixStorybook.Router 44 | 45 | live_storybook("/storybook", []) 46 | end 47 | end) 48 | end 49 | end 50 | 51 | describe "storybook_assets/1" do 52 | test "generates helper for any asset" do 53 | assert Routes.storybook_asset_path(build_conn(), :asset, ["js", "app.js"]) == 54 | "/storybook/assets/js/app.js" 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/phoenix_storybook/stories/doc_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Stories.DocTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ExUnit.CaptureLog 5 | 6 | alias PhoenixStorybook.ExsCompiler 7 | alias PhoenixStorybook.Stories.Doc 8 | 9 | describe "fetch_doc_as_html/2" do 10 | test "it returns a function component documentation" do 11 | assert %Doc{ 12 | header: "

\n Component first doc paragraph.\nStill first paragraph.

\n", 13 | body: "

\nSecond paragraph.

\n

\nExamples

" <> examples 14 | } = 15 | "component.story.exs" |> compile_story() |> Doc.fetch_doc_as_html() 16 | 17 | assert examples =~ ~s[.component] 18 | assert examples =~ ~s[Component] 19 | assert examples =~ ~s[:cool] 20 | assert examples =~ ~s[:boring] 21 | end 22 | 23 | test "it returns a live component documentation" do 24 | assert "live_component.story.exs" |> compile_story() |> Doc.fetch_doc_as_html() == %Doc{ 25 | header: "

\n LiveComponent first doc paragraph.\nStill first paragraph.

\n", 26 | body: "

\nSecond paragraph.

\n" 27 | } 28 | end 29 | 30 | test "returns no body for a single line documentation" do 31 | assert "b_folder/all_types_component.story.exs" 32 | |> compile_story() 33 | |> Doc.fetch_doc_as_html() == 34 | %Doc{ 35 | header: "

\n Component mixing any attribute possible types.

\n", 36 | body: nil 37 | } 38 | end 39 | 40 | test "it returns nil when there is no doc" do 41 | assert "let/let_live_component.story.exs" |> compile_story() |> Doc.fetch_doc_as_html() == 42 | nil 43 | end 44 | 45 | test "it returns [] when function doc has not yet been compiled" do 46 | defmodule NoDocComponent do 47 | use Phoenix.Component 48 | def no_doc_component(assigns), do: ~H[] 49 | end 50 | 51 | defmodule NoDocStory do 52 | use PhoenixStorybook.Story, :component 53 | def function, do: &NoDocComponent.no_doc_component/1 54 | end 55 | 56 | log = 57 | capture_log(fn -> 58 | assert Doc.fetch_doc_as_html(NoDocStory) == nil 59 | end) 60 | 61 | assert log =~ 62 | "could not fetch function docs from PhoenixStorybook.Stories.DocTest.NoDocComponent" 63 | end 64 | 65 | test "it returns [] when live_component doc has not yet been compiled" do 66 | defmodule NoDocLiveComponent do 67 | use Phoenix.LiveComponent 68 | def render(assigns), do: ~H[] 69 | end 70 | 71 | defmodule NoDocStory do 72 | use PhoenixStorybook.Story, :live_component 73 | def component, do: NoDocLiveComponent 74 | end 75 | 76 | log = 77 | capture_log(fn -> 78 | assert Doc.fetch_doc_as_html(NoDocStory) == nil 79 | end) 80 | 81 | assert log =~ 82 | "could not fetch module doc from PhoenixStorybook.Stories.DocTest.NoDocLiveComponent" 83 | end 84 | 85 | test "it does not crash with css and untyped code blocks" do 86 | %Doc{header: header, body: body} = 87 | "event/event_component.story.exs" 88 | |> compile_story() 89 | |> Doc.fetch_doc_as_html() 90 | 91 | refute is_nil(header) 92 | refute is_nil(body) 93 | end 94 | end 95 | 96 | defp compile_story(path) do 97 | {:ok, story} = 98 | ExsCompiler.compile_exs( 99 | path, 100 | Path.expand("../../fixtures/storybook_content/tree/", __DIR__) 101 | ) 102 | 103 | story 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /test/phoenix_storybook/stories/index_validator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Stories.IndexValidatorTest do 2 | use ExUnit.Case, async: true 3 | 4 | setup do 5 | [path: Path.expand("../../fixtures/indexes", __DIR__)] 6 | end 7 | 8 | test "with valid index it won't raise", %{path: path} do 9 | Code.compile_file("valid.index.exs", path) 10 | end 11 | 12 | test "with empty index it won't raise", %{path: path} do 13 | Code.compile_file("empty.index.exs", path) 14 | end 15 | 16 | test "with bad folder_icon it will raise", %{path: path} do 17 | assert_raise CompileError, ~r/icon must be a tuple 2, 3 or 4 items/, fn -> 18 | Code.compile_file("bad_folder_icon.index.exs", path) 19 | end 20 | end 21 | 22 | test "with bad folder_name it will raise", %{path: path} do 23 | assert_raise CompileError, ~r/folder_name must return a binary/, fn -> 24 | Code.compile_file("bad_folder_name.index.exs", path) 25 | end 26 | end 27 | 28 | test "with bad entry it will raise", %{path: path} do 29 | assert_raise CompileError, ~r/entry\("colors"\) icon is invalid/, fn -> 30 | Code.compile_file("bad_entry.index.exs", path) 31 | end 32 | end 33 | 34 | test "with bad entry icon it will raise", %{path: path} do 35 | assert_raise CompileError, ~r/icon provider must be either :fa, :hero, or :local/, fn -> 36 | Code.compile_file("bad_entry_icon_provider.index.exs", path) 37 | end 38 | end 39 | 40 | test "with bad local icon class it will raise", %{path: path} do 41 | assert_raise CompileError, ~r/icon class must be a binary/, fn -> 42 | Code.compile_file("bad_local_icon_class.index.exs", path) 43 | end 44 | end 45 | 46 | test "with bad local icon tuple it will raise", %{path: path} do 47 | assert_raise CompileError, ~r/local icons only support 2 or 3 elem tuples/, fn -> 48 | Code.compile_file("bad_local_icon_tuple.index.exs", path) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/phoenix_storybook/stories/story_source_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybook.Stories.StorySourceTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixStorybook.Stories.StorySource 5 | alias PhoenixStorybook.TreeStorybook 6 | 7 | describe "__module_source__/0" do 8 | test "it returns module source" do 9 | {:ok, story} = TreeStorybook.load_story("/component") 10 | source = story.__module_source__() 11 | assert source =~ ~s|defmodule Component do| 12 | assert source =~ ~s|def component(assigns) do| 13 | assert source =~ ~s|def unrelated_function| 14 | assert source =~ ~s|use Phoenix.Component| 15 | end 16 | 17 | @tag :capture_log 18 | test "it fails gracefully if source cannot be loaded" do 19 | defmodule SourceFailStory do 20 | use PhoenixStorybook.Story, :component 21 | import Phoenix.Component 22 | def function, do: &SourceFailStory.badge/1 23 | def badge(assigns), do: ~H"Hello World" 24 | end 25 | 26 | assert is_nil(SourceFailStory.__module_source__()) 27 | end 28 | end 29 | 30 | describe "__source__/0" do 31 | test "it returns story.exs source code" do 32 | {:ok, story} = TreeStorybook.load_story("/component") 33 | assert story.__source__() =~ File.read!(tree_fixture_path("component.story.exs")) 34 | end 35 | end 36 | 37 | describe "__extra_sources__/0" do 38 | test "it returns a list of all story's extra sources" do 39 | {:ok, story} = TreeStorybook.load_story("/examples/example") 40 | path1 = tree_fixture_path("examples/example_html.ex") 41 | path2 = tree_fixture_path("examples/templates/example.html.heex") 42 | 43 | assert story.__extra_sources__() == %{ 44 | "./example_html.ex" => File.read!(path1), 45 | "./templates/example.html.heex" => File.read!(path2) 46 | } 47 | end 48 | end 49 | 50 | describe "__file_path__/0" do 51 | test "it returns story.exs file path" do 52 | {:ok, story} = TreeStorybook.load_story("/component") 53 | assert story.__file_path__() =~ tree_fixture_path("component.story.exs") 54 | end 55 | end 56 | 57 | describe "strip_function_source" do 58 | test "it extracts function from module source" do 59 | {:ok, story} = TreeStorybook.load_story("/component") 60 | module_source = story.__module_source__() 61 | source = StorySource.strip_function_source(module_source, story.function()) 62 | assert source =~ ~s|defmodule Component do| 63 | assert source =~ ~s|def component(assigns) do| 64 | refute source =~ ~s|def unrelated_function| 65 | refute source =~ ~s|use Phoenix.Component| 66 | refute source =~ ~s|attr :index2| 67 | refute source =~ ~s|should not appear| 68 | end 69 | end 70 | 71 | defp tree_fixture_path(path) do 72 | Path.expand("../../fixtures/storybook_content/tree/" <> path, __DIR__) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/phoenix_storybook_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixStorybookTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PhoenixStorybook.{FolderEntry, StoryEntry} 5 | 6 | alias PhoenixStorybook.{ 7 | EmptyFilesStorybook, 8 | EmptyFoldersStorybook, 9 | FlatListStorybook, 10 | TreeStorybook, 11 | TreeBStorybook 12 | } 13 | 14 | describe "stories/0" do 15 | test "when no content path set it should raise" do 16 | assert_raise RuntimeError, "content_path key must be set", fn -> 17 | defmodule PhoenixStorybook.NoContentStorybook do 18 | use Elixir.PhoenixStorybook, otp_app: :phoenix_storybook 19 | end 20 | end 21 | end 22 | 23 | test "with a flat list of stories, it should return a flat list of 2 components" do 24 | assert FlatListStorybook.content_tree() == [ 25 | %FolderEntry{ 26 | name: "Storybook", 27 | icon: {:fa, "book-open", :light, "psb-mr-1"}, 28 | path: "", 29 | entries: [ 30 | %StoryEntry{ 31 | name: "A Component", 32 | path: "/a_component" 33 | }, 34 | %StoryEntry{ 35 | name: "B Component", 36 | path: "/b_component" 37 | } 38 | ] 39 | } 40 | ] 41 | end 42 | 43 | test "with a tree hierarchy of contents it should return a hierarchy of components, correctly sorted" do 44 | [%FolderEntry{entries: entries}] = TreeStorybook.content_tree() 45 | assert Enum.count(entries) == 11 46 | 47 | assert %StoryEntry{name: "A Page", path: "/a_page", icon: {:fa, "page"}} = 48 | Enum.at(entries, 0) 49 | 50 | assert %StoryEntry{name: "B Page", path: "/b_page"} = Enum.at(entries, 1) 51 | assert %StoryEntry{name: "Component", path: "/component"} = Enum.at(entries, 2) 52 | 53 | assert %StoryEntry{name: "Live Component (root)", path: "/live_component"} = 54 | Enum.at(entries, 3) 55 | 56 | assert %FolderEntry{ 57 | path: "/a_folder", 58 | icon: {:fa, "icon"}, 59 | name: "A Folder", 60 | entries: [%StoryEntry{}, %StoryEntry{}] 61 | } = Enum.at(entries, 4) 62 | 63 | assert %FolderEntry{ 64 | path: "/b_folder", 65 | name: "Config Name", 66 | entries: [%StoryEntry{}, %StoryEntry{}, %StoryEntry{}, %StoryEntry{}] 67 | } = Enum.at(entries, 5) 68 | 69 | assert %FolderEntry{ 70 | path: "/event", 71 | name: "Event", 72 | entries: [%StoryEntry{}, %StoryEntry{}] 73 | } = Enum.at(entries, 7) 74 | end 75 | 76 | test "with an empty folder it should return no stories" do 77 | assert EmptyFilesStorybook.content_tree() == [ 78 | %FolderEntry{ 79 | entries: [], 80 | icon: {:fa, "book-open", :light, "psb-mr-1"}, 81 | name: "Storybook", 82 | path: "" 83 | } 84 | ] 85 | end 86 | 87 | test "it should not return empty sub-folders" do 88 | assert [%FolderEntry{entries: []}] = EmptyFoldersStorybook.content_tree() 89 | end 90 | end 91 | 92 | describe "leaves/0" do 93 | test "with a tree it should return all leaves" do 94 | assert TreeBStorybook.leaves() == [ 95 | %StoryEntry{ 96 | path: "/b_folder/bb_folder/b_ba_component", 97 | name: "B Ba Component" 98 | }, 99 | %StoryEntry{ 100 | path: "/b_folder/bb_folder/b_bb_component", 101 | name: "B Bb Component" 102 | } 103 | ] 104 | end 105 | 106 | test "with empty sub folders" do 107 | assert EmptyFoldersStorybook.leaves() == [] 108 | end 109 | end 110 | 111 | describe "load_story/1 & story_path/1" do 112 | test "it returns the path when the module is loaded" do 113 | path = "/a_folder/component" 114 | {:ok, module} = TreeStorybook.load_story(path) 115 | assert TreeStorybook.storybook_path(module) == path 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | defmodule PhoenixStorybook.EmptyFilesStorybook do 4 | use PhoenixStorybook, 5 | otp_app: :phoenix_storybook, 6 | content_path: Path.expand("./fixtures/storybook_content/empty_files", __DIR__) 7 | end 8 | 9 | defmodule PhoenixStorybook.EmptyFoldersStorybook do 10 | use PhoenixStorybook, 11 | otp_app: :phoenix_storybook, 12 | content_path: Path.expand("./fixtures/storybook_content/empty_folders", __DIR__) 13 | end 14 | 15 | defmodule PhoenixStorybook.FlatListStorybook do 16 | use PhoenixStorybook, 17 | otp_app: :phoenix_storybook, 18 | content_path: Path.expand("./fixtures/storybook_content/flat_list", __DIR__) 19 | end 20 | 21 | defmodule PhoenixStorybook.TreeStorybook do 22 | use PhoenixStorybook, 23 | otp_app: :phoenix_storybook, 24 | content_path: Path.expand("./fixtures/storybook_content/tree", __DIR__), 25 | compilation_mode: :lazy 26 | end 27 | 28 | defmodule PhoenixStorybook.TreeBStorybook do 29 | use PhoenixStorybook, 30 | content_path: Path.expand("./fixtures/storybook_content/tree_b", __DIR__) 31 | end 32 | 33 | defmodule PhoenixStorybook.TestStorybook do 34 | use PhoenixStorybook, 35 | content_path: Path.expand("./fixtures/storybook_content/tree", __DIR__), 36 | compilation_mode: :lazy, 37 | themes: [ 38 | default: [name: "Default"], 39 | colorful: [name: "Colorful", dropdown_class: "text-pink-400"] 40 | ], 41 | themes_strategies: [ 42 | sandbox_class: "theme-prefix", 43 | assign: :theme 44 | ], 45 | color_mode: true, 46 | strip_doc_attributes: false 47 | end 48 | 49 | defmodule PhoenixStorybook.TestRouter do 50 | use Phoenix.Router 51 | import PhoenixStorybook.Router 52 | 53 | storybook_assets() 54 | 55 | live_storybook("/storybook", 56 | otp_app: :phoenix_storybook, 57 | backend_module: PhoenixStorybook.TestStorybook 58 | ) 59 | 60 | live_storybook("/tree_storybook", 61 | otp_app: :phoenix_storybook, 62 | backend_module: PhoenixStorybook.TreeStorybook, 63 | session_name: :tree_storybook, 64 | pipeline: false 65 | ) 66 | 67 | scope "/admin" do 68 | live_storybook("/storybook", 69 | otp_app: :phoenix_storybook, 70 | backend_module: PhoenixStorybook.TestStorybook, 71 | session_name: :live_storybook_admin, 72 | as: :admin_live_storybook, 73 | pipeline: false 74 | ) 75 | end 76 | end 77 | 78 | for endpoint <- [ 79 | PhoenixStorybook.AssetNotFoundControllerEndpoint, 80 | PhoenixStorybook.ComponentIframeLiveEndpoint, 81 | PhoenixStorybook.StoryLiveTestEndpoint, 82 | PhoenixStorybook.PlaygroundLiveTestEndpoint, 83 | PhoenixStorybook.VisualTestLiveEndpoint 84 | ] do 85 | defmodule endpoint do 86 | use Phoenix.Endpoint, otp_app: :phoenix_storybook 87 | 88 | plug(Plug.Session, 89 | store: :cookie, 90 | key: "_live_view_key", 91 | signing_salt: "/VEDsdfsffMnp5" 92 | ) 93 | 94 | plug(PhoenixStorybook.TestRouter) 95 | end 96 | 97 | Application.put_env(:phoenix_storybook, endpoint, 98 | url: [host: "localhost", port: 4000], 99 | secret_key_base: "Hu4qQN3iKzTV4fJxhorPQlA/osH9fAMtbtjVS58PFgfw3ja5Z18Q/WSNR9wP4OfW", 100 | live_view: [signing_salt: "hMegieSe"], 101 | check_origin: false, 102 | render_errors: [view: PhoenixStorybook.ErrorView] 103 | ) 104 | end 105 | --------------------------------------------------------------------------------