الجزء 5: اختبار تطبيقات React

React Router ومكتبات الواجهات

واجهة مستخدم تطبيقنا بسيطة إلى حد كبير حالياً:

نريد تغيير ذلك. لنبدأ ببنية التنقّل في التطبيق.

من الشائع جداً أن تحتوي تطبيقات الويب على شريط تنقّل يتيح للمستخدمين التبديل بين عروض مختلفة داخل التطبيق. يمكن أن يتضمّن تطبيق تدوين الملاحظات لدينا صفحة رئيسية:

وصفحة منفصلة لعرض الملاحظات:

وكذلك صفحة لإنشاء الملاحظات:

في تطبيق ويب من الطراز القديم، كان التبديل بين الصفحات التي يعرضها التطبيق يتضمّن إرسال المتصفح طلب HTTP GET جديد إلى الخادم، ثم عرض شيفرة HTML التي يعيدها الخادم، والتي تقابل العرض الجديد.

أما في تطبيقات الصفحة الواحدة، فأنت في الواقع على الصفحة نفسها طوال الوقت، وشيفرة JavaScript المنفَّذة في المتصفح تخلق وهم «صفحات» مختلفة. وإذا أُجريت طلبات HTTP عند تغيير العرض، فهي تُستخدم فقط لجلب بيانات بصيغة JSON قد تكون مطلوبة لعرض العرض الجديد.

سيكون من السهل تنفيذ تطبيق يحتوي على شريط تنقّل وعروض متعددة باستخدام React، مثلاً بأن تجعل حالة التطبيق page تتذكّر الصفحة التي يوجد فيها المستخدم، وتعرض العرض الصحيح بناءً على ذلك:

const App = () => {
  const [page, setPage] = useState('home')

 const  toPage = (page) => (event) => {
    event.preventDefault()
    setPage(page)
  }

  const content = () => {
    if (page === 'home') {
      return <Home />
    } else if (page === 'notes') {
      return <Notes />
    } else if (page === 'users') {
      return <Users />
    }
  }

  return (
    <div>
      <div>
        <a href="" onClick={toPage('home')} >
          home
        </a>
        <a href="" onClick={toPage('notes')}>
          notes
        </a>
        <a href="" onClick={toPage('users')} >
          users
        </a>
      </div>

      {content()}
    </div>
  )
}

لكن هذه الطريقة ليست مثالية: يبقى عنوان URL للموقع كما هو حتى عندما تكون في عرض مختلف. ينبغي أن يكون لكل عرض عنوان URL خاص به، حتى يتمكّن المستخدمون مثلاً من حفظ الصفحات في المفضلة. علاوة على ذلك، لا يعمل زر الرجوع في المتصفح بشكل منطقي إذا لم تكن للصفحات عناوين خاصة بها؛ أي أن النقر على زر الرجوع لا ينقلك إلى العرض الذي شاهدته سابقاً في التطبيق بل إلى مكان آخر تماماً.

React Router

لحسن الحظ، تقدّم مكتبة React Router حلاً ممتازاً لإدارة التنقّل في تطبيق React.

ثبّت React Router:

npm install react-router-dom

أنشئ مكوّناً جديداً يعمل كصفحة رئيسية للتطبيق

const Home = () => {
  return (
    <div>
      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
    </div>
  )
}

export default Home

سنستخرج العرض الرئيسي السابق للتطبيق (الذي كان في المكوّن App) إلى مكوّن خاص به، لكن سننقل إدارة حالة الملاحظات خارج المكوّن:

// قائمة الملاحظات تُمرَّر كمعامل
const NoteList = ({ notes }) => {
  // المحتوى مطابق تقريباً لما في مكوّن App
  // أُزيلت الإشارة إلى NoteForm
}

يتغيّر المكوّن App الآن كما يلي

import { useState, useEffect } from 'react'
import noteService from './services/notes'

import {
  BrowserRouter as Router,
  Routes, Route, Link
} from 'react-router-dom'
import NoteList from './components/NoteList'
import Home from './components/Home'
import Footer from './components/Footer'
import NoteForm from './components/NoteForm'

