Establecer etiqueta predeterminada por categoría en un componente de tema

Si estás visitando /tags/category-slug/tag-name y haces clic en el botón Nuevo tema, el compositor tendrá la etiqueta predefinida, como se describe aquí:

Esto es increíble. Pero ahora yo (y al menos otra persona) queremos poder configurar este comportamiento con una etiqueta predeterminada al visitar /c/cat-slug/cat-id. Parece que un componente de tema debería poder apuntar a ese botón y modificarlo, o bien ocultarlo y agregar un nuevo botón (hay una salida de plugin justo allí que no logro encontrar ahora, pero vi hace un minuto).

¿Alguien puede darme una pista?

¿Se supone que debe funcionar solo en una categoría en particular, o necesitarías que admitiera una “etiqueta predeterminada” para muchas categorías, donde esa etiqueta sea diferente para cada una de ellas?

Imagino que podría crear una configuración para tener una etiqueta predeterminada para varias categorías. Probablemente pueda hacerlo, pero no sé dónde ni cómo cambiar el botón “Crear tema” para que incluya la etiqueta predeterminada.

TL;DR salta al código funcional aquí


Cuando estás viendo una página que tiene el + Nuevo Tema, puedes verificar el HTML a través del inspector.

Si lo haces, notarás que tiene un id.

Los ids en los elementos HTML deben ser únicos; es decir, ningún dos elementos en la misma vista pueden compartir un atributo id de HTML. Por lo tanto, esto es suficiente para empezar.

Si busco "create-topic" en GitHub, esto es lo que veo…

Repository search results · GitHub

Observa el filtro a la izquierda.

Sé que quiero rastrear el HTML del botón, por lo que buscaré en Handlebars, ya que estoy intentando encontrar la acción que envía.

Así que selecciono Handlebars; entonces veo esto.

Repository search results · GitHub

Solo hay un resultado allí, así que tenemos suerte. Si hay más resultados, hay cosas que puedes hacer para reducir aún más la lista, pero eso está fuera del alcance de este tema.

Así que, revisemos ese archivo.

Entonces verás que la acción que tiene el botón se establece de la siguiente manera:

action=action

Bueno… eso no es muy útil… ¿Qué hacemos ahora?

Cuando ves action=action, significa que la acción se está pasando al componente desde una plantilla padre.

Intentemos ver qué plantillas tienen ese componente. Así que vamos a GitHub y buscamos el nombre del componente tal como se usaría en una plantilla. Para este ejemplo, usaríamos algo como esto: "{{create-topic-button"

Nota que solo agregué {{NOMBRE_COMPONENTE y omití el resto. No conocemos los otros argumentos que se le pasan, por lo que queremos una búsqueda genérica.

Este es el resultado:

Repository search results · GitHub

Obtenemos dos resultados… uno de ellos está en el plugin de estilo, así que simplemente lo ignoramos. El otro está en el núcleo. Así que veamos cómo se ve eso:

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
  }}

Ahh… nos estamos acercando. Ahora ves que la acción para el botón es:

action=(action "clickCreateTopicButton")

Ahora necesitamos averiguar qué hace esa acción. Así que buscamos el nombre de la acción. Luego filtramos a archivos .js porque ahora queremos ver la definición de esa acción en el archivo js del componente.

Repository search results · GitHub

De nuevo, obtenemos solo un resultado, así que veámoslo.

Parece que la acción hace una de dos cosas. Si la categoría es de solo lectura y el usuario ya no tiene un borrador, muestra una alerta. De lo contrario, llama a un método createTopic().

Estamos interesados en este último, así que veámoslo.

Si buscas createTopic() en ese archivo (búsqueda en línea, no en GitHub)… notarás que solo hay una referencia para ello. ¿Qué pasa? ¿Cómo puede este componente llamar a un método que no está definido?

Bueno, la respuesta está más arriba en el archivo.

¿Qué significa esto?

No quiero perder mucho tiempo aquí, pero Ember usa Clases. Piensa en las clases como paquetes de código reutilizables. Toda la línea resaltada arriba significa:

Toma el paquete Component de Ember, agrega el paquete FilterModeMixin a él y déjame agregar más métodos, o sobrescribir algunos de los existentes, al resultado para crear un nuevo componente de Ember para mi aplicación.

Así que, ahora volvamos a la acción que estamos intentando rastrear.

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

Llama a this.createTopic(). Esto no es un método predeterminado de un componente de Ember. Es un método personalizado de Discourse, por lo que debe provenir de FilterModeMixin. ¿Qué es FilterModeMixin? Bueno… está definido en la parte superior del archivo.

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

Así que, supongo que tenemos que ir allí.

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

Pausa un momento y haz una búsqueda en línea de createTopic() en ese archivo. En serio. Deja de leer y hazlo. Esperaré… no hagas trampa… tengo mis :ojos: puestos en ti.


OK. Buscaste y no hubo resultados. ¿Qué hacemos ahora?

