تعيين العلامة الافتراضية لكل فئة في مكون سمة

إذا كنت تزور /tags/category-slug/tag-name ونقرت على زر موضوع جديد، فسيكون الوسم مُعدًا مسبقًا في محرر الموضوع، كما هو موضح هنا:

هذا رائع. لكنني الآن (وكذلك شخص واحد على الأقل) نريد القدرة على تعيين هذا السلوك باستخدام وسم افتراضي عند زيارة /c/cat-slug/cat-id. يبدو أن مكون السمة يجب أن يكون قادرًا على استهداف هذا الزر إما لتعديله أو لإخفائه وإضافة زر جديد (هناك منفذ لإضافة مكون إضافي هناك بالضبط، لكنني لم أعد أجده الآن، رغم أنني رأيته قبل دقيقة).

هل يمكن لأحد أن يعطيني تلميحًا؟

هل من المفترض أن يعمل فقط على فئة معينة، أم أنك تحتاج إلى دعم “وسم افتراضي” للعديد من الفئات، حيث يختلف هذا الوسم لكل فئة منها؟

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

ملخص سريع: تخطَّ إلى الكود النشط هنا


عندما تصفّح صفحة تحتوي على عنصر + موضوع جديد، يمكنك فحص كود HTML عبر أداة المطوّر.

إذا فعلت ذلك، ستلاحظ أن للعنصر معرفًا (id).

المعرفات في عناصر HTML مفترض أن تكون فريدة؛ أي لا يمكن لعنصرين في نفس العرض أن يتشاركا في نفس سمة id في HTML. لذا، هذا كافٍ لبدء التحليل.

إذا بحثت عن "create-topic" على GitHub، فإليك ما ستراه…

Repository search results · GitHub

لاحظ عامل التصفّح على اليسار.

أنا أعرف أنني أريد تتبع كود HTML الخاص بالزرّ، لذا سأبحث في قوالب Handlebars لأنني أحاول تتبع الإجراء الذي يُرسَل.

لذا، سأحدّد Handlebars؛ ثم أرى هذا.

Repository search results · GitHub

هناك نتيجة واحدة فقط، لذا نحن محظوظون. إذا كانت هناك نتائج أكثر، فهناك إجراءات يمكنك اتخاذها لتضييق القائمة أكثر، لكن ذلك خارج نطاق هذا الموضوع.

لذا، دعنا نتحقّق من ذلك الملف.

سترى بعد ذلك أن الإجراء المرتبط بالزرّ مُعيَّن كالتالي

action=action

حسنًا… هذا ليس مفيدًا جدًا… إذن، ماذا الآن؟

عندما ترى action=action فهذا يعني أن الإجراء يُمرَّر إلى المكوّن من قالب أبوي.

دعنا نحاول معرفة القوالب التي تستخدم هذا المكوّن. لذا، نذهب إلى GitHub ونبحث عن اسم المكوّن كما يُستخدم في القالب. في هذا المثال، سنستخدم شيئًا مثل هذا: "{{create-topic-button"

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

إليك النتيجة

Repository search results · GitHub

نحصل على نتيجتين… إحداهما في إضافة styleguide، لذا نتجاهلها ببساطة. الأخرى في النواة (core). لذا، دعنا نرى كيف تبدو

discourse/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs at 292412f19610d49944f3e109aa7546ccd0553d6a · discourse/discourse · GitHub

  {{create-topic-button
    canCreateTopic=canCreateTopic
    action=(action "clickCreateTopicButton")
    disabled=createTopicButtonDisabled
    label=createTopicLabel
    btnClass=createTopicClass
    canCreateTopicOnTag=canCreateTopicOnTag
  }}

آه… نحن نقترب. الآن ترى أن الإجراء الخاص بالزرّ هو

action=(action "clickCreateTopicButton")

الآن نحتاج إلى معرفة ما يفعله هذا الإجراء. لذا، نبحث عن اسم الإجراء. ثم نفلتر إلى ملفات .js لأننا نريد الآن رؤية تعريف ذلك الإجراء في ملف JavaScript الخاص بالمكوّن.

Repository search results · GitHub

مرة أخرى، نحصل على نتيجة واحدة فقط، لذا دعنا ننظر إليها.

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

نحن مهتمون بالثاني، لذا دعنا ننظر إليه.

إذا بحثت عن createTopic() في ذلك الملف (بحث داخلي، وليس على GitHub)… ستلاحظ أن هناك مرجعًا واحدًا فقط له. ماذا يحدث؟ كيف يستدعي هذا المكوّن دالة غير مُعرَّفة؟

حسنًا، الإجابة أعلى في الملف.

ماذا يعني هذا؟

لا أريد أن أقضي الكثير من الوقت هنا، لكن Ember يستخدم الفئات (Classes). فكّر في الفئات كحزم كود قابلة لإعادة الاستخدام. كل ما تعنيه السطر المظلل أعلاه هو:

خذ حزمة Component من Ember، وأضف إليها حزمة FilterModeMixin، واسمح لي بإضافة المزيد من الدوال أو تجاوز بعض الدوال الموجودة لإنشاء مكوّن Ember جديد لتطبيقي.

لذا، دعنا نعود الآن إلى الإجراء الذي نحاول تتبعه.

clickCreateTopicButton() {
  if (this.categoryReadOnlyBanner && !this.hasDraft) {
    bootbox.alert(this.categoryReadOnlyBanner);
  } else {
    this.createTopic();
  }
},

إنه يستدعي this.createTopic(). هذه ليست دالة افتراضية لمكوّن Ember. إنها دالة مخصصة لـ Discourse، لذا يجب أن تأتي من FilterModeMixin. ما هو FilterModeMixin؟ حسنًا… هو مُعرَّف في أعلى الملف.

import FilterModeMixin from "discourse/mixins/filter-mode";

لذا، أعتقد أننا يجب أن نذهب إلى هناك.

discourse/app/assets/javascripts/discourse/app/mixins/filter-mode.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

توقف لحظة وقم بالبحث الداخلي عن createTopic() في ذلك الملف. أعني ذلك. توقف عن القراءة وقم بذلك الآن. سأنتظر… لا تغش… عيناي عليك.


حسنًا. لقد بحثت، ولم تكن هناك نتائج. ماذا الآن؟

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

لذا، دعنا نلخّص… أين نحن الآن؟ قبل أن نتعثر، كنا ننظر إلى ملف JS الخاص بمكوّن d-navigation. دعنا ننظر إلى قالبه.

مرة أخرى، نستخدم "{{اسم_المكوّن" ونبحث.

Repository search results · GitHub

هذا يعطينا أربع نتائج…

هل هذا مهم؟ ربما. هل هو مهم في هذه الحالة؟ لا. نحن نحاول فقط معرفة من أين تأتي createTopic() أو ما هي. لذا، دعنا نذهب إلى النتيجة الأولى ببساطة.

discourse/app/assets/javascripts/discourse/app/templates/navigation/default.hbs at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

هل ترى ذلك…

createTopic=(route-action "createTopic")

رائع… المزيد من المصطلحات التقنية… لأن الجميع يحب ذلك

بجدية، دعنا نتحدث عن إجراءات المسارات (route actions). ما هي؟ حسنًا. إنها إجراءات… للمسارات؟ أي إجراءات مُعرَّفة على المسار. لماذا هي جيدة؟ لأن المسارات في Discourse يمكن أن تكون متداخلة

انظر إليها بهذه الطريقة

- route-1
  - route-1-1
  - route-1-2
  - route-1-3

إذا كان لدي مكوّن مشترك أحتاج إلى استخدامه في المسارات 111 و112 و113 بمعاملات مختلفة، ألا سيكون من الأسهل إذا عرّفت استخدام نفس المكوّن في جميعها وتمرير نفس الإجراء؟ ثم عدّله لكل مسار إذا لزم الأمر؟

هذا ما تفعله route-actions.

حسنًا، دعنا نعود إلى السؤال. كنا ننظر إلى

createTopic=(route-action "createTopic")

في مكوّن navigation/default.

الآن يجب علينا فقط معرفة ما هو المسار للتحقق من إجراء ذلك المسار.

أنت تريد تعديل سلوك زرّ موضوع جديد في صفحات /c/cat-slug/cat-id. لذا، دعنا نزور إحدى تلك الصفحات. على سبيل المثال: http://localhost:4200/c/meta/6

ما هو هذا المسار؟ إلا إذا كنت معتادًا جدًا على Discourse، فلن تتمكن من معرفة ذلك. إذن، ماذا الآن؟

هنا تصبح إضافة Ember لمتصفحك مفيدة.

تثبّت هنا إذا لم يكن لديك بالفعل. سأنتظر.
(الرابط مستودع GitHub، لكن الوصف يحتوي على روابط الإضافة لمتصفحات مختلفة)


حسنًا، الآن قمت بتثبيتها، زرّح تلك الصفحة مرة أخرى /c/cat-slug/cat-id وانظر إلى صفحة الإضافة.

بمجرد تحميلها، انقر على Routes، ثم فعّل خيار “Current Route only” فقط

آه… انظر إلى ذلك. نحن الآن نعرف المسار الذي نحن عليه. نحن على discovery.category

لكن هذه ليست القصة كاملة… إنها

application > discovery > discovery.category

تذكّر، المسارات متداخلة. إذن، ماذا الآن؟

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

discourse/app/assets/javascripts/discourse/app/routes/application.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

اتضح أنه ليس كذلك… لذا نتحرك لأسفل شجرة التداخل إلى مسار discovery.

discourse/app/assets/javascripts/discourse/app/routes/discovery.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

ابحث هناك… و… بوم!

حسنًا، الآن نعرف ما يشير إليه

createTopic=(route-action "createTopic")

لذا دعنا ننظر إلى ذلك الإجراء.

createTopic() {
  if (this.get("currentUser.has_topic_draft")) {
    this.openTopicDraft();
  } else {
    this.openComposer(this.controllerFor("discovery/topics"));
  }
},

يبدو أنه يقوم بأحد أمرين. إذا كان لدى المستخدم مسودة، فإنه يفتحها. إذا لم يكن كذلك، فإنه يستدعي openComposer() بمعامل. ما التالي؟ حسنًا، يجب أن تعرف الإجابة بالفعل. نحتاج إلى معرفة من أين تأتي openComposer() أو ما تفعله.

لذا نبحث في الملف عن openComposer() و… بالطبع لا نحصل على أي نتائج. لا توجد دالة في ذلك المسار تُسمّى openComposer()

ما التالي؟ تذكّر الجزء حول فئات Ember؟ دعنا نجرب ذلك.

لدينا هذا في أعلى ملف المسار.

هذا يعني أن هذا المسار يرث جميع الدوال من حزمة DiscourseRoute وكذلك تلك المُعرَّفة في حزمة OpenComposer.

من المرجح أن يكون openComposer هو ما نريده، لذا دعنا ننظر إليه. قبل أن نفعل ذلك… يجب أن ننظر إلى كيفية تعريف openComposer في ذلك الملف.

import OpenComposer from "discourse/mixins/open-composer";

انظر إلى الرابط. إنه ليس مكوّن Ember. إنه ليس مسارًا؛ إنه ليس نموذجًا. إنه mixin. ما هو mixin؟ الإجابة القصيرة جدًا جدًا… إنها حزمة من الدوال القابلة لإعادة الاستخدام.

تعرف هذه في mixin الخاص بك.

add(number) {
  return number + 1
}

substract(number) {
  return number - 1
}

ثم أضف mixin إلى مكوّن Ember الخاص بك، ثم يمكنك فعل شيء مثل هذا

// القيمة الابتدائية هي 1
myMethod () {
  this.add(value) // يعيد 2
  this.substract(value) // يعيد 0 
}

إذن، كيف يرتبط هذا بما نحاول فعله؟

حسنًا، open-composer هنا.

import OpenComposer from "discourse/mixins/open-composer";

هو mixin. إحدى الدوال في ذلك mixin هي OpenComposer()

لا بأس إذا شعرت بالارتباك بشأن هذا. إنهما يشتركان في نفس الاسم - باستثناء أن أحدهما يبدأ بحرف كبير، مما يشير إلى أنه فئة (Class).

إنهما يعنيان شيئين مختلفين.

لفهم هذا، ستحتاج إلى معرفة أن الاسم الذي تعطيه للوحدات المستوردة لا يهم (في هذه الحالة بالتحديد)، طالما أنها مُصدَّرة كـ “default”.

شرح هذا يتجاوز نطاق هذا الموضوع قليلاً. كل ما تحتاج إلى معرفته هو أن هذا.

OpenComposer هنا

discourse/app/assets/javascripts/discourse/app/routes/discovery.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

وopenComposer() هنا

discourse/app/assets/javascripts/discourse/app/mixins/open-composer.js at 1472e47aae5bfdfb6fd9abfe89beb186c751f514 · discourse/discourse · GitHub

ليسا نفس الشيء.

حسنًا… دعنا نلخّص.

معرف HTML لزرّ موضوع جديد < إجراء زرّ موضوع جديد < إجراء مكوّن d-navigation < إجراء مسار discovery < mixin OpenComposer < دالة openComposer()

إذن… هذه هي الدالة التي يتم استدعاؤها في النهاية عند النقر على زرّ + موضوع جديد على ذلك المسار.

openComposer(controller) {
  let categoryId = controller.get("category.id");
  if (
    categoryId &&
    controller.category.isUncategorizedCategory &&
    !this.siteSettings.allow_uncategorized_topics
  ) {
    categoryId = null;
  }
  this.controllerFor("composer").open({
    prioritizedCategoryId: categoryId,
    topicCategoryId: categoryId,
    action: Composer.CREATE_TOPIC,
    draftKey: controller.get("model.draft_key") || Composer.NEW_TOPIC_KEY,
    draftSequence: controller.get("model.draft_sequence") || 0,
  });
},

لذا دعنا نعود إلى سؤالك.

لقد حددنا كيف يمكنك معرفة إجراء ذلك الزرّ على /c/cat-slug/cat-id، لكنه يبدو مختلفًا عما يحدث عند زيارة /tags/category-slug/tag-name، وهو ما تريد فعله.

إذن ما هي الخطوة التالية؟ دعنا ننظر إلى ما يفعله ذلك المسار للتعامل مع إجراء createTopic().

حسنًا… ستلاحظ أنه يتعامل مع الإجراء بشكل مختلف.

لـ /c/cat-slug/cat-id يبدو هكذا

createTopic() {
  if (this.get("currentUser.has_topic_draft")) {
    this.openTopicDraft();
  } else {
    this.openComposer(this.controllerFor("discovery/topics"));
  }
},

لـ /tags/category-slug/tag-name يبدو هكذا

createTopic() {
  if (this.get("currentUser.has_topic_draft")) {
    this.openTopicDraft();
  } else {
    const controller = this.controllerFor("tag.show");
    const composerController = this.controllerFor("composer");
    composerController
      .open({
        categoryId: controller.get("category.id"),
        action: Composer.CREATE_TOPIC,
        draftKey: Composer.NEW_TOPIC_KEY
      })
      .then(() => {
        // تعبئة حقل إدخال الوسوم مسبقًا
        if (composerController.canEditTags && controller.get("model.id")) {
          const composerModel = this.controllerFor("composer").get("model");
          composerModel.set(
            "tags",
            [
              controller.get("model.id"),
              ...makeArray(controller.additionalTags)
            ].filter(Boolean)
          );
        }
      });
  }
}

هذا الفرق هو بالضبط ما تسأل عنه هنا.

إذن، كل ما عليك فعله هو… تعديل إجراء createTopic() في مسار discovery لجعله يعمل كما يعمل في مسار tag-show. إذن كيف تفعل ذلك؟

تذكّر كيف تحدثنا عن استخدام Ember للفئات؟ نعم، سنضطر إلى العودة إلى ذلك مرة أخرى.

تسمح واجهة برمجة التطبيقات (API) للإضافات بتعديل فئات Ember عبر هذه الطريقة.

https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/app/lib/plugin-api.js#L166-L195

إذن، ماذا نحاول تعديله هنا؟ مسار discovery… لأن… تذكّر، هناك يتم تعريف createTopic() عندما تكون على صفحة مثل /c/cat-slug/cat-id

نبدأ بهذا

api.modifyClass("route:discovery", {
  pluginId: "prefill-composer-tags",
  actions: {
    createTopic() {
      console.log("fires");
    }
  }
});

ماذا يفعل ذلك؟ إنه يكسر زرّ + موضوع جديد؛ ومع ذلك، يخبرنا أننا في الاتجاه الصحيح. إذا جربت إضافة المقطع أعلاه، ستلاحظ أن النقر على الزرّ لم يعد يفتح محرر الكتابة. بدلاً من ذلك، يطبع رسالة فقط في وحدة التحكم. هذا أمر جيد لأنه يعني أننا استهدفنا الفئة الصحيحة والإجراء الصحيح - route:discovery وcreateTopic().

