Definindo tag padrão por categoria em um componente de tema

Se você estiver visitando /tags/category-slug/tag-name e clicar no botão Novo Tópico, o compositor terá a tag pré-definida, conforme descrito aqui:

Isso é incrível. Mas agora eu (e pelo menos mais uma pessoa) queremos poder definir esse comportamento com uma tag padrão ao visitar /c/cat-slug/cat-id. Parece que um componente de tema deveria ser capaz de direcionar esse botão e modificá-lo ou ocultá-lo e adicionar um novo botão (há uma saída de plugin bem ali, que não consigo encontrar agora, mas vi há um minuto).

Alguém pode me dar uma dica?

É suposto funcionar apenas em uma categoria específica, ou você precisaria que ele suportasse uma “tag padrão” para várias categorias, onde essa tag seja diferente para cada uma delas?

Imagino que eu poderia criar uma configuração para definir uma tag padrão para algumas categorias. Provavelmente consigo fazer isso, mas não sei onde ou como alterar o botão “Criar tópico” para que ele inclua essa tag padrão.

TL;DR pule para o código funcional aqui


Quando você estiver visualizando uma página que possui o botão + Novo Tópico, você pode verificar o HTML usando o inspetor.

Se você fizer isso, notará que ele possui um id.

Os ids em elementos HTML devem ser únicos, ou seja… nenhum dois elementos na mesma visualização podem compartilhar o mesmo atributo id HTML. Portanto, isso é suficiente para começarmos.

Se eu pesquisar "create-topic" no Github, é isso que vejo…

Repository search results · GitHub

Observe o filtro à esquerda.

Eu sei que quero rastrear o HTML do botão, então, handlebars, porque estou tentando descobrir a ação que ele envia.

Então, eu seleciono handlebars; então vejo isso.

Repository search results · GitHub

Há apenas um resultado lá, então temos sorte. Se houver mais resultados, há coisas que você pode fazer para refinar ainda mais a lista, mas isso está fora do escopo deste tópico.

Então, vamos verificar esse arquivo.

Você verá então que a ação que o botão possui é definida assim:

action=action

Bem… isso não é muito útil… Então, o que agora?

Quando você vê action=action, significa que a ação está sendo passada para o componente a partir de um template pai.

Vamos tentar ver quais templates têm esse componente. Então, vamos ao Github e pesquisamos pelo nome do componente como ele seria usado em um template. Para este exemplo, usaríamos algo assim: "{{create-topic-button"