const App = () => {
  const [notes, setNotes] = useState([])

  useEffect(() => {
    noteService.getAll().then(initialNotes => {
      setNotes(initialNotes)
    })
  }, [])

  const addNote = noteObject => {
    noteService.create(noteObject).then(returnedNote => {
      setNotes(notes.concat(returnedNote))
    })
  }

  const padding = {
    padding: 5
  }

  return (
    // highlight-start
    <Router>
      <div>
        <Link style={padding} to="/">home</Link>
        <Link style={padding} to="/notes">notes</Link>
        <Link style={padding} to="/create">new note</Link>
      </div>
        // highlight-end  

    // highlight-start
      <Routes>
        <Route path="/notes" element={
          <NoteList notes={notes} />
        } />
        <Route path="/create" element={
          <NoteForm createNote={addNote}/>
        } />
        <Route path="/" element={<Home />} />
      </Routes>

      <Footer />
    </Router>
    // highlight-end
  )
}

export default App

يُفعَّل التوجيه، أي العرض الشرطي للمكوّنات بناءً على URL المتصفح، بوضع المكوّنات كأبناء لمكوّن Router، أي داخل وسوم Router.

أولاً، يُعرَّف شريط تنقّل التطبيق باستخدام مكوّنات Link. تحدّد السمة to كيفية تغيّر عنوان URL في المتصفح عند النقر على الرابط:

<div>
  <Link style={padding} to="/">home</Link>
  <Link style={padding} to="/notes">notes</Link>
  <Link style={padding} to="/create">new note</Link>
</div>

بعد ذلك، يُعرَّف توجيه التطبيق باستخدام مكوّن Routes. وداخل المكوّن، نستخدم Route لتعريف مجموعة من القواعد والمكوّنات القابلة للعرض المقابلة لها:

<Routes>
  <Route path="/notes" element={
    <NoteList notes={notes} />
  } />
  <Route path="/create" element={
    <NoteForm createNote={addNote}/>
  } />
  <Route path="/" element={<Home />} />
</Routes>

إذا كنت في عنوان URL الجذري للتطبيق، يُعرض المكوّن Home:

عند النقر على «notes» في شريط التنقّل، يتغيّر العنوان في شريط عنوان المتصفح إلى notes، ويُعرض المكوّن NoteList:

وبالمثل، عند النقر على «new note»، يصبح عنوان URL هو create، ويُعرض المكوّن NoteForm.

في صفحة ويب عادية، يؤدي تغيير العنوان في شريط عنوان المتصفح إلى إعادة تحميل الصفحة. لكن عند استخدام React Router، لا يحدث ذلك؛ بل يُدار التوجيه بالكامل عبر JavaScript في الواجهة الأمامية.

مكوّن Router الذي نستخدمه هو BrowserRouter:

import {
  BrowserRouter as Router,
  Routes, Route, Link
} from 'react-router-dom'

وفقاً للتوثيق

BrowserRouter هو Router يستخدم واجهة history في HTML5 (pushState وreplaceState وحدث popstate) لإبقاء واجهة المستخدم متزامنة مع عنوان URL.

يستخدم BrowserRouter واجهة History في HTML5 للسماح باستخدام عنوان URL في شريط عنوان المتصفح في «التوجيه» الداخلي ضمن تطبيق React، بمعنى أنه حتى إذا تغيّر عنوان URL في شريط العنوان، فإن محتوى الصفحة يُعالَج عبر JavaScript فقط، ولا يحمّل المتصفح محتوى جديداً من الخادم. ومع ذلك، فإن سلوك المتصفح فيما يتعلق بوظيفتي الرجوع والتقدّم والحفظ في المفضلة يبقى بديهياً — فهو يعمل تماماً كما في المواقع التقليدية.

شيفرة التطبيق الحالية متاحة بالكامل على GitHub، في الفرع part5-10.

مسار بمعامل

لننقل تفاصيل ملاحظة واحدة إلى عرض خاص بها، يمكن الوصول إليه بالنقر على اسم الملاحظة:

نُفِّذت قابلية النقر على الاسم في المكوّن NoteList كما يلي:

import { Link } from 'react-router-dom'