Lo que describí arriba es solo un método para pasar cosas hacia abajo. Si no encuentras lo que buscas, da un paso atrás e intenta un enfoque diferente.

Así que, repasemos… ¿dónde estamos ahora? Antes de quedarnos atascados, estábamos viendo el archivo JS del componente d-navigation. Veamos su plantilla.

De nuevo, usamos "{{NOMBRE_COMPONENTE" y buscamos.

Repository search results · GitHub

Esto nos da cuatro resultados…

¿Importa esto? Quizás. ¿Importa para este caso? No. Solo estamos tratando de averiguar de dónde viene createTopic() o qué es. Así que simplemente usemos el primer resultado.

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

¿Lo ves?

createTopic=(route-action "createTopic")

Genial… más jerga… porque a todos nos encanta eso

En serio, hablemos de las acciones de ruta. ¿Qué son? Bueno, son acciones de ruta… ¿acciones? Es decir, acciones definidas en la ruta. ¿Por qué son buenas? Porque las rutas en Discourse pueden estar anidadas.

Míralo de esta manera:

- ruta-1
  - ruta-1-1
  - ruta-1-2
  - ruta-1-3

Si tengo un componente compartido que necesito usar en las rutas 111, 112 y 113 con diferentes parámetros, ¿no sería más fácil si simplemente definiera el mismo componente en todas ellas y pasara la misma acción? Luego lo modifico para cada ruta si es necesario.

Eso es lo que hacen las acciones de ruta.

OK, volvamos a la pregunta. Estábamos viendo:

createTopic=(route-action "createTopic")

en el componente navigation/default.

Ahora solo tenemos que averiguar cuál es la ruta para verificar qué hace esa acción de ruta.

Quieres modificar el comportamiento del botón de nuevo tema en las páginas /c/cat-slug/cat-id. Así que visitemos una de esas páginas. Por ejemplo: http://localhost:4200/c/meta/6

¿Cuál es esta ruta? A menos que estés muy familiarizado con Discourse, no podrías decirlo. ¿Qué hacemos ahora?

Aquí es donde la extensión de Ember para tu navegador resulta útil.

Instálala aquí si aún no la tienes. Esperaré.
(el enlace es un repositorio de GitHub, pero la descripción tiene los enlaces de la extensión para diferentes navegadores)


OK, ahora que la tienes instalada, visita esa página nuevamente /c/cat-slug/cat-id y mira la página de la extensión.

Una vez que se cargue, haz clic en Rutas y luego activa solo «Ruta actual».

Ahhh… mira eso. Ahora sabemos en qué ruta estamos. Estamos en discovery.category.

Pero esa no es toda la historia… es:

application > discovery > discovery.category

Recuerda, las rutas están anidadas. ¿Qué hacemos ahora?

Por lo general, empiezo desde la parte superior. En este caso, sería la ruta application. Encuentra el archivo para esa ruta y busca para ver si la acción está definida allí.

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

Resulta que no está… así que bajamos en el árbol de anidación a la ruta discovery.

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

Busca allí… y… ¡bingo!

Ok, ahora sabemos a qué se refiere:

createTopic=(route-action "createTopic")

Así que veamos esa acción.

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

Parece que hace una de dos cosas. Si el usuario tiene un borrador, lo abre. Si no, llama a openComposer() con un parámetro. ¿Qué sigue? Bueno, ya deberías saber la respuesta. Necesitamos averiguar de dónde viene openComposer() o qué hace.

Así que buscamos en el archivo openComposer() y… por supuesto no obtenemos resultados. No hay ningún método en esa ruta llamado openComposer().

¿Qué sigue? Recuerda lo de las Clases de Ember. Intentémoslo.

Tenemos esto en la parte superior del archivo de ruta.

Esto significa que esta ruta hereda todos los métodos del paquete DiscourseRoute, así como los definidos en el paquete OpenComposer.

Es más probable que openComposer sea lo que queremos, así que veámoslo. Pero antes de hacerlo… necesitamos ver cómo se define openComposer en ese archivo.

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

Mira la URL. No es un componente de Ember. No es una ruta; no es un modelo. Es un mixin. ¿Qué diablos es un mixin? La respuesta muy, muy corta… es un paquete de funciones reutilizables.

Definimos estos en tu mixin.

add(number) {
  return number + 1
}

substract(number) {
  return number - 1
}

luego agregamos el mixin a tu componente de Ember, y luego puedes hacer algo como esto:

// valor inicial es 1
myMethod () {
  this.add(value) // devuelve 2
  this.substract(value) // devuelve 0 
}

Entonces, ¿cómo se relaciona esto con lo que estamos intentando hacer?

Bueno, open-composer aquí.

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

es un mixin. Uno de los métodos en ese mixin es OpenComposer().

Está bien si te sientes confundido por esto. Comparten el mismo nombre, excepto que uno comienza con mayúscula, lo que indica que es una Clase.

Significan cosas diferentes.

Para entender esto, necesitarías saber que el nombre que le das a tus módulos importados no importa (en este caso particular), siempre y cuando se exporten como «default».

