اذهب إلى المحتوى

استخدام TypeScript والأنواع التي توفرها في تطبيقات React


ابراهيم الخضور

علينا أولًا وقبل أن ندخل في موضوع استخدام TypeScript مع React تحديد ما يلزمنا وما الذي نهدف لتحقيقه. فعندما يعمل كل شيء كما ينبغي، ستساعدنا TS في التقاط الأخطاء التالية:

  • محاولة تمرير خاصيات Props زائدة أو غير مطلوبة لمكوِّن
  • نسيان تمرير خاصية يحتاجها المكوَّن
  • تمرير خاصية من النوع الخاطئ إلى مكوِّن

ستساعدنا TS على التقاط أيًا من الأخطاء السابقة ضمن المحرر مباشرة. وفي حال لم نستخدم TS، سنضطر إلى التقاط هذه الأخطاء لاحقًا عن طريق الاختبارات، وربما سنقضي وقتًا مرهقًا في إيجاد مسبباتها.

هذه الأسباب كافية حتى الآن لنبدأ إذًا!

إنشاء تطبيق React باستخدام TypeScript

يمكننا استخدام create-react-app لإنشاء تطبيق React باستخدام TS بإضافة الوسيط template إلى سكربت التهيئة الأولية. لذا نفّذ الأمر التالي لإنشاء تطبيق create-react-app باستخدام TS:

npx create-react-app my-app --template typescript

ينبغي بعد تنفيذ الأمر، أن تحصل على تطبيق React مكتمل يستخدم TS. يمكنك أن تشغل التطبيق باستخدام الأمر npm start في جذر المشروع.

لو ألقينا نظرة على الملفات والمجلدات، ستجد أن التطبيق لا يختلف كثيرًا عن التطبيقات التي تستخدم JavaScript صرفة. إذ تقتصر الاختلافات على تحول الملفات التي تحمل إحدى اللاحقتين js. و jsx. إلى ملفات باللاحقتين ts.وtsx. وستحتوي هذه الملفات على مسجلات للأنواع، كما سيحتوي المجلد الجذري على الملف tsconfig.json.

لنلق نظرة الآن على الملف tsconfig.json الذي أُنشئ نيابة عنا. ينبغي أن تكون قواعد التهيئة ضمنه مناسبة إلى حد ما، إلا أنّ هذه القواعد ستسمح بتصريف ملفات JavaScript، لأن القاعدة allowJs تأخذ القيمة "true". لا بأس بذلك إن كنت ستمزج بين TS و JavaScript (في الحالة التي تعمل فيها مثلًا على تحويل شيفرة JS إلى TS أو ما شابه)، لكننا نريد هنا إنشاء تطبيق TS صرف، لذا سنغير قيمة القاعدة allowJs إلى false.

استخدمنا في المشروع السابق المدقق لمساعدتنا في فرض أسلوب برمجة معين، سنفعل ذلك أيضًا في هذا التطبيق. لا حاجة لتثبيت أية اعتماديات لأن create-react-app قد اهتم بكل الترتيبات.

يحمل الملف ذو اللاحقة "eslintrc." قواعد eslint التالية:

{
  "env": {
    "browser": true,
    "es6": true,
    "jest": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "plugins": ["react", "@typescript-eslint"],
  "settings": {
    "react": {
      "pragma": "React",
      "version": "detect"
    }
  },
  "rules": {
    "@typescript-eslint/explicit-function-return-type": 0
  }
}

طالما أنّ النوع الذي تعيده جميع مكوّنات React هي عناصر JSX أو القيمة null، سنغير قواعد المدقق قليلًا بإلغاء تفعيل القاعدة explicit-function-return-type. وهكذا لن نحتاج إلى التصريح عن نوع القيمة التي تعيدها الدالة في كل مكان.

علينا أيضًا أن نجعل المدقق قادرًا على فهم ملفات "tsx."، وهي المقابل في TS لملفات "jsx." في React. سننفذ ذلك بتغيير السكربت lint في الملف package.json على النحو التالي:

{
  // ...
    "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "lint": "eslint './src/**/*.{ts,tsx}'"  },
  // ...
}

قد تحتاج إلى استخدام علامة التنصيص المزدوجة عند كتابة مسار المدقق:

{lint": "eslint './src/**/*.{ts,tsx}

وذلك إن كنت تعمل على نظام Windows.

لو نفذنا الآن الأمر npm run lint سنحصل على رسالة خطأ من المدقق eslint مجددًا:

lint_error_01.png

لماذا يحدث ذلك؟ تخبرنا رسالة الخطأ أنّ الملف serviceWorker.ts لا يتقيد بقواعد المدقق. والسبب في ذلك، أنّ الدالة register ستستخدم دوال أخرى عُرِّفت لاحقًا في الملف نفسه وهذا لا يتوافق مع القاعدةtypescript-eslint/no-use-before-define@ . ولإصلاح المشكلة لا بدّ من نقل الدالة register إلى آخر الملف.

لا ينبغي أن تظهر أية أخطاء بعد الآن. لا يشكل الخطأ السابق عائقًا في واقع الأمر، لأننا لن نحتاج إلى الملف serviceWorker.ts. لذا من الأفضل حذفه.

مكونات React مع TypeScript

لنتأمل المثال التالي لتطبيق React كُتب باستخدام JavaScript:

import React from "react";
import ReactDOM from 'react-dom';
import PropTypes from "prop-types";

const Welcome = props => {
  return <h1>Hello, {props.name}</h1>;
};

Welcome.propTypes = {
  name: PropTypes.string
};

const element = <Welcome name="Sara" />;
ReactDOM.render(element, document.getElementById("root"));

لدينا في هذا المثال المكوًن Welcome الذي نمرر إليه اسمًا كخاصية ليقوم بتصييره على الشاشة.

ينبغي أن يكون الاسم من النوع string وقد استخدمنا الحزمة prop-types التي تعرفنا عليها في القسم 5، لكي نحصل على تلميحات حول الأنواع المطلوبة لخصائص المكوّنات وتحذيرات عند استخدام خصائص من النوع الخاطئ.

لن نحتاج إلى الحزمة prop-types أبدًا عند استخدام TS. إذ يمكننا تعريف الأنواع بمساعدة TS، وذلك باستخدام واجهة النوع "FunctionComponent" أو باستخدام اسمها المستعار FC.

عندما نستخدم TS مع المكوّنات، ستبدو مسجلات الأنواع مختلفة قليلًا عن شيفرات TS الأخرى. حيث نضيف النوع إلى المتغيًر الذي يُسند إليه المكوّن بدلًا من الدالة وخصائصها. يدعى النوع الناتج عن "FunctionComponent" بالنوع المُعمَّم generic. بحيث يمكن أن نمرر لهذه الواجهة نوعًا كمعامل، ثم تستخدمه على أنه النوع الخاص بها.

تبدو تصريحات React.FC وReact.FunctionComponent على النحو التالي:

type FC<P = {}> = FunctionComponent<P>;

interface FunctionComponent<P = {}> {
  (props: PropsWithChildren<P>, context?: any): ReactElement | null;
  propTypes?: WeakValidationMap<P>;
  contextTypes?: ValidationMap<any>;
  defaultProps?: Partial<P>;
  displayName?: string;
}

سترى أولًا أن FC ببساطة هو اسم مستعار لواجهة النوع FunctionComponent، وكلاهما من النوع المعمم الذي يمكن تمييزه بسهولة من قوسي الزاوية "<>" بعد اسم النوع. سترى داخل قوسي الزاوية هذه الشيفرة {}=P. وتعني أنه بالإمكان تمرير نوع كمعامل. سيُعرَف النوع الذي سيُمرَّر بالاسم P، وهو افتراضيًا كائن فارغ {}.

لنلق نظرة على السطر الأول من شيفرة FunctionComponent:

(props: PropsWithChildren<P>, context?: any): ReactElement | null;

تحمل الخصائص النوع PropsWithChildren، وهو أيضًا نوع معمم يُمرَّر إليه النوع P. يمثل النوع "PropsWithChildren" بدوره تقاطعًا intersection بين النوعين بالشكل التالي:

type PropsWithChildren<P> = P | { children?: ReactNode };

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

لنعد إلى مثالنا ونحاول أن نعرًف نوعًا لخصائص المكوًن Welcome باستخدام TS:

interface WelcomeProps {
  name: string;
}

const Welcome: React.FC<WelcomeProps> = (props) => {
  return <h1>Hello, {props.name}</h1>;
};

const element = <Welcome name="Sara" />;
ReactDOM.render(element, document.getElementById("root"));

عرًفنا في الشيفرة السابقة واجهة النوع WelcomeProps ومررناها إلى المكوّن Welcome عندما صرّحنا عن نوعه:

const Welcome: React.FC<WelcomeProps>;

يمكننا كتابة الشيفرة بأسلوب أقصر:

const Welcome: React.FC<{ name: string }> = ({ name }) => (
  <h1>Hello, {name}</h1>
);

سيعرف المحرر الآن أنّ الخاصية من النوع string. لكن المدقق لن يقتنع تمامًا بما فعلنا، وسيعترض بأن الخاصية name غير موجودة عند تقييم الخصائص. ويحدث هذا لأنّ قاعدة المدقق ستتوقع منا أن نعرِّف أنواعًا لجميع الخصائص. فلن يدرك المدقق أننا نستخدم TS في تعريف الأنواع لخصائصنا.

لإصلاح الخلل، لا بدّ من إضافة قاعدة تدقيق جديدة إلى الملف "eslintrc.":

{
  // ...
  "rules": {
    "react/prop-types": 0,  },  
  // ...
}

التمرين 9.14

9.14

أنشئ تطبيق create-react-app مستخدمًا TS وهيئ المدقق لمشروعك بالطريقة التي تعلمناها.

يشبه هذا التمرين تمرينًا نفَّذناه في القسم 1 من المنهاج لكن باستخدام TS هذه المرة بالإضافة إلى بعض التعديلات. إبدأ بتعديل محتويات الملف "index.tsx" لتصبح على النحو التالي:

import React from "react";
import ReactDOM from "react-dom";

const App: React.FC = () => {
  const courseName = "Half Stack application development";
  const courseParts = [
    {
      name: "Fundamentals",
      exerciseCount: 10
    },
    {
      name: "Using props to pass data",
      exerciseCount: 7
    },
    {
      name: "Deeper type usage",
      exerciseCount: 14
    }
  ];

  return (
    <div>
      <h1>{courseName}</h1>
      <p>
        {courseParts[0].name} {courseParts[0].exerciseCount}
      </p>
      <p>
        {courseParts[1].name} {courseParts[1].exerciseCount}
      </p>
      <p>
        {courseParts[2].name} {courseParts[2].exerciseCount}
      </p>
      <p>
        Number of exercises{" "}
        {courseParts.reduce((carry, part) => carry + part.exerciseCount, 0)}
      </p>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));

واحذف الملفات غير الضرورية.

إنّ التطبيق ككل موجود في مكوّن واحد، وهذا ما لا نريده. أعد كتابة الشيفرة لتتوزع على ثلاثة مكوّنات: Header وContent وTotal. ابق على جميع البيانات ضمن المكوّن App الذي سيمرر كل البيانات اللازمة لمكوّن كخصائص. واحرص على تعريف نوع لكل خاصية من خصائص المكوُنات.

سيتحمل المكوًن Header مسؤولية تصيير اسم المنهاج، وسيصيّر المكوّن Content أسماء الأقسام المختلفة وعدد التمارين في كل قسم، أما المكوّن Total فسيصيّر مجموع التمارين في كل الأقسام.

سيبدو المكوًن App بالشكل التالي تقريبًا:

const App = () => {
  // const-declarations

  return (
    <div>
      <Header name={courseName} />
      <Content ... />
      <Total ... />
    </div>
  )
};

استخدام أدق للأنواع

يتضمن المثال السابق منهاجًا من ثلاثة أقسام، ويمتلك كل قسم نفس الصفتين name وexcerciseCount. لكن ماذا لو احتجنا إلى صفات أخرى، وتطلّب كل قسم صفات مختلفة عن الآخر؟ كيف ستبدو شيفرة التطبيق؟

لنتأمل المثال التالي:

const courseParts = [
  {
    name: "Fundamentals",
    exerciseCount: 10,
    description: "This is an awesome course part"
  },
  {
    name: "Using props to pass data",
    exerciseCount: 7,
    groupProjectCount: 3
  },
  {
    name: "Deeper type usage",
    exerciseCount: 14,
    description: "Confusing description",
    exerciseSubmissionLink: "https://fake-exercise-submit.made-up-url.dev"
  }
];

أضفنا في الشيفرة السابقة صفات أخرى إلى كل قسم. يمتلك الآن كل قسم الصفتين name وexerciseCount، ويمتلك القسمان الأول والثالث الصفة description، كما يمتلك القسمان الثاني والثالث بعض الصفات الإضافية الخاصة.

لنتخيل أنّ تطبيقنا سيستمر في النمو، وأننا سنضطر إلى تمرير الأقسام المختلفة ضمن الشيفرة. وقد تّضاف أيضًا خصائص أخرى أو أقسام أخرى. كيف سنضمن أن الشيفرة قادرة على التعامل مع كل الأنواع المختلفة للبيانات بشكل صحيح، وأننا لن ننس تصيير أحد أقسام المنهاج على صفحة ما؟ هنا ستظهر فائدة اللغة TS!

لنبدأ بتعريف أنواع لأقسام المنهاج المختلفة:

interface CoursePartOne {
  name: "Fundamentals";
  exerciseCount: number;
  description: string;
}

interface CoursePartTwo {
  name: "Using props to pass data";
  exerciseCount: number;
  groupProjectCount: number;
}

interface CoursePartThree {
  name: "Deeper type usage";
  exerciseCount: number;
  description: string;
  exerciseSubmissionLink: string;
}

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

type CoursePart = CoursePartOne | CoursePartTwo | CoursePartThree;

يمكننا الأن تحديد نوع المتغيّر Coursepart، وسيحذرنا المحرر تلقائيًا إن استخدمنا النوع الخاطئ لإحدى الصفات، أو استخدمنا صفة زائدة، أو نسينا أن نحدد صفة متوقعة. اختبر ذلك بوضع علامة تعليق قبل أي صفة لأي قسم. وبفضل القيمة النصية الحرفية التي تحملها السمة name، يمكن أن تحدد TS أي قسم سيحتاج إلى أية صفات إضافية، حتى لو عرفنا المتغير على أنه من النوع الموّحد.

لم نصل درجة القناعة بتطبيقنا بعد، فلا زال هناك تكرار كثير للأنواع، ونريد أن نتجنب ذلك. لهذا سنبدأ بتعريف الصفات المشتركة بين جميع الأقسام، ثم سنعرِّف نوعًا أساسيًا (Base Type) يحتويها. سنوسع بعد ذلك النوع الأساسي لإنشاء الأنواع الخاصة بكل قسم:

interface CoursePartBase {
  name: string;
  exerciseCount: number;
}

interface CoursePartOne extends CoursePartBase {
  name: "Fundamentals";
  description: string;
}

interface CoursePartTwo extends CoursePartBase {
  name: "Using props to pass data";
  groupProjectCount: number;
}

interface CoursePartThree extends CoursePartBase {
  name: "Deeper type usage";
  description: string;
  exerciseSubmissionLink: string;
}

كيف سنستخدم هذه الأنواع الآن في مكوًناتنا؟

من الطرق المفيدة في استخدام هذه الأنواع هي في عبارة switch case. فعندما تصرُح علنًا عن نوع المتغير أنه من النوع الموّحد أو استدلت TS على ذلك واحتوى كل نوع موجود ضمن النوع الموًحد صفة معينة، يمكن استخدام ذلك كمعرّفات للأنواع. يمكن بعد ذلك استخدام البنية switch case للتنقل بين الصفات وستحدد TS الصفات الموجودة في كل حالة من حالات البنية switch.

using_types_in_comp_02.png

ستميّز TS في المثال السابق أن المتغير coursePart من النوع CoursePart. ويمكنها عندها أن تستدل أن المتغير part من أحد الأنواع التالية CoursePartOne أو CoursePartTwo أو CoursePartThree. أما الصفة name فهي مختلفة ومميزة لكل نوع، لذلك من الممكن استخدامها لتمييز الأنواع وستكون TS قادرة على تحديد الصفات الموجودة في كل حالة case من حالات البنية switch case. وهكذا ستعطي خطأً إن حاولت على سبيل المثال أن تستخدم الصفة part.descriptionضمن كتلة الحالة Using props to pass data.

كيف سنضيف نوعًا جديدًا؟ من الجيد أن نعرف إن كنا قد أنجزنا مسبقًا آليةً للتعامل مع هذا النوع في شيفرتنا، عند إضافة قسم جديد للمنهاج. فإضافة أي نوع جديد في المثال السابق ستجعله موجودًا في الكتلة default من البنية switch case وبالتالي لن يطبع شيء عن هذا النوع. وقد يكون هذا الأمر مقبولًا في بعض الحالات، كالحالة التي نريد فيها التعامل مع نوع واحد محدد من النوع الموّحد، لكن في أغلب الحالات عليك التعامل مع كل الحالات وبشكل منفصل.

يمكن في TS أن نستخدم طريقة تدعى "التحقق الشامل من الأنواع". ومبدأ هذه الطريقة: أنه في حال واجهتنا قيمة غير معروفة النوع، نستدعي دالة تقبل قيمة من النوع never وتعيد قيمة من النوع نفسه.

تمثل الشيفرة التالية تطبيقًا مباشرًا لهذا المبدأ:

/**
 * Helper function for exhaustive type checking
 */
const assertNever = (value: never): never => {
  throw new Error(
    `Unhandled discriminated union member: ${JSON.stringify(value)}`
  );
};

لكن لو أردنا استبدال محتويات الكتلة كالتالي:

default:
  return assertNever(part);

ووضع علامة تعليق قبل الكتلة Deeper type usage case block سنرى الخطأ التالي:

type_never_helper_error_03.png

تنص الرسالة أن معاملًا من النوع CoursePartThree لم يُسند إلى معامل من النوع never. أي أننا نستخدم متغيرًا في مكان ما يفترض به أن يكون من النوع never. وهذا ما يدلنا على وجود مشكلة. لكن بمجرد أن نزيل علامة التعليق التي وضعناها على الكتلة Deeper type usage case block سيختفي الخطأ.

التمرين 9.15

9.15

أضف في البداية النوع information إلى الملف index.tsx واستبدل المتغير courseParts بالمتغير الموجود في المثال التالي:

// new types
interface CoursePartBase {
  name: string;
  exerciseCount: number;
}

interface CoursePartOne extends CoursePartBase {
  name: "Fundamentals";
  description: string;
}

interface CoursePartTwo extends CoursePartBase {
  name: "Using props to pass data";
  groupProjectCount: number;
}

interface CoursePartThree extends CoursePartBase {
  name: "Deeper type usage";
  description: string;
  exerciseSubmissionLink: string;
}

type CoursePart = CoursePartOne | CoursePartTwo | CoursePartThree;

// this is the new coursePart variable
const courseParts: CoursePart[] = [
  {
    name: "Fundamentals",
    exerciseCount: 10,
    description: "This is an awesome course part"
  },
  {
    name: "Using props to pass data",
    exerciseCount: 7,
    groupProjectCount: 3
  },
  {
    name: "Deeper type usage",
    exerciseCount: 14,
    description: "Confusing description",
    exerciseSubmissionLink: "https://fake-exercise-submit.made-up-url.dev"
  }
];

نعلم الآن أن واجهتي النوع CoursePartThree وCoursePartOne يتقاسمان صفة تدعى description بالإضافة إلى الصفات الأساسية (الموجودة في واجهة النوع الأساسية)، وهذه الصفة من النوع string في كلتا الواجهتين.

تقتضي مهمتك الأولى أن تصرِّح عن واجهة نوع جديد تتضمن الصفة description وتوسِّع واجهة النوع CoursePartThree. عدّل الشيفرة بعد ذلك بحيث تصبح قادرًا على إزالة الصفة description من الواجهتين دون حدوث أية أخطاء.

أنشئ بعد ذلك المكوّن Part الذي يصيّر كل الصفات من كل نوع ضمن أقسام المنهاج. استخدم آلية تحقق شاملة من الأنواع معتمدًا على بنية switch case. استخدم المكوِّن الجديد ضمن المكوّن Content.

أضف في النهاية واجهة نوع لقسم جديد يحوي على الأقل الصفات التالية: name وexerciseCount وdescription. ثم أضف واجهة النوع هذه إلى النوع الموّحد CoursePart وأضف البيانات المتعلقة بالقسم إلى المتغير CourseParts. إن لم تكن قد عدّلت المكوّن Content بشكل صحيح، ستحصل على رسالة خطأ، لأنك لم تضف ما يدعم النوع الخاص بالقسم الرابع. أجر التعديلات المناسبة على المكوّن Content لكي تُصيّر كل صفات القسم الجديد دون أخطاء.

ملاحظة حول تعريف أنواع للكائنات

لقد استخدمنا واجهات النوع لتعريف أنواع للكائنات مثل DiaryEntry من الفقرة السابقة:

interface DiaryEntry {
  id: number;
  date: string;
  weather: Weather;
  visibility: Visibility;
  comment?: string;
} 

وCoursePart من هذه الفقرة.

interface CoursePartBase {
  name: string;
  exerciseCount: number;
}

لقد كان بمقدورنا تنفيذ ذلك باستخدام نوع بديل type alias.

type DiaryEntry = {
  id: number;
  date: string;
  weather: Weather;
  visibility: Visibility;
  comment?: string;
} 

يمكنك استخدام الأسلوبين Interface وType لإنشاء نوع في معظم الحالات. لكن هنالك بعض النقاط التي ينبغي الانتباه لها. فلو عرَّفت عدة واجهات نوع لها نفس الاسم ستحصل على واجهة نوع مختلطة، بينما لو عرّفت عدة أنواع لها الاسم ذاته ستحصل على رسالة خطأ مفادها أنّ نوعًا يحمل نفس الاسم قد جرى التصريح عنه مسبقًا.

ينصحك توثيق TS باستخدام الواجهات في معظم الحالات.

العمل مع شيفرة جاهزة

من الأفضل عندما تبدأ العمل على شيفرة جاهزة للمرة الأولى أن تلقي نظرة شاملة على هيكلية المشروع وأسلوب العمل. يمكنك البدء بقراءة الملف README.md الموجود في جذر المستودع. يحتوي هذا الملف عادة على وصف موجز للتطبيق ومتطلباته، وكيف سنجعله يعمل لبدء عملية التطوير. إن لم يكن الملف README متاحًا أو أنّ أحدهم فضّل اختصار الوقت ووضعه كمرجع احتياطي، سيكون من الجيد الاطلاع على الملف package.json. ومن الجيد دائمًا تشغيل التطبيق وتجريبه والتحقق من وظائفه.

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

تزوّدك TS بالأنواع التي تخبرك عن شكل بنى البيانات والدوال والمكوّنات والحالة التي ستحصل عليها. يمكنك إلقاء نظرة على الملف types.ts أو أي ملف مشابه كبداية. كما سيساعدك VSCode كثيرًا فمجرد توضيحه للمتغيرات والمعاملات سيمنحك رؤية أفضل للشيفرة. ويعتمد هذا كله بالطبع على طريقة استخدام الأنواع في هذا المشروع.

إن احتوى المشروع اختبارات أجزاء أو اختبارت تكامل فسيكون الاطلاع عليها مفيدًا بالتأكيد. فالاختبارات أداة مفيدة جدًا عندما تعيد كتابة الشيفرة أو عند كتابة ميزات جديدة للتطبيق. وتأكد من عدم تخريب أية ميزة موجودة عندما تبدأ بالتلاعب بالشيفرة. سترشدك TS أيضًا بما يتعلق بالمعاملات والقيم المعادة عندما تتغير الشيفرة.

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

الواجهة الأمامية لتطبيق إدارة المرضى

لقد حان الوقت لإكمال الواجهة الأمامية للواجهة الخلفية التي بنيناها في التمارين السابقة وقبل أن نتعمق في كتابة الشيفرة، سنشغل الواجهتين معًا.

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

بعد التأكد من كل الأمور، يمكننا الشروع في دراسة الشيفرة. سنجد معظم النقاط المهمة في المجلد /‎src. ولمساعدتك هنالك أيضًا ملف جاهز يصف الأنواع الرئيسية التي يستخدمها التطبيق، والتي علينا ان نوسِّعها أو نعيد كتابتها خلال التمارين.

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

ستبدو هيكلية المجلد على النحو التالي

src_folder_strucure_04.png

تحتوي الواجهة الخلفية حاليًا مكوّنين هما: AddPatientModal وPatientListPage. يحتوي المجلد state على الشيفرة التي تتعامل مع حالة الواجهة الأمامية. وتقتصر وظيفة الشيفرة في هذا المجلد على إبقاء البيانات في مكان واحد وتزويد الواجهة بأفعال بسيطة لتبديل حالة التطبيق.

التعامل مع حالة التطبيق

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

بُني أسلوب إدارة الحالة باستخدام الخطافين useContext وuseReducer للمكتبة React. وهذا قرار صائب لأنّ التطبيق صغير نوعًا ما ولن نحتاج إلى Redux أو إلى أية مكتبات أخرى لإدارة الحالة. ستجد على الانترنت مواد تعليمة مفيدة عن استخدام هذه المقاربة.

تستخدم هذه المقاربة أيضًا الواجهة البرمجية context العائدة للمكتبة React، إذ ينص توثيق الواجهة أنها:

اقتباس

مصممة لمشاركة البيانات التي يمكن اعتبارها بيانات "عامة" لشجرة من مكوّنات React مثل بيانات المستخدم الذي سجل دخوله حاليًا والسمات واللغة المفضلة.

فالبيانات المشتركة في حالتنا هي حالة التطبيق ودالة الإيفاد التي تستخدم لإجراء التعديلات على البيانات. ستعمل شيفرتنا بأسلوب يشابه كثيرًا أسلوب Redux في إدارة الحالة والذي خبرناه في القسم 6، لكنها شيفرة أسرع لأنها لا تحتاج أية مكتبات خارجية. يفترض هذا القسم أنك مطلع على طريقة عمل Redux على الأقل، وبعبارة أخرى من المفترض أنك اطلعت على الفصل الأول من القسم 6.

تتضمن الواجهة البرمجية context قناة تضم حالة التطبيق ودالة إيفاد لتغيير الحالة. وقد حُدِّد نوع للحالة على النحو التالي:

export type State = {
  patients: { [id: string]: Patient };
};

فالحالة كما تُظهر الشيفرة هي كائن بمفتاح واحد يدعى Patients يمتلك قاموسًا، أو بعبارة أخرى، يقبل كائنًا له مفاتيح من النوع "string" مع كائن Patient كقيمة له. يمكن لقرينة الفهرسة أن تكون من أحد النوعين "string" أو"number" إذ يمكننا الوصول إلى قيم الكائن باستخدام هذين النوعين. وبهذا نجبر الحالة أن تتقيد بالصيغة التي نريد، وتمنع المطورين من استخدامها بشكل غير صحيح.

لكن انتبه إلى نقطة مهمة! عندما يُصرّح عن نوع بالطريقة التي اتبعناها مع patients، فلن تمتلك TS أية طريقة لمعرفة إن كان المفتاح الذي تحاول الوصول إليه موجودًا أم لا. فلو حاولنا الوصول إلى بيانات مريض بمعرِّف غير موجود، سيعتقد المصرّف أن القيمة المعادة من النوع Patient، ولن تلق أية أخطاء عند محاولة الوصول إلى خصائص هذا الكائن:

const myPatient = state.patients['non-existing-id'];
console.log(myPatient.name); // لا أخطاء إذ يعتقد المصرّف أن القيمة المعادة 
//patient من النوع

لإصلاح الخلل، يمكننا أن نعرّف نوع القيم التي تحمل بيانات المريض على أنها من نوعٍ موحّد بين Patient وundefined كالتالي:

export type State = {
  patients: { [id: string]: Patient | undefined };
};

وجراء هذا الحل، سيحذرنا المصرّف بالرسالة التالية:

const myPatient = state.patients['non-existing-id'];
console.log(myPatient.name); // error, Object is possibly 'undefined'

من الجيد دائمًا إنشاء هذا النوع مع بعض الإضافات الأمنية عندما تستخدم مثلًا بيانات من مصادر خارجية أو قيمة أدخلها المستخدم للوصول إلى بيانات ضمن شيفرتك. لكن إن كنت متأكدًا من أن شيفرتك قادرة على التعامل مع البيانات غير الموجودة، فلا مانع أبدًا من استخدام أول حلٍ قدمناه.

وعلى الرغم من عدم استخدام هذا الأسلوب في هذا القسم، ينبغي الإشارة إلى أن استخدام كائنات Map سيؤمن لك طريقة أكثر تشددًا في استخدام الأنواع. حيث يمكنك أن تُصرح عن نوع لكلٍ من المفتاح ومحتواه. تعيد دالة الوصول ()getإلى كائنات Map نوعًا موحّدًا يجمع بين النوع المصرّح عنه وundefined، وبالتالي ستطلب TS تلقائيُا إجراء تحقق من البيانات المستخلصة من كائن Map:

interface State {
  patients: Map<string, Patient>;
}
...
const myPatient = state.patients.get('non-existing-id'); // type for myPatient is now Patient | undefined 
console.log(myPatient.name); // error, Object is possibly 'undefined'

console.log(myPatient?.name); // valid code, but will log 'undefined'

تُنفذ التعديلات على الحالة باستخدام دوال الاختزال، تمامًا كما في Redux. عُرِّفت هذه الدوال في الملف reducer.ts بالإضافة إلى النوع Action الذي يبدو على النحو التالي:

export type Action =
  | {
      type: "SET_PATIENT_LIST";
      payload: Patient[];
    }
  | {
      type: "ADD_PATIENT";
      payload: Patient;
    };

تبدو دالة الاختزال مشابه تمامًا للدوال التي أنشأناها في القسم 6. حيث تغيّر حالة كل نوع من الأفعال:

export const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "SET_PATIENT_LIST":
      return {
        ...state,
        patients: {
          ...action.payload.reduce(
            (memo, patient) => ({ ...memo, [patient.id]: patient }),
            {}
          ),
          ...state.patients
        }
      };
    case "ADD_PATIENT":
      return {
        ...state,
        patients: {
          ...state.patients,
          [action.payload.id]: action.payload
        }
      };
    default:
      return state;
  }
};

ينحصر الفرق في أنّ الحالة الآن على شكل قاموس (أو كائن) بدلًا من المصفوفة التي استخدمناها في القسم 6.

تجري الكثير من الأمور في الملف state.ts والتي تهيئ سياق العمل. ويعتبر الخطاف useReducer الذي يستخدم لإنشاء الحالة، ودالة الإيفاد المكونان الرئيسيان لإنجاز التغييرات على حالة التطبيق، حيث يمرران إلى التابع context.povider:

export const StateProvider: React.FC<StateProviderProps> = ({
  reducer,
  children
}: StateProviderProps) => {
  const [state, dispatch] = useReducer(reducer, initialState);
    return (
    <StateContext.Provider value={[state, dispatch]}>
        {children}
    </StateContext.Provider>
  );
};

يستخدم التابع السابق في تزويد كل المكوّنات بالحالة ودالة الإيفاد، وذلك بفضل الإعدادات الموجودة في الملف index.ts:

import { reducer, StateProvider } from "./state";

ReactDOM.render(
  <StateProvider reducer={reducer}>
    <App />
  </StateProvider>, 
  document.getElementById('root')
);

كما يُعرّف أيضًا الخطاف useStateValue:

export const useStateValue = () => useContext(StateContext);

وتستعمله أيضًا المكوّنات التي تحتاج إلى الحالة أو دالة الإيفاد لتخزينهما:

import { useStateValue } from "../state";

// ...

const PatientListPage: React.FC = () => {
  const [{ patients }, dispatch] = useStateValue();
  // ...
}

لا تقلق إن بدا الأمر مربكًا قليلًا، فبالطبع سيبقى كذلك حتى تدرس توثيق context وطريقة استخدامها في إدارة الحالة. لكن ليس عليك فهم كل ذلك بشكل كامل حتى تحل التمارين.

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

صفحة قائمة المرضى

لنتوجه إلى الملف index.ts الموجود في المجلد PateintListPage لنأخذ بعض الأفكار التي تساعدنا على إحضار البيانات من الواجهة الخلفية وتحديث حالة التطبيق. تستخدم الصفحة خطافًا أنشئ خصيصًا لوضع البيانات في الحالة ودالة الإيفاد لتحديث محتوياتها. فعندما ننشئ قائمة بالمرضى لا نحتاج سوى تفكيك خصائص الكائن patients الموجود في حالة التطبيق:

import { useStateValue } from "../state";

const PatientListPage: React.FC = () => {
  const [{ patients }, dispatch] = useStateValue();
  // ...
}

ونستخدم أيضًا الحالة app التي أنشئت باستخدام الخطاف useState لإدارة حالات إظهار الوحدة أو إخفائها، وكذلك معالجة الأخطاء:

const [modalOpen, setModalOpen] = React.useState<boolean>(false);
const [error, setError] = React.useState<string | undefined>();

نمرر للخطاف useState نوعًا كمعامل، ثم يُطبَّق على الحالة الفعلية. فالمعامل modalOpen من النوع Boolean والمعامل error من النوع string | undefined. وكلا دالتي الضبط اللتان يعيدهما الخطاف useState سيقبلان معاملات من نوع يماثل النوع الذي يمرر إليها من خلال معامل (يمتلك نوعًا). فالنوع الفعلي للدالة setModalOpen هو ما يلي:

 <<React.Dispatch<React.SetStateAction<boolean.

كما نستخدم الدالتين المساعدتين closeModal وopenModal لقراءة البيانات بشكل أفضل وأكثر ملاءمة:

const openModal = (): void => setModalOpen(true);

const closeModal = (): void => {
  setModalOpen(false);
  setError(undefined);
};

تتعلق الأنواع في الواجهة الأمامية بما قمت بتطويره بإنشائه عند تطوير الواجهة الخلفية في الفصل السابق.

سيحضر المكوّن App عندما يُثبَّت المرضى مستخدمًا المكتبة axios. ويجدر الانتباه إلى أننا مررنا نوعًا على شكل معامل إلى الدالة axios.get لكي نحدد نوع البيانات التي سنحصل عليها من الاستجابة:

React.useEffect(() => {
  axios.get<void>(`${apiBaseUrl}/ping`);

  const fetchPatientList = async () => {
    try {
      const { data: patients } = await axios.get<Patient[]>(
        `${apiBaseUrl}/patients`
      );
      dispatch({ type: "SET_PATIENT_LIST", payload: patients });
    } catch (e) {
      console.error(e);
    }
  };
  fetchPatientList();
}, [dispatch]);

تحذير! لن تُقيَّم البيانات عندما نمرر النوع كمعامل إلى المكتبة axios. وهو خطر تمامًا وخاصة إن كنت تستخدم واجهة برمجية خارجية. لتفادي ذلك يمكنك إنشاء دوال تقييم مخصصة تتحمل عبء التقييم وتعيد النوع الصحيح، أو يمكنك استخدام "حاميات النوع". ستجد أيضًا عدة مكتبات تزوّدك بأساليب لتقييم البيانات ضمن أنواع مختلفة من التخطيطات، مثل المكتبة io-ts. لكننا وتوخيًا للبساطة سنثق أننا سنحصل على البيانات بشكلها الصحيح من الواجهة الخلفية.

وطالما أن تطبيقنا صغير، سنحدِّث الحالة باستدعاء دالة الإيفاد التي يؤمنها لنا الخطاف useStateValue. وسيساعدنا المصرّف بالتأكد من أننا أوفدنا الأفعال بما يوافق النوع Action الذي يضم قيمة محددة مسبقًا من النوع string وحمولة من البيانات payload.

dispatch({ type: "SET_PATIENT_LIST", payload: patients });

التمارين 9.16 - 9.18

سنضيف قريبًا النوع Entry إلى التطبيق والذي يمثّل مُدخلًا على شكل ملخّص بسيط عن المريض. يتكون الملخص من نص وصفي، وتاريخ الإنشاء، ومعلومات تتعلق بالاختصاصي الذي أنشأ الملخص ورمز التشخيص المحتمل. ترتبط شيفرة التشخيص بالرموز ICD-10 التي تُعيدها وصلة التخديم api/diagnosis/. سيكون ما ننفذه بسيطًا، إذ يعطي لكل مريض مصفوفة من المُدخلات.

سنُجري بعض التحضيرات، قبل الشروع في العمل.

9.16 تطبيق إدارة المرضى: الخطوة 1

أنشئ وصلة تخديم على العنوان api/patients/:id/ تعيد كل المعلومات عن مريض، بما فيها مصفوفة المُدخلات الخاصة به والتي ستبقى فارغة لجميع المرضى. وسّع حاليًا الأنواع في الواجهة الخلفية على النحو التالي:

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Entry {
}

export interface Patient {
  id: string;
  name: string;
  ssn: string;
  occupation: string;
  gender: Gender;
  dateOfBirth: string;
  entries: Entry[]}

export type PublicPatient = Omit<Patient, 'ssn' | 'entries' >

ينبغي أن تبدو الاستجابة كالتالي:

backend_response_patients_05.png

9.17 تطبيق إدارة المرضى: الخطوة 2.

أنشئ صفحة لإظهار المعلومات الكاملة عن مريض ضمن الواجهة الأمامية.

ينبغي أن يكون المستخدم قادرًا على الوصول إلى معلومات المريض بالنقر على اسم المريض مثلًا.

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

طالما أننا سنستخدم حالة التطبيق ضمن سياق العمل، عليك أن تُعرّف فعلًا جديدًا لتحديث بيانات مريض محدد.

يستخدم التطبيق المكتبة Semantic UI React لإضافة التنسيقات إليه. وهي مكتبة مشابهة كثيرًا للمكتبتين React Bootstrap وMaterialUI اللتان تعاملنا معهما في القسم 7. يمكنك استخدامهما لتنسيق المكوّن الجديد، وهذا أمر يعود إليك، فاهتمامنا ينصب الآن على TS.

ويستخدم التطبيق أيضًا المكتبة react router للتحكم بإظهار واجهات العرض على الشاشة. عليك إلقاء نظرة على القسم 7 إن لم تكن متمكنًا من فهم آلية عمل الموجّهات routers.

ستبدو النتيجة كالتالي:

patientor_step2_06.png

يظهر جنس المريض باستخدام المكون Icon من المكتبة Semantic UI React.

ملاحظة: لتصل إلى المُعرِّف بكتابة عنوان المورد، عليك إعطاء الخطاف useParams معامل من نوع مناسب:

const { id } = useParams<{ id: string }>();

9.18 تطبيق إدارة المرضى: الخطوة 3.

سننشئ حاليًا كائنات أفعال action في أي مكان نوفد إليه هذه الأفعال. إذ يمتلك المكوّن App مثلًا دالة الإيفاد التالية:

dispatch({
  type: "SET_PATIENT_LIST", payload: patientListFromApi
});

أعد كتابة الشيفرة مستخدمًا دوال توليد الأفعال المُعرّفة ضمن الملف "reducer.tsx".

سيتغير المكوِّن App مثلًا على النحو التالي:

import { useStateValue, setPatientList } from "./state";

// ...

dispatch(setPatientList(patientListFromApi));

مدخلات كاملة

أنجزنا في التمرين 9.12 وصلة تخديم لإحضار تشخيص مريض، لكننا لم نستخدمها بعد. من الجيد توسيع بياناتنا قليلُا بعد أن أصبح لدينا صفحة لعرض معلومات المرضى. لنضف إذًا حقلًا من النوع Entry إلى بيانات المريض، تتضمن مدخلاته الدوائية مع التشخيص المحتمل.

لنفصل بنية بيانات المرضى السابقة عن الواجهة الخلفية ونستخدم الشكل الجديد الموسَّع.

ملاحظة: إن تنسيق البيانات هذه المرة بالصيغة "ts." وليس بالصيغة ".json". كما أن النوعين Gender وPatient جاهزين مسبقًا، لذلك كل ما عليك الآن هو تصحيح مسار إدراجهما إن اقتضت الحاجة.

لننشئ النوع Entry بشكل ملائم بناء على البيانات المتوفرة.

لو ألقينا الآن نظرة أقرب على البيانات المتوفرة، سنجد أن المُدخلات مختلفة عن بعضها قليلًا. لاحظ الاختلافات بين أول مدخلين على سبيل المثال:

{
  id: 'd811e46d-70b3-4d90-b090-4535c7cf8fb1',
  date: '2015-01-02',
  type: 'Hospital',
  specialist: 'MD House',
  diagnosisCodes: ['S62.5'],
  description:
    "Healing time appr. 2 weeks. patient doesn't remember how he got the injury.",
  discharge: {
    date: '2015-01-16',
    criteria: 'Thumb has healed.',
  }
}
...
{
  id: 'fcd59fa6-c4b4-4fec-ac4d-df4fe1f85f62',
  date: '2019-08-05',
  type: 'OccupationalHealthcare',
  specialist: 'MD House',
  employerName: 'HyPD',
  diagnosisCodes: ['Z57.1', 'Z74.3', 'M51.2'],
  description:
    'Patient mistakenly found himself in a nuclear plant waste site without protection gear. Very minor radiation poisoning. ',
  sickLeave: {
    startDate: '2019-08-05',
    endDate: '2019-08-28'
  }
}

سنرى أن بعض الحقول متطابقة لكن المُدخل الأول يمتلك الحقل discharge والثاني يمتلك الحقلين employerName وsickLeave. وبشكل عام تحتوي جميع المدخلات حقول مشتركة وحقول خاصة.

وبالنظر إلى الحقل type، سنجد ثلاثة أشكال للمدخلات تشير إلى حاجتنا إلى ثلاثة أنواع منفصلة:

  • OccupationalHealthcare
  • Hospital
  • HealthCheck.

تشترك المدخلات بعدة حقول، وبالتالي من المناسب إنشاء واجهة نوع أساسي تدعى "Entry" قابلة للتوسّع بإضافة حقول خاصة لكل نوع.

تُعَدّ الحقول الآتية مشتركةً بين كل المدخلات:

  •  id
  • description
  • date
  • specialist

ويبدو أن الحقل diagnosesCodes موجود فقط في المدخلات من النوعين OccupationalHealthcare وHospital. وطالما أنه لا يستخدم دائمًا حتى في هذين النوعين، فسنفترض أنه حقل اختياري. ويمكن إضافته إلى النوع HealthCheck أيضًا، إذ ليس من الضرورة استخدامه في أي من المدخلات الثلاث.

ستبدو واجهة النوع الأساسي كالتالي:

interface BaseEntry {
  id: string;
  description: string;
  date: string;
  specialist: string;
  diagnosisCodes?: string[];
}

لو أردنا تهيئة الواجهة بشكل أفضل، ولعلمنا أن النوع Diagnoses قد عُرِّف مسبقًا في الواجهة الخلفية، من الممكن إذًا الإشارة مباشرة إلى حقل الشيفرة للنوع Diagnoses في حال تغيّر نوعه لسبب ما. سننفذ ذلك على النحو التالي:

interface BaseEntry {
  id: string;
  description: string;
  date: string;
  specialist: string;
  diagnosisCodes?: Array<Diagnosis['code']>;
}

وتذكّر أن <Array<Type هي صيغة بديلة للتعليمة []Type. ومن الأفضل والأوضح في حالات كهذه أن نستخدم المصفوفة، لأن استخدام الخيار الآخر سيدفعنا إلى تعريف النوع بالعبارة DiagnosisCode والتي تبدو غريبة بعض الشيء.

يمكننا الآن وبعد تعريف النوع الأساسي Entry، أن ننشئ الأنواع Entry الموسّعة التي سنستخدمها فعليًا. وسنبدأ بإنشاء النوع HealthCheckEntry.

تحتوي المدخلات من النوع HealthCheck على الحقل HealthCheckRating الذي يأخذ قيمًا صحيحة بين 0 و 3. تعني القيمة 0 أن المريض بصحة جيدة، أما القيمة 3 فتعني أن المريض بحالة حرجة. وهذا ما يجعل استخدام التعداد مثاليًا. وبناء على المعطيات السابقة يمكن تعريف النوع HealthCheckEntry على النحو التالي:

export enum HealthCheckRating {
  "Healthy" = 0,
  "LowRisk" = 1,
  "HighRisk" = 2,
  "CriticalRisk" = 3
}

interface HealthCheckEntry extends BaseEntry {
  type: "HealthCheck";
  healthCheckRating: HealthCheckRating;
}

يبقى علينا الآن إنشاء النوعين OccupationalHealthcareEntry وHospitalEntry ومن ثم نضم الأنواع الثلاثة ونصدّرها كنوع موحّد Entry كالتالي:

export type Entry =
  | HospitalEntry
  | OccupationalHealthcareEntry
  | HealthCheckEntry;

التمارين 9.19 - 9.22

9.19 تطبيق إدارة المرضى: الخطوة 4

عّرف النوعين بما يتلائم مع البيانات الموجودة. تأكد أن الواجهة الخلفية ستعيد المُدخل المناسب عندما تتوجه لإحضار معلومات مريض محدد.

patientor_step4_07.png

استخدم الأنواع بشكل صحيح في الواجهة الخلفية. لا حاجة الآن لتقييم بيانات كل الحقول في الواجهة الخلفية، ويكفي التحقق أن الحقل type سيحتوي قيمة من النوع الصحيح.

9.20 تطبيق إدارة المرضى: الخطوة 5

وسّع صفحة المريض في الواجهة الأمامية لإظهار قائمة تضم تاريخ ووصف ورمز التشخيص لكل مدخلات المريض.

يمكنك استخدام نفس النوع Entry الذي عرّفناه الآن ضمن الواجهة الأمامية. ويكفي في هذه التمارين أن ننقل تعريف الأنواع كما هو من الواجهة الخلفية إلى الأمامية (نسخ/لصق).

قد يبدو حلك قريبًا من التالي:

pateintor_step5_08.png