Note que eu adicionei apenas {{NOME_DO_COMPONENTE e pulei o resto. Não sabemos os outros argumentos passados a ele, então queremos uma pesquisa genérica.

Aqui está o resultado:

Repository search results · GitHub

Obtemos dois resultados… um deles está no plugin styleguide, então simplesmente ignoramos. O outro está no núcleo (core). Então, vamos ver como isso se parece:

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… estamos chegando mais perto. Agora você vê que a ação para o botão é:

action=(action "clickCreateTopicButton")

Agora precisamos descobrir o que essa ação faz. Então, pesquisamos pelo nome da ação. Depois, filtramos para arquivos .js porque agora queremos ver a definição dessa ação no arquivo js do componente.

Repository search results · GitHub

Novamente, obtemos apenas um resultado, então vamos olhar para ele.

Então, parece que a ação faz uma de duas coisas. Se a categoria for somente leitura e o usuário já não tiver um rascunho, ela mostra um alerta. Caso contrário, ela chama um método createTopic().

Estamos interessados neste último, então vamos olhar para ele.

Se você pesquisar por createTopic() naquele arquivo (pesquisa inline, não no Github)… você notará que há apenas uma referência para ele. O que houve? Como este componente está chamando um método que não está definido?

Bem, a resposta está mais acima no arquivo.

O que isso significa?

Eu não quero gastar muito tempo aqui, mas o Ember usa Classes. Pense em classes como pacotes de código reutilizáveis. Toda a linha destacada acima significa apenas:

Pegue o pacote Component do Ember, adicione o pacote FilterModeMixin a ele e deixe-me adicionar mais alguns métodos, ou sobrescrever alguns dos existentes, ao resultado para criar um novo componente Ember para meu aplicativo.

Então, agora vamos voltar para a ação que estamos tentando rastrear.

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

Ele chama this.createTopic(). Isso não é um método padrão de componente Ember. É um método personalizado do Discourse, então deve vir de FilterModeMixin. O que é FilterModeMixin? Bem… ele é definido no topo do arquivo.

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

Então, acho que temos que ir até lá.

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

Pare por um segundo e faça uma pesquisa inline por createTopic() naquele arquivo. Eu falo sério. Pare de ler e faça isso. Vou esperar… não trapaceie… estou de olho em você :eyes:.


OK. Você pesquisou e não houve resultados. E agora?

O que descrevi acima é apenas um método para passar coisas para baixo. Se você não encontrar o que está procurando. Dê um passo para trás e tente uma abordagem diferente.

Então, vamos recapitular… onde estamos agora? Antes de ficarmos presos, estávamos olhando para o arquivo JS do componente d-navigation. Vamos olhar para seu template.

Novamente, usamos "{{NOME_DO_COMPONENTE" e pesquisamos.

Repository search results · GitHub

Isso nos dá quatro resultados…

Isso importa? Talvez. Isso importa para este caso? Não. Estamos apenas tentando descobrir de onde vem createTopic() ou o que é. Então, vamos apenas seguir com o primeiro resultado.

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

Olhe só isso…

createTopic=(route-action "createTopic")

Ótimo… mais jargão… porque todo mundo adora isso

Sério, vamos falar sobre ações de rota. O que são elas? Bem. Elas são ações… de rota? Como em ações definidas na rota. Por que elas são legais? Porque as rotas no Discourse podem ser aninhadas.

Veja assim:

- rota-1
  - rota-1-1
  - rota-1-2
  - rota-1-3

Se eu tiver um componente compartilhado que preciso usar nas rotas 111, 112 e 113 com parâmetros diferentes, não seria mais fácil se eu apenas definisse usar o mesmo componente em todas elas e passasse a mesma ação? Então, modificá-lo para cada rota se necessário?

É isso que as route-actions fazem.

OK, vamos voltar à pergunta. Estávamos olhando para:

createTopic=(route-action "createTopic")

no componente navigation/default.

Agora, só precisamos descobrir qual é a rota para verificar o que essa ação de rota faz.

Você quer modificar o comportamento do botão novo tópico nas páginas /c/cat-slug/cat-id. Então, vamos visitar uma dessas páginas. Por exemplo: http://localhost:4200/c/meta/6

Qual é esta rota? A menos que você esteja realmente familiarizado com o Discourse, você não seria capaz de dizer. Então, o que agora?

É aqui que a extensão Ember para seu navegador se torna útil.

Instale-a aqui se você ainda não a tiver. Vou esperar.
(o link é um repositório do Github, mas a descrição tem os links da extensão para diferentes navegadores)


OK, agora que você a instalou, visite a página novamente /c/cat-slug/cat-id e olhe para a página da extensão.

Quando carregar, clique em Rotas e, em seguida, alterne “Apenas rota atual”.

Ahhh… olhe só. Agora sabemos em qual rota estamos. Estamos em discovery.category.

Mas essa não é toda a história… é:

application > discovery > discovery.category

Lembre-se, as rotas são aninhadas. Então, o que agora?

Eu geralmente começo no topo. Neste caso, seria a rota application. Encontre o arquivo para essa rota e pesquise para ver se a ação está definida lá.

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

Acontece que não está… então descemos a árvore de aninhamento para a rota discovery.

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

Pesquise lá… e… bingo!

Ok, então agora sabemos o que:

createTopic=(route-action "createTopic")

refere-se. Então, vamos olhar para essa ação.

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

Parece que está fazendo uma de duas coisas. Se o usuário tiver um rascunho, ele o abre. Se não, chama openComposer() com um parâmetro. Qual é o próximo passo? Bem, você já deve saber a resposta agora. Precisamos descobrir de onde vem openComposer() ou o que ele faz.

Então, pesquisamos o arquivo por openComposer() e… é claro que não obtemos resultados. Não há nenhum método nessa rota chamado openComposer().

E agora? Lembre-se da parte sobre Classes Ember? Vamos tentar isso.

Temos isso no topo do arquivo de rota.

Isso significa que esta rota herda todos os métodos do pacote DiscourseRoute bem como os definidos no pacote OpenComposer.

O openComposer é mais provável o que queremos, então vamos olhar para ele. Antes de fazermos isso, porém… precisamos ver como openComposer é definido naquele arquivo.

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

Olhe para a URL. Não é um componente Ember. Não é uma rota; não é um modelo. É um mixin. O que diabos é um mixin? A resposta muito, muito curta… é um pacote de funções reutilizáveis.

Você define esses no seu mixin.

add(number) {
  return number + 1
}

substract(number) {
  return number - 1
}

então adicione o mixin ao seu componente Ember, então você pode fazer algo assim:

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

Então, como isso se relaciona com o que estamos tentando fazer?

Bem, open-composer aqui.

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

é um mixin. Um dos métodos nesse mixin é OpenComposer().

Está tudo bem se você se sentir confuso com isso. Eles compartilham o mesmo nome - exceto que um começa com uma letra maiúscula, o que indica que é uma Classe.

Eles significam coisas diferentes.

Para entender isso, você precisaria entender que o nome que você dá aos seus módulos importados não importa (neste caso particular), desde que sejam exportados como “default”.

Explicar isso está um pouco além do escopo deste tópico. Tudo o que você precisa saber é que isso:

OpenComposer aqui:

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

e openComposer() aqui:

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

não são a mesma coisa.

OK… vamos recapitular.

id HTML do botão Novo tópico < ação do botão Novo tópico < ação do componente d-navigation < ação da rota discovery < mixin OpenComposer < método openComposer()

Então… este é o método que eventualmente é chamado quando você clica no botão + Novo Tópico naquela rota.

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

Então, vamos voltar à sua pergunta.

Estabelecemos como você pode descobrir a ação daquele botão na /c/cat-slug/cat-id, mas parece diferente do que acontece quando você visita /tags/category-slug/tag-name, que é o que você quer fazer.

Então, qual é o próximo passo? Vamos ver o que aquela rota faz para lidar com a ação createTopic().

Bem… você notará que ela lida com a ação de maneira diferente.

para /c/cat-slug/cat-id parece assim:

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

para /tags/category-slug/tag-name parece assim:

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(() => {
        // Pré-preencher o campo de entrada de tags
        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)
          );
        }
      });
  }
}

