React+StoryBook+AdminLTEでのComponent開発環境(3)
前回
今回は何するか
AdminLTEというFramework下でReactによる実装をしてStoryBookで動作確認したい。
AdminLTEはDashboard的なweb pageを作るのに都合のいいframework。ReactはWeb Page(View)を組むためのjsのlibrary。StoryBookは、ReactのComponentにprops(stateやdata)を設定したときに、そのcomponentがどう表示されるか、というのを一覧で出してくれる、componentの管理に都合のいいlibrary。
これらを組み合わせて使えれば、viewの構築はとても楽になると思う。
Web Pageを作る時も
- API Serverの作成(Mojo <= 単に僕の好みなので別にnodeで組んでもええのよ)
- Componentの作成 (React+AdminLTE+StoryBook)
- ComponentとWeb ServerのAPIのつなぎ込み(Redux)
という3つの行程で綺麗に開発項目を分離できる。
そんな感じを目指して作り込んでいく。
StoryBookの入れ込み
setup
npm install --save-dev react-storybook
でまずはinstall。
で、どう使えばいいんだろこれ。
これを参考に進めていく。
設定は.storybook/config.js
とあるけど/config/storybook.js
で作成してみる(なんで素直にやらないのかこいつは
で、/src/storyにcomponent
を呼び出すscriptを置く。
start-storybook -p 9001
で起動するらしいので、これをpackage.jsonに入れ込み、設定fileをなんか設定してみる。
が、そもそもそんなcommandなかった。というかnode_modules/react-storybook
にもpackage.jsonしかない。
https://storybooks.js.org/docs/react-storybook/basics/slow-start-guide
npm i --save-dev @kadira/storybook
か。このやろ。
で、package.json
に"storybook": "start-storybook -p 9001 -c config/storybook.js"
を定義。
あ、configがないと怒られた。素直に/.storybook/config.js
に作り直す。
index.jsも作り直す。まあpathは/src/story/index.js
だけど。
おお。動いた動いた。
へぇ。これはなかなか。
で、どう使うべき物なの?
これは与えるstateを固定しているものっぽいなあ。
じゃあreducerとかはいらなくて本当にcomponentだけ並べて試験する感じか。
設定の中にwebpackの設定を入れ込めるからcompile周りはもう少しいろいろ工夫できそう。
reduxとはどうすれば組み合わせることが出来るんだろう
これから完全にcomponentごとに独立して記述するならここに書いていって本番環境にはbundleしたjsとtempalteへの書き込みだけですみそう。
とりあえずcomponentを入れ込んでみる。
import FormApp from '/components/ReflectForm/FormApp'; storiesOf('Original', module) .add('initialized', () => ( <FormApp onClick={action('clicked')} value={""} /> )) .add('with text', () => ( <FormApp onClick={action('clicked')} value={"default value setted"} /> ));
うん、表示された。ただし入力を反映してくれないけど。
ああなるほど。addDecoratorというのを使ってwrapしてあげるのか。そこでstoreを定義する、と。
ちょっとやってみよう。……できるか?
Reduxとの組み合わせ
一応reduxでdecorateしてみた。
import React from 'react'; import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; import { Provider } from 'react-redux'; import createSagaMiddleware from 'redux-saga' import { storiesOf, action } from '@kadira/storybook'; import { namespace as ReflectFormNamespace, State as ReflectFormState, Reducer as ReflectFormReducer, Container as ReflectFormContainer } from '/components/ReflectForm'; const reducers = { [ReflectFormNamespace]: ReflectFormReducer, }; const initialState = { [ReflectFormNamespace]: ReflectFormState, }; const store = createStore( combineReducers(reducers), initialState, window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() ); storiesOf(ReflectFormNamespace, module) .addDecorator((getStory) => ( <Provider store={store}> { getStory() } </Provider> )) .add('initialized', () => ( <ReflectFormContainer onClick={action('clicked')} value="" /> )) .add('with text', () => ( <ReflectFormContainer onClick={action('clicked')} value="default value setted" /> ));
動きはしたけどいくつか問題が。
storybookのaction loggerに何も出てこなくなったとか、初期値の設定がうまく通らないとか(というかredux使うようにした以上初期値の設定ってこれじゃうまくいかないんじゃね?)
action loggerに表示させたいならたぶんactionのpropertyをstorybookのそれで置き換えてあげないといけない。 とするとやっぱりこれはreact componentの検証用なんだなーきっと。
File配置の整理
とりあえずidnex.js
を/src/root/story.jsx
として保存し直す。設定も修正。
使うとしたら、今はContainer単位でDirectory作ってるから、そこにstory.jsxというのを保存して、それをimportする感じかなあ。これならFormの名前の衝突とか気にしないで済むし。
一つをこうして保存する。
import React from 'react'; import { storiesOf, action } from '@kadira/storybook'; import FormApp from './FormApp'; export default storiesOf('Get Form', module) .add('initialized', () => ( <FormApp onClick={action('clicked')} value="" /> )) .add('with text', () => ( <FormApp onClick={action('clicked')} value="default value setted"/> ));
でstory.jsxでこうimportする
import ReflectForm from '/components/ReflectForm/story';
これは動いた。でももう一つ、GetFormに同じようにstoryを置いて、
import ReflectForm from '/components/ReflectForm/story'; import GetForm from '/components/GetForm/story';
こうしたらerrorになった。Module not found: Error: Cannot resolve 'file' or 'directory'
だそうな。
んで、どう使っていこうか
componentのparts検証目的でstorybookは入れ込むことにする。
で、componentの本番に近い動作確認(local開発用)にはhtmlのtemplateを使ったものを試してみる。
最後にjsが出来たら本番環境への入れ込みをいれる、というような開発工程を想定中。
ということでまずはstorybookをなんとか使えるようにする。具体的にはimport周り。
Reflect Formだけなら機能する。 Get Formを追加するとerror。Reflect Formをcomment outしてGet Formだけにしてもerror。なので単にReflect Formのimportができてない。
Module not found: Error: Cannot resolve 'file' or 'directory' /XXXX/src/components/GetForm/story.jsx in /XXXX/src/root
そう言われても。lsするとちゃんとあるんだよなあそこに。
storybookのindexとしているjsxではなく、各containerのstorybookをstorybookのconfigから直接呼び出してみようか。
これも弾かれる。
これを参考に自動探索させてみた。これも弾かれる。でも自動探索させるのは楽でいいのでこれを使おう。
あ、いやできた。package.jsonでのcommand定義の設定がうまくいってなかった模様。書き直したら通った。
ばかめー。
結局こんな感じにした。
% cat ./.storybook/config.js import { configure } from '@kadira/storybook'; const req = require.context('../src', true, /story\.jsx*$/) function loadStories() { req.keys().forEach(req) } configure(loadStories, module);
こうすれば各containerの中にstory.jsxを配置するだけで機能する。test codeのようにcontainerの中に置いておけば検証に使えるのだからこれでよし。
serverの設定修正
portをそれぞれ以下に修正
- webpack-dev-server 3000
- json-server 3001
- storybook 3002
CSSの取り扱い
cssはどうしようかな。普通はstate変えたらそれに併せてcssが変化して描画が変わるみたいな事もあるんだけど。
jsへの埋め込み
なるほど。元々cssの取り扱いが面倒になったからjsの中に入れ込んでしまうと言う発想になったのか。えー。……うん。まあ。なしではないけど。
でもそうだなあ。componentのためだけのstyleだったら、そこに入れ込んでしまう方が楽かもしれない。これは実装するときに留意しておこう。
考察中
ここが最後の課題だなあ。
cssを考慮したとき、component levelでの話と、page level(template level)での話があって、その上にBootstrapやAdminLTEのようなframeworkを入れ込むとなると、どうしたものかという話になる。
だからこう、cssにも粒度があるわけだ。
component levelのstyleはcomponentの中に入れ込んでしまいたいし、page levelのstyleは今まで書いたようにskin-black-dark.lessとかにいれこみたい。
これをうまく切り分ける形でcssを入れ込み直すしかないだろうなあ。
幸いにしてBootstrapもAdminLTEもlessでそのあたり分割されている。
あーでも。page全体でskinとして定義されていることを考えると、これ、下手に分離できなくない?
このあたりは既存のframeworkを使う上で悩むところだなあ。そもそもreactのようなものが想定されていないから組み込みづらい。 bootstrapはまあまだなんとかなるにしても、だ。
skinという概念で包括的に定義されているAdminLTEがつらい。skinの粒度だけtemplateよりも大きな範囲にある。
ううーん。なんとかcomponentで分けることが出来たとしても、skinはskinでいるから結局storybookで外部cssを読み込む必要はあるのか。
headerだけ別途定義できそう
ほうほう。head.htmlを定義できる、と。これで行くか。
で、静的なroutingを定義したい場合は、
https://storybooks.js.org/docs/react-storybook/configurations/serving-static-files
できた。これで3rd party libraryの読み込みはいける。
都度自前で生成しているcssなど
あとは都度自前で生成する必要のあるやつだ。やつだが……うーん。
いっそ並行してwebpack回してしまい、そこへstaticなroutingを張るか。
できた。/config/webpack-less.config.js
という名前でlessだけ生成するwebpackのconfigを生成し、storybookには/distに対してroutingした。
jsも同様にroutingした。
このままいけばstorybookで全部の実験が出来そうだな。
3rd party cssを反映したComponentを作ってみる
とりあえずBootstrapとAdminLTEの一部のclassを設定してみる。
が、domにclass=で指定していたけど、反映されず。
jsxだとclasNameとしないと駄目だった模様。
以下で反映されたことを確認。
import React from 'react'; import { storiesOf, action } from '@kadira/storybook'; import FormApp from './FormApp'; export default storiesOf('Reflect Form', module) .addDecorator((story) => ( <div className="wrapper"> <div className="content-wrapper"> <section className="content"> <div className="row"> <div className="col-md-1 col-sm-1 col-xs-1"></div> <div className="col-md-10 col-sm-10 col-xs-10"> { story() } </div> <div className="col-md-1 col-sm-1 col-xs-1"></div> </div> </section> </div> </div> )) .add('initialized', () => ( <FormApp onClick={action('clicked')} value="" /> )) .add('with text', () => ( <FormApp onClick={action('clicked')} value="default value setted"/> ));
次にbody。それとできればこの手のdecoratorを一般化して反映したい。
https://storybooks.js.org/docs/react-storybook/addons/introduction
あー。storybookのconfigで定義すればいけそう。
できた。
bodyにいれていたclass、divでいれても機能したのでそれでよしとする。
あとは抽象化をもう少し工夫して、actionがうまく反映されない原因を調べたら一区切りとする。
htmlの生成は別にいいや。それこそ本番環境でやればいい。
とりあえずこれで行くことにした。
const CenterDecorator = (story) => ( <div className="skin-blue"> <section className="content"> { story() } </section> </div> );
……でも使い勝手が微妙。
結局comment outした。styleは実際componentを組みながら考えていこう。
あとはactionだけ。
Actionの取り扱い
あー。わかってきた。これ特定のstateが設定された時にはこのように表示されるよ、ということを確認するためのものなんだ。 あるいはreduxを使わなければcomponentの中にactionを定義していってそういう真似もできたけど、storeを外に定義した時点でstorybookの中でいじれるものじゃなくなる。
それを踏まえてどうするかちょっと考えよう。
こうなるとstorybookは単にいろんなstateをcomponentに入れて様子を見てみる道具という感じになる。unittestみたいに特定のstateを定義しておけるのが強み。
一方でstoreも意識した振る舞い、すなわちactionが入るような処理を見るのなら何かpageを作った方がいい。 で、これでlessを綺麗に分割できるならstorybookにも価値が生まれてくるんだけど、AdminLTEのせいでそれもやや難しい。ちくせう。
かといってpageを作ってしまうなら最初から埋め込んで全体のpageを作るということになり過程がまるっとすっ飛ばされる。たぶん最終的にはdebugしづらい。
うん。ここだな。どこの粒度でtestできるようにしておくと一番便利だろう。
さてどうするか。
tateとstore、すなわちpropsを固定したときにどういう表示がされるかみたい。これだけならstorybookで充分。
問題は3つ。
css周りがいただけないこと。けどそこはもう少しdecoratorを考えればなんとかなるかも。
json requestが発生するようなもの。redux使うことを前提としているからactionを入れ込めない。
動的に値が変化していくようなもの。これも上と同じ問題。
componentの検証で欲張りすぎているかもなあこれは。
さてどう割り切ろう。
- componentの振る舞いの確認 => storybook
- StoreやActionの振る舞いの確認 => 本番環境
まあこうかな。こうすれば
- serverでapiの作成
- componentの作成(viewの作成)
- containerの作成(redux部分の作成)
という風に作業を分割できる。
となるとstorybookでもう少しcssも本番にふさわしいものをいれて確かめたい。
storybook用のlibraryを入れるところを用意してそこからimportできるようにするか。
AdminLTEの組み込み
css周り
さて割り切れば話は早い。 要するにcssをどうするか、AdminLTEをどのように組み込むか、だ。
とりあえず固定のskinで扱うにはaddDecoratorを使えばいい。
const CenterDecorator = (story) => ( <div className="skin-blue"> <div className="wrapper"> <div className="content-wrapper"> <section className="content"> { story() } </section> </div> </div> </div> ); addDecorator(CenterDecorator);
けどこれだと実際使いづらいのだ。
ええい。css周りはまた今度なんとか切り分けよう。今は棚上げする。
boxの組み込み
とりあえず簡単なboxを入れてみた。表示はされたがcolorが微妙。やはりstyleか。 あとfont類もnodeにいれておくことにする。これも3rd partyか。
fontの入れ込み
npm install --save font-awesome ionicons
でinstall
makefileを修正して3rdpartyに追加
head.html
に組み込み。
とりあえずここまでは組み込めた。boxもcomponentにできた。ややparameter多いけど、今までlogicで組んでいた部分がずいぶん削れたので結果的には楽になったと思う。
reduxについて覚書
こうなるとredux部分も各componentのところにいれようとこだわらなくてもいいかもしれないな。現在の実装と少し変えるかも。
まあそれはまたあとで。
storybookのview部分の調整
AdminLTEの書式に沿ってclassをbodyにいれたい。StoryBookはiframeで区切って中にhtmlを定義しているからそこのbodyをいじれればいい。
bodyかー。jsの中からclass追加出来るかな。
できた。
const skin = "skin-blue"; const CenterDecorator = (story) => { const body = document.querySelector("body"); body.className=skin; return ( <div className="wrapper"> <div className="content-wrapper"> <section className="content"> { story() } </section> </div> </div> ); }; addDecorator(CenterDecorator);
skinの切り替え
AdminLTEのdemo.jsを見つつ、skinの切り替えもできるようになった。
const skin = "skin-blue"; const CenterDecorator = (story) => { const body = document.querySelector("body"); body.className=skin; return ( <div className="wrapper"> <div className="content-wrapper"> <section className="content"> { story() } </section> <ul className="list-unstyled clearfix"> <li><a onClick={()=>(body.className='skin-blue')} href="javascript:void(0);" className="clearfix full-opacity-hover"><p className="text-center no-margin">Blue</p></a></li> <li><a onClick={()=>(body.className='skin_black_dark')} href="javascript:void(0);" className="clearfix full-opacity-hover"><p className="text-center no-margin">Black</p></a></li> </ul> </div> </div> ); }; addDecorator(CenterDecorator);
これをもう少し綺麗にしていく。
最終的にどうなったか
/.storybook/head.html
はこんな感じ。
<link rel="stylesheet" href="/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" href="/fontawesome/css/font-awesome.min.css"> <link rel="stylesheet" href="/ionicons/css/ionicons.min.css"> <link rel="stylesheet" href="/adminlte/plugins/jvectormap/jquery-jvectormap-1.2.2.css"> <link rel="stylesheet" href="/adminlte/css/AdminLTE.min.css"> <link rel="stylesheet" href="/css/skin_all.css"> <script src="/adminlte/plugins/jQuery/jquery-2.2.3.min.js"></script> <script src="/bootstrap/js/bootstrap.min.js"></script> <script src="/adminlte/plugins/fastclick/fastclick.js"></script> <script src="/adminlte/js/app.min.js"></script> <script src="/adminlte/plugins/sparkline/jquery.sparkline.min.js"></script> <script src="/adminlte/plugins/jvectormap/jquery-jvectormap-1.2.2.min.js"></script> <script src="/adminlte/plugins/jvectormap/jquery-jvectormap-world-mill-en.js"></script> <script src="/adminlte/plugins/slimScroll/jquery.slimscroll.min.js"></script> <script src="/adminlte/plugins/chartjs/Chart.min.js"></script>
で、/.storybook/config.js
はこうなった。
import React from 'react'; import { configure, addDecorator } from '@kadira/storybook'; /////////////////////////////////////////////////////////////// // story.jsx const req = require.context('../src', true, /story\.jsx*$/) function loadStories() { req.keys().forEach(req) } /////////////////////////////////////////////////////////////// // Decorator function addSkinList(body,skin,name){ const li_style = { display:'table-cell', }; return ( <li style={li_style}> <button className='btn btn-block btn-default btn-sm' onClick={()=>(body.className=`${skin} layout-top-nav`)}>{name}</button> </li> ) } const CenterDecorator = (story) => { const html = document.querySelector("html"); html.style.cssText='height: 100%;'; const body = document.querySelector("body"); body.className="skin-blue layout-top-nav"; body.style.cssText='height: 100%;'; const root = document.querySelector("div#root"); root.style.cssText='height: 100%;'; return ( <div className="wrapper"> <ul style={{display:'table'}}> <li style={{display:'table-cell','background-color':'#ffffff', padding:"0 10px 0"}}>Skins: </li> {addSkinList(body,'skin-blue', 'Blue')} {addSkinList(body,'skin-black-dark', 'Black')} </ul> { story() } </div> ); }; /////////////////////////////////////////////////////////////// // Run addDecorator(CenterDecorator); configure(loadStories, module);
で、肝心のcomponentだけど、
/src/components/Box/story.jsx
はこう。
import React from 'react'; import { storiesOf, action } from '@kadira/storybook'; import Box from './Box'; export default storiesOf('Box', module) .addDecorator((story) => ( <div className="content-wrapper"> <section className="content"> { story() } </section> </div> )) .add('default', () => ( <Box type="default" id="test_box" collapse="no" title="Title" body={<div> body text </div>} footer={<div> footer text</div>}/> )) .add('collapsed box', () => ( <Box type="default" id="test_box" title="Title" collapse="yes" body={<div> body text </div>}/> )) .add('laoding box', () => ( <Box type="default" id="test_box" title="Title" loading={true} body={<div> body text </div>}/> )) .add('primary box', () => ( <Box type="primary" id="test_box" title="Title" body={<div> body text </div>}/> ));
/src/components/Box/Box.jsx
はこう。
import React from 'react'; import PropTypes from 'prop-types'; import CollapseBtn from './CollapseBtn' import CloseBtn from './CloseBtn' import Footer from './Footer' import LoadingLayer from './LoadingLayer' export default class Box extends React.Component { boxClass(){ let result = ['box', 'box-border-change-effect']; result.push(`box-${this.props.type}`); if(this.props.collapse == "yes") result.push('collapsed-box'); return result.join(' '); } render() { const boxClass = this.boxClass(); return ( <div className={boxClass} id={this.props.id}> <div className="box-header"> <h3 className="box-title">{this.props.title}</h3> <div className="box-tools pull-right"> <CollapseBtn mode={this.props.collapse} /> <CloseBtn mode={true} /> </div> </div> <div className="box-body"> {this.props.body} </div> <Footer body={this.props.footer} /> <LoadingLayer run={this.props.loading}/> </div> ); } } Box.propTypes = { id: PropTypes.string, title: PropTypes.string, type: PropTypes.string.isRequired, collapse: PropTypes.string, loading: PropTypes.bool, footer: PropTypes.node, body: PropTypes.node, };
その他呼び出しているものについてはあとでGitに公開するのでそちらを参照のこと。
しかしこうなってくるとAdminLTEのdemo.jsは自分用に直した方がいいなあ……。大部分reactのcomponentの中の処理に置き換えられそうだし。
今はひとまずこれで一区切りとして、ここから必要なcomponentを組んだり、serverへの入れ込みを手直ししたりしていくことにする。
次回に続く
今回で決着にしようと思ったけど、make 3rdparty
というのもなんかいやなのでmake vendor
となるようにしたり、Destinationも/pages/vendor
とかに変えたり、あとless周りとかもいじっていた理想、3rd party moduleの組み込みでいろいろ知見があったりしたので、もうちょっとだけ続くんじゃ。