const NoteList = ({ notes }) => {
  // ...

  return (
    <div>
      <h1>Notes</h1>
      <Notification message={errorMessage} />

      {!user && loginForm()}

      <div>
        <button onClick={() => setShowAll(!showAll)}>
          show {showAll ? 'important' : 'all'}
        </button>
      </div>
      <ul>
        {notesToShow.map(note => (
          <li key={note.id}>
            <Link to={`/notes/${note.id}`}>{note.content}</Link>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default NoteList

إذن، يُستخدم Link مرة أخرى. مثلاً، النقر على اسم ملاحظة معرّفها id هو 12345 يؤدي إلى تحديث عنوان URL في المتصفح إلى notes/12345.

يُعرَّف عنوان URL ذو المعامل في التوجيه داخل المكوّن App كما يلي:

<Router>
  // ...

  <Routes>
    // highlight-start
    <Route path="/notes/:id" element={
      <Note notes={notes} toggleImportanceOf={toggleImportanceOf} />
     } />
    // highlight-end
    <Route path="/notes" element={<Notes notes={notes} />} />   
    <Route path="/users" element={user ? <Users /> : <Navigate replace to="/login" />} />
    <Route path="/login" element={<Login onLogin={login} />} />
    <Route path="/" element={<Home />} />      
  </Routes>
</Router>

المسار الذي يعرض واجهة ملاحظة واحدة يُعرَّف بأسلوب «Express» عبر وسم معامل المسار بالترميز :id كما يلي:

<Route path="/notes/:id" element={<Note notes={notes} ... />} />

عندما ينتقل المتصفح إلى عنوان URL الفريد لملاحظة، مثل /notes/12345، يُعرض المكوّن Note، الذي اضطررنا الآن إلى تعديله قليلاً:

import { useParams } from 'react-router-dom'

const Note = ({ notes, toggleImportance }) => {
  // highlight-start
  const id = useParams().id
  const note = notes.find(n => n.id === id)
  // highlight-end

  const label = note.important ? 'make not important' : 'make important'

  return (
    <li className="note">
      <span>{note.content}</span>
      <button onClick={() => toggleImportance(id)}>{label}</button>
    </li>
  )
}

export default Note

خلافاً لما كان عليه الحال سابقاً، يتلقّى المكوّن Note الآن كل الملاحظات عبر prop المسمّى notes، ويمكنه الوصول إلى الجزء الفريد من عنوان URL، وتحديداً id الملاحظة المراد عرضها، باستخدام دالة React Router المسماة useParams.

useNavigate

تدعم الواجهة الخلفية بالفعل حذف الملاحظات. لتنفيذ ذلك، لنضف زراً إلى صفحة الملاحظة الفردية في التطبيق:

لنضف معالجاً إلى المكوّن App ينفّذ الحذف، ونمرّره إلى المكوّن Note:

const App = () => {

  // highlight-start
  const deleteNote = (id) => {
    noteService.remove(id).then(() => {
      setNotes(notes.filter(n => n.id !== id))
    })
  }
  // highlight-end

  return (
      // ...

      <Routes>
        <Route path="/notes/:id" element={
          <Note 
            notes={notes}
            toggleImportanceOf={toggleImportanceOf}
            deleteNote={deleteNote}
          />
        } />
        <Route path="/notes" element={
          <NoteList notes={notes} />
        } />
        <Route path="/create" element={
          <NoteForm createNote={addNote}/>
        } />
        <Route path="/" element={<Home />} />
      </Routes>

      <Footer />
    </Router>
  )
}  

يتغيّر المكوّن Note كما يلي:

import { useParams, useNavigate } from 'react-router-dom'

const Note = ({ notes, toggleImportanceOf, deleteNote }) => {
  const id = useParams().id
  const navigate = useNavigate()
  const note = notes.find(n => n.id === id)

  const label = note.important ? 'make not important' : 'make important'

// highlight-start
  const handleDelete = () => {
    if (window.confirm(`Delete note "${note.content}"?`)) {
      deleteNote(id)
      navigate('/notes')
    }
  }
  // highlight-end

  return (
    <li className="note">
      <span>{note.content}</span>
      <button onClick={() => toggleImportanceOf(id)}>{label}</button>
      <button onClick={handleDelete}>delete</button>
    </li>
  )
}

export default Note

عند حذف ملاحظة، يُعاد توجيه المستخدم إلى الصفحة التي تسرد كل الملاحظات. ويتم ذلك باستدعاء الدالة التي يعيدها useNavigate من React Router مع عنوان URL المطلوب: navigate('/notes').

الدالتان useParams وuseNavigate من مكتبة React Router هما دالتا خطاف، تماماً مثل useState وuseEffect اللتين استخدمناهما مرات عديدة. وكما نتذكّر من الجزء 1، هناك قواعد معيّنة مرتبطة باستخدام دوال الخطافات.

لنعدّل أيضاً المكوّن NoteForm بحيث يُوجَّه المستخدم، بعد إضافة ملاحظة جديدة، إلى الصفحة التي تحتوي على كل الملاحظات:

import { useState } from 'react' 
import { useNavigate } from 'react-router-dom'

const NoteForm = ({ createNote }) => {
  const [newNote, setNewNote] = useState('')
  const navigate = useNavigate()

  const addNote = event => {
    event.preventDefault()
    createNote({
      content: newNote,
      important: true
    })

    navigate('/notes')
    setNewNote('')
  }

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <input
          value={newNote}
          onChange={event => setNewNote(event.target.value)}
          placeholder="write note content here"
        />
        <button type="submit">save</button>
      </form>
    </div>
  )
}

إعادة النظر في المسار بمعامل

هناك مشكلة مزعجة قليلاً في التطبيق. يتلقّى المكوّن Note كل الملاحظات كـ props، رغم أنه لا يعرض سوى الملاحظة التي يطابق id الخاص بها الجزء ذا المعامل من عنوان URL:

const Note = ({ notes, toggleImportance }) => { 
  const id = useParams().id
  const note = notes.find(n => n.id === Number(id))
  // ...
}

هل يمكن تعديل التطبيق بحيث يتلقّى Note الملاحظة المراد عرضها فقط كـ prop:

import { useParams, useNavigate } from 'react-router-dom'

const Note = ({ note, id, toggleImportanceOf, deleteNote }) => {
  const id = useParams().id
  const navigate = useNavigate()

  // ...

  return (
    <li className="note">
      <span>{note.content}</span>
      <button onClick={() => toggleImportanceOf(id)}>{label}</button>
      <button onClick={handleDelete}>delete</button>
    </li>
  )
}

export default Note

إحدى الطرق هي تحديد id الملاحظة المراد عرضها داخل المكوّن باستخدام دالة الخطاف useMatch من React Router.

لا يمكن استخدام خطاف useMatch في المكوّن نفسه الذي يعرّف الجزء القابل للتوجيه من التطبيق. لننقل مكوّن Router خارج App:

ReactDOM.createRoot(document.getElementById('root')).render(
  <Router>
    <App />
  </Router>
)

يصبح المكوّن App:

import {
  // ...
  useMatch
} from 'react-router-dom'

const App = () => {
  // ...

 // highlight-start
  const match = useMatch('/notes/:id')

  const note = match
    ? notes.find(note => note.id === match.params.id)
    : null
  // highlight-end

  return (
    <div>
      <div>
        <Link style={padding} to="/">home</Link>
        // ...
      </div>

      <Routes>
        <Route path="/notes/:id" element={
          <Note
            note={note}
            toggleImportanceOf={toggleImportanceOf}
            deleteNote={deleteNote}
          />
        } />
        <Route path="/notes" element={
          <NoteList notes={notes} />
        } />
        <Route path="/create" element={
          <NoteForm createNote={addNote}/>
        } />
        <Route path="/" element={<Home />} />
      </Routes>

      <div>
        <em>Note app, Department of Computer Science 2026</em>
      </div>
    </div>
  )
}    

في كل مرة يُعرض فيها المكوّن App (وهو ما يحدث عملياً كلما تغيّر عنوان URL في شريط عنوان المتصفح) يُنفَّذ الأمر التالي

const match = useMatch('/notes/:id')

إذا كان عنوان URL بالصيغة /notes/:id، أي يقابل عنوان URL لملاحظة واحدة، تُسنَد إلى المتغيّر match قيمة كائن يمكن استخدامه لتحديد الجزء ذي المعامل من المسار، أي id الملاحظة. وهذا يتيح لنا استرجاع الملاحظة المراد عرضها:

const note = match 
  ? notes.find(note => note.id === match.params.id)
  : null

لا يزال هناك خطأ صغير في تطبيقنا. إذا أُعيد تحميل المتصفح في صفحة ملاحظة واحدة، يحدث خطأ:

تنشأ المشكلة لأن الصفحة يُحاول عرضها قبل جلب الملاحظات من الواجهة الخلفية. يمكننا حل هذه المشكلة بالعرض الشرطي:

const Note = ({ note, toggleImportanceOf, deleteNote }) => {
  const id = useParams().id
  const navigate = useNavigate()

// highlight-start
  if(!note) {
    return null
  }
  // highlight-end

  return (
    //...
  )
}

في التطبيق ميزة مزعجة أخرى: منطق تسجيل الدخول لا يزال كله في الصفحة التي تسرد الملاحظات. ومع ذلك، سنترك الوظيفة في هذه الحالة غير المكتملة نوعاً ما في الوقت الحالي.

شيفرة التطبيق الحالية متاحة بالكامل على GitHub، في الفرع part5-11.

تمارين 5.24–5.28.

5.24: مدوّنات موجّهة، الخطوة 1

أضف React Router إلى تطبيق المدوّنات بحيث يتيح لك النقر على الروابط في شريط التنقّل التحكّم في العرض المعروض.

في جذر التطبيق، أي المسار /، تُعرض قائمة بكل المدوّنات:

يتيح المسار /login للمستخدمين تسجيل الدخول

إذا كان المستخدم مسجّلاً، يظهر زر تسجيل الخروج في شريط التنقّل:

بعد تسجيل الدخول والخروج، ينبغي توجيه المستخدم إلى الصفحة التي تسرد كل المدوّنات.

في هذه المرحلة، لا تحتاج إلى القلق بشأن إنشاء المدوّنات بعد.

5.25: مدوّنات موجّهة، الخطوة 2

نفّذ عرضاً في التطبيق يعرض معلومات تدوينة واحدة:

ينتقل المستخدمون إلى عرض التدوينة الواحدة من قائمة المدوّنات:

تأكد من أن ميزة «الإعجاب» بالمدوّنات لا تزال تعمل! وعدّل الوظيفة أيضاً بحيث لا يستطيع إلا المستخدمون المسجّلون «الإعجاب» بتدوينة.

5.26: مدوّنات موجّهة، الخطوة 3

أنشئ عرضاً جديداً لإنشاء تدوينة جديدة، يمكن للمستخدمين المسجّلين الوصول إليه عبر شريط التنقّل:

ينبغي أن تؤدي إضافة تدوينة جديدة وحذف تدوينة موجودة إلى إعادة توجيه المستخدم إلى عرض كل المدوّنات

5.27: مدوّنات موجّهة، الخطوة 4

أصبحت سهولة استخدام التطبيق ومظهره أفضل من قبل. لسوء الحظ، تعطّلت بعض الاختبارات.

عدّل الآن اختبارات عرض التدوينة الواحدة المكتوبة بـ Vitest كما يلي

  • تُعرض معلومات التدوينة وعدد الإعجابات للمستخدمين غير المسجّلين، ولا تُعرض الأزرار
  • يُعرض للمستخدمين المسجّلين الذين ليسوا منشئي التدوينة زر الإعجاب فقط
  • يُعرض لمنشئ التدوينة أيضاً زر الحذف

5.28: مدوّنات موجّهة، الخطوة 5

التالي هو إصلاح الاختبارات الشاملة من الطرف إلى الطرف المكتوبة بـ Playwright. الاختبارات التي كتبناها سابقاً معطّلة تماماً، وسنضطر إلى إجراء تغييرات كبيرة عليها.

أنشئ اختبارات للسيناريوهات التالية:

  • ينجح تسجيل الدخول مع الجمع الصحيح لاسم المستخدم/كلمة المرور
  • يفشل تسجيل الدخول إذا كان اسم المستخدم/كلمة المرور غير صحيحين
  • يمكن للمستخدم المسجّل إنشاء تدوينة
  • يمكن للمستخدم المسجّل الإعجاب بالمدوّنات
  • يمكن للمستخدم المسجّل حذف تدوينة

إذن، لا يتم اختبار ترتيب المدوّنات حسب الإعجابات في الوقت الحالي.

مكتبات الواجهات

اطّلعنا في الجزء 2 على طريقتين لإضافة الأنماط: ملف CSS واحد من الطراز القديم والأنماط المضمّنة. في هذا القسم، سنطّلع على بضع طرق أخرى.

من الطرق المتّبعة لتعريف أنماط التطبيق استخدام «إطار عمل للواجهات»، أو بعبارة أخرى مكتبة أنماط للواجهات.

أول إطار عمل للواجهات حقّق شعبية واسعة كان Bootstrap، الذي طوّرته Twitter. وخلال السنوات القليلة الماضية، ظهرت أطر عمل الواجهات بكثرة كالفطر بعد المطر. والاختيار واسع جداً لدرجة أنه لا يستحق حتى محاولة إعداد قائمة شاملة هنا.

تتضمّن كثير من أطر عمل الواجهات سمات معرّفة مسبقاً لتطبيقات الويب، إضافة إلى «مكوّنات» مثل الأزرار والقوائم والجداول. وقد وُضع مصطلح «مكوّن» بين علامتَي اقتباس أعلاه لأنه لا يشير تماماً إلى الشيء نفسه الذي يشير إليه مكوّن React. في أغلب الأحيان، تُستخدم أطر عمل الواجهات بتضمين أوراق أنماط CSS وشيفرة JavaScript الخاصة بالإطار في التطبيق.

كُيِّفت كثير من أطر عمل الواجهات إلى نسخ متوافقة مع React، حيث حُوّلت «المكوّنات» التي يعرّفها إطار العمل إلى مكوّنات React. مثلاً، هناك نسختان من Bootstrap لـ React، وأشهرهما React-Bootstrap.

بدلاً من Bootstrap، لنطّلع الآن على ما هو ربما أشهر إطار عمل للواجهات حالياً: مكتبة React المسماة MaterialUI، التي تطبّق لغة التصميم Material Design من Google.

لنثبّت المكتبة:

npm install @mui/material @emotion/react @emotion/styled

عند استخدام MaterialUI، يُعرض عادةً محتوى التطبيق كله داخل مكوّن Container:

import { Container } from '@mui/material'

const App = () => {
  // ...
  return (
    <Container>
      // ...
    </Container>
  )
}

الجدول

لنبدأ بالمكوّن NoteList ونعرض قائمة الملاحظات كـجدول، يعرض أيضاً المستخدم الذي أنشأ كل ملاحظة:

import { useState, useEffect } from 'react'

import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper } from '@mui/material'

//...

const NoteList = ({ notes }) => {

  // ...

  return (
    <div>
      // ...
      <h2>Notes</h2>

      <TableContainer component={Paper}>
        <Table>
          <TableHead>
            <TableRow>
              <TableCell>content</TableCell>
              <TableCell>user</TableCell>
              <TableCell>important</TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            {notes.map(note => (
              <TableRow key={note.id}>
                <TableCell>
                  <Link to={`/notes/${note.id}`}>
                    {note.content}
                  </Link>
                </TableCell>
                <TableCell>
                  {note.user.name}
                </TableCell>
                <TableCell>
                  {note.important ? 'yes': ''}
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </TableContainer>

    </div>
  )
}

export default NoteList

يبدو الجدول كما يلي:

النموذج

بعد ذلك، لنحسّن عرض إنشاء ملاحظة جديدة NoteForm باستخدام مكوّني TextField وButton:

import { TextField, Button } from '@mui/material'

// ...

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <TextField
          label="note content"
          value={newNote}
          onChange={event => setNewNote(event.target.value)}
        />
        <div>
          <Button type="submit" variant="contained" style={{ marginTop: 10 }}>
            save
          </Button>
        </div>
      </form>
    </div>
  )
}

export default NoteForm


النتيجة أنيقة:

الإشعارات

لنحسّن مكوّن الإشعارات في التطبيق باستخدام مكوّن Alert من MaterialUI:

import { Alert } from '@mui/material'

const Notification = ({ notification }) => {
  if (notification === null) {
    return null
  }

  return (
    <Alert style={{ marginTop: 10, marginBottom: 10 }} severity={notification.type}>
      {notification.text}
    </Alert>
  )
}

export default Notification

انقل مكوّن الإشعارات وإدارة حالته إلى المكوّن App:

const App = () => {
  const [notes, setNotes] = useState([])
  const [notification, setNotification] = useState(null)

  // ...

  const addNote = noteObject => {
    noteService.create(noteObject).then(returnedNote => {
      setNotes(notes.concat(returnedNote))
      setNotification({ text: `Note '${returnedNote.content}' added!`, type: 'success' })
      setTimeout(() => {
        setNotification(null)
      }, 5000)
    })
  }

  return (
    <Container>
      <div>
        <Link style={padding} to="/">home</Link>
        <Link style={padding} to="/notes">notes</Link>
        <Link style={padding} to="/create">new note</Link>
      </div>

      <Notification notification={notification} />

      <Routes>
        <Route path="/notes/:id" element={
          <Note
            note={note}
            toggleImportanceOf={toggleImportanceOf}
            deleteNote={deleteNote}
          />
        } />
        <Route path="/notes" element={
          <NoteList notes={notes} setNotification={setNotification} />
        } />
        <Route path="/create" element={
          <NoteForm createNote={addNote} />
        } />
        <Route path="/" element={<Home />} />
      </Routes>

      <Footer />
    </Container>
  )
}

يتميّز Alert بتصميم أنيق:

قائمة التنقّل

تُنفَّذ قائمة التنقّل باستخدام مكوّن AppBar.

إذا طبّقنا المثال من التوثيق مباشرة

<AppBar position="static">
  <Toolbar>
    <Button color="inherit"><Link to="/">home</Link></Button>
    <Button color="inherit"><Link to="/notes">notes</Link></Button>
    <Button color="inherit"><Link to="/create">new note</Link></Button>
  </Toolbar>
</AppBar>

يوفر هذا حلاً يعمل بالفعل، لكن مظهره ليس الأفضل ممكناً:

بتصفّح [التوثيق](https://mui.com/material-ui/guides/composition/# routing-libraries)، ستجد طريقة أفضل: خاصية component، التي تتيح لك تغيير طريقة عرض العنصر الجذري لمكوّن MaterialUI.

بتعريف

<Button color="inherit" component={Link} to="/">
  home
</Button>

يُعرض مكوّن Button بحيث يكون مكوّنه الجذري هو مكوّن Link من مكتبة react-router-dom، ويُمرَّر إليه prop المسمّى to الذي يحدّد المسار.

الشيفرة الكاملة لشريط التنقّل كما يلي

<AppBar position="static">
  <Toolbar>
    <Button color="inherit" component={Link} to="/">home</Button>
    <Button color="inherit" component={Link} to="/notes">notes</Button>
    <Button color="inherit" component={Link} to="/create">new note</Button>
  </Toolbar>
</AppBar>

وتبدو النتيجة تماماً كما نريد:

لكننا نلاحظ أن مؤشّر التحويم باهت جداً عند تحريك الفأرة فوق شريط التنقّل. لنصلح ذلك بتعريف لون خلفية أفضل قليلاً لهذه الحالات:

const style = { '&:hover': { bgcolor: 'rgba(255,255,255,0.3)' } }

return (
  <Container>
    <AppBar position="static">
      <Toolbar>
        <Button color="inherit" component={Link} to="/" sx={style}>
          home
        </Button>
        <Button color="inherit" component={Link} to="/notes" sx={style}>
          notes
        </Button>
        <Button color="inherit" component={Link} to="/create" sx={style}>
          new note
        </Button>
      </Toolbar>
    </AppBar>

    // ...
)

نحن راضون أخيراً:

شيفرة التطبيق الحالية متاحة بالكامل على GitHub، في الفرع part5-12.

مكوّنات styled-components

إضافة إلى ما رأيناه سابقاً، هناك طرق أخرى لتطبيق الأنماط على تطبيق React.

تقدّم مكتبة styled-components، التي تستفيد من صيغة القوالب النصية الموسومة (tagged template literal) في ES6، مقاربة مثيرة للاهتمام لتعريف الأنماط.

لنقم بتثبيت styled-components ونستخدمها لإجراء بعض التغييرات الأسلوبية على تطبيق تدوين الملاحظات (النسخة قبل تثبيت MaterialUI). أولاً، لننشئ تعريفَي نمط للمكوّنين اللذين سنستخدمهما:

import styled from 'styled-components'

const Button = styled.button`
  background: Bisque;
  font-size: 1em;
  margin: 1em;
  padding: 0.25em 1em;
  border: 2px solid Chocolate;
  border-radius: 3px;
`

const Input = styled.input`
  margin: 0.25em;
  width: 300px;  
`

تُنشئ الشيفرة نسخاً منسّقة من عنصرَي HTML هما button وinput، وتُسنِدها إلى المتغيّرين Button وInput.

صيغة تعريف الأنماط مثيرة للاهتمام فعلاً، إذ توضع تعريفات CSS داخل علامات الاقتباس الخلفية (backticks). هذه هي صيغة القوالب النصية الموسومة في ES6.

تعمل المكوّنات المعرّفة مثل عنصرَي button وinput العاديين، وتُستخدم في التطبيق بالطريقة المعتادة:

const NoteForm = ({ createNote }) => {
  // ...

  return (
    <div>
      <h2>Create a new note</h2>

      <form onSubmit={addNote}>
        <Input>
          value={newNote}
          onChange={event => setNewNote(event.target.value)}
          placeholder="write note content here"
        />
        <Button type="submit">save</Button>
      </form>
    </div>
  )
}

يبدو النموذج الآن كما يلي:

لنعرّف المكوّنات التالية لإضافة الأنماط، وكلها نسخ محسّنة من عناصر div:

const Page = styled.div`
  padding: 1em;
  background: papayawhip;
`

const Navigation = styled.div`
  background: BurlyWood;
  padding: 1em;
`

const Footer = styled.div`
  background: Chocolate;
  padding: 1em;
  margin-top: 1em;
`

يمكن الآن استخدام المكوّنات الجديدة في التطبيق:

const App = () => {
  // ...

  return (
    <Page>
      <Navigation>
        <Link style={padding} to="/">home</Link>
        <Link style={padding} to="/notes">notes</Link>
        <Link style={padding} to="/create">new note</Link>
      </Navigation>

      <Routes>
        <Route path="/notes/:id" element={
          <Note
            note={note}
            toggleImportanceOf={toggleImportanceOf}
            deleteNote={deleteNote}
          />
        } />
        <Route path="/notes" element={
          <NoteList notes={notes} />
        } />
        <Route path="/create" element={
          <NoteForm createNote={addNote}/>
        } />
        <Route path="/" element={<Home />} />
      </Routes>
// highlight-start
      <Footer>
         Note app, Department of Computer Science, University of Helsinki 2026
      </Footer>
    </Page>
    // highlight-end
  )
}

النتيجة النهائية كما يلي:

اكتسبت styled-components شعبية متزايدة باطراد في الآونة الأخيرة، ويبدو حالياً أن كثيرين يعتبرونها أفضل طريقة لتعريف الأنماط لتطبيقات React.

تمارين 5.29–5.31

بعد ذلك، حسّن أنماط تطبيق المدوّنات باستخدام MaterialUI أو styled-components.

5.29: مدوّنات منسّقة، الخطوة 1

أضف أنماطاً إلى نماذج التطبيق.

قد يبدو حلك شيئاً كهذا. نموذج تسجيل الدخول:

إنشاء تدوينة جديدة:

5.30: مدوّنات منسّقة، الخطوة 2

نسّق الآن شريط تنقّل التطبيق والمكوّن الذي يعرض الإشعارات. قد تبدو النتيجة شيئاً كهذا:

5.31: مدوّنات منسّقة، الخطوة 3

خصّص مظهر مكوّن عرض التدوينة الواحدة كما تراه مناسباً. إليك مثالاً:

كان هذا آخر تمرين في القسم، وحان وقت دفع الشيفرة إلى GitHub وتعليم التمارين المنجزة في نظام تسليم التمارين.