Essa diferença é basicamente o que você está pedindo aqui.

Então, tudo o que você precisa fazer é… modificar a ação createTopic() na rota discovery para fazê-la funcionar como na rota tag-show. Então, como você faz isso?

Lembre-se de como falamos sobre o Ember usar Classes? Sim, vamos ter que voltar a isso novamente.

A API de plugins permite que você modifique classes Ember através deste método.

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

Então, o que estamos tentando modificar aqui? A rota discovery… porque… lembre-se, é lá que createTopic() é definido quando você está em uma página como /c/cat-slug/cat-id.

Começamos com isso:

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

O que isso faz? Quebra o botão + Novo Tópico; no entanto, nos diz que estamos na direção certa. Se você tentar adicionar o trecho acima, notará que clicar no botão não abre mais o compositor. Em vez disso, ele apenas imprime uma mensagem no console. Isso é uma coisa boa porque significa que alvoamos a Classe certa e a ação certa - route:discovery e createTopic().

Então, o que vem a seguir? Bem, lembre-se de que o botão em /tags/category-slug/tag-name faz exatamente o que queremos. Então, vamos copiar o código daquela rota - e adicionar as importações necessárias.

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(() => {
            // Pré-preencher o campo de entrada de tags
            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)
              );
            }
          });
      }
    }
  }
});

Isso vai funcionar? Não, mas estamos um passo perto. Por que não funciona? Porque as tags que ele adiciona quando o compositor abre não estão definidas. Por quê? Porque elas são carregadas do controlador tag.show - o que não é o que queremos. Vamos modificar o código para fazê-lo funcionar com a rota em que estamos.

Antes de fazermos isso, porém, precisamos de algum tipo de índice para nossas tags padrão desejadas. Vamos com um novo objeto assim:

// category-slug: [ARRAY_DE_TAGS_PADRAO]
const defaultTagIndex = {
  // slug de palavra única
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // slug com um traço
  ["general-chat"]: ["d", "e", "f"]
};

Isso basicamente significa que se o compositor for aberto na página da categoria meta, adicione as tags “a, b, c”.
Se o compositor for aberto na página da categoria core, adicione as tags “g, h” e assim por diante.

Agora que temos isso, podemos modificar a ação para fazê-la parecer assim.

Código final

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

// category-slug: [ARRAY_DE_TAGS_PADRAO]
const defaultTagIndex = {
  // slug de palavra única
  meta: ["a", "b", "c"],
  core: ["g", "h"],
  // slug com um traço
  ["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(() => {
              // Pré-preencher o campo de entrada de tags
              if (composerController.canEditTags && categoryId) {
                const composerModel = composerController.model;
                composerModel.set(
                  "tags",
                  makeArray(defaultTagIndex[categorySlug]).filter(Boolean)
                );
              }
            });
        }
      } catch {
        this._super(...arguments);
        return;
      }
    }
  }
});

Notas:

  1. Envolve tudo em um bloco try…catch. Se o código falhar, executamos this._super(...arguments).

  2. Se você estiver familiarizado com o Ember, saberá o que this._super(...arguments) faz. Se não, aqui está uma explicação simples. Estamos sobrescrevendo o createTopic(), então se as sobrescrições falharem devido a um erro - talvez o núcleo tenha sido atualizado - então retorne ao método no núcleo conforme definido aqui.

  3. Se o usuário tiver um rascunho de novo tópico, simplesmente retornamos a this._super(...arguments) e deixamos o núcleo fazer o seu trabalho.

Isso deve ser suficiente. Tudo o que você precisa adicionar agora é uma maneira de criar o índice de tags padrão através das configurações do tema.