Explicar esto está un poco más allá del alcance de este tema. Todo lo que necesitas saber es que esto:

OpenComposer aquí

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

y openComposer() aquí

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

no son lo mismo.

OK… repasemos.

Id HTML del botón Nuevo tema < Acción del botón Nuevo tema < Acción del componente d-navigation < Acción de la ruta discovery < Mixin OpenComposer < método openComposer()

Así que… este es el método que finalmente se llama cuando haces clic en el botón + Nuevo Tema en esa ruta.

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,
  });
},

Así que volvamos a tu pregunta.

Hemos establecido cómo puedes averiguar la acción para ese botón en /c/cat-slug/cat-id, pero parece diferente de lo que sucede cuando visitas /tags/category-slug/tag-name, que es lo que quieres hacer.

¿Cuál es el siguiente paso? Veamos qué hace esa ruta para manejar la acción createTopic().

Bueno… notarás que maneja la acción de manera diferente.

Para /c/cat-slug/cat-id se ve así:

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

Para /tags/category-slug/tag-name se ve así:

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(() => {
        // Rellenar previamente el campo de entrada de etiquetas
        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)
          );
        }
      });
  }
}

Esta diferencia es básicamente lo que estás preguntando aquí.

Así que, todo lo que tienes que hacer es… modificar la acción createTopic() en la ruta discovery para que funcione como lo hace en la ruta tag-show. ¿Cómo lo haces?

Recuerda cómo hablamos de que Ember usa Clases? Sí, tendremos que volver a eso de nuevo.

La API de plugins te permite modificar clases de Ember mediante este método.

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

Así que, ¿qué estamos tratando de modificar aquí? La ruta discovery… porque… recuerda, ahí es donde se define createTopic() cuando estás en una página como /c/cat-slug/cat-id.

Empezamos con esto:

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

¿Qué hace eso? Rompe el botón + Nuevo Tema; sin embargo, nos dice que estamos en la dirección correcta. Si intentas agregar el fragmento anterior, notarás que al hacer clic en el botón ya no se abre el compositor. En su lugar, simplemente imprime un mensaje en la consola. Esto es bueno porque significa que hemos apuntado a la Clase correcta y a la acción correcta: route:discovery y createTopic().

¿Qué sigue? Bueno, recuerda que el botón en /tags/category-slug/tag-name hace exactamente lo que queremos. Así que copiemos el código de esa ruta y agreguemos las importaciones necesarias.

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(() => {
            // Rellenar previamente el campo de entrada de etiquetas
            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)
              );
            }
          });
      }
    }
  }
});

¿Funcionará eso? No, pero estamos un paso cerca. ¿Por qué no funciona? Porque las etiquetas que agrega cuando se abre el compositor no están definidas. ¿Por qué? Porque se cargan desde el controlador tag.show, que no es lo que queremos. Modifiquemos el código para que funcione con la ruta en la que estamos.

Antes de hacerlo, necesitamos algún tipo de índice para nuestras etiquetas predeterminadas deseadas. Usemos un nuevo objeto así:

// slug-de-categoría: [ARRAY_ETIQUETAS_POR_DEFECTO]
const defaultTagIndex = {
  // slug de una sola palabra
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // slug con un guion
  ["general-chat"]: ["d", "e", "f"]
};

Esto básicamente significa que si el compositor se abre en la página de la categoría meta, agrega las etiquetas «a, b, c».
Si el compositor se abre en la página de la categoría core, agrega las etiquetas «g, h», y así sucesivamente.

Ahora que tenemos eso, podemos modificar la acción para que se vea así.

Código final

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

// slug-de-categoría: [ARRAY_ETIQUETAS_POR_DEFECTO]
const defaultTagIndex = {
  // slug de una sola palabra
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // slug con un guion
  ["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(() => {
              // Rellenar previamente el campo de entrada de etiquetas
              if (composerController.canEditTags && categoryId) {
                const composerModel = composerController.model;
                composerModel.set(
                  "tags",
                  makeArray(defaultTagIndex[categorySlug]).filter(Boolean)
                );
              }
            });
        }
      } catch {
        this._super(...arguments);
        return;
      }
    }
  }
});

Notas:

  1. He envuelto todo en un bloque try…catch. Si el código falla, ejecutamos this._super(...arguments).

  2. Si estás familiarizado con Ember, sabrás qué hace this._super(...arguments). Si no, aquí tienes una explicación simple. Estamos sobrescribiendo createTopic(), por lo que si las sobrescrituras fallan debido a un error (quizás el núcleo se actualizó), entonces volvemos al método en el núcleo definido aquí.

  3. Si el usuario tiene un borrador de nuevo tema, simplemente volvemos a this._super(...arguments) y dejamos que el núcleo haga su trabajo.

Eso debería ser suficiente. Todo lo que necesitas agregar ahora es una forma de crear el índice de etiquetas predeterminadas mediante la configuración del tema.