diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 5b1be3191417..0dd4b97b1187 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -16,88 +16,88 @@ const imageContextPath = join(currentDirPath, '../frameworks/nextjs/src/image-co const config = defineMain({ stories: [ - './bench/*.stories.@(js|jsx|ts|tsx)', - { - directory: '../core/template/stories', - titlePrefix: 'core', - }, - { - directory: '../core/src/manager', - titlePrefix: 'manager', - }, - { - directory: '../core/src/preview-api', - titlePrefix: 'preview', - }, - { - directory: '../core/src/preview', - titlePrefix: 'preview', - }, - { - directory: '../core/src/components/brand', - titlePrefix: 'brand', - }, - { - directory: '../core/src/components/components', - titlePrefix: 'components', - }, + // './bench/*.stories.@(js|jsx|ts|tsx)', + // { + // directory: '../core/template/stories', + // titlePrefix: 'core', + // }, + // { + // directory: '../core/src/manager', + // titlePrefix: 'manager', + // }, + // { + // directory: '../core/src/preview-api', + // titlePrefix: 'preview', + // }, + // { + // directory: '../core/src/preview', + // titlePrefix: 'preview', + // }, + // { + // directory: '../core/src/components/brand', + // titlePrefix: 'brand', + // }, + // { + // directory: '../core/src/components/components', + // titlePrefix: 'components', + // }, { directory: '../core/src/component-testing/components', titlePrefix: 'component-testing', }, - { - directory: '../core/src/controls/components', - titlePrefix: 'controls', - }, - { - directory: '../core/src/highlight', - titlePrefix: 'highlight', - }, - { - directory: '../addons/docs/src/blocks', - titlePrefix: 'addons/docs/blocks', - }, - { - directory: '../addons/a11y/src', - titlePrefix: 'addons/accessibility', - }, - { - directory: '../addons/a11y/template/stories', - titlePrefix: 'addons/accessibility', - }, - { - directory: '../addons/docs/template/stories', - titlePrefix: 'addons/docs', - }, - { - directory: '../addons/links/template/stories', - titlePrefix: 'addons/links', - }, - { - directory: '../addons/themes/template/stories', - titlePrefix: 'addons/themes', - }, - { - directory: '../addons/onboarding/src', - titlePrefix: 'addons/onboarding', - }, - { - directory: '../addons/pseudo-states/src', - titlePrefix: 'addons/pseudo-states', - }, - { - directory: '../addons/vitest/src/components', - titlePrefix: 'addons/vitest', - }, - { - directory: '../addons/vitest/template/stories', - titlePrefix: 'addons/vitest', - }, - { - directory: '../addons/vitest/src', - titlePrefix: 'addons/vitest', - files: 'stories.tsx', - }, + // { + // directory: '../core/src/controls/components', + // titlePrefix: 'controls', + // }, + // { + // directory: '../core/src/highlight', + // titlePrefix: 'highlight', + // }, + // { + // directory: '../addons/docs/src/blocks', + // titlePrefix: 'addons/docs/blocks', + // }, + // { + // directory: '../addons/a11y/src', + // titlePrefix: 'addons/accessibility', + // }, + // { + // directory: '../addons/a11y/template/stories', + // titlePrefix: 'addons/accessibility', + // }, + // { + // directory: '../addons/docs/template/stories', + // titlePrefix: 'addons/docs', + // }, + // { + // directory: '../addons/links/template/stories', + // titlePrefix: 'addons/links', + // }, + // { + // directory: '../addons/themes/template/stories', + // titlePrefix: 'addons/themes', + // }, + // { + // directory: '../addons/onboarding/src', + // titlePrefix: 'addons/onboarding', + // }, + // { + // directory: '../addons/pseudo-states/src', + // titlePrefix: 'addons/pseudo-states', + // }, + // { + // directory: '../addons/vitest/src/components', + // titlePrefix: 'addons/vitest', + // }, + // { + // directory: '../addons/vitest/template/stories', + // titlePrefix: 'addons/vitest', + // }, + // { + // directory: '../addons/vitest/src', + // titlePrefix: 'addons/vitest', + // files: 'stories.tsx', + // }, ], addons: [ '@storybook/addon-themes', @@ -105,6 +105,7 @@ const config = defineMain({ '@storybook/addon-designs', '@storybook/addon-vitest', '@storybook/addon-a11y', + '@storybook/addon-story-inspector', 'storybook-addon-pseudo-states', '@chromatic-com/storybook', ], diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index dc335c620322..b383eb060105 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -11,7 +11,8 @@ import addonA11y from '@storybook/addon-a11y'; // TODO add empty preview // import * as designs from '@storybook/addon-designs/preview'; import addonDocs from '@storybook/addon-docs'; -import { DocsContext } from '@storybook/addon-docs/blocks'; +// import { DocsContext } from '@storybook/addon-docs/blocks'; +import addonInspector from '@storybook/addon-story-inspector'; import addonThemes from '@storybook/addon-themes'; import addonTest from '@storybook/addon-vitest'; @@ -142,8 +143,8 @@ const ThemedSetRoot = () => { const loaders = [ /** - * This loader adds a DocsContext to the story, which is required for the most Blocks to work. A - * story will specify which stories they need in the index with: + * // * This loader adds a DocsContext to the story, which is required for the most Blocks to + * work. A story will specify which stories they need in the index with: * * ```ts * parameters: { @@ -193,25 +194,26 @@ const loaders = [ const decorators = [ // This decorator adds the DocsContext created in the loader above - (Story, { loaded: { docsContext } }) => - docsContext ? ( - - - - ) : ( - - ), + // (Story, { loaded: { docsContext } }) => + // docsContext ? ( + // + // + // + // ) : ( + // + // ), /** * This decorator adds wrappers that contains global styles for stories to be targeted by. * Activated with parameters.docsStyles = true - */ (Story, { parameters: { docsStyles } }) => - docsStyles ? ( - - - - ) : ( - - ), + */ + // (Story, { parameters: { docsStyles } }) => + // docsStyles ? ( + // + // + // + // ) : ( + // + // ), /** * This decorator renders the stories side-by-side, stacked or default based on the theme switcher * in the toolbar @@ -406,6 +408,7 @@ export default definePreview({ addonA11y(), addonTest(), addonPseudoStates(), + addonInspector(), templatePreview, ], decorators, diff --git a/code/addons/story-inspector/README.md b/code/addons/story-inspector/README.md new file mode 100644 index 000000000000..ea4d0087f2b3 --- /dev/null +++ b/code/addons/story-inspector/README.md @@ -0,0 +1,56 @@ +# Storybook Story Inspector + +A Storybook addon that helps you visualize which components in your stories have corresponding story files and which don't. + +## Features + +- 🔍 **Visual Component Inspection**: Highlights components in your stories with color-coded indicators +- ✅ **Story Status Indication**: Green highlights for components that have stories, orange for those that don't +- 🚀 **Quick Navigation**: Click highlighted components with stories to navigate directly to them +- ➕ **Story Creation**: Click highlighted components without stories to automatically create a story file +- 🎯 **Smart Detection**: Uses file path metadata to accurately match components to their story files + +## How it works + +1. **File Transformation**: A Vite plugin automatically injects component file path metadata into your JSX elements during build +2. **Component Detection**: The addon scans the rendered story for components with this metadata +3. **Story Matching**: Components are matched against the story index to determine if they have stories +4. **Visual Feedback**: Components are highlighted with different colors based on their story status +5. **Interactive Actions**: Click highlights to navigate to stories or create new ones + +## Usage + +1. Install the addon (it's included as an internal Storybook addon) +2. Click the eye icon in the Storybook toolbar to enable story inspection +3. Components will be highlighted: + - **Green**: Component has stories - click to navigate + - **Orange**: Component has no stories - click to create one + +## Highlighting Colors + +- 🟢 **Green**: Components that have corresponding story files +- 🟠 **Orange**: Components that don't have story files yet + +## Creating Stories + +When you click on an orange-highlighted component (one without stories), the addon will: + +1. Automatically create a new story file for that component +2. Generate basic story structure with sensible defaults +3. Navigate you to the newly created story +4. Show a success notification + +## Technical Details + +This addon consists of: + +- **Vite Plugin**: Injects component file paths as data attributes +- **Manager Extension**: Provides toolbar controls and highlighting logic +- **Story Matching**: Compares component paths against the story index +- **Story Creation**: Integrates with Storybook's story creation APIs + +## Limitations + +- Currently optimized for Vite-based Storybook setups +- Works best with React components (JSX/TSX) +- Requires components to be in separate files for accurate detection diff --git a/code/addons/story-inspector/USAGE.md b/code/addons/story-inspector/USAGE.md new file mode 100644 index 000000000000..2ef5b2676aaf --- /dev/null +++ b/code/addons/story-inspector/USAGE.md @@ -0,0 +1,138 @@ +# Using the Story Inspector Addon + +The Story Inspector addon helps you visualize which components in your Storybook stories have corresponding story files and which don't. This is particularly useful for large codebases where you want to ensure story coverage for your components. + +## Installation and Setup + +### For Internal Use (Development) + +The addon is built as an internal Storybook addon. To use it in your Storybook: + +1. **Add to your `.storybook/main.ts`:** + +```typescript +module.exports = { + // ... other config + addons: [ + // ... other addons + '@storybook/addon-story-inspector', + ], +}; +``` + +2. **Ensure Vite is used as your builder** (required for the component path injection): + +```typescript +module.exports = { + framework: { + name: '@storybook/react-vite', // or another vite-based framework + options: {}, + }, + // ... other config +}; +``` + +## How to Use + +### 1. Enable the Inspector + +- Look for the eye icon 👁️ in the Storybook toolbar +- Click it to toggle the Story Inspector on/off +- The icon will show a badge with the number of detected components when active + +### 2. View Component Highlights + +When enabled, components in your stories will be highlighted with colored outlines: + +- **🟢 Green**: Components that have existing story files +- **🟠 Orange**: Components that don't have story files yet + +### 3. Interact with Highlighted Components + +Click on any highlighted component to see a context menu with options: + +**For components WITH stories (green):** + +- "Go to story" - Navigate directly to the component's story + +**For components WITHOUT stories (orange):** + +- "Create story" - Automatically create a new story file for the component + +### 4. Creating Stories Automatically + +When you click "Create story" on an orange-highlighted component: + +1. The addon automatically creates a new story file +2. Generates basic story structure with sensible defaults +3. Navigates you to the newly created story +4. Shows a success notification + +## What Gets Detected + +The Story Inspector detects: + +- React components (JSX/TSX files) +- Vue components (`.vue` files) +- Svelte components (`.svelte` files) + +It excludes: + +- Story files (`.stories.*`) +- Test files (`.test.*`, `.spec.*`) +- Node modules +- HTML elements (lowercase tags) + +## Technical Details + +### How It Works + +1. **Build-time injection**: A Vite plugin injects `data-sb-component-path` attributes into component elements +2. **Runtime detection**: The addon scans rendered stories for these attributes +3. **Story matching**: Component paths are matched against the story index to determine story existence +4. **Visual feedback**: Components are highlighted using the same system as the a11y addon + +### File Path Matching + +The addon matches components to stories by comparing: + +- Component file path (from the injected metadata) +- `rawComponentPath` field in story index entries + +Paths are normalized to handle cross-platform differences (Windows vs Unix paths). + +## Limitations + +- **Vite-only**: Currently works only with Vite-based Storybook setups +- **Component detection**: Only detects components that render as distinct elements +- **Path accuracy**: Relies on accurate file path metadata injection + +## Troubleshooting + +### Components not being detected + +- Ensure you're using a Vite-based framework +- Check that components are being rendered in the story +- Verify component names start with uppercase letters (React convention) + +### Story creation failing + +- Ensure the component file exists and is accessible +- Check that you have write permissions in the project directory +- Verify the component exports are structured correctly + +### Performance + +- The inspector scans the DOM when enabled, which may impact performance with many components +- Consider disabling when not needed for story development + +## Development Notes + +The addon consists of: + +- **Vite Plugin**: Injects component metadata during build +- **Manager UI**: Provides toolbar controls and highlighting +- **Story Creation**: Integrates with Storybook's story creation APIs +- **Highlighting System**: Uses the same highlighting infrastructure as other addons + +For more details, see the implementation in `code/addons/story-inspector/src/`. diff --git a/code/addons/story-inspector/build-config.ts b/code/addons/story-inspector/build-config.ts new file mode 100644 index 000000000000..bbcb517d5b5c --- /dev/null +++ b/code/addons/story-inspector/build-config.ts @@ -0,0 +1,35 @@ +import type { BuildEntries } from '../../../scripts/build/utils/entry-utils'; + +const config: BuildEntries = { + entries: { + browser: [ + { + exportEntries: ['.'], + entryPoint: './src/index.ts', + }, + { + exportEntries: ['./manager'], + entryPoint: './src/manager.tsx', + dts: false, + }, + { + exportEntries: ['./preview'], + entryPoint: './src/preview.ts', + }, + ], + node: [ + { + exportEntries: ['./preset'], + entryPoint: './src/preset.ts', + dts: false, + }, + { + exportEntries: ['./vite-plugin'], + entryPoint: './src/vite-plugin.ts', + dts: false, + }, + ], + }, +}; + +export default config; diff --git a/code/addons/story-inspector/manager.js b/code/addons/story-inspector/manager.js new file mode 100644 index 000000000000..2e9de16087a7 --- /dev/null +++ b/code/addons/story-inspector/manager.js @@ -0,0 +1 @@ +module.exports = require('./dist/manager'); diff --git a/code/addons/story-inspector/package.json b/code/addons/story-inspector/package.json new file mode 100644 index 000000000000..a58024036860 --- /dev/null +++ b/code/addons/story-inspector/package.json @@ -0,0 +1,76 @@ +{ + "name": "@storybook/addon-story-inspector", + "version": "10.0.0-beta.6", + "description": "Storybook Story Inspector addon: Visualize components and navigate to their stories", + "keywords": [ + "storybook", + "storybook-addon", + "inspector", + "component", + "components", + "stories", + "highlight", + "debug", + "development" + ], + "homepage": "https://github.com/storybookjs/storybook/tree/next/code/addons/story-inspector", + "bugs": { + "url": "https://github.com/storybookjs/storybook/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/storybookjs/storybook.git", + "directory": "code/addons/story-inspector" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./manager": "./dist/manager.js", + "./package.json": "./package.json", + "./preset": "./dist/preset.js", + "./preview": { + "types": "./dist/preview.d.ts", + "default": "./dist/preview.js" + }, + "./vite-plugin": "./dist/vite-plugin.js" + }, + "files": [ + "dist/**/*", + "README.md", + "*.js", + "*.d.ts", + "!src/**/*" + ], + "scripts": { + "check": "jiti ../../../scripts/check/check-package.ts", + "prep": "jiti ../../../scripts/build/build-package.ts" + }, + "dependencies": { + "magic-string": "^0.30.5", + "ts-dedent": "^2.0.0" + }, + "devDependencies": { + "@storybook/icons": "^1.6.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.8.3" + }, + "peerDependencies": { + "storybook": "workspace:^" + }, + "publishConfig": { + "access": "public" + }, + "storybook": { + "displayName": "Story Inspector", + "icon": "🔍" + } +} diff --git a/code/addons/story-inspector/preset.js b/code/addons/story-inspector/preset.js new file mode 100644 index 000000000000..a83f95279e7f --- /dev/null +++ b/code/addons/story-inspector/preset.js @@ -0,0 +1 @@ +module.exports = require('./dist/preset'); diff --git a/code/addons/story-inspector/preview.js b/code/addons/story-inspector/preview.js new file mode 100644 index 000000000000..38ee88eee6f0 --- /dev/null +++ b/code/addons/story-inspector/preview.js @@ -0,0 +1 @@ +module.exports = require('./dist/preview'); diff --git a/code/addons/story-inspector/project.json b/code/addons/story-inspector/project.json new file mode 100644 index 000000000000..4e88c7434b1a --- /dev/null +++ b/code/addons/story-inspector/project.json @@ -0,0 +1,8 @@ +{ + "name": "storybook-inspector", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "targets": { + "build": {} + } +} diff --git a/code/addons/story-inspector/src/components/StoryInspectorTool.tsx b/code/addons/story-inspector/src/components/StoryInspectorTool.tsx new file mode 100644 index 000000000000..23442d081ca5 --- /dev/null +++ b/code/addons/story-inspector/src/components/StoryInspectorTool.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { IconButton } from 'storybook/internal/components'; + +import { PaintBrushAltIcon, PaintBrushIcon } from '@storybook/icons'; + +import { useGlobals } from 'storybook/manager-api'; + +import { PARAM_KEY } from '../constants'; +import { useStoryInspector } from '../hooks/useStoryInspector'; + +export const StoryInspectorTool = () => { + const [globals, updateGlobals] = useGlobals(); + const isEnabled = !!globals[PARAM_KEY]; + + const { components } = useStoryInspector(); + const { withStories, withoutStories } = components; + const totalComponents = withStories.length + withoutStories.length; + + const toggleInspector = () => { + updateGlobals({ [PARAM_KEY]: !isEnabled }); + }; + + const title = isEnabled + ? `Story Inspector: ON (${totalComponents} components found: ${withStories.length} with stories, ${withoutStories.length} without)` + : 'Story Inspector: OFF - Click to scan for components'; + + return ( + + + {totalComponents > 0 && isEnabled && ( + 0 ? '#f59e0b' : '#22c55e', + color: 'white', + borderRadius: '50% 0 30% 0', + fontSize: '8px', + lineHeight: '15px', + fontWeight: 'bold', + bottom: 0, + right: 0, + width: '14px', + height: '14px', + textAlign: 'center', + }} + > + {totalComponents > 9 ? '9+' : totalComponents} + + )} + + ); +}; diff --git a/code/addons/story-inspector/src/constants/index.ts b/code/addons/story-inspector/src/constants/index.ts new file mode 100644 index 000000000000..b5a3d95c3947 --- /dev/null +++ b/code/addons/story-inspector/src/constants/index.ts @@ -0,0 +1,16 @@ +export const ADDON_ID = 'storybook/story-inspector' as const; +export const TOOL_ID = `${ADDON_ID}/tool` as const; +export const PARAM_KEY = 'storyInspector' as const; + +// Data attribute for component file paths +export const COMPONENT_PATH_ATTRIBUTE = 'data-sb-component-path' as const; + +// Highlight IDs +export const HIGHLIGHT_ID_WITH_STORIES = `${ADDON_ID}/with-stories` as const; +export const HIGHLIGHT_ID_WITHOUT_STORIES = `${ADDON_ID}/without-stories` as const; + +// Events +export const EVENTS = { + TOGGLE_INSPECTOR: `${ADDON_ID}/toggle-inspector`, + CREATE_STORY_FOR_COMPONENT: `${ADDON_ID}/create-story-for-component`, +} as const; diff --git a/code/addons/story-inspector/src/hooks/useStoryInspector.ts b/code/addons/story-inspector/src/hooks/useStoryInspector.ts new file mode 100644 index 000000000000..2636a9002168 --- /dev/null +++ b/code/addons/story-inspector/src/hooks/useStoryInspector.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { StoryIndex } from 'storybook/internal/types'; + +import { HIGHLIGHT, REMOVE_HIGHLIGHT } from 'storybook/highlight'; +import type { HighlightMenuItem } from 'storybook/highlight'; +import { useChannel, useGlobals, useStorybookState } from 'storybook/manager-api'; + +import { + ADDON_ID, + EVENTS, + HIGHLIGHT_ID_WITHOUT_STORIES, + HIGHLIGHT_ID_WITH_STORIES, + PARAM_KEY, +} from '../constants'; +import { + type ComponentInfo, + checkComponentsAgainstIndex, + findComponentsInDOM, + generateSelectorsForComponents, + groupComponentsByStoryStatus, +} from '../utils/story-matcher'; + +export function useStoryInspector() { + const [globals] = useGlobals(); + const isEnabled = !!globals[PARAM_KEY]; + const [components, setComponents] = useState([]); + const emit = useChannel({}); + const { index, storyId: currentStoryId } = useStorybookState(); + + const createStoryForComponent = useCallback( + (componentPath: string) => { + emit(EVENTS.CREATE_STORY_FOR_COMPONENT, { componentPath }); + }, + [emit] + ); + + const scanComponents = useCallback(() => { + if (!index) { + return; + } + + const foundComponents = findComponentsInDOM(); + const componentsWithStoryStatus = checkComponentsAgainstIndex(foundComponents, index as any); + + // Filter out the current story's component to avoid redundant highlighting + const filteredComponents = componentsWithStoryStatus.filter((component) => { + if (!currentStoryId) { + return true; + } + + const currentStoryEntry = index[currentStoryId]; + + if (!currentStoryEntry) { + return true; + } + + // Compare component paths (normalize for comparison) + + // Compare component paths (normalize for comparison) + const currentComponentPath = (currentStoryEntry as any).componentPath?.replace(/\\/g, '/'); + const componentPath = component.componentPath.replace(/\\/g, '/'); + + return currentComponentPath !== componentPath; + }); + + setComponents(filteredComponents); + }, [index, currentStoryId]); + + const updateHighlights = useCallback(() => { + // Remove existing highlights + emit(REMOVE_HIGHLIGHT, HIGHLIGHT_ID_WITH_STORIES); + emit(REMOVE_HIGHLIGHT, HIGHLIGHT_ID_WITHOUT_STORIES); + + if (!isEnabled || components.length === 0) { + return; + } + + const { withStories, withoutStories } = groupComponentsByStoryStatus(components); + + // Highlight components with stories + if (withStories.length > 0) { + const selectorsWithStories = generateSelectorsForComponents(withStories); + + emit(HIGHLIGHT, { + id: HIGHLIGHT_ID_WITH_STORIES, + priority: 1, + selectors: selectorsWithStories, + styles: { + outline: '2px solid #22c55e', + backgroundColor: 'rgba(34, 197, 94, 0.1)', + }, + hoverStyles: { + outline: '2px solid #16a34a', + backgroundColor: 'rgba(34, 197, 94, 0.2)', + }, + menu: withStories.map((component) => [ + { + id: `${component.storyId}:info`, + title: `Component: ${component.componentPath + .split('/') + .pop() + ?.replace(/\.(tsx?|jsx?)$/, '')}`, + description: `Story exists: ${component.storyEntry?.title || 'Unknown'}`, + selectors: [generateSelectorsForComponents([component])[0]], + }, + { + id: component.storyId, + iconLeft: 'shareAlt', + title: 'Go to story', + clickEvent: 'storybook/navigate-to-story', + eventData: { storyId: component.storyId }, + selectors: [generateSelectorsForComponents([component])[0]], + }, + { + id: component.componentPath, + iconLeft: 'edit', + title: 'Open in editor', + clickEvent: 'storybook/open-editor', + selectors: [generateSelectorsForComponents([component])[0]], + }, + ]), + }); + } + + // Highlight components without stories + if (withoutStories.length > 0) { + const selectorsWithoutStories = generateSelectorsForComponents(withoutStories); + + emit(HIGHLIGHT, { + id: HIGHLIGHT_ID_WITHOUT_STORIES, + priority: 1, + selectors: selectorsWithoutStories, + styles: { + outline: '2px solid #f59e0b', + backgroundColor: 'rgba(245, 158, 11, 0.1)', + }, + hoverStyles: { + outline: '2px solid #d97706', + backgroundColor: 'rgba(245, 158, 11, 0.2)', + }, + menu: withoutStories.map((component) => [ + { + id: `${component.componentPath}:info`, + title: `Component: ${component.componentPath + .split('/') + .pop() + ?.replace(/\.(tsx?|jsx?)$/, '')}`, + description: 'No story exists yet', + selectors: [generateSelectorsForComponents([component])[0]], + }, + { + id: component.componentPath, + iconLeft: 'plus', + title: 'Create story', + clickEvent: EVENTS.CREATE_STORY_FOR_COMPONENT, + eventData: { componentPath: component.componentPath }, + selectors: [generateSelectorsForComponents([component])[0]], + }, + { + id: component.componentPath, + iconLeft: 'edit', + title: 'Open in editor', + clickEvent: 'storybook/open-editor', + selectors: [generateSelectorsForComponents([component])[0]], + }, + ]), + }); + } + }, [isEnabled, components, emit]); + + // Scan components when inspector is enabled or index changes + useEffect(() => { + if (isEnabled) { + scanComponents(); + } + }, [isEnabled, scanComponents]); + + // Update highlights when components or enabled state changes + useEffect(() => { + updateHighlights(); + }, [updateHighlights]); + + // Clean up highlights when component unmounts + useEffect(() => { + return () => { + emit(REMOVE_HIGHLIGHT, HIGHLIGHT_ID_WITH_STORIES); + emit(REMOVE_HIGHLIGHT, HIGHLIGHT_ID_WITHOUT_STORIES); + }; + }, [emit]); + + return { + isEnabled, + createStoryForComponent, + components: groupComponentsByStoryStatus(components), + }; +} diff --git a/code/addons/story-inspector/src/index.ts b/code/addons/story-inspector/src/index.ts new file mode 100644 index 000000000000..33719e72ead9 --- /dev/null +++ b/code/addons/story-inspector/src/index.ts @@ -0,0 +1,5 @@ +import { definePreviewAddon } from 'storybook/internal/csf'; + +import * as addonAnnotations from './preview'; + +export default () => definePreviewAddon(addonAnnotations); diff --git a/code/addons/story-inspector/src/manager.tsx b/code/addons/story-inspector/src/manager.tsx new file mode 100644 index 000000000000..2c23fbc364f4 --- /dev/null +++ b/code/addons/story-inspector/src/manager.tsx @@ -0,0 +1,233 @@ +import React from 'react'; + +import type { + CreateNewStoryResponsePayload, + FileComponentSearchResponsePayload, + ResponseData, +} from 'storybook/internal/core-events'; + +import { addons, types } from 'storybook/manager-api'; + +import { StoryInspectorTool } from './components/StoryInspectorTool'; +import { ADDON_ID, EVENTS, PARAM_KEY, TOOL_ID } from './constants'; + +// Register the addon +addons.register(ADDON_ID, (api) => { + // Add the toolbar tool + addons.add(TOOL_ID, { + title: 'Story Inspector', + type: types.TOOL, + match: ({ viewMode, tabId }) => !!(viewMode && viewMode.match(/^(story|docs)$/)) && !tabId, + render: () => , + paramKey: PARAM_KEY, + }); + + // Handle navigation to stories + api.on('storybook/navigate-to-story', (storyId: string) => { + api.selectStory(storyId); + }); + + api.on('storybook/open-editor', (file: string) => { + api.openInEditor({ file }); + }); + + console.log('manager'); + // Handle create story requests + api.on(EVENTS.CREATE_STORY_FOR_COMPONENT, async (componentPath: string) => { + try { + // Use the same logic as CreateNewStoryFileModal + const { experimental_requestResponse, addons: storybookAddons } = await import( + 'storybook/manager-api' + ); + const { + CREATE_NEW_STORYFILE_REQUEST, + CREATE_NEW_STORYFILE_RESPONSE, + ARGTYPES_INFO_REQUEST, + ARGTYPES_INFO_RESPONSE, + SAVE_STORY_REQUEST, + SAVE_STORY_RESPONSE, + FILE_COMPONENT_SEARCH_REQUEST, + FILE_COMPONENT_SEARCH_RESPONSE, + } = await import('storybook/internal/core-events'); + + const channel = storybookAddons.getChannel(); + + // First, search for component information using channel.on/emit pattern + const searchId = `search-${Date.now()}`; + let componentName = + componentPath + .split('/') + .pop() + ?.replace(/\.(tsx?|jsx?)$/, '') || 'Component'; + let isDefaultExport = true; + let exportCount = 1; + + // Search for component information + const searchResult: ResponseData = await new Promise( + (resolve) => { + const handleSearchResponse = (data: any) => { + channel.off(FILE_COMPONENT_SEARCH_RESPONSE, handleSearchResponse); + resolve(data); + }; + + console.log('emitting search'); + channel.on(FILE_COMPONENT_SEARCH_RESPONSE, handleSearchResponse); + channel.emit(FILE_COMPONENT_SEARCH_REQUEST, { + id: componentPath.replace('./', ''), + payload: {}, + }); + } + ); + + // Find the component in search results + if (searchResult?.success) { + const componentFile = searchResult.payload?.files?.find( + (file: any) => !!componentPath.match(file.filepath) + ); + + if (componentFile?.exportedComponents?.length > 0) { + // Use the first export found, preferring default exports + const defaultExport = componentFile.exportedComponents.find((exp: any) => exp.default); + const firstExport = componentFile.exportedComponents[0]; + const selectedExport = defaultExport || firstExport; + + componentName = selectedExport.name; + isDefaultExport = selectedExport.default; + exportCount = componentFile.exportedComponents.length; + } + } + + // Create new story file + const createNewStoryResult: CreateNewStoryResponsePayload = + await experimental_requestResponse( + channel, + CREATE_NEW_STORYFILE_REQUEST, + CREATE_NEW_STORYFILE_RESPONSE, + { + componentExportName: componentName, + componentFilePath: componentPath, + componentIsDefaultExport: isDefaultExport, + componentExportCount: exportCount, + } + ); + + if (!createNewStoryResult || !createNewStoryResult.storyId) { + throw new Error('Failed to create new story - no story ID returned'); + } + + const storyId = createNewStoryResult.storyId; + + // Try to select the new story + await new Promise((resolve) => { + const attemptSelect = (attempts = 0) => { + if (attempts > 10) { + resolve(undefined); + return; + } + + try { + api.selectStory(storyId); + resolve(undefined); + } catch { + setTimeout(() => attemptSelect(attempts + 1), 100); + } + }; + attemptSelect(); + }); + + // Get argTypes and save with defaults + try { + const argTypesInfoResult = (await experimental_requestResponse( + channel, + ARGTYPES_INFO_REQUEST, + ARGTYPES_INFO_RESPONSE, + { storyId } + )) as any; + + if (!argTypesInfoResult || !argTypesInfoResult.argTypes) { + throw new Error('Failed to get component argTypes'); + } + + const argTypes = argTypesInfoResult.argTypes; + + // Extract required args with default values + const requiredArgs: Record = {}; + Object.entries(argTypes || {}).forEach(([key, argType]: [string, any]) => { + if (argType.type?.required || argType.control?.required) { + // Provide sensible defaults based on type + if (argType.type?.name === 'string') { + requiredArgs[key] = 'Sample text'; + } else if (argType.type?.name === 'number') { + requiredArgs[key] = 42; + } else if (argType.type?.name === 'boolean') { + requiredArgs[key] = true; + } else { + requiredArgs[key] = null; + } + } + }); + + const saveStoryResult = (await experimental_requestResponse( + channel, + SAVE_STORY_REQUEST, + SAVE_STORY_RESPONSE, + { + args: JSON.stringify(requiredArgs, (_, value) => { + if (typeof value === 'function') { + return '__sb_empty_function_arg__'; + } + return value; + }), + importPath: createNewStoryResult.storyFilePath, + csfId: storyId, + } + )) as any; + + if (!saveStoryResult || !saveStoryResult.storyFilePath) { + throw new Error('Failed to save story - no file path returned'); + } + + const storyFilePath = saveStoryResult.storyFilePath; + } catch (e) { + // Ignore argTypes errors + } + + // Show success notification + api.addNotification({ + id: 'story-inspector-create-success', + content: { + headline: 'Story created successfully', + subHeadline: `Created story for ${componentName}`, + }, + duration: 5000, + icon: , + }); + } catch (error: unknown) { + // Handle errors + let errorMessage = 'Failed to create story'; + const componentName = + componentPath + .split('/') + .pop() + ?.replace(/\.(tsx?|jsx?)$/, '') || 'Component'; + + if (error?.payload?.type === 'STORY_FILE_EXISTS') { + errorMessage = 'Story already exists'; + // Try to navigate to existing story + try { + await api.selectStory(error.payload.kind); + } catch {} + } + + api.addNotification({ + id: 'story-inspector-create-error', + content: { + headline: errorMessage, + subHeadline: error.message || `Error creating story for ${componentName}`, + }, + duration: 8000, + icon: , + }); + } + }); +}); diff --git a/code/addons/story-inspector/src/preset.ts b/code/addons/story-inspector/src/preset.ts new file mode 100644 index 000000000000..e6684313e586 --- /dev/null +++ b/code/addons/story-inspector/src/preset.ts @@ -0,0 +1,18 @@ +import type { Options } from 'storybook/internal/types'; + +import { componentPathInjectorPlugin } from './utils/vite-plugin'; + +export const viteFinal = async ( + config: import('vite').UserConfig, + options: Options +): Promise => { + const plugins = config.plugins || []; + + // Add our component path injector plugin + plugins.push(componentPathInjectorPlugin(options)); + + return { + ...config, + plugins, + }; +}; diff --git a/code/addons/story-inspector/src/preview.ts b/code/addons/story-inspector/src/preview.ts new file mode 100644 index 000000000000..e26800fcc967 --- /dev/null +++ b/code/addons/story-inspector/src/preview.ts @@ -0,0 +1,31 @@ +import type { DecoratorFunction } from 'storybook/internal/types'; + +import { PARAM_KEY } from './constants'; + +/** Global decorator that helps with story inspector functionality */ +export const decorators: DecoratorFunction[] = [ + (storyFn: any, context: any) => { + // The decorator doesn't need to do much - the main work is done by: + // 1. The Vite plugin (injecting component paths) + // 2. The manager-side highlighting logic + + // We could add functionality here if needed, such as: + // - Scanning for components after story render + // - Adding event listeners for inspector interactions + + return storyFn(); + }, +]; + +/** Global parameters for the story inspector */ +export const parameters = { + [PARAM_KEY]: { + // Default to disabled - users can enable via toolbar + enabled: false, + }, +}; + +/** Initial globals for the story inspector */ +export const initialGlobals = { + [PARAM_KEY]: false, +}; diff --git a/code/addons/story-inspector/src/utils/__tests__/story-matcher.test.ts b/code/addons/story-inspector/src/utils/__tests__/story-matcher.test.ts new file mode 100644 index 000000000000..5719f0134057 --- /dev/null +++ b/code/addons/story-inspector/src/utils/__tests__/story-matcher.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type ComponentInfo, + checkComponentsAgainstIndex, + findComponentsInDOM, + generateSelectorsForComponents, + groupComponentsByStoryStatus, +} from '../story-matcher'; + +// Mock DOM +global.document = { + querySelectorAll: vi.fn(), +} as any; + +describe('story-matcher', () => { + describe('checkComponentsAgainstIndex', () => { + it('should match components against story index', () => { + const components: ComponentInfo[] = [ + { + element: {} as Element, + componentPath: '/src/components/Button.tsx', + hasStory: false, + }, + { + element: {} as Element, + componentPath: '/src/components/Input.tsx', + hasStory: false, + }, + ]; + + const storyIndex = { + entries: { + 'button--default': { + id: 'button--default', + title: 'Button', + name: 'Default', + componentPath: '/src/components/Button.tsx', + }, + }, + }; + + const result = checkComponentsAgainstIndex(components, storyIndex.entries as any); + + expect(result[0].hasStory).toBe(true); + expect(result[0].storyId).toBe('button--default'); + expect(result[1].hasStory).toBe(false); + }); + + it('should normalize paths for comparison', () => { + const components: ComponentInfo[] = [ + { + element: {} as Element, + componentPath: '/src\\components\\Button.tsx', // Windows path + hasStory: false, + }, + ]; + + const storyIndex = { + entries: { + 'button--default': { + id: 'button--default', + title: 'Button', + name: 'Default', + componentPath: '/src/components/Button.tsx', // Unix path + }, + }, + }; + + const result = checkComponentsAgainstIndex(components, storyIndex.entries as any); + + expect(result[0].hasStory).toBe(true); + }); + }); + + describe('groupComponentsByStoryStatus', () => { + it('should group components by story status', () => { + const components: ComponentInfo[] = [ + { + element: {} as Element, + componentPath: '/src/components/Button.tsx', + hasStory: true, + storyId: 'button--default', + }, + { + element: {} as Element, + componentPath: '/src/components/Input.tsx', + hasStory: false, + }, + ]; + + const result = groupComponentsByStoryStatus(components); + + expect(result.withStories).toHaveLength(1); + expect(result.withoutStories).toHaveLength(1); + expect(result.withStories[0].componentPath).toBe('/src/components/Button.tsx'); + expect(result.withoutStories[0].componentPath).toBe('/src/components/Input.tsx'); + }); + }); + + describe('findComponentsInDOM', () => { + it('should deduplicate components with same path', () => { + // Mock the preview iframe and its document + const mockElement1 = { getAttribute: vi.fn().mockReturnValue('./src/Button.tsx') }; + const mockElement2 = { getAttribute: vi.fn().mockReturnValue('./src/Button.tsx') }; // Same path + const mockElement3 = { getAttribute: vi.fn().mockReturnValue('./src/Input.tsx') }; // Different path + + const mockContentDocument = { + querySelectorAll: vi.fn().mockReturnValue([mockElement1, mockElement2, mockElement3]), + }; + + const mockIframe = { + contentDocument: mockContentDocument, + contentWindow: { document: mockContentDocument }, + }; + + // Mock DOM + global.document = { + getElementById: vi.fn().mockReturnValue(mockIframe), + } as any; + + const result = findComponentsInDOM(); + + // Should only have 2 components (deduplicated) + expect(result).toHaveLength(2); + expect(result[0].componentPath).toBe('./src/Button.tsx'); + expect(result[1].componentPath).toBe('./src/Input.tsx'); + // Should use the first element found for each path + expect(result[0].element).toBe(mockElement1); + }); + }); + + describe('generateSelectorsForComponents', () => { + it('should generate CSS selectors for components', () => { + const components: ComponentInfo[] = [ + { + element: { + tagName: 'BUTTON', + getAttribute: () => '/src/components/Button.tsx', + } as any, + componentPath: '/src/components/Button.tsx', + hasStory: false, + }, + ]; + + const result = generateSelectorsForComponents(components); + + expect(result).toHaveLength(1); + expect(result[0]).toBe('button[data-sb-component-path="/src/components/Button.tsx"]'); + }); + }); +}); diff --git a/code/addons/story-inspector/src/utils/__tests__/vite-plugin.test.ts b/code/addons/story-inspector/src/utils/__tests__/vite-plugin.test.ts new file mode 100644 index 000000000000..172ac84e8b72 --- /dev/null +++ b/code/addons/story-inspector/src/utils/__tests__/vite-plugin.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; + +import { componentPathInjectorPlugin } from '../vite-plugin'; + +describe('componentPathInjectorPlugin', () => { + it('should create a plugin with correct name', () => { + const plugin = componentPathInjectorPlugin(); + + expect(plugin.name).toBe('storybook:story-inspector-component-path-injector'); + expect(plugin.enforce).toBe('pre'); + }); + + it('should inject component path into JSX elements', async () => { + const plugin = componentPathInjectorPlugin(); + const code = ` +import React from 'react'; + +const Button = () => ; + +export const MyStory = () => ( +
+
+); +`; + + const result = await plugin.transform(code, '/some-project/src/components/Button.tsx'); + + expect(result?.code).toContain('data-sb-component-path="./src/components/Button.tsx"'); + expect(result?.code).toContain('