編集フォームの構築

このパートでは、アプリに 編集 機能を追加します。以下はその完成した様子です。

ステップ 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」のカラムの追加です。

Top ↑

ステップ 2: 「編集」フォームの表示

このボタンは見た目はきれいですが、まだ何もできません。編集フォームを表示するには、まず、フォームを用意する必要があります。

import { Button, TextControl } from '@wordpress/components';
export 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>
			) }
		</>
	)
}

「編集」ボタンをクリックすると、次のモーダルが表示されます。

いいですね。これで基本的なユーザーインターフェイスができました。

Top ↑

ステップ 3: フォームへのページの詳細の挿入

EditPageForm には、現在編集中のページのタイトルを表示するつもりですが、見て分かるように page ではなく、pageId プロパティしか受け取りません。でも大丈夫。Gutenberg Data を使えば、どのコンポーネントからでも簡単にエンティティレコードにアクセスできます。

この場合、getEntityRecord セレクタを使用する必要があります。MyFirstApp の getEntityRecords 呼び出しのおかげで、レコードのリストはすでに利用可能で、追加の HTTP リクエストは必要ありません。キャッシュされたレコードをすぐに取得できます。

ブラウザの開発ツールの中でも試すことができます。

wp.data.select( 'core' ).getEntityRecord( 'postType', 'page', 9 );  // 「9」は実際のページ ID で置換する

EditPageForm を更新します。

export 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 }
			/>
			{ /* ... */ }
		</div>
	);
}

以下のようになります。

Top ↑

ステップ 4: ページのタイトルフィールドを編集可能に

「Page title」フィールドにはひとつ問題があります。編集できないのです。固定された value を受け取りますが、タイプしても更新されません。onChange ハンドラが必要です。

これと似たパターンを他の React アプリで見たかもしれません。これは Controlled Component (制御されたコンポーネント) と呼ばれます。

export 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 は文字列です。

これは偶然ではありません。titleexcerptcontent などのフィールドは ショートコード や ダイナミックブロック を含む場合あります。つまり、これらのフィールドはサーバー上でのみレンダリング可能です。このようなフィールドに対して、REST API は raw マークアップ および rendered 文字列の両方を公開します。例えば、ブロックエディタでは、 content.rendered をビジュアルプレビューとして、 content.raw をコードエディタの入力として使用できます。

では、なぜ「編集されたエンティティレコード」の content は文字列なのでしょうか ? Javascript は任意のブロックマークアップを適切にレンダリングできないため、rendered 部分を除いた、rawマークアップだけを保存します。そして、それが文字列であるため、フィールド全体も文字列になります。

これで EditPageForm を更新できるようになりました。セレクタへのアクセスに useSelect を使用するのと同じように、アクションへのアクセスには、useDispatch フックを使用できます。

import { useDispatch } from '@wordpress/data';

export 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 に変更しました。

ここまでで以下のようになります。

Top ↑

ステップ 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 です。

export 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 で利用します。

export function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
	// ...
	const handleSave = async () => {
		await saveEditedEntityRecord( 'postType', 'page', pageId );
		onSaveFinished();
	};
	// ...
}

Top ↑

ステップ 6: エラー処理

ここまで楽観的に、「保存」操作は必ず成功するものと考えてきました。しかし、残念ながら様々な理由により失敗します。

  • ウェブサイトがダウンした
  • 更新が正しくなかった
  • 処理中に誰かがページを削除した

エラーが発生したことをユーザーに伝えるるには、2つの調整が必要です。まず、更新が失敗した場合に、フォームモーダルを閉じないようにします。saveEditedEntityRecord が返すプロミスは、更新が実際にうまくいった場合のみ、更新されたレコードで解決されます。何か問題が発生した場合は、空の値で解決されます。これを利用して、モーダルを開いたままにしておきます。

export 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 内で使用する方法を示します。

export 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 (
		<>
			{/* ... */}
			{ lastError ? (
				<div className="form-error">
					Error: { lastError.message }
				</div>
			) : false }
			{/* ... */}
		</>
	);
}

素晴らしい。EditPageForm は、完全にエラーを把握しました。

実際にエラーメッセージを表示してみましょう。無効な更新を行い、失敗させます。投稿のタイトルではエラーを起こしにくいため、代わりに date プロパティを -1 に設定します。これで確実にバリデーションエラーが発生します。

export function EditPageForm( { pageId, onCancel, onSaveFinished } ) {
	// ...
	const handleChange = ( title ) => editEntityRecord( 'postType', 'page', pageId, { title, date: -1 } );
	// ...
}

ページを更新し、フォームを開いてタイトルを変更し、保存を押すと、次のようなエラーメッセージが表示されるはずです。

素晴らしい。変更した handleChange を変更前のバージョンに戻して、次のステップに進みます。

Top ↑

ステップ 7: ステータスインジケータ

このフォームにはもうひとつ問題があります。視覚的なフィードバックがありません。これでは、「保存」ボタンが正しく機能したかどうか、フォームが消えるか、エラーメッセージが表示されるまで、はっきりしません。

これをクリアにするため、「保存中」と「変更が検出されない」の2つの状態をユーザーに伝えましょう。関連するセレクタは isSavingEntityRecord と hasEditsForEntityRecord です。getEntityRecord とは異なり、これらは HTTP リクエストを発行せず、現在のエンティティレコードの状態を返すのみです。

EditPageForm の中で使用します。

export 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 を使用して、保存中の場合はスピナーを表示し、編集がない場合は「保存」ボタンをグレイアウトします。

export 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 ではサポートされていないため、条件付きで「キャンセル」ボタンも無効にしています。

以下に動作の様子を示します。

Top ↑

すべてをひとつに

すべての部品が揃いました、素晴らしい。以下は、この章で作成したすべてをひとつにまとめたものです。

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>
			) }
		</>
	);
}

export 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>
	);
}

Top ↑

次のステップ

原文

最終更新日: