├── .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 `
([^<]*)<\/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 |
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 =~ "