このパートでは、アプリに 編集 機能を追加します。以下はその完成した様子です。
ステップ 1: 「編集」ボタンの追加
「編集」ボタンがなければ、「編集」フォームもありませんので、PagesList
コンポーネントにボタンを追加するところから始めます。
import { Button } from '@wordpress/components';
import { decodeEntities } from '@wordpress/html-entities';
const PageEditButton = () => (
<Button variant="primary">
Edit
</Button>
)
function PagesList( { hasResolved, pages } ) {
if ( ! hasResolved ) {
return <Spinner />;
}
if ( ! pages?.length ) {
return <div>No results</div>;
}
return (
<table className="wp-list-table widefat fixed striped table-view-list">
<thead>
<tr>
<td>Title</td>
<td style={{width: 120}}>Actions</td>
</tr>
</thead>
<tbody>
{ pages?.map( ( page ) => (
<tr key={page.id}>
<td>{ decodeEntities( page.title.rendered ) }</td>
<td>
<PageEditButton pageId={ page.id } />
</td>
</tr>
) ) }
</tbody>
</table>
);
}
PagesList
の唯一の変更点は、ラベル「Actions」のカラムの追加です。
ステップ 2: 「編集」フォームの表示
このボタンは見た目はきれいですが、まだ何もできません。編集フォームを表示するには、まず、フォームを用意する必要があります。
import { Button, TextControl } from '@wordpress/components';
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
return (
<div className="my-gutenberg-form">
<TextControl
value=''
label='Page title:'
/>
<div className="form-buttons">
<Button onClick={ onSaveFinished } variant="primary">
Save
</Button>
<Button onClick={ onCancel } variant="tertiary">
Cancel
</Button>
</div>
</div>
);
}
そして、ボタンと、作成したフォームの表示を結びつけます。このチュートリアルはウェブデザインに焦点を当てたものではありませんので、最も少ないコードで済む Modal
コンポーネントを使用して2つを接続します。PageEditButton
を更新します。
import { Button, Modal, TextControl } from '@wordpress/components';
function PageEditButton({ pageId }) {
const [ isOpen, setOpen ] = useState( false );
const openModal = () => setOpen( true );
const closeModal = () => setOpen( false );
return (
<>
<Button
onClick={ openModal }
variant="primary"
>
Edit
</Button>
{ isOpen && (
<Modal onRequestClose={ closeModal } title="Edit page">
<EditPageForm
pageId={pageId}
onCancel={closeModal}
onSaveFinished={closeModal}
/>
</Modal>
) }
</>
)
}
「編集」ボタンをクリックすると、次のモーダルが表示されます。
いいですね。これで基本的なユーザーインターフェイスができました。
ステップ 3: フォームへのページの詳細の挿入
EditPageForm
には、現在編集中のページのタイトルを表示するつもりですが、見て分かるように page
ではなく、pageId
プロパティしか受け取りません。でも大丈夫。Gutenberg Data を使えば、どのコンポーネントからでも簡単にエンティティレコードにアクセスできます。
この場合、getEntityRecord
セレクタを使用する必要があります。MyFirstApp
の getEntityRecords
呼び出しのおかげで、レコードのリストはすでに利用可能で、追加の HTTP リクエストは必要ありません。キャッシュされたレコードをすぐに取得できます。
ブラウザの開発ツールの中でも試すことができます。
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', 9 ); // 「9」は実際のページ ID で置換する
EditPageForm
を更新します。
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
const page = useSelect(
select => select( coreDataStore ).getEntityRecord( 'postType', 'page', pageId ),
[pageId]
);
return (
<div className="my-gutenberg-form">
<TextControl
label='Page title:'
value={ page.title.rendered }
/>
{ /* ... */ }
</div>
);
}
以下のようになります。
ステップ 4: ページのタイトルフィールドを編集可能に
「Page title」フィールドにはひとつ問題があります。編集できないのです。固定された value
を受け取りますが、タイプしても更新されません。onChange
ハンドラが必要です。
これと似たパターンを他の React アプリで見たかもしれません。これは Controlled Component (制御されたコンポーネント) と呼ばれます。
function VanillaReactForm({ initialTitle }) {
const [title, setTitle] = useState( initialTitle );
return (
<TextControl
value={ title }
onChange={ setTitle }
/>
);
}
Gutenberg Data のエンティティレコードの更新も同様ですが、setTitle
を使用して、ローカル (コンポーネントレベル) のステートに保存する代わりに、editEntityRecord
アクションを使用して、Redux ステートに更新を保存します。以下は、ブラウザの開発ツールで試す方法です。
// editEntityRecord を呼ぶには正しいページ ID が必要です。そこでまず、getEntityRecords を使用して何か1つ取得します。
const pageId = wp.data.select( 'core' ).getEntityRecords( 'postType', 'page' )[0].id;
// タイトルを更新します。
wp.data.dispatch( 'core' ).editEntityRecord( 'postType', 'page', pageId, { title: 'updated title' } );
この時点で、editEntityRecord
は、 useState
より何が良いのか ? と思うかもしれません。それは、他の方法では得られない、いくつかの機能が提供されるためです。
まず、データを取得するのと同じように簡単に変更を保存でき、すべてのキャッシュが正しく更新されることが保証されます。
次に、 editEntityRecord
によって適用された変更は、 undo
や redo
アクションによって簡単に元に戻せます。
最後に、変更は Redux ステート内にあるため、「グローバル」であり、他のコンポーネントからもアクセスできます。例えば、PagesList
に現在編集中のタイトルを表示できます。
最後の点については、先ほど更新したエンティティレコードに getEntityRecord
を使用してアクセスして、何が起きるか見てみます。
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', pageId ).title
編集が反映されていません。どうなっているのでしょう ?
<PagesList />
は getEntityRecord()
が返すデータをレンダリングします。もし getEntityRecord()
が更新されたタイトルを反映しているなら、ユーザーが TextControl
に入力したすべての内容は、すぐに <PagesList />
の中に表示されることになりますが、これは私たちが望んでいることではありません。ユーザーが保存を決定するまで、編集内容はフォームの外に漏れるべきではありません。
Gutenberg Data では、この問題を解決するために、「エンティティレコード」と「編集されたエンティティレコード」を区別します。「エンティティレコード」は API からのデータを反映し、ローカルでの編集は無視します。一方、「編集されたエンティティレコード」は、その上にローカルでの編集を適用します。どちらも Redux のステートとして、同時に存在します。
getEditedEntityRecord
を呼ぶと何が起きるかを見ます。
wp.data.select( 'core' ).getEditedEntityRecord( 'postType', 'page', pageId ).title
// "updated title"
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', pageId ).title
// { "rendered": "<original, unchanged title>", "raw": "..." }
見たように、「エンティティレコード」の title
はオブジェクトですが、「編集されたエンティティレコード」の title
は文字列です。
これは偶然ではありません。title
、excerpt
、content
などのフィールドは ショートコード や ダイナミックブロック を含む場合あります。つまり、これらのフィールドはサーバー上でのみレンダリング可能です。このようなフィールドに対して、REST API は raw
マークアップ および rendered
文字列の両方を公開します。例えば、ブロックエディタでは、 content.rendered
をビジュアルプレビューとして、 content.raw
をコードエディタの入力として使用できます。
では、なぜ「編集されたエンティティレコード」の content
は文字列なのでしょうか ? Javascript は任意のブロックマークアップを適切にレンダリングできないため、rendered
部分を除いた、raw
マークアップだけを保存します。そして、それが文字列であるため、フィールド全体も文字列になります。
これで EditPageForm
を更新できるようになりました。セレクタへのアクセスに useSelect
を使用するのと同じように、アクションへのアクセスには、useDispatch
フックを使用できます。
import { useDispatch } from '@wordpress/data';
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
const page = useSelect(
select => select( coreDataStore ).getEditedEntityRecord( 'postType', 'page', pageId ),
[ pageId ]
);
const { editEntityRecord } = useDispatch( coreDataStore );
const handleChange = ( title ) => editEntityRecord( 'postType', 'page', pageId, { title } );
return (
<div className="my-gutenberg-form">
<TextControl
label="Page title:"
value={ page.title }
onChange={ handleChange }
/>
<div className="form-buttons">
<Button onClick={ onSaveFinished } variant="primary">
Save
</Button>
<Button onClick={ onCancel } variant="tertiary">
Cancel
</Button>
</div>
</div>
);
}
editEntityRecord
アクションを介して編集を追跡するために、onChange
ハンドラを追加しました。また、常に page.title
が変更を反映するよう、セレクタを getEditedEntityRecord
に変更しました。
ここまでで以下のようになります。
ステップ 5: フォームデータの保存
ページのタイトルを編集できるようになったので、次に保存できることを確認します。Gutenberg Data では、saveEditedEntityRecord
アクションを使用し てWordPress REST API に変更を保存します。リクエストを送信し、結果を処理し、Redux のステート内のキャッシュされたデータを更新します。
ブラウザの開発ツールで試すことのできるサンプルです。
// 「9」を実際のページ ID で置換する
wp.data.dispatch( 'core' ).editEntityRecord( 'postType', 'page', 9, { title: 'updated title' } );
wp.data.dispatch( 'core' ).saveEditedEntityRecord( 'postType', 'page', 9 );
上のスニペットは、新しいタイトルを保存します。以前とは異なり、getEntityRecord
は、更新されたタイトルを反映します。
// 「9」を実際のページ ID で置換する
wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', 9 ).title.rendered
// "updated title"
REST API リクエストが終了すると、保存された変更を反映するために、エンティティレコードが更新されます。
以下は、動作する「保存」ボタンを持った EditPageForm
です。
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
// ...
const { saveEditedEntityRecord } = useDispatch( coreDataStore );
const handleSave = () => saveEditedEntityRecord( 'postType', 'page', pageId );
return (
<div className="my-gutenberg-form">
{/* ... */}
<div className="form-buttons">
<Button onClick={ handleSave } variant="primary">
Save
</Button>
{/* ... */}
</div>
</div>
);
}
動作はしますが、まだひとつ修正すべき点があります。onSaveFinished
を呼び出していないため、フォームモーダルが自動的に閉じないのです。幸いなことに、saveEditedEntityRecord
は、保存操作が終了すると解決されるプロミスを返します。これを EditPageForm
で利用します。
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
// ...
const handleSave = async () => {
await saveEditedEntityRecord( 'postType', 'page', pageId );
onSaveFinished();
};
// ...
}
ステップ 6: エラー処理
ここまで楽観的に、「保存」操作は必ず成功するものと考えてきました。しかし、残念ながら様々な理由により失敗します。
- ウェブサイトがダウンした
- 更新が正しくなかった
- 処理中に誰かがページを削除した
エラーが発生したことをユーザーに伝えるるには、2つの調整が必要です。まず、更新が失敗した場合に、フォームモーダルを閉じないようにします。saveEditedEntityRecord
が返すプロミスは、更新が実際にうまくいった場合のみ、更新されたレコードで解決されます。何か問題が発生した場合は、空の値で解決されます。これを利用して、モーダルを開いたままにしておきます。
function EditPageForm( { pageId, onSaveFinished } ) {
// ...
const handleSave = async () => {
const updatedRecord = await saveEditedEntityRecord( 'postType', 'page', pageId );
if ( updatedRecord ) {
onSaveFinished();
}
};
// ...
}
素晴らしい。次に、エラーメッセージを表示します。失敗の詳細は getLastEntitySaveError
セレクタを使用して取得できます。
// 「9」を実際のページ ID で置換する
wp.data.select( 'core' ).getLastEntitySaveError( 'postType', 'page', 9 )
以下に、EditPageForm
内で使用する方法を示します。
function EditPageForm( { pageId, onSaveFinished } ) {
// ...
const { lastError, page } = useSelect(
select => ({
page: select( coreDataStore ).getEditedEntityRecord( 'postType', 'page', pageId ),
lastError: select( coreDataStore ).getLastEntitySaveError( 'postType', 'page', pageId )
}),
[ pageId ]
)
// ...
return (
<div className="my-gutenberg-form">
{/* ... */}
{ lastError ? (
<div className="form-error">
Error: { lastError.message }
</div>
) : false }
{/* ... */}
</div>
);
}
素晴らしい。EditPageForm
は、完全にエラーを把握しました。
実際にエラーメッセージを表示してみましょう。無効な更新を行い、失敗させます。投稿のタイトルではエラーを起こしにくいため、代わりに date
プロパティを -1
に設定します。これで確実にバリデーションエラーが発生します。
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
// ...
const handleChange = ( title ) => editEntityRecord( 'postType', 'page', pageId, { title, date: -1 } );
// ...
}
ページを更新し、フォームを開いてタイトルを変更し、保存を押すと、次のようなエラーメッセージが表示されるはずです。
素晴らしい。変更した handleChange
を変更前のバージョンに戻して、次のステップに進みます。
ステップ 7: ステータスインジケータ
このフォームにはもうひとつ問題があります。視覚的なフィードバックがありません。これでは、「保存」ボタンが正しく機能したかどうか、フォームが消えるか、エラーメッセージが表示されるまで、はっきりしません。
これをクリアにするため、「保存中」と「変更が検出されない」の2つの状態をユーザーに伝えましょう。関連するセレクタは isSavingEntityRecord
と hasEditsForEntityRecord
です。getEntityRecord
とは異なり、これらは HTTP リクエストを発行せず、現在のエンティティレコードの状態を返すのみです。
EditPageForm
の中で使用します。
function EditPageForm( { pageId, onSaveFinished } ) {
// ...
const { isSaving, hasEdits, /* ... */ } = useSelect(
select => ({
isSaving: select( coreDataStore ).isSavingEntityRecord( 'postType', 'page', pageId ),
hasEdits: select( coreDataStore ).hasEditsForEntityRecord( 'postType', 'page', pageId ),
// ...
}),
[ pageId ]
)
}
isSaving
と hasEdits
を使用して、保存中の場合はスピナーを表示し、編集がない場合は「保存」ボタンをグレイアウトします。
function EditPageForm( { pageId, onSaveFinished } ) {
// ...
return (
// ...
<div className="form-buttons">
<Button onClick={ handleSave } variant="primary" disabled={ ! hasEdits || isSaving }>
{ isSaving ? (
<>
<Spinner/>
Saving
</>
) : 'Save' }
</Button>
<Button
onClick={ onCancel }
variant="tertiary"
disabled={ isSaving }
>
Cancel
</Button>
</div>
// ...
);
}
注意: 編集がない場合、および、現在保存中の場合は「保存」ボタンを無効にします。これは、ユーザーが誤ってボタンを2回押すのを防ぐためです。
また、保存の中断は @wordpress/data
ではサポートされていないため、条件付きで「キャンセル」ボタンも無効にしています。
以下に動作の様子を示します。
すべてをひとつに
すべての部品が揃いました、素晴らしい。以下は、この章で作成したすべてをひとつにまとめたものです。
import { useDispatch } from '@wordpress/data';
import { Button, Modal, TextControl } from '@wordpress/components';
function PageEditButton( { pageId } ) {
const [ isOpen, setOpen ] = useState( false );
const openModal = () => setOpen( true );
const closeModal = () => setOpen( false );
return (
<>
<Button onClick={ openModal } variant="primary">
Edit
</Button>
{ isOpen && (
<Modal onRequestClose={ closeModal } title="Edit page">
<EditPageForm
pageId={ pageId }
onCancel={ closeModal }
onSaveFinished={ closeModal }
/>
</Modal>
) }
</>
);
}
function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
const { page, lastError, isSaving, hasEdits } = useSelect(
( select ) => ( {
page: select( coreDataStore ).getEditedEntityRecord( 'postType', 'page', pageId ),
lastError: select( coreDataStore ).getLastEntitySaveError( 'postType', 'page', pageId ),
isSaving: select( coreDataStore ).isSavingEntityRecord( 'postType', 'page', pageId ),
hasEdits: select( coreDataStore ).hasEditsForEntityRecord( 'postType', 'page', pageId ),
} ),
[ pageId ]
);
const { saveEditedEntityRecord, editEntityRecord } = useDispatch( coreDataStore );
const handleSave = async () => {
const savedRecord = await saveEditedEntityRecord( 'postType', 'page', pageId );
if ( savedRecord ) {
onSaveFinished();
}
};
const handleChange = ( title ) => editEntityRecord( 'postType', 'page', page.id, { title } );
return (
<div className="my-gutenberg-form">
<TextControl
label="Page title:"
value={ page.title }
onChange={ handleChange }
/>
{ lastError ? (
<div className="form-error">Error: { lastError.message }</div>
) : (
false
) }
<div className="form-buttons">
<Button
onClick={ handleSave }
variant="primary"
disabled={ ! hasEdits || isSaving }
>
{ isSaving ? (
<>
<Spinner/>
Saving
</>
) : 'Save' }
</Button>
<Button
onClick={ onCancel }
variant="tertiary"
disabled={ isSaving }
>
Cancel
</Button>
</div>
</div>
);
}