تا اینجا کامپوننت صفحه بندی را به همراه اعمال آن به لیست نمایش داده شده، پیاده سازی کردیم. در ادامه میخواهیم لیست ژانرهای سینمایی را که در فایل fakeGenreService.js تعریف شدهاند:
export const genres = [
{ _id: "5b21ca3eeb7f6fbccd471818", name: "Action" },
{ _id: "5b21ca3eeb7f6fbccd471814", name: "Comedy" },
{ _id: "5b21ca3eeb7f6fbccd471820", name: "Thriller" }
];
export function getGenres() {
return genres.filter(g => g);
}
توسط list-groupهای بوت استرپی، در کنار صفحه نمایش داده و سپس به ازای هر گروه انتخابی توسط کاربر، فیلمهای مرتبط با آن گروه را فیلتر کرده و نمایش دهیم.
بررسی ساختار کامپوننت ListGroup
شبیه به کامپوننت صفحه بندی که در قسمت قبل ایجاد کردیم، میخواهیم کامپوننت ListGroup نیز به طور کامل از اشیاء movie مستقل باشد؛ تا در آینده بتوان از آن در جاهای دیگری نیز استفاده کرد. به همین جهت فایل جدید src\components\common\listGroup.jsx را ایجاد کرده و سپس با استفاده از میانبرهای imrc و cc در VSCode، ساختار ابتدایی این کامپوننت را ایجاد میکنیم. هرچند میتوان این کامپوننت را به صورت «Stateless Functional Component» نیز طراحی کرد؛ چون state و متد دیگری بجز render نخواهد داشت و تمام اطلاعات خودش را از والد خود دریافت میکند.
سپس به کامپوننت movies مراجعه کرده و این کامپوننت خالی را import میکنیم:
import ListGroup from "./common/listGroup";
پس از آن به متد رندر کامپوننت movies مراجعه کرده و با اضافه کردن یک row بوت استرپی دو ستونی، قصد داریم کامپوننت لیست فیلمها را در ستون اول این ردیف نمایش دهیم. به همین جهت المان آنرا در این محل قرار میدهیم تا بتوانیم اینترفیس ابتدایی آنرا پیش از پیاده سازی آن، طراحی کنیم.
برای این منظور ابتدا React.Fragment موجود را با یک div با "className="row جایگزین میکنیم. سپس داخل این row، دو ستون را تعریف خواهیم کرد که در اولی، المان جدید ListGroup قرار میگیرد و در دومی، مابقی عناصری که تاکنون اضافه کردهایم؛ مانند جدول، صفحه بندی و نمایش تعداد آیتمها:
return (
<div className="row">
<div className="col-2">
<ListGroup />
</div>
<div className="col">
...
</div>
</div>
);
این listGroup، حداقل نیاز به لیست آیتمهایی را دارد که باید نمایش دهد. این لیست نیز از fakeGenreService و متد getGenres آن تامین میشود که به صورت یک خاصیت جدید در state به نحو زیر درج خواهد شد:
import { getGenres } from "../services/fakeGenreService";
// ...
class Movies extends Component {
state = {
// ...
genres: getGenres()
};
همانطور که در
قسمت 9 این سری نیز بررسی کردیم، اگر getGenres قرار است از سمت سرور و توسط یک درخواست Ajax ای تامین شود، محل صحیح قرارگیری آن در متد lifecycle hook ویژهای به نام componentDidMount است. اما در اینجا چون genres یک لیست درون حافظهای است، مقدار دهی فوق، مشکلی را ایجاد نمیکند. هرچند میتوان هم اکنون نیز تعریف فوق را کمی اصولیتر نوشت. برای اینکار متد componentDidMount را اضافه کرده و به نحو زیر تنظیم میکنیم:
class Movies extends Component {
state = {
movies: [],
pageSize: 4,
currentPage: 1,
genres: []
};
componentDidMount() {
this.setState({ movies: getMovies(), genres: getGenres() });
}
ابتدا آرایههای مورد نیاز movies و genres را در state تعریف کرده و آنها را با یک آرایهی خالی، مقدار دهی اولیه میکنیم. از این جهت که تا رسیدن به مرحلهی componentDidMount که اندکی طول میکشد، خطاهای زمان اجرای عدم دسترسی به این آرایهها در برنامه رخ ندهد. سپس زمانیکه وهلهای از این کامپوننت در DOM رندر شد، متد componentDidMount فراخوانی شده و دو خاصیت state را با مقادیر دریافتی، به روز رسانی میکند.
پس از آن میتوان ویژگی جدید items این کامپوننت را به آرایهی genres دریافتی از state، تنظیم کرد:
<ListGroup items={this.state.genres} />
در این مرحله، ورودی دیگری به نظر نمیرسد که مورد نیاز باشد. اکنون این سؤال مطرح میشود که چه رخدادهایی را قرار است از این کامپوننت دریافت کنیم یا به عبارتی خروجی آن چیست؟
بهتر است هر زمانیکه کاربر، آیتمی را از این لیست انتخاب کرد، توسط بروز رخدادی مانند onItemSelect از وقوع آن مطلع شد و سپس نسبت به آن توسط متد handleGenreSelect، واکنش نشان داد؛ مانند فیلتر کردن لیست فیلمها بر اساس آیتم انتخابی و نمایش آن. به همین جهت ویژگی onItemSelect را به تعریف المان ListGroup اضافه میکنیم:
<ListGroup
items={this.state.genres}
onItemSelect={this.handleGenreSelect}
/>
و سپس متد handleGenreSelect متصل به آنرا به نحو زیر تعریف خواهیم کرد:
handleGenreSelect = genre => {
console.log("handleGenreSelect", genre);
};
تا اینجا اینترفیس کامپوننت ListGroup را پیش از پیاده سازی آن تعریف کردیم (تعیین ورودی و خروجی آن). در مرحلهی بعد، این کامپوننت را تکمیل میکنیم.
پیاده سازی نمایش آیتمها در کامپوننت ListGroup
پیاده سازی ابتدایی کامپوننت ListGroup را در اینجا مشاهده میکنید:
import React, { Component } from "react";
class ListGroup extends Component {
render() {
return (
<ul className="list-group">
{this.props.items.map(item => (
<li key={item._id} className="list-group-item">
{item.name}
</li>
))}
</ul>
);
}
}
export default ListGroup;
کار با درج یک ul که با کلاس list-group مزین شدهاست، شروع میشود. سپس باید liهای آنرا که نمایانگر آیتمهای این لیست است، به صورت پویا با کلاسهای list-group-item رندر کرد. برای اینکار از آرایهی دریافتی this.props.items و فراخوانی متد map بر روی آن کمک میگیریم. در اینجا key هر ردیف با استفاده از خاصیت id هر آیتم و برچسب هر کدام از طریق خاصیت name هر شیء دریافتی، تامین میشود.
تا اینجا اگر برنامه را ذخیره کرده و در مرورگر نمایش دهیم، به خروجی زیر میرسیم:
البته به نظر عرض ستون آن نامناسب است. به همین جهت به کامپوننت movies مراجعه کرده و col-2 ستون آنرا به col-3 تبدیل میکنیم.
پویا سازی انتخاب نام خواص شیء دریافتی، در کامپوننت ListGroup
در حال حاضر پیاده سازی کامپوننت ListGroup، به شیءای دقیقا با خواص id_ و name وابستهاست و اگر شیء دیگری را که دارای خواصی معادل این نامها نیست، به آن ارسال کنیم، دیگر کار نخواهد کرد. به همین جهت در محل تعریف المان این کامپوننت در کامپوننت movies، دو ویژگی دیگر نام خواص شیء مدنظر را تنظیم میکنیم تا بتوانیم با هر نوع شیءای در اینجا کار کنیم:
<ListGroup
items={this.state.genres}
textProperty="name"
valueProperty="_id"
onItemSelect={this.handleGenreSelect}
/>
پس از این تغییر و افزودن textProperty و valueProperty، برای پویا سازی نامهای خواص دریافتی در کامپوننت ListGroup، از روش کار با []، جهت دسترسی پویای به خواص یک شیء، استفاده میکنیم تا دیگر این کامپوننت به شیء خاص genre، وابستگی نداشته باشد و قابلیت استفادهی مجدد از آن افزایش یابد:
import React, { Component } from "react";
class ListGroup extends Component {
render() {
return (
<ul className="list-group">
{this.props.items.map(item => (
<li key={item[this.props.valueProperty]} className="list-group-item">
{item[this.props.textProperty]}
</li>
))}
</ul>
);
}
}
export default ListGroup;
تعیین مقادیر پیشفرضی برای خواص props
با زیاد شدن تعداد خواص props، اینترفیس کامپوننتها پیچیدهتر میشوند. در یک چنین حالتی میتوان در کامپوننتها defaultProps را تعریف کرد و توسط آن مقادیر پیشفرضی را برای خواص props درنظر گرفت. به این صورت در حین تعریف المان این کامپوننت، اگر مقادیر مدنظر با مقادیر پیشفرض تعیین شده یکی باشند، دیگر نیازی به ذکر این پارامترها نخواهد بود. برای مثال در انتهای کامپوننت ListGroup، خاصیت جدید defaultProps را تعریف میکنیم (املای آن باید دقیقا به همین شکل باشد؛ و گرنه شناخته نخواهد شد). سپس در اینجا خواصی را که میخواهیم مقادیر پیشفرضی را برای آنها تعیین کنیم، ذکر خواهیم کرد:
ListGroup.defaultProps = {
textProperty: "name",
valueProperty: "_id"
};
export default ListGroup;
برای نمونه در اینجا دو خاصیت جدید textProperty و valueProperty را به همان مقادیر name و id_ مورد استفادهی در این مثال تنظیم کردهایم. پس از این تعریف، میتوان به کامپوننت movies که از این ویژگیها استفاده میکند مراجعه کرده و آنهایی را که با defaultProps تطابق دارند، از لیست ویژگیهای ذکر شده حذف کرد؛ یعنی تعریف المان ListGroup به صورت زیر ساده میشود:
<ListGroup
items={this.state.genres}
onItemSelect={this.handleGenreSelect}
/>
بدیهی است اگر در آینده با اشیاء دیگری سر و کار داشتیم، میتوان مجددا این خواص پیشفرض را بر اساس ساختار این اشیاء، مقدار دهی و تعیین کرد.
مدیریت انتخاب گروههای فیلمها
در ادامه میخواهیم رخداد onClick بر روی هر li این لیست را مدیریت کنیم و سبب بروز رخدادی به نام onItemSelect شویم که در ابتدای بحث، آنرا به عنوان خروجی این کامپوننت تعریف کردیم. این رخداد نیز در کامپوننت movies به متد handleGenreSelect متصل است. به همین جهت تعریف ویژگی onClick را که سبب انتقال شیء جاری رندر شده، توسط رویداد onItemSelect به خارج از آن میشود، به المان li کامپوننت ListGroup اضافه میکنیم:
<li
key={item[this.props.valueProperty]}
className="list-group-item"
onClick={() => this.props.onItemSelect(item)}
style={{ cursor: "pointer" }}
>
{item[this.props.textProperty]}
</li>
پس از این تغییرات و ذخیرهی برنامه، اگر به خروجی برنامه در مرورگر مراجعه کرده و بر روی هر کدام از آیتمهای لیست گروههای فیلمها کلیک کنیم، شیء مرتبط با آن آیتم در کنسول توسعه دهندههای مرورگر، لاگ میشود که نشان از برقراری صحیح ارتباطات این قسمت را دارد.
پس از فعالسازی امکان کلیک بر روی هر آیتم لیست رندر شده، اکنون میخواهیم با انتخاب هر گروه، این گروه در این لیست، به صورت انتخاب شده، همانند شماره صفحهی انتخاب شدهی در کامپوننت صفحه بندی، تغییر رنگ دهد و متمایز نمایش داده شود تا مشخص باشد که هم اکنون با کدام آیتم در حال کار هستیم. برای اینکار تنها کافی است کلاس active را به صورت پویا به className هر li، اضافه یا کم کنیم. البته برای این منظور این کامپوننت باید از آیتم انتخاب شده مطلع باشد؛ به همین جهت selectedItem را در لیست ویژگیهای اینترفیس تعریف این المان اضافه میکنیم. برای اینکار ابتدا selectedGenre را با هربار فراخوانی handleGenreSelect که به onItemSelect کامپوننت متصل است، با فراخوانی متد setState به روز رسانی میکنیم:
handleGenreSelect = genre => {
console.log("handleGenreSelect", genre);
this.setState({selectedGenre: genre});
};
در یک چنین حالتی الزامی به تعریف selectedGenre در خاصیت state ابتدای کامپوننت نیست. چون با فراخوانی متد setState اگر یکی از خواص منتسب به شیء state به روز شده باشد، آن خاصیت نیز به روز میشود و یا اگر این خاصیت جدید باشد، با state موجود یکی خواهد شد؛ هرچند آنرا به صورت زیر نیز میتوان تعریف کرد که با یک شیء خالی مقدار دهی شدهاست:
class Movies extends Component {
state = {
// ...
selectedGenre: {}
};
سپس ویژگی selectedItem کامپوننت را به این مقدار تغییر یافتهی this.state.selectedGenre تنظیم میکنیم تا با هر بار فراخوانی setState که سبب رندر مجدد کامپوننت Movies در DOM مجازی React میشود، کامپوننت از selectedItem تغییر یافته مطلع شده و با افزودن کلاس active به آن آیتم، واکنش نشان دهد:
<ListGroup
items={this.state.genres}
onItemSelect={this.handleGenreSelect}
selectedItem={this.state.selectedGenre}
/>
اکنون به کامپوننت ListGroup مراجعه کرده و بر اساس ویژگی جدید selectedItem، تغییرات زیر را به className اعمال میکنیم:
<li
key={item[this.props.valueProperty]}
className={
item === this.props.selectedItem
? "list-group-item active"
: "list-group-item"
}
style={{ cursor: "pointer" }}
onClick={() => this.props.onItemSelect(item)}
>
{item[this.props.textProperty]}
</li>
در اینجا اگر item در حال رندر با this.props.selectedItem دریافتی یکی باشد، کلاس active به کلاس list-group-item اضافه میشود و برعکس.
مدیریت فیلتر کردن اطلاعات گروه فیلم انتخابی
در قسمت قبل، در ابتدای متد رندر کامپوننت movies، از متد paginate برای صفحه بندی اطلاعات استفاده کردیم. فیلتر گروه جاری انتخاب شده را باید پیش از این متد قرار دارد؛ چون تعداد صفحات و اطلاعات نمایش داده شدهی در هر کدام باید بر اساس لیست فیلمهای فیلتر شده باشد.
برای انجام اینکار تغییرات زیر را اعمال خواهیم کرد:
الف) بجای متد paginate، از متد getPagedData زیر استفاده میکنیم:
getPagedData() {
const {
pageSize,
currentPage,
selectedGenre,
movies: allMovies
} = this.state;
const filteredMovies =
selectedGenre && selectedGenre._id
? allMovies.filter(m => m.genre._id === selectedGenre._id)
: allMovies;
const first = (currentPage - 1) * pageSize;
const last = first + pageSize;
const pagedMovies = filteredMovies.slice(first, last);
return { totalCount: filteredMovies.length, data: pagedMovies };
}
- در اینجا بجای اینکه مدام this.statها را جهت دریافت خواص آن تکرار کنیم، با استفاده از ویژگی Object Destructuring، خواصی را که نیاز داریم یکبار انتخاب کرده و سپس به دفعات از آنها استفاده میکنیم. به همین جهت در این قطعه کد، فقط یکبار this.state را مشاهده میکنید که بسیار تمیزتر است و همچنین کارآیی آن نیز به علت عدم انتخاب مداوم مقدار خاصیتی از یک شیء، بالاتر از حالت قبل است.
- در حین Object Destructuring، نام خاصیت movies را نیز به allMovies تغییر دادهایم تا واضحتر باشد.
- در ادامه با استفاده از متد filter جاوااسکریپت، بر اساس id هر گروه انتخاب شده، اشیاء مرتبط با آن، از allMovies جدا شده و بازگشت داده میشود. البته اگر id هم انتخاب نشده باشد (اولین بار نمایش صفحه)، تمام رکوردها یعنی allMovies، مورد استفاده قرار میگیرد.
- پس از آن، همان کدهای صفحه بندی اطلاعات را که در قسمت قبل بررسی کردیم، مشاهده میکنید که اینبار بجای allMovies قسمت قبل، بر روی filteredMovies اعمال شدهاست.
- در آخر، این متد، یک شیء را با دو خاصیت که بیانگر تعداد کل رکوردهای انتخاب شده و دادههای فیلتر شدهی صفحه بندی شدهاست، بازگشت میدهد.
ب) تغییرات متد رندر کامپوننت movies به صورت زیر است:
- ابتدا متد getPagedData فوق، فراخوانی شده و شیء دریافتی از آن با استفاده از ویژگی Object Destructuring، به دو خاصیت totalCount و movies انتساب داده میشود:
render() {
const { length: count } = this.state.movies;
if (count === 0) return <p>There are no movies in the database.</p>;
const { totalCount, data: movies } = this.getPagedData();
- از آرایهی movies، در قسمت قبل برای رندر لیست فیلمها استفاده شد. به همین جهت در اینجا تغییر نام data به movies را مشاهده میکنید.
- همچنین کامپوننت صفحه بندی، اینبار باید totalCount آیتمهای فیلتر شده را نمایش دهد و نه totalCount تمام فیلمهای موجود را:
<Pagination
itemsCount={totalCount}
در اینجا برچسب نمایش تعداد آیتمهای موجود نیز باید تغییر کند:
<p>Showing {totalCount} movies in the database.</p>
ج) ممکن است در اولین بار مشاهدهی صفحه، کاربر صفحهی شمارهی 3 را انتخاب کند که سبب تغییر currentPage موجود در state، به عدد 3 میشود. اکنون اگر کاربر نمایش فیلتر شدهی فیلمهای یک گروه خاص را انتخاب کند، باید این شماره، به عدد 1 مجددا تنظیم شود:
handleGenreSelect = genre => {
console.log("handleGenreSelect", genre);
this.setState({ selectedGenre: genre, currentPage: 1 });
};
افزودن گزینهی نمایش تمام اطلاعات به لیست گروههای فیلمها
در ادامه قصد داریم به بالای لیست گروههای موجود، گزینهی All Genres را نیز اضافه کنیم تا با کلیک بر روی آن، مجددا بتوان لیست تمام فیلمهای موجود را مشاهده کرد.
برای این منظور در جائیکه لیست getGenres را دریافت و نمایش میدهیم، یعنی متد componentDidMount، اندکی تغییر ایجاد کرده و یک آرایهی جدید را ایجاد میکنیم؛ بطوریکه اولین عنصر آن، گزینهی جدید All Genres باشد و سپس توسط spread operator، مابقی عناصر آرایهی گروهها را به این آرایهی جدید اضافه میکنیم:
componentDidMount() {
const genres = [{ _id: "", name: "All Genres" }, ...getGenres()];
this.setState({ movies: getMovies(), genres });
}
همین اندازه تغییر برای فعالسازی این گزینه کفایت میکند؛ از این جهت که در متد getPagedData، ابتدا بررسی میشود که اگر آیتمی انتخاب شده بود و همچنین دارای id نیز بود، آنگاه کار فیلتر کردن صورت گیرد، درغیراینصورت، تمام رکوردها را بازگشت دهد:
const filteredMovies =
selectedGenre && selectedGenre._id
? allMovies.filter(m => m.genre._id === selectedGenre._id)
: allMovies;
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید:
sample-12.zip