پس از فراگیری اصول کار کردن با فرمها در React، اکنون میخواهیم چند فرم جدید را برای تمرین بیشتر، به برنامهی نمایش لیست فیلمها اضافه کنیم؛ مانند فرم ثبت نام، فرمی برای ثبت و یا ویرایش فیلمها و یک فرم جستجوی سریع در لیست فیلمهای موجود.
تمرین 1 - ایجاد فرم ثبت نام
میخواهیم به برنامه، فرم ثبت نام را که حاوی سه فیلد نام کاربری، کلمهی عبور و نام است، اضافه کنیم. نام کاربری باید از نوع ایمیل باشد. بنابراین اعتبارسنجی مرتبطی نیز باید برای این فیلد تعریف شود. کلمهی عبور وارد شده باید حداقل 5 حرف باشد. همچنین تا زمانیکه اعتبارسنجی فرم تکمیل نشدهاست، باید دکمهی submit فرم، غیرفعال باقی بماند. لینک ورود به این فرم نیز باید به منوی راهبری سایت اضافه شود.
برای حل این تمرین، فایل جدید registerForm.jsx را در پوشهی components ایجاد میکنیم و سپس توسط میانبرهای imrc و cc در VSCode، ساختار ابتدایی کامپوننت RegisterForm را ایجاد کرده و سپس آنرا به صورت زیر تکمیل میکنیم:
- ابتدا در فایل app.js، پس از import ماژول آن:
import RegisterForm from "./components/registerForm";
در ابتدای سوئیچ تعریف شده، مسیریابی آنرا تعریف میکنیم:
<Route path="/register" component={RegisterForm} />
- سپس در فایل src\components\navBar.jsx، لینک به آنرا، در انتهای لیست اضافه میکنیم، تا در منوی راهبری ظاهر شود:
<NavLink className="nav-item nav-link" to="/register">
Register
</NavLink>
- در ادامه کدهای کامل کامپوننت ثبت نام را ملاحظه میکنید:
import Joi from "@hapi/joi";
import React from "react";
import Form from "./common/form";
class RegisterForm extends Form {
state = {
data: { username: "", password: "", name: "" },
errors: {}
};
schema = {
username: Joi.string()
.required()
.email({ minDomainSegments: 2, tlds: { allow: ["com", "net"] } })
.label("Username"),
password: Joi.string()
.required()
.min(5)
.label("Password"),
name: Joi.string()
.required()
.label("Name")
};
doSubmit = () => {
// Call the server
console.log("Submitted");
};
render() {
return (
<div>
<h1>Register</h1>
<form onSubmit={this.handleSubmit}>
{this.renderInput("username", "Username")}
{this.renderInput("password", "Password", "password")}
{this.renderInput("name", "Name")}
{this.renderButton("Register")}
</form>
</div>
);
}
}
export default RegisterForm;
- ابتدا این کامپوننت را بجای ارث بری از Component خود React، از کامپوننت Form که در
قسمت قبل ایجاد کردیم، ارث بری میکنیم تا به تمام امکانات آن مانند اعتبارسنجی، مدیریت حالت و متدهای کمکی تعریف فیلدها و دکمهها بهرهمند شویم.
- سپس state این کامپوننت را با شیءای حاوی دو خاصیت data و error، مقدار دهی اولیه میکنیم. خواص متناظر با المانهای فرم را نیز به صورت یک شیء، به خاصیت data انتساب دادهایم.
- پس از آن، خاصیت schema تعریف شدهاست؛ تا قواعد اعتبارسنجی تک تک فیلدهای فرم را به کمک کتابخانهی Joi، مطابق نیازمندیهایی که در ابتدای تعریف این تمرین مشخص کردیم، ایجاد کند.
- در ادامه، متد doSubmit را ملاحظه میکنید. این متد پس از کلیک بر روی دکمهی Register و پس از اعتبارسنجی موفقیت آمیز فرم، به صورت خودکار فراخوانی میشود.
- در آخر، تعریف فرم ثبتنام را مشاهده میکنید که نکات آنرا در
قسمت قبل، با معرفی کامپوننت Form و افزودن متدهای کمکی رندر input و button به آن، بررسی کردیم و در کل با نکات بررسی شدهی در فرم لاگینی که تا به اینجا ایجاد کردیم، تفاوتی ندارد.
تمرین 2- ایجاد فرم ثبت و یا ویرایش یک فیلم
فرم جدید ثبت و ویرایش یک فیلم، نکات بیشتری را به همراه دارد. در اینجا میخواهیم در بالای لیست نمایش فیلمها، یک دکمهی new movie را اضافه کنیم تا با کلیک بر روی آن، به فرم ثبت و ویرایش فیلمها هدایت شویم. این فرم، از فیلدهای یک عنوان متنی، انتخاب ژانر از یک drop down list، تعداد موجود (بین 1 و 100) و امتیاز (بین صفر تا 10) تشکیل شدهاست. همچنین تا زمانیکه اعتبارسنجی فرم تکمیل نشدهاست، دکمهی submit فرم باید غیرفعال باقی بماند. پس از ذخیره شدن این فیلم (در لیست درون حافظهای برنامه)، با مراجعهی به لیست فیلمها و انتخاب آن از لیست (با کلیک بر روی لینک آن)، باید مجددا به همین فرم، در حالت ویرایش این رکورد هدایت شویم. به علاوه اگر در بالای صفحه یک id اشتباه وارد شد، باید صفحهی «پیدا نشد» نمایش داده شود.
کامپوننت MovieForm و مسیریابی آنرا در
قسمت 17، تعریف و اضافه کردیم. برای تعریف لینکی به آن، به کامپوننت movies مراجعه کرده و بالای متنی که تعداد کل آیتمهای موجود در بانک اطلاعاتی را نمایش میدهد، المان زیر را اضافه میکنیم:
import { Link } from "react-router-dom";
// ...
<div className="col">
<Link
to="/movies/new"
className="btn btn-primary"
style={{ marginBottom: 20 }}
>
New Movie
</Link>
<p>Showing {totalCount} movies in the database.</p>
این Link را هم با کلاس btn مزین کردهایم تا شبیه به یک دکمه، به نظر برسد. با کلیک بر روی آن، به آدرس movies/new هدایت خواهیم شد؛ یعنی id جدید این مسیریابی را به "new" تنظیم کردهایم که در ادامه بر اساس آن، تفاوت بین حالت ویرایش و حالت ثبت اطلاعات، مشخص میشود.
سپس به کامپوننت src\components\movieForm.jsx که پیشتر آنرا اضافه کرده بودیم، مراجعه کرده و به صورت زیر آنرا تکمیل میکنیم:
import Joi from "@hapi/joi";
import React from "react";
import { getGenres } from "../services/fakeGenreService";
import { getMovie, saveMovie } from "../services/fakeMovieService";
import Form from "./common/form";
class MovieForm extends Form {
state = {
data: {
title: "",
genreId: "",
numberInStock: "",
dailyRentalRate: ""
},
genres: [],
errors: {}
};
- ابتدا importهای مورد نیاز به Joi، React و همچنین سرویسهای لیست فیلمها و لیست ژانرهای سینمایی، به همراه کامپوننت فرم، تعریف شدهاند.
- سپس این کامپوننت نیز از کامپوننت Form ارث بری میکند تا به امکانات ویژهی آن دسترسی پیدا کند.
- در ادامه در خاصیت state، طبق روالی که در کامپوننت فرم درنظر گرفتهایم، دو خاصیت data و errors باید حضور داشته باشند. در خاصیت data، شیءای که نام خاصیتهای آن با فیلدهای فرم تطابق دارد، ذکر شدهاند. در اینجا برای ذخیره سازی اطلاعات انتخاب شدهی از drop down list مرتبط با ژانرهای سینمایی، از خاصیت genreId استفاده میشود؛ این تنها اطلاعاتی است که از کل آیتمهای یک drop down list نیاز داریم. آرایهی genres که آیتمهای این drop down list را مقدار دهی میکند، در روال componentDidMount، از سرویس مرتبطی دریافت و مقدار دهی خواهد شد.
در ادامهی کدهای کامپوننت MovieForm، کدهای schema اعتبارسنجی شیء data را ملاحظه میکنید:
schema = {
_id: Joi.string(),
title: Joi.string()
.required()
.label("Title"),
genreId: Joi.string()
.required()
.label("Genre"),
numberInStock: Joi.number()
.required()
.min(0)
.max(100)
.label("Number in Stock"),
dailyRentalRate: Joi.number()
.required()
.min(0)
.max(10)
.label("Daily Rental Rate")
};
در اینجا، id به required تنظیم نشدهاست؛ چون زمانیکه قرار است یک شیء movie جدید را ایجاد کنیم، هنوز این id نامشخص است. سایر موارد خاصیت schema، به لطف fluent api کتابخانهی Joi، بسیار خوانا بوده و نیاز به توضیحات خاصی ندارند. برای مثال هر دو خاصیت numberInStock و dailyRentalRate باید عددی وارد شده و بین بازهی مشخصی قرار گیرند.
اکنون به مرحلهی componentDidMount میرسیم:
componentDidMount() {
const genres = getGenres();
this.setState({ genres });
const movieId = this.props.match.params.id;
if (movieId === "new") return;
const movie = getMovie(movieId);
if (!movie) return this.props.history.replace("/not-found");
this.setState({ data: this.mapToViewModel(movie) });
}
- در اینجا لیست ژانرهای سینمایی از متد getGenres فایل src\services\fakeGenreService.js دریافت شده و پس از آن کار به روز رسانی خاصیت genres در state را انجام میدهیم. این به روز رسانی state، سبب میشود تا این خاصیت که آرایهای است، در رندر بعدی این کامپوننت، به لیست options مربوط به drop down list درج شدهی در فرم، ارسال شده و در فرم رندر شود.
- پس از آن، نحوهی دریافت پارامتر id مسیریابی رسیده را ملاحظه میکنید. این id اگر به "new" تنظیم شده بود، یعنی قرار است، اطلاعات جدیدی ثبت شوند. بنابراین متد جاری را خاتمه میدهیم (چون کار ادامهی این متد، مقدار دهی اولیهی تمام فیلدهای فرم، بر اساس اطلاعات شیء دریافت شدهی از سرویس فیلمها است). در غیراینصورت (و با مشخص بودن id)، با استفاده از این id و متد getMovie سرویس src\services\fakeMovieService.js، سعی خواهیم کرد تا اطلاعات شیء movie متناظری را دریافت کنیم. اگر خروجی این متد null بود، یعنی id وارد شده معتبر نیست. به همین جهت کاربر را به صفحهی not-found هدایت میکنیم. اگر دقت کنید در اینجا بجای متد push، از متد replace استفاده کردهایم. چون اگر از متد push استفاده میکردیم و کاربر بر روی دکمهی back مرورگر کلیک میکرد، دوباره به همین صفحه، با id غیرمعتبر قبلی وارد میشد و یک حلقهی بیپایان رخ میداد. همچنین به return ای هم که به همراه متد replace استفاده شده، دقت کنید. کار redirect به یک صفحهی دیگر، به معنای عدم اجرای کدهای پس از آن نیست. بنابراین اگر میخواهیم کار این متد با redirect، به پایان برسد، ذکر return الزامی است.
- در پایان این متد، خاصیت data موجود در state را به روز رسانی میکنیم؛ تا سبب رندر فرم، با اطلاعات شیء movie یافت شده گردد و چون ساختار شیء movie دریافت شدهی از سرویس، با ساختار data تعریف شدهی در state یکی نیست، نیاز به نگاشت این دو به هم، توسط متد سفارشی mapToViewModel زیر است:
mapToViewModel(movie) {
return {
_id: movie._id,
title: movie.title,
genreId: movie.genre._id,
numberInStock: movie.numberInStock,
dailyRentalRate: movie.dailyRentalRate
};
}
این سناریو بسیار متداول است و اکثر دادههای دریافت شدهی از سرور، الزاما با ساختار دادههایی که در فرمهای خود تعریف میکنیم (که در اینجا view-model نام گرفته)، یکی نیستند و نیاز به نگاشت بین آنها وجود دارد. برای مثال genreId موجود در view-model این فرم (همان شیء منتسب به data در state)، دقیقا به همین نام، در شیء movie تعریف نشدهاست و نیاز به نگاشت این دو به هم است.
در ادامهی کدهای کامپوننت فرم فیلمها، به متد doSubmit میرسیم:
doSubmit = () => {
saveMovie(this.state.data);
this.props.history.push("/movies");
};
این متد پس از کلیک کاربر بر روی دکمهی submit و اعتبارسنجی کامل فرم، فراخوانی میشود. در این مرحله میتوان اطلاعات موجود در شیء data را به متد saveMovie سرویس src\services\fakeMovieService.js ارسال کرد، تا آنرا به لیست خودش اضافه کند. سپس کاربر را به لیست به روز شدهی فیلمها هدایت میکنیم.
در انتهای این کامپوننت نیز به متد رندر آن میرسیم:
render() {
return (
<div>
<h1>Movie Form</h1>
<form onSubmit={this.handleSubmit}>
{this.renderInput("title", "Title")}
{this.renderSelect("genreId", "Genre", this.state.genres)}
{this.renderInput("numberInStock", "Number in Stock", "number")}
{this.renderInput("dailyRentalRate", "Rate")}
{this.renderButton("Save")}
</form>
</div>
);
}
تمام قسمتهای این فرم را منهای متد جدید renderSelect آن، پیشتر در قسمت قبل، مرور کردهایم و نکتهی جدیدی ندارند.
برای تعریف متد جدید renderSelect به این صورت عمل میکنیم:
- ابتدا فایل جدید src\components\common\select.jsx را ایجاد کرده و سپس آنرا جهت نمایش یک drop down list، ویرایش میکنیم:
import React from "react";
const Select = ({ name, label, options, error, ...rest }) => {
return (
<div className="form-group">
<label htmlFor={name}>{label}</label>
<select name={name} id={name} {...rest} className="form-control">
<option value="" />
{options.map(option => (
<option key={option._id} value={option._id}>
{option.name}
</option>
))}
</select>
{error && <div className="alert alert-danger">{error}</div>}
</div>
);
};
export default Select;
شبیه به یک چنین کامپوننتی را در قسمت قبل، در فایل src\components\common\input.jsx ایجاد کردیم و ساختار کلی آنها با هم یکی است. ابتدا تمام تگها و کلاسهای بوت استرپی مورد نیاز، در این کامپوننت محصور میشوند. سپس آرایهای بر روی لیست options رسیده، ایجاد شده و به صورت پویا، لیست نمایش داده شدهی توسط drop down آنرا تشکیل میدهد. در پایان آن هم کار نمایش اخطار اعتبارسنجی متناظری، در صورت وجود خطایی، قرار گرفتهاست.
- پس از آن به کامپوننت src\components\common\form.jsx مراجعه کرده و متد رندر آنرا اضافه میکنیم:
import Select from "./select";
// ...
class Form extends Component {
// ...
renderSelect(name, label, options) {
const { data, errors } = this.state;
return (
<Select
name={name}
value={data[name]}
label={label}
options={options}
onChange={this.handleChange}
error={errors[name]}
/>
);
}
}
کار این متد، مقدار دهی ویژگیهای مورد نیاز کامپوننت Select، بر اساس نام فیلد، یک برچسب و آیتمهای ارسالی به آن است. مزیت وجود یک چنین متد کمکی، کم شدن کدهای تکراری Selectهای مورد نیاز و همچنین عدم فراموشی قسمتی از این اتصالات و در نهایت یکدست شدن کدهای کل برنامهاست. این متد در نهایت سبب رندر یک drop down list، بر اساس اطلاعات خاصیت genres موجود در state میشود:
تمرین 3- جستجوی در لیست فیلمها
میخواهیم در بالای لیست نمایش فیلمها، یک search box را قرار دهیم تا توسط آن بتوان بر اساس عنوان وارد شده، در فیلمهای موجود جستجو کرد. همچنین این جستجو قرار است کلی بوده و حتی در صورت انتخاب ژانر خاصی از منوی کنار صفحه، باید در کل اطلاعات موجود جستجو کند. به علاوه اگر کاربر ژانری را انتخاب کرد، این text box باید خالی شود.
برای اینکار ابتدا فایل جدید src\components\searchBox.jsx را ایجاد کرده و به صورت زیر آنرا تکمیل میکنیم:
import React from "react";
const SearchBox = ({ value, onChange }) => {
return (
<input
type="text"
name="query"
className="form-control my-3"
placeholder="Search..."
value={value}
onChange={e => onChange(e.currentTarget.value)}
/>
);
};
export default SearchBox;
این SeachBox، یک controlled component است و دارای state خاص خودش نیست. تمام اطلاعات مورد نیاز خود را از طریق props دریافت کرده و خروجی خود را (اطلاعات تایپ شدهی در input box را) از طریق صدور رخدادها، اطلاع رسانی میکند.
سپس به کامپوننت movies مراجعه کرده و آنرا ذیل متن نمایش تعداد رکوردها، درج میکنیم:
<p>Showing {totalCount} movies in the database.</p>
<SearchBox value={searchQuery} onChange={this.handleSearch} />
که البته نیاز به import کامپوننت مربوطه، تعریف واژهی جستجو شده در state و مدیریت رخداد onChange را نیز دارد:
import SearchBox from "./searchBox";
//...
class Movies extends Component {
state = {
//...
selectedGenre: {},
searchQuery: ""
};
handleSearch = query => {
this.setState({ searchQuery: query, selectedGenre: null, currentPage: 1 });
};
handleGenreSelect = genre => {
console.log("handleGenreSelect", genre);
this.setState({ selectedGenre: genre, searchQuery: "", currentPage: 1 });
};
در متد handleSearch، اطلاعات وارد شدهی توسط کاربر دریافت شده و توسط آن سه خاصیت state به روز رسانی میشوند تا توسط آنها در حین رندر مجدد کامپوننت، کار فیلتر صحیح اطلاعات صورت گیرد. همچنین selectedGenre نیز به حالت اول بازگشت داده میشود. به علاوه اگر کاربر در حین مشاهدهی صفحهی 3 بود، نیاز است currentPage صحیحی را به او نمایش داد.
متد handleGenreSelect را نیز اندکی تغییر دادهایم تا اگر گروهی انتخاب شد، مقدار searchQuery را خالی کند. اگر در اینجا searchQuery را به نال تنظیم میکردیم، controlled component جعبهی جستجو، تبدیل به کامپوننت کنترل نشدهای میشد و در این حالت، React، اخطار تبدیل بین این دو را صادر میکرد.
در آخر، ابتدای متد getPageData هم جهت اعمال searchQuery، به صورت زیر تغییر میکند:
getPagedData() {
const {
pageSize,
currentPage,
selectedGenre,
movies: allMovies,
sortColumn,
searchQuery
} = this.state;
let filteredMovies = allMovies;
if (searchQuery) {
filteredMovies = allMovies.filter(m =>
m.title.toLowerCase().startsWith(searchQuery.toLowerCase())
);
} else if (selectedGenre && selectedGenre._id) {
filteredMovies = allMovies.filter(m => m.genre._id === selectedGenre._id);
}
در اینجا اگر searchQuery مقداری داشته باشد، یک جستجوی غیرحساس به کوچکی و بزرگی حروف، بر روی خاصیت title اشیاء فیلم، انجام میشود.
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید:
sample-21.zip