إذن، ما التالي؟ حسنًا، تذكّر أن الزرّ على /tags/category-slug/tag-name يفعل بالضبط ما نريده. لذا، دعنا ننسخ الكود من ذلك المسار - ونضيف الاستيرادات المطلوبة.

const Composer = require("discourse/models/composer");
const { makeArray } = require("discourse-common/lib/helpers");
api.modifyClass("route:discovery", {
  pluginId: "prefill-composer-tags",
  actions: {
    createTopic() {
      if (this.get("currentUser.has_topic_draft")) {
        this.openTopicDraft();
      } else {
        const controller = this.controllerFor("tag.show");
        const composerController = this.controllerFor("composer");
        composerController
          .open({
            categoryId: controller.get("category.id"),
            action: Composer.CREATE_TOPIC,
            draftKey: Composer.NEW_TOPIC_KEY
          })
          .then(() => {
            // تعبئة حقل إدخال الوسوم مسبقًا
            if (composerController.canEditTags && controller.get("model.id")) {
              const composerModel = this.controllerFor("composer").get("model");
              composerModel.set(
                "tags",
                [
                  controller.get("model.id"),
                  ...makeArray(controller.additionalTags)
                ].filter(Boolean)
              );
            }
          });
      }
    }
  }
});

هل سيعمل ذلك؟ لا، لكننا على بعد خطوة واحدة. لماذا لا يعمل؟ لأن الوسوم التي تُضاف عند فتح محرر الكتابة غير مُعرَّفة. لماذا؟ لأنها تُحمَّل من وحدة تحكم tag.show - وهو ما لا نريده. دعنا نعدّل الكود لجعله يعمل مع المسار الذي نحن عليه.

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

// category-slug: [DEFAULT_TAGS_ARRAY]
const defaultTagIndex = {
  // معرف بـ كلمة واحدة
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // معرف بـ شرطة
  ["general-chat"]: ["d", "e", "f"]
};

هذا يعني ببساطة أنه إذا تم فتح محرر الكتابة على صفحة فئة meta، أضف الوسوم “a,b,c”
إذا تم فتح محرر الكتابة على صفحة فئة core، أضف الوسوم “g,h” وهكذا.

الآن بعد أن أصبح لدينا ذلك، يمكننا تعديل الإجراء لجعله يبدو هكذا.

الكود النهائي

const Composer = require("discourse/models/composer");
const { makeArray } = require("discourse-common/lib/helpers");

// category-slug: [DEFAULT_TAGS_ARRAY]
const defaultTagIndex = {
  // معرف بـ كلمة واحدة
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // معرف بـ شرطة
  ["general-chat"]: ["d", "e", "f"]
};

api.modifyClass("route:discovery", {
  pluginId: "prefill-composer-tags",
  actions: {
    createTopic() {
      try {
        const hasDraft = this.currentUser?.has_topic_draft;
        if (hasDraft) {
          this._super(...arguments);
          return;
        } else {
          const controller = this.controllerFor("discovery/topics");
          const composerController = this.controllerFor("composer");
          const categoryId = controller.category?.id;
          const categorySlug = controller.category?.slug;

          if (!categoryId) {
            this._super(...arguments);
            return;
          }

          composerController
            .open({
              categoryId: categoryId,
              action: Composer.CREATE_TOPIC,
              draftKey: Composer.NEW_TOPIC_KEY
            })
            .then(() => {
              // تعبئة حقل إدخال الوسوم مسبقًا
              if (composerController.canEditTags && categoryId) {
                const composerModel = composerController.model;
                composerModel.set(
                  "tags",
                  makeArray(defaultTagIndex[categorySlug]).filter(Boolean)
                );
              }
            });
        }
      } catch {
        this._super(...arguments);
        return;
      }
    }
  }
});

ملاحظات:

  1. لقد قمت بتغليف كل شيء في كتلة try…catch. إذا فشل الكود، ننفذ this._super(...arguments)

  2. إذا كنت معتادًا على Ember، فستعرف ما يفعله this._super(...arguments). إذا لم تكن كذلك، فإليك شرحًا بسيطًا. نحن نتجاوز createTopic()، لذا إذا فشلت التعديلات بسبب خطأ - ربما تم تحديث النواة - فانتقل إلى الطريقة في النواة كما هو مُعرَّف هنا

  3. إذا كان لدى المستخدم مسودة موضوع جديد، فإننا نعود ببساطة إلى `this._super(…arguments) ونترك النواة تفعل ما عليها.

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