Come riscrivere il template nei componenti gjs

frontend/discourse/app/components/search-menu.gjs

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { concat } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { cancel } from "@ember/runloop";
import { service } from "@ember/service";
import { Promise } from "rsvp";
import DButton from "discourse/components/d-button";
import MenuPanel from "discourse/components/menu-panel";
import PluginOutlet from "discourse/components/plugin-outlet";
import AdvancedButton from "discourse/components/search-menu/advanced-button";
import ClearButton from "discourse/components/search-menu/clear-button";
import Results from "discourse/components/search-menu/results";
import SearchTerm from "discourse/components/search-menu/search-term";
import concatClass from "discourse/helpers/concat-class";
import lazyHash from "discourse/helpers/lazy-hash";
import loadingSpinner from "discourse/helpers/loading-spinner";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
import discourseDebounce from "discourse/lib/debounce";
import { bind } from "discourse/lib/decorators";
import getURL from "discourse/lib/get-url";
import {
  isValidSearchTerm,
  searchForTerm,
  updateRecentSearches,
} from "discourse/lib/search";
import DiscourseURL from "discourse/lib/url";
import userSearch from "discourse/lib/user-search";
import { CANCELLED_STATUS } from "discourse/modifiers/d-autocomplete";

const CATEGORY_SLUG_REGEXP = /(\#[a-zA-Z0-9\-:]*)$/gi;
const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]*)$/gi;
const SUGGESTIONS_REGEXP = /(in:|status:|order:|:)([a-zA-Z]*)$/gi;
export const MODIFIER_REGEXP = /.*(\#|\@|:).*$/gi;
export const DEFAULT_TYPE_FILTER = "exclude_topics";

export default class SearchMenu extends Component {
  @service search;
  @service currentUser;
  @service siteSettings;
  @service appEvents;

  @tracked loading = false;
  @tracked isPMInboxCleared = false;
  @tracked typeFilter = DEFAULT_TYPE_FILTER;
  @tracked suggestionKeyword = false;
  @tracked suggestionResults = [];
  @tracked invalidTerm = false;
  @tracked menuPanelOpen = false;

  searchInputId = this.args.searchInputId ?? "search-term";
  searchInputPlaceholder = this.args.searchInputPlaceholder || "search.title";

  _debouncer = null;
  _activeSearch = null;

  willDestroy() {
    if (!this.args.inlineResults) {
      document.removeEventListener("mousedown", this.onDocumentPress);
      document.removeEventListener("touchend", this.onDocumentPress);
    }
    super.willDestroy(...arguments);
  }

  @bind
  setupEventListeners() {
    // Dobbiamo registrare gli eventi di click solo quando il menu di ricerca viene renderizzato al di fuori dell'intestazione.
    // L'intestazione gestisce il click all'esterno.
    if (!this.args.inlineResults) {
      document.addEventListener("mousedown", this.onDocumentPress);
      document.addEventListener("touchend", this.onDocumentPress);
    }
  }

  @bind
  onDocumentPress(event) {
    if (!this.menuPanelOpen) {
      return;
    }

    if (!event.target.closest(".search-menu-container.menu-panel-results")) {
      this.close();
    }
  }

  get classNames() {
    const classes = ["search-menu-container"];

    if (!this.args.inlineResults) {
      classes.push("menu-panel-results");
    }

    if (this.loading) {
      classes.push("loading");
    }

    return classes.join(" ");
  }

  get includesTopics() {
    return (
      !!this.search.results?.topics?.length ||
      this.typeFilter !== DEFAULT_TYPE_FILTER
    );
  }

  get searchContext() {
    if (this.search.inTopicContext || this.inPMInboxContext) {
      return this.search.searchContext;
    }

    return false;
  }

  get inPMInboxContext() {
    return (
      !this.isPMInboxCleared &&
      this.search.searchContext?.type === "private_messages"
    );
  }

  get isPMOnly() {
    // Verifica se la ricerca è filtrata solo ai messaggi privati
    const searchTerm = this.search.activeGlobalSearchTerm || "";
    return (
      this.inPMInboxContext ||
      /\bin:(personal|messages|personal-direct|all-pms)\b/i.test(searchTerm)
    );
  }

  @action
  onKeydown(event) {
    if (event.key === "Escape") {
      this.close();
      event.preventDefault();
      event.stopPropagation();
    }
  }

  @action
  close() {
    if (this.args?.onClose) {
      return this.args.onClose();
    }

    // Vogliamo deselezionare l'input di ricerca quando siamo in modalità standalone
    // in modo che quando rimettiamo a fuoco l'input, il pannello del menu si apra
    document.getElementById(this.searchInputId)?.blur();
    this.menuPanelOpen = false;
  }

  @action
  open() {
    if (!this.menuPanelOpen) {
      this.appEvents.trigger("search-menu:search_menu_opened");
    }
    this.menuPanelOpen = true;
  }

  @bind
  fullSearchUrl(opts) {
    let url = "/search";
    let params = new URLSearchParams();

    if (this.search.activeGlobalSearchTerm) {
      let q = this.search.activeGlobalSearchTerm;

      if (this.searchContext?.type === "topic") {
        q += ` topic:${this.searchContext.id}`;
      } else if (this.searchContext?.type === "private_messages") {
        q += " in:messages";
      }
      params.set("q", q);
    }
    if (opts?.expanded) {
      params.set("expanded", "true");
    }
    if (params.toString() !== "") {
      url = `${url}?${params}`;
    }
    return getURL(url);
  }

  @action
  openAdvancedSearch() {
    this.fullSearch();
    this.close();
  }

  get displayMenuPanelResults() {
    if (this.args.inlineResults) {
      return false;
    }

    return this.menuPanelOpen;
  }

  @bind
  clearSearch(e) {
    e.stopPropagation();
    e.preventDefault();
    this.search.activeGlobalSearchTerm = "";
    this.search.focusSearchInput();
    this.triggerSearch();
  }

  @action
  searchTermChanged(term, opts = {}) {
    this.typeFilter = opts.searchTopics ? null : DEFAULT_TYPE_FILTER;
    if (opts.setTopicContext) {
      this.search.inTopicContext = true;
    }
    if (opts.setPMInboxContext) {
      this.isPMInboxCleared = false;
    }
    this.search.activeGlobalSearchTerm = term;
    this.triggerSearch();
  }

  @action
  fullSearch() {
    this.loading = false;
    const url = this.fullSearchUrl();
    if (url) {
      DiscourseURL.routeTo(url);
    }
  }

  @action
  updateTypeFilter(value) {
    this.typeFilter = value;
  }

  @action
  clearPMInboxContext() {
    this.isPMInboxCleared = true;
  }

  @action
  clearTopicContext() {
    this.search.inTopicContext = false;
  }

  // per annullare la ricerca con debounce
  cancel() {
    if (this._activeSearch) {
      this._activeSearch.abort();
      this._activeSearch = null;
    }
  }

  async perform() {
    this.cancel();

    const matchSuggestions = this.matchesSuggestions();
    if (matchSuggestions) {
      this.search.noResults = true;
      this.search.results = {};
      this.loading = false;
      this.suggestionResults = [];

      if (matchSuggestions.type === "category") {
        const categorySearchTerm = matchSuggestions.categoriesMatch[0].replace(
          "#",
          ""
        );

        const categoryTagSearch = searchCategoryTag(
          categorySearchTerm,
          this.siteSettings
        );
        Promise.resolve(categoryTagSearch).then((results) => {
          if (results !== CANCELLED_STATUS) {
            this.suggestionResults = results;
            this.suggestionKeyword = "#";
          }
        });
      } else if (matchSuggestions.type === "username") {
        const userSearchTerm = matchSuggestions.usernamesMatch[0].replace(
          "@",
          ""
        );
        const opts = { includeGroups: true, limit: 6 };
        if (userSearchTerm.length > 0) {
          opts.term = userSearchTerm;
        } else {
          opts.lastSeenUsers = true;
        }

        userSearch(opts).then((result) => {
          if (result?.users?.length > 0) {
            this.suggestionResults = result.users;
            this.suggestionKeyword = "@";
          } else {
            this.search.noResults = true;
            this.suggestionKeyword = false;
          }
        });
      } else {
        this.suggestionKeyword = matchSuggestions[0];
      }
      return;
    }

    this.suggestionKeyword = false;

    if (!this.search.activeGlobalSearchTerm) {
      this.search.noResults = false;
      this.search.results = {};
      this.loading = false;
      this.invalidTerm = false;
    } else if (
      !isValidSearchTerm(this.search.activeGlobalSearchTerm, this.siteSettings)
    ) {
      this.search.noResults = true;
      this.search.results = {};
      this.loading = false;
      this.invalidTerm = true;
    } else {
      this.loading = true;
      this.invalidTerm = false;

      this._activeSearch = searchForTerm(this.search.activeGlobalSearchTerm, {
        typeFilter: this.typeFilter,
        fullSearchUrl: this.fullSearchUrl,
        searchContext: this.searchContext,
      });

      this._activeSearch
        .then((results) => {
          // assicuriamoci che il termine di ricerca corrente sia quello utilizzato
          // all'avvio della query
          if (results) {
            this.search.noResults = results.resultTypes.length === 0;
            this.search.results = results;
          }
        })
        .catch(popupAjaxError)
        .finally(() => {
          this.loading = false;
        });
    }
  }

  matchesSuggestions() {
    if (
      this.search.activeGlobalSearchTerm === undefined ||
      this.includesTopics
    ) {
      return false;
    }

    const term = this.search.activeGlobalSearchTerm.trim();
    const categoriesMatch = term.match(CATEGORY_SLUG_REGEXP);

    if (categoriesMatch) {
      return { type: "category", categoriesMatch };
    }

    const usernamesMatch = term.match(USERNAME_REGEXP);
    if (usernamesMatch) {
      return { type: "username", usernamesMatch };
    }

    const suggestionsMatch = term.match(SUGGESTIONS_REGEXP);
    if (suggestionsMatch) {
      return suggestionsMatch;
    }

    return false;
  }

  @action
  triggerSearch() {
    this.search.noResults = false;

    if (this.includesTopics) {
      if (this.search.contextType === "topic") {
        this.search.highlightTerm = this.search.activeGlobalSearchTerm;
      }
      this.loading = true;
      cancel(this._debouncer);
      this.perform();

      if (this.currentUser) {
        updateRecentSearches(
          this.currentUser,
          this.search.activeGlobalSearchTerm
        );
      }
    } else {
      this.loading = false;
      if (!this.search.inTopicContext) {
        this._debouncer = discourseDebounce(this, this.perform, 400);
      }
    }
  }

  <template>
    <div
      class={{this.classNames}}
      {{didInsert this.setupEventListeners}}
      {{! template-lint-disable no-invalid-interactive }}
      {{on "keydown" this.onKeydown}}
    >
      <div class="search-input-wrapper">
        <div
          class={{concatClass
            "search-input"
            (concat "search-input--" @location)
          }}
        >
          {{#if this.search.inTopicContext}}
            <DButton
              @icon="xmark"
              @label="search.in_this_topic"
              @title="search.in_this_topic_tooltip"
              @action={{this.clearTopicContext}}
              class="btn-default btn-small search-context"
            />
          {{else if this.inPMInboxContext}}
            <DButton
              @icon="xmark"
              @label="search.in_messages"
              @title="search.in_messages_tooltip"
              @action={{this.clearPMInboxContext}}
              class="btn-default btn-small search-context"
            />
          {{/if}}

          <PluginOutlet
            @name="search-menu-before-term-input"
            @outletArgs={{lazyHash openSearchMenu=this.open}}
          />

          <SearchTerm
            @searchTermChanged={{this.searchTermChanged}}
            @typeFilter={{this.typeFilter}}
            @updateTypeFilter={{this.updateTypeFilter}}
            @triggerSearch={{this.triggerSearch}}
            @fullSearch={{this.fullSearch}}
            @clearPMInboxContext={{this.clearPMInboxContext}}
            @clearTopicContext={{this.clearTopicContext}}
            @closeSearchMenu={{this.close}}
            @openSearchMenu={{this.open}}
            @autofocus={{@autofocusInput}}
            @inputId={{this.searchInputId}}
            @inputPlaceholder={{this.searchInputPlaceholder}}
          />

          {{#if this.loading}}
            <div class="searching">
              {{loadingSpinner}}
            </div>
          {{else}}
            <div class="searching">
              <PluginOutlet @name="search-menu-before-advanced-search" />
              {{#if this.search.activeGlobalSearchTerm}}
                <ClearButton @clearSearch={{this.clearSearch}} />
              {{/if}}
              <AdvancedButton @openAdvancedSearch={{this.openAdvancedSearch}} />
            </div>
          {{/if}}
        </div>
      </div>

      {{#if @inlineResults}}
        <Results
          @searchInputId={{this.searchInputId}}
          @loading={{this.loading}}
          @invalidTerm={{this.invalidTerm}}
          @suggestionKeyword={{this.suggestionKeyword}}
          @suggestionResults={{this.suggestionResults}}
          @searchTopics={{this.includesTopics}}
          @inPMInboxContext={{this.inPMInboxContext}}
          @isPMOnly={{this.isPMOnly}}
          @triggerSearch={{this.triggerSearch}}
          @updateTypeFilter={{this.updateTypeFilter}}
          @closeSearchMenu={{this.close}}
          @searchTermChanged={{this.searchTermChanged}}
          @clearSearch={{this.clearSearch}}
        />
      {{else if this.displayMenuPanelResults}}
        <MenuPanel class="search-menu-panel">
          <Results
            @searchInputId={{this.searchInputId}}
            @loading={{this.loading}}
            @invalidTerm={{this.invalidTerm}}
            @suggestionKeyword={{this.suggestionKeyword}}
            @suggestionResults={{this.suggestionResults}}
            @searchTopics={{this.includesTopics}}
            @inPMInboxContext={{this.inPMInboxContext}}
            @isPMOnly={{this.isPMOnly}}
            @triggerSearch={{this.triggerSearch}}
            @updateTypeFilter={{this.updateTypeFilter}}
            @closeSearchMenu={{this.close}}
            @searchTermChanged={{this.searchTermChanged}}
            @clearSearch={{this.clearSearch}}
          />
        </MenuPanel>
      {{/if}}
    </div>
  </template>
}

Come riscrivere il template

O per dirla diversamente:

<PluginOutlet @name="search-menu-before-advanced-search" /> come interagisce con il componente search-menu nel sistema dei plugin? Ad esempio, se volessi accedere al valore dell’istanza this.search, ho provato a usare appEvents ma non ha funzionato.

L’outlet non ha parametri, quindi non interagisce affatto con il componente.

Quindi al momento non c’è alcun modo per implementare l’interattività? Dovrebbe essere possibile con appEvents.

Suppongo che, se ne hai davvero bisogno, potresti creare una PR per aggiungere argomenti all’outlet.

Esiste anche il search service, che potrebbe esserti utile.