0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

StorybookのTags機能をつかおう

Posted at

はじめに

  • storybookのTags機能について有効活用を考えたい
  • storyの数が多くなってきているので、フィルタして対象有無が判別しやすくしたい

Tag機能とは

Tags は、ストーリーやコンポーネントに任意の文字列ラベル(タグ)を付与する仕組み。ストーリーのフィルタリングや表示制御などに使える。

すべての Storybook プロジェクトにはデフォルトで以下のタグが存在します。
  - dev:サイドバーに表示されるストーリーに自動的に付与
  - test:テスト実行ランナー(Vitest 等)に含められるストーリーに自動的に付与
  - autodocs:これをタグ付けされたストーリーがある場合、ドキュメントページが自動生成される

参考記事https://storybook.js.org/blog/storybook-tags/

やってみる

LoginPage.stories.tsxにtagをつけてみます。タグ:feature:auth


import type { Meta, StoryObj } from '@storybook/react-vite';

import { LoginFlow, LoginFlowWrapper } from './LoginFlow';
import { PasswordResetForm } from './components/PasswordResetForm';
import { TwoFactorForm } from './components/TwoFactorForm';
import { LoginForm } from './components/LoginForm';
import { evaluateLoginAttempt, loginStoryActions } from './storyHelpers';

const meta: Meta = {
  title: 'Pages/Login/LoginScenarios',
  tags: ['feature:auth'], //  ← tagの記述
  parameters: {
    layout: 'fullscreen',
  },
};

export default meta;

type Story = StoryObj;

export const PAG_LGN_FORM: Story = {
  name: '[FLOW] ログインフォーム',
  render: () => (
    <LoginFlowWrapper>
      <LoginForm
        onLoginAttempt={evaluateLoginAttempt}
        onLoginSuccess={loginStoryActions.loginSubmit}
        onPasswordReset={loginStoryActions.requestReset}
      />
    </LoginFlowWrapper>
  ),
};

するとフィルタできるようになりました。やったぜ。

image.png

タグづけするスクリプトをつくる

すべてのstoryにタグづけるのはだるいので簡易自動化スクリプトをつくりました。
これでハッピー。やったぜ。

add-tags.mjs
#!/usr/bin/env node
import { Project, SyntaxKind } from 'ts-morph';
import path from 'node:path';
import fs from 'node:fs';

const CATEGORY_DIRS = new Set([
  'inputs',
  'data-display',
  'navigation',
  'feedback',
  'surfaces',
  'forms',
  'dialogs',
  'dnd',
  'marketing',
  'data-input',
  'theme',
  'i18n',
]);

const FEATURE_MAP_PAGES = {
  login: 'auth',
  register: 'register',
  dashboard: 'dashboard',
  helpCenter: 'help-center',
  userProfile: 'user-profile',
  profile: 'user-profile',
  shoppingCartDrawer: 'cart',
  adminUserManagement: 'admin-users',
  projectRegistration: 'project-registration',
};

const FEATURE_MAP_TEMPLATES = {
  auth: 'auth',
  dashboard: 'dashboard',
  // navigation/todo などは意図的にスキップ
};

function getCategoryTagFromPath(filePath) {
  // .../stories/(atoms|molecules|organisms)/<category>/... の形式を想定
  const parts = filePath.split(path.sep);
  const idx = parts.lastIndexOf('stories');
  if (idx === -1) return undefined;
  const layer = parts[idx + 1]; // atoms | molecules | organisms | pages | templates | patterns
  if (!['atoms', 'molecules', 'organisms'].includes(layer)) return undefined;
  const catCandidate = parts[idx + 2];
  if (CATEGORY_DIRS.has(catCandidate)) return `category:${catCandidate}`;
  // molecules/mobile|perf|a11y などのフォルダは意図的にスキップ
  return undefined;
}

function getFeatureTagFromPath(filePath) {
  const parts = filePath.split(path.sep);
  const idx = parts.lastIndexOf('stories');
  if (idx === -1) return undefined;
  const layer = parts[idx + 1]; // pages | templates
  if (layer === 'pages') {
    const pageKey = parts[idx + 2];
    const feature = FEATURE_MAP_PAGES[pageKey];
    return feature ? `feature:${feature}` : undefined;
  }
  if (layer === 'templates') {
    const templateKey = parts[idx + 2];
    const feature = FEATURE_MAP_TEMPLATES[templateKey];
    return feature ? `feature:${feature}` : undefined;
  }
  return undefined;
}

function ensureArrayLiteral(propInit) {
  if (propInit == null) return null;
  if (propInit.getKind() === SyntaxKind.ArrayLiteralExpression) return propInit;
  // 非配列の初期化子を配列に変換
  const initText = propInit.getText();
  propInit.replaceWithText(`[${initText}]`);
  return propInit.getParent().getInitializerIfKind(SyntaxKind.ArrayLiteralExpression) || null;
}

function addTagToMetaObject(objLit, tag) {
  const existingProp = objLit.getProperty('tags');
  if (existingProp && existingProp.getKind() !== SyntaxKind.PropertyAssignment) {
    return false;
  }
  if (existingProp) {
    const propAssign = existingProp;
    const init = propAssign.getInitializer();
    const arr = ensureArrayLiteral(init);
    if (!arr) return false;
    const hasTag = arr.getElements().some((el) => el.getText().replace(/['"`]/g, '') === tag);
    if (!hasTag) arr.addElement(`'${tag}'`);
    return true;
  }
  // 'component'の後に挿入、存在しない場合は'title'の後、それもない場合は末尾に挿入
  const props = objLit.getProperties();
  const componentIdx = props.findIndex((p) => p.getKind() === SyntaxKind.PropertyAssignment && p.getName && p.getName() === 'component');
  const titleIdx = props.findIndex((p) => p.getKind() === SyntaxKind.PropertyAssignment && p.getName && p.getName() === 'title');
  const insertIndex = componentIdx >= 0 ? componentIdx + 1 : titleIdx >= 0 ? titleIdx + 1 : props.length;
  objLit.insertPropertyAssignment(insertIndex, { name: 'tags', initializer: `['${tag}']` });
  return true;
}

function processFile(sf) {
  const filePath = sf.getFilePath();
  const isStory = /\.stories\.(t|j)sx?$/.test(filePath);
  if (!isStory) return false;

  const categoryTag = getCategoryTagFromPath(filePath);
  const featureTag = getFeatureTagFromPath(filePath);
  const tagToAdd = categoryTag || featureTag;
  if (!tagToAdd) return false;

  const metaVar = sf.getVariableDeclaration('meta');
  if (!metaVar) return false;
  const init = metaVar.getInitializer();
  if (!init || init.getKind() !== SyntaxKind.ObjectLiteralExpression) return false;
  const objLit = init;

  const ok = addTagToMetaObject(objLit, tagToAdd);
  return ok;
}

async function main() {
  const rootTsconfig = path.resolve('tsconfig.json');
  const useTsconfig = fs.existsSync(rootTsconfig);
  const project = new Project({
    tsConfigFilePath: useTsconfig ? rootTsconfig : undefined,
    skipAddingFilesFromTsConfig: true,
    manipulationSettings: { quoteKind: 'single' },
  });

  const sources = project.addSourceFilesAtPaths('apps/storybook/src/stories/**/*.stories.@(ts|tsx)');
  let changed = 0;
  for (const sf of sources) {
    try {
      if (processFile(sf)) changed++;
    } catch (e) {
      console.warn('スキップ (エラー):', sf.getFilePath(), e?.message || e);
    }
  }
  await project.save();
  console.log(`更新されたファイル数: ${changed}`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?