9.21 تطبيق إدارة المرضى: الخطوة 6

أحضر التشخيص ثم أضفه إلى حالة التطبيق من وصلة التخديم api/diagnosis/. استخدم بيانات التشخيص الجديدة لإظهار توصيف رموز التشخيصات للمريض.

patientor_step6_09.png

9.22 تطبيق إدارة المرضى: الخطوة 7

وسّع قائمة المدخلات في صفحة المريض لتتضمن تفاصيل أكثر باستخدام مكوّن جديد يُظهِر بقية المعلومات الموجودة ضمن مدخلات المريض ومميزًا الأنواع عن بعضها.

يمكنك أن تستخدمعلى سبيل المثال المكوّن Icon أو أي مكوًن آخر من مكوّنات المكتبة SemanticUI للحصول على مظهر مناسب لقائمتك.

ينبغي تنفيذ عملية تصيير شرطية باستخدام البنية switch case وآلية التحقق الشاملة من الأنواع لكي لا تنس أية حالة.

تأمل الشكل التالي:

patientor_step7_10.png

ستبدو قائمة المدخلات قريبة من الشكل التالي:

patientor_step7_entries_list_11.png

نموذج لإضافة مريض

قد يكون التعامل مع النماذج مزعجًا أحيانًا في ، لذلك قررنا استخدام الحزمة Formik لإنشاء نموذج لإضافة مريض في تطبيقنا. فيما سيأتي تقديم بسيط اجتزأناه من توثيق :

اقتباس

Formik هي مكتبة صغيرة تساعدك في تطوير الأقسام الثلاث الأكثر إزعاجًا

اقتباس
  • إحضار المعلومات من وإلى حالة التطبيق
  • التقييم وإظهار رسائل الخطأ
  • التعامل مع عمليات إرسال معلومات النموذج

ستنظم Formik الأشياء بتجميع العمليات السابقة في مكان واحد، وستغدو الاختبارات و إعادة كتابة الشيفرة وتقييم النماذج أمرًا يسيرًا.

يمكنك إيجاد شيفرة النموذج في الملف src/AddPatientModal/AddPatientForm.tsx ،كما يمكنك إيجاد الدوال المساعدة لهذا النموذج في الملف src/AddPatientModal/FormField.tsx.

لو نظرت إلى الملف AddPatientForm.tsx لوجدت أننا أنشأنا نوعًا لقيم النموذج يدعى PatientFormValues. يمثل هذا النوع نسخة معدلة عن النوع Patient بعد حذف الخاصيتين id و entries. فلا نريد أن يرسل المستخدم المعلومات الموجودة ضمن هاتين الخاصيتين عند إنشاء مريض جديد. فالمعرِّف id ستنشئه الواجهة الخلفية والمدخلات entries لا يمكن إضافتها إلا لمريض موجود مسبقًا.

export type PatientFormValues = Omit<Patient, "id" | "entries">;

سنصرّح تاليًا عن خصائص المكوّن الخاص بالنموذج:

interface Props {
  onSubmit: (values: PatientFormValues) => void;
  onCancel: () => void;
}

يتطلب المكوّن وجود خاصيتين: onSubmit وonCancel. وكلاهما دالة استدعاء لا تعيد قيمًا. ينبغي أن تتلقى الدالة كائنًا من النمط "PatientFormValues" كمعامل لكي تتمكن من معالجة قيم النموذج.

بالنظر إلى مكوّن الدالة AddPatientForm، ستجد أننا ربطنا به خصائصه، ومن ثم فككنا onSubmit وonCancel من هذه الخصائص.

export const AddPatientForm: React.FC<Props> = ({ onSubmit, onCancel }) => {
  // ...
}

قبل أن نكمل، لنلق نظرة على الدوال المساعدة في نموذجنا والموجودة في الملف FormField.tsx. لو تحققت من الأشياء التي يصدرها الملف، ستجد النوع GenderOption ومكوني الدوال SelectField وTextField.

لنلق نظرة على مكوِّن الدالة SelectField والأنواع المتعلقة به. أنشأنا أولًا نوعًا معممًا لكل كائن خيارات يتضمن قيمة وتسمية. هذه هي أنواع كائنات الخيارات التي سنسمح بها في نموذجنا ضمن حقل الخيارات. وطالما أن الخيار الوحيد الذي سنسمح به هو جنس المريض سنجعل القيمة value من النوع Gender.

export type GenderOption = {
  value: Gender;
  label: string;
};

أعطينا المتغير genderOptions في الملف AddPatientForm.tsx النوع GenderOption وصرحنا أنه مصفوفة تحتوي على كائنات من النوع GenderOption.

const genderOptions: GenderOption[] = [
  { value: Gender.Male, label: "Male" },
  { value: Gender.Female, label: "Female" },
  { value: Gender.Other, label: "Other" }
];

لاحظ إيضًا النوع SelectFieldProps. إذ يقوم بتعريف نوع لخصائص مكوِّن الدالة SelectField. سترى أن الخيارات عبارة عن مصفوفة من النوع GenderOption.

type SelectFieldProps = {
  name: string;
  label: string;
  options: GenderOption[];
};

إن عمل مكوِّن الدالة SelectField واضح تمامًا. فهو يصيّر التسمية ويختار عنصرًا، ومن ثم يعطي كل الخيارات المتاحة لهذا العنصر ( قيم هذه الخيارات وتسمياتها).

export const SelectField: React.FC<SelectFieldProps> = ({
  name,
  label,
  options
}: SelectFieldProps) => (
  <Form.Field>
    <label>{label}</label>
    <Field as="select" name={name} className="ui dropdown">
      {options.map(option => (
        <option key={option.value} value={option.value}>
          {option.label || option.value}
        </option>
      ))}
    </Field>
  </Form.Field>
);

لننتقل الآن إلى مكوِّن الدالة TextField. يُصيِّر هذا المكوّن الكائن Form.Field من المكتبة SemanticUI إلى تسمية ومكوّن Field من المكتبة Formik. يتلقى هذا المكوًن القيمة name والقيمة placeholder كخصائص.

interface TextProps extends FieldProps {
  label: string;
  placeholder: string;
}

export const TextField: React.FC<TextProps> = ({ field, label, placeholder }) => (
  <Form.Field>
    <label>{label}</label>
    <Field placeholder={placeholder} {...field} />
    <div style={{ color:'red' }}>
      <ErrorMessage name={field.name} />
    </div>
  </Form.Field>
);

لاحظ أننا استخدمنا المكوّن ErrorMessage من المكتبة Formik لتصيير رسالة خطأ تتعلق بالقيمة المدخلة إن اقتضى الأمر. ينفذ المكوّن كل ما يلزم خلف الستار، فلا حاجة لكتابة شيفرة خاصة لذلك.

من الممكن أيضًا الاحتفاظ برسالة الخطأ ضمن المكوّن باستخدام الخاصيّة form.

export const TextField: React.FC<TextProps> = ({ field, label, placeholder, form }) => {
  console.log(form.errors); 
  // ...
}

سنعود الآن إلى مكوّن النموذج الفعلي AddPatientForm.tsx. سيصيّر مكون الدالة AddPatientForm مكوّن Formik. سيغلّف هذا المكون بقية المكوّنات ويتطلب خاصيتين initialValues وonSubmit. شيفرة دالة الخصائص واضحة تمامًا. سيتعقب مكوِّن Formik حالة النموذج وسيظهر هذه الحالة بالإضافة إلى عدة توابع قابلة لإعادة الاستخدام ومعالجات الأحداث ضمن النموذج عن طريق الخصائص.

نستخدم أيضًا الخاصية الاختيارية valiate التي تقبل دالة تقييم وتعيد كائنًا يتضمن الأخطاء المحتملة. نتحقق في نموذجنا أن الحقول النصية ليست فارغة، لكن بالإمكان أن نضيف بعض المعايير الأخرى للتقييم كالتأكد من صحة رقم الضمان الاجتماعي على سبيل المثال. يمكن لرسائل الخطأ التي تحددها دالة التقييم أن تُعرض ضمن مكوّن ErrorMessage الخاص بكل حقل.

لنلق نظرة أولًا على المكوّن بشكله الكامل. ثم سنناقش بعدها أقسامه المختلفة بشيء من التفصيل.

interface Props {
  onSubmit: (values: PatientFormValues) => void;
  onCancel: () => void;
}

export const AddPatientForm: React.FC<Props> = ({ onSubmit, onCancel }) => {
  return (
    <Formik
      initialValues={{
        name: "",
        ssn: "",
        dateOfBirth: "",
        occupation: "",
        gender: Gender.Other
      }}
      onSubmit={onSubmit}
      validate={values => {
        const requiredError = "Field is required";
        const errors: { [field: string]: string } = {};
        if (!values.name) {
          errors.name = requiredError;
        }
        if (!values.ssn) {
          errors.ssn = requiredError;
        }
        if (!values.dateOfBirth) {
          errors.dateOfBirth = requiredError;
        }
        if (!values.occupation) {
          errors.occupation = requiredError;
        }
        return errors;
      }}
    >
      {({ isValid, dirty }) => {
        return (
          <Form className="form ui">
            <Field
              label="Name"
              placeholder="Name"
              name="name"
              component={TextField}
            />
            <Field
              label="Social Security Number"
              placeholder="SSN"
              name="ssn"
              component={TextField}
            />
            <Field
              label="Date Of Birth"
              placeholder="YYYY-MM-DD"
              name="dateOfBirth"
              component={TextField}
            />
            <Field
              label="Occupation"
              placeholder="Occupation"
              name="occupation"
              component={TextField}
            />
            <SelectField
              label="Gender"
              name="gender"
              options={genderOptions}
            />
            <Grid>
              <Grid.Column floated="left" width={5}>
                <Button type="button" onClick={onCancel} color="red">
                  Cancel
                </Button>
              </Grid.Column>
              <Grid.Column floated="right" width={5}>
                <Button
                  type="submit"
                  floated="right"
                  color="green"
                  disabled={!dirty || !isValid}
                >
                  Add
                </Button>
              </Grid.Column>
            </Grid>
          </Form>
        );
      }}
    </Formik>
  );
};

export default AddPatientForm;

يمتلك مكوّن التغليف دالة تعيد محتويات النموذج كمكوِّن ابن child component. ونستخدم العنصر Form من المكتبة Formik لتصيير العناصر الفعلية للنموذج. سنضع داخل هذا العنصر مكونات الدوال TextField وSelectField التي أنشأناها في الملف FormField.tsx.

سننشئ في النهاية زرين: الأول لإلغاء إرسال معلومات النموذج، وآخر لإرسالها. يستدعي الزر الأول الدالة onCancel مباشرة، ويحرّض الزر الثاني الحدث onSubmitt للمكتبة Formik الذي يستدعي بدوره الدالة onSubmitt من خصائص المكوّن. سيُفعَّل زر الإرسال إن كان النموذج صالحًا (يعيد القيمة valid) و مستعملًا (يعيد القيمة dirty). أي عندما يضيف المستخدم معلومات ضمن بعض الحقول.

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

وُضع الزرين داخل العنصر Grid من المكتبة Formik لضبطهما متجاورين بطريقة سهلة.

<Grid>
  <Grid.Column floated="left" width={5}>
    <Button type="button" onClick={onCancel} color="red">
      Cancel
    </Button>
  </Grid.Column>
  <Grid.Column floated="right" width={5}>
    <Button type="submit" floated="right" color="green">
      Add
    </Button>
  </Grid.Column>
</Grid>

يُمرر الاستدعاء onSubmit من صفحة قائمة المرضى نزولًا إلى نموذج إضافة مريض، ليرسل طلب HTTP-POST إلى الواجهة الخلفية، ويضيف المريض الذي تعيده الواجهة الخلفية إلى حالة التطبيق ويغلق النموذج. إن أعادت الواجهة الخلفية خطأً، سيُعرض الخطأ على النموذج.

تمثل الشيفرة التالية دالة الاستدعاء:

const submitNewPatient = async (values: FormValues) => {
  try {
    const { data: newPatient } = await axios.post<Patient>(
      `${apiBaseUrl}/patients`,
      values
    );
    dispatch({ type: "ADD_PATIENT", payload: newPatient });
    closeModal();
  } catch (e) {
    console.error(e.response.data);
    setError(e.response.data.error);
  }
};

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

التمارين 9.23 - 9.27

9.23 تطبيق إدارة المرضى: الخطوة 8

صممنا التطبيق بحيث يمكن أن تجد عدة مدخلات لنفس المريض. لا توجد حتى الآن آلية في تطبيقنا لإضافة مُدخلات. عليك الآن أن تضيف وصلة تخديم على العنوان api/patients/:id/entries/ في الواجهة الخلفية، لتتمكن من إضافة مُدخل لمريض.

تذكر أن هناك أنواع مختلفة من المدخلات في تطبيقنا، ويجب أن تدعم الواجهة الخلفية كل هذه الأنواع، وتحقق من وجود كل الحقول المطلوبة لكل نوع.

9.24 تطبيق إدارة المرضى: الخطوة 9

ستدعم الواجهة الخلفية الآن إضافة المُدخلات، ونريد أن ننفذ الوظيفة نفسها في الواجهة الأمامية. عليك إذًا إنشاء نموذج لإضافة مُدخل إلى سجل مريض. وتعتبر صفحة المريض مكانًا مناسبًا للوصول إلى هذا النموذج.

يكفي في هذا التمرين أن تدعم نوعًا واحدًا من المدخلات، وليس مطلوبًا معالجة كافة الأخطاء الآن. إذ يكفي أن تنشئ بنجاح مدخلًا جديدًا عندما تُملأ حقول النموذج ببيانات صحيحة.

ينبغي أن يُضاف مُدخل جديد إلى سجل المريض المحدد بعد نجاح عملية إرسال بيانات النموذج. كما ينبغي تحديث صفحة المريض ليظهر المُدخل الجديد.

يمكنك إن أردت استخدام جزء من شيفرة نموذج إضافة مريض، لكن ذلك غير ضروري.

انتبه إلى وجود المكوّن الجاهز GiagnosesSelection في الملف FormField.tsx حيث يمكنك استخدامه لضبط الحقل diagnoses بالشكل التالي:

const AddEntryForm: React.FC<Props> = ({ onSubmit, onCancel }) => {
  const [{ diagnoses }] = useStateValue()
  return (
    <Formik
    initialValues={{
      /// ...
    }}
    onSubmit={onSubmit}
    validate={values => {
      /// ...
    }}
  >
    {({ isValid, dirty, setFieldValue, setFieldTouched }) => {
      return (
        <Form className="form ui">
          // ...

          <DiagnosisSelection
          setFieldValue={setFieldValue}
          setFieldTouched={setFieldTouched}
          diagnoses={Object.values(diagnoses)}          />    
          // ...
        </Form>
      );
    }}
  </Formik>
  );
};

كما يوجد أيضًا مكوّن جاهز يدعى NumberField للقيم العددية ضمن مجال محدد:

<Field
  label="healthCheckRating"
  name="healthCheckRating"
  component={NumberField}
  min={0}
  max={3}
/>

9.25 تطبيق إدارة المرضى: الخطوة 10

وسّع التطبيق ليُظهر رسالة خطأ إن كانت إحدى القيم المطلوبة مفقودة أو بتنسيق غير صحيح.

9.26 تطبيق إدارة المرضى: الخطوة 11

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

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

9.27 تطبيق إدارة المرضى: الخطوة 12

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

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

ترجمة -وبتصرف- للفصل React with Types من سلسلة Deep Dive Into Modern Web Development


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...