Menu
CoddyTech

الترتيب

جزء من قسم واجهات REST API في رحلة Node.js على Coddy. الدرس 16 من 25.

يريد العميل ظهور أحدث المهام أولًا، أو ترتيب القائمة من الألف إلى الياء. من الأعراف الشائعة استخدام ?sort=field، ووضع - في البداية لعكس الترتيب:

// GET /api/books?sort=-year
const field = sort.startsWith('-') ? sort.slice(1) : sort;   // 'year'
const direction = sort.startsWith('-') ? -1 : 1;
list = list.toSorted((a, b) => (a[field] - b[field]) * direction);

تحقق أولًا من أن field ضمن الحقول المسموح بها، وأعِد 400 لأي قيمة أخرى، لأن الحقل الذي لا تملكه الكتب لا يوفّر ترتيبًا مفيدًا. تعيد toSorted نسخة مرتبة. أما sort فتعيد ترتيب المصفوفة نفسها، وإذا لم ينشئ أي مرشح نسخة، فستكون تلك المصفوفة هي المخزن: سيظهر الترتيب الجديد في كل طلب لاحق. يطرح هذا المثال قيمة من أخرى، وهذا لا يصلح إلا للأرقام. لمقارنة سلسلتين نصيتين، استخدم a.localeCompare(b): مثل الطرح، تعيد قيمة سالبة أو 0 أو قيمة موجبة. تواريخ ISO سلاسل نصية تُرتَّب زمنيًا.

challenge icon

التحدي

سهل

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

يريد العملاء عرض أحدث المهام أولاً، أو ترتيب القائمة من الألف إلى الياء. دع القائمة ترتب نفسها باستخدام ?sort=.

  1. في routes/tasks.js، ضمن مسار القائمة، اقرأ sort من req.query. من دونه، تحتفظ القائمة بترتيبها المخزن.
  2. يرتب ?sort=title حسب العنوان من الألف إلى الياء. ويرتب ?sort=createdAt حسب وقت الإنشاء، من الأقدم أولاً. تعكس علامة - في البداية الترتيب: ?sort=-title يرتب من الياء إلى الألف، و?sort=-createdAt يعرض الأحدث أولاً.
  3. كلا الحقلين نصّان. createdAt تاريخ بتنسيق ISO مثل "2026-03-03T10:30:00.000Z"، ويُرتب بشكل صحيح كنص. لذا قارِن باستخدام localeCompare، لا بطرح القيم.
  4. أي حقل آخر، مثل ?sort=color، يعيد الحالة 400:
    { "error": { "code": "invalid_query", "message": "sort must be title or createdAt, with - in front to reverse" } }
  5. نفّذ الفرز بعد عوامل التصفية، لكي يعيد ?done=false&sort=-title المهام المفتوحة مرتبة من الياء إلى الألف.
  6. رتّب نسخة باستخدام toSorted. يعيد sort ترتيب المصفوفة المخزنة نفسها، وسترى كل الطلبات اللاحقة الترتيب الجديد.

جرّب ذلك: يعيد GET /api/tasks?sort=title المهام 2 و3 و1 ("Book...", "Send...", "Write..."). ويعيد GET /api/tasks?sort=-createdAt المهام 3 و2 و1. ثم يعيد GET /api/tasks العادي المهام 1 و2 و3 مجدداً. ويعيد GET /api/tasks?sort=color الحالة 400 مع الرمز invalid_query.

جرّب بنفسك

import { Router } from 'express';
import { z } from 'zod';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';

const router = Router();

// مهمة كاملة كما يرسلها PUT. ترفض .strict() الحقول التي لا تُدرجها.
const Task = z
    .object({
        title: z.string().trim().min(1).max(100),
        done: z.boolean(),
    })
    .strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();

// تكون القيمة True عندما تحمل مهمة أخرى هذا العنوان بالفعل، بغض النظر عن حالة الأحرف.
const titleTaken = (title, exceptId) =>
    tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());

router.get('/', (req, res) => {
    const { done, q } = req.query;
    let list = tasks;
    if (done !== undefined) {
        if (done !== 'true' && done !== 'false') {
            return sendError(res, 400, 'invalid_query', 'done must be true or false');
        }
        list = list.filter((t) => t.done === (done === 'true'));
    }
    if (q !== undefined) {
        const words = String(q).toLowerCase();
        list = list.filter((t) => t.title.toLowerCase().includes(words));
    }
    if (req.query.sort === 'title') {
        list = list.toSorted((a, b) => a.title.localeCompare(b.title));
    }
    res.json(list);
});

router.get('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    res.json(task);
});

router.post('/', (req, res) => {
    const result = NewTask.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { title, done = false } = result.data;
    if (titleTaken(title)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    const task = { id: nextId(), title, done, createdAt: new Date().toISOString() };
    tasks.push(task);
    res.status(201).location(`/api/tasks/${task.id}`).json(task);
});

router.patch('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = TaskChanges.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    Object.assign(task, result.data);
    res.json(task);
});

router.put('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    const result = Task.safeParse(req.body);
    if (!result.success) {
        return sendInvalid(res, result.error);
    }
    const { id, createdAt } = tasks[index];
    if (titleTaken(result.data.title, id)) {
        return sendError(res, 409, 'conflict', 'A task with this title already exists');
    }
    tasks[index] = { id, ...result.data, createdAt };
    res.json(tasks[index]);
});

router.delete('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return sendError(res, 404, 'not_found', 'Task not found');
    }
    tasks.splice(index, 1);
    res.status(204).end();
});

export default router;

جميع دروس واجهات REST API

تدرّب بنفسك: Playground